256 Commits
Author SHA1 Message Date
Fr0zkaandClaude Opus 5.5 b4ac9969f5 fix(passages): skip proofs cover the new descent geometry -- soundness PASS; passage tiles +28% cost
- Passage proof bounds now enclose everything the passage code can write: separated switchback
  legs, outward door approaches, native floor slabs with a derived relief supremum, and the final
  body-envelope carve (segment XY extent + half-width, floor range + player height + step).
- ClassifyTile: the cave-only AllSolid shortcut could restore a solid verdict after a passage
  carve candidate was found; it now keeps the tile mixed.
- Descent corridor test asserts no corridor stencil tile gets a uniform verdict.

Evidence: SealedSolidProofSoundness and ClassifyTileSoundness pass; 64 owner seeds, 0 body
blockers, 0 uniform stencil tiles, CRCs match. Negative control (passage reach shrunk to 4 voxels):
7 sealed-solid proofs among 620 corridor tiles, i.e. the check bites.

KNOWN COST, not fixed: tile profile, owner world, seed 0, 15 s window: passage-candidate tiles
83.6 ms (de05e29) -> 107.2 ms (+28%), all tiles +5.4%. Two segment-rejection filters did not help
and were removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-28 10:03:34 +02:00
Fr0zkaandClaude Opus 5.5 fb052716c6 wip(passages): A->B descent corridor clear -- 60/64 seeds blocked -> 0/64; soundness + cost not yet checked
What the pillars were: at switchbacks the native-floor D-section clip in
UVoxelStrateManager::EvaluateModifierSDF used ONE floor branch (the nearest by projection) for
neighbouring capsule segments, so one leg's floor clipped the other leg's carve and left rock
standing in the corridor (seed 0 witness: density -29.45, carve SDF +3.28; with
voxel.TunnelAblateNativeFloor=1 the same point is air). Separately, the lower ramp was routed
through the lower landing room (sign error in the lower "outward" score), where room ownership
correctly erased the tunnel floor: up to 12.5-voxel drops.

Fix: each segment clips with its own floor branch before the union; switchback legs separated
and door approaches moved outward so ramps leave their landing rooms; a final carve clears the
player body envelope (1.36 + 2x1 voxel wide, 1.8..8.8 voxels above the route floor) after all
writers, with a room-SDF guard. No floor slab, no fill, no blockade on the through edge.

Owner world, 64-seed sweep, stations every 0.5 voxel x 5 lateral x 14 heights:
before 89,603 body blockers in 60/64 seeds; after 0 in 64/64, 0 floor/width gaps, worst step
0.958 voxels (limit 1.8). Seed 0 built twice: identical density CRC.
Crossing at default speed still stops on capsule_overlaps_pending_floor (also before); 500 cm/s
reaches B.

NOT YET CHECKED: sealed-solid / classifier soundness against the moved passage geometry, and the
generation cost of the per-segment projection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-25 13:42:58 +02:00
Fr0zkaandClaude Opus 5.5 de05e293aa docs(codemap): aligned clipmap rings, indexed reconcile, immediate gate support, two new tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-25 06:11:41 +02:00
Fr0zkaandClaude Opus 5.5 6ee433efa5 test(gate): stress repro judges terrain penetration -- gate on 10/10 clear, gate off 3/3 falls through
The old criteria (travel distance) could not tell a working gate from a broken one: every run,
gate on or off, ended at (455.7, 0, -58.1). That spot is measured air ~1.2 m above a real floor
(density zero-crossing at world Z ~ -275.7 cm), so it is a terrain trap, not a fall-through.

The stress test now drops the pawn fast onto an unready support tile (0,0,-1) and samples the
density at the capsule feet every frame, sweeping at <= half a voxel between positions. PASS =
no measured clearance below -5 cm.

Owner config (radius 3, max level 6, distance 768, sheets off):
- gate on: 10/10 PASS, min clearance +120.5..+120.9 cm, one hold of 0.43-0.47 s each,
  support submitted in ~0.7 ms, collision ready in 0.43-0.47 s
- gate off: 3/3 FAIL terrain_penetration, clearance -5.7..-8.1 cm
Edit-ghost, clipmap no-overlap sweep: pass. Test-harness code only; no production path changed.
This validates a49725d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-25 06:11:08 +02:00
Fr0zkaandClaude Opus 5.5 a49725d365 wip(gate): request the support tile at once; no gate timeout -- stress criteria NOT trusted yet
- RequestCollisionGateSupportTile submits the level-0 support tile immediately, even before the
  first desired set; the desired rebuild adopts the same pending key (1 early request, 1 adoption,
  1 ready callback in all 10 runs). First check -> submit went from ~2.2 s to ~1 us.
- The 2 s gate release timeout is removed: at the owner's startup the game thread applies nothing
  for ~2.2 s, so the timeout always fired and dropped the pawn. The gate holds downward Z only;
  horizontal movement stays free.
- Controls (previous round): legacy selector+reconcile failed the stress repro 0/5, aligned 1/5,
  so 85a803b did not regress the gate.

NOT VALIDATED: the stress repro's pass criteria were rewritten (support submitted/ready, a hold,
progress during a hold). 10/10 "pass" under them, but every one of those runs, and the gate-
disabled run, ends stuck at (455.7, 0, -58.1), ~1.55 m below the Z~97-100 at which the old
passing runs travelled 113 m. Whether that spot is a real pit or the pawn below the surface is
unresolved; until it is, a pass under these criteria proves nothing about falling through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-25 04:54:00 +02:00
Fr0zkaandClaude Opus 5.5 85a803b873 wip(streaming): LOD rings tile exactly once, cache race, reconcile 104x -- gate stress FAILS 3/4
- Clipmap: finer rings snap to child-pair boundaries, so no two desired tiles of different
  levels overlap. Owner config 635 overlapping pairs -> 0; LOD0 ring 343 -> 512 tiles.
  New test sweeps 6,174 cases; fails on 7486c29 as intended, passes here.
- Shared room-graph cache: Entry->Cache read/written only under the mutex (race fix).
- ReconcileReadyTransitionVisibility indexed: moving p95 32.0 ms -> 0.31 ms.
- Block-vs-scalar density equivalence test: 5.1M samples bit-identical; catches an injected
  optimistic EffectOverBox (5,612 mismatches).

UNVALIDATED for merge: collision-gate stress passed 1 of 4 runs on this code (fails at 4.56 m,
support collision unready at start); it passed on 7486c29. No control run yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-25 02:47:54 +02:00
Fr0zkaandClaude Opus 5 7486c299d4 fix(streaming): no stale geometry over its replacement -- edit ghost 40s -> 0.17s
The owner reported a wall staying visible after he carved through it, and overlapping LODs when crossing slopes at speed. Two causes: an edit only re-meshed the level-0 tiles of the touched chunks, so coarser tiles kept pre-edit geometry until something else re-queued them; and superseded tiles stayed drawn until the budgeted teardown reached them.

Now: edits fence late pre-edit async results and re-mesh every affected loaded/pending level; a replaced tile is hidden as soon as its replacement is fully covered, while destruction and collision removal stay budgeted (the anti-spike rule that made teardown budgeted in the first place). Load-before-unload is unchanged, so no holes.

Measured: edit ghost 40.095 s (unresolved at test exit) -> 0.172 s; 6000 cm/s slope overlap 19.952 s with 153 overlapping pairs -> 0.000 s with zero events. Gate stress PASS (113.68 m, 2 holds, 0 full stops). Apply-frame cost peaked at 0.979 ms against a 2 ms budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-23 21:10:20 +02:00
Fr0zkaandClaude Opus 5 f36bc1f9b2 wip(streaming): priority by class and level -- near ring 53.6s -> 1.2s, but crossing test fails
Scheduling rule: CollisionCritical -> Edited -> LOD0 -> Horizon, with coarse work capped so 4 task slots stay reserved for near work; result queues follow the same order; epoch and cancellation handling unchanged.

Measured at MaxClipLevel=5 / RenderDistanceChunks=768: spawn ring request-to-ready 53.632 s -> 1.171 s; spawn ring from trace start 68.252 -> 62.549 s; moving first complete ring 66.808 -> 63.917 s, warm ring p50 0.267 s, per-tile moving LOD0 p50 69.4 -> 62.0 ms; full settle of 10,841 tiles 110.7 -> 106.9 s. Gate stress PASS (139.99 m, 0 full stops); static perf PASS.

⚠️ NOT SAFE TO MERGE YET: the strate-crossing test fails in both patched runs with capsule_overlaps_pending_floor -- the exact guarantee the gate exists for. Codex suspects the owner's current DA_Settings (seal 50, clip level 5) rather than this change, since older passing captures used different settings. Control needed: same test, same assets, on 370c39e.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-23 19:04:13 +02:00
Fr0zkaandClaude Opus 5 370c39e473 feat(streaming): horizon distance with real tiles only -- sheets off by default
The sheet system models terrain as one ground and one cap height per column, which cannot express this world's overhangs, floating islands and open sky; three attempts to fix the MC/sheet seam produced holes, then caps over the void, then both. Measured alternative: cover the render distance with real marching-cubes tiles and switch the far-sheet ring off.

Benchmarks at RenderDistanceChunks=768, all tiles loaded at steady state: L4 57,473 tiles / 30.1M tris / 837 MiB / 2,514 s worker / 296 s to settle; L5 10,841 / 8.1M / 226 MiB / 375 s / 108 s; L6 3,281 / 2.9M / 82 MiB / 127 s / 77 s. Defaults now MaxClipLevel=5, bFarSheetRing=false. Invariants: strate crossing PASS, gate stress PASS (140.06 m, 0 full stops), determinism PASS with 10,841 tile hashes identical vs -voxel.CrtFma3=0. Peak memory ~4.2 GiB in all three.

Owner action required: his DA_Settings still serializes MaxClipLevel=3 (asset edits are out of scope for Codex), so his world needs that value raised to 5 to use the new far field. At twice the distance the estimate is ~39.5k tiles / 29-30M tris / ~0.8 GiB / ~1,500 worker-seconds, which would want L6 rather than L5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-23 17:39:49 +02:00
Fr0zkaandClaude Opus 5 ea3f0d9a52 fix(classifier): exact lattice check before a uniform verdict; ceiling cause is NOT false certificates
ParameterRoll's 5 false box verdicts are fixed: fine room-graph intervals are now checked against exact lattice samples before a uniform verdict is accepted, while the SDF bound keeps its conservative lower bound. Lattice sweep 600 tiles / 0 violations; ParameterRoll passes with 9,577 exact lattice leaves and 774 interval disagreements caught.

The owner's ceiling holes are NOT explained by this: in his recorded session outer_classifier_mode=0, solid-proof skips were 0, and air-proof skips were outside the strate. The trace points at the far-field sheet system instead: sheet tile L5 (0,1,0) is deliberately empty inside the sheet hole while its bridge tile L4 (0,1,-1) meshed 662 ceiling triangles. Continuous ceiling is NOT verified: every render launch failed (D3D12 driver error, stalls before map load, the 6 GB cap), so the round produced no matched after-render.

Cost note: this round used 3.46M tokens on Luna 6 at max, most of it burned on failed render launches. Verification by render is unreliable in this harness; verify ceiling continuity from tile/geometry data instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-23 06:30:05 +02:00
Fr0zkaandClaude Opus 5 30253f0e89 fix(streaming): vertical reach follows the strate, and the level below the sheet ring is filled
The owner marked missing tiles around him in a 64-chunk surface strate with RenderDistanceChunks=768. Cause: the desired-set vertical clamp used the player's small window (bClampViewToStrate on, ViewDistanceUp/Down=5, margin 3), so at chunk Z=0 only [-5..3] was ever requested; the rest of his strate was never asked for. Second cause: with MaxClipLevel=3 and a two-level sheet span the outer ring is level 5, while the marching-cubes loop stopped at 3, leaving level 4 empty.

The band now follows the player's whole strate plus margin ([-66..3] in the pinned case) and keeps the small window only when there are no strate bounds (inter-strate gap), so the hidden-rock column the clamp exists to avoid does not return. Bridge shells are emitted up to the level below the sheet ring, filling level 4.

Pinned case desired tiles per level, before -> after: l0 343->343, l1 218->218, l2 129->178, l3 80->178, l4 0->178, l5 4801->9586; total 5,571 -> 10,681 (+91.7%). New focused fixture VoxelForgeVerticalStrateReachTest fails before and passes after. Stress repro passes (140.089 m, 4 holds, 0 full stops); strate crossing passes (0 holds, 0 floor overlap).

⚠️ Cost not established: the post-fix perf sample was incomplete and showed a 55.7 s p50 queue wait, which may be the fixture's extreme settings (768 render distance -> 9,586 sheet tiles) or a real backlog. Measure in the owner's world before merging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-23 01:49:57 +02:00
Fr0zkaandClaude Opus 5 d7af2cab97 fix(collision gate): hold the fall, not the player; add voxel.CollisionGate switch
The owner could not do speed runs: the gate stopped the whole pawn whenever streaming fell behind. Measured at 6000 cm/s on 0dd45ca: one 18.9 ms hold followed by terminal non-resume, 4.522 m of a 120 m route. The gate now cancels only downward motion into unresolved ground, keeps horizontal velocity and player control, ignores air and out-of-layout tiles, and requests a blocking support tile at high priority. voxel.CollisionGate (default 1) disables the safeguard for stress runs; it is snapshotted at start-up.

After: stress run PASS -- 4 holds, 1.881924 s total, full_stops=0, 139.947 m of 120 m. Strate crossing PASS with pending_floor_overlap=0 and the lower strate reached. Static perf sanity PASS. Verification budget kept small on purpose (no suite, no exports): the owner's complaint was that a one-line request triggered a thousand tests.

Known, NOT fixed here: the surface-fall repro fails as surface_ground_timeout -- the pawn now lands, but on top of the authored (0,0) entry at Z=799.5 cm instead of dropping to its floor at -150 cm. The gate has released by then; this looks like the entry opening being capped, i.e. the owner's 'surface generation is bugged'. Next round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-22 16:23:20 +02:00
Fr0zkaandClaude Opus 5 88f3e44402 wip(collision gate): stop the fall instead of the player -- UNVALIDATED, after-change run never happened
The round was stopped when Windows began refusing to start processes for it (0xC0000142) under memory pressure, before any after-change test run. Treat as a draft: the baseline run happened, the verification did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-22 15:19:12 +02:00
Fr0zkaandClaude Opus 5 0dd45ca410 fix(collision gate): stop freezing the player on a stale prediction
The owner fell through a surface-only world and hovered in mid-air: log showed 'ready=1 predicted_wait=1' for 50 s. Two causes, both fixed: (1) engaging the gate zeroes velocity, so the predicted-support probe never re-ran and the gate kept waiting on the tile remembered from when the pawn was still moving -- the prediction is now re-derived every tick from the pawn's current pose; (2) a tile the streamer never requests (out of layout, or outside the desired set) could never become ready, so the wait was unbounded -- such tiles are now logged and treated as nonblocking, and any wait is bounded to 2 s with a warning.

Repro added to the harness (surface-fall scenario): before, the pawn stops at Z=1315.9 with collision_gate_prediction_deadlock; after, it reaches the ground in 2.25 s at Z=-45.6. Strate crossing still protected: 145.49 s route, max gate wait 0.109 s (limit 10 s). Perf S/M/S/M 24.265/54.262 and 23.947/53.057 s, within repeat noise. Owner and canonical geometry unchanged (44E7C813 / 94B47A1C).

Known red, pre-existing and unrelated: VoxelForge.Composer.ParameterRoll still reports false box verdicts in the owner's current single-strate surface layout (5 violations, identical in the baseline). Next round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-22 11:17:01 +02:00
Fr0zkaandClaude Opus 5 7af8d0e870 fix(correctness): false AllAir certificates and a stale shaft cache key; 44/44 tests green
The harness's first full suite run showed 16 of 44 tests failing. Two were real defects in the owner's world:

1. ParameterRoll: the tile classifier could certify a box AllAir while the final field is solid there (3 false proofs found, e.g. density -2.092319 at (-192,16,-224)). Cause: the box diagnostic narrowed 'tail reach' to 'air reach', so the swept tunnel FLOOR -- which writes solid -- did not count as reaching the box. A false AllAir skips meshing, i.e. missing floor in game. The classifier now uses the same reach predicate as the final writer (swept chain, floor band, relief, room-mouth envelope) and propagates it through Mixed refinement nodes. Re-run: 1,288,143 lattice samples, 0 violations.

2. VerticalShaftEquivalence: LedgeSpacing and LedgeDepth were missing from the production TLS cache key, so a shaft could reuse stale ledge parameters. Both are now in the key.

The other 14: broken fixtures (missing staged corpus assets, a sampler leaking the shaft TLS marker between queries, stale candidate IDs, a window entirely inside the common tail) or stale expectations after this week's owner decisions (no authored tunnel floor, slope in degrees, rooms owning their interior). No test was deleted or weakened into vacuity; retired legacy comparisons stay as telemetry and the current stack is asserted instead. PassageLandsInOpenSpace now requires a real player-capsule fit in the final density at each authored landing (14/14, worst gradient 0.115723).

Suite: 42 tests green in one run, the two long op-stack sweeps green in serialized filtered runs (the 1800 s launch guard); a harness timeout-recording bug was fixed. Bounded probe exports unchanged (canonical 94B47A1C, owner 44E7C813). ⚠️ Harness perf scenario moved 50.74 s -> 53.58 s worker (+5.6%): the wider reach predicate skips fewer boxes. Measure on the game path and decide before merging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-18 13:13:21 +02:00
Fr0zkaandClaude Opus 5 cc04c0bd4e chore(tools): untrack a stray __pycache__ artifact
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-18 05:13:24 +02:00
Fr0zkaandClaude Opus 5 a7b8d10de6 tooling(test): one command to test VoxelForge, with a commandlet-vs-game parity check
Tools/VoxelForgeTest.ps1 runs scenarios (canonical, owner, probe, perf, tests, parity) with the known-good flag sets, isolated user/config dirs, serialized launches, an owner-process guard, and a compact result.json + summary.txt instead of 300 MB logs that Codex has to stream. Answers the owner's question 'can the commandlet reflect in-game behaviour': -gameconfig=1 makes the commandlet use the authored settings, and the parity scenario compares per-tile geometry hashes between the commandlet export and the game's streaming path. Parity PASS: 64 commandlet tiles vs 810 game records, no differing tile.

First run of the tests scenario: 44 tests, 28 pass, 16 fail (8 composer, 6 op-stack equivalence, PassageLandsInOpenSpace, StrateParamBlendCoverage). Triage is the next round: stale expectations from the floor removal and the degrees change, versus real defects, especially the legacy-vs-op-stack equivalence ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-18 05:13:16 +02:00
Fr0zkaandClaude Opus 5 1c642fd182 perf(tunnels): memoise the room mouth -- room ownership now cheaper than before the change
The room-wins rule cost +55% static / +36% moving worker CPU because every sample re-answered the room-mouth question. A bounded exact memo around BuildRoomMouth (4096 sets x 4 ways, 7.49 MiB, keyed on every geometry-affecting room/tunnel input) removes that: hit rate 99.91% static / 99.89% moving, and VF_FindRoomFloorCrossingWorld (19.5%/17.1% inclusive), VF_FindRoomMouthBoundary (16.0%/14.0%) and VF_EvaluateRoomLandingDensity leave the sampler's top 30 entirely.

vs the tip DLL (bf9384a runtime), same staged owner map: worker sum 29.003 s vs 30.749 s static (-5.7%), 57.232 s vs 60.923 s moving (-6.1%), request p95 -3.3% / -8.4%. Geometry unchanged by construction: canonical hash 86A99075, 64/64 tile hashes, OBJ SHA 7E0D8109..C543, identical across a repeat run and with -voxel.CrtFma3=0. Walk Connected 42,870 / 17,413; tunnel-attributed solid inside rooms 0; mouth amplitude 1.4903 cm; TilePostReachProof and DetSinCos pass. The crossing test was not rerun (the memo returns the same values; its PASS stands at 248.43 m / 26.584 s).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-18 03:41:38 +02:00
Fr0zkaandClaude Opus 5 76bc8bce97 docs(work-next): room-wins result, checks passed, and the perf regression it introduced
Crossing PASS (248 m, 26.6 s), TilePostReachProof PASS, DetSinCos PASS, canonical export identical with -voxel.CrtFma3=0 (86A99075), walk Connected 42,870 / 17,413. FAILED: one S/M/S/M pair on the owner's staged map shows worker generation 47.7 s vs 30.7 s static (+55%) and 83.1 s vs 60.9 s moving (+36%), request p95 +19-28%. The room-ownership containment test now runs per sample in the generic tunnel SDF, the core evaluator and both native fallbacks. Next round: same geometry (export hash must stay 86A99075), cost back to the tip's level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-18 01:04:02 +02:00
Fr0zkaandClaude Opus 5 d01af4606d wip(tunnels): room wins over tunnels -- no shelf, smooth mouths; stopped before final checks
Stopped by the owner (he wanted his PC back to play) after most acceptance was in. Measured with the owner's assets: the shelf across rooms came from the tunnel core reasserting solid below its floor inside room volumes, plus the landing-floor post. Room ownership now suppresses the tunnel bottom in the generic tunnel SDF, the core evaluator and both native fallbacks: tunnel-attributed solid inside rooms 469 -> 0, and a matched-camera re-render shows the slab gone. Trimmed mouth endpoints now take their height from the room's own vertical zero crossing (join offsets -2.08/+0.40 -> -1.13/-0.71 voxels). Mouth surface periodic amplitude 1.49 cm floor / 0 cm wall (degree-six trend removal; raw local-median audit 6.19 cm kept). Canonical walk Connected, 42,870 fit / 17,413 walk-reachable (was 41,184 / 13,879); export 141,054 triangles, geometry hash 86A99075. Launches use an isolated engine saved/config directory so the owner's editor settings are not rewritten.

NOT DONE: the -voxel.CrtFma3=0 export comparison, TilePostReachProof / DetSinCos / strate crossing, the perf pair vs the experimental tip, the EditorSettings.ini diff. Unvalidated in game.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-17 14:56:06 +02:00
Fr0zkaandClaude Opus 5 01cb9d47f1 docs(work-next): tunnel playtest night -- rib cause, removed floor, what's queued
Records the owner-playtest findings (stale editor build, owner assets vs harness defaults, the 0.1 slope freeze, ribs with every terrace setting at 0), the proven rib cause and its fix on fix/tunnel-shape, the reverted Catmull-Rom attempt and why its evidence was wrong, the in-editor ablation procedure (cvar read once at first Play), current canonical numbers, and the queue awaiting the owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-17 02:49:18 +02:00
Fr0zkaandClaude Opus 5 4fa3931176 fix(tunnels): ribs removed at the cause; authored tunnel floor removed; slope acts on the route
The owner proved in game that the tunnel core caused the ribs (voxel.TunnelAblateTunnelCore=1 removes them). Root cause: the core's final reassertion replaced any sufficiently interior sample with a fixed +16 air density, destroying the distance gradient, so marching cubes snapped the surface to the voxel grid (all 385 centre samples saturated). The core now reasserts the continuous -Core.SDF, with bottom polarity from the swept shape. The Catmull-Rom sweep from 0231072 is reverted (it cost 18-30% and did not help); the linear tapered-capsule sweep is restored.

Owner decisions implemented: bTunnelFloorEnabled has no runtime effect (reflected only for asset compatibility, ignored on PostLoad), and the authored slab / graph support-floor paths are detached from generation, so the tunnel's own carved shape is the floor. Winding and dramatic ledges no longer depend on the floor switch. Degrees authoring, its migration, and the 3840-voxel route cap stay. Mouth trim stays behind voxel.TunnelMouthTrim (default off).

Evidence: along owner tunnel 15, 385 samples at 0.25 voxel, periodic surface amplitude floor 3.55 -> 0.50 cm, wall 16.9 -> 1.10 cm, ceiling 8.80 -> 0.69 cm (acceptance < 2 cm); 0/385 samples saturated. Matched-camera render: ribs absent. Canonical walk: Connected, player-fit 41,184 (was 20,830), walk-only 13,879 (was 10,909), mouth gap 0; mesh identical with -voxel.CrtFma3=0. TilePostReachProof, DetSinCos, strate connectivity 16/16, strate crossing: pass. Perf, one S/M/S/M pair vs a rebuilt bf9384a DLL: static 26.10 vs 25.41 s (+2.7%), moving 55.34 vs 53.08 s (+4.2%), request p95 about equal. Treat as a possible small cost to re-measure, not as noise.

Not merged into experimental: waiting for the owner's in-game look.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-17 02:48:20 +02:00
Fr0zkaandClaude Opus 5 0231072cc4 wip(tunnels): continuous world-core sweep, slope in degrees, bounded winding -- NOT MERGED: +18-30% worker CPU, visuals unconfirmed
Tunnel round from the owner's playtest. Luna's ablations traced the ribs to the world-space tunnel-core stage: disabling it removes them, while terracing, detail ops, roughness, the support floor, lateral warp, radius jitter, winding and LOD do not. The per-span capsule union is replaced by a deterministic Catmull-Rom sweep. Winding and graph expansion are now gated on bTunnelFloorEnabled (they ran even with the floor off). TunnelFloorGentleSlopeThreshold is authored in degrees (0-90, min 1 deg, DetSinCos conversion, legacy tangent values migrated: 0.3 -> 16.7 deg). The route length is capped at 6 waves x 640 = 3840 voxels, so 0.1 deg loads instead of freezing (2048 collapsed canonical reach; 24576 used ~2 GB). Room-wall trimming is behind voxel.TunnelMouthTrim (default OFF): with it on, a 36 m gap disconnects the walk.

Canonical walk: 20,907 / 10,981, connected (was 20,830 / 10,909); exports identical with -voxel.CrtFma3=0; TilePostReachProof, DetSinCos and strate crossing pass. NOT merged because: one S/M pair vs the tip DLL shows worker CPU +17.8% static / +30.3% moving and worse p95; and the orchestrator's look at the before/after renders is inconclusive (camera not matched; regular grooves still visible in the after interior). Next: owner checks the look in game; if smooth, make the sweep cheap before merging; if not, drop the sweep and keep the degrees/bound/wind-gate parts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-16 18:29:27 +02:00
Fr0zkaandClaude Opus 5 bf9384a1d5 docs(work-next): bet A killed, bet B cleared rung 1, rung 2 interrupted by the usage limit
Bet A: the per-sample lookup it would remove is 5.1-6.1% exclusive, and feature-major visiting would touch 5.2-6.6x more lattice points than the current code evaluates; the swept kernel is branch-divergent. Killed on its own numbers. Bet B: 20.04% of static LOD0 density calls are exact duplicates with 80.6% donors already finished (~2.96 s of 18.3 s), moving 20.70% / 63.3%; re-derived independently from the raw trace. Its rung-2 prototype is committed unvalidated on sol/tile-sample-reuse after the Codex usage limit ended the round mid-rebuild; resume brief in the scratchpad.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-16 04:09:40 +02:00
Fr0zkaandClaude Opus 5 8722293f6b docs: deterministic maths recorded (new canonical hashes); stale landing-connector paragraph corrected
- WORK-NEXT: state after 2026-09-16 -- Sol's review, the sol/deterministic-math result, new
  canonical export/worms/field hashes, and the next branches in order.
- CODEMAP 3.2: VoxelMath::DetSinCos / IsFiniteFast rows.
- ARCHITECTURE: passage landings have had no connector to the (0,0) room since the owner's
  d97373c playtest correction; the doc still claimed one. Records the open gap Sol found (the
  random-reach fallback when the footing query declines is unproven) and the proposed fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-16 00:56:04 +02:00
Fr0zkaandClaude Opus 5 10fd0b6e77 fix(determinism): own sin/cos on the world path -- same world on every CPU, 3.5% faster
On Win64, FMath::Sin/Cos resolve to CRT sinf/cosf (GenericPlatformMath.h:521/530; no platform
override), and the UCRT picks FMA3 or non-FMA3 implementations at startup by CPU. /fp:precise
closed only the compiler half, so the same build and seed could build a slightly different world
on two Windows PCs, and on Linux.

- VoxelMath::DetSin/DetCos/DetSinCos (VoxelTypes.h): fixed double-precision period reduction to
  a quarter period, then fixed float minimax polynomials; no CRT, FMath or CPU-dependent
  intrinsic. Measured max error vs sinf 5.96e-8 over the used range (|arg| <= 6.19).
- Every world-deciding FMath::Sin/Cos replaced (generator, op stacks, cave morphology, strate
  manager, content placement, strate measure); none remain in runtime code.
- Pinned test VoxelForge.Determinism.DetSinCos (golden bit patterns + error bound).
- Diagnostic start-up switch -voxel.CrtFma3=0 (Windows) forces the CRT's non-FMA3 path.
- Parse-once console switches now initialise in StartupModule on the game thread; workers only
  read them (was an unsynchronised first parse inside GetDensityAt).
- Corrected the CRC comment that claimed a CRC32 cannot give a false positive.

Proof (Codex round; orchestrator re-checked):
- Before the fix, FMA3 on vs off was identical on this Ryzen 7 5800X3D: the hole exists but was
  not observed in this world. After the fix, the canonical exports are byte-identical with
  FMA3 on and off.
- The world changed once, as expected. New canonical export SHA-256 CBB8AE18...906F (geometry
  B9EF84AC; was B3E5F4C3...377); worms region 9242C093 (was 311CA42C), walk OBJ 20CCF7BD....
  Triangle counts unchanged in every tile. Order-independent nearest-vertex distance, worms
  region: max 8.7 micrometres (the reported "11.4 m" was a line-order artefact of a line-by-line
  OBJ diff); export region max 3e-5 m.
- Capability exact: player-fit 20,830, walk-reachable 10,909, connected. TilePostReachProof,
  sealed-proof, cross-platform and strate-crossing tests pass.
- Performance, headless game path, S/M/S/M vs the committed-tip DLL: static worker 27.41 s vs
  28.44 s (-3.6%), moving 59.59 s vs 61.74 s (-3.5%); LOD0 p50/p95 not worse.

From Sol's code review (DESIGN-SOL-2026-09-15-CODE.md, section 4.1), branch sol/deterministic-math.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-16 00:55:04 +02:00
Fr0zkaandClaude Opus 5 cda082fa10 docs(design): Sol reads the code -- three changes worth testing, one determinism hole
Round two of the owner's "build it every better way" question: Sol's round-one session was forked
read-only and given the repository, to say what could reasonably change and how each change would
prove its worth. Advice only; nothing was changed, built or run.

Sol's verdict: don't rebuild. The representation, mesher, clipmap and operator stack are already
close to its clean-slate design. Three things are worth testing, each with a cheap
instrumentation-only first step and explicit kill criteria:
- tile-wide, feature-major tunnel evaluation (inferred 8-18% of worker CPU);
- exact density reuse across adjacent gameplay tiles (theoretical ceiling ~23%);
- a canonical numeric contract for world-deciding maths (correctness, ~neutral on speed).

The orchestrator verified against the code and the UE 5.7 engine source:
- FMath::Sin/Cos resolve to the CRT sinf/cosf on Win64 (FMA3 variant chosen at runtime);
- per-sample lookup is ~5% exclusive, so round one's "the loop direction is the largest gap"
  overstated it;
- worker-thread parse-once flags, raw-memory CRC cache keys and a docs-vs-code contradiction on
  landing connectors are real.
Round one's note gets a pointer to the corrections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-15 21:37:24 +02:00
Fr0zkaandClaude Opus 5 cf9904e8b1 docs(design): Sol's clean-slate architecture answer, with a determinism risk it surfaced
The owner's reframing idea: after local "find and fix the hot spot" rounds hit diminishing
returns, we asked Sol High, with no code and no file access, how it would build this project in
every better way. It recommends feature-rasterised sparse bricks (each feature stamps into the
bricks it touches instead of every sample searching for features), noise only in surface bands,
coarse LOD from finer data, a fixed-point determinism contract and deadline-driven collision-first
streaming.

The orchestrator's check against our code: 34 FMath::Sin/Cos calls sit on the generation path
(per-sample strata line/rib detail and passage/tunnel shape building). The MSVC C runtime switches
those to FMA3 implementations at runtime, so cross-machine bit-identity is not guaranteed. Not
changed yet; recorded for a deterministic replacement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-15 19:45:37 +02:00
Fr0zkaandClaude Opus 5 410788f7dd perf(generation): per-chunk routing cache -- ~4% less worker CPU, exact
GetDensityAt asked the strate manager on every sample whether the chunk is a gap chunk, which
generator type it uses, and whether it uses the operator stack. All three are constant per chunk.
voxel.ChunkRoutingCache (default 1) memoises them per worker thread (Codex; reviewed by me):
- thread_local storage only, so no cross-thread sharing;
- key = generator owner id + manager pointer + manager lifetime id + layout version + full chunk
  coord, the same identity pattern as the existing CP_* refetch;
- the editor composer override is still queried in the CP_* refetch, not cached here.

Attribution for the re-rank's ~28% shared cost (offline-symbolised sampler, exclusive): GetDensityWithParams
16.4%, GetDensityAt 6.2%, unknown 5.8%, FChunkSDFSpatialIndex::GetRange 3.8%, FScopedTimer::End 1.3%,
VF_ForEachSpatialCandidate 1.2%, UsesOperatorStackForChunk 1.0%. The tunnel-cache lookup was already
near-perfect (48 misses in 2,248,091 samples). A thread-local timer cache was tried and rejected as
slower (+0.4% static, +1.2% moving). Spatial-iteration and mesher-loop changes found no isolated
exact win.

Routing A/B: static worker 28.50 -> 27.78 s (-2.5%), moving 63.01 -> 59.81 s (-5.1%).
Final DEFAULT build vs the committed-tip DLL (two interleaved runs each, same command line):
  static  worker 28.79 -> 27.58 s (-4.2%)   LOD0 p50/p95 52.3/94.8 -> 50.1/92.6 ms
  moving  worker 62.25 -> 59.97 s (-3.7%)   LOD0 p50/p95 51.4/95.8 -> 48.8/92.3 ms
Field exact: OBJ SHA-256 b3e5f4c3..9b377 on both builds, 0 per-tile differences, capability
20,830 / 10,909; TilePostReachProof, sealed-proof and strate-crossing tests pass.
Ledger: every launch was serialized with 0 editors before it. Two attribution attempts were
rejected on harness bookkeeping/output-path bugs before a clean run; one crossing run was invalid
(profiling left on, exit -1) and its retry passed. No crash folders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-15 19:33:48 +02:00
Fr0zkaandClaude Opus 5 c7b832db4c docs(work-next): the tunnel SDF/core merge is closed -- the world core is load-bearing
Records why the merge must not be re-proposed without a new idea. WorldControlPoints keep the
authored room-floor anchors and walkable routes before cave warp; replacing them lost 1,146 fit /
975 walk-reachable cells, and the exact shared-segment variant was slower. The remaining large
costs are the geometry itself, so the exact-skip perf push has reached diminishing returns; the
reach proofs are now guarded by an automation test (8ef1a3b).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-15 17:42:54 +02:00
Fr0zkaandClaude Opus 5 8ef1a3b579 test(reach): automation test guarding the per-tile reach proofs; merge closed as load-bearing
VoxelForge.Correctness.TilePostReachProof (VoxelForgeTilePostReachTest.cpp) runs the real mesher
twice in one process: at full reach it compares the skipped posts' FINAL field (356,946
comparisons, 0 differences), and at a deliberately shrunk reach it must find differences (774
found). Native/fused sample counts are identical (137,180). So a future tunnel or passage geometry
change that breaks a reach proof (ce4d878, a668833) fails loudly instead of silently making holes.
VoxelGenLOD::ResetTilePostReachDiagnostics resets every per-kind counter between the two runs, so
the proof cannot pass vacuously.

Test fixture fix: the strate connectivity test's density-sign/coarse-lie controls use boxes up to
256 voxels, which the DiffLayer budget added in 8879045 rejected. The fixture now uses an unlimited
budget; the test measures the field, not the gameplay brush.

The tunnel SDF/core merge was attempted and NOT shipped (Codex; reviewed by me):
- The world-space core is load-bearing. WorldControlPoints keep the authored room-floor anchors and
  walkable tunnel routes before cave warp (8e42d6d, against warped floors becoming vertical
  breaks), and cf2b2ad made the world chain authoritative for option B winding/ledge walkability.
  EvaluateTunnelCoreWorld also supplies the support/room-floor ownership the tail uses.
- Replacing the world shape: player-fit 20,830 -> 19,684, walk 10,909 -> 9,934, components
  261 -> 220. Raw world evaluation without floor handling also failed (20,348 / 10,713): a
  coordinate-frame / junction mismatch, not only floor projection.
- The exact shared-segment variant was SLOWER than the committed tip: static worker +5.6%, moving
  +12.6%, LOD0 p95 +6% / +13%.
- A 0.0001-0.01% world-chain blend that merely passed the gate was rejected as cosmetic.
All merge source was reverted. This build is bit-identical to the committed tip (311CA42C, 75,482
triangles, capability 20,830 / 10,909); connectivity 6/6 + 16/16, passage open-space 14/14 and
sealed proof 22/22 pass. Every launch was serialized.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-15 17:42:25 +02:00
Fr0zkaandClaude Opus 5 cfd81b5ce9 docs(work-next): state after 2026-09-15 -- gameplay fixes, correctness, perf, current cost map
Records the playtest-driven fixes (strate crossing, sealed-rock proof, option B floors, gap
D-floors), the cold-audit correctness round (including the tile-cache determinism bug), the perf
gains measured against committed-tip DLLs (static worker ~184 s -> ~28 s over the whole push, same
world), the current remove-one cost map, and the parked exact-cull branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-15 11:31:09 +02:00
Fr0zkaandClaude Opus 5 a668833ef5 perf(generation): exact reach gating for the tunnel core (per tile) and passage carving (per 8^3 block)
Applies ce4d878's decide-once-then-prove pattern to the two largest exact targets from the re-rank
(tunnel core 20.8%, passage carving 9.1%). Codex; reviewed by me.

Measured first, where the time is:
  core world:      far 223,860 vs near 35,515,979 cycles   -> tile gate only; the core is
                                                             near-dominated (~99%)
  core tail:       far 363,440 vs near 20,110,219
  passage, tile:   far 7,315,426 vs near 30,168,796        -> refined to an 8^3 block gate
  passage, 8^3:    far 11,233,384 vs near 26,250,838
The core already uses the tunnel spatial index. An exact skip cannot remove its cost: that cost is
in tiles near tunnels. The remaining lever there is the field-changing SDF/core merge (next).

- Core reach per tile, from lattice plus halo: swept capsule reach, floor band, floor-profile
  deviation, relief bound, room-mouth ownership. The GetDensityAt tail is identity unless
  bSupportFloor, bRoomFloor or CoreSDF < -inset.
- Passage carving per 8^3 block: capsule-chain and landing-room domains, the carve threshold and
  the SmoothMin K/6 margin. Malformed descriptors stay candidates (conservative).
- voxel.TileReachCostDiagnostics (default 0) for the far/near cost split.

Soundness: the final-field debug comparison ran 130,285,889 comparisons with 0 differences; shrunk
reach (0.5) produced 140,035 differences (60,098 in the final field) and failed as required.
Field exact: canonical 07C14005 / b3e5f4c3..9b377, 64/64 per-tile ledger identical, capability
20,830 / 10,909, strate-crossing and sealed-proof tests pass.

Final DEFAULT build vs the committed-tip DLL (same command line, two interleaved runs each):
  static  worker 28.84 -> 28.10 s (-2.6%)  LOD0 p50 53.3 -> 51.5 ms  p95 94.4 -> 93.1 ms
  moving  worker 61.39 -> 60.61 s (-1.3%)  LOD0 p50 50.9 -> 49.7 ms  p95 92.21 -> 92.27 ms (flat,
                                                                        +0.06%, within noise)
Maintenance note: these reach proofs must track tunnel/passage geometry. The next round adds an
automation test for the final-field comparison so a geometry change cannot silently break them.
Ledger: 15 builds (one C2065 fixed, one access-denied path retry); rejected TileOnly/BlockLookup
variants were slower; one hung diagnostic (a non-owner PID) was stopped; wrapper quoting failures
launched no editor. No crash folders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-15 11:30:37 +02:00
Fr0zkaandClaude Opus 5 0208e93606 measure(perf): re-rank component costs on the current build -- the tunnel core is now the largest
Measurement only (Codex): the same remove-one ablation method and command line as 6ccf371, all 14
voxel.TunnelAblate* bits static x2 and the top five moving x2, on the committed tip after the
landing-post, cave-warp, gap-floor and option B changes.

Static leaders now: tunnel core 20.84%, tunnel SDF 12.64%, passage carving 9.08%, cave warp 6.68%;
27.62% remains unattributed/shared. The tunnel core could not be measured at 6ccf371 because of the
ablation gate bug fixed in 7a972f5. The two tunnel evaluations together are now about a third of
worker time. Next targets as proposed: tunnel SDF/core merge (field-changing), passage carving
reuse (exact), cave warp result reuse (exact). Full tables and the launch ledger are in
PERF-SAMPLED.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-15 09:30:22 +02:00
Fr0zkaandClaude Opus 5 1d824aaab2 perf(noise): batch the three cave-warp channels through the SIMD evaluator -- exact, ~4% faster
The remove-one ablation, re-confirmed on the current build, puts the cave warp at 11.0% static /
11.8% moving of worker time: three independent Perlin3D calls per sample. VoxelNoise::Perlin3D_x3
evaluates the three warp channels together through the existing SIMD evaluator (Codex; reviewed
by me). It is wired into the generator, op-stack, classifier and room/player-fit unwarp paths.

Exact: the field is byte-identical. 0/841 tiles changed, 0 triangle delta, 0 voxels of maximum
surface displacement; canonical export 07C14005 / b3e5f4c3..9b377; capability 20,830 / 10,909;
strate-crossing and sealed-proof tests pass. The seam test (VoxelForgeMazeSeamTest, extended) has
48 neighbouring-tile and 24 cross-LOD comparisons, 0 mismatches. Before/after renders from a
camera aimed at the real arrival tunnel are byte-identical.

Cross-machine determinism: Perlin3D_x3 adds no new intrinsics, no AVX, no FMA and no runtime CPU
dispatch. It reuses the evaluator behind the compile-time gate
`#if PLATFORM_ENABLE_VECTORINTRINSICS && PLATFORM_CPU_X86_FAMILY`, so every x64 machine runs the same
instruction path; its output matches the scalar path bit for bit.

Final DEFAULT build vs the committed-tip DLL (two interleaved runs each):
  static  worker 30.83 -> 29.47 s (-4.4%)   LOD0 generation p50/p95 56.3/99.3 -> 53.3/95.3 ms
  moving  worker 67.28 -> 64.56 s (-4.0%)   LOD0 generation p50/p95 55.9/99.0 -> 53.4/96.4 ms
Ledger: every launch passed; two harness false negatives (the seam test on the game-only
validator, and a trace ended by intentional forced termination) were rerun. No crash folders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-15 08:41:59 +02:00
Fr0zkaandClaude Opus 5 ce4d8783e9 perf(generation): per-tile reach test skips landing and structural posts -- ~12% less worker CPU
The remove-one ablation put landing posts at ~10% and passage structural posts at ~6% of worker
time, though landings exist only near passage ends and the origin. The parked exact-cull round's
per-SAMPLE post bounds had been a net loss. This round decides reach once per TILE (Codex;
reviewed by me).

- VoxelTilePostReach.h: one per-tile reach mask (thread-local byte), set when the tile is set
  up. The origin landing, passage landing SDF/floors, native floors, tunnel air and passage
  structural posts return their input unchanged on tiles no instance can reach. Per sample this
  costs an inline flag read.
- Reach proofs come from each post's geometry (origin rooms, landing SDF and floors, native floor
  band, structural support spheres), not from comments.
- voxel.TilePostReachDebug=1 evaluates the skipped posts anyway and compares the FINAL field;
  voxel.TilePostReachScale=0.5 deliberately shrinks the reach and must fail.

Default build: 694 tiles, landing reachable on 4 (skipped on 690), structural posts skipped on 169.
Soundness: 123,958,673 comparisons, 0 differences; shrunk reach: 13,929 differences, FAIL as
required.

Final DEFAULT build vs the committed-tip DLL (same command line, two interleaved runs each):
  static  worker 34.80 -> 30.52 s (-12%)   LOD0 generation p50/p95 67.0/107.6 -> 55.8/95.8 ms
  moving  worker 75.96 -> 66.66 s (-12%)   LOD0 generation p50/p95 66.3/106.0 -> 56.0/95.8 ms
Field exact: 841/841 keyed tiles vs a fresh committed-tip trace (669,902 triangles, which includes
9260775's gap floors), export b3e5f4c3..9b377, capability 20,830 / 10,909; strate-crossing and
sealed-proof tests pass.
Ledger: all launches passed or failed where designed to; early raw-SDF debug attempts failed
until the comparator was corrected to compare the final field. No crash folders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-15 07:42:13 +02:00
Fr0zkaandClaude Opus 5 92607751b1 fix(passages): D-floors now exist inside the inter-strate gap; two stale tests corrected
Investigation of the two tests that had been failing for days (Codex; the fix reviewed and its
field claims re-run by me).

Real bug, introduced by adb34df: gap chunks take the compact bedrock path in GetDensityAt, which
applied the passage modifier but never the build-authored native D-floor. So the walkable passage
floor vanished exactly at the gap/strate seam, in the passages the player uses to cross strates.
Now gap chunks set Result = -Density, then ApplyPassageNativeFloorMC, then VF_ApplyXYEdgeSealMC.
The seal runs on the MC value, in the same order as before.

Stale tests, not code bugs:
- PassageLandsInOpenSpace: since d97373c production passes the cached world/warp seed to
  VF_SuggestLandingPoint, while the test still queried with the default seed. Both queries are
  fixed; the test now allows only exact graph-owned support-floor samples (the production
  bProtectAuthoredTunnelFloor contract), and anything else still fails. 14/14 landing ends,
  5,774/5,774 floor and capsule samples.
- ClassifyTileSoundness: since d38d4ca ClassifyTile covers the cell vertices g=0..CPA, but the test
  still sampled the mesher's outer normal halo. Both "holes" were halo-only (g = CPA+1). The test now
  asserts the current contract. TryProveSealedSolidTile already uses the exact core lattice, so it
  is not affected.

Verified: crossing test PASS (max gate 0.129 s); sealed-proof test 22/22; both classifier variants
pass (600 tiles each). Re-run by the orchestrator against this build: export OBJ SHA-256
b3e5f4c3..9b377 unchanged; capability 20,830 / 10,909 / walk-only 10,909.
Ledger: one DDC startup fatal (crash folder 43101DC8, 04:38) and a set of expected diagnostic
failures while locating the gap-branch omission; one malformed ablation argument (not a valid
ablation).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-15 06:08:59 +02:00
Fr0zkaandClaude Opus 5 cf2b2add20 feat(tunnels): option B floors -- no staircases; steep tunnels wind or drop
Owner's playtest showed a staircase (dozens of <= 45 cm risers) on a steep tunnel into a room. He
chose option B: "smooth passage and 'dramatic ledge' on tunnels that crossed the slope limit, so we
both have serpenting tunnels and sometimes dramatic drops that needs to be escalated".

Implementation (Codex; the edge rule reviewed by me):
- VF_TunnelFloorNeedsTerrace and all tunnel-floor NumSteps quantisation are removed; new profiles
  always have NumSteps = 0. Gentle floors remain continuous ramps with bounded relief.
- The room graph's bridges and a deterministic reserved walk spanning forest decide each steep
  edge. bGraphLedgeEligible = leaf || (redundant && !walk-forest edge). Eligible edges may receive a
  few large drops at control-point boundaries, never repeated small risers. Every other steep edge
  WINDS (a sinusoidal wander lengthens the WorldControlPoints route until its floor is walkable).
  Several drops chosen together cannot disconnect walking, because the forest stays walkable.
- The collect margin includes the longer wound-route envelope (window invariance). Classifier
  interval bounds include both endpoint floor levels, covering a ledge riser.

Verified:
- default acceptance (slot 1, threshold 0.9657): player-fit 20,830, walk 10,909, arrival ->
  departure connected, TunnelFloorMultiStepEdges 0, mesh 311CA42C unchanged. NOTE: this config
  has 0 steep edges, so it does not exercise option B at all.
- stress (threshold 0.2): arrival -> departure connected; player-fit 21,305 (winding adds
  volume), walk-only 10,787. Fewer walk-only cells are expected behind climbable ledges. Ledges
  4-8 voxels: 52,826 observations; 8+: 215,880; below 4: 0.
- determinism: two runs identical (164,126 triangles, 70F987AA, 0 per-tile differences).
- performance vs baseline DLL: moving worker 93.6 -> 85.2 s; LOD0 request p95 static 196 ->
  181 ms, moving 188 -> 164 ms.
- the two already-failing tests fail identically before and after (ClassifyTileSoundness: 2
  expected-uniform holes; PassageLandsInOpenSpace: upper landing endpoint mismatch).

NOT verified visually: every render reused one fixed camera, never aimed at a changed tunnel. The
arrival case was rendered at the default threshold (8/8 views identical); in the steep/wind cases
the changed views did not show a tunnel clearly. The owner's in-game check is the real acceptance.
Ledger: several intermediate capability gates failed before the final configuration passed; two
malformed launches were aborted; no crash folders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-15 04:23:31 +02:00
Fr0zkaandClaude Opus 5 8879045627 fix(correctness): cold-audit fixes -- tile-cache determinism bug, cache identities, lifetimes
Correctness round on AUDIT-COLD-2026-09-14.md (Codex; key fixes reviewed by me).

#7 (determinism, introduced by the tile-cache work): the refetch guard in GetDensityAt now
rebuilds on `bTileCacheContext != CP_UsesTileCacheWindow` or when the worker-local stack/core were
moved into a cache entry (`CP_ActiveTunnelDensityCache != nullptr`). A cache miss can no longer
evaluate a moved-from, empty operator stack. Test (VoxelForgeCorrectnessRoundTest): the pre-fix
binary CRASHED in the context-switch case (negative control); the fixed binary passes with 0/5
coarse and fine mismatches, and warm vs fresh origin-tile meshes are identical.

Also fixed, each with a focused test that passes:
- #3/#8: worker diff/surface/biome caches carry owner/layer identity (no cross-world reuse);
  #9: the shaft key gains LedgeSpacing/LedgeDepth and the island key BoundarySealThickness;
  #14: clearing passages bumps PassagesVersion; #21: HasAnyModInChunkRange is proportional to
  the queried range (the sealed-rock proof calls it per candidate).
- #1: the density-volume fill thread is paused/drained with generation; #2: each content manager
  joins its own decoration tasks before destruction; #15: non-finite/invalid modification
  geometry is rejected; #20: zero-width transition blend is guarded at runtime.
- Also #4-#6, #10-#13, #16-#18, #22. #19 is valid C++20 and unchanged.

Verified: canonical export b3e5f4c3..9b377; capability 20,830 / 10,909; fresh trace 841/841
identical, 0 origin-column mismatches; static mesher calls 18,078,130. Performance: two pairs
disagree in sign, and the means are final -2.5% static / -0.8% moving; no repeatable regression.

Known and NOT caused by this round: ClassifyTileSoundness and PassageLandsInOpenSpace were already
failing (15/17 in the grouped suite). Queued for investigation.

Ledger: first determinism launch hit the DDC fatal (crash folder 57DB6ADC, 23:21); the retry
exited 1 on the two known failures; one malformed multi-test launch was inconclusive; one
malformed trace launch was stopped; one launch with an unquoted project path found no test and its
retry passed. Everything else passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-15 00:29:46 +02:00
Fr0zkaandClaude Opus 5 39c306b524 perf(streaming): prove sealed rock solid instead of sampling it -- crossing fix's cost recovered
4c8d06f stopped content-cutting LOD0 so the player can cross strates, at a cost of +29% static
worker CPU: LOD0 tiles in the sealed rock between strates, which the old cut skipped by accident,
were sampled in full and found solid. This proves those tiles solid instead (Codex; reviewed by me).

- UVoxelGenerator::TryProveSealedSolidTile: AllSolid only for a tile whose whole lattice lies in a
  gap/seal region. It rejects player mods (HasAnyModInChunkRange), passages plus carve pads, the
  origin spine/landing, disturbances, recipes and overrides. It is narrower than the old
  ClassifyTile, which stays off in streaming. CollisionSolidTiles / CollisionNotRequiredTiles
  bookkeeping is preserved, so the collision gate's support search still resolves.
- TryProveOutOfLayoutAirTile: a separate fail-closed constant-air proof for the zero-triangle tiles
  outside the strate layout. It never supplies collision support.
- voxel.SealedSolidProof (default 1). Mining a proven tile invalidates and re-meshes it.
- A soundness test that can fail (VoxelForgeSealedSolidProofTest): 22 candidates, 22 skips, 0
  non-solid lattice samples; a deliberately weakened diff guard produces 437 non-solid samples
  and fails.

Verified: mining tile (14,-18,-8) went from proof=1 / 0 triangles to proof=0 / 8 triangles and
re-meshed; strate-crossing test PASS (max gate 0.098 s); off/on trace 899/899 identical; export
b3e5f4c3..9b377; capability 20,830 / 10,909.

Two interleaved rounds, serialized (owner-editor count 0 before each launch):
                        baseline   crossing-fixed   + proof
  static  worker        35.5 s     41.9 s           36.0 s   (+1.4% vs baseline)
  moving  worker        78.1 s     93.2 s           78.2 s   (+0.1%)
  mesher calls static   18.078M    22.280M          18.078M
Applied triangles identical: 669,834 static / 1,331,482 moving.
Runs: one early automation probe exited 3 with no log (the corrected harness passed); one
exploratory moving run was interrupted and excluded.

Note: HasAnyModInChunkRange scans every modified chunk per candidate (cold audit #21). That is cheap
today (~20 candidates), but it grows with mining; it is queued in the correctness round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-14 22:08:24 +02:00
Fr0zkaandClaude Opus 5 481e7bbaee docs(audit): cold read-only examination with verification notes -- one determinism bug confirmed
A read-only Codex examination (no project background in the brief; it did read the root docs) with
22 code-level findings, kept verbatim, plus the orchestrator's verification notes:
- #7 CONFIRMED: after VoxelGenerator.cpp:2968 moves CP_OpStack into the tunnel cache, a miss under
  the other tile-cache context skips the refetch (:2723-2725) and evaluates an EMPTY stack. The hit
  path makes the spawn column X=Y=0 reach this after an ordinary hit. Introduced by the tile-cache
  work; queued first in the next correctness round.
- #3/#8 are real but narrower (multiplayer peers share seed and layout by design).
- #19 is not a defect under C++20.
The rest are to be verified individually before fixing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-14 20:14:35 +02:00
Fr0zkaandClaude Opus 5 4c8d06fd8f fix(streaming): the player can walk from one strate into the next
Owner playtest: he could not walk from strate A into B. Forcing the pawn through in the editor
showed the junction tunnel (and his edits to the lower strate) were there.

Cause: UVoxelSettings::StrateContentCutMinLevel defaulted to 0, and DA_Settings does not override
it, so the strate content band (the PAWN's strate) also cut LOD0, the collision tiles. While the
player stood in A, the passage below A's seal, the gap and strate B had no LOD0 geometry, and the
collision gate correctly refused to step onto missing collision. The band only moves once the pawn
is inside B: a deadlock. bClampViewToStrate is a different setting, which is why toggling it did
not help. Some historical fall-throughs were likely this, before the gate turned "fall" into
"blocked".

Fix (Codex; reviewed by me):
- StrateContentCutMinLevel default 0 -> 1, ClampMin 1, and
  UVoxelSettings::GetEffectiveStrateContentCutMinLevel() clamps to [1,9] at every runtime
  consumer, so a legacy asset serialising 0 cannot bring the deadlock back. LOD1+ stay banded
  inside strates and go full in the gap.
- A regression test, -voxel.TestStrateCrossing=1 (off unless passed): it walks the real manager
  passage control points with AddMovementInput and normal swept character movement, with limits
  of 10 s max gate, 120 s total and a 2-voxel floor tolerance. Old code: FAIL
  collision_gate_timeout at route 5/9 (10.0 s gated). Fixed: PASS reached_lower_strate, strate
  0 -> 1, max gate 0.092 s. The trace shows band [-7..0] -> full in the gap -> [-17..-10].

Field unchanged: canonical export b3e5f4c3..9b377, capability 20,830 / 10,909. In game, the LOD0
change in the crossing scene is 11 transition-gap tiles going from 0 to 22,390 triangles; no LOD0
interior tile changed.

COST, stated plainly (two interleaved runs, baseline vs fixed DLL):
  static  generation CPU 34.1 -> 44.1 s (+29%), density calls +23%, LOD0 request p95 +8.9%
  moving  generation CPU 78.8 -> 91.1 s (+16%), density calls +26%, LOD0 request p95 +3.1%
  applied triangles identical (669,834 / 1,331,482): the extra work is LOD0 tiles in sealed rock
  that the old cut skipped. The next round proves them solid instead of sampling them.

Visual caveat: the old default of 0 was a deliberate July choice, because LOD0/1 tiles straddling
a strate boundary mixed both strates' materials. With LOD0 never cut, that mixing may reappear
near seals. The headless runs are -nullrhi, so this still needs checking in the editor.

Launches: all serialized and waited by PID. Failures: one malformed first baseline attempt, one
over-strict early harness assertion, and one run against stale staging (it logged CutMinLevel=0),
all superseded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-14 19:46:35 +02:00
Fr0zkaandClaude Opus 5 7ece3ceafc docs(work-next): state after 2026-09-14 -- perf ledger, measured costs, what failed and why
Records in the repo what two days of perf rounds established: the in-game worker CPU trajectory
(~184 s -> ~34 s static), the remove-one ablation costs, the three approaches that were tried and
not kept (with where the parked work lives), the measurement lessons that each cost a round, and
the next candidates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-14 17:26:44 +02:00
Fr0zkaandClaude Opus 5 7a972f513d fix(ablation): tunnel-core ablation no longer disables the fused evaluator
Salvaged from the parked exact-culling round (wip/exact-cull-20260914, 49369ac). This is the only
change ported. `if (!VoxelDensityAblation::IsTunnelCoreOff() && CP_UseOpStack)` becomes
`if (CP_UseOpStack)`: with the tunnel-core ablation on, the old condition also sent coarse tiles
to the scalar per-chunk cache path. That was the 2.9x "tunnel core off" anomaly in 6ccf371. In
normal runs (ablation off) the condition is unchanged.

Salvage round (Codex; checked by me):
- Baseline confirmed on this machine: static worker 33.98 s. So the parked round really was a
  regression (~41 s with all its switches off), not machine drift.
- The parked round's always-on cost: EvaluateSDFCached did a tile-window/candidate lookup
  unconditionally; tunnel-chain signatures added candidate range checks and per-segment AABB/Sqrt
  lower bounds; CanSkipSmoothMin read exported globals + TLS per primitive even when disabled;
  passage candidate setup ran before the disabled check; the passage/landing/post paths copied
  density and built debug closures with debug off. No FORCEINLINE was lost; the added work was
  inlined into the hot sample path.
- Not ported: tile candidates (48.26 s on vs 46.19 s off even inside the WIP), the SmoothMin cutoff,
  the post early-outs (a measured loss) and the debug hooks.

Final vs baseline DLL, two interleaved runs each: static worker 33.98 -> 33.56 s, moving
73.89 -> 74.70 s; LOD0 generation p50 66.1 -> 65.5 ms static, 64.7 -> 65.3 ms moving. Equal
within noise, as expected from a change that is inert in normal runs.
Field exact: 841/841 keyed tiles, export b3e5f4c3..9b377, capability 20,830 / 10,909.
Every launch was serialized (prelaunch UnrealEditor count 0); no crash folders; two harness-only
trace wrapper failures preceded the passing trace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-14 17:26:09 +02:00
Fr0zkaandClaude Opus 5 6ccf3718ff measure(perf): per-component ablation of the TunnelNetwork path; exact worms restored
Round (Codex; reviewed by me against PERF-SAMPLED.md).

Revert: voxel.WormNoiseMode / voxel.WormLatticeStep, their storage and their switches are
REMOVED. VoxelWormField has one path again, exact Perlin with the N1 short circuit. Kept:
bEnableWorms (asset), WormsForceOff (measurement only), the exact block skip (default 0).

Ablation instrument: 14 development-only, world-changing force-off bits (voxel.TunnelAblate*),
applied on the fused, op-stack/classifier, cached SDF/core and modifier/floor paths. They resolve
once per process, are hashed into the generation fingerprint, and are compiled to zero in
UE_BUILD_SHIPPING (VoxelDensityAblation.cpp). They are a measurement instrument, never a gameplay
or multiplayer switch.

Defaults exact: canonical export OBJ b3e5f4c3..9b377, worms-region 311CA42C / 75,482 triangles,
capability 20,830 / 10,909, and 841/841 keyed tiles identical to NextHeadStaticTrace_20260913.

Static worker seconds saved by removing ONE component (two off runs vs the interleaved all-on
33.06 s; baseline noise 0.27%):
  tunnel SDF 11.4%, cave warp 10.3%, passage carving 10.1%, landing posts 9.9%,
  passage structural posts 5.6%, worms 2.4%, native floor 2.2%, origin spine 1.6%,
  room SDF 1.6%, everything else <= 1.1%
Moving (2+2 runs each) confirms the top five at 6.0-11.6%. Tunnel SDF off cuts LOD0 generation
p95 104 -> 83 ms: it owns the slow tail. The positive savings sum to 58% of the static total; the
other 42% is shared. The rows are not additive.

Unexplained: tunnel-core OFF made the static total 2.9x SLOWER (95.5 s) while LOD0 generation got
faster (p50 64.7 -> 60.6 ms) and triangles fell. The report blames the changed field, which does
not fit; more likely a slow fallback path in coarse tiles. That row is not a cost measurement.

Ledger: every Unreal process exited 0 and verified the loaded DLL; two PowerShell parser errors
happened before any process started. No crashes or retries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-14 09:39:57 +02:00
Fr0zkaandClaude Opus 5 c132243b30 feat(worms): asset-owned worm off switch; offline stack symboliser -- cheap-worm default to be reverted
Worms round (Codex; reviewed by me against its report and PERF-SAMPLED.md).

Part 1, a real off switch: UVoxelStrateDefinition::bEnableWorms (default true, so existing assets
are unchanged). It is deliberately not in FStrateGenerationParams, so the 83-field memo-key guard
is untouched. It resolves after composer candidates, season vectors and boundary blending, is
included in the generation fingerprint, and clamps composer overrides, so a roll cannot re-enable
it. With strength 0 the fused path skips the worm block, the op-stack builder omits the worm
source, and the interval/classifier paths do no worm work. Proof: disabled == hand-zeroed control
byte for byte (70D310A4, 74,672 triangles), capability 20,856 / 10,917, connected, zero worm rows
in the symbolised sampler. voxel.WormsForceOff is a dev-only, world-changing measurement
override, included in the fingerprint.

Part 0: the sampler writes a module map (733 modules, identity checked), and a post-exit
offline symboliser (VoxelStackOfflineSymbolizer, editor module) rebuilds the all-LOD / LOD0 /
LOD1+ exclusive / inclusive / source-line tables. A planted cost was found (5 named rows) and then
removed. DbgHelp still never runs in the measured process.

Part 2, "cheap worms": FAILED to deliver, and the default is wrong. No candidate was faster:
lattice-2/4 gave no gain, and value noise was within noise static and ~2.1% WORSE in moving worker
seconds. Yet this commit carries voxel.WormNoiseMode=1 (value noise) as the default, a field change
(29/64 test tiles) with no benefit, selected after value-noise variants were tried until one
passed capability (three were rejected at 20,821-20,828). The next round reverts the default to
exact Perlin (mode 0) and removes the no-gain world-changing experiment modes.

The finding that matters: disabling worms entirely (WormsForceOff A/B, two interleaved runs each)
saves only ~2.5% static and ~3.4% moving worker time. The sampler's "worm line
VoxelGenerator.cpp:3985 = 19.4% of LOD0" was a misattribution of inlined noise code to one PDB
line. Line-level attribution of inlined code must be confirmed by ablation before a round targets
it. That is why the worm block skip (26b3476) found nothing.

Runs: the full ledger is in PERF-SAMPLED.md. Failures: one moving wrapper hit the graceful-close
timeout and was rerun; the first offline-symboliser commandlet hit the DDC no-writable-nodes fatal
(crash folder UECC-...CE75ACD5, 08:01) and was retried with the writable Saved\DDC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-14 08:19:50 +02:00
Fr0zkaandClaude Opus 5 11f0c1335b fix(diagnostics): sampler can no longer crash the run it measures; worm skip default off
Harness round. Every launched run is in Saved/HarnessFixAcceptance_20260914.ledger.txt.

- FVoxelStackSampler: two sampler-on runs had died in EndPlay (logs stop after "workers drained",
  before the latency summary). The lifecycle teardown is now serialised, registration TLS is
  tagged by session, stale worker registrations are cleared safely, and in-process DbgHelp
  initialisation/symbolisation is REMOVED. The measured process now writes raw PCs only; symbol
  resolution is deferred to after process exit. (An offline symboliser is still needed for
  readable tables; it is queued first in the next round.)
- voxel.WormBlockSkip default 1 -> 0: it measured neutral (26b3476). The code, the proven bound
  and the soundness test are kept for a tighter skip later.
- Harness (Saved/*.ps1, not tracked): a writable DDC under the plugin's Saved\DDC with
  -ddc=NoZenLocalFallback, fixing the DerivedDataBackends.cpp:208 startup fatal. UE 5.7 Windows
  was verified NOT to honour -nocrashreports for its startup monitor, so the harness runs
  unattended with dialogs forced off plus a PID-scoped CrashReportClient watchdog. Failures
  still exit non-zero, and a run without its final summary line is rejected.

Acceptance: 10 consecutive mixed runs, 7 of them sampler-on, all PASS; no new crash folders, dumps,
dialogs or lingering processes. Other runs: one smoke run failed on a PowerShell
HashSet.ToArray() compatibility bug in the harness (Unreal itself exited cleanly); its retries and
the export passed. Export OBJ unchanged (b3e5f4c3..9b377).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-14 05:17:27 +02:00
Fr0zkaandClaude Opus 5 26b34765d5 perf(generation): exact worm block skip with a proven 3D Perlin bound -- measured NEUTRAL
The sampler put the worm carve's first noise call (VoxelGenerator.cpp:3985, FusedWorm) at 19.4% of
LOD0. The worm only carves where N1 + N2 < T with N2 >= 0, so where N1 >= T it contributes nothing,
bit for bit. A block proven to have N1 >= T everywhere can skip both noise calls exactly.

- The Lipschitz bound for this Perlin3D implementation: L = (15/4) * sqrt(3) * 1.25 = 8.118988160,
  in scaled-N1 units, with a 1e-3 float-rounding margin. Adversarial sampling found a worst ratio
  of 3.187 (0.39 L). The existing 2D floor-relief bound (8.5) is a component bound of a different
  field and was confirmed not to transfer.
- A soundness test that can fail (VoxelForgeWormBlockSkipTest): 6,476 proved blocks, 355,945 exact
  lattice samples, 0 violations. A deliberately halved L proves a bad block; the full L rejects it.
- voxel.WormBlockSkip, default 1.

Field exact: export OBJ byte-identical (b3e5f4c3..9b377), 841/841 keyed tiles identical to
NextHeadStaticTrace_20260913, capability 20,830 / 10,909.

RESULT, stated plainly: no measurable speed gain. The skip rate is 4.07% of worm-eligible LOD0
samples and 0% at LOD1-4, because the bound is ~2.5x looser than the worst observed ratio at the
block size used. Paired on-vs-off, two interleaved runs each: static worker +2.15%, generation p50
+1.8%; moving worker -1.42%, request p50 -2.5%. Within noise. The next round turns the default OFF.
The proven bound and its test are kept for a tighter skip later (smaller or hierarchical blocks,
or a per-cell bound).

Run ledger (from Codex's report): the first build failed on an ambiguous test overload and was
fixed; two timing runs are void because the orchestrator stopped the session so the owner could
use the PC; the eight valid timing runs, the sampler run and the diagnostics runs completed. No
new crash folders. PERF-SAMPLED.md now lists all eight earlier-round UECC crash records.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-14 04:29:11 +02:00
Fr0zkaandClaude Opus 5 5489b5e311 perf(generation): inline exact IsFinite -- LOD0 ~11-12% faster, field-exact
The sampler's last "unknown" LOD0 bucket (~13.8%) was ucrtbase.dll!_finite: FMath::IsFinite is an
out-of-line CRT call on the MSVC game path, hit from the innermost tunnel, passage, density,
spatial-index and meshing loops.

- VoxelMath::IsFiniteFast(float/double) in VoxelTypes.h: memcpy to uint32/uint64,
  (Bits & ExpMask) != ExpMask. It is the same predicate for every bit pattern, with no FP
  comparison for fast-math to fold. The disassembly shows it inlined (movd/movq, mask, compare).
- VoxelMath::IsFinite routes through a plain global, voxel.FastIsFinite (default 1), for the A/B.
  The CRT call remains only on the switch-off branch. Hot generation-path uses are replaced; no
  _finite / std::isfinite remains in Source/VoxelForge. Tool/editor/test code keeps FMath.
- A test that can fail: VoxelForge.Math.IsFiniteExact compares against FMath::IsFinite over 22
  explicit float/double patterns (+-0, denormals, min/max normal, +-Inf, quiet and signalling NaN
  payloads) plus 2,000,000 hashed patterns each: 0 mismatches. The explicit NaNs make a NaN-wrong
  helper fail.

Field exact: 841/841 keyed tiles identical to NextHeadStaticTrace_20260913 (verdict/empty/
triangles), export OBJ unchanged (b3e5f4c3..9b377), capability 20,830 / 10,909.

Within-round A/B, game path, two interleaved runs each, sampler off (off -> on):
  static  LOD0 request p50  95.5 -> 84.8 ms   generation p50/p95  70.3/114.1 -> 62.3/100.6 ms
          worker            35.4 -> 31.6 s (-10.5%)
  moving  LOD0 request p50/p95  70.8/144.2 -> 62.0/126.1 ms
          generation p50/p95    62.6/113.4 -> 55.1/101.0 ms
          worker            102.6 -> 91.3 s (-11.0%)
Sampler-on moving: the _finite/CRT cluster is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-13 23:50:30 +02:00
Fr0zkaandClaude Opus 5 22186473f9 perf(generation): spatial index default on; the 10 s question and the unknown bucket, answered
Measurement round (Codex; checked against its report):
- Per-tile identity for ab4bbe3's always-on LOD0 changes: a fresh 841-tile HEAD trace matches
  traces recorded before them (MemoStaticOn/Off), keyed by (x,y,z,level) with
  verdict/empty/triangles compared. 0 differences, 0 missing; LOD totals identical, 669,834 in all.
- The "missing 10 s" is not the spatial index: all six TileCacheWindow x SpatialIndex
  combinations are within ~1% for a given window setting. TileCacheWindow=0 at HEAD is not
  2990009's path. It still thrashes at L3/L4 (p95 ~250 ms). The default beats 2990009 on every
  metric: static worker 48 -> 34.4 s, moving 125 -> 101 s, LOD0 generation p50 73 -> 66 ms
  static and 70 -> 62 ms moving.
- voxel.SpatialIndex default -1 -> 1: forced-on was marginally best in every pair (within noise).
- The sampler's "unknown" ~13.8% LOD0 leaf is ucrtbase.dll!_finite (RVA 0x3C080), the non-inlined
  CRT call behind FMath::IsFinite, not Core.dll. Named by parsing Core's export table (no export at
  or below 0xC080) and aggregating callers: the tunnel chain, passage landing/floor checks,
  GetDensityWithParams, GetDensityAt, the spatial index range and EvaluateSDFCached.

Gates: export OBJ unchanged (b3e5f4c3..9b377), capability 20,830 / 10,909, determinism assertion
passed.

Next: an inline bit-test IsFinite (the same predicate for every input, so field-exact).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-13 23:05:33 +02:00
Fr0zkaandClaude Opus 5 ab4bbe3680 perf(generation): field-exact LOD0 overhead removal; the unknown bucket is Core.dll
Field-preserving floor round (Codex; reviewed by me against the code):
- exact per-(sample, passage) caching of native/generic floor projections and walkable-air
  checks, behind voxel.FloorRound1PassageProjectionCache
- a zero-work disabled path for VoxelDensityProfile::FScopedTimer, unchanged when enabled
- pointer / range-for iteration in the proven-hot tunnel, passage, spatial and candidate loops
  (TArray RangeCheck was ~6.5% of LOD0 samples on the Development binaries the owner plays on)
- FVector2D temporaries replaced with scalar doubles, same operations in the same order
- FSurfaceColumn made trivial, so thread-local construction does no work. Verified safe:
  ComputeSurfaceColumn writes all five outputs unconditionally before any branch, and every read
  of Box.Cols is gated by Box.Computed

Verified: export OBJ byte-identical (b3e5f4c3..9b377); capability 20,830 / 10,909; all 8
interleaved game runs gave identical totals (841 tiles / 669,834 triangles static, 2,348 /
1,985,216 moving, the same as previous rounds). Per-tile identity for the always-on changes still
needs a check against a stored trace; that is queued first in the next round.

Game path (sampler off, two interleaved runs each):
  projection-cache switch alone: neutral (static worker +0.41%, moving -0.05%)
  vs the previous round (cfad3d1, same harness; cross-round numbers reproduced within ~1% here):
    LOD0 generation p50  static 76.6 -> 65.8 ms (-14%), moving 72.4 -> 59.5 ms (-18%)
    LOD0 request p50/p95 moving 79.4/158.5 -> 67.6/137.6 ms
    worker seconds       static 39.2 -> 33.4, moving 113.7 -> 97.9
  The always-on changes carry the gain; they have no switch, so this is a cross-round comparison.

The sampler's ~12.9% "unknown" LOD0 leaf is engine code: UnrealEditor-Core.dll + RVA 0xC080,
without a Core PDB. The old FSurfaceColumn label is gone. Next round names it by its callers.

PERF-SAMPLED.md: corrected a sentence that repeated my false "daytime use" explanation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-13 21:00:34 +02:00
Fr0zkaandClaude Opus 5 b839edd3a3 docs(work-next): correct cfad3d1's false "machine in daytime use" explanation
cfad3d1's message blamed cross-round drift on the owner using his PC. That was my unverified
guess, and it is false: he had not used the PC all day. The gap is systematic. The switch-off
baseline measured 58 s static worker CPU in two rounds hours apart, against 48 s at 2990009, and
LOD0 generation p50 is ~4% slower than at 2990009. Something left on with
voxel.TileCacheWindow=0 costs ~10 s. Queued: attribute it with in-tree switches (first suspect:
voxel.SpatialIndex auto-on at fused LOD1-2), never by running old commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-13 18:51:47 +02:00
Fr0zkaandClaude Opus 5 cfad3d1c18 perf(generation): gate the tile cache window by LOD; LOD0 back to switch-off speed
2de603c's tile-sized room cache cut worker CPU, but LOD0 generation was ~9% slower. The per-tile
split shows the loss was evaluation (more candidates per sample in a bigger window), not cache
building. Policy, measured per path and LOD:
- fused path: tile window at LOD >= voxel.TileCacheWindowMinLOD (default 3)
- op-stack path: tile window at LOD >= voxel.TileCacheWindowOpStackMinLOD (default 4)
- lower LODs keep the 4-chunk worker window
- voxel.SpatialIndex: -1 auto (off at fused LOD0, on elsewhere), 0 or 1

Proof caveat closed: VF_ComputeTerrainOpEnvelope adds authored terrain-op extents (pits, chimneys,
detail overrides, ...) to the invariance envelope. Ops beyond the proven 256-voxel extent, or
malformed/unloaded ones, force the legacy window, so window invariance holds for every asset.

Sampler fix: the leaf symbol kept pointers into a TMap that FindOrAdd can rehash while later
frames resolve; the leaf is now re-found by program counter after insertion.

Game path, two runs each, sampler off (Codex; checked against its report):
                       LOD0 request p50/p95    generation p50/p95    worker s
  static  optimized    106.3 / 175.9 ms        76.6 / 124.3 ms       39.2
  static  switch-off   106.3 / 173.0           75.9 / 124.1          58.5   (-33%)
  moving  optimized     79.4 / 158.5           72.4 / 123.7         113.7
  moving  switch-off    82.8 / 160.4           73.2 / 125.4         141.9   (-20%)
LOD0 is flat within noise static and slightly better moving; worker CPU keeps most of the gain.
Field exact: 841/841 per-tile triangle identity, export OBJ SHA-256 unchanged (b3e5f4c3..9b377),
capability 20,830 / 10,909.

Caution: compare these numbers only within this round. The machine is in daytime use, so
absolute figures drift between rounds.

Current LOD0 map (sampler, moving, inclusive): tunnel SDF 12.5% + tunnel core 12.3% (the same
tunnel in two coordinate spaces); passage carving 11.1%, structural posts 11.4%, native floor
4.1%, with floor projection at 8.9% inside them; cave warp ~8% exclusive. Development-build-only
overheads: TArray RangeCheck ~6.5%, per-sample FScopedTimer ~2.6%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-13 18:17:28 +02:00
Fr0zkaandClaude Opus 5 2de603cd77 perf(generation): size the room cache to the tile; spatial index; memo-key guard
Coarse tiles thrashed the per-worker 4-chunk room-cache window: 633 BuildChunkCache calls per
LOD3 tile and 1,062 per LOD4 tile. This round was started by one Codex session and finished by a
second, after the orchestrating session died at 06:06 and corrupted the first session's record.
The second session reviewed and built its predecessor's half-finished tree.

- voxel.TileCacheWindow (default 1): the room cache covers the requesting tile's footprint plus a
  fixed collect margin. It is set with TGuardValue around GenerateMesh and classify, so it never
  leaks to other work on the thread. Tile windows are capped at 64 chunks per side;
  unsupported/non-finite params fall back to the legacy window.
- Window invariance, argued by Codex: graph decisions depend on at most two bounded
  MaxTunnelLength hops, and primitives are culled only once their complete bounds are known.
  So the collect margin 2*MaxTunnelLength + max(room reach, pair/chain/tube reach) does not grow
  with the window. Caveat: oversized authored terrain-op radii are not yet in the envelope check.
- PrepareTunnelCoreCache reuses the graph, rebuilding only the exact support-column table for a
  crossed chunk.
- FChunkSDFSpatialIndex: ordered XY buckets over room/join/tunnel bounds; rejection stays exact
  and within-bucket order is preserved. Only 9-13% of candidates reach exact evaluation.
- static_asserts in VoxelStrateTypes.h: VF_STRATE_PARAM_FIELDS lists 83 fields, and their byte sum
  + 8 B padding == sizeof(FStrateGenerationParams). This closes the memo key's known gap (it
  catches omissions and size changes, not same-size replacements or padding-hidden fields).

Verified (game path, two runs each, sampler off):
- field exact: 841/841 per-tile triangle identity, 669,834 triangles; export OBJ SHA-256
  unchanged (b3e5f4c3..9b377); capability 20,830 / 10,909, connected
- cache builds: fused 139,187 -> 1,019 (-99.3%), op-stack 60,598 -> 879
- worker CPU: static 58.4 -> 40.8 s (-30%), moving 138.4 -> 121.2 s (-12%)

REGRESSION, stated plainly: with the switch on, LOD0 gets slower. Generation p50 is ~9% slower,
static 75 -> 82 ms and moving 71 -> 77 ms; request-to-ready p50/p95 is ~5-6% slower. This is
consistent across both runs of both cases. The thrash only lived at LOD2+ (LOD0 had 1.66 builds
per tile), so the next round gates the tile window by LOD and must bring LOD0 back to switch-off
speed while keeping the CPU gain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-13 15:06:24 +02:00
Fr0zkaandClaude Opus 5 2990009f3b perf(generation): memoise room player-fit points -- 3.8x less worker CPU in game
The sampler (8e40bdd) showed ~3/4 of in-game worker CPU inside BuildChunkCache ->
VF_FindPlayerFitPointForRoom, recomputed for the same rooms on every per-worker cache rebuild.
The search reads only the strate params, the room site, the strate Z bounds and the warp seed,
so its answer is pure. It is now memoised process-wide.

- The key is exact, 97 words: every FStrateGenerationParams field via VF_STRATE_PARAM_FIELDS
  (83 scalar fields, audited: no pointers or TArrays), the room centre/radii/hash/bOrigin,
  both Z bounds, the warp seed and a key version. The hash only picks a shard; lookup compares
  every word. A miss is the worst case, and eviction just recomputes the same value.
- 4096 sets x 4 ways, a per-slot reader/writer state, a per-set lock only on miss (single
  flight), 7.1 MB fixed. The search algorithm is unchanged.
- voxel.PlayerFitMemo (CVar + command line), default on. The logs confirm =0 reaches the path.
- Diagnostics: the SdfCacheBuild count moves to BuildChunkCache entry, per-tile cache_builds in
  the tile profile, tile coordinates in the startup trace, and memo stats logged at EndPlay.

Game path, two runs each, sampler off (Codex; checked against its report):
                      worker CPU     LOD0 ready p50 / p95      tiles applied
  static  off -> on   183.7 -> 48.2 s  (3.81x)   109 / 178 -> 103 / 172 ms   841 = 841
  moving  off -> on   256.9 -> 124.7 s (2.06x)   104 / 192 ->  78 / 157 ms   2226 -> 2348
Hit rate 99.95%, 463 real searches in the moving run, 0 evictions.

Field and capability: per-tile triangle counts are identical on/off for 841/841 tiles
(669,834 triangles), the export OBJ SHA-256 is unchanged (b3e5f4c3..9b377), and player-fit
20,830 / walk-reachable 10,909 are unchanged.

Next, measured by this round: coarse tiles thrash the 4-chunk room-cache window. BuildChunkCache
calls per tile are 1.7 at LOD0, 18 at LOD2, 633 at LOD3 and 1,062 at LOD4.

Known gap, closed in the next round: nothing yet asserts that VF_STRATE_PARAM_FIELDS covers every
FStrateGenerationParams field. A field added to the struct but not to the list would be missing
from the memo key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZS94UH8bSsBeg5sZaxe2Y
2026-09-13 04:48:44 +02:00
Fr0zkaandClaude Opus 5 8e40bdd92c measure(perf): in-process stack sampler; the game spends ~3/4 of worker CPU on room player-fit
A diagnostic stack-sampling profiler, off by default. A sampler thread captures the stacks of
registered generation workers with FPlatformStackWalk::CaptureThreadStackBackTrace every N us and
tags each sample with the tile's LOD. Symbolisation (inline frames included) happens after the
run, into a bounded CSV plus a summary. Registration is per tile task, never per sample; when the
sampler is off it costs one relaxed atomic load. Game: -voxel.SampleStacks=<us>. Commandlet:
-samplestacks[=us]. The streaming latency line now also prints applied tile, visible tile and
triangle counts.

Instrument checks (Codex, PERF-SAMPLED.md):
- a planted 20 ms busy loop was found at 10.5% inclusive, the expected share, then removed
- closure: 193k static / 251k moving samples, 0 capture failures, <0.75% wait frames,
  leaf resolved for 82-84%
- static game output identical with the sampler on or off: 841 tiles, 447 visible, 669,834
  triangles
- overhead at 1 ms: +4.7% static worker CPU; moving worker CPU within noise, moving timing +4-5%.
  So trust large shares, not single-digit deltas.

The finding, on the game path (the owner's instruction: profile the game, not the export):

                                all-LOD inclusive   static   moving
  BuildChunkCache                                   79.5%    80.5%
  VF_FindPlayerFitPointForRoom                      73.4%    74.0%

About three quarters of in-game worker CPU goes to player-fit searches for rooms. They are
recomputed on every per-worker room-cache rebuild, and coarse tiles span many cache windows. At
LOD0 the costs are the cave warp (3 Perlin per sample), the swept tunnel evaluated both in the
SDF and in EvaluateTunnelCoreWorld (~12% inclusive each, duplication confirmed), and the passage
structural tail (~11-14%). Candidate AABB rejection is cheap (1.5-2.2%). The 74-field params copy
does not show up as a separate cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZS94UH8bSsBeg5sZaxe2Y
2026-09-13 03:32:29 +02:00
Fr0zkaandClaude Opus 5 c65b015f01 perf(streaming): outer tile classifier off by default; delete mode 2
Owner-approved, on the A/B measured in 83a0e4d: the classifier plus exact validation cost more
than it saved on the game path. With it off, ready p95 is 182 vs 190-195 ms, generation p95 is
125-127 vs 139-141 ms, and density calls drop 18%.

- GVoxelForgeOuterClassifierMode defaults to 0. Mode 1 stays reachable via
  voxel.OuterClassifierMode=1 as an A/B instrument; the clamp is now 0..1.
- Mode 2 (validation samples reused as the mesher core grid) is deleted: the reuse grid,
  FVoxelMesherCoreDensityGrid, GenerateMesh's ReusableCoreGrid parameter, bValidationCoreReused,
  TotalValidationCoreReuseTiles and its log field. It could not fire: it reused the grid only
  when validation disproved a uniform verdict, which never happened, and Mixed tiles never
  validate. The mode-1 validation loop exits early again on the first disagreeing sample.
- All three GenerateMesh call sites checked; the density-count pointer binds after BandVoxHi.

Verified by Codex, checked by me against the logs:
- build ok; the measured process loaded the staged DLL, SHA-256 1024D8CF..04D
- export OBJ SHA-256 unchanged: b3e5f4c3..9b377
- a run WITHOUT the override prints outer_classifier_mode=0 validation_density_calls=0,
  proving the new default took effect
- 841/841 tiles have identical triangle counts between mode 0 and mode 1. The 394 tiles mode 1
  skipped as uniform produce 0 triangles in mode 0, so the strate band cut (applied by the
  mesher, not by validation) was not hiding geometry.

Docs: ARCHITECTURE 8.10, CODEMAP (GenerateTileResult, ClassifyTile) and fable-idea T1.d record
that streaming no longer classifies by default. WORK-NEXT item 1 records the owner's playtest:
he can no longer fall through the world.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZS94UH8bSsBeg5sZaxe2Y
2026-09-13 02:06:24 +02:00
Fr0zkaandClaude Opus 5 83a0e4d85c measure(classifier): outer classifier is net-negative in game; nested 3.5x IS real
Three-mode outer-classifier A/B on the headless game path, nested refinement off in all
modes, clean and diagnostics off. Runs made by the forked Codex session; the table was
assembled and checked by me from its logs (Saved/OuterABMode*_20260912.log,
OuterABMove*Headless_20260912.log).

  static  ready p95   mode0 182.4-182.7   mode1 189.7-194.8   mode2 190.0-194.7 ms
          gen p95     mode0 124.9-127.0   mode1 139.1-140.6   mode2 140.3-141.3 ms
          worker CPU  mode0 184.9-193.5   mode1 201.2-202.3   mode2 201.2-201.3 s
          density     mode0 18.05M        mode1 21.89M        mode2 21.89M
  moving  ready p95   mode0 198.9         mode1 208.0         mode2 210.7 ms
          density     mode0 57.7M         mode1 69.6M         mode2 69.6M

The outer classifier costs more than it saves: with it removed (mode 0), every latency
figure improves and density calls drop by about 18%. Mode 0 also cannot remove geometry,
because it skips nothing.

Mode 2 (reuse the validation samples as the mesher's core grid) recorded 0 reuse tiles in
every run. That is by design, not a bug: the grid is only reused when validation DISPROVES
a uniform verdict, and it disproved none. Mixed tiles, which are the ones that need a
grid, never run validation at all.

Recommendation recorded in WORK-NEXT.md, pending the owner: default the outer classifier
mode to 0. The code default is NOT changed in this commit.

CORRECTION to f204413, whose message says the nested classifier's 3.5x could not be
reproduced. That was my error. I passed the streaming console variable
-voxel.UseBlockEarlyOut=1 to the export commandlet, which reads its own -blockearlyout=
argument (VoxelForgeExploreCommandlet.cpp:381, :1093), so both of my runs had nested
refinement off. Re-run with the right flag:
  -blockearlyout=0   0.553 / 0.572 s
  -blockearlyout=1   2.036 / 2.060 s     3.6x, geometry SHA-256 b3e5f4c3..9b377 identical
The 3.5x was real. Turning nested refinement off in streaming (f204413) was worth it,
and Sol's reading of the table was right where mine was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZS94UH8bSsBeg5sZaxe2Y
2026-09-12 22:02:53 +02:00
Fr0zkaandClaude Opus 5 667da8df14 docs(work-next): promote item 4 to performance architecture
Written by the classifier-fix Codex round before it was closed. The classifier is a
second evaluator -- interval proofs, exact certificates and final-field validation --
beside the canonical lowered one, and the runtime can validate the same tile again
before meshing. Any classifier kept long-term must consume the lowered representation
and reusable density data.

That round's other uncommitted edit was NOT kept: it flipped
GVoxelForgeOuterClassifierMode 1 -> 0, turning the outer classifier off by default.
That was A/B setup for an unfinished experiment, not a measured decision, so the
committed default (1, classifier on) is restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9DLqyXrJxG4TojzDrSmVw
2026-09-12 13:34:28 +02:00
Fr0zkaandClaude Opus 5 f2044135ea fix(mesher): stop forcing the nested block classifier on -- but I cannot reproduce the 3.5x
The code contradicted its own documentation. VoxelMarchingCubesMesher.h:150 says to leave
the optional 8^3-block refinement DISABLED in streaming, "repeating a proof on ..." --
and VoxelWorld.cpp forced `Mesher->bUseBlockEarlyOut = true` regardless.

It is now off by default, behind `voxel.UseBlockEarlyOut` (CVar + command line), so the
capability stays available for the focused diagnostics where it was measured to help.

⚠️ THE HEADLINE NUMBER DOES NOT REPRODUCE, AND I AM NOT CLAIMING IT
Sol's second pass read a 3.5x from the attribution table:
    classifier disabled    587.991 ms
    classifier enabled   2,056.596 ms
My own clean A/B on this flag, quiet machine, 3 runs each:
    nested OFF   0.597 / 0.613 / 0.607 s   mean 0.606
    nested ON    0.637 / 0.599 / 0.605 s   mean 0.614
Identical within noise.

So either the 2,056 ms control exercised more than this flag does, or the 3.5x lives on
a path the 128^3 export does not take. PERF-ATTRIBUTION.md describes that figure as "the
separate classifier control", which is not obviously the same lever. Until the game-path
A/B is run, treat the 3.5x as UNCONFIRMED.

What this commit is worth on its own: the code now does what its documentation says, the
behaviour is switchable rather than hardcoded, and geometry is unchanged --
SHA-256 b3e5f4c3..9b377 identical with the flag on, off, and against bb8ec51.

⚠️ An earlier measurement pass in this round was contaminated: a local llama-server was
using 5 GB and competing for cores. Those runs came out 0.67-0.78 s with the on/off
ranges overlapping. Discarded and re-measured on a quiet machine; only the clean numbers
above are reported. Worth recording as a reminder that the machine is part of the
instrument.

NOT DONE -- the round was OOM-killed partway (same llama-server):
- the three-way outer-classifier A/B (no outer / current+discarded validation / ⭐ outer
  with validation samples REUSED as the mesher core grid). That third mode is the
  interesting one: exact validation currently costs 33^3 = 35,937 density calls whose
  results are then DISCARDED, while the mesher then fills 35^3 = 42,875. A successful
  uniform verdict saves at most 16.18%, and a Mixed verdict saves nothing.
- game-path measurement of any of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-12 13:19:42 +02:00
Fr0zkaandClaude Opus 5 bb8ec51939 measure(perf): the remaining 4.3x is ALL density evaluation -- and the classifier costs 3.5x in game
Measurement only; no density or meshing decision changed. Full table in
PERF-ATTRIBUTION.md. The profiler self-check finally passes honestly: profiled 606.128
vs unprofiled 602.341 ms, **+0.63%** (it used to inflate 11.5x).

THE ATTRIBUTION, and it is almost embarrassingly one-sided

  mesh ledger, additive, sums to the clock exactly (sum_matches_mesh = true)
    shared density-grid fill    590.923 ms   97.4913%
    tile parallel wall           10.972 ms    1.8102%   cells, interp, normals, streams
    tile merge                    4.232 ms    0.6982%
    pre-tile setup / prep         0.002 ms    ~0%
    remainder                     0.0001 ms   ~0%
    ------------------------------------------------
    total                       606.128 ms

  GetDensityAt parent: 580.184 ms worker-wall, nested inside that 590.923 ms.

⇒ **The mesher is exonerated. Marching cubes, vertex interpolation, normals and stream
building are 2.5% combined.** The whole 4.3x is density evaluation, which is where three
of us have been looking and none of us had proven it.

WHAT IS ACTUALLY BEING DONE, per 2,248,091 samples
  rooms        11,099,891 candidates ->  1,730,920 evaluated
  tunnels      28,951,420 candidates ->  3,462,565 evaluated
  tunnel-core  28,951,420 candidates ->  1,881,812 evaluated
  ~31 candidates SCANNED per sample, ~3.1 evaluated
  fused detail ran on 1,079,777 and skipped 1,168,314 samples (52.0%)
  software tunnel cache: 2,248,043 hits / 48 misses -- not the problem

The post-stack structural tail, which I have suspected twice, is small: StructuralTail
20.9 ms CPU / 1.9 ms wall, DensityStructuralPosts 21.9 / 2.0, PassageStructuralPosts
10.5 / 1.0 -- all nested samples against a 590.9 ms wall.

⭐⭐ AND THE FINDING THAT MATTERS MOST FOR THE GAME
  clean export, classifier absent       587.991 ms
  same export with the classifier       2,056.596 ms
The classifier makes generation **3.5x slower**, it is ABSENT from the export path we
have benchmarked for five days, and the GAME PATH USES IT. Its control route runs
211,289 support-column builds, 2,171,763 candidates, 1,370,314 floor queries and
2,296,243 checks -- none of which the fused export route executes at all.

⇒ Every optimisation this week was measured on a path that skips the single largest
cost the player actually pays. That is a measurement-design failure and it is mine.

Also newly visible: the full export wall is 1,004.557 ms, of which mesh is 606.128 ms
(60.3%) and OBJ WRITING is 394.273 ms (39.2%). My "0.556 s" figures have always been
mesh_seconds alone.

One real hot-path improvement landed with the instrumentation: AreCountersEnabled()
returned an unconditional `true`, so counter hooks were live in shipping builds. It is
now a relaxed load gated on diagnostic mode.

VERIFIED BY ME: build 0 errors; export 0.552 / 0.555 / 0.566 s (cf0319d was
0.563-0.566, unchanged); geometry SHA-256 b3e5f4c3..9b377 UNCHANGED.

Honest gaps, as reported: exact per-operation shares inside the density stage, physical
cache-miss counters, and a rebuilt 34f06df binary for a true A/B were all unavailable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-12 08:45:26 +02:00
Fr0zkaandClaude Opus 5 cf0319d480 perf(streaming): item 3 bought nothing, item 5 removed dead weight -- both worth knowing
Two WORK-NEXT items, one null result and one confirmed removal. Item 4 untouched.

ITEM 3 -- CANCEL OBSOLETE WORK AND PRIORITISE THE FLOOR: NULL RESULT
Clean -nullrhi, diagnostics off, 800 cm/s, 200 m of travel:

                          before            after
  request->ready p95      0.369117 s        0.367612 s
  queue wait p95          0.066949 s        0.064483 s
  generation p95          0.287926 s        0.283642 s
  p95 travel distance     2.952938 m        2.940894 m
  obsolete aborts                 0                12

Flat. Aborting obsolete tiles, sorting from the true pawn position and prioritising the
occupied/next tile changed request-to-ready by 0.4%.

⭐ AND THE NULL RESULT IS ITSELF THE FINDING. Look at the split: queue wait is 65 ms of a
368 ms p95; generation is 284 ms. **The queue was never the problem, so reordering it
cannot help.** Only 12 tiles in a 200 m run were obsolete enough to abort. The floor is
late because generation is slow, not because work is badly ordered -- item 3 establishes
that by exclusion, which is worth more than the 0.4%.

The machinery is kept: it is correct, it costs nothing, and it will matter once
generation is fast enough for ordering to be the binding constraint.

ITEM 5 -- VERDICT CACHE REMOVED. It was pure overhead.
Clean moving-session audit:

  outer  calls/probes/hits    2,109 / 2,109 / 0
  nested calls/probes/hits   19,904 / 19,904 / 0
  enclosing hits                              0
  stores 15,877, evictions 15,365

⇒ It stored 15,877 proofs, evicted 15,365 of them, and hit ZERO times. A moving player
visits unique regions; the cache was filling and thrashing a 512-entry table under a
global lock for no benefit. Same-binary A/B: request p95 0.367645 vs 0.365978,
generation p95 0.285559 vs 0.285992 -- neutral within noise, so its cost was the lock and
the linear scans rather than time we can see.

Removed: the global cache, its lock, the entry table, the invalidation path, the
cache-only profiler fields and the synthetic probe. The proof classifier stays.

⚠️ I asked for this cache two days ago on the strength of a commandlet measurement that
re-visited the same region repeatedly. That access pattern is nothing like a moving
player, and I should have said so when specifying it.

VERIFIED BY ME: build 0 errors; export 0.563 / 0.565 / 0.566 s (35de59b was 0.545-0.563,
flat as expected); geometry SHA-256 b3e5f4c3..9b377 UNCHANGED; capability 20,830 /
10,909, connected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-12 06:49:20 +02:00
Fr0zkaandClaude Opus 5 35de59bac5 perf(opstack): fused evaluator -- 1.05s to 0.56s, byte-identical, and the ~70% early-out is back
WORK-NEXT item 2. The authored operator graph is lowered at chunk-cache build time into a
fused scalar evaluator for canonical TunnelNetwork/Underwater stacks. Operators are
untouched; this is a lowering, not a de-composition. Custom recipes, composer/lateral
stacks and other archetypes stay on the interpreted block path, selected by a build-time
EVoxelOpFusedEvaluator marker with `-voxel.UseFusedEvaluator=0` to A/B it.

⭐ MY OWN A/B, same build, 3 runs each:
    fused         0.545 / 0.563 / 0.561 s    mean 0.556
    interpreted   1.066 / 1.036 / 1.033 s    mean 1.045
                                             -46.8%,  1.88x

  geometry SHA-256 b3e5f4c3..9b377 IDENTICAL across fused, interpreted, and 081cc5e.

⚠️ Codex reported this as 5.88% (2.121 vs 2.253 s). Its figures come from its staged
harness with diagnostics enabled, which inflates both sides and compresses the ratio.
Measured clean it is 1.88x. This is the fourth time a profiled number has misled us --
the instrument is the instrument, not the thing.

THE MECHANISM IS RESTORED, which is the part that matters
The audit's finding was that the old path short-circuited the cave detail suite per
sample when bNearCaveSurface was false -- historically ~70% of deep-solid samples -- and
that we had traded that for per-block pruning.

  fused export skipped detail on  1,130,230 / 2,490,664 samples = 45.38%
  capability walk skipped                                        69.26%

69.26% matches the historical ~70% almost exactly. The early-out is genuinely back.

Capability exact: player-fit 20,830, walk-reachable 10,909, Connected.

WHERE THIS LEAVES THE 8x
  pre-op-stack 34f06df   0.130 s
  now                    0.556 s      4.3x, down from ~8x

So roughly half the regression is recovered, and the remaining 4.3x is NOT operator
interpretation -- that hypothesis is now tested and spent. Codex's own read: the rest sits
in "common structural/passage work and meshing". Three of us predicted the interpretation
overhead was the whole story; it was about half.

Build 40 actions, 0 errors, loaded-module verified. Six plugin source files.

Cumulative on this region: 342.2 s -> 0.556 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-12 04:30:46 +02:00
Fr0zkaandClaude Opus 5 0388c7dede docs: Sol's read-only audit, verbatim
The full 34 KB assessment that WORK-NEXT.md was built from, kept in the repo so any
future session has the reasoning rather than my summary of it. Read-only audit by
gpt-5.6-sol at xhigh; nothing was modified when it ran.

Its two-sentence verdict:
  'The operator abstraction leaked into the innermost loop, and streaming treats
   work submitted/applied as safe to stand on.'

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-12 03:42:31 +02:00
Fr0zkaandClaude Opus 5 081cc5e459 fix(streaming): collision-ready gate -- the pawn waits for a COOKED floor, not a submitted one
WORK-NEXT item 1, plus all three of item 6. Item 2 (the fused evaluator) untouched as
instructed.

⭐ RMC DOES EXPOSE WHAT WE NEEDED, so the gate rests on a real signal:
  OnCollisionBodyUpdated                              (delegate -- diagnostic)
  SetCollisionConfig() -> TFuture<ERealtimeMeshCollisionUpdateResult>  (AUTHORITATIVE)
RealtimeMeshComponent was READ ONLY, per the owner's grant. Nothing there was modified.

THE INVARIANT
Until now nothing guaranteed a tile the player occupies has collision. apply_s ended at
mesh SUBMISSION; RMC cooks asynchronously and we recorded no completion, so no
measurement this project has ever taken said when physics could actually collide.

  - per-tile CollisionReadyTiles state, with stale-callback protection
  - HandleTileCollisionCookComplete driven by RMC's cook future
  - pawn gated at BeginPlay and pre-physics on the FEET-SUPPORT tile -- deliberately not
    the actor-centre tile, because the floor that catches a falling pawn is what must be
    cooked -- plus the predicted movement path and solid/air traversal
  - movement-component state saved/restored, so a temporary stream wait cannot
    permanently change a character's movement mode
  - new trace fields: mesh_submit_to_cook_s, collision_request_to_cook_s,
    body_event_to_future_s -- three boundaries where we had one blurred number

ITEM 6, all three:
  - LoadedTiles is now recorded only after SUCCESSFUL mesh submission, and a failed
    mesh init retries. Previously a failed init left a tile marked loaded forever -- a
    permanent hole.
  - origin spawning fixed (the zero-position guard made a pawn at exactly
    FVector::ZeroVector indistinguishable from "no pawn" -- and the owner spawns at 0,0)
  - exact core-lattice validation for uniform whole-tile verdicts, which previously had
    no safety net while block verdicts did

⚠️ I FIXED TWO COMPILE ERRORS MYSELF rather than spend another Codex round on them:
`GetMovementMode()` / `GetCustomMovementMode()` do not exist on
UCharacterMovementComponent -- MovementMode and CustomMovementMode are public fields.
Two lines. Codex did not build this round, so it could not have caught them.

VERIFIED BY ME: build 0 errors after the patch; export 1.022 / 1.055 s (aa62971 was
1.034 / 1.089); geometry SHA-256 b3e5f4c3..9b377 UNCHANGED -- generation is untouched,
as it should be for a streaming-lifecycle change.

⚠️ UNVERIFIED AND IMPORTANT: the submission-to-cook gap is still unmeasured -- Codex had
no live process to measure it in. The instrumentation exists and will report it on the
next play session. And the GATE ITSELF IS UNTESTED IN MOTION: if a support tile never
becomes collision-ready, the pawn stays frozen rather than falling. That trade is
deliberate, but the owner should watch for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-12 03:38:58 +02:00
Fr0zkaandClaude Opus 5 58bdfb32de docs: WORK-NEXT.md -- the queue after Sol's read-only audit
Ordered by importance then size. Sol (read-only, xhigh) audited experimental @ aa62971
against main and delivered the two-sentence diagnosis this queue is built on:

  "The operator abstraction leaked into the innermost loop, and streaming treats
   'work submitted/applied' as 'safe to stand on.'"

Two corrections it made to us, both recorded in the doc:

- The fall-through is NOT a removed correctness barrier. main has no spawn gate
  either, marks tiles loaded before applying, and collides only LOD0. It was fast
  enough to hide the race. The owner's evidence was real; the inference was not.
- The loop inversion did not recover the 8x because it traded PER-SAMPLE
  short-circuiting (~70% of deep-solid samples skipped the cave suite) for per-block
  pruning. The fix is lowering the operator graph into a fused evaluator, not more
  dispatch removal.

It also found that RMC cooks collision ASYNCHRONOUSLY and we never record
cook-complete -- apply_s ends at submission, so nothing we have ever measured says
when physics could actually collide.

Reading Plugins/RealtimeMeshComponent is now permitted for item 1, READ ONLY, by the
owner's decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-11 22:01:47 +02:00
Fr0zkaandClaude Opus 5 aa62971047 tools(trace): Play-to-steady-state timeline -- startup is 83ms, the other 8.2s is tiles
Measurement only, no optimisation, per the instruction. First time anyone has looked at
the path BEFORE LoadTile, and first time any measurement has used the owner's REAL
assets instead of the commandlet's synthetic strates.

Trace: Saved/StartupTraceRealFinalTrace.startup.jsonl -- 873 JSONL records, 268 KB,
841 tile records, bounded at 4096 with zero suppressed. Structured, own file,
stream-parseable; the 13 GB log pile taught that lesson twice.

TIMELINE
  BeginPlay                    45.585 ms
  StrateManager.Initialize     29.018 ms
  GeneratePassages              6.628 ms   (5 passages)
  first BuildDesiredTiles       2.710 ms
  ------------------------------------------
  all startup stages           ~83 ms
  BeginPlay -> steady state     8.216 s
  process launch -> stop       20.164 s

⚠️ My hypothesis was wrong: I expected layout resolution or GeneratePassages to be a
hidden one-off cost. They are 83 ms combined. **The 8.2 seconds is tiles, all of it.**
A further ~2.05 s sits between BeginPlay finishing and the first desired-set build, and
the trace attributes that to engine/map/player startup, not to us.

REAL ASSET RESOLUTION -- two things worth the owner's attention
  settings                /Game/VoxelForge/DA_Settings, seed 0
  ⭐ five slots, ALL resolving to /Game/VoxelForge/DA_Strate3
  InterStrateGapChunks    2
  MaxClipLevel 4, ClipRadius 3, RenderDistanceChunks 0
  bFarSheetRing true, but NO sheet tiles exist because render distance is 0
  ⭐ bEnableDensityVolume FALSE -- already off, no volume object, no GPU upload

DA_Strate1 (the 10.7 KB surface strate) and DA_Strate2 (rooms + water) are not in play
at all. Every strate in the world is Strate3.

THE DESIRED SET -- 841 tiles, and the shape is the story
  LOD0  343 tiles    8 m each     56 x 56 x 56 m
  LOD1  218         16 m         112 x 112 x 80 m
  LOD2  120         32 m         224 x 224 x 96 m
  LOD3   80         64 m         448 x 448 x 128 m
  LOD4   80        128 m         896 x 896 x 256 m

⭐ LOD4 covers 896 x 896 x 256 m -- about 205 MILLION cubic metres of cave -- and its
verdicts are 0 AllAir / 0 AllSolid / 80 Mixed. Not one LOD4 tile can be skipped, and
each costs 127.5 ms p50 / 291.6 ms max to generate. That is roughly 12 s of CPU spent on
geometry nobody can see from inside a cave.

TILE DISTRIBUTIONS (p50 / p95 / max ms)
  LOD  tiles  verdicts A/S/M   req->ready        generation        mesh
   0    343   98/123/122    44.9/420.8/819.5    9.6/358.2/767.6   0/341.6/726.1
   1    218   49/83/86      31.1/148.9/188.6    8.7/134.3/164.2   0/117.8/151.1
   2    120   0/39/81       34.0/61.6/76.9     10.9/43.6/63.7   9.9/40.4/60.5
   3     80   0/2/78        46.4/85.9/96.3     38.5/80.7/87.8  31.0/67.5/70.7
   4     80   0/0/80       148.2/272.8/304.2  127.5/253.8/291.6 127.4/253.8/291.5
  totals: 147 AllAir, 247 AllSolid, 447 Mixed, 669,834 triangles
  apply never exceeds 0.43 ms at any level
  zero classifier-cache and region-cache hits on a fresh process, as expected

⇒ The two costs are LOD0's 122 meshed tiles (~360 ms p95 each) and LOD4's 80
unskippable tiles (~150 ms each). Apply, queue and startup are all noise beside them.

VERIFIED BY ME: build 0 errors; export 1.034 / 1.089 s (5ddd966 was 1.013-1.075, so the
instrumentation does not distort); geometry SHA-256 b3e5f4c3..9b377 UNCHANGED. Game peak
RSS 2.07 GB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-11 20:03:24 +02:00
Fr0zkaandClaude Opus 5 5ddd96664d perf(opstack): invert the loop + prune per block -- 1.45s to 1.02s, byte-identical
The architecture round. Loop inverted from per-sample-per-op to per-op-per-block,
operators pruned per block by interval bounds, profiler moved out of the hot loop.

  my build, 3 runs:  1.013 / 1.036 / 1.075 s   (adb34df was 1.418 / 1.445 / 1.458)
                     mean 1.041 vs 1.440  =  -28.5%, 1.39x
  geometry SHA-256   b3e5f4c3..9b377  IDENTICAL to adb34df

  pruning removed 20,999 / 31,320 operator-block visits = 67.1%
  surviving ops per block:  LOD0 6.63  LOD1 6.30  LOD2 5.23  LOD3 4.41  LOD4 3.89
                            (out of 22)

⚠️ I PREDICTED THE DISPATCH OVERHEAD WAS "THE 10x". IT WAS NOT.
864,688 virtual dispatches per chunk sounded decisive and is worth ~20-28%. Real, and
worth having, but the order of magnitude is elsewhere. That is my fourth wrong
performance prediction in this project, and the pattern is identical each time: I reason
from a call site's shape instead of measuring what the data actually costs.

⭐⭐ AND THE "5-20x GAME/EXPORT GAP" WAS MY ARITHMETIC ERROR. IT DOES NOT EXIST.
I told the owner the largest unexplained number in the project was that export does
22 ms/tile while the game does 115-500 ms. That 22 ms came from dividing 1.4 s of WALL
CLOCK by 64 tiles -- ignoring that 15 workers ran them in parallel. Per-job means on the
same build:

  nonshared block export   447 ms/job
  shared-grid export       261 ms/job
  game LOD0 meshing        253 ms/tile p50

They are the same. The game is not anomalously slow; generation simply costs ~250 ms per
LOD0 tile. The shared grid's benefit is real but modest, and a neighbourhood cache is NOT
justified by these numbers -- I would have had Codex build one on the strength of a
division error.

GAME PATH, final build (841 profiles, 447 applies)
  meshed p50/p95/max   LOD0 253/521/697 ms   LOD1 79/130/135   LOD2 19/42/51
                       LOD3 36/64/81         LOD4 93/238/271
  LOD0 request-to-ready  p50 47.7 ms  p95 389 ms  max 798 ms
                         worker queue p95 84 ms, generation p95 344 ms
  at 6 m/s: p95 = 2.3 m of travel, max = 4.8 m
  empty LOD0 tiles never enter the mesher; classifier p50 8.7 ms, p95 17.2 ms
  apply is not the bottleneck: p50 0.107 ms, p95 0.161 ms, max 0.296 ms

⭐ THE BINDING CONSTRAINT IS COMPUTE, NOT POLICY:
  logical cores 16, automatic core cap 14, effective generation cap 14, CAP HIT.
Apply limits never bind. We are saturating the machine, which means the remaining wins
must come from doing less work, not from scheduling it better.

NOT DONE, with reasons rather than excuses:
- Coarse-first LOD refinement: coarse tiles carry NO COLLISION, so streaming them first
  would let the player fall through. Needs a conservative collision proxy or an L0
  handoff first. Correct refusal.
- Heading prefetch: desired tiles are sorted by distance only (VoxelWorld.cpp:1748).
  Velocity-weighted prediction with a bounded budget and expiry is the shape, not built.

A failed experiment, reported rather than buried: eagerly filling every classifier leaf
with EvalBlock regressed export to 3.2-3.3 s because it defeated the classifier's early
exit. Removed.

Build 43 actions, 0 errors. Peak RSS under 2.1 GB. Sources: Epic's Tasks System and
Level Streaming docs, Lorensen-Cline.

Cumulative on this region: 342.2 s -> 1.02 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-11 18:09:12 +02:00
Fr0zkaandClaude Opus 5 adb34dffa9 feat(passages): passages are D-tunnels now -- legacy support writer fires ZERO
The owner was right and said so plainly: "the tunnels should be a flat D with then a
bit of noise added, as it's most basic shape -- this alone should make all tunnels not
over X steepness to be walkable." Codex had reached the same conclusion from the other
direction ("the native fix is to author the inter-strate passage floor profile during
passage construction"); it had measured the current position while he described the
destination. This is that refactor.

Walkable inter-strate passages now bake their floor profile and bounded relief during
GeneratePassages. Evaluation composes a D-shaped SmoothMax floor, with final MC
ownership after the generic writers -- the same ordering fix that made the tunnel floor
native in 2260c4d.

  native compositions      7,629
  legacy support fires         0
  floor-profile fallbacks      0

  player-fit cells    20,804 -> 20,830   (+26)
  walk-reachable      10,884 -> 10,909   (+25)
  climb-reachable              10,911
  connectivity        arrival -> departure, Connected
  largest component           10,909

⭐ Support-ON and support-OFF now produce IDENTICAL export geometry (hash 07C14005,
82,273 v / 153,346 t, mesh 2.981 s vs 2.960 s). The writer that previously cost
3,359 reachable cells when disabled is now inert -- the shape provides what it used to
patch. That is the ~17.43% of banded pixels from 4f8b4a9 addressed at the source.

The legacy writer stays only as a fail-closed fallback for a malformed/non-native
passage. It is no longer load-bearing.

PUSHBACK, and it is the right restraint: only passages carrying
`bWalkableTunnelContract` -- currently the authored `Straight` style -- receive the
guaranteed D-floor. Spiral, Cascading and Worm styles do NOT silently gain a
walkability promise without a separate gradient proof. Correct: a guarantee nobody
proved is worse than no guarantee.

ROUGHNESS -- the owner's report, measured properly this time and CLOSED
He confirmed twice that changing roughness type/frequency changed generation time from
startup. The earlier sweep was a single 128^3 export at LOD0 and could not have seen
it, so this one used the game path, fixed streaming centre, identical 850-tile workload:

  skip rate   LOD0 70.8%  LOD1 57.3%  LOD2 32.6% (Cellular 31.8%)  LOD3 2.5%  LOD4 0%
  gen p95 s   FBM .472/.369/.083/.087/.145   Ridged .473/.365/.084/.084/.143
              Mixed .474/.358/.084/.090/.149  Cellular .479/.360/.083/.089/.161
  FBM frequency 0.025-0.2: flat across every LOD

No classifier-bound collapse by type or frequency. The startup difference was
workload/streaming behaviour, not a type-specific bound defect. No unsafe bounds change
was made to chase it.

⚠️ Worth noting for later: the skip rate collapses with coarseness -- 70.8% at LOD0 to
0% at LOD4. That is expected (a LOD4 tile spans a huge volume and almost certainly
contains something) but it means the classifier does nothing for exactly the tiles that
cover the most ground.

VERIFIED BY ME: build 0 errors; export 1.418 / 1.445 / 1.458 s (4f8b4a9 was
1.353 / 1.430 / 1.432); 153,346 triangles. Peak game RSS ~2.05 GB.

Sources consulted: Epic's UE task-systems documentation for the worker/game-path
distinction, and Lorensen-Cline for the discrete isosurface context.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-11 16:29:49 +02:00
Fr0zkaandClaude Opus 5 4f8b4a9a27 fix(gen): the phantom terracing is the PASSAGE support writer -- plus a region verdict cache
The owner played the build and reported four things. Three are answered with evidence,
one is disproven, and my own leading hypothesis was wrong.

⭐ 1. THE PHANTOM TERRACING IS NOT MARCHING-CUBES ALIASING
He reported stairs surviving with bTunnelFloorTerracingEnabled = false. I suspected MC
lattice aliasing on a near-planar floor; I was wrong. An A/B over the same mesher,
counted in pixels:

  graph-tunnel floor removed     2,813 / 147,456 px changed   1.91%
  passage support removed       25,695 / 147,456 px changed  17.43%

⇒ The bands are the INTER-STRATE PASSAGE SUPPORT WRITER, which is independent of the
tunnel terracing flag. That is exactly why turning terracing off did nothing: he was
disabling the wrong system. FCaveTerraceMod is inactive (TerraceStepHeight=0) and was
never involved.

⚠️ It cannot simply be switched off. With it disabled:
  fit 20,804 -> 20,511, walk-reachable 10,884 -> 7,525, components 260 -> 272.
Route still connected, but a third of reachable space gone. It stays as a correctness
backstop. The native fix is the same pattern we just applied to tunnels: author the
inter-strate passage floor profile during passage CONSTRUCTION. Codex judged that
refactor too large to risk this round and said so rather than attempting it.

MC aliasing remains theoretically possible at shallow angles (Lorensen & Cline 1987),
but the same-mesher A/B proves our writer dominates. Dual Contouring (Ju et al.) was
considered and rejected as a disproportionate mesher/topology change.

2. THE ROUGHNESS COST DEFECT DOES NOT EXIST -- my hypothesis, cleanly disproven
  FBM 2.91s / 66.92% skips · Ridged 2.85s / 66.53% · Mixed 2.92s / 66.82%
  Cellular 2.99s / 66.43% · FBM frequency 0.025-0.5: 2.88-2.99s, 66.80-67.04%
I predicted a shared bound across noise types collapsing the classifier. The code
already carries separate conservative bounds per type. No roughness code was changed.
⇒ Whatever the owner saw is not steady-state export cost. Most likely the in-editor
regeneration a parameter change triggers, which is the full-rebuild cost, not the
per-sample cost. Worth confirming with him.

3. ROOM MOUTH RISE -- his idea, implemented, off by default
  RoomMouthRiseBlendVoxels, default 8.0
  blend 8   fit 20,804  reach 10,884  40,429 v / 75,416 t   capability-NEUTRAL
  blend 24  fit 20,792  reach 10,874  40,337 v / 75,232 t   REJECTED, loses capability
Relief preserved at blend 8 (dihedral p50 2.5584, p90 33.7191). The chosen camera did
not see a mouth bench, so no visual claim is made.

4. ⭐ VERDICT CACHE WITH REGION REUSE -- answers "why re-prove what we proved?"
  130 classification calls -> 1 store, 129 hits, of which 128 ENCLOSING-REGION hits.
The region level is the one that matters: deep rock is empty thousands of chunks at a
time, and one proof over a large box now serves all of them.

Soundness, which is the whole risk (a stale "empty" silently deletes geometry):
512-entry bounded LRU; stores ONLY AllAir/AllSolid, never Mixed; key includes seed,
layout/lifetime id, params fingerprint, diff version, density/seal settings, support
mode, lattice step, origin and extent; dynamic season/composer/terrain-op/biome
configs FAIL CLOSED and bypass reuse. Deliberately session-resident, not on disk --
cross-process persistence cannot serialise all asset/layout/edit state safely yet.

VERIFIED BY ME: build 0 errors; export 1.353 / 1.430 / 1.432 s (2260c4d was
1.320 / 1.424 / 1.439); geometry SHA-256 b3e5f4c3..9b377 IDENTICAL to 2260c4d.
Peak RSS ~1.75 GB. Determinism repeat equal.

⚠️ My composer hypothesis was also wrong: I suspected
`bTunnelFloorTerracingEnabled = CategoryRng.FRand() < 0.75f` in the composer was
overriding his setting. It is not the cause here. The design question it raises is
still open and worth the owner's view: should a composer roll be allowed to overwrite
an explicitly authored value? Otherwise "I turned it off" never reliably means anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-11 13:13:28 +02:00
Fr0zkaandClaude Opus 5 2260c4d47a perf(gen): the floor is finally NATIVE -- clamp 29,634/29,634 -> 0, and the 551ms empty tile is dead
Two targets, both hit, and the second is the biggest in-game win since the interval work.

1. THE FLOOR IS NO LONGER PATCHED IN
The audit proved the backstop was doing the work: TunnelAuthoredFloorProtectionClamps
fired on EVERY authored floor sample while the named fallbacks fired zero. The cause,
in its own words, was that the stack composed the floor before the common
disturbance/passage writers, so every sample looked like a post-hoc clamp.

The floor is now composed ONCE, after those writers, at the final MC boundary. The
clamp inside the operator stack is deleted; the metadata stays on the sample for the
hand-off and for diagnostics but may no longer modify density.

  clamp fires        29,634 / 29,634  ->  0
  player-fit cells   20,804  (unchanged)
  walk reachable     10,884  (unchanged)
  connectivity       arrival -> departure, Connected
  geometry SHA-256   b3e5f4c3..9b377  IDENTICAL to 8e42d6d
  export, my 3 runs  1.320 / 1.424 / 1.439 s   (8e42d6d: 1.357 / 1.365 / 1.420)

⭐ Byte-identical output with the patch removed entirely. That is the proof the clamp
was never adding anything -- it was compensating for its own position in the pipeline.
Three days of "is the floor native yet" answered by moving where it is composed.

Remaining, and deliberately: the final support backstop still fires (6,509 export /
9,700 walk). Removing it is not capability-safe and it was not removed.

2. THE 551 ms EMPTY TILE IS GONE
An EMPTY LOD0 tile cost 551.7 ms: it returned verdict=Mixed, ran the full mesher, and
produced no triangles. Pure waste -- work done to discover there was nothing to do.

The cause is precise and worth recording: `AnyPassageModifierNearLattice` fed
`FPassageCarveOp::EffectOverBox` with a SPATIAL candidate test. Returning true killed
the AllSolid hypothesis even when every exact MC sample had a zero carve factor. A
"maybe something is near here" answer was being used where "does it actually change
anything at these samples" was required. It now takes the cheap domain test as a
reject, then consults the exact cached lattice carve factor the fold already computes.
Unknown or non-finite geometry stays conservative.

  empty Mixed fallbacks   86  ->  0      no empty tile enters the mesher at all
  empty tile p50      48.7 ms  ->  7.14 ms
  empty tile p95     451.3 ms  ->  11.54 ms
  empty tile max     551.7 ms  ->  27.86 ms

⚠️ Not fully at the "few ms" target: the remaining 27.86 ms tail is conservative
tunnel-tail proof work, not the old fallback. Honest, and a different problem.

Build 0 errors, three source files, no RMC or external paths touched. The shared
walkable-volume descriptor was not started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-11 05:55:43 +02:00
Fr0zkaandClaude Opus 5 5156084cac audit(gen): which post-hoc ops are tunnel-floors in disguise -- and the X+2A budget is unsound
The owner's framing for the night: "if it sees performance opportunity by unifying
some process that should be, or like the tunnels, have it be native more than patching
it after generation". This is the audit. No generator behaviour changed; the only code
here is request/generation timing instrumentation in VoxelWorld.

STRUCTURAL-POST AUDIT -- verdict per operator
  FBoundarySealOp    0.000011 us/sample  KEEP. A boundary condition, not a workaround.
  FXYEdgeSealOp      0.000054 us/sample  KEEP. Domain constraint; identity today with
                                         WorldRadiusVoxels=0.
  FOriginSpineOp     0.000588 us/sample  PARTLY family, but origin anchoring and
                                         ordering are special.
  FPassageCarveOp    0.019977 us/sample  Could share the volume/floor representation,
                                         but its boundary-crossing order and
                                         current-density carve are load-bearing.
  floor protection   0.061298 us/sample  ⭐ THIS IS STILL THE REAL PATCH.

  TunnelAuthoredFloorProtectionClamps fired 29,634 / 29,634 -- every authored floor
  sample -- while the named support/room fallbacks fired ZERO. So the backstop is not
  a rare safety net; it is doing the work, and the tunnel floor is not yet native.

⭐ AND IT NAMED THE PATH OUT, which I had not seen: the floor is authored at build time
  but LATER WRITERS can still refill or erase the clearance, so a clamp re-asserts it.
  Native means either composing the floor AFTER all writers, or making every later
  writer floor-aware with a proof it cannot violate the invariant. Removing the clamp
  today would be unsafe. It did not remove it.

PRIMITIVE FAMILY -- valid, but my proposal was too crude
  Algebraically sound: solid + floor via SmoothMax + bounded relief + smooth union.
  Rooms, swept tunnels, landing rooms and some passages can share a cached
  walkable-volume DESCRIPTOR (shape/profile, baked floor, relief params, mouth/room
  ownership, capability metadata).
  ⛔ But they should NOT become one undifferentiated operator: tunnels are swept
  multi-segment chains, passages cross structural boundaries, origin landings have
  special finite geometry, seals are domain conditions. Flattening those would WEAKEN
  interval proofs and operation ordering -- the opposite of what I hoped unification
  would buy. Renaming the passage carve would not help either; it Lerps against
  incoming density, so its global bound stays unbounded regardless.

X + 2A CLEARANCE BUDGET -- SETTLED, AND IT IS UNSOUND AS SPECIFIED
  A surface-displacement budget needs BOTH an upper bound on field perturbation AND a
  lower bound on the gradient near the zero surface. The second fails at flat or
  degenerate crossings, and the current-density Lerp/Min operators make a global bound
  unavailable. Perlin derivative bounds alone do not fix it.
  ⇒ Usable only inside separately proven primitive regions; elsewhere the validator and
  backstop must stay. The owner and I both assumed interval propagation already gave
  us this. It does not, and now we know why rather than just that.

ITEM ZERO -- the suspected in-game regression: NARROWED, NOT CONFIRMED
  Queue wait is real, but meshed latency is primarily GENERATION time, not queue. One
  EMPTY LOD0 generation took 551.7 ms, so it is not pure queue saturation either.
  RoomGraphSource measures 0.278939 us/sample, which cannot explain a 400-1000 ms mesh.
  Codex declined to call it a confirmed code regression on this data. Still open, and
  it now points at the known-unsolved LOD0 mixed-classifier fallback rather than at the
  floor work.

REFERENCE ARCHETYPE -- costs, and a correction to my arithmetic
  CorridorRadius 12.5 voxels is a 6.25 m DIAMETER (I said 3.1 m), and validation caps
  it at 16 voxels. The 15-20 m reference hall needs a separate gallery archetype or a
  larger cell-size budget; raising the value violates current lattice/wall assumptions.
  FCaveTerraceMod is a true no-op disabled (0.000531 us/sample); enabled it adds two
  cached SDF probes, estimated lower bound ~0.56 us for a qualifying sample -- estimated,
  not measured. Columns belong to slab generation; TunnelNetwork needs a baked column
  op. Water is presentation, not rock density, and null-RHI cannot price it.

Build 0 errors. No RMC files, links, historical checkouts or logs touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-11 05:05:47 +02:00
Fr0zkaandClaude Opus 5 8e42d6d807 feat(tunnels): floor authored at BUILD time, room clips tunnel, climb tier added
The round the owner asked for, with three corrections from Codex that are worth more
than the features.

WHAT LANDED
- The complete tunnel floor profile is authored into FCachedTunnel during
  BuildChunkCache, not derived per sample. This was his point: "it should be done at
  creation time, since it knows its destination."
- Room floor ownership blends/clips the tunnel floor at mouths -- his A > B boolean.
- The traversal validator gained a climb tier, structured exactly as agreed:
    bWalkOnlyCanReachDeparture / bClimbCanReachDeparture kept SEPARATE, with
    WalkOnly / Climb / MineRequired cell counts reported alongside.
  The comment in the header states the contract: "The primordial capability contract
  is walk-only... climb is reported as a separate tier and never makes the hard
  connectivity verdict pass." That is the distinction we wanted: reporting tiers, not
  permission.
- Floor controls exposed under Cave Morphology|Tunnels|Floor.

⚠️ CORRECTION 1 -- LARGE LEDGES ARE NOT CAPABILITY-SAFE YET, so they stay opt-in.
    default (preference 0)   fit 20,804   walk reach 10,884   0 ledges
    preference 2             fit 20,765   walk reach 10,712   360 ledges
  Ledges cost 172 walk-reachable cells. Codex kept preference 0 (the old staircase) as
  the default rather than shipping the owner's preferred look at the price of walkable
  space, and led its report with that. Correct call.

⚠️ CORRECTION 2 -- THE BACKSTOP STILL CANNOT GO. Support/room fallback counters were
  zero, but the structural protection clamp fired on 31,017 of 31,017 authored floor
  samples -- 100%. So the shape is still not correct by construction; the clamp is
  doing the work. Instrumenting it was the right move: we now have a number instead of
  an argument.

⚠️ CORRECTION 3 -- THE "X + 2A" CLEARANCE BUDGET IS NOT IMPLEMENTABLE AS I SPECIFIED.
  Interval propagation bounds FIELD VALUES, not SPATIAL DISPLACEMENT. Deriving A needs
  a proven Lipschitz/influence bound for every post-operation, which we do not have.
  Both the owner and I assumed the interval work already gave us this; it does not.
  The idea is still right, the prerequisite is a displacement bound.

MEASURED BY ME: build 0 errors; export 1.365 / 1.357 / 1.420 s (checkpoint was
1.321 / 1.357), flat within noise; 153,346 triangles.

ALSO MEASURED THIS ROUND
- Edit cost: 8 affected chunks, 195.5 ms total -- generation 195.471 ms, apply 0.059,
  diff 0.020, queue 0.001. It is full level-0 regeneration; a dirty-mesh path needs
  partial section support that is unavailable without modifying RMC.
- Streaming caps: effective generation cap 14, pending max 14, CAP HIT. Mesh applies
  per frame 4 (not hit), 2 ms apply budget (not hit), unloads 6 (not hit).
  ⇒ The generation core cap is what binds during startup fill.
- Peak export RSS 1.70 GB (was 19.5 GB before the memory work).
- LOD4 empty outliers gone. LOD0 not solved: 4,600 empty tiles at 3 us median, but 11
  mixed-classifier fallbacks over 100 ms, max 554 ms -- verdict=Mixed, then the full
  mesher, producing no triangles.

⚠️ UNVERIFIED AND CONCERNING: the reported in-game meshed times are far worse than
  412a7ad's -- p50 498.8 ms vs 115 ms, p95 1.227 s vs 410 ms, max 1.974 s vs 844 ms.
  The samples differ (9,336 ready events vs 841 requests, different traversal, possibly
  queue wait included), and the export path is flat, so I do not know whether this is a
  real regression or a different measurement. I have NOT reproduced it myself. It is
  the first thing the next round must settle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-11 04:25:45 +02:00
Fr0zkaandClaude Opus 5 e91f1c44bb wip(tunnels): checkpoint -- floor parameters exposed, round cut short by Codex credit limit
⚠️ THIS IS A CHECKPOINT, NOT A FINISHED CHANGE. The agent hit its usage limit
mid-round (locked out until 2026-09-15) after compacting its context, and never
filed a report or capability numbers. Committing so 1,344 lines are not left
uncommitted for five days. Verified by me only to the extent stated below.

WHAT LANDED
The tunnel floor controls are now real strate parameters under
"Cave Morphology|Tunnels|Floor", which is what the owner asked for -- hand-made
assets can tune them and the composer can roll them later:

  bTunnelFloorEnabled                true
  bTunnelFloorTerracingEnabled       true
  TunnelFloorTerraceStepHeight       1.71 voxels   (the existing 42.75 cm riser)
  TunnelFloorMaxLedgeHeight         12.0  voxels   (3 m -- climbable, not walkable)
  TunnelFloorGentleSlopeThreshold    tan(44 deg)
  TunnelFloorLedgeCountPreference    0             (0 = old per-segment staircase;
                                                    >0 = whole-chain ledge policy)
  TunnelFloorMaxLedges               4096

⭐ So the staircase-vs-ledges question the owner raised is now answerable IN THE
EDITOR without a rebuild: set LedgeCountPreference to 2-3 and the whole-chain
policy takes over from the per-segment one.

VERIFIED BY ME
  build            0 errors
  export, 2 runs   1.321 / 1.357 s   (ce53552 was 1.384 / 1.405 / 1.415)  ~-4%
  triangles        153,382           (was 153,338, +44)
  geometry hash    9815c9a2..ebeb9   -- CHANGED, so behaviour is live beyond the
                                       stated compatibility defaults

⚠️ CAPABILITY DIPPED SLIGHTLY, and I am not calling this an improvement:
  player-fit cells   20,835 -> 20,800   (-35,  -0.17%)
  reachable cells    10,901 -> 10,886   (-15,  -0.14%)
  largest component  10,886 = reachable, still one connected mass

Small enough to be a consequence of the 44-triangle geometry shift rather than a
real regression, but it is a drop, and my own rule for this work is that fit and
reachable must not fall. Treat it as unresolved until the round can be finished.

NOT DONE (the substance of the brief):
- terracing objective still minimises riser height, not step count
- the room does not yet clip the tunnel floor where they overlap (A > B)
- the traversal validator still cannot climb, so it cannot score real ledges
- the floor profile is still derived per sample, not authored at build time
- the support-column backstop is still present, i.e. the shape is still not
  correct by construction
- dig cost and the streaming-cap audit untouched

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-10 22:01:32 +02:00
Fr0zkaandClaude Opus 5 ce53552543 perf(relief): column cache for floor noise -- 8.5%, and my "it's 2D" premise was wrong
I predicted this would recover the D-section's 30% outright, on the grounds that the
floor relief is FMath::PerlinNoise2D and therefore a function of (X,Y) only, so every
voxel in a column recomputes the same pair of octaves. Codex checked the premise and
it does not hold:

  production applies a 3D CAVE WARP before the floor is evaluated, so the warped X/Y
  fed to the noise change with Z. A world-XY cache would have changed the field.

That is the third arithmetic prediction of mine that did not survive contact with the
code (after the double->float round-trips, and "the candidate scan is the last big
win"). Recording it because the pattern is consistent: I keep reasoning about cost
from a call site instead of from the data flow reaching it.

WHAT WORKS INSTEAD
Cache keyed on actual SDF-domain integer columns plus feature, owner, seed and
frequency -- correct in the warped domain, where world-XY is not. Fixed size, 32,768
entries, ~1.25 MiB per worker, no per-tile or per-region retention.

  28,154,092 column lookups
  25,842,374 hits / 2,311,718 misses      (91.8% hit rate)
  71,133,837 conservative relief skips    (amplitude bound, only where SmoothMax is
                                           provably the identity)
  77,109,353 actual two-octave evaluations

  my build, 3 runs:  1.384 / 1.405 / 1.415 s   (ff8bd7a was 1.509 / 1.542 / 1.543)
                     mean 1.401 vs 1.531  =  -8.5%
  geometry SHA-256 bc0dc059..a8ae, IDENTICAL to ff8bd7a's output

Capability unchanged exactly: player-fit 20,835, reachable 10,901, connected,
40,429 verts / 75,416 tris, dihedral p50/p90 2.5529/33.7191, hash 4520DC6F,
determinism repeat passed.

Codex also declined a sign-only shortcut for the main SDF, correctly: marching cubes
interpolates on the field MAGNITUDE, not just its sign, so a sign-only answer would
move vertices. It kept it for the landing helper where only the sign is consumed.

⚠️ NOTE ON MY OWN "17% NOISE FLOOR": that figure came from a profiled-vs-unprofiled
comparison, not from run-to-run variance. Within one binary the spread here is ~2%
(1.384-1.415). An 8.5% shift that is consistent across every run in both directions
is real. I have been using the wrong number as a threshold and should stop.

Net position: the D-section still costs ~17% against pre-swept-floor (1.401 vs 1.15),
in exchange for +682 player-fit and +418 reachable cells, floors that undulate instead
of presenting a slab, and the retirement of the 1.5M-call validator and two dead ops.

Build 47 actions, 0 errors. Only morphology source changed; zero reparse points;
RealtimeMeshComponent untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-10 18:55:06 +02:00
Fr0zkaandClaude Opus 5 ff8bd7a8bc feat(tunnels): tunnels get the floor rooms always had -- capability up, 30% slower (fix known)
The owner asked the question that unlocked this: "isn't the floor cut a modification
made to rooms afterward, instead of having them be naturally like so?"

Reading the code answered it, and the answer was that rooms and tunnels were built on
OPPOSITE principles:
  room   = SmoothMax(sphereSDF, floorHalfSpace), floor part of the SHAPE, evaluated
           in one pass, with two octaves of Perlin relief already on the floor height
           (FloorReliefStrength / RoomFloorCutMin=0.70, hash-rolled per room)
  tunnel = a bare capsule with NO floor, then CaveTunnelFloorOp + support columns +
           IsTunnelSupportFloorWorldPoint asserting a slab in afterwards

That is why tunnels read as "forced flat" and rooms never did, and why the previous
conditional-floor round could only find 1,539 skips in 1.5M tests: it was optimising
a correction instead of removing the need for one.

WHAT LANDED
Corridors now use a swept SmoothMax(capsule, floor): endpoint floor heights derived
from the rooms they join, deterministically interpolated along the control chain, the
existing two-octave relief reused (not reimplemented) and faded at mouths, and
TERRACES where a room-to-room transition is too steep -- risers capped at 1.71 voxels
(42.75 cm), with relief disabled on risers so the step bound holds.

  player-fit cells   20,153 -> 20,835   (+682)
  reachable cells    10,483 -> 10,901   (+418)
  connectivity       connected, both;  mouth gap 0
  vertices/tris      40,623/75,776 -> 40,429/75,416
  dihedral p50/p90   2.5429/33.8499 -> 2.5529/33.7191 deg

Capability went UP, which is the point: the shape now provides what the correction
was faking, and it reaches places the slab did not.

RETIRED: the 1.5M-call natural-floor validator and its counters, plus the now-dead
CaveTunnelFloorOp / CaveTunnelAirOp implementations and profiler labels.

NOT retired: the finite support-column backstop. Removing it failed hard --
19,097 fit, 396 reachable, DISCONNECTED. It stays, and Codex was right not to force it.

⚠️ THE HONEST COST: 30% SLOWER
  my own build, 3 runs:  1.509 / 1.542 / 1.543 s   (was 1.118 / 1.131 / 1.197 s)
Well above the 17% noise floor. The relief evaluation is what costs.

⭐ And the fix is already identified: the relief is `FMath::PerlinNoise2D` -- a
function of (X,Y) ONLY -- yet it is evaluated per 3D sample. That is exactly the bug
pattern the support floor had, where making the decision per-column took it from
2.29 to 0.138 us/sample (16x). Caching relief per XY column should recover most or
all of the 30%. Next round.

Renders confirm the floors now undulate gently instead of presenting a flat plane;
the global relief metric is wall-dominated and inconclusive, and Codex correctly
declined to claim the visual complaint solved on that basis.

Determinism holds: both final runs hash 4520DC6F with canonical repeat equality.
Analytically the authored floor profile is bounded by tan(44 deg) including the
Perlin derivative and mouth envelope; the full post-modifier SDF still is not
provable, so the player-fit audit stays.

Build 0 errors. Nothing outside Plugins/VoxelForge; RealtimeMeshComponent untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-10 18:00:11 +02:00
Fr0zkaandClaude Opus 5 383ec86c37 perf(apply): stutter is NOT the game thread -- and the flat floor cannot be relaxed
Two things the owner reported from playing 412a7ad. One is answered, one is a null
result that Codex flagged itself rather than dressing up.

THE FLATTENED TUNNEL FLOORS -- capability-safe, and worth almost nothing
He has now flagged these twice: every tunnel carries a smooth flat strip running in
and out through otherwise craggy rock. The idea was to make the floor CONDITIONAL --
assert it only where the natural rock fails walkability, leave the rock alone
elsewhere. Cheaper and better looking, in theory.

The floor is now skipped only when the exact no-floor field passes the same
player-fit validator at foot offsets 0, -1, +1. Unknown or failed keeps the slab.

  natural-floor skips accepted   1,539 of 1,529,241 tests   = 0.10%
  player-fit cells        20,153 -> 20,153
  reachable cells         10,483 -> 10,483
  arrival -> departure    connected, both
  dihedral p50/p90        2.5429 / 33.8499 deg, IDENTICAL
  mesh time (their batch) 1.757135 -> 1.757140 s

Capability preserved exactly. Visual complaint NOT solved, and no speed gained.

⭐ The useful part is what it PROVED by trying to go further. Every broader policy
lost walkable space:
    analytic-only        fit -217, reachable -195
    tightened analytic   fit  -76, reachable  -76
    exact +/-4 foot      fit  -16, reachable  -16
⇒ The floor cannot be relaxed without changing the walkability contract or adding a
route-aware global proof. That is a real constraint, now measured rather than assumed.

Codex's own words, and it was right to lead with them: "the conservative floor change
is capability-safe, but it does not visibly solve the flattened-strip complaint."

THE STUTTER -- measured, and it is not where we thought
Game-thread apply, meshed tiles, under -nullrhi:

  total apply            p50 0.107 ms   p95 0.153 ms   max 0.273 ms
    stream upload/RMC    p50 0.040      p95 0.088      max 0.124
    component creation   p50 0.019      p95 0.021      max 0.035
    registration         p50 0.010      p95 0.011      max 0.020
    collision config     p50 0.005      p95 0.007      max 0.019

A quarter of a millisecond at worst. The game-thread drain is NOT the hitch.
⇒ Remaining visible stutter is most likely render-thread/RHI work or asynchronous
collision cooking behind RealtimeMeshComponent, which is off-limits for edits.

Added anyway as a cheap safety valve: MaxMeshApplyMilliseconds, default 2 ms of drain
budget per frame. Over 729 apply frames: drain p50/p95/max 0.111/0.182/0.313 ms,
budget hits 0, at most 2 visible applies in a frame. It costs nothing today and caps
a future burst.

Request-to-ready, now timed after game-thread application:
  all     p50 57.1 ms   p95 432.9 ms   max 919.4 ms   -> 2.60 m at 6 m/s
  meshed  p50 134.1 ms  p95 457.4 ms   max 919.4 ms

⚠️ My own verification: 1.197 / 1.131 / 1.118 s vs 1.088-1.102 s on 412a7ad. Mean
+4.7% -- below the 17% noise floor, but slower in all three runs, and the validator
runs 1.5M times to accept 1,539 skips. Geometry moved by 4 triangles (153,740 ->
153,744), which is allowed. If the next round cannot make that validator earn its
place, it should come out.

Build 0 errors. Peak RSS 1.67 GB export / 1.90 GB game. Nothing outside
Plugins/VoxelForge; RealtimeMeshComponent untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-10 14:46:29 +02:00
Fr0zkaandClaude Opus 5 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 d38d4ca:  meshed p50/p95/max 0.6085 / 4.9075 / 5.0199 s
                    LOD4 empty max 5.618 s
  => meshed p50 5.3x, p95 12.0x, and the multi-second empty outliers are gone.

REQUEST-TO-READY -- the number that answers "I can outrun the generation and fall"
  all tiles     p50  57.576 ms   p95 429.052 ms   max 905.633 ms
  empty         p50  30.943 ms   p95  98.302 ms   max 305.505 ms
  meshed        p50 128.521 ms   p95 462.235 ms   max 905.633 ms

At the harness walking speed of 6 m/s: p95 = 2.574 m, worst case = 5.434 m. A walking
player now outruns tile readiness by a couple of metres, not by a strate.

CAPABILITY PRESERVED, which is the point
The owner asked that speed never come from quietly simplifying the world. It did not:
geometry SHA-256 98eaad61..d3c4f is IDENTICAL to d38d4ca, 153,740 triangles, 82,516
vertices, across three runs. No resolution, sample-count, octave or seed reduction.
Unknown intervals still cost the skip. Prove and skip, never simplify and hope.

Export path, my own build, 3 runs: 1.088 / 1.102 / 1.100 s (was 1.171-1.258 s). ~9%,
below the 17% noise floor, so not claimed as a speed win -- the export was not the
target.

Honest remainder: LOD0 empties are ~7 ms p50 with a 25 ms tail, not the 2-5 ms of a
pure layout proof. Those are bounded, sound interval refinements that never invoke
meshing; going lower needs stronger proof bounds, not more skipping.

Verified by me: build 0 errors, binary timestamp checked against sources, peak RSS
measured, hashes compared. Nothing outside Plugins/VoxelForge; RMC untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-10 11:05:09 +02:00
Fr0zkaandClaude Opus 5 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
0e9df9e established why no block could ever be skipped: OriginSpineOp,
PassageCarveOp and the active seals Lerp/Min/Max against the CURRENT density, so
with an unknown incoming density their supremum is genuinely unbounded. Not a
missing bound -- the operator semantics.

The answer is to propagate a density INTERVAL through the stack, and that is what
landed: FVoxelBoxSdfInterval + PropagateSdfOverBox on the operator interface, an
interval fold through every converter and modifier, interval arithmetic down into
the noise primitives themselves (gradient corners, warp, worm frequencies), and a
BlockSkip lattice the mesher consults per 8^3 block.

Its own guiding comment, which is the right default:
  "no finite interval is a proof. Unknown costs the skip and protects the geometry."

  128^3 TunnelNetwork seed 1337 slot 1, freshly built binary, 3 runs each:
    before  1.515 / 1.554 / 1.569 s
    after   1.213 / 1.258 / 1.171 s     -22.5%, 1.28x -- clears the 17% floor

  Geometry SHA-256 98eaad61..d3c4f IDENTICAL to 0e9df9e. 153,740 triangles either
  way. It skips only provably-empty work; nothing about the world changed.

It also fixed one cause of the 5.6 s empty tiles found in 0e9df9e: an LOD4 tile
legitimately spans 19^3 = 6,859 chunk keys and was rebuilding per-chunk state
thousands of times within a single tile. A shared cache removed that failure mode;
remaining slow empties were LOD0 and are a separate fast-classifier bail, still open.

Partially landed, unfinished when killed: RequestStartCycles on FChunkResult, to
report request-to-ready latency rather than worker generation time. Harmless as is.

⚠️ WHAT KILLED IT, AND IT IS OURS
A single UnrealEditor-Cmd commandlet grew to 19.5 GB, leaving 1.3 GB free on a
32 GB machine, and the OS killed the agent. Freeing it restored 21.6 GB. The owner
said twice that his machine should not be under memory pressure and he was right
both times: first the 411 MiB worker caches, now a 19.5 GB commandlet. A generation
run must not need 19.5 GB. Next task.

Cumulative on this region: 342.2 -> 16.2 -> 2.62 -> 1.49 -> 1.21 s  (~283x).
3,374 insertions / 342 deletions across 12 files. Build 0 errors. Nothing outside
Plugins/VoxelForge; RealtimeMeshComponent untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-09 21:11:13 +02:00
Fr0zkaandClaude Opus 5 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 2f33870. Task 3 is memory, not speed.

⚠️ PROCESS, second time: the agent's reported DLL timestamp was from a staging copy
it built under the plugin's own Saved/, not Plugins/VoxelForge/Binaries. My first
three runs measured the 05:58 binary again. Verify the binary you are about to
measure, not the one someone says they built.

Determinism: all export hashes ED0F1F31 within the run; classifier vs generator
verdicts zero mismatches. Nothing outside Plugins/VoxelForge; RMC untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-09 09:00:41 +02:00
Fr0zkaandClaude Opus 5 2f33870a84 perf(mesher): adjacent tiles were re-evaluating 18% of the field -- 2.69s to 1.49s
Overnight task 2. Every claim here is end-to-end wall clock on a real 128^3 export,
not a profiled bucket, because the last two rounds shipped large bucket wins worth
0% in reality. Measurement floor is ~17% run-to-run; only Part 2 cleared it.

PART 2 -- KEPT. Density-grid reuse.
The mesher samples an expanded grid (-1..Cells+1), so adjacent tiles re-evaluated
their shared boundary planes. Measured: 2,744,000 requested samples contained
495,909 duplicates -- 18.07% of the field generated twice. A run-scoped read-only
lattice (FVoxelSharedDensityGrid) owns each world sample once and hands it to both
adjacent tile grids. Duplicates now 0.

  128^3 TunnelNetwork seed 1337 slot 1, freshly built binary:
    before  2.603 / 2.693 / 2.697 s
    after   1.488 / 1.499 / 1.486 s      -44.6%, 1.80x

  Geometry SHA-256 98eaad61..d3c4f IDENTICAL to the pre-change binary's output.
  153,740 triangles either way. Pure win, no field change.

The lattice is deliberately not a UObject and not a cache with eviction: ownership,
lifetime and iteration order stay explicit. A malformed or incomplete lattice is
ignored and falls back to the canonical generator path rather than altering geometry.

PART 1 -- REVERTED. Block-level empty-space early-out.
**0 of 4,096 blocks were safely classifiable**, and the attempt made it 85% slower
(3.632 -> 6.735 s). No unsafe bound was retained.
⇒ This is a finding, not just a failure: our conservative bounds are too loose to
prove ANY 8^3 block uniform. Hierarchical evaluation of any kind is blocked until
we know which operator has effectively unbounded reach. Next task.

PART 3 -- REVERTED. Task granularity.
15 workers, 64 tile jobs, mean job 0.594 s, launch fringe ~14.1 us. MinBatchSize=4
was 1.09% SLOWER end-to-end despite a better bucket time -- exactly the trap this
task's rule exists to catch. Default stays 1. ParallelForWithExistingTaskContext
not adopted.

PART 4 -- NO CHANGE. Renderer audited over 3 runs (8.736 s total, 1.483 s raster).
Mesh and BVH are already reused across viewpoints; no per-pixel heap allocation, no
redundant remeshing. Nothing gross to fix; it benefits from the above automatically.

STILL OPEN: the spatial index from ee89c4d has never earned its keep in wall clock
and accounts for ~214 MB of measured allocations. Codex proposes an on/off ablation
then removal if confirmed. Not done unilaterally.

⚠️ PROCESS NOTE: the agent reported "build passed" but the runtime DLL was still
from 03:07 while sources were edited at 05:22/05:42. My first three verification
runs measured the OLD binary and showed no change. Always check binary timestamps
against source timestamps before believing a measurement.

Cumulative on this region: 342.2 s -> 16.2 s -> 2.62 s -> 1.49 s (230x).
Determinism: identical hashes across all repeat runs. Build clean. Nothing outside
Plugins/VoxelForge touched; RealtimeMeshComponent untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-09 05:59:56 +02:00
Fr0zkaandClaude Opus 5 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
ee89c4d delivered a 5x candidate reduction and a 16x cheaper support floor for
0% end-to-end gain, because the profiler inflated by ~11.5x (14.74 us/sample
profiled vs 1.28 us/voxel real). Two rounds of bucket rankings were artefacts of
FPlatformTime::Cycles64() wrapped around ~100 ns operations -- the timer pair
calibrates at ~0.1 us, so it was measuring itself.

PROFILER
Deterministic count sampling plus amortised block timing every 64 density calls.
Coarse counters (calls, candidates) stay always-on -- they were never the problem.
Timing-only scopes are opt-in behind -profiledensityfull. UE CPU trace scopes added.

  before   profiled 14.74 us/sample  vs  1.28 us/voxel real   ~11.5x
  after    profiled 0.463050 s       vs  0.558470 s unprofiled   -17.09%
           geometry 235AC2DC in both

⭐ Every profiled report now prints profiled total, unprofiled total, discrepancy,
ratio and geometry equality, and EXITS CODE 2 if the comparison fails. An
intermediate run hit -22.6% and failed loudly, which is the point: this class of
bug must be impossible to miss rather than something noticed three rounds later.

⚠️ The -17% residual is run-to-run noise, so ~17% is our measurement floor.
Improvements smaller than that cannot be claimed from a single pair of runs.

BATCH EXPLORER
One process, many cases, one JSON. Startup was ~25-30 s of engine boot for ~3 s of
work, which is what made every iteration expensive.

  3 cases in 6.59 s of case time
  standalone vs batched, same binary: hash 235AC2DC, 2,461 tris, 343,000 tunnel
  lookups, 554,381 passage evaluations -- identical on both

-out= is enforced absolute under the plugin's own Saved/.

CACHE BREAKDOWN (the 272 KB/entry question)
The slot table is small. The weight is spatial-index bins and candidate arrays,
room arrays, support-column arrays and tunnel-world indices -- i.e. much of the
411 MiB is the spatial index ee89c4d added, which also bought 0% end-to-end.
Worth revisiting on those terms.

RESEARCH (recorded, not acted on) -- CODEX-TASK-1-PROFILER-BATCH-RESEARCH.md
Ranked follow-ups: deterministic empty-space early-out, density-grid reuse, task
granularity (ParallelForWithExistingTaskContext + MinBatchSize), SIMD MC (~2x over
optimised scalar, but cross-machine determinism is the risk), sparse hierarchy,
RMC partial updates, Transvoxel LOD seams. Sources cited in the doc.
⚠️ Upstream RMC is 5.3.2; the owner's copy identifies as 5.4 with custom
modifications. No upgrade is safe. Left untouched.

PUSHBACK, accepted: "buckets must sum to their parent" needs a parallel-time
definition -- summing worker CPU is not wall time. Reports now separate worker CPU
sum from wall-clock estimate.

End-to-end unchanged, as expected for a tooling change: 2.746 / 2.691 s on the
128^3 seed 1337 region (was 2.678-2.705). Build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-09 05:02:21 +02:00
Fr0zkaandClaude Opus 5 ee89c4d8c2 perf(gen): 5x fewer candidates, 16x cheaper floor -- and 0% end-to-end. Read the last section.
Owner, after playing 0576e80: "slightly faster, still not on par... I can outrun the
generation and fall." Also, unprompted: "the tunnels seem forced, with a forced
flat -- it technically looks good, but could be a reason." He was right that it
was a reason; it was not THE reason.

WHAT LANDED, all real and all measured

Conservative spatial index (immutable per-chunk uniform bins, baked conservative
bounds, squared rejects, deterministic ordering):

  candidates scanned/sample   25.140 -> 5.192
  candidates evaluated/sample 13.879 -> 2.992
    cave tunnels  11.228/6.069 -> 1.525/0.723
    tunnel core   11.228/5.831 -> 1.216/0.567

The earlier flat AABB attempt failed because loose bounds culled almost nothing
while adding a pass. Technique from GPU Gems 3 ch.32 broad-phase.

The forced-flat floor, which the owner spotted by eye: a floor decision is
inherently per-COLUMN and was being recomputed for every Z sample in that column.
Now resolved once per integer XY column and reused.

  TunnelCorePosts   2.29 us/sample -> 0.138 us/sample   (16x)

The floor is unchanged in behaviour: still applied only after a tunnel passes its
world bounds and walkable projection, still walkable, look untouched.

FIRST MEASUREMENT OF OUR MEMORY FOOTPRINT
The owner: "it shouldn't be pressured on such system, even less when not running
anything." He is right, and it is ours:

  total resident worker cache   431,162,116 B  =  411 MiB
  15 workers                    27.41 MiB each
  average valid entry           278,704 B
  largest entry                 541,344 B
  static slot storage             2.4 MiB   (dynamic: 428.6 MB)

Cutting 4096 -> 128 slots only saved static storage. The weight is 272 KB PER
CACHED CHUNK STATE, ~102 live per worker. Nobody has yet asked why one cached
chunk state needs 272 KB. That is the open question, not the slot count.

⚠️ AND THE PART THAT MATTERS MOST: THIS BOUGHT NOTHING END-TO-END

  128^3 TunnelNetwork, seed 1337 slot 1, same region, unprofiled:
    before this commit   2.623 s
    after, 3 runs        2.705 / 2.678 / 2.682 s

Flat. A 5x candidate reduction and a 16x cheaper floor moved real generation time
by zero.

The reason is visible in the numbers and I should have caught it earlier: the
PROFILED path reports 14.74 us/sample while the real unprofiled cost is
1.28 us/voxel. **The instrument inflates by ~11x.** Every bucket we have been
ranking and optimising for two rounds was measured under that inflation, so the
ranking does not reflect where real time goes. We optimised what the profiler
exaggerated.

⇒ Before any further optimisation: make the profiler cheap enough that profiled
and unprofiled totals agree within a small factor, or switch to sampling. An
alarming number is the instrument until proven otherwise -- three for three in
this project, and now four.

Still true: ~21x slower than the pre-op-stack build, and generation still loses
to a walking player. Geometry changed by 40 triangles (153,692 -> 153,732), which
is allowed; two full exports hash identically (37E00BEE..0A7DF). Build 42/42.
No CPU-feature dispatch. Nothing outside Plugins/VoxelForge was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-09 03:05:40 +02:00
Fr0zkaandClaude Opus 5 0576e800c4 perf(gen): the landing work was per-voxel -- 342s to 2.6s, same world
The owner played 64f5d61 and reported the truth: "generation still takes ages...
TunnelNetwork doesn't even finish generating, as big as the surface world" while
SurfaceWorld completed in under 10s, and stopping PIE hung for 30+ seconds.

e08b49a's 21x was real and irrelevant. It fixed per-CHUNK state rebuilds, which
dominate the commandlet export path. The game is bound by per-VOXEL evaluation.
Same symptom, different bottleneck, and nobody had measured the second one.

WHY PIE HUNG
bShuttingDown was never checked inside VoxelMarchingCubesMesher.cpp -- zero
occurrences. A worker inside GenerateMesh could not abort, so 64f5d61's correct
unconditional EndPlay wait was waiting on tiles physically unable to stop. The
hang was not a teardown bug; it was evidence that one tile took tens of seconds.
Added shutdown polling per slab/row in the mesher and through GenerateTileResult;
aborted results carry Epoch + bAborted and ProcessQueue drops them. EndPlay still
waits unconditionally -- the 3s mark only logs now.

  PIE stop   3.409s / 44.759s  ->  4 ms teardown, 38 us worker drain

WHERE THE PER-VOXEL COST WAS
The owner's own comparison was the sharpest instrument available. Profiled both:

  GetDensityAt          TunnelNetwork 12.64 us/sample   SurfaceWorld 1.32
  tunnel candidates evaluated      7.088 per sample                 0
  tunnel-core candidates evaluated 7.201 per sample                 0
  room candidates evaluated        1.000 per sample                 0

  largest buckets: RoomGraphSource 2.57, TunnelCorePosts 0.76,
                   PassageCarveOp 0.22, PassageStructuralPosts 0.20

The three regressing commits map cleanly, and one is innocent:
  7a20eee drainage tree  -- cache-build work, NOT per-voxel
  413c15d landings       -- repeated full passage modification per sample
  55bf807 landing rooms  -- room SDF + tunnel/core evaluation per sample

Fixes: FPassageCarveOp carves only, landing/support moved to the structural MC
post; tunnel core support and SDF fused into one pass; room graph caches shared
per worker on complete deterministic keys; tunnel density cache uses open
addressing with 4096 slots. Room cache rebuilds fell to 9 per 42,875 samples, so
what remains is genuine O(local cached room/tunnel count) work.

  TunnelNetwork 128^3, seed 1337 slot 1, same region:
    342.2 s / 163.161 us/vox   ->   2.623 s / 1.251 us/vox     (130x)

  Geometry SHA-256 aaf95559..066b4, IDENTICAL to the pre-optimisation run at the
  same roughness. 153,692 triangles either way. Verified independently of the
  agent's own report.

NOT FIXED, and the first one is the next real target:
- L4 tiles still cost 16.93 s. Hit rate falls 99.9% (L0) -> 23.7% (L4), but
  1024 -> 4096 slots only moved 18.39s -> 16.93s and the 1,927 misses ARE the
  distinct key count. That is low reuse, not eviction. A bigger cache will not
  save it.
- TunnelNetwork is still ~10x SurfaceWorld (0.354s vs 0.028s on a matched 32^3).
- The live PIE tile table is SurfaceWorld: this checkout's PIE map is configured
  that way. TunnelNetwork went through the same mesher and density path via the
  commandlet, but no TunnelNetwork tile has been timed in an actual game session.
  Export numbers are not presented as game numbers.

Still outstanding, untouched here: the inter-strate passage has intersections it
should not have, routes through open passage when it should not, and should
descend from a low point of the current strate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-08 23:47:53 +02:00
Fr0zkaandClaude Opus 5 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). d97373c removed the landing-to-hub radial roads; it
did not touch these. FLatticeCorridorSource also still emits axis-aligned capsule
edges for Maze (VoxelDensityOpStack.cpp:548). Awaiting the owner's call on what
should replace them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-08 21:17:57 +02:00
Fr0zkaandClaude Opus 5 e08b49a183 perf(gen): a thrashing one-slot cache cost 18.7x -- and the world is unchanged
TunnelNetwork density evaluation was 45x more expensive than Maze for identical
work: 2,097,152 voxels, 64 tiles, same mesher, same machine. Profiling killed
every hypothesis I brought to it -- box classification skipping 0/64 tiles was
true but irrelevant (export bypasses classification), near-surface early-outs
fired at 49.7%, the room cache built 340 entries for 2.74M samples, and the
per-room overrides resolved once per sample rather than once per modifier.

The cause was the wrapper, not the stack. A mesher tile samples an EXPANDED grid
(-1..Cells+1), so one worker crosses several exact chunk coordinates while
meshing a single tile. The generator's cache held only the last coordinate, so
every boundary crossing rebuilt the resolved parameters, the whole 22-operator
stack and the tunnel-core cache. On one representative tile: 3,675 refetches,
38.0s. The entire operator stack was 18.5s of a 4,272s total -- 0.43%.

Replaced with a 128-slot direct-mapped thread_local cache keyed on owner, chunk,
layout version, params fingerprint and generator type. Collisions force a safe
rebuild, never a stale read. No shared mutable state, no order-dependent
reduction.

  TunnelNetwork  344.5s / 164.267 us/vox  ->  18.4s / 8.763 us/vox   (18.7x)
  Maze             7.4s /   3.535 us/vox  ->   1.08s / 0.513 us/vox  ( 6.9x)
  same tile        38.0s                  ->   0.51s ; refetches 3675 -> 27

Output is BYTE-IDENTICAL, before vs after and across repeats, both archetypes:
TunnelNetwork 50d6b4d0..f23126, Maze 364f7489..2fbe99. The permission to change
the field was not needed.

Also in this commit, from the two preceding tasks:

- Explorer renders from the MESH instead of raymarching. It meshes the region
  once through the canonical UVoxelMarchingCubesMesher, then CPU-rasterises via
  a BVH. A viewpoint went from ~16 minutes to 0.155s; 8 views now cost 1.25s of
  drawing. No render pixel samples the density field.
- One sampled grid and player-fit mask per run, shared by render/walk/export,
  instead of the same 368s walk computed twice.
- A wall-clock budget (25 min default, hard 30 max) that stops cleanly and
  records truncated/truncated_during/completed_modes rather than overshooting.
- ParallelFor across the measurement sampling; the suite was ~10% of a 16-thread
  machine.
- -surfaceroughness= on the commandlet, and a surface-relief metric in export
  mode (edge lengths, dihedral distribution, face-vs-smooth normal divergence)
  so "does this look like rock" is a number, not a screenshot argument.
- Smooth vertex normals in the rasteriser: FVoxelMeshData::Normals is the
  density-gradient normal the engine shades with, and the raster path was
  recomputing a face normal over it.
- ScaleDiagnosis: baked candidate indices are now derived from the current
  corpus, so a legitimate field change stops breaking the test.

NOT verified, and not claimed:
- TunnelNetworkSpineEquivalence exceeded the 30-minute cap and was stopped.
  Inconclusive, not passing. The identical hashes are strong evidence the
  op-stack/legacy equivalence holds, but the test has not confirmed it.
- CrossPlatformDigest passes but reports 2 samples within 1e-6 of the
  isosurface; FMath::Sin/Cos leaves Windows/Linux equivalence unproven.
  Pre-existing, not caused by this change.
- The cache's memory footprint (128 entries x ~16 workers) is unmeasured.

What the roughness sweep settled, for the record: SurfaceRoughness 0 -> 8 moves
cost by 0.3% (163.2 / 163.2 / 163.7 us), so roughness was never the cost. It
does reach the surface (dihedral p50 1.1 -> 2.2 -> 5.9), which kills my claim
that the density-channel operator contributed nothing. TunnelNetwork's hard
seams are present at roughness 0 (4.4% of edges past 40 deg vs Maze's 1.0%), so
they are structural -- in how primitives are unioned -- not a roughness
deficiency. More roughness makes them worse, not better.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-08 20:31:56 +02:00
Fr0zkaandClaude Opus 5 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 413c15d. Jahni:
    "its straight up making other tunnels straightly through everything...
    they have nothing to do here." Zero references remain.
2d. ⭐ FLATTEN THE ROOM JOINS instead. Where two spherical rooms intersect
    and leave a lip above head height, the join is brought down to a
    walkable level.

⭐ 2c AND 2d ARE ONE CORRECTION. I solved "rooms do not connect" by
TUNNELLING BETWEEN THEM when the answer was MAKING THEIR FLOORS MEET. The
81-run sweep had already said so -- zero narrow-gap events, every failure a
vertical severance, renders showing "a sharp diagonal ledge descending into
black open space". A radial corridor to the origin could never have fixed
that; flattening the joins is aimed at exactly it.

2e. Descent tunnel 1.5x wider, per "1.5x bigger wouldn't be too much".
2f. Corridors WANDER. Jahni, from the renders: "its not a maze, its still a
    latice made straight paths." The spanning tree changed WHICH edges
    exist, not what an edge looks like -- every corridor was a straight
    axis-aligned capsule, every junction 90 degrees. Now hash-jittered
    chains, seam-safe like the tree.

RENDERER: 192x128 -> 512x288, and cameras are now seeded from the
player-fit set -- "a density-checked pose from the exact player-fit walk
mask". Previously they sat INSIDE ROCK looking at back-faces, which Jahni
spotted instantly and no metric could. First genuinely legible pictures of
this world.

ParallelFor landed at 6 sites in VoxelStrateMeasure before I stopped the
run; the rest of that work continues next.

⚠️ AND THE RENDERER IS ARCHITECTURALLY WRONG, per Jahni: "why, in engine,
does it take a minute to have clear view of thousands of angles, but in our
renderer it takes ages?" Because the engine MESHES ONCE and rasterises --
~32k density calls per chunk, then every angle is free. I RAYMARCH: tens of
millions of density calls PER IMAGE, nothing reused between frames. We
already have the mesher and an export mode. Next task renders from the
mesh.

Also from him: runs must stay under 30 minutes. This one hit 33 and
produced 2 images.

Build clean. Committed as a checkpoint -- the task was stopped mid-Part-3,
not finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-08 14:14:29 +02:00
Fr0zkaandClaude Opus 5 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 6b91669 -- floors at different levels inside a strate with nothing
joining them. Separate and larger. Not tuned toward.

⚠️ PROCESS: this took 5.7 hours, of which Codex used 4 SECONDS of CPU. The
rest was polling the 73-minute automation suite at ~10% machine load. The
commandlet exists to avoid exactly that. New rule recorded: iterate with the
commandlet, full suite ONCE at the end.

Editor + Shipping builds clean. Automation 32/32, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-08 02:14:37 +02:00
Fr0zkaandClaude Opus 5 6b916699af test(explore): 81 runs in an afternoon -- every failure is a DROP, not a squeeze
First real use of the commandlet, and it paid for itself immediately: 64
sweep runs + failure renders + origin probes, which at the old 73-minutes-
per-automation-run would have been days.

⭐ FINDING 1: THE WORLD IS FRAGMENTED VERTICALLY, NOT PINCHED.
Across all 64 runs: ZERO narrow-gap events. Every failed run has separate
arrival/departure fit components with a positive gap, 12.75 m to 72.98 m.
So corridors are not too tight anywhere -- the space is severed.

And for the first time in this project we LOOKED at a failure instead of
inferring from aggregates:
  FlatPlain/42   "a broad flat floor sheet with a sharp diagonal ledge
                  descending into black open space" -- a drop, gap 63.9 m
  SurfaceWorld/7 "terrain shelves and a large black void between them.
                  There is no connecting floor" -- gap 51.8 m
  Maze/42        "a massive solid rock wall/overhang fills the separation"
                  -- gap 27.7 m
I checked the images myself. FlatPlain shows two floor LEVELS 27 m apart
in Z with nothing joining them.

⇒ The missing thing is VERTICAL CONNECTION between floor levels. Ramps,
slopes, ledges, stairs. Every archetype builds floors; nothing builds a way
between them.

⭐ FINDING 2: THE ORIGIN HUB IS NOT REACHABLE, which contradicts 413c15d.
  agent's reachable set included the origin column:  0 / 64 runs
  origin column had ANY player-fit cell:            17 / 64 runs
  Maze slot 4, all 8 seeds:                          0 fit cells, unreachable
Even in the 17, the origin cells were not in the arrival component.

The reason is structural and obvious in hindsight: the origin spine is a
VERTICAL CYLINDER. A vertical shaft has no walkable floor except at its
bottom, so a scalar-density carve that guarantees open air guarantees
nothing standable. The landings join a hub you cannot stand in.

That is a finding about the §6.5 hub join as exposed to the player-fit
layer -- not walk tuning. Recorded rather than patched.

Also from Jahni, looking at the renders -- two things numbers missed:
- The render cameras sit INSIDE ROCK, viewing tunnel surfaces from the
  solid side. Cameras must be seeded from the player-fit set.
- ⚠️ THE MAZE IS STILL A LATTICE OF STRAIGHT PATHS. The spanning tree
  changed WHICH edges exist, not what an edge LOOKS like: every corridor is
  still a straight axis-aligned capsule between cell centres, every junction
  90 deg. So 15.9% dead ends and mean degree 2.504 measured maze-ness of the
  TOPOLOGY while the eye sees a sparser grid. Sixth time a metric answered a
  question adjacent to the one that mattered.

Two tool issues found: PowerShell needs -modes="render,walk" quoted or it
parses as render-only; and "showcase slot" is ambiguous (cards use 1/6, the
Maze diagnosis uses 4).

Determinism spot-checked: repeated arguments give identical SHA-256 JSON.
Measurement-only changes; no generation, parameter or threshold edits.
Editor build succeeded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-07 18:33:15 +02:00
Fr0zkaandClaude Opus 5 bd547c35c4 feat(explore): a commandlet -- 91x faster, and the first real PICTURE
Every experiment in this project cost a build + full-suite cycle. The
landing task took 52 of them over ~9 hours. And nobody could SEE the world:
every visual judgement all session was made on density SLICES -- diagrams,
not pictures. That is how a floor heaving 1.4 m every two metres got
described as "roughness visible but modest" while Jahni spotted it in
thirty seconds by walking it.

-run=VoxelForgeExplore, editor-only module, three modes, one build many runs:

  render  raymarched PNGs, fixed 0.25-voxel step + 10 bisection iterations
          (NOT sphere tracing -- the op stack yields density, not a signed
          distance, so a long step tunnels through walls). Three views, each
          with a 1.76 m human marker in frame, because every scale mistake
          here came from numbers with no body beside them.
  walk    an agent using the EXISTING VF player-fit stencil, never its own
          movement model -- two definitions of "can a player stand here"
          would drift and we could not tell which was lying.
  export  OBJ through the canonical UVoxelMarchingCubesMesher. No second
          mesher; a forked meshing invariant is the bug this project keeps
          paying for. 23.2 MB, 106,762 verts, 200,842 tris for 128^3.

⭐ THE NUMBER THAT JUSTIFIES IT: the full automation run takes 4,396 s
(73m16s). The commandlet does all three modes in 48.1 s wall, 5.9 s of
which is engine startup. ~91x faster. And I had guessed the suite cost
~90 s -- it is 73 MINUTES. The iteration loop was far more expensive than
I knew.

First real walk metrics on the Maze (seed 0, slot 4):
  distance 214 m · tortuosity 4.98 · dead ends 0.935/100 m
  narrow-gap events 0 · reachable fit fraction 3.01%
  result NotConnectedAtThisResolution
Tortuosity ~5 is a genuinely winding route, which is right for a maze.
3% reachable is not.

Editor Development build clean. SHIPPING build clean with the receipt
containing VoxelForge and NOT VoxelForgeEditor -- the instrument cannot
reach a packaged game. Automation 32/32, 0 failed.

⚠️ Codex initially shipped this UNBUILT, and correctly: I omitted the
## Validation section from that spec, so it fell back to CLAUDE.md's
"NEVER build or compile". 100+ compile errors from three causes -- bare
TCondensedJsonPrintPolicy and TJsonWriter needing their template
arguments, and a missing UObject/Package.h. That include is the SECOND
miss this session, so CODEMAP §6 now carries the gotcha.

⚠️ Unrelated, found in passing: VoxelForge.uplugin does not declare
RealtimeMeshComponent although the runtime module uses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8V9my1PL68pmQb2gutZZV
2026-09-07 04:07:07 +02:00
Fr0zkaandClaude Opus 5 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
2026-09-06 23:55:47 +02:00
Fr0zkaandClaude Opus 5 e4e159f0cd test(measure): the window was NOT the problem -- my sixth hypothesis dies
I thought the fitted mouth-AABB window was cropping an origin-routed path
and producing a false NotConnected. Codex tested it three ways at step 1 on
the Maze candidate Jahni walked:

  mouth-AABB + margin      842,996 cells    NotConnected
  origin-inclusive       3,251,556 cells    NotConnected
  WHOLE STRATE          31,881,528 cells    NotConnected

115,578 fit cells, 2,903 components, largest 11,418 -- and the mouths sit
in components of 172 and FOUR cells, 274 voxels apart. The window was never
the problem. Sixth measurement hypothesis, first one that was wrong in the
other direction: this time the instrument was fine and I doubted it.

The window rule still improved: bIncludeOriginInCoverWindow now covers the
route an origin-rooted topology actually implies (Maze, VerticalShafts,
TunnelNetwork, Underwater), expansion happens BEFORE MaxCells, and an
over-cap window is REFUSED honestly instead of silently cropping back to a
mouth-only box. VerticalShafts refused at the 4M production cap and said so.

Corrected law across eight: 0/7 measured Connected, 1 unknown
(CoarseLiedBudgetExhausted), 1 refused.

⇒ FIVE CAUSES NOW ELIMINATED: size (Jahni walked it), the fit stencil
(fixed, 309 pockets -> 96), placement (mouths land on legal poses),
corridor connectivity (spanning tree, 0 disconnected cells over 64 seeds),
and the measurement window (whole-strate says the same thing).

⭐ AND THE REMAINING CAUSE IS NAMED BY ITS OWN NUMBER: the departure mouth
sits in a FOUR-CELL player-fit component. A passage tube is a bore through
rock -- sloped or vertical, no floor within the walkable angle -- so the
mouth point is not a standing position and nothing around it is either.
You arrive and there is nowhere to stand.

That is precisely COMPOSER-NOTES §6.5 / Tier 1's unbuilt item: "the passage
mouth becomes a room plus a distinctive landmark". It was never started.
The law cannot pass while a mouth opens into a bore instead of a landing.

Build clean, 32/32 successful, 0 failed, same two known warnings.
WorldRadiusVoxels=0, lateral gating, determinism, equivalence all intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8V9my1PL68pmQb2gutZZV
2026-09-06 12:58:56 +02:00
Fr0zkaandClaude Opus 5 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 7a20eee: guarantee it by
construction. Each non-origin node hashes ONE parent axis toward
(0,0,0) so Manhattan distance strictly decreases -- a proof of a global
rooted tree, not a probability. Loops are capped at 0.18 x
BranchProbability and 0.10 x Verticality and can never supply connectivity.
⛔ No edge probability was raised; percolation is a phase transition, not a
knob.

  64 seeds x 9,261 nodes:  0 disconnected cells, 0 edge-count violations
  determinism:             20,000 bit-identical checks
  seam freedom:            42 probes, 0 legacy and 0 op-stack mismatches

⭐ AND IT READS AS A MAZE, in numbers rather than adjectives:

  degree   1 (dead ends)  15.9%      mean degree 2.504
  degree   2              36.6%      run length median 1, p90 3, max 18
  degree   3              31.7%
  cubic-grid control:     100% degree-6, 0% dead ends, mean 6.000

Two abreast, derived not nudged: 3 m usable floor / 0.7 = 4.286 m clear
bore, + roughness reach 3.75 voxels => CorridorRadius 12.5 (6.25 m bore),
giving 3.06 m of usable floor. CellSize 64 still leaves a 7.875 m wall
between neighbouring bores.

BranchProbability and Verticality are now LOOP-DETAIL knobs, not
connectivity controls. Documented at their declarations so nobody tunes
them expecting the old meaning.

⚠️ The player-fit law still fails, and Codex named the likely reason in its
caveats: "global tree connectivity does not guarantee connectivity inside
an arbitrary finite cropped measurement window." The tree routes toward the
ORIGIN, and the fitted window is the AABB of the two mouths -- so the
connecting path runs A -> origin -> B and leaves the box. The coarse maze
graph now measures Connected while the fine windowed law does not. That
smells like the sixth measurement artifact of this session, and it is the
next thing to test rather than to assume.

Box audits: existing maze 104 proved / 0 violations; novel fill stack 240
proved / 319,440 voxels / 0 violations. Op-stack bit-identical over 20,000
samples. Perf 1.13 us rebuild, 0.145 us hot call.

Showcase regenerated. Build clean, 32 tests, 30 clean successes, 2 known
deliberate warnings, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8V9my1PL68pmQb2gutZZV
2026-09-06 11:12:52 +02:00
Fr0zkaandClaude Opus 5 4378385b37 fix(passages): land mouths where a BODY fits -- and the cause is now cornered
VF_SuggestLandingPoint aimed at open AIR. Air is not where a body fits: a
mouth could sit in a perfectly open pocket no capsule can stand in. It now
requires a legal capsule pose -- clearance (r 1.36, half-height 3.52),
support within a 1.8-voxel / 45 cm step height, a walkable support PATCH
(4 of 5 columns, centre required, slope <= 44.8 deg), positive density
throughout the capsule, strict 2x seal interior, bounded lateral snap.

It stays a PURE free function, which was the hard constraint --
GeneratePassages runs inside Initialize while the layout is half-built. So
it cannot flood-fill, and Codex correctly did not pretend otherwise: it
aims at deterministic guaranteed features instead (room centre/floor, slab
floor, horizontal maze edge, shaft feature-core ledge pose, island top).
Both mouths query their own strate independently; no passage chaining, so
LayoutOrderIndependence stays green.

Placement measurably improved:
  Maze            comps 309 -> 82,  largest 176 -> 1217, gap 206 -> 122
  VerticalShafts  comps 112 -> 36,  largest  96 ->  619
  CrystalChamber  gap 47.5 -> 19.1, departure mouth now in a 717-cell comp
  Underwater      comps  86 -> 13

⚠️ AND THE LAW IS STILL 1/8. Reported honestly rather than tuned. Mouths
now land on legal body-sized poses; the poses just are not connected to
each other.

⇒ BY ELIMINATION, THE CAUSE IS GENERATION. Four candidates, three dead:
  size        DEAD -- Jahni walked it; bore measures 3.62 m vs 3.677 m spec
  the stencil FIXED -- fragmentation was 3x overstated by an exact-Z-1 floor
  placement   FIXED -- mouths now sit on legal poses, several in large comps
  GENERATION  STANDS -- body-sized space exists in big components that do
              not reach each other

⭐ AND THE MAZE TIES TWO OF JAHNI'S COMPLAINTS TOGETHER. Its arrival mouth
lands in a 30-cell pocket while the main network holds 1217. Edge selection
is INDEPENDENT HASH GATING per +X/+Y/+Z edge
(VoxelDensityOpStack.cpp:582/584/586) -- so the corridor graph is a random
subgraph with no connectivity guarantee, exactly the percolation failure
VerticalShafts had before its drainage tree. That is the same root cause as
"mostly a cube full of straight pathway along the edges": a spanning tree
would make it BOTH feel like a maze (dead ends, uneven runs) AND be
connected by construction. One fix, two complaints.

Codex found a real error in my spec: an infinite shaft cylinder has no
support surface AT its axis, so the old exact-axis assertion was
unsatisfiable. It now requires a deterministic feature core and audits tree
connectivity from the resolved axis.

Build clean, 29 succeeded / 0 failed / 2 known deliberate warnings.
Box audits 0 violations. Shaft refinement 16/16. Order-independence green
across 10 slots, 31 passages, 3 pool orders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8V9my1PL68pmQb2gutZZV
2026-09-06 08:08:38 +02:00
Fr0zkaandClaude Opus 5 110ae9d0ed fix(measure): the fragmentation was the instrument -- 309 pockets -> 96
Jahni walked the maze end to end while the metric called it 309
disconnected pockets whose largest held 176 cells, with the arrival mouth
in a THREE-CELL pocket. Direct observation beat the instrument, again.

Cause, found exactly: the stencil demanded a solid cell at precisely
(X,Y,Z-1). No supporting-surface search, no slope tolerance, no step
height. At step 1 a floor that slopes even slightly loses its solid cell
under most of its air, so a walkable corridor dissolved into specks.

Now: a bounded downward search for a COHERENT SUPPORTING SURFACE within a
step-height tolerance (MaxStepHeight 45 cm = 1.8 voxels), a walkable-slope
normal test (WalkableFloorAngle 44 deg), then the full capsule re-tested at
the resolved support height. Deliberately NOT "any solid within N cells",
which would accept ledges and isolated pixels.

⭐ ON THE WALKED MAZE (seed 0, index 50, slot 4):
  components   309 -> 96          largest       176 -> 1401
  fit cells   2801 -> 6668        mouth comps  43/81 -> 147/1401
The 300-pocket world was a measurement artifact. Fifth time this session
the instrument was the suspect; fourth time it was the culprit.

⚠️ AND IT IS NOT SELF-FLATTERING -- a metric change that only ever makes
numbers rise is worthless, so it ships with controls that must REJECT:
  vertical shaft wall      0 fit cells
  isolated 1-voxel ledge   0 fit cells
  overhang underside       0 fit cells
  ramp steeper than 44 deg 0 fit cells
  coarse step 4            refused, not guessed
Open flat-floor control: 6.13% of air cells fit, ONE component of 12,168 --
sensible, since only near-floor poses qualify in a tall room.

⚠️ BUT THE ROUTE STILL DOES NOT EXIST, and that part of H3 survives. The law
is 1/8 before and after -- what changed is its honesty: the one connection
used to be FloatingIslands through FOUR cells, and is now FlatPlain through
2,221 with a zero gap. Seven archetypes still have both mouths in separate
player-fit components 56-169 voxels (14-42 m) apart, and those components
are now large and real (663-2902 cells), not specks.

⇒ So the world HAS substantial connected player-sized space. The passage
MOUTHS are not landing in it. That is the Tier 1 landing problem again,
one level down: VF_SuggestLandingPoint aims at open AIR, and air is not
where a body fits. Next task.

Codex found three errors in my baseline quoting, all mine: I wrote "0/8
connected" while also saying one connects through four cells (it was 1/8),
misquoted SurfaceWorld's gap as 1101 when the saved value is 110.648, and
mixed provenance on the VerticalShafts row.

Build clean, 31 succeeded / 0 failed. The 2 warnings are both deliberate:
the lateral-regions gate, and a test reporting that its own sample "barely
asked its question".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8V9my1PL68pmQb2gutZZV
2026-09-06 04:46:01 +02:00
Fr0zkaandClaude Opus 5 cf5f83e9f0 test(diag): H1 dead, H3 measured -- player-sized space is SHATTERED
Diagnosis only, no generation or parameter changes.

⭐ H1 IS DEAD, confirmed by Jahni's playtest and by measurement. He walked
the maze candidate: "i CAN go through it, the tunnels are just the right
size with very tiny wiggle room." The radial probe agrees: measured bore
span 3.62 m against a 3.677 m parameter. The carve produces what it claims;
the scale change worked.

⭐ H3 IS WHAT THE NUMBERS SHOW. Player-sized volume EXISTS but is broken
into hundreds of disconnected pockets, with the two mouths in different
ones, tens to hundreds of metres apart (step 1, fitted window):

  archetype        fit cells  components  largest  arrival/departure  gap
  Maze                 2801        309       176      43 / 81       206 vox (51 m)
  TunnelNetwork        2748        467      1292       2 / 1        164 vox (41 m)
  VerticalShafts       1156        112        96       3 / 36        94 vox (24 m)
  SurfaceWorld          713        520         6       1 / 1       1101 vox (275 m)
  CrystalChamber        655        197        24       1 / 1         48 vox

Only FloatingIslands connects, through FOUR cells.

⚠️ BUT I DO NOT ACCEPT "H3, therefore the world is shattered" YET, and the
reason is Jahni. He WALKED a maze end to end that this metric says is 309
disconnected pockets whose largest holds 176 cells. Direct observation
contradicts the instrument. An arrival mouth sitting in a THREE-CELL pocket
is not a description of a cave; it is the signature of a stencil that
fragments a continuous floor.

H2 is the likely cause of that fragmentation, and Codex found it exactly:
the fit test demands a solid cell at EXACTLY (X,Y,Z-1) -- no supporting-
surface search, no slope tolerance, no step height. HeadroomCells applies
only to the legacy walkable metric, not the capsule mask.
  VoxelStrateMeasure.cpp:697 (stencil), :729 (the exact-floor test)
At step 1, any floor that slopes or is rough loses its solid cell at Z-1
under most of its air, so a walkable corridor dissolves into specks.

⇒ SEQUENCING: fix the stencil first, THEN re-measure H3. Until the
instrument stops shredding continuous floors its component counts cannot be
trusted. Fifth time this session that the measurement was the suspect.

Recommended stencil, Codex's design and I agree: a bounded downward search
for a COHERENT SUPPORTING SURFACE within a step-height tolerance, then
re-test capsule clearance at that support height -- explicitly NOT "any
solid within N cells", which would accept ledges and isolated support
pixels.

Fixed the mixed-resolution reporting that nearly cost me a false finding:
fields are now coarse_step4_* and *_step1, never both unlabelled on one
line.

Recorded from the playtest, not acted on:
- The maze is a GRID, not a maze. Edge selection is independent hash gating
  per +X/+Y/+Z edge (VoxelDensityOpStack.cpp:582/584/586), so every junction
  looks alike. A spanning subset would give dead ends and uneven runs.
- TunnelNetwork DOES have explicit room-to-room connections
  (VoxelCaveMorphology.cpp:1406 backbone, :1465 tunnels) -- so Jahni's
  "lack of real passageway" applies to FlatPlain/CrystalChamber, which are
  slab voids with no room graph at all.
- Two-abreast wants 2.5-3.0 m usable floor; a 4 m circular bore gives ~2 m.

Build clean, 31 succeeded / 0 failed, run by me after Jahni closed the
editor (the previous attempt could not link -- the DLL was held by his
running editor).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8V9my1PL68pmQb2gutZZV
2026-09-06 03:16:55 +02:00
Fr0zkaandClaude Opus 5 fc43a86709 feat(scale): size the world to a human body -- and it is NOT enough
Jahni: "can't you try and make the world to scale?" Every length parameter
is now derived from the body and annotated with its metre value, so nobody
has to do the arithmetic to notice something is absurd.

  CorridorRadius   4 -> 8     1 m -> 2 m radius (4 m bore)
  CellSize        40 -> 64    keeps a real wall between wider corridors
  TunnelMinRadius  3 -> 6     0.75 m -> 1.5 m
  ShaftMinRadius   5 -> 8     1.25 m -> 2 m
  IslandMinRadius 18 -> 24    4.5 m -> 6 m
  MouthRadius      6 -> 8     passage mouths 4 m
  plus passage reach, spiral, cascade, bridges, ridges, chasms

The reasoning that motivated it still looks right: a 2 m circular bore for
a 1.76 m player clears the head only along the exact centreline, so almost
no cell has a floor with 7.04 voxels of clear air above it. A 4 m bore is
genuinely walkable.

⚠️ BUT IT DID NOT FIX TRAVERSABILITY, and this time it is not the
instrument. The player-fit law is measured at STEP 1 on a mouth-fitted
window -- full resolution, both mouths inside the box -- and across the
eight showcase archetypes it reports:

  6 NotConnectedAtThisResolution   (real negatives at full resolution)
  2 OutOfWindow                    (unmeasurable, not passing)
  1 StartCellNotPlayerFit          (the arrival mouth itself is too small)
  0 CONNECTED

And every archetype reports hard-gate survivors=0, so the showcase is
displaying the least-bad of an entirely failing set.

⛔ DO NOT READ player_fit_fraction / player_clearance FROM THE SHOWCASE
LINE. Those come from the COARSE step-4 measurement in the same log line,
where the 1.36-voxel player is sub-cell and fit is unmeasurable --
player_clearance reads a uniform "1.00 voxels" for all eight archetypes,
which is one cell, not one voxel. Only restricted_law is at step 1 and
only it is trustworthy. Fixing that mixed-resolution reporting is next.

So scale was necessary and insufficient. My hypothesis was that bore
diameter versus body height was the binding constraint; doubling it moved
nothing measurable. Something else is preventing player-sized volume from
existing at all -- worth finding before tuning any further.

Codex applied these edits and then died mid-poll without validating them
(third time a killed agent left a clean-looking unbuilt diff). Build and
suite run by me: build clean, 30 succeeded / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8V9my1PL68pmQb2gutZZV
2026-09-06 00:43:33 +02:00
Fr0zkaandClaude Opus 5 eb47efdec9 feat(measure): a player does not fit. The law was passing impossible routes.
Jahni walked composed strates for the first time: "'maze' is way too tight
tunnel radius wise, i can't enter nor anything". The candidates he could not
enter were the ones our metrics ranked BEST -- Maze had the highest walkable
fraction (.285) and highest connected surface (.99) of all eight.

⭐ THE HEADLINE, AND IT IS BAD NEWS WORTH HAVING:

  old arrival->departure law     passing on 7 of 8 candidates
  player-fit-restricted law      CONNECTED ON 0 OF 8

**The primordial law has been passing on routes no player fits through.**
Every "the world is traversable" claim in this project measured air, not
passage. Player-fit fraction across the eight showcase candidates ranges
.0000 (FloatingIslands -- literally zero) to .0117. The season composer now
returns 0 survivors for 6 required slots, and the showcase gate rejects
8 of 8.

That is the correct outcome. The gate is not tuned to a threshold; it
requires a resolved player-fit volume and a connected player-fit route.

Capsule confirmed by Jahni as the base ACharacter: 34 cm radius, 88 cm
half-height = 1.36 voxels radius, 7.04 tall. Named constant with the
derivation beside it.

Two traps avoided, both of which would have produced confident nonsense:
- THE COARSE GRID CANNOT RESOLVE A PLAYER. SampleStep 4 makes a cell 4
  voxels, ~3x the player's radius, so fit is below the resolution of every
  metric built so far -- which is exactly why nothing flagged an impassable
  maze. Fit runs on step 1 and REFUSES (bPlayerFitResolved=false + reason)
  when the step is too coarse.
- Manhattan distance >= Euclidean, so reusing the existing transform for
  clearance would OVERESTIMATE free space and report fits that do not
  exist. Clearance uses a separate Chebyshev transform; Manhattan stays
  only on the legacy feature-scale metric.

⚠️ MY ROUGHNESS HYPOTHESIS WAS WRONG, and Codex disproved it rather than
confirming it. Forcing SurfaceRoughness=0 on the Maze moved PlayerFit
.0105 -> .0138 and the restricted law stayed NotConnected. Minimum corridor
clearance is 1.00 voxel (25 cm) with or without roughness -- the corridors
are simply too tight for a 34 cm capsule, full stop.

Roughness IS still a real pinch risk where reach exceeds corridor radius:
Underwater 2.21x, FlatPlain 2.31x, CrystalChamber 1.81x, TunnelNetwork
1.44x on corridors, VerticalShafts 1.30x on connectors. The 5.625-voxel
default bound stays dangerous.

Islands measured, not changed (feel is Jahni's call): 0/2 touch either
seal, median clear gap 47.8 above / 9.0 below -- but effective radius
spans 27-258 voxels at H=128 with ThicknessRatio .546, so the worst case
has zero guaranteed separation. Recommendation recorded: radius back to
18-42, StrateHeightInChunks 4 -> 6, ThicknessRatio <= .40.

Build clean, 30 succeeded / 0 failed, verified in the namespace log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
2026-09-05 12:33:58 +02:00
Fr0zkaandClaude Opus 5 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 8e346a3 made EffectOverBox correct in ISOLATION,
proved on a stack no builder produces. Keeping the force would have cost
tile skipping on every composed world forever.

VF_RollStrateRegionCount moved behind WITH_EDITOR while the partition-seed
helpers stayed runtime: rolling out, evaluation in. Lateral regions remain
gated off.

⚠️ Sol hit its usage limit mid-task, so the edits arrived unvalidated. I
built and ran them myself rather than assume -- and they did not compile:
VoxelForgeComposerSeasonTest.cpp was missing UObject/Package.h, which every
other test picks up transitively through the shared fixture it does not use.
One include; every NewObject/GetTransientPackage in the file was failing.

Editor build clean. SHIPPING BUILD SUCCEEDS -- the invariant that proves no
composer roller is linked into a packaged game. 30 succeeded / 0 failed.

⚠️ UNVERIFIED: whether it looks right on screen. Nothing headless can tell
us that; it needs Jahni to assign a season asset and press play.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
2026-09-05 08:37:01 +02:00
Fr0zkaandClaude Opus 5 088b40216d feat(composer): showcase of all 8 archetypes -- and "not navigable" answered
Jahni asked for one strate per archetype to look at, and said the previews
looked "not navigable nor anything except maybe the mazes". Both delivered.

⭐ PART A -- HIS EYE WAS RIGHT, AND SO WAS THE METRIC. BOTH EXPLANATIONS
WERE TRUE, and they split by archetype:

  archetype        walkable   FLOOR AREA   largest connected surface
  CrystalChamber     .059        .9999          1.00
  FlatPlain          .048        .9985           .9998
  SurfaceWorld       .064        .9374           .9936
  Maze               .285        .2629           .9905
  Underwater         .229        .2061           .3312
  TunnelNetwork      .155        .2084           .4640
  VerticalShafts     .108        .1138           .1449
  FloatingIslands    .004        .1016           .4992

1. THE METRIC WAS WRONG FOR CHAMBERS. Walkable fraction is volumetric: an
   air cell with solid one step below and headroom above. In a cathedral
   almost no air cell is near a floor, so a huge open room scores ~.06 while
   a tight maze scores ~.29. It was measuring CORRIDOR-NESS, not
   navigability -- and mazes win that by construction, which is exactly the
   pattern he spotted. Slab families actually have 94-100% walkable FLOOR
   AREA, fully connected. They were never the problem.

2. AND FOUR ARCHETYPES ARE GENUINELY FRAGMENTED, which is a real content
   finding, not an artifact. Largest connected walkable surface:
   VerticalShafts .14, Underwater .33, TunnelNetwork .46, FloatingIslands
   .50. In VerticalShafts, 86% of the floor is unreachable from the biggest
   patch. The primordial law still passes -- you CAN get from arrival to
   departure -- but most of the floor is not part of that. That is precisely
   "not navigable" and it needed a new metric to see.

New metrics: WalkableFloorAreaFraction, NumWalkableSurfaceComponents,
LargestWalkableSurfaceShare. A floor you cannot cross is not a floor.

Web references checked, per his suggestion -- we invented every metric in
this project without once testing them against reality: NPS solution-cave
and Mammoth Cave morphology, Lehman Cave dimensions (Talus Room 90x376 ft,
21,511 sq ft floor, 113 ft ceiling), Deep Rock Galactic cave-design notes,
Team Cherry on Metroidvania maps. They support heterogeneous room+passage
space with branches and shafts -- NOT a universal floor-to-volume target.
So there is no single number to optimise, which is itself worth knowing.

PART B -- Saved/VoxelForge/Showcase/index.html, 8 cards, 64 PNGs, fine
renders (step 1, radius 64, centred on LargestComponentPoint), 0 blanks.
Each card carries the exact PIE values to type in, because navigability is
judged by walking:

  CrystalChamber  seed 7331 idx 34 slot 1     Maze            seed 0 idx 58 slot 1
  FlatPlain       seed 0    idx  4 slot 1     SurfaceWorld    seed 0 idx 11 slot 2
  FloatingIslands seed 0    idx 34 slot 1     TunnelNetwork   seed 0 idx 61 slot 1
  Underwater      seed 0    idx 46 slot 1     VerticalShafts  seed 0 idx 15 slot 1

All bComposerRollStructure = false. Selection searched seeds 0/7331 x
indices 0-63, applied the three hard gates, then scored
floor_area + .25*clearance + .10*largest_surface.

Caveats kept honest: floor area is a sampled XY proxy, projected
connectivity is not a 3D traversal proof, "best" means best within the
searched range.

Build clean, 30 succeeded / 0 failed, verified by me.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
2026-09-05 06:59:51 +02:00
Fr0zkaandClaude Opus 5 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
2026-09-05 05:06:32 +02:00
Fr0zkaandClaude Opus 5 42e1f0ec8c feat(composer): Tier 4c -- a season that can actually ship, with a veto page
The composer could invent, measure and show strates. What did not exist was
an ARTEFACT a season could ship. Now it does.

VF_ComposeSeason: generate N candidates -> measure -> reject failures ->
select a descent sequence -> emit a JSON manifest + a review page.

⭐ THE PROPERTY THAT MATTERS: round-trip bit-identity. The manifest was
loaded, every selected stack rebuilt, and 393,216 density samples compared
BIT-IDENTICALLY. Re-composing the same seed yields byte-identical JSON.
A manifest that cannot reproduce its own world is worthless, and that is
the one thing that could not be assumed. Floats serialise as readable
values PLUS exact IEEE bits, so a season survives a text round trip.

Test season: 24 generated, 19 survived the hard gates, 6 selected.
18 not selected -- 4 vacuous, 1 primordial-law failure, 13 policy
non-selections. 4 grounded, 2 outliers. EVERY selected slot passes the
primordial law, asserted per slot rather than sampled.

Selection policy is explicit, named and PROVISIONAL, because Jahni has not
defined "good" yet and said so: "it'll be easier once i'm able to see the
strates candidates." So no quality score was invented and presented as
settled. The knobs are 75/25 grounded-to-outlier (outlier axis = distance
from corpus centroid), adjacent-similarity threshold .20 with penalty 1.0,
new-archetype bonus .10, new-recipe bonus .05, boss cadence every 5th
non-terminal slot. All of them are his to turn.

The veto is a review page in DESCENT ORDER -- the season read top to bottom
the way it is played, not the survivor/outlier ordering the candidate sheet
uses. Reuses VoxelStratePreview rather than a second renderer.

  Saved/VoxelForge/SeasonTest/season_0000_seed_9320/season_manifest.json
  .../review/index.html

⚠️ FIVE GAPS CODEX RAISED, recorded not buried:
1. §4's Tier A is strates PLUS passages; only the strate spine is persisted.
   Manager-generated passage geometry and content placement are not.
2. Authored fixed strates keep scalar vectors + asset paths, not every
   asset-level passage/biome/disturbance/content field.
3. Boss slots are MARKERS, not arena or content guarantees.
4. WorldRadiusVoxels == 0 is enforced as specced but contradicts a
   production bounded world -- Jahni's call, still unset by design.
5. Bit identity is proven for the same build and platform. A frozen season
   spanning engine versions needs an explicit schema contract. Real risk,
   since seasons freeze generation for months.

Build clean, 28 succeeded / 0 failed, verified by me.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
2026-09-05 02:55:50 +02:00
Fr0zkaandClaude Opus 5 0b3ec18b43 feat(composer): PROMOTION -- the corpus grows itself, and it did not collapse
Tier 5. §3.3's answer to Jahni's "I'd like the system to not rely at all on
the handmade ones": promotion does not REMOVE the authored seed, it dilutes
it. Every invented strate that measures well rejoins the corpus.

Five simulated seasons, 24 candidates each:

  season  corpus  project/default/promoted  survival  spread
    1       15          4/8/3               20.8%     0.869
    2       20          4/8/8               54.2%     0.849
    3       20          4/8/8               54.2%     0.849
    4       21          4/8/9               45.8%     0.916
    5       21          4/8/9               50.0%     0.916

Corpus 12 -> 21, of which 9 are discovered. Survival roughly doubles.

⭐ AND THE COLLAPSE RISK DID NOT MATERIALISE, which was the thing worth
measuring rather than assuming. Promotion selects survivors, and blending
survivors repeatedly can converge the corpus onto a narrow safe region --
the composer would get better at passing and worse at surprising. Spread
went 0.869 -> 0.916. It dipped and plateaued mid-run, but did not narrow.

Recorded honestly: seasons 3 and 5 promoted ZERO. The near-duplicate gate
(normalised metric distance < 0.20 over air, largest share, walkable,
feature scale, clearance) is doing its job, but it also means the corpus
saturates at this scale. Five seasons of 24 demonstrates the MECHANISM, not
§3.3's "season 5 blends 200 discovered ones" -- that needs the overnight
budget, not a test.

Safety: a promoted record is RE-MEASURED on load, never trusted. Verified by
corrupting a stored metric -- the loader discarded it and re-derived the
geometry. A stale or tampered record therefore cannot introduce an
unescapable strate into a future season, which is the one failure that would
matter. Provenance (project/default/promoted) is tracked and logged.

Codex's recommendation, and I agree: keep a small explicit OUTLIER QUOTA
among candidates that pass every hard gate. This run showed no collapse, but
survivor-only promotion keeps the long-term risk, and a quota costs nothing.

PART A: the fine ROI now centres on LargestComponentPoint instead of (0,0).
Blank fine renders 6/42 -> 3/42. The label now reads "largest open space",
not "strate centre" -- the image moved, so the caption had to.

Build clean, 27 succeeded / 0 failed, verified by me. WorldRadiusVoxels=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
2026-09-05 01:46:38 +02:00
Fr0zkaandClaude Opus 5 43eaf1ef0e test(composer): the handmade corpus is load-bearing for QUALITY, not validity
Answers Jahni's standing wish directly: "in the best of both worlds, i'd
like the system to not rely at all on the handmade ones."

Three arms, same size, same measurement, same criteria:

  approach              survival   walkable mean  feature-scale mean
  corpus blend (today)  13/16 81%    0.0661          92.3
  naive uniform          7/16 44%    0.0126          64.6
  constraint-sampled     7/16 44%    0.0129          31.4

⭐ THE ANSWER IS NOT THE SURVIVAL RATE, IT IS THE QUALITY GAP. Constraint
sampling produces VALID worlds -- it passes the primordial law more often
than naive rolling (1 law failure vs 3) -- but the survivors are materially
worse: ~5x lower walkable fraction and ~3x lower feature scale. A composer
that emits technically-passing but uniformly mediocre places has not solved
the problem.

So the corpus carries something the declared constraints do not. §3.3 said
independent rolling destroys the CORRELATIONS; stating those correlations
explicitly recovers validity but not the tacit quality that authored
vectors encode. That is worth knowing and it was not knowable by argument.

⇒ VERDICT: keep the corpus in production. Constraint sampling is a
corpus-free BOOTSTRAP (it can seed a corpus from nothing), and PROMOTION
(Tier 5) is the real route off handmade content -- dilution, not removal.
Naive rolling is rejected outright.

⚠️ UNDER-POWERED, stated plainly: 16 candidates per arm, not the 256 the
spec asked for -- the fixed 8M-cell box/law workload made 256x3 too slow to
finish. The 5x walkable gap is large enough to be believable at n=16, but
the survival rates (44% vs 44%) are not separable at that sample size. Do
not quote these as precise.

Authored-corpus audit, and Codex read it correctly: 3 of 12 vectors violate
the proposed relations (DA_Strate3 room coverage 2.68 vs bound 0.35;
Default_SurfaceWorld ground bound; Default_VerticalShafts shaft radius 5 vs
roughness margin 5.875). That does NOT mean those assets are broken -- it
means the new relations are conservative HYPOTHESES, not universal corpus
invariants. Recorded as such.

Box law held across all three arms: 768 proved, 673,486 voxels brute-forced,
0 violations. §6.2 did not reopen under corpus-free parameters.

Also noted: "uniform within clamps" was underspecified in my spec -- many
fields have no finite upper clamp. Both corpus-free arms use the same
declared H-relative envelopes so the comparison stays honest.

Build clean, CorpusFree green, WorldRadiusVoxels=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
2026-09-05 00:04:24 +02:00
Fr0zkaandClaude Opus 5 d717bc74ff feat(editor): walk a composed candidate in PIE + why rooms read angular
PART B -- the thing Jahni asked for: "any way to test those in game?"
A candidate existed only as measurements and PNGs. Now he can stand in one.

  Select the VoxelWorld actor in PIE -> Live Edit|Composer ->
  ComposerSeed / ComposerCandidateIndex / ComposerTargetStrateIndex /
  bComposerRollStructure -> click Apply Composer Candidate.

The button and the automation tests call the SAME roll wrapper, which was
the load-bearing requirement: a second roll path could drift, and then he
would be judging a different world than the contact sheet describes. A
determinism sample is logged on apply (seed, index, recipe, archetype,
metrics, epoch, eight-point density verdict) so drift is caught the day it
happens rather than in a month.

Hazards handled, since this is the first composer code to touch the live
runtime path: preflight materialisation before any live mutation, generation
and decoration tasks drained, PassagesVersion bumped to invalidate
thread_local caches, reuse of the existing RegenerateAllChunks / Epoch
invalidation rather than a new path, no Initialize() or GeneratePassages()
during apply (the re-entrancy trap), DiffLayer locks untouched, and custom
recipes conservatively force ClassifyTile to Mixed.

Scope is deliberate: a DENSITY-ONLY overlay. Layout height, authored
passages, content, water and biome metadata stay from the existing slot;
RebuildStrates() clears it. A full composed layout replacement needs a safe
rebuild path that does not exist yet.

Editor-only, verified: a Shipping build succeeds with the bridge excluded
(96/96 actions), so §3.1's "offline only, never on a player's machine"
still holds.

⚠️ UNVERIFIED BY DESIGN: a button cannot be tested headlessly and Codex was
told not to claim it works. Jahni presses it and confirms the visual plus
the "determinism=PASS" log line.

PART A -- why rooms read angular, measured not guessed. One of three room
shapes is VoxelSDF::RoundedBox:
  ShapeA = (.8 Rxy, .8 Rxy, .8 Rz)   ShapeR = .25 Rxy
so rounding is 31% of the horizontal half-extent but 78% of the vertical.
Corners are rounded; the broad planar FACES survive. SmoothMin (blend 4)
softens room/tunnel junctions not isolated corners; the cave warp bends
geometry but preserves flat regions; roughness is surface-scale only.
The name is accurate -- "rounded box" is not "blob".

NOT ACTED ON. Jahni corrected his own read: the angular archetype is Maze
(lattice source, rectilinear BY DESIGN and correct), and TunnelNetwork is
"alright-ish". Codex's recommendation is recorded for if he ever wants it:
~5-10% boxes, 10-20% capsules, rest ellipsoids, or a larger ShapeR.

Also closed REVIEW_FINDINGS EVoxelPassageType/Style as WONTFIX, enacting a
2026-08-16 judgement that recommended closure and was left open.

Build clean (Editor + Shipping), 25 succeeded / 0 failed, run by me.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
2026-09-04 22:03:01 +02:00
Fr0zkaandClaude Opus 5 90c40c8d5a feat(preview): fine ROI renders + density contour -- the caves look like caves
Implements Codex's own recommendation from its critique, after it settled
the owner's two observations with evidence:

  "hard walls"  -> THE RENDERER. rg (room graph) re-rendered at step 1 is
                   rounded and organic. The hard edges were 4-voxel
                   sampling drawn nearest-neighbour. Fifth time in this
                   project that an alarming result was the instrument.
  "like a maze" -> REAL AND CORRECT. lt (lattice) stays rectilinear at
                   step 1 because it IS a maze; 19 of 64 candidates use
                   that source.
  "noisy"       -> ALIASING. nr#22 speckle 7 solid / 1 air at step 4 ->
                   0 / 0 at step 1, broad shapes intact.

Built: an optional fine ROI pass at step 1, radius 64 (~1.8M cells, cap
2M), run only for the 42 coarse survivors, plus a deterministic density=0
CONTOUR rendered beside the filled view.

Both views are kept side by side on purpose. The filled view is honest
about what was sampled and carries the walkability evidence; the contour
is legible. Codex's line, and it is the right principle: anti-aliasing the
coarse image "would only cosmetically blur the evidence and could invent
geometry". Do not replace evidence with beauty.

Fine labels carry their own sample step and resolved window so a fine
image can never be mistaken for a coarse one, and a MaxCells refusal shows
in the page rather than looking like a failed render.

  fine sampling 71.1 s · rendering 0.6 s · peak grid 9.34 MB
  42 filled pairs, 42 contour pairs, 0 refusals, 424 image links, 0 missing

Codex corrected my spec twice in the critique, both sharp: a binary step-1
raster ALWAYS has one-voxel axis-aligned edges, so only persistent
LARGE-SCALE right angles prove boxy generation (my criterion would have
produced a false "it is boxy" verdict); and persistent speckle is not
necessarily a roughness modifier.

Roughness deliberately untouched -- its call to judge that only after fine
rendering exists, and it stands.

⚠️ KNOWN, next: the fine ROI is centred on (0,0), so a candidate whose
geometry sits elsewhere renders blank (candidate 003 is a flat sheet with
no surface in the ROI). FVoxelStrateMetrics already carries
LargestComponentPoint -- centre the ROI there instead.

Build clean, 25 succeeded / 0 failed, verified by me.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
2026-09-04 20:05:14 +02:00
Fr0zkaandClaude Opus 5 993512f0bc feat(composer): F1 -- the candidates are VISIBLE, and detail is invisible
PART B: the preview. Jahni, asked what "good" means for ranking a season:
"good is hard to describe, i believe it'll be easier once i'm able to see
the strates candidates." So the ranking policy was blocked on this, not on
more metrics. COMPOSER-NOTES §3.4 promised the preview comes nearly free
once the measurement exists; collected.

Per candidate, rendered from the grid the measurement ALREADY built (no
second sampling of the world): a vertical XZ section and a plan XY slice,
solid dark, air light, walkable cells highlighted, with a chunk scale bar
so a corridor is distinguishable from a cathedral. Plus a self-contained
index.html contact sheet of all 64 -- survivors first, outliers first
within them, recipes, metrics, and every rejected candidate labelled with
WHY (vacuous 3, fragmented 2, law failed 19).

  Saved/ComposerPreview/<runid>/index.html   64 candidates, 128 PNGs

Two honesty rules are on the page itself, not buried in a comment: every
number states the window that produced it (margin/step/Z span), and a
2D slice CANNOT show connectivity -- two regions that look joined in one
slice may not be.

Plan-Z is chosen by highest solid/air boundary-transition count, not
hardcoded mid-height; a slab strate would otherwise render as a blank
sheet. Codex flagged the ambiguity in my spec and picked correctly.

PART A: bootstrap jitter for zero-spread fields. ±25% of a field's own
magnitude when corpus spread is ~0, ±15% of measured spread otherwise,
clamps last. Marked in code as a BOOTSTRAP for a starved corpus that
promotion (Tier 5) should retire. 1,422 applications over 168 fields in
the parameter roll; 6,848 over 107 in the structure roll.

⚠️ AND IT DID NOT BROADEN THE DISTRIBUTIONS. Feature scale 0-376 either
way; walkable actually NARROWED (.389 -> .287). Codex's conclusion, and I
agree: the measurement is INSENSITIVE to these fields, not merely
corpus-starved.

The reason is now obvious and worth writing down: the grid samples every
4 voxels, and cave DETAIL -- terraces, scallops, ribbing -- lives at 1-3
voxels. It is below the sampling resolution. A step-4 grid structurally
cannot see feel; it sees size. So "vary the detail" and "score the detail"
need different instruments, and the preview images inherit the same limit.

Box proofs stayed sound under wider jitter: parameter roll 1,204 proved /
1,602,524 voxels / 0 violations; structure roll 659 proved / 877,129 /
0 violations. §6.2 did not reopen.

fable-idea.md F1 ticked (measurement + preview).

Build clean, 25 succeeded / 0 failed, verified by me.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
2026-09-04 17:35:07 +02:00
Fr0zkaandClaude Opus 5 f088c19f88 feat(composer): cave FEEL is rollable -- 23 of 34 fields were live all along
Jahni: "we need to be able to have everything tuned, so feeling can be
quite different." So the 34 excluded terrain-detail fields had to be
settled -- they are the difference between varying how BIG a cave is and
varying how it FEELS.

Settled by EXPERIMENT, not by reading, because the exclusion rested on the
same kind of structural claim that produced the withdrawn fuse assertion
and the "99.95% void" reading. Sample GetDensityAt over a lattice, change
ONLY those fields, re-sample, per operation group:

  LIVE (23 fields)  terrace, layer-line, ribbing, overhang, cliff,
                    scallop, arch, dome, pinch  -> now rolled
  DEAD (11 fields)  column, pit, chimney -> pre-baked into the chunk cache
                    from Definition->TerrainOperations, so the values in
                    FStrateGenerationParams are transport, not tunables

The call chain for the dead ones: BuildParamsFromDefinition returns only
the base params (VoxelStrateManager.cpp:1399); VoxelGenerator separately
reads Definition->TerrainOperations (VoxelGenerator.cpp:1099);
BuildChunkCache calls RoomOp->ApplyTo and pre-bakes pits/chimneys/columns
(VoxelCaveMorphology.cpp:1741).

⚠️ AND THE TRAP FIRED, exactly as specced against: these are native
unreflected fields, so REMOVING THEM FROM THE EXCLUSION LIST WOULD HAVE
ROLLED NOTHING. A knob that looks like it works and does not is worse than
an honest exclusion. An explicit jitter path was needed. This is why the
task was specced as an experiment with a liveness assertion (24 group/path
rows, 0 mismatches) rather than as a list edit.

⚠️ HONEST NEGATIVE: rolling detail did NOT materially broaden the measured
distributions. Feature scale 4-376 -> 0-376; walkable unchanged. All 17/17
TunnelNetwork/Underwater candidates activated live detail and every one of
the nine live groups appeared, so it is not that the fields are inert --
it is that most of them have ZERO CORPUS SPREAD, because the four authored
assets leave them at header defaults. Jitter is a fraction of measured
spread, and a fraction of zero is zero.

⇒ Real limitation, recorded not patched: "ranges come from corpus spread"
(§3.3) is right in principle and DEGENERATES on a homogeneous corpus. The
composer needs a bootstrap for zero-spread fields. Next.

Box verdicts with detail rolled: 1,228 proved, 1,634,468 voxels
brute-forced, 0 violations. Survival 59/64.

Recommendation recorded for later: roll terrain-operation parameter
STRUCTS directly into a transient candidate pool rather than corpus-ing
over assets -- there is exactly one such asset in the project, so an asset
corpus would starve like the strate corpus did.

Build clean, 25 succeeded / 0 failed, verified by me.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
2026-09-04 16:30:57 +02:00
Fr0zkaandClaude Opus 5 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 8e346a3 was for. A false uniform verdict on an invented stack
means no geometry, no collision, and a player falls through the floor --
and it is the failure that cannot be seen without brute force. Zero.

Survival is 71.9% against 92.2% for the parameter roll, and that is the
EXPECTED direction, not a regression. Rolling structure aggressively is
supposed to fail sometimes; §3.5's reject-and-resample is what that is for.
A composer that only emits safe stacks is not exploring. Deliberately not
tuned.

Recipes are serialisable (op ids + param-block bindings), so a season
manifest can ship one -- which is also what will let an archetype become a
spatial REGION inside a strate rather than a label on it.

Build clean, 24 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
2026-09-04 15:21:27 +02:00
Fr0zkaandClaude Opus 5 190ff381c0 feat(composer): it invents strates that work -- 59/64, and §6.2 is CLOSED
The corpus was starved: the loader followed the active settings pool and
found ONE vector, so all 64 candidates were bit-identical and every number
was a placeholder. It now loads every UVoxelStrateDefinition via the Asset
Registry (Saved/Autosaves and Saved/Cooked excluded -- including them would
silently weight the corpus toward whatever was autosaved last) plus the
per-archetype defaults from VoxelStrateTypes.h, which are themselves
hand-tuned known-good vectors. Blending stays WITHIN an archetype;
different archetypes read different subsets of the 73 fields.

⭐ THE COMPOSER WORKS. 64 candidates blended from real parents, every one
measured:

  survival 59/64 (92.2%)
  non-vacuous 60 · largest-share >= .50  60 · primordial law 63 · roll
  failures 0

and they are genuinely different worlds, not one world 64 times:
  air fraction   .0137 -> 1.000
  walkable       .000  -> .389
  feature scale  4     -> 376 voxels
  archetypes     TunnelNetwork, VerticalShafts, FlatPlain, CrystalChamber,
                 SurfaceWorld, Underwater, FloatingIslands, Maze

⭐⭐ §6.2 IS CLOSED, and it was the real risk. Box-verdict bounds were only
ever proved against the parameters Jahni typed; the whole worry was that
invented parameters would violate a bound nobody had checked -- silently,
leaving no geometry and no collision. Against ROLLED parameters:

  1,224 boxes proved (549 AllSolid, 675 AllAir), 1,336 Mixed
  1,629,144 voxels brute-forced
  0 VIOLATIONS

The bounds hold across the rolled range, not just at authored points. The
isolation work in 8e346a3 is what made that true.

The 5 rejections are a real finding, not noise: FloatingIslands can jitter
its islands out of existence and return an all-air strate (air 1.000,
walkable 0.000, feature scale 0). That is exactly what reject-and-resample
(§3.5) is for -- the measurement caught it without anyone anticipating it,
which is the whole argument for measuring rather than trusting.

Roll+measure 112.6 s for 64 candidates. §3.1's budget is "runs overnight",
so there is room for thousands.

Build clean, 23 succeeded / 0 warnings / 0 failed (the deliberate vacuity
warning is gone because the check is no longer vacuous). Verified by me.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
2026-09-04 08:32:01 +02:00
Fr0zkaandClaude Opus 5 358bd415a9 feat(composer): the parameter roll -- infrastructure real, corpus starved
Tier 4a. The first piece of the composer that INVENTS rather than fixes.

The design, from COMPOSER-NOTES §3.3: do NOT roll 73 parameters
independently. The coherent region of a 73-dimensional space is a
vanishingly thin sheet and uniform sampling never lands on it -- and
independent rolling destroys the CORRELATIONS (room spacing <-> room
radius <-> tunnel length) that make a vector coherent. So blend known-good
vectors through the existing FStrateGenerationParams::Lerp instead:
constraint satisfaction is free, because a convex combination preserves
every relation holding in both parents. Min <= Max stays true because it
was never broken.

Built: FVoxelStrateCorpus (loads authored vectors, computes per-field
spread), VF_RollStrateParams (2-3 weighted parents, convex blend, jitter
by +-15% of each field's MEASURED corpus range -- not by the clamps, which
are "don't type nonsense" limits, not "this makes a good world" limits),
and VoxelForge.Composer.ParameterRoll. Determinism asserted by rolling
twice and comparing every field.

Exclusion list found by reflection, not assumption: StrateTopWorldZ and
StrateBottomWorldZ (manager-owned runtime bounds) plus 34 terrain-op
transport fields (TerraceStepHeight..PinchLength) that are copied in from
room operators rather than being strate tunables. Bools inherit from the
dominant parent; ints/enums use Lerp's existing snap.

⚠️ THE RESULT IS VACUOUS AND SAYS SO. 64/64 candidates "survived" -- but
the corpus loaded ONE vector (DA_Strate3), so all 64 are bit-identical.
100% survival of a single point is not evidence of anything. The box-
verdict check is vacuous too: 2,560 boxes, 0 proved, 0 voxels brute-forced,
so §6.2 is NOT closed. The test emits a warning saying exactly this rather
than reporting a clean pass, and no workaround was added to hide it.

Four strate assets exist (DA_Strate1..4); the loader found one. Fixing the
corpus is the next task -- with one parent there is nothing to blend, and
every number above is a placeholder.

Also owed: clamp metadata is editor-only, so the safety net needs a
cook-time baked table before this ships.

Nothing calls the composer from any runtime path (§3.1: offline, once per
season, on a build box).

Build clean, 23 tests / 0 failed (1 carries the deliberate vacuity
warning), verified in VoxelM.log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
2026-09-04 07:19:11 +02:00
Fr0zkaandClaude Opus 5 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
2026-09-04 06:20:40 +02:00
Fr0zkaandClaude Opus 5 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
2026-09-04 04:30:45 +02:00
Fr0zkaandClaude Opus 5 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
2026-09-04 03:57:30 +02:00
Fr0zkaandClaude Opus 5 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
2026-09-04 01:17:12 +02:00
Fr0zkaandClaude Opus 5 44938f3f98 fix(measure): a blocked route is not a disconnected pair
VF_AreConnected took the shortest coarse BFS route, re-walked it at full
resolution, and on hitting a wall returned false and gave up -- never
looking for another way round. That is a false NEGATIVE, and it was
inflating the failure count of a bug we were actively trying to fix.

On refutation it now excludes that specific coarse step and re-searches,
up to a bounded budget (16). Outcomes are distinguished honestly:
  Connected                   a route survived full-resolution verification
  NotConnectedAtThisResolution no coarse route remains after exclusions
  CoarseLiedBudgetExhausted    UNKNOWN -- routes kept being refuted
The third is deliberately NOT folded into either other verdict. Collapsing
"unknown" into "disconnected" is precisely what misled us.

  arrival->departure   7/16 -> 14/16
  All 7 former COARSE_LIED became CONNECTED.
  arrival->spine   15/16 connected      departure->spine 15/16 connected
  per-archetype law: 6/6 applicable routes pass

Retries needed are small -- max 3 per query, 0 budget hits -- so 16 is
generous rather than load-bearing. Runtime slightly IMPROVED (45.3 -> 44.9 s
on the 16-seed sweep) because a satisfied query stops early.

The false-POSITIVE guard is untouched and is now stronger in meaning:
every Connected still requires a fully re-walked, air-verified route, and
it now says "this specific route is open" rather than "the first route I
tried was open". The controlled wall still returns BudgetExhausted after
exactly 16 retries, and a repeat query is bit-identical in verdict, snap
flags and retry count.

⚠️ REMAINING: seeds 6 and 14 are genuinely NOT_CONNECTED. Real or a
step-2 resolution limit is not yet settled -- refine before touching
generation again, per COMPOSER-NOTES §13.

No generation, placement or parameter changed. Verifier only.
Build clean, 20 succeeded / 0 warnings / 0 failed, confirmed in VoxelM.log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
2026-09-03 23:56:25 +02:00
Fr0zkaandClaude Opus 5 271bbdd034 docs(composer): record what measuring the world actually found
The notes described Tier 1 as done and had no record of today. Adds §13
(Tier 2 built, and the three artifacts that looked exactly like findings),
§14 (the VerticalShafts bug end to end -- three bugs wearing one coat),
§15 (working notes that cost something), and corrects the Tier 1/Tier 2
entries in §7.

The rule worth keeping, because it held three times out of three: when a
measurement reports something alarming, SUSPECT THE MEASUREMENT FIRST.
"FlatPlain is 99.95% void", "5 of 15 endpoints disconnected" and "7 of 16
seeds fail" were all method, not world.

Also recorded: the two guard directions are not symmetric (a false
positive drops a player through the floor; a false negative merely sends
you fixing healthy code), percolation is a phase transition and not a
knob, and Tier 1 shipped half built in a way only a connectivity
measurement could reveal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
2026-09-03 15:21:41 +02:00
Fr0zkaandClaude Opus 5 9d5633ad54 fix(passages): aim the UPPER mouth too -- Tier 1 was only half built
VF_SuggestLandingPoint was called exactly once per passage, for the LOWER
mouth. The upper mouth fell out of the control-point chain with no landing
query at all: Tier 1 fixed where a player ARRIVES and never touched where
they DEPART. That predicted the measurement exactly -- departure->spine was
0 connected across all 16 seeds.

The ring proxy could never have caught this. It checks each mouth for open
space individually, and an unaimed mouth still has open space around it,
because the passage tube carved some. It took a connectivity measurement.

Each mouth now queries ITS OWN strate independently, which is what keeps
the layout a set rather than a sequence -- passage i never reads passage
i-1, so LayoutOrderIndependence stays green. Seed contracts kept unmixed;
SurfaceWorld still refuses; a declined query still preserves today's
placement rather than degrading it.

  departure->spine   0 -> 9 connected of 16
  arrival->departure 4 -> 7 of 16
  aiming coverage    upper 6/7 and lower 6/7 mouths now aimed at
                     generator-confirmed open space

⚠️ ACCEPTANCE STILL FAILS at 7/16, and the breakdown says why it is not
what it looks like:

  arrival->departure: CONNECTED 7, NOT_CONNECTED 2, COARSE_LIED 7

Only TWO seeds are genuinely disconnected. Seven are COARSE_LIED -- the
coarse BFS found a route and the full-resolution walk hit solid on it.
VF_AreConnected checks that ONE route and gives up, so a blocked candidate
is reported as failure even when another route exists. That is a false
NEGATIVE in the verifier, and it means this acceptance number probably
understates the world. Next task fixes the measurement, not the world.

Codex was killed mid-flight by a session teardown, so the edits arrived
unvalidated; I built and ran the suite myself rather than assume.
Build clean, 20 succeeded / 0 warnings / 0 failed, verified in VoxelM.log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
2026-09-03 15:14:25 +02:00
Fr0zkaandClaude Opus 5 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
2026-08-30 17:26:20 +02:00
Fr0zkaandClaude Opus 5 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
2026-08-30 16:52:57 +02:00
Fr0zkaandClaude Opus 5 5519b323c8 test(measure): the spine is the backbone in 7 of 8 strates -- and not in
VerticalShafts

Diagnosis only. No generation or placement changed.

The hypothesis was that the (0,0) spine (OriginSpineRadius 14) is the
universal connector, so "can this mouth reach the spine" would be the
real primordial-law test. Measured at margin 0, step 2, radius 192:

  strate           spine share  largest?  arr->spine   dep->spine
  TunnelNetwork      0.979        YES     CONNECTED    not connected
  FlatPlain          1.000        YES     CONNECTED    CONNECTED
  CrystalChamber     1.000        YES     CONNECTED    CONNECTED
  Maze               0.997        YES     coarse-lied  CONNECTED
  SurfaceWorld       1.000        YES     CONNECTED    CONNECTED
  VerticalShafts     0.096        NO      not conn.    not connected
  FloatingIslands    1.000        YES     CONNECTED    CONNECTED
  Underwater         0.999        YES     CONNECTED    n/a

The spine forms one uninterrupted open interior run in 8/8 strates, and
holds essentially ALL the air in 7 of them. VerticalShafts is the lone
anomaly: the spine punches through as an isolated column holding 9.6% of
the air and is NOT the largest component.

So the archetype has three mutually unreachable things -- the spine, the
arrival network, the departure network. It has no lateral connectivity at
all. Over 16 seeds: spine open 16/16, spine largest only 8/16,
arrival->spine 1 connected, departure->spine 0 connected.

Neither extreme was right. The spine is not a universal connector, but it
is far from irrelevant: it is the de-facto backbone everywhere except
here, which reframes the bug as an ANOMALY to bring in line rather than a
missing feature to invent.

Ordering hazard confirmed on the record: making passage i depend on
passage i-1 would break LayoutOrderIndependence. Both candidate fixes
avoid it, because the spine is a fixed deterministic anchor that two
mouths can target independently.

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
2026-08-30 15:10:00 +02:00
Fr0zkaandClaude Opus 5 0bbfdeb7a7 test(measure): VerticalShafts breaks the primordial law in 15 of 16 seeds
The margin is exonerated. Every earlier run excluded 8 voxels at each
strate boundary, and voxels 4-8 of those are ordinary interior, not seal
-- the same window mistake that produced "FlatPlain is 99.95% void" at
margin 32. So the query was re-run at margin 0, 2, 4 and 8. All four stay
negative; margin 0 includes the seal itself and still reports negative.
(The implementation had clamped explicit margins to >= 1, which made the
margin-0 check impossible; Codex found and fixed that.)

Diagnosis -- this is not a near-miss wall:
  arrival   LowerPoint (65.8,  95.8, -1047.25)
  departure UpperPoint (88.4, -104.6,  -991.58)
  XY separation 201.7 voxels (~50 m), Z delta 55.7
  arrival component   22,531 cells (22.0% of air)
  departure component 21,621 cells (21.1% of air)
  nearest approach between the two components: 135-179 voxels

Two large, healthy, entirely separate shaft networks half a strate apart.
Nothing to breach; the archetype has no lateral connection at all.

16-seed sweep: 1 pass, 15 fail. Not one unlucky world -- structural.

So: descend into a VerticalShafts strate and you probably cannot reach
its exit. This is the "live hole in the shipping game" COMPOSER-NOTES
Tier 1 predicted, now measured instead of assumed.

Stated precisely, because it names the fix: VF_SuggestLandingPoint makes
each mouth land in open space, but NOTHING makes the two mouths of one
strate land in the SAME open space.

New: VF_DiagnoseConnectivity returns component sizes and the spatial
separation between the two mouths' components, so a near-miss and a
never-meant-to-connect read differently instead of both being "false".

No generation change, no threshold tuning. The fix is a design decision.

Build clean, 19 succeeded / 0 warnings / 0 failed. Sweeps 3.4 s + 6.9 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bp7FxoQcSoiuhxjuLr6aBM
2026-08-30 14:42:47 +02:00
Fr0zkaandClaude Opus 5 cd31dec869 test(measure): the VerticalShafts disconnection is real, not a coarse lie
Our guard was one-directional. FullResolutionPathIsAir catches the false
POSITIVE -- coarse says joined, reality says walled -- which is the
dangerous direction for safety, so we built it first. The opposite error
was unguarded: a corridor narrower than SampleStep falls between sample
points and vanishes, so connected regions read as separate. That is
exactly what a network of narrow vertical shafts would fake at step 4.

Refinement sweep, radius held constant for the controls:

  step 4 / r256  NOT_CONNECTED  57 comp  largest .119     458,752 cells
  step 4 / r192  NOT_CONNECTED  32 comp  largest .220     258,048
  step 2 / r192  NOT_CONNECTED  27 comp  largest .220   2,064,384
  step 4 / r128  NOT_CONNECTED  20 comp  largest .291     114,688
  step 1 / r128  NOT_CONNECTED  11 comp  largest .303   7,340,032

Both fixed-radius pairs stayed negative, so neither the step nor the
radius explains it. At one-voxel sampling the arrival and departure
mouths are still in different air components.

VerticalShafts is a live primordial-law violation: a player descends into
it and cannot reach its exit.

NotConnected is renamed NotConnectedAtThisResolution and documents its own
limit at the declaration, so a bare negative can never again be read as
proof. CoarseLied names one direction; this names the other.

Sweep split into VoxelForge.Generation.StrateConnectivityRefinement so the
fast test stays fast. 4.4 s. The MaxCells refusal path was exercised and
still refuses.

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
2026-08-30 14:24:24 +02:00
Fr0zkaandClaude Opus 5 efcc8fcb65 fix(measure): ask the primordial law the right question
Three defects, two of them mine.

1. The anchor was a guess. LargestComponentAnchor() returned a hardcoded
   point near the (0,0) spine and the comment admitted it, so
   NOT_CONNECTED meant "not connected to a guessed point". The flood fill
   already knows the answer; it now returns LargestComponentPoint (tie-
   broken by lowest cell index, so it stays deterministic),
   LargestComponentCells and NumComponentsAtLeast1Pct.

2. "Connected to the largest component" is NOT the primordial law. The
   law is that a player must not descend into a place they cannot leave
   -- a question about MOUTHS. A strate can be 57 disconnected shafts and
   be fine, provided the shaft you arrive in is the shaft you leave from.
   The test now asks arrival -> departure per strate: the LowerPoint of
   the passage arriving, to the UpperPoint of the passage departing.

3. A false verdict and a true one were indistinguishable. VF_AreConnected
   returned false both when no route existed and when the endpoint's own
   coarse cell sampled solid -- and a passage mouth is a carved tube whose
   cell centre lands in adjacent rock easily. It now returns
   EVoxelConnectivityResult and snaps to an adjacent air cell first,
   reporting the repair.

RESULT: the earlier "5 of 15 endpoints disconnected" was an ARTIFACT of
the guessed anchor plus the wrong question. Corrected: 13 of 15 connected,
0 StartCellSolid, 0 GoalCellSolid.

One real finding survives. arrival -> departure passes for 5 of 6
applicable strates; VerticalShafts FAILS -- its arrival mouth is in the
largest component, its departure mouth is not. NOT yet reported as a bug:
a coarse grid can falsely SEPARATE as well as falsely join, and a
connecting corridor thinner than SampleStep vanishes. Next task settles
it at a finer step.

Also noted: GetPassages() contains a self-referential surface entry
(0 -> 0), excluded from the chain law.

Build clean, 18 succeeded / 0 warnings / 0 failed, verified in VoxelM.log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bp7FxoQcSoiuhxjuLr6aBM
2026-08-30 14:06:43 +02:00
Fr0zkaandClaude Opus 5 13399529b5 fix(measure): measure the strate, not the band above its floor
The window excluded a whole chunk (32 voxels) at each end to avoid a
4-voxel seal band -- 8x too much. On 4-chunk strates that discarded half
of every strate, floor included, so the pass was measuring the open air
above the ground and calling it the strate.

The margin now derives from the seal that actually exists:
ceil(2 x BoundarySealThickness), clamped to [1, height/4], overridable
via InteriorMarginVoxels. The fixture resolves to 8 voxels.

FlatPlain/CrystalChamber walkable fraction: 0.00044 -> 0.0578, ~132x.
The "99.95% empty void" reading was the window, not the world.

Metrics now carry ResolvedMarginVoxels, SampledMinZ and SampledMaxZ, and
the test prints them. A measurement that does not state the volume it
measured is exactly how the previous defect survived review.

VerticalShafts largest-component share rose only 0.074 -> 0.119 across
57 components: the window explained part of the swiss-cheese signal, not
all of it. That one is at least partly real.

Coarse-lie guard still load-bearing: 24 routes checked, 3 refuted at full
resolution (was 21 / 2).

Build clean, 18 succeeded / 0 warnings / 0 failed, confirmed in
Saved/Logs/VoxelM.log rather than from the agent's summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bp7FxoQcSoiuhxjuLr6aBM
2026-08-30 13:46:28 +02:00
Fr0zkaandClaude Opus 5 6988e5ace1 feat(measure): one grid, one flood fill -- the strate measurement pass
Tier 2 slice 1 from COMPOSER-NOTES.md 3.4. A read-only, deterministic,
headless measurement of a built strate: sample a coarse grid once,
flood-fill the air once, derive every metric from that same grid.

What it answers today: air fraction, air component count and largest
component share (the swiss-cheese signal), walkable fraction, median
feature scale, median vertical clearance -- and whether two points are
actually connected.

The connectivity verdict is the point. Coarse sampling LIES about it in
the dangerous direction: a wall thinner than the sample step vanishes and
two sealed caverns read as joined. So VF_AreConnected recovers the actual
BFS route and re-walks that one corridor at one-voxel spacing; a true
verdict is never returned on coarse evidence alone. It fired: 2 of 21
routes were coarse false positives.

The test carries a negative control, because a green suite that cannot
fail is what hid the last defect. Two of them, in fact: a direct probe of
the known-solid inter-strate gap (AirFraction 0.00042), and -- Codex's
improvement on the spec -- a diff-layer box filled solid and measured
through VF_MeasureStrate itself, so an inverted sign is caught end to end
rather than around the function under test.

Build clean, 18 succeeded / 0 warnings / 0 failed.

KNOWN, fix in flight: the interior window excludes a whole chunk (32
voxels) at each end to avoid a 4-voxel seal band -- 8x too much. On the
fixture's 4-chunk strates that discards the floor, so the reported
FlatPlain/CrystalChamber air fractions of 0.999 describe the open band
above the floor, not the strate. The engine is right; the window is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bp7FxoQcSoiuhxjuLr6aBM
2026-08-30 13:20:42 +02:00
Fr0zkaandClaude Opus 5 8cd5face9b fix(strate): aim at the room's centre, not beside it
Commit 1ab8c0c reported Tier 1 working on the strength of a green suite and a
fixture reading 48/48. A million-seed sweep then showed the room query put the
landing point outside the selected room 92.795% of the time. Seven sampled
passages cannot see a systemic defect; the 48/48 was luck.

The cause was my instruction. I specced MaxLateralSnap = 0 for rooms and slabs
to "preserve existing placement", so the query found the nearest room and then
returned the CALLER's XY with that room's Z -- it located a room and aimed
beside it. Precisely the defect I had identified for Maze, VerticalShafts and
FloatingIslands, then exempted rooms from.

The function already computed the room's XY and threw it away. It now returns
it. A room's centre is inside its own room by construction, so this needs no
SDF evaluation and no warp handling -- and the centre also holds the largest
margin against the production warp, which displaces the field about 1.5 voxels:
decisive at a room's edge, irrelevant at its centre. Simpler and more robust
than the warped-SDF inside-proof I had been asking for.

Caves get a real RoomSpacing budget, because a room is an XY-sparse target just
like a maze corridor. Slabs correctly keep zero: their void band is
XY-continuous, so the requested XY is already inside it.

Room/slab rings go 38/48 to 32/32. But TunnelNetwork and Underwater now decline
entirely at the fixture seed -- with RoomDensity below 1 the nearest room is
often past one spacing, so they fall back to random reach. Honest, not landing.
Roughly 2x spacing would restore them at the cost of passages drifting from the
(0,0) spine, which is what makes them findable. Left untuned deliberately;
raising a number until coverage looks better is the move I twice told Codex not
to make.

One guard softened: the test asserted all four room/slab archetypes must answer
within seven sampled passages. That asserts a property of the sample, not the
code, and it failed on this fix, since a supported query is allowed to decline
on budget. Now reported rather than asserted. Vacuity, per-mouth ring and
footing checks, endpoint matching and the SurfaceWorld refusal all remain hard
failures.

Built clean; 17 succeeded, 0 warnings, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
2026-08-30 00:52:22 +02:00
Fr0zkaandClaude Opus 5 1ab8c0cae4 feat(strate): passages aim at open space -- for half the archetypes, measurably
Tier 1. GeneratePassages placed every passage at a random angle, distance and
reach, never consulting the destination strate. The carved tube is air by
construction, so nothing looked wrong -- but whether the lower mouth joined the
destination's connected cave space was luck. A player could descend and arrive
in a sealed pocket. The primordial law was a hope.

VF_SuggestOpenPointZ is a pure free function of (archetype, params, seed, XY):
no operator stack, no StrateManager, no cache. That is deliberate. This runs
inside Initialize, where the layout is half-built and LayoutVersion is in
flight, so anything holding a manager pointer there is a re-entrancy trap.

Coverage is four archetypes of eight, and the number is the deliverable:
7 passages, 3 aimed at generator-confirmed open space with 48/48 ring samples
air, 4 falling back. The hole is not closed. It is measured, where this morning
it was invisible.

Two corrections from Codex, both mine and both would have shipped confident
nonsense: room placement is seeded by MakeStrateSeed, not the passage salt, so
my version would have queried a cave graph that does not exist; and slabs need
the world seed because that is what GetSlabDensity uses.

And one of mine caught in review: the first test sampled GetDensityAt at the
mouth and asserted air, which is true by construction because the passage carve
is a structural-post invariant. It could not fail. Replaced with a 16-point
ring outside the carve radius requiring 8/16 air -- a proxy for connectivity,
not a proof. The proof is a flood fill, which is Tier 2.

Logging: my first spec asked for a per-passage warning on fallback. It fired
572 times and turned 14 clean tests yellow, which is precisely the anti-pattern
OPSTACK-HANDOFF.md warns about -- a warning that always means "this is fine"
teaches people to ignore warnings. Now one sorted Log line per layout build.

Built clean, suite green: 17 succeeded, 0 with warnings, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
2026-08-29 17:45:05 +02:00
Fr0zkaandClaude Opus 5 10a8bbc677 feat(strate): the layout is a set, not a sequence -- and the suite is green
Tier 0. Reordering Settings->StratePool in the editor used to change every
world in the game, because Initialize Fisher-Yates shuffled the array in
editor order. The pool is now sorted by stable soft-asset path before the
shuffle, so the layout is a pure function of (seed, pool contents as a SET).
The selection algorithm itself is untouched -- same Fisher-Yates, same cycling,
same FixedStrates handling, same Z ranges. Sorting by pointer would have looked
right and been load-order dependent, which is the trap here.

GeneratePassages walked one sequential FRandomStream across every strate, so
changing an early connection count shifted every later passage. Each draw is
now a salted VoxelHash of (seed, boundary index, connection index), with the
distributions preserved exactly -- ToFloat01 returns [0,1), so Lerp(Min,Max,t)
reproduces FRandRange precisely.

Codex implemented it and caught a real flaw in my spec: I asked for a test that
permutes the pool and asserts the passages match, but once the layout is
order-independent that test cannot fail whether or not the passage fix works --
the layout comes out identical, so even the old sequential stream would agree.
It added the perturbation that actually bites: change an early connection count
and verify every later boundary keeps its own values. It also contradicted me
on passage type being randomly drawn. It isn't, and it preserved that.

Built VoxelMEditor clean, then ran the suite headless: 16 succeeded, 0 failed,
0 not run. That also closes the thread OPSTACK-HANDOFF.md left open -- 4d33321
and 91585ea were built but never re-verified, and both acceptance conditions
are now met: violations 0 everywhere (12 of 40 TunnelNetwork tiles proved over
15972 brute-forced voxels), all equivalences green. No revert needed.

Expected: every seed now lays out differently than before. Accepted,
pre-content-lock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
2026-08-29 16:30:47 +02:00
Fr0zkaandClaude Opus 5 a60138b19c docs(composer): split the transcript -- notes become generator-only
CLAUDE.md says each doc has one job and they must not duplicate. After GDD.md
landed, COMPOSER-NOTES.md and it both held the death loop, the city tiers and
the economy -- and duplicated documents drift until neither is authoritative.

So the notes are now the engineering document and nothing else: the vision and
its correction, what the game *requires* of the generator (constraints only,
pointing at the GDD for why), the composer itself, the manifest, naming, every
code finding and hazard, the build order, and the corrections nobody should
re-derive. 1561 lines down to 527, with the game design replaced by pointers.

Two sections earn their place on their own. Section 6 is the verified code
findings, including the one that blocks free composition (EffectOverBox answers
for the source+carve pair, so an op's box verdict is only correct in the stack
it shipped in) and the primordial law's live hole (GeneratePassages never
consults the destination strate's layout, so a passage can open into a sealed
pocket). Section 8 is the list of corrections that cost real time: Phase 3 is
reopened, Jahni does not want to author strates either, peer determinism never
required matching the old system.

The full 1561-line transcript remains in git at b781e3b for the reasoning
behind anything here that reads as bald assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
2026-08-29 15:51:35 +02:00
Fr0zkaandClaude Opus 5 b781e3bab2 docs: a game design document, distilled from the transcript
COMPOSER-NOTES.md is a conversation: chronological, with my wrong turns kept
on purpose so nobody re-derives them. That is the right shape for us reading it
back and the wrong shape for anyone else, including Codex.

GDD.md is the other half -- the decided state without the journey. Seventeen
sections covering the premise, the core loop, the city, the depths, a strate,
descending, the economy, death, housing, the verbs, the season, the community
layer and the opening. Every section separates what Jahni decided from what is
still open, and design that is mine rather than his says so, so nothing reads
as settled that isn't.

Written deliberately plainer than the notes: prose rather than dense tables,
because a design document that only its authors can read has failed at the one
thing it exists for.

Also records why the file is in the wrong folder. This is a document about the
game, and most of it -- the city, combat, the economy, seasons -- is nothing to
do with VoxelForge. It lives in the plugin because the plugin is the only part
of the project under version control. It should move to the project root once
that becomes a repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
2026-08-29 15:44:35 +02:00
Fr0zkaandClaude Opus 5 b7cf12af4c docs(composer): the depths regenerate, the city does not -- game design session
Second design session. Started on the composer, ended up with the whole game's
economic loop, and one terminology fix that was overdue.

The loop, closed: the surface is a sky island or an unclimbable mountain, so
resources must come from below; the depths supply them; the resources build the
settlement; the settlement enables deeper descent. The settlement is communal,
identical for everyone, and permanent -- a season advances it one tier
(camp, village, town, city). So the wipe means something beyond freshness: the
world is impermanent, the civilization is not. We lost our maps, but we built a
town.

I had that arc backwards and it is marked as such in the file, along with the
vocabulary that caused it: "the world" was being used for two different things.
The depths are what VoxelForge generates and what regenerates each season; the
city is a plain Unreal level, permanent, and entirely outside this plugin. That
distinction is now pinned at the top of the notes.

Also settled: no bottom, boss strates every ~5 as intermediary gates, gear stats
capped around depth 30 with deeper loot buying better rolls and unique materials
rather than bigger numbers, materials tagged by difficulty tier rather than
depth so their location is rediscovered every season, and death leaving a
possessed corpse that copies your weapons without taking them -- a mirror match
that self-balances at every tier forever, provided the copied gear is never
lootable.

Two design records worth keeping: the manifest is two things (a tiny stored
spine and an infinite hash-addressable field, where the ID encodes the position
so resolving it needs no table), and the opening cinematic -- civilization
emerged from the depths, humans crystallized in the first artificial strate --
justifies the authored/generated seam in fiction. A player who senses strate 1
feels different from strate 2 is reading the story correctly, not spotting the
join.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
2026-08-28 11:56:23 +02:00
Fr0zkaandClaude Opus 5 302cf16681 fix(world): the voxel field is in ACTOR space -- make every entry point agree
The city's hole is not at world (0,0), so the AVoxelWorld actor has to move.
It couldn't: the plugin was only HALF actor-relative, and had been for a long
time without symptoms because the actor always sat at the origin.

Already correct: GetBiomeAtWorldLocation, GetVoxelSurfaceHeightAt,
UpdateDecorations, UpdateLandmarks, UpdateWater, UVoxelDensityVolume::Update,
the passage debug draw, the clipmap material.

Not correct: the streaming centre, the streaming anchors, all six carve/fill
entry points, GetStrateAtPosition, and UVoxelAtmosphereManager::UpdateForPlayer
-- whose own comment documented the assumption out loud ("actor at
origin/identity"). Carving would have landed offset; the strate lookup would
have been quietly wrong by the actor's Z.

The trap worth recording: because the content managers already convert
internally, converting PlayerLastPos once at the source would have DOUBLE-
converted four call sites. The fix has to be per-boundary. That was only
visible after reading each consumer -- the estimate before that read was wrong
in both directions.

WorldToLocalCm / WorldToLocalVoxel / LocalVoxelToWorld are now declared as the
only sanctioned boundary between Unreal coordinates and voxel coordinates, and
the real deliverable is the invariant next to them: no `/ VOXEL_SIZE` applied
to a parameter named `World*` outside those three functions. A grep can check
that. The risk was never a missed site among the ones found -- it is the next
one nobody notices.

Also adds a translation-only guard in BeginPlay. Tiles, ClassifyBox verdicts,
the clipmap window, culling and streaming distance all assume world-axis-
aligned boxes; a rotation or non-unit scale breaks that structurally rather
than gracefully, so it logs an Error naming what was set.

With the actor at the origin every conversion is identity, so this is a no-op
until the actor actually moves. Built and confirmed working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
2026-08-23 17:47:23 +02:00
Fr0zkaandClaude Opus 5 dfa1d5f5b1 test(strate): the X-macro guard -- a warning in prose does not fail CI
FStrateGenerationParams::Lerp blends the hand-written VF_STRATE_PARAM_FIELDS
list, not the struct. Add a field to one and not the other and it compiles,
tests green, and silently takes its default inside every Gradient/Interleaved
transition band -- terrain that changes shape at the boundary and nowhere else.
CODEMAP has warned about this in prose for a month.

The test expands the X-macro a third way (after LERP and SNAP), into a list of
names, and diffs it against the struct's UObject reflection. No fixture, no
world -- a shape test, instant. GExemptFieldNames is empty on purpose: every
reflected field is covered today, so an exemption has to be written down as a
decision rather than happening by omission.

The stakes rise with the world composer, which intends to invent parameter sets
through this same Lerp: a missing field would then be constant across every
invented strate, with nothing to notice it by.

(Its CODEMAP §3.12 row lands in the following commit, which also touches that
file for an unrelated reason.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
2026-08-23 17:47:07 +02:00
Fr0zkaandClaude Opus 5 eae7e68caf docs(composer): the composer runs once per season, offline -- so it can take all night
Second half of the design session. The realisation that resets the whole cost
budget: everyone shares one seed per season, so there is exactly one world per
season and the composer never runs on a player's machine. It runs once, on a
build box. Not "fast enough for a loading screen" -- "runs overnight". It can
generate a thousand candidates, keep eight, and let Jahni veto the season before
it publishes. What ships is the seed plus the chosen parameter vectors.

The measurement pass itself is one coarse grid and one flood fill, producing one
feature vector that serves validation, danger and rarity at once. Noted the one
place coarse sampling can lie -- a wall thinner than the sample spacing makes
sealed spaces look connected, which would pass the primordial-law check on an
impassable strate. Coarse to find the route, full resolution to confirm it.

And the answer to "how do we ever judge what's good": the judge is the corpus,
not a model. Good never gets defined, only collected -- strate-level thumbs
up/down, player behaviour, and best of all where players choose to snapshot
their home, which is an ungameable "I love this place" that costs nothing to
collect. Known risk written down: promote only what you already liked and the
system converges on a comfortable middle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
2026-08-23 13:05:59 +02:00
Fr0zkaandClaude Opus 5 d8f7dabee8 docs(composer): the system should invent worlds -- design session notes
A long design conversation, written down as it happened. The headline: the
op stack's "Phase 3" is REOPENED. Asked what the world was missing, Jahni
described exactly it -- the system assembling worlds from data assets and a
seed -- so the 2026-08-16 handoff's "do NOT start Phase 3" is superseded.

Then a correction that moved the whole thing a level deeper: he does not want
to author strates either. The system must INVENT them. He authors raw content
(operators, meshes, materials, sounds, creatures) and tags it; everything above
that is composed and measured.

Decided along the way: worlds are wiped every 6-12 months, which dissolves the
"generation is frozen forever once public" problem -- generation is frozen only
within a season. Shared player builds are blueprint structures, vote-ranked.
Housing is an instanced level built from a snapshot of land the player chose.
Danger is depth x a per-strate multiplier, not a sort. Every strate has a
findable entrance and exit -- built first, generated around.

The range problem turned out cheap: don't roll 73 parameters independently
(that is what makes mush), blend known-good ones with FStrateGenerationParams::Lerp,
which already exists and already blends the whole field set. Ranges come from
the spread of the existing corpus, not from authored numbers. Verified against
the code: the X-macro is in sync with the struct, only Alpha differs.

Recurring mechanism, arrived at three separate times: measure, don't declare --
and promote what measures well back into the corpus, so the system's vocabulary
grows from its own history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
2026-08-23 12:57:04 +02:00
Fr0zkaandClaude Opus 5 451ebcb776 docs: close the operator stack -- handoff rewritten in plain language
Jahni built the last two commits and the game looks fine. Closing the thread.

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

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

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

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

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

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

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

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

Test-only. Not built.

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

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

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

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

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

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

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

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

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

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

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

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

Not built -- Jahni builds.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:50:34 +02:00
Fr0zka 6e7ea7038a docs(opstack): record unbuilt experimental landing
Append the exact commits, Approach A memory decision, cave-only warning caveat, diagnostic semantics, CP owner scope, compile watchpoints, deliberately open measurements, and recoverable worktree cleanup. No build or runtime result is claimed.
2026-08-17 00:44:27 +02:00
Fr0zka f90c4e56c3 fix(generator): key CP cache by world owner
Function-static thread_local CP state previously trusted only chunk coordinates and each manager's locally-reused layout version, allowing a worker to carry params, biome context, CP_UseOpStack, and the built stack into another world. Allocate each generator a monotonic owner ID and add one uint64 comparison to the hot key. This intentionally fixes only the proved CP_* path; other TLS caches remain outside this change.
2026-08-17 00:41:51 +02:00
Fr0zka 1e02c6314f diagnostics(opstack): separate disabled-slot bail causes
The old Not Op Stack counter fired before slot attribution, so interior disabled strates and boundary encounters produced the same number. Split the instrumentation into sole-slot, boundary-tile, unresolved-layout, and late-recheck counters while preserving every ClassifyTile condition, return point, and verdict.
2026-08-17 00:39:47 +02:00
Fr0zka 8295f6e76b perf(opstack): retain six surface column regions
The single direct-indexed box discarded all 6,561 computed columns whenever spatial or column identity moved. Port the reference six-box LRU so interleaved regions keep five warm working sets, accepting the documented ~0.79 MiB TLS cost per worker to preserve the recommended and measurable A/B path. Also report disabled cave opt-ins at layout initialization; SurfaceWorld is excluded because its exact-lattice tile proof does not use that flag.
2026-08-17 00:37:56 +02:00
Fr0zkaandClaude Opus 5 588f9e0294 docs: Sol investigation -- refutes my boundary-tile hypothesis, corrects my false accusation
Sol High investigated with Luna xHigh sub-agents; two approaches built in
isolated worktrees, main tree untouched.

Two corrections to my own assertions:

1. "One un-ticked neighbouring strate kills boundary tiles" is WRONG.
   UsesOperatorStackForChunk depends only on ChunkCoord.Z, and ClassifyTile's
   earlier Z loop already required the predicate for every sampled cave Z within
   one slot -- so the later XYZ sweep is redundant, not over-strict. Removing it
   unlocks nothing; boundary tiles still fail the one-slot/type/params guards,
   correctly.

2. I falsely wrote that VF-03's fixture citation was fabricated. It exists
   (VoxelForgeTestFixture.h ~134/146, explicit about CP_UseOpStack
   contamination). I had read only the 30-line header and asserted a negative
   from a partial read. Header corrected. VF-03's core claim is now CONFIRMED:
   GetDensityAt keys thread_local CP_* by (ChunkCoord, LayoutVersion) with no
   world identity, and every manager's version starts equal.

Also: the Cave Bail Not Op Stack counter I specced is ambiguous -- it fires
before the different-slot attribution, so it cannot distinguish "flown slot
un-ticked" from "boundary tile met an un-ticked slot first". My 80% reading was
over-confident. Same defect I fixed for TilesSkippedAllSolid, reintroduced one
layer down.

Approach A recommended (explicit opt-in + six-box LRU) over B (code-enforced
cutover): the code does not justify weakening ClassifyTile, and B would delete
the flag, which is the only same-route A/B lever available to prove the
refactor. Narrow A's warning to cave archetypes before adopting.

Nothing built. Worktrees left in place for review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:22:06 +02:00
Fr0zkaandClaude Opus 5 4ba53f2ede docs: T1.d is blocked by CONFIG not code (Cave Bail Not Op Stack = 80%); retract memo claim
Part B paid for itself on its first flight. Over the caves, Tiles Classified
== Tiles Meshed (nothing skipped at all) and CaveBailNotOpStack accounts for
~80% of cave-branch attempts. That counter has exactly one cause:
UsesOperatorStackForChunk returned false -- so either the strate flown over has
not ticked bUseOperatorStack, or a chunk in the tile's box belongs to an
adjacent strate that has not, since the per-chunk sweep requires every chunk to
agree. CaveBailStackVerdict being non-zero (0.32) corroborates: where the flag
did hold for the whole box, the stack built fine and merely returned Mixed.
The machinery works; it is mostly not being reached.

RETRACTION: I called the column-memo port a regression after comparing 14.7%
from one flight against 23.3% from another. Invalid -- the miss rate is
strongly location-dependent (~10-30%) and was in that band before the port.
Comparing single-flight averages from different routes measures the route. A
real before/after needs the same seed and route; parked until then, and not
urgent.

Kept while diagnosing: GSurfColCache is a 6-box LRU, not one box. My spec said
"a direct-indexed box" and Codex implemented one faithfully, so any recentre
now wipes all 6561 cells where the old table lost one entry. Plausible thrash
source, unmeasured. Port the LRU if the memo is revisited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 18:36:40 +02:00
Fr0zkaandClaude Opus 5 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 cab8e8f, so its stillness proves
Jahni's room/tunnel Min <= Max are correctly ordered. All eight equivalence
tests bit-identical, which TESTS the "bounds only, cannot reach density" claim
made on 7dbdf51 / eaa44bf / cab8e8f rather than asserting it.

PART A -- the column memo thrash is confirmed quantitatively. Measured 14.74%
miss rate against a prediction of 14.0% if thrashing and 1.4% if not; and 8,300
recomputes per tile against a healthy 1,225 (6.8x) from an independent
statistic. Replaced the 4096-slot direct-mapped hashed table with the
direct-indexed box scheme GSurfColCache has always used: no hash, no
collisions, every column computed exactly once. ParamsFingerprint is retained
in the box key -- its absence was the shipped bug that silently deleted the
overhang, and GSurfColCache's own omission of it was deliberately not copied.

PART B -- T1.d never fires in game: the two op-stack counters never appeared,
and since every displayed row has Min 1.00 rather than 0.00, rows only render
when a counter fires. The cave branch has 13 return-Mixed paths; guessing which
is the mistake this project keeps paying for. Six attribution counters now name
the bail category outright. Only edits there are brace-expansions so a counter
fits before each existing return -- every condition and returned value is
byte-identical.

Open falsifiable hypothesis: Tiles Meshed averages 2.13/frame, plausibly the
reason LOD rings update slowly. If misses drop ~10x and LODs speed up, the memo
was the ceiling; if not, they are separate problems, cleanly separated.

Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 18:28:46 +02:00
Fr0zkaandClaude Opus 5 49a9959aed fix(world): drain workers before mutating strate layout/passages (VF-01)
Initialize does StrateLayout.Empty() and Passages.Empty()/Add() -- freeing and
reallocating both -- with no guard, while mesher workers read them through
AnyPassageNearBox, EvaluateModifierSDF and FindSlotIndexForChunkZ. The epoch is
bumped AFTER, so previous-epoch workers are live during the mutation; the epoch
rejects a finished result, it cannot make a read of a freed allocation safe.
Same class as the DiffLayer.ChunkMods carve-vs-stream AV that ModsLock fixed.

Chose the drain over the two alternatives:
  - FRWLock on the arrays: correct, exact in-repo precedent, but a read lock on
    the per-voxel hot path (~43k/tile) would contaminate the perf measurement
    CODEX-TASK-001/002 are queued to take. Worst possible timing.
  - Immutable generation snapshot (Sol's proposal): right long-term, refactors
    the whole StrateManager API surface. A design conversation, not an agent's
    unprompted call.
  - Drain: zero hot-path cost, reuses the machinery EndPlay already proved, and
    its stall lands only on human-initiated editor actions, never during play.

FScopedGenerationPause raises a new bGenerationPaused (deliberately NOT
bShuttingDown, which means teardown) and drains both reader populations: chunk
tasks via ActiveTaskCount and decoration tasks via a new
WaitForDecorationTasks, whose refactor also removed NotifyShutdown's duplicate
spin loop. On timeout it does NOT mutate -- logs an Error and leaves the world
consistent. A timeout that proceeds is what the bug already does.

RebuildStrates, OnObjectModifiedInEditor and ChangeSeed cannot reach Initialize
without the pause. BeginPlay untouched (no tasks yet). EndPlay untouched --
VF-02's 3s timeout is a separate deliberate decision.

Verified: four files, no density/mesher file, so terrain cannot move.
ShouldAbortWork replaced three existing checks, adding none. No worker path
waits on the game thread, so the drain is bounded.

Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:51:19 +02:00
Fr0zkaandClaude Opus 5 bc0bf83c64 docs: VF-01 confirmed (worker race on rebuild) + VF-10 triaged; hold further code
VF-01 CONFIRMED and it is the most serious find of the day. Initialize does
StrateLayout.Empty() and Passages.Empty()/Add() -- freeing and reallocating both
-- with no lock, barrier or drain anywhere in that file, while worker threads
read the same arrays through AnyPassageNearBox (:460), EvaluateModifierSDF and
FindSlotIndexForChunkZ, reached from GetDensityAt/ClassifyTile. RegenerateAllChunks
bumps the epoch AFTER the mutation, so previous-epoch workers are live during it;
the epoch rejects a finished result, it cannot make a read of a freed allocation
safe. Same class already fixed once here: DiffLayer.ChunkMods got ModsLock after
a carve-vs-stream AV. The dangerous call site is OnObjectModifiedInEditor, which
fires automatically on a strate asset edit while streaming -- routine here.

VF-10 confirmed real (74 fields, per near-surface sample) but Sol missed the
conclusion: the copy is INHERITED from GetDensityWithParams, so both paths pay it
and it does NOT explain the op-stack regression. The op stack improved it by
memoising across eleven detail ops. Future optimisation, not the answer.

Deliberately NOT fixing VF-01 now. Four code changes are stacked unbuilt and
three have "nothing should move" as their acceptance signal; a fifth change to
streaming lifecycle would make an odd build result unattributable. The three
fix options (drain / snapshot / RWLock) trade a possible crash for an editor
hitch or hot-path lock traffic -- a product decision, not an agent's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:38:09 +02:00
Fr0zkaandClaude Opus 5 cab8e8fe55 fix(morphology): BuildChunkCache under-bounds room/tunnel collection when Min > Max
Third instance of the same class, and the worst one -- found by the Sol-High
read-only audit (VF-05) and verified against the code before acting.

MaxInfluence, RoomZBuffer and EvaluateSDF's Margin all derive from
MaxRoomRadius / TunnelMaxRadius, while the radii are Lerp(Min, Max, hash), which
yields up to max(Min, Max). A room able to reach a chunk can therefore sit in a
cell the collect region never visited.

Worse than the two fixed earlier today because:
  - it is TunnelNetwork, the largest archetype;
  - BuildChunkCache is called by BOTH density paths (FRoomGraphSource calls it
    rather than transcribing it), so this was never an op-stack bug -- it is in
    the shipped original code and always has been;
  - the failure mode is a window-invariance break (ARCHITECTURE 8.4): whether a
    room exists depends on which chunk you queried from, which in multiplayer
    means two peers generate different geometry from the same seed.

Four bound sites now use RoomRadiusEnvelope / TunnelRadiusEnvelope; the two
duplicated copies of the formula still compute the identical expression. No
Lerp, placement, hash or bStore line changed -- with correctly ordered params
max(Min,Max) == Max, so this is bit-identical. A no-op at correct values is the
acceptance signal.

Also adds a reviewer's header to AUDIT-2026-08-CODEX.md marking which findings I
verified (VF-05 confirmed, VF-02 premise confirmed, VF-03 evidence overstated --
its cited fixture corroboration does not exist) and which are unverified leads.

Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:32:09 +02:00
Fr0zkaandClaude Opus 5 8ef4d7dc09 docs: CORRECTION -- bUseOperatorStack is ON in the game's data assets
The handoff said "No strate asset has the box ticked -- that is my call and I
still haven't made it." Stale, and I reasoned from it all session. Jahni: "the
data assets in game have the switch on."

Everything downstream flips. The operator stack is the PRODUCTION density path,
so the measured perf regression is one players feel, and any unsound
EffectOverBox is a live hole rather than a latent one. The two correctness fixes
committed today (7dbdf51, eaa44bf) both say "nothing in the running game was
affected" in their bodies -- that sentence is wrong. Those commits are pushed
and stay as written; OPSTACK-PROGRESS 2026-08-16 (h) is the correction of record.

Concrete consequence: 7dbdf51 did not fail in practice only because this
Perlin's empirical sup (~1.0-1.1) happened to stay under the margin the wrong
bound left (1.27 for shafts, 1.40 for maze) -- not because the code was right.
And eaa44bf is worth checking against the real assets: if any strate has
ColumnMinRadius > ColumnMaxRadius or ShaftMinRadius > ShaftMaxRadius, it WAS
producing tiles with no geometry and no collision.

Process failure worth keeping: the flag lives in .uasset binary data, which is
not greppable from here, so the docs carried a hand-written claim about it that
nobody re-checked. A fact that lives outside the repo cannot be maintained
inside it -- record what it was and when it was checked, never assert it as
current. Rule added to the handoff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:12:01 +02:00
Fr0zkaandClaude Opus 5 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>
2026-08-16 17:06:24 +02:00
Fr0zkaandClaude Opus 5 f737488d88 test(opstack): three tile scans sampled <1 lattice period; fix the CLASS this time
The "a sampler must cover at least one period" bug was found in the tunnel test
(+/-32 vs RoomSpacing 80) and again in the shaft test (+/-48 vs ShaftSpacing 55).
Both were fixed. The class was not: three box-verdict scans still ran the
original RandRange(-6,6)*Extent at Step 1 / Cells 8, i.e. +/-48 voxels.

  Island  +/-48 vs IslandSpacing 95 = 0.51 periods  (worse than either fix)
  Slab    +/-48 vs ColumnSpacing 60 = 0.80 periods
  Maze    +/-48 vs CellSize      40 = 1.20 periods

Verified the fixture does not override any of those spacings, so the header
defaults are what these tests really ran against.

This matters now specifically: 7dbdf51 changed the island and maze verdicts and
eaa44bf changed the slab column verdict, and these are the tests that guard
them. Widening before the build is what makes that build's green mean anything.

The rule the fixed tests already encoded, now explicit: Extent = 8, so
SpanCells = the lattice spacing gives exactly 8 periods -- which is where the
shaft test's 55 came from. Island 95, Slab 60, Maze 40, each printing the ratio
computed LIVE from the params struct so a future narrowing cannot be silent.

No assertion, tolerance, AddError, brute-force loop, seed, Step, Cells or tile
count was touched: this changes what the measurement looks at, never what it
demands. Proved counts will move (expected); NumUnsound must stay 0.

Test-only -- git diff lists three files under Private/Tests/. Not built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:04:55 +02:00
Fr0zkaandClaude Opus 5 c993e6e877 docs(opstack): handoff heads with the four-things-on-one-build table
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:50:56 +02:00
Fr0zkaandClaude Opus 5 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>
2026-08-16 16:50:01 +02:00
Fr0zkaandClaude Opus 5 108982d135 docs(opstack): the proved-bound rule now names VF_PerlinAbsBound and the FBM normalisation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:36:45 +02:00
Fr0zkaandClaude Opus 5 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 e002bd4 -- which is what makes this reachable rather than latent.

Fix: PerlinAbsBound hoisted to file scope as VF_PerlinAbsBound (one definition,
not a class-static plus three implicit 1.0s) and multiplied into all three
reaches; the three comments now state the real justification instead of the
refuted one.

Cannot change density: ExtraReach is read only inside EffectOverBox, verified
mechanically -- no Eval/GetCells line appears in the diff. Direction is strictly
conservative, so it can only cost CPU. Expect FEWER proved tiles for Maze and
VerticalShafts; that is the correct outcome, not a regression.

Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:36:04 +02:00
Fr0zkaandClaude Opus 5 b5294b2d5b docs: close C2, sharpen the column-memo prediction, reassess REVIEW_FINDINGS
Reading session -- no build available, so: verify things instead of writing code.

- Cross-checked the eight stat declarations against their definitions and use
  sites. Identical. The one compile risk I had only eyeballed is now retired.

- REVIEW_FINDINGS: the GetDensityWithParams and BuildChunkCache splits are now
  counter-productive rather than optional -- the op stack is replacing the
  first, and FRoomGraphSource deliberately CALLS the second so a restructure
  forks what was kept unforked. The two passage enums are not duplicates and
  are UMETA-serialised into authored assets; merging them rewrites content for
  tidiness. Judged: leave, and close the item.

- CODEX-TASK-002's acceptance said "20-30% of lookups", a guess wearing a
  number. Replaced with arithmetic from the real grid: 1225 columns/tile over
  35 Z planes, load factor 0.299, ~317 colliding columns, ~12000 vs 1225
  computations = ~9.8x. Plus the reading rule that matters -- the overhang and
  cliff mods re-query the same XY and add HITS only, so compare the ratio of
  the two hypotheses, not an absolute percentage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:14:18 +02:00
Fr0zkaandClaude Opus 5 b426cfcb0d docs(audit): C2 is FULLY closed -- the live-edit half was fixed too
Was about to spec a Codex fix for OC_Chunk / BM_Chunk / FChunkBiomeCache,
which three docs still list as open. Checked the sites first. All of them
already carry a layout-version guard:

  CP_Chunk -> CP_Version      OC_Chunk -> OC_Version
  BM_Chunk -> BM_Version      TC_BiomeCache -> TC_SeenVersion

FChunkBiomeCache::Invalidate() exists precisely because the validity box says
nothing about the FBiomeContext its cells were classified against, and all four
thread_local instances call it on a version change. The only other two
instances in the tree (VoxelContentManager ~445, the height-stack test) are
function-local, constructed per task, so staleness is impossible there.

Seventh time in this project a confident premise reversed on reading. It cost
a doc edit instead of a Codex run and a build cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:11:54 +02:00
Fr0zkaandClaude Opus 5 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>
2026-08-16 16:01:26 +02:00
Fr0zkaandClaude Opus 5 7909c4f2ca docs(opstack): handoff -- 001 chains into 002, and PERF is now aimed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:46:30 +02:00
Fr0zkaandClaude Opus 5 7313201e91 docs: spec CODEX-TASK-002 (column-memo thrash) and aim the PERF work
Three orchestration findings on the parked PERF item, none measured yet:

1. The perf A/B needs no new code -- two Insights traces on a deterministic
   world are a clean before/after. But tile skipping changes how many times
   GenerateMesh runs, so totals conflate "cheaper per tile" with "fewer
   tiles". TilesMeshed from task 001 is the denominator. 001 is therefore a
   PREREQUISITE for the perf work, not a parallel item as the handoff had it.

2. The suspects don't share an archetype: SurfaceWorld is the column memo,
   TunnelNetwork is 19 virtual calls per voxel (16 + 3 structural post).
   One lumped number can't be acted on -- measure one strate at a time.

3. GetColumn is a direct-mapped hashed table where GSurfColCache is a
   direct-indexed box, and the mesher pre-samples Z-OUTERMOST (verified,
   VoxelMarchingCubesMesher.cpp ~226) so every column is revisited ~34x per
   tile. The table's sizing comment reasons about fullness; a direct-mapped
   table evicts on collision. At load factor 0.28 that is still ~25% of
   columns recomputing the full height stack every Z plane -- derived ~9x.

Task 002 measures that and fixes nothing, so the instrument and the fix
cannot land in the same build and make each other unreadable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:45:44 +02:00
Fr0zkaandClaude Opus 5 57002b35cf docs(opstack): log the CODEX-TASK-001 acceptance-bar correction
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:40:48 +02:00
Fr0zkaandClaude Opus 5 6bd5589d53 docs(codex-001): the lumped AllSolid counter cannot prove T1.d -- add the op-stack site
Reviewing the spec against ClassifyTile reversed its acceptance bar. The function
reaches a non-Mixed verdict two independent ways: the hand-written path (a bedrock
gap sets bCanAir=false, VoxelGenerator.cpp ~2835, and the tile resolves AllSolid)
and the operator-stack path (the bAnyCave block). The first fires with NO strate
opted in, so the spec's baseline -- "TilesSkippedAllSolid stays 0 underground" --
was never going to hold, and the before/after would have been unreadable.

Adds site B at the bAnyCave exit (~3009) with TilesOpStackSolid / TilesOpStackAir.
Those are zero BY CONSTRUCTION without an opted-in strate, since the branch returns
Mixed at the UsesOperatorStackForChunk gate -- a stronger baseline than the one it
replaces. Deliverable is now: tick the box, TilesOpStackSolid goes non-zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:40:23 +02:00
Fr0zkaandClaude Opus 5 251a1288e0 docs: spec CODEX-TASK-001 (tile-skip stats) and refresh the handoff for the Codex tandem
Jahni built a world and said "I don't know if it dropped any meshing? but it
looks alright by the eye." That sentence is the honest state of this refactor:
everything proved so far was proved in an automation harness on 40 sampled
tiles, and in the running game tile-skipping is unobservable.

grep INC_DWORD_STAT over Source/ returns nothing -- the plugin has zero stat
counters -- and "skipped correctly" renders identically to "skipped nothing", so
no visual check can separate them. The tests got the "coverage is a number, not
a boolean" discipline this session; the game never did.

CODEX-TASK-001-tile-skip-stats.md specs a stat VoxelForge group with
TilesClassified / TilesSkippedAllSolid / TilesSkippedAllAir / TilesMeshed. Solid
and air are split deliberately: cave archetypes prove AllSolid, so that counter
is the one that says whether the op-stack work did anything real. Its deliverable
is the before/after that constitutes the PRODUCTION proof of T1.d, which does not
exist today.

The spec carries the invariants rather than just the task, which is the point of
a spec here: bTrivialEmpty decides whether a tile has COLLISION, the five-clause
gate is load-bearing, and GenerateTileResult runs on worker threads -- so a plain
static int32++ is a data race while INC_DWORD_STAT is not.

Handoff updated: a "How we work now" section (Codex Model Luna xHigh writes most
code, Claude orchestrates -- hand over the INVARIANT, review against the code and
not the description), first actions split into the Codex task and the pending
e002bd4 build, and the T1.d heading qualified as harness-measured rather than
game-proven. Also records the Insights interim answer and its trap: ~84% of tiles
were already rejected by the hand-written surface/bedrock paths, so surface skips
drown the cave ones unless you are underground in an opted-in strate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:36:29 +02:00
Fr0zkaandClaude Opus 5 e4d1fdd7ea docs(opstack): experimental is pushed and tracked -- correct the flat "never push" rule
The rule said "Never push" without qualification, which was true while
experimental existed only locally and became misleading the moment it did not:
a future session would read it and let origin/experimental drift.

Now: push experimental freely (tracked since 2026-07-29), never push main, which
stays pinned at the known-good commit. Both statements of the rule are updated
(OPSTACK-HANDOFF.md and OPSTACK-PROMPT.md's crash-safe discipline).

Also added at both sites, because it is the failure mode this creates: a pushed
commit is NOT a "verified green" marker. This branch carries unbuilt work by
design, so OPSTACK-PROGRESS.md remains the only record of what was actually
built and measured, and the remote records only what was written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 14:41:00 +02:00
Fr0zkaandClaude Opus 5 41ba3c34a9 docs(opstack): rewrite the handoff for the T1.d-delivered state
The old handoff's "first action" (confirm the Underwater coverage number) and its
"one task everything is waiting on" (make FRoomGraphSource::EffectOverBox answer
spatially) are both done, so most of it was actively misleading.

What it says now:

- T1.d is real and measured: 11 of 40 tiles proved AllSolid at production
  defaults, 14641 voxels brute-forced, 0 violations; dense fixture correctly 0.
- A boxed invariant near the top, because it is the most dangerous thing in the
  current code: Identity from the room source means Sdf >= T, not FLT_MAX, and
  any new Sdf consumer with a bigger threshold silently produces tiles with no
  geometry and no collision.
- First action is now "build e002bd4 and read ONE line" (VerticalShafts box
  verdicts), including what to check first if it is still 0 -- ExtraReach --
  rather than re-deriving, which is what cost three rounds on TunnelNetwork.
- Both old debts restated with their real status: the per-room-op bound is
  DORMANT (checked, premise reversed), and AUDIT C2 is FIXED on the switch path
  with a note on why the audit's own suggested alternative was the wrong fix.
- The warp squeeze is parked WITH its measured ceiling and a recorded negative
  result, so the obvious next attempt (local Lipschitz warp bound) is not
  repeated: 8.5 * 0.206 = 1.75 exceeds the global range bound of 1.5.

New method lessons, led by the one this session actually paid for: instrument
what you ASSUMED, not just what you changed. Also that a sampler must cover a
period of what it samples, that a fixture tuned for coverage can be antagonistic
to what you are measuring, that diagnostics must report this run rather than
carry narrative, and that you must not assert a number you intend to improve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 14:36:36 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-29 03:36:34 +02:00
Fr0zkaandClaude Opus 5 861dc8e109 docs(opstack): WARP SHARE measured -- half the blocking is the box; plus two corrections
The instrument landed on its first run. At production defaults, rooms reaching
go 0.9 -> 0.4 and tunnels 2.4 -> 1.1 when the warp dilation is set to zero: more
than half of all remaining blocking is the query box rather than cave geometry.
Hypothesis confirmed, and the number is permanent output now.

CORRECTION 1. Last entry I claimed BuildChunkCache's Expansion =
CaveWarpStrength + 2 means "the whole plugin bets on |Perlin3D| <= 0.8", and used
it to argue 2.0 was needlessly conservative. False. Six lines further down,
bNeedRebuild tests WarpedX < CachedSMinX || WarpedX > CachedSMaxX -- the cache
REBUILDS when the warped query leaves the box, so that expansion is a
rebuild-frequency heuristic and nothing bets on 0.8. The 2.0 -> 1.5 change stands
on its own derivation. Sixth premise this refactor that reversed on checking, and
I asserted this one in the same entry where I diagnosed the habit.

CORRECTION 2 / NEGATIVE RESULT. The obvious next move -- evaluate the warp at the
box centre, SHIFT the box, dilate only by the variation across it -- does not pay
here, worked out before writing it. A rigorous per-axis Lipschitz bound for this
Perlin3D is |dV/dfx| <= 4*1.875 + 1 = 8.5 per unit cell (fade derivative times
the spread of GradDot, plus GradDot's own linear term, u and v being distinct
axes). The half-box is 0.206 in noise units, so the local variation bound is 1.75
against a GLOBAL range bound of 1.5. The local bound is worse than the global
one. Recorded so nobody spends a build rediscovering it.

That leaves one route for the warp term: tightening the sup of |Perlin3D| from
the proved 1.5 toward its apparent true value ~1.0-1.1, worth ~27% of the
dilation. Spot-checking a grid is not a proof, and a sup proved wrong is a tile
with no geometry and no collision -- so that is a judgement call about appetite,
not a technical unknown.

State: T1.d delivered and verified. 11 of 40 tiles (27.5%) proved AllSolid at
production defaults, 14641 voxels brute-forced, 0 violations. The dense fixture
correctly proves nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 03:28:49 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-29 03:21:37 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-28 18:45:56 +02:00
Fr0zkaandClaude Opus 5 87a0b996ec test(opstack): measure the box verdict on BOTH densities -- the fixture cannot prove a tile
The widened sampler worked: 39 of 40 tiles now land clear of the (0,0) spine.
The verdict did not move -- rooms still hit 40 of 40. By my own pre-registered
rule that means the room-lattice model was wrong, and it was, for a reason the
report had been printing at me for three runs: "= 7.6 x RoomSpacing 42".

RoomSpacing is 42 here, not the 80 I did the arithmetic with. I read the
UPROPERTY default out of the header instead of the fixture that overrides it.
Fifth premise this refactor that reversed on being checked, and the plainest:
the number was on screen.

EnableTunnelFeatures densifies on purpose -- RoomSpacing 80 -> 42, RoomDensity
0.35 -> 0.85 -- because at the defaults only 1.1% of samples were in open cave
and the equivalence compared solid rock to solid rock. That densification is what
makes check 1 mean anything, and it is exactly antagonistic to provability:

  room cull radius = max(R*1.5, R*HeightRatio) + 3*SDFBlendRadius
                   = 1.5R + 12  for R in [10,30]  =>  27..57, mean ~42
  lattice spacing  = 42 at 85% occupancy

The mean cull radius equals the lattice spacing, so cull spheres cover that world
~3.6x over and NO box can be outside all of them. "6.3 of 8.3 rooms reach" is not
a loose test, it is a saturated world. No sampler change and no tunnel tightening
can move it -- which is why widening the sampler correctly changed nothing.

So 0 proved on this fixture is the RIGHT answer, and informative: the prize
shrinks to nothing as caves saturate. It is simply not an answer about
production.

Check 4 is now a lambda run twice -- parameterised rather than copy-pasted,
because two copies of the criterion would drift and the second would lie:
[dense fixture] as before, expected to stay ~0 and now documented as correct;
[production defaults] the same 40 tiles against RoomSpacing 80 / RoomDensity
0.35, the real UVoxelStrateDefinition defaults. Both brute-forced voxel by voxel.
Not "widen it until it passes": the dense run stays in the report, must stay ~0,
and a false verdict in either still fails. The two stacks deliberately share
FRoomGraphSource's thread_local caches, so this run and check 3 now watch each
other through the params fingerprint in the key.

Unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:37:45 +02:00
Fr0zkaandClaude Opus 5 f2fefade4c test(opstack): the tile sampler never left the (0,0) spine -- widen it to 4x RoomSpacing
The per-class breakdown refuted my own hypothesis on its first run. I predicted
"tunnels >> rooms, so it is capsule bounding spheres". Tunnels ARE wildly
over-counted (69.2 of 80.4 reach), but rooms hit all 40 tiles too, so fixing
tunnels alone would have moved the number by exactly zero. Fourth confident
chain this refactor that reversed on contact with a measurement.

The real finding is that the measurement was invalid. The sampler drew tile XY
from RandRange(-4,4)*8, i.e. +/-32 voxels, against RoomSpacing = 80 and a
guaranteed OriginRoomRadius = 20 room at (0,0) whose cull radius is 42, with the
(0,0) spine descending exactly there. All 40 tiles sat inside the origin room's
cull sphere, in the most cave-riddled spot in the world. "4.9 of 7.2 rooms
reach" was measuring the spine hub, not the world's cave density -- every
conclusion about whether deep rock is provable was answering another question.

Widened to +/-320 voxels (4x RoomSpacing). The report now prints its own
sampling extent and how many tiles landed clear of the spine, because a sampler
whose extent you cannot quote is one nobody is watching. This changes what the
measurement LOOKS AT, never what it demands: every verdict is still brute-forced
voxel by voxel, so a wider sampler that produced a false verdict still fails.

Also worked out, before writing any code, why rooms cannot be tightened and
tunnels can. SmoothMin's penalty is exactly zero once |A-B| >= K, so the running
minimum saturates at K below the true minimum and Sdf >= min_i(SDF_i) - K for
ANY number of primitives. With K=4, WormNetworkRange=24, mods gating at 3K=12,
the threshold T is 24. For rooms the cull rejects at Rmax+3K=57 while an
"Sdf >= T+K" test rejects only at Rmax+T+K=73 -- the cull is strictly better.
For tunnels the cull is a capsule BOUNDING SPHERE, radius up to ~107 for a
200-long tube of radius 7, while the real segment distance rejects at 35. An
order of magnitude, and sound because TaperedCapsule is a genuine distance
function (verified, not assumed).

That disjunction is NOT in this build on purpose: it changes what Identity means
here, from "Sdf stays FLT_MAX" to "Sdf >= T", which is only sound if every
consumer threshold is <= T. Three premises still need reading rather than
assuming -- VF_NearCaveSurface's constant, the Blend passed to FSdfConvertOp,
and whether any modifier re-probes the SDF outside the box before its gate.
Fix the measurement first; it costs nothing and it is wrong today.

Unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:25:43 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-28 18:16:23 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-28 18:10:51 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-28 14:48:18 +02:00
Fr0zkaandClaude Opus 5 e55f12a9de docs(handoff): ce409e7 is built -- turn the Underwater section from a task into a question
Two green builds now, so the handoff's most prominent instruction (build ce409e7, it is unverified)
was stale, and a stale first action is worse than no first action: it sends a fresh context looking
for a bug that may already be fixed.

What I did NOT see is the number. The floor-division fix probably resolved the 0% cave coverage, but
'the suite is green' is not evidence of that -- a bit-identity over solid rock is green for exactly
the wrong reason, which is why that counter exists at all. So the section now states both branches
explicitly: non-zero coverage closes it and says so in the log; still 0.0% and the diagnosis line
names which of the three causes it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:27:19 +02:00
Fr0zkaandClaude Opus 5 b533294f5d docs(opstack): record the green build and rewrite the handoff for a fresh context
OPSTACK-PROGRESS gets the measured entry: every number the run produced, what each one settles, and
the single open warning. OPSTACK-PLAN's status header goes from UNVERIFIED to BUILT AND GREEN.

OPSTACK-HANDOFF is rewritten end to end. It no longer describes a transition in progress but a
completed one, and it leads with the two things a cold context needs: the Underwater 0%-coverage
warning (with the truncation-vs-floor finding behind it, and the reminder that a green bit-identity
over solid rock is not evidence), and the one task everything else now waits on -- making
FRoomGraphSource::EffectOverBox answer spatially.

It also carries forward the two debts that must be paid BEFORE that lands rather than after: box
bounds computed from strate params can be too optimistic once a per-room op raises them, and AUDIT
C2 is confirmed but unfixed on the switch path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:13:59 +02:00
Fr0zkaandClaude Opus 5 ce409e7bb1 test(opstack C2): floor-divide the sampled chunk range, and DIAGNOSE the Underwater 0% instead of guessing
The build is green: 14 tests, TunnelNetwork A+B bit-identical over 6000 samples, all twelve group
probes non-zero, all 8 roughness variants identical, 0 gate leaks, C1 proved (10 Terrace rooms,
1119 samples inside them). One warning fired, and it is the one the test exists to fire:

  Underwater (stage C2): 2000 samples, 0 of them in open cave (0.0%)

A GREEN BIT-IDENTITY OVER 2000 SAMPLES OF SOLID ROCK IS NOT EVIDENCE. It is exactly what two
agreeing voids look like -- the 1.1% run of stage A, again, in a different slot.

A REAL BUG FOUND WHILE DIAGNOSING IT
The sampled chunk-Z range used Z / CHUNK_SIZE, and C++ integer division TRUNCATES TOWARD ZERO.
TunnelNetwork sits at the top of the layout in positive Z, where truncation and floor agree, so it
could not show there. Underwater sits at the BOTTOM, in NEGATIVE Z: -1 / 32 is 0 by truncation and
-1 by floor, so the upper chunk bound starts one notch too high and the Clamp that follows piles the
excess onto the strate's very last voxel -- inside the top seal band, i.e. solid rock. Fixed in both
point builders via FloorDivChunk. Same family as the DivideAndRoundDown lesson already in the
project notes: truncation costs a build cycle every time it is assumed to be a floor.

That is a CANDIDATE cause, not a conclusion, so the commit does not stop there.

NEW CHECK 5b -- ASK, DO NOT INFER
Three causes produce "no sample in open cave" and they are fixed differently, so each now has its
own number: rooms baked for the Underwater strate index (cause: the bake), samples landing inside
the seal-free interior (cause: the sampled Z range), and the two together (cause: the XY spread).
The info line says explicitly how to read them. Sampling also widens from 8 clusters to 24, matching
the main scan.

Fourth application of the rule this archetype keeps teaching: the check that explains a zero must be
able to fail for exactly one reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:09:54 +02:00
Fr0zkaandClaude Opus 5 46b507a261 docs(opstack): close the unattended run -- the post-8/8 batch and the reviewer pass
Four commits after 8 of 8, logged in OPSTACK-PROGRESS with the same shape as the first entry:
what each touches, what breaks first, and the numbers to read before the colours.

Records three things that only became visible while doing the work:

1. DECOMPOSITION 0.2 frames the worm as THE blocker for tile skipping. It is one of three. The
   subtraction also had no first term (nothing declared how solid the rock was), and the twelve
   detail modifiers were the bigger drag -- not through their amplitudes but because they declared
   a direction where they are provably Identity, being gated on the SDF the room source writes.

2. A safety property that was checked rather than assumed: no box query anywhere in the operator
   library touches per-worker memo state. That is what makes it safe for ClassifyTile to build and
   fold its own stack without clobbering the caches the density path depends on.

3. The reviewer pass over every ported operator found no transcription error. It did confirm the
   four things a future reader would otherwise have to re-derive: the operator order matches the
   original line for line, EffectiveZ is recomputed per op but bit-identically, the terrace's
   SDFBlendRadius is equal on both paths because no ApplyTo writes it, and LocalParams() is lazy so
   it reproduces the original's cost profile rather than merely its value.

Next action is now one thing rather than a queue: make FRoomGraphSource::EffectOverBox answer
spatially. Both of this batch's mechanism commits were built to receive it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 04:08:16 +02:00
Fr0zkaandClaude Opus 5 6a8390f11f feat(opstack): ClassifyTile consumes ClassifyBox for cave archetypes -- the T1.d prize, behind the same opt-in
⚠️ THIS IS THE ONE COMMIT OF THIS RUN WHOSE FAILURE MODE IS A HOLE, NOT A REGRESSION. A non-Mixed
verdict makes the world skip GenerateMesh entirely: no triangles, no collision, invisible until a
player falls through. Read VoxelForge.OpStack.ClassifyTileSoundness before trusting it.

WHAT CHANGED
ClassifyTile used to `return Mixed` without a call for every cave archetype ("archétype cave [...]
pas prouvable en v1"). It now builds that strate's stack and folds ClassifyBox. SurfaceWorld and
bedrock gaps keep their hand-written proofs untouched -- the exact-lattice column test is strictly
better than any box bound, so the stack has nothing to add there.

ONE DEFINITION OF THE STACK MAPPING, and that is the load-bearing part
GetDensityAt's ~90-line build switch is extracted into VF_BuildOpStackForChunk and both callers now
use it. A second copy would be the worst bug available here: a tile skipped on the verdict of a
stack that is not the one producing its density is precisely a hole. A "keep these in sync" comment
would not have been enough; there had to be only one. Params are passed in by pointer, never fetched
inside, because both callers already have them.

SIX GUARDS, ALL FAILING TO MIXED, none giving the benefit of the doubt
1. The strate must actually opt in -- on EVERY chunk coord the box touches, not just the one that
   triggered the attempt. Otherwise the classifier judges a field the mesher will not produce.
2. One cave slot per tile. Two slots means two param sets, and a stack answers only for its strate.
3. No mixed cave/surface/gap tile: the cave stack's box would cover Z belonging to another strate.
4. The params must be BIT-IDENTICAL across every chunk coord the box touches. This is the guard that
   matters, and it exists because of the finding committed earlier today: GetGenerationParams blends
   params inside a strate (Alpha depends on chunk Z for Gradient, and on chunk XY as well for
   Interleaved), so one stack genuinely cannot represent a tile that straddles a transition band.
   Memcmp on POD: differing padding can only produce a false MISMATCH, i.e. one Mixed too many.
5. A 27-chunk-coord cap, so a very wide tile does not pay for the check. We give up the gain, never
   the safety.
6. The disturbances are folded by hand (chasm ⇒ CarveOnly, bridge/ridge ⇒ FillOnly), because
   DECOMPOSITION 10.2 leaves them OUTSIDE the stack -- GetDensityAt applies them after the negate.
   A verdict that ignored them would be wrong exactly where they act. Same inequalities the
   SurfaceWorld branch already uses.

The diff layer needs no new guard: ClassifyTile already returns Mixed for any tile with player mods
in range, before any of this.

TEST: VoxelForge.OpStack.ClassifyTileSoundness -- the same brute-force oracle as the existing
ClassifyTileSoundness, on a world where every strate opted in. It does not check the fold (that is
BoxVerdictFold) or the operators (those are the eight equivalence tests); it checks the WIRING. The
number to read first is the count of tiles actually brute-forced: zero non-Mixed verdicts would mean
the test proved nothing, so that case is an ERROR rather than a quiet pass. Its failure message
lists the four suspects in the order worth checking.

FIXTURE: FTestWorld::Build gains a bUseOperatorStack parameter (default false, so the thirteen
existing tests still exercise the switch), and every test world now gets a PROCESS-UNIQUE
LayoutVersion. That second change fixes a real cross-test hazard that was only ever hidden by an
accident: PassagesVersion is per-instance and starts at 0, so two FTestWorlds both reported 1, and
GetDensityAt's per-chunk caches are keyed on (ChunkCoord, LayoutVersion) -- one world could be
served the previous world's params AND its CP_UseOpStack flag. Invisible while every world agreed
the flag was false. The first world that ticks it removes that coincidence, in both directions.

WHAT THIS BUYS TODAY: Maze, FlatPlain/CrystalChamber, VerticalShafts and FloatingIslands can now
prove tiles in production, which is where the measured skipping (Maze 23/60, slabs 36-40/60) turns
into frames. TunnelNetwork and Underwater still prove nothing: their chain dies at FRoomGraphSource,
which answers Both with unknown amplitude. Making it answer spatially means building the SDF cache
for the queried box -- now worth doing, since a skipped tile saves 30k+ density evaluations, and the
amplitude fold plus the modifiers' Identity inheritance are already in place to receive it. That is
the next piece.

And nothing here changes Jahni's current world: no strate asset has bUseOperatorStack ticked, so
every guard above is unreachable in his project until he ticks one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 04:05:42 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-28 03:55:39 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-28 03:53:02 +02:00
Fr0zkaandClaude Opus 5 64d0e11821 docs(audit C2): the within-strate stale-room suspicion is CONFIRMED by reading -- and it is the default config
No code changed. AUDIT-2026-07.md only: §C2's "SUSPECTED, NOT PROVEN" sub-item becomes "CONFIRMED BY
READING", with the four links of the chain and where each one is in the source.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:48:01 +02:00
Fr0zkaandClaude Opus 5 10dbe64b06 feat(opstack C3): TunnelNetwork + Underwater in the ported list -- 8 of 8
UsesOperatorStackForChunk now returns true for both, so the archetype switch has a complete
operator-stack twin: per-strate opt-in, every archetype equivalence-tested bit for bit against its
original function.

This changes nothing by itself. The flag still requires bUseOperatorStack ticked on a strate asset,
which is Jahni's call and was deliberately NOT done. What HAS changed is that the flag is no longer
a no-op anywhere: ticking it on any strate now really switches that strate onto the stack.

Not done, and it is the next real prize: ClassifyTile still uses hand-written guards and does not
consume ClassifyBox. That is where measured tile-skipping becomes frames.

DOCS
- CODEMAP 3.2d: ported list 6 of 8 -> 8 of 8; the BuildTunnelNetworkStack row rewritten (19 ops, one
  builder for two archetypes); six new rows for the detail modifiers, each carrying the thing a
  reader would otherwise have to rediscover -- roughness reads STRATE params, terrace re-queries the
  SDF, the cliff's comment disagrees with its code, columns have no strate parameter at all, and
  LocalParams() is the override whose EffectOverBox is too optimistic on a strate with an op pool.
  Also corrected the stale "never compare the two paths" line: C10 is closed and all eight
  equivalence tests compare bit for bit.
- CODEMAP 3.3 UsesOperatorStackForChunk row: same list, plus the warning that the flag is now a real
  switch rather than a harmless tick.
- OPSTACK-PLAN: status header and the Phase 2 order both updated; the three-stage TunnelNetwork
  breakdown and the calls-not-transcribes rule recorded there rather than only in the code.
- OPSTACK-PROGRESS: the closing entry for this unattended run -- every commit in order, the five
  original-code findings ported as-is, the two decisions that are not reversible by taste, the
  ClassifyBox optimism C1 introduced and that must be fixed before ClassifyTile consumes it, what
  breaks first per group, and the likely compile-error spots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:46:17 +02:00
Fr0zkaandClaude Opus 5 e479fcdf7d feat(opstack C2): Underwater reaches the tunnel stack from a second case + its own equivalence check
STAGE C group 2. No operator, no parameter, no new stack: Underwater IS TunnelNetwork.

PREMISE VERIFIED BEFORE BUILDING ON IT (the lesson this refactor has paid for four times)
OPSTACK-DECOMPOSITION §8 claims there is no density difference at all. Both halves were re-read
rather than assumed:
  - GetDensityAt already puts `case Underwater:` and `case TunnelNetwork:` in the SAME arm, both
    calling GetDensityWithParams (VoxelGenerator.cpp, the production switch);
  - a repo-wide search for WaterLevelRelative shows FStrateGenerationParams' copy is read only by
    UVoxelStrateManager's water-level query (render side) and by the SurfaceWorld beach op through
    a DIFFERENT struct. GetDensityWithParams never reads it.
So the wiring is one more `case` above the existing one, sharing CP_Tunnel.

ONE REAL DIFFERENCE FROM THE FIVE CASES ABOVE IT, stated in the code
No degenerate-strate guard. The other five archetypes have one because their density functions
early-out to `return 1.0f` on zero height and the stack has no such early-out by design.
GetDensityWithParams has no early-out at all -- read line by line, not assumed. Adding a guard here
would make the stack diverge from the switch on degenerate strates, in exactly the direction the
guard exists elsewhere to prevent.

TEST (same commit): new check 5 samples 2000 points in the Underwater SLOT, with the same room-op
pool attached to that definition before anything is evaluated. This is not tautological even
though both paths call the same function: the Underwater slot is a different strate, hence a
different StrateIndex, hence a different bake seed and a different entry in the strate-index memo.
It exercises what six chunks of slot 0 cannot -- that the stack follows the right room set when
two strates of the same archetype coexist in one world.

The failure message says explicitly that if the TunnelNetwork equivalence is green and only this
one fails, the finding is a real density difference between the two archetypes, which contradicts
§8 and is worth more written down than patched.

UsesOperatorStackForChunk still returns false for both. That is C3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:42:07 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-28 03:39:38 +02:00
Fr0zkaandClaude Opus 5 8a303cc085 test(opstack B5): prove the bNearCaveSurface gate -- a voxel outside it is bit-identically untouched
STAGE B group 5 of 5, and the only one that adds no operator. Stage B is complete: 19 ops, all
twelve detail modifiers ported.

THE DECISION, restated where it is paid for
The gate is a repeated early-out inside each operator (VF_NearCaveSurface), not a scoping
container. Reasons live in the code at that function; the short form is that the stack is a flat
list 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 cannot become a Phase-3
asset. What that choice buys in composability it pays for in risk: TWELVE places to forget the
gate instead of one. Hence a check aimed at exactly that.

NEW CHECK 1d
Classify each sample by its final SDF (nothing downstream of the room source writes that channel),
then compare the full stack against one with the eleven controllable amplitudes zeroed:
  - outside the gate the two must be BIT-IDENTICAL -- asserted, and the leak count is printed;
  - inside the gate they must differ often -- printed, and ZERO is an error, because "nothing
    leaked" is worthless if the answer is "nothing happened anywhere". Same trap as a coverage
    guard that only fires at zero, one level up: a check can be vacuous as well as a counter.
  - the outside-gate sample count is printed and warns below 10%, since deep rock is the common
    case in production and a sample set that never leaves the cave does not exercise the gate.

WHAT THIS CHECK CANNOT COVER, stated in the output rather than left implicit: the column operator
(STEP 4d) has no amplitude to zero, so it cannot appear in the zeroed stack. Its gate rides on
check 1's bit-identity against the original instead. That is sound but invisible, which is why the
info line says so.

Also corrected in the test header: the modifier count is TWELVE, not the thirteen that had been
carried in the notes, and only ELEVEN of them read the per-room param copy -- surface roughness
(4b) sits before the shadow declaration and reads strate params. Both numbers matter for C1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:33:34 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-28 03:31:55 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-28 03:26:40 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-28 03:22:21 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-28 03:18:21 +02:00
Fr0zkaandClaude Opus 5 7a87cdda14 docs: handoff — take the unattended queue all the way to 8 of 8
Jahni, twice and explicitly: he is fine with unverified work as long as it
is committed per group, and he wants the successor working toward full op
stack completeness. So the queue no longer stops at stage B.

Removed the hard stops on stage C and on flipping the ported list. Two facts
make that reasonable rather than reckless, and both are now written down:
committing per group IS the safety net (he reverts by sha), and reaching
8 of 8 does not change his world by itself — UsesOperatorStackForChunk still
returns false unless a strate has bUseOperatorStack ticked, so the flag flip
is inert until he ticks a box.

Queue is now B1-B5 (13 detail modifiers), C1 (per-room op override), C2
(Underwater, which §8 establishes has no density difference at all), C3
(both into the ported list = 8 of 8), then the §C2 audit question, the worm
amplitude cap, and making ClassifyTile consume ClassifyBox.

C1's design is settled here rather than left open, because an unattended run
should not be improvising architecture: take §2's option (a), and use the
mechanism this codebase already has three instances of — one op owns shared
state, others read it through a non-owning pointer (overhang <- column
source, ledge <- shaft source). FRoomGraphSource exposes LocalParamsAt();
detail ops read their fields from it. Same resolution as the pit/chimney
"fiddliest thing in the decomposition": the difficulty came from assuming
each op must own its params.

Still off limits: build, editor, push, ticking bUseOperatorStack on an
asset, and Phase 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:05:33 +02:00
Fr0zkaandClaude Opus 5 473bccb43f docs: handoff — add an unattended-run work queue for stage B
Jahni may start a session and leave, then have it shut the PC down. The
successor still cannot build, so the section is about shaping ~680 lines of
mechanical transcription for ONE informative build afterwards rather than
pretending verification is available.

Work queue B1-B5 (roughness / terrace-layerlines-ribbing / overhang-cliff-
scallop-arch / columns-domes-pinch-floorbias / the bNearCaveSurface gate),
one commit per group with the group named in the subject line, since Jahni
reverts by group. Transcribe literally, note anything that looks wrong in
the log and port it as-is anyway — that is how C1 and the MinDivisor split
were both found without breaking anything.

Records the subtlety that lets B precede C: the 13 modifiers read a copy of
the params with the nearest room's op applied, so porting them against
strate-level params is equivalent only while the test's room pool stays
Pit/Chimney-only.

Hard stops: do not flip TunnelNetwork or Underwater on, do not start stage C
(design freedom without feedback), do not start the worm amplitude cap (it
changes the fold contract all 13 tests rest on), do not build or push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:02:43 +02:00
Fr0zkaandClaude Opus 5 b888b86729 docs: stage A verified with real coverage; rewrite the handoff
The run that matters: bit-identical over 6000 samples with 23.9% of them in
open cave, and a bake carrying 49 rooms / 56 pits / 28 chimneys / 0 columns
— so the pit and chimney loops finally ran on real data, and 0 columns
confirms STEP 4d stayed dormant as stage A requires.

Same code and same colour as the previous green run, three different
strengths of evidence. That is the argument for printing coverage numbers
rather than pass/fail.

Handoff rewritten for a fresh context: the three-stage TunnelNetwork plan
and why stage A is verifiable while incomplete, the calls-not-transcribes
rule for BuildChunkCache, FRAME ops recorded as retired (0 of 3 candidates
needed one), the per-room override reclassified as load-bearing rather than
polish, and the method lessons regrouped around the three coverage traps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 02:58:33 +02:00
Fr0zkaandClaude Opus 5 68e43238bd test: pits and chimneys never ran — ask the bake, not the density
Check 3b earned its keep on its first run: 0 of 1500 points moved when
PitDensity was zeroed, because BuildChunkCache bakes pits inside
`if (!CR.RoomOp) continue;` and then reads a FRESH param struct with only
that room's terrain op applied. FStrateGenerationParams::PitDensity is
never read by the bake. Pits, chimneys and columns exist only through a
per-room UVoxelTerrainOpDefinition, and the fixture had none.

Not a product bug: those fields carry no UPROPERTY, so no editor surface
offers a setting that quietly does nothing. My reading was wrong.

Three attempts, and the second is the instructive one. Zeroing the params
tests fields nothing reads. Building a second stack with an empty op pool
would have LIED — the op pool is not in the SDF cache key (LayoutVersion
covers pool edits in production), so both stacks share the thread_local
cache and report "no contribution" for a third wrong reason. So: ask the
bake what it baked. Rooms > 0, pits > 0, chimneys > 0, columns == 0.

The test now attaches a real Pit/Chimney op pool. Safe at stage A because
ApplyTo(Pit) writes only pit fields, so none of the 13 unported detail
modifiers wake up — a Terrace op there would break it, which is exactly
what stage B adds.

Also reworded the green equivalence message, which claimed pits and
chimneys were exercised while zero existed. A success message that asserts
coverage instead of reporting it reads as evidence while measuring nothing.

Cave coverage 1.1% -> 21%, fingerprint 0.75% -> 95.8%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:22:07 +02:00
Fr0zkaandClaude Opus 5 5bf61c1815 test: stage A is bit-identical — strengthen the test its own counters indicted
The port is correct across 6000 samples. The only failure was arithmetic in
the assertion: 4 ops + 3 structural = 7, and I wrote 6.

But two numbers that PASSED are the real finding. 65 of 6000 samples landed
in open cave (1.1%), so the equivalence mostly compared solid rock to solid
rock — the carve, pits, chimneys and worm carve only run near the network.
And 3 of 400 probe points genuinely differed between the two param sets, so
the stale-cache check asked its question three times and said "fine" 397
times about points that could never have answered it.

Both counters were written to say exactly this, and did. Both guards only
fired at ZERO, so the run went green with the coverage of a much smaller
test. A coverage guard that only trips at zero does not measure coverage,
it notices absence. Both are now fraction thresholds that print a percent.

RoomSpacing 80 -> 42, RoomDensity 0.35 -> 0.85.

New check 3b: rebuild the stack with PitDensity = ChimneyDensity = 0 and
count the points that move. Enabling a feature in the params is not evidence
it fired — SDFCache.Pits can come back empty and the test stays green — and
these are the two loops DECOMPOSITION §2 calls the fiddliest in the plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 18:41:56 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-27 18:28:06 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-27 17:50:36 +02:00
Fr0zkaandClaude Opus 5 f5d5a03ad1 docs: end-of-day handoff — 5 of 8 ported, 11 tests green
Rewrites OPSTACK-HANDOFF.md for a fresh context: the port table, what is left in
order, the two things Phase 2 invented that were not in the original design (height
space as a second operator family, and IVoxelBiomeField so ops depend on a capability
rather than the generator), and the method lessons that cost build cycles to learn.

Open items, none blocking: perf (parked by Jahni), C9's library half (no measured
risk), VerticalShafts' pessimistic box verdicts, and ClassifyTile still using
hand-written guards while ClassifyBox sits verified but unconsumed — which is where
the measured tile-skipping would actually become frames.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:17:35 +02:00
Fr0zkaandClaude Opus 5 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 f3faa3b, which was
hash(StrateBottomWorldZ, LayoutVersion, Seed) and omitted the params. Two stacks of
the same strate with different overhang settings therefore shared a key, and the
second read the first's columns, computed with OverhangAmp = 0. The overhang silently
vanished wherever a column was already cached.

Not a test artifact: this is the weakness the codebase already documents for
GSurfColCache (extended AUDIT C2 note) — a live edit that changes params without
moving the strate leaves the key unchanged and serves stale columns. Production masks
it because RebuildStrates bumps LayoutVersion; my key inherited the hole.

Fixed by folding an FCrc::MemCrc32 fingerprint of the params, and of every per-biome
param set, into the key. FSurfaceGenerationParams is verified pure POD, so a memory
CRC cannot produce a false hit; padding can only cause a false miss, i.e. a recompute.

A perf optimisation introduced a correctness bug and the tests caught it the same
day. The failure was invisible to inspection and produced plausible terrain.

Known and not fixed: VerticalShafts proves 0 of 60 tiles because EffectOverBox
returns CarveOnly whenever any shaft sits within a Spacing*1.6 halo rather than
testing real connector capsules. Pessimistic, not wrong — lost CPU, never a hole.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:13:50 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-27 17:11:07 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-27 16:59:37 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-27 16:51:18 +02:00
Fr0zkaandClaude Opus 5 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 7cd2bed, in the same file, for the identical reason.

Removed the early closer so the class keeps internal linkage with the other six.

The root cause is that the closing brace was unlabelled, so it reads as noise when
scanning for an insertion point. Both op files now mark it explicitly with what goes
above and what happens if you insert below. Cheap, and it turns a repeatable mistake
into a visible one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:46:18 +02:00
Fr0zkaandClaude Opus 5 6ad9f37a65 feat: the Mask combiner — biome blending in height space
Biome blending needs to ask which biome is at an XY, and the real answer is a warped
Voronoi with a per-chunk cache on UVoxelGenerator. The op must not hold a generator
pointer — Phase 3 wants ops to become assets, and one that owns a generator never
can. So it depends on IVoxelBiomeField, a two-line interface returning (dominant,
neighbour, weight), and the adapter that knows the generator stays on the generator's
side. Same move as cliff -> structural: depend on the capability, not the owner.

FBiomeBlendHeightSource holds one complete height stack per biome and lerps the
HEIGHTS in the border band. Each biome's stack computes its own relief and gates its
own terrace, exactly as the original makes two independent full calls and blends only
the outputs. Blending heights rather than params is what keeps borders continuous
across any param difference.

The ceiling SELECTS the dominant instead of blending, because that is what the
original does. Reproduced as-is rather than improved — a blended sky cap changes the
world's silhouette and a port is not where that gets decided.

Tested against a synthetic field rather than the real resolver: the resolver has its
own coverage, while a synthetic field sweeps the weight 0 -> 1 continuously, which is
where an inverted lerp hides. Five weights x 400 points, bit-exact against FMath::Lerp
of the two full stacks, plus a check that the ceiling still returns the dominant's at
weight 1.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:43:01 +02:00
Fr0zkaandClaude Opus 5 353c023dfd test: C9 is only half fixed — the libm half survives FPSemantics
All 9 tests green. LargeSeedSurvives proves C1 fixed by property rather than by
comparison: seed 2e9 (what FMath::Rand produces) now yields 400 distinct heights
over 400 samples where it previously gave a constant field. The three equivalence
tests stayed green through an 85-site rewrite.

The digest's NearIso warning fired at 2/115000 and its text blamed the /fp:fast vs
precise split, which is fixed. First instinct was "stale warning, soften it". Checked
instead, and the risk is real by a different mechanism:

sinf/cosf are not specified by IEEE-754. FPSemantics = Precise makes MSVC and Clang
agree on expression evaluation and says nothing about the math library; MSVC's CRT
and glibc's libm may differ by ~1 ULP. FMath::Sin/Cos are used throughout the density
path — layer lines, ribs, room placement, rotations. So C9's compiler half is closed
by construction and its library half is not, and no build flag can close it.

The measurement was also over-stating by ~100x: a single 1e-4 band is far too wide
for a libm-scale delta (~1e-6 absolute at densities of magnitude ~10). Replaced with
a three-band profile; only the tight band warns.

UNVERIFIED: the reworded test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:38:19 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-27 16:33:36 +02:00
Fr0zkaandClaude Opus 5 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>
2026-07-27 16:27:26 +02:00
Fr0zkaandClaude Opus 5 4dc55b1af3 feat: SurfaceWorld step 2b — the overhang shelf, the one op that is genuinely 3D
Step 2a green on all four counts, including FSurfaceColumnSource bit-identical to
GetSurfaceDensity over 20000 samples.

FOverhangShelfMod is the case that justifies where the two spaces were split: its
uphill reach grows with altitude (Frac = (Z - TerrainZ) / OverhangHeight), so it is
essentially Z-dependent and could not have lived in VoxelHeightOp.h. The boundary
falls where the code changes nature.

It needs TerrainZ plus a per-column gate (amp, uphill dir) the source computes.
Recomputing per voxel would pay the cliff's four resamples per lip voxel; adding a
third channel to FVoxelOpSample would put a COLUMN property in a per-voxel slot and
pollute a shared contract (section 11 has that open). Instead the source memoises
the column and the overhang reads it — the same shape as cliff -> structural.

The memo is keyed on (InstanceId, X, Y) with InstanceId from a monotonic atomic
counter, not on `this`: a freed stack and a newly allocated one can share an address,
a never-decreasing counter cannot collide. The stack evaluates every Z of a column at
one XY, so the hit rate is ~1 and this recovers per-column reuse without inventing a
second cross-chunk cache.

ComputeSurfaceColumn and SurfaceDensityFromColumn are now public: they are the only
oracle for the overhang, since GetSurfaceDensity passes OverhangAmp = 0. Private
declarations removed.

The test's third pass places half its samples inside the overhang window on purpose —
a uniform Z draw would almost never hit it and the test would pass having never run
the op, the same trap as WaterLevelRelative in the height pass. The in-window count is
reported and warns at zero.

Still missing before wiring: biome blending (the Mask combiner, section 5's Phase 3
prototype). Do not tick bUseOperatorStack on a SurfaceWorld strate with biomes yet.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:23:27 +02:00
Fr0zkaandClaude Opus 5 7cd2bed237 fix: stray brace — the sky cap class landed outside the anonymous namespace
My edit anchored on the FACTORIES banner, which sits AFTER the anonymous namespace's
closing brace. So FSkyCapHeightSource was inserted at file scope and the `}` I added
with it closed nothing — C2059 at the following `}`.

Removed the early closer instead of the late one, so the class keeps internal
linkage alongside the other five rather than leaking into the TU's global scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:18:13 +02:00
Fr0zkaandClaude Opus 5 2b2afacd9e feat: SurfaceWorld step 2a — the bridge to density space; fix IsXYPure on the slab
Height stack is green: bit-identical to ComputeSurfaceTerrainZ on both passes,
including all four F20 terrain ops on. MaxDisplacement is loose (27% used) and left
that way — loose only costs CPU, tight-but-wrong is a hole.

FIX: FSlabVoidSource::IsXYPure() returned true and that was wrong. The contract is
"Eval does not depend on Z", and Eval computes min(Z - floor, ceil - Z). Section 3.1
made the SURFACES XY-pure; the density is a distance to them and never can be. I
conflated the two while writing the operator that quotes the warning against it.

Latent only because nothing reads the flag yet — and step 2b is where it would have
gone live, since a generic T1.a column cache keyed without ChunkZ would have shared
one density down the whole vertical chunk stack. AUDIT 6.3 says that corrupts every
chunk silently and ValidateDeterminism would not catch it.

That is also the clearest argument for the height-space split: what is XY-pure is
the HEIGHT, and in VoxelHeightOp.h it lives in a type with no Z to get wrong.

Step 2a:
- FSkyCapHeightSource: the ceiling is an altitude, so it belongs in height space
  rather than density space as section 5 had it — same category slip as the terrain
  ops. The subtraction happens later, in the combine.
- FSurfaceColumnSource: consumes both height stacks, IsXYPure false.
- BuildSurfaceStack: source + 3 structural, no per-column memo inside the op since
  T1.a already exists one level up and a second cache key is a second thing to get
  wrong.

NOT covered, and the test header now says so: the overhang (GetSurfaceDensity passes
OverhangAmp = 0, so only the cached path computes it) and biome blending. Both are
step 2b; do not wire SurfaceWorld into a biome or overhang world before then.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:14:39 +02:00
Fr0zkaandClaude Opus 5 921a9fb666 feat: height-space operator family — SurfaceWorld step 1, and C10 is solved
Maze and Slab now report BIT-IDENTICAL: FPSemantics = Precise, set for
cross-platform play, dissolved the ULP residue. Hypothesis 3 had the right
mechanism all along — under /fp:fast the compiler transforms by surrounding
context with no isolable axis, which is exactly why five one-variable experiments
all came back negative. Removing the permission removed the difference. Nobody
solved C10; C9 got fixed for an unrelated reason and C10 fell out of it.

SurfaceWorld step 1 forced an architectural decision. DECOMPOSITION section 5 notes
the height ops operate on Z values rather than density, then lists them as children
of FHeightfieldSource. Writing them made the consequence unavoidable: they do not
fit IVoxelDensityOp. No input Z (they produce one), XY-pure per column rather than
per voxel, and they write neither channel. Forcing them in would need a per-voxel
channel for a column property, or one opaque op — section 2.5's failure mode.

So height space gets its own contract: VoxelHeightOp.h (FVoxelHeightSample with
Height + Relief, IVoxelHeightOp, FVoxelHeightStack) and five ops. Relief is the
original's M — produced by the structural source, consumed by the terrace gate.
Section 0.1 found density needed a second channel; this found terrain needs a
second space.

The type system now forbids for free what AUDIT 6.3 warns about: a height stack
cannot hold Z-dependent data because there is no Z in the signature.

Deliberately staged — this touches nothing on the density path. If height space had
not decomposed cleanly, it shows up here for one test rather than after building the
adapter, the column cache integration and the dispatch on top.

The test runs twice; the second pass is load-bearing because the F20 terrain ops are
off by default, so a defaults-only run leaves all four modifiers untested. It also
brute-forces MaxDisplacement, since a false bound would later be a hole.

ComputeSurfaceTerrainZ moved private -> public for the test, same justification as
GetSlabDensity. Old declaration removed.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:06:50 +02:00
Fr0zkaandClaude Opus 5 25df4fceff build: complete the IWYU tail — APawn in VoxelWorld.cpp
VoxelWorld.cpp:526 dereferences the pawn, so APawn must be complete; Casts.h only
forward-declares it. Adds GameFramework/Pawn.h, and PlayerController.h which was
complete transitively only — the same fragility this change removes.

My earlier scan covered Public/ only. The shared PCH served .cpp files too, and
APawn was named in Build.cs's own error list. Everything else in the module
compiled, so this is the entire tail.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:56:06 +02:00
Fr0zkaandClaude Opus 5 6a60732c98 build: FPSemantics = Precise + clear the IWYU debt that blocked it
Jahni: cross-platform play (Linux and Windows, either side hosting) is a product
requirement, so fix C9 at the cause instead of measuring the symptom.

Verified in UE 5.7 source rather than assumed:
  VCToolChain.cs:1264    Default -> /fp:fast          Precise -> /fp:precise
  ClangToolChain.cs:712  Default -> -ffp-contract=off Precise -> -ffp-contract=off
Default really did mean opposite float models per platform; Precise collapses them
onto the same one, so a Windows host and a Linux client agree by construction.

Losing the shared PCH is what the IWYU debt hid behind. Every use turned out to be a
pointer, TWeakObjectPtr or TSubclassOf parameter, so forward declarations suffice;
only the templates and macros needed real includes. Seven headers fixed.

VoxelDensityVolume.h was the one worth catching: it tests ENABLE_DRAW_DEBUG in an
#if, and an undefined macro there is silently 0 — the debug block would have
vanished without a warning rather than failing the build. Include paths verified
against the engine tree, not guessed.

Expect a residual tail; the shared PCH hid these for years and only a build
enumerates them all. Build.cs now says so, and says the fix is to add the include
rather than revert FPSemantics.

Also adds VoxelForge.Determinism.CrossPlatformDigest: SHAPE digest (sign of density
= the world) and FIELD digest (bit-for-bit) over a fixed integer grid, plus NearIso
to bound how many samples could flip sign at all. Reports rather than asserts until
pinned. The cross-platform comparison itself is deferred per Jahni.

Expect a perf regression from losing reassociation and contraction on a noise-heavy
hot path — measure against ARCHITECTURE 8.10.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:53:23 +02:00
Fr0zkaandClaude Opus 5 f1fd1e0b05 docs: new acceptance bar (peer agreement, not fidelity) — and C1's documented fix is wrong
Jahni: "I do not need your work to be identical or near identical to what I had
before, only having it 99.99% at worst reproducible if two people share the same
seed, since everyone rebuilds it on multiplayer."

Recorded as OPSTACK-PLAN 2.6.1, superseding 2.6's "recognisably the same place".
Consequences, each recorded where it will be found:

- C10 closed permanently rather than parked: it measures old-path vs new-path
  agreement and the two never coexist in a shipped world.
- The equivalence tests keep their value as PORT-CORRECTNESS checks, not fidelity
  checks. Isosurface hard-fail stays; ULP grading is diagnostic only.
- C9 promoted to top open risk. "Two people share a seed" is exactly what /fp:fast
  weakens across toolchains, and a Linux dedicated server against Windows clients
  compiles the density path under opposite float models. FPSemantics = Precise is
  the fix and the IWYU debt now blocks something that matters.
- C1 unblocked: it was deferred only because it re-rolls the world's noise.

Then, doing C1's arithmetic before applying its documented one-liner: THE FIX IS
WRONG. It bounds SeedF but keeps the * 97.7 multiplier, so the coordinate term
still reaches 1.6e6 where the ULP is 0.19 — 9.5x the ~0.02/voxel step. It would
have left the bug live for mid-range seeds while closing the ticket.

The real fix deletes the multipliers: they only decorrelate the ~40 noise sites,
which is a hashing job. VoxelHash::SeedOffset(Seed, Site) gives a site-salted,
bounded, final-units offset. Bounding SeedF alone would also funnel every seed
through 16384 offsets shared by all sites; per-site salting requires a collision at
all ~40 sites instead.

The op stack has already inherited the bug via FSlabVoidSource, so it must land in
both paths at once — and every further port copies it again.

Docs only; the C1 fix is not written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:43:23 +02:00
Fr0zkaandClaude Opus 5 51d8db842b test: fix the ULP yardstick — it measured output density, not where the error is born
The tuned pass warned: 121/20000 differ, 6 over the bound, worst 1.72e-05, still 0
isosurface crossings. The port is fine; the bound was wrong.

It was 16 * max(|Old|, 1) * FLT_EPSILON — ULPs on the OUTPUT density. But density is
min(Z - Floor, Ceil - Z), so near the isosurface the output tends to 0 while the
intermediates are in the hundreds. Rounding born at scale ~400 judged against a
yardstick of scale 1: 400x too tight, and tightest exactly where the test looks
hardest. Large amplitudes are what expose it, which is why the tuned pass earned
its place immediately.

Measured rather than assumed: amplitudes rose x2.25-3.33 and the deltas rose x4.5,
with the worst delta at 0.345 ULP of |Z| — sub-ULP at the scale it is born in. Error
proportional to amplitude is ordinary rounding. A wrong noise offset or a missing
abs() would move the surface by voxels, four orders of magnitude above this.

The bound now scales with max(|Old|, |Z|, strate Z bounds), and the warning prints
the discriminator instead of just the alarm: the density at the offending sample and
the delta in ULPs of the working scale. A few ULP at near-zero density is
cancellation; thousands is drift. That distinction is now readable rather than
re-derivable at a build apiece.

The box verdicts held under the worst case: 32/60 proved uniform, 0 unsound, under
tripled ceiling roughness and 3x the columns — exactly the case that stresses the
Max(CeilZ - noise, FloorSurface + 2) clamp.

Also recorded in DECOMPOSITION section 3: FlatPlain and CrystalChamber render
identical in the live world because nothing in the content distinguishes them. The
merge loses no distinction; it reveals there was none.

UNVERIFIED: the corrected bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:39:03 +02:00
Fr0zkaandClaude Opus 5 85993199fb test: the slab test proved less than it claimed — add the pass that varies CeilingRoughness
SlabEquivalence came back green (FlatPlain 36/60 and CrystalChamber 40/60 tiles
proved uniform, vs zero for today's ClassifyTile; 52/20000 ULP-scale diffs, 0
isosurface crossings). But both archetypes reported the SAME 52 and the same worst
delta, which pointed at the fixture: FTestWorld::Build sets only GeneratorType, so
both slots carry DEFAULT slab params.

So the two passes were the same configuration at two depths. The test claimed to
demonstrate "one op, two archetypes" while never varying CeilingRoughness — the
only field that actually distinguishes CrystalChamber. The differing tile counts
come from the slots' Z ranges, not from the archetypes.

Third pass added: CrystalChamber(tuned), CeilingRoughness 6 -> 20, rougher floor,
3x the columns. It varies what matters and doubles as the worst case for the
ClassifyBox amplitude bounds — a large CeilingRoughness widens the ceiling band and
makes the FloorSurface + 2 clamp far more likely to bind, which is precisely where
a false verdict would be a hole. The default params were too gentle to stress it.

The ULP residue is left alone: deterministic, 0 isosurface crossings, and the same
shape C10 already cost six builds to prove not worth chasing.

UNVERIFIED: the third pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:34:16 +02:00
Fr0zkaandClaude Opus 5 644339def5 feat: Phase 2 first port — FlatPlain + CrystalChamber collapse into one op
Jahni closed OPSTACK-DECOMPOSITION 3.1: the slab noise Z term was not
intentional character. Phase 1 also closed — the visual A/B on Maze passed.

Two changes, deliberately together, kept attributable by the test:

1. Design: GetSlabDensity's floor and ceiling noise lose their Z terms. A floor
   height no longer depends on the altitude you sample it from. The ceiling keeps
   its + 3000.0f, which is a decorrelation offset, not a Z term. The world
   re-tunes once — a different slice of the noise field, not a worse one.

2. Refactor: the now-XY-pure function ports to FSlabVoidSource + FGridColumnMod
   plus the three structural ops. BuildSlabStack has NO branch on archetype
   because GetSlabDensity never had one — CrystalChamber is FlatPlain with a
   bigger CeilingRoughness. 8 archetypes -> 7.

SlabEquivalence compares against the reference AS IT IS NOW and runs the whole
battery on both slots, so green means the port is a pure refactor and any visual
delta is attributable to the Z-term removal alone. The attribution comes from the
test, not from splitting it across two builds.

The payoff 3.1 was actually about: FSlabVoidSource::ClassifyBox is exact and needs
no sampling. FBM is contractually [-1,1], so both surfaces live in Z bands with
known bounds — a tile below the floor band is provably solid, a tile between the
bands provably air. ClassifyTile proves zero tiles for these archetypes today.
FGridColumnMod answers Identity when no column reaches the box, which is what lets
the source's AllAir verdict survive the fold.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:30:26 +02:00
Fr0zkaandClaude Opus 5 974e795b66 fix: call PrepareChunk and guard the degenerate strate on the wired path
Read the step-3 wiring against the equivalence test before spending a build.
The symbols all line up; the two PATHS did not.

- GetDensityAt never called FVoxelOpStack::PrepareChunk, though the test does.
  All seven concrete bodies are empty today so behaviour is unchanged — which is
  the reason to fix it now: the first op to hoist real per-chunk work would have
  been green in test and silently wrong in game. Builds an FVoxelOpContext in the
  same refetch block (chunk, seed, layout version, strate Z bounds). Step stays 1;
  GetDensityAt does not know the mesher's sampling step (T2.b).

- GetMazeDensity early-outs to air on a degenerate strate (height <= 0) and the
  stack has no such early-out by design. Unguarded that is air on one path and
  spine/seal-of-a-zero-height-band on the other, so the wired path now falls back
  to the switch there — the reference behaviour is the behaviour.

Docs: VoxelDensityOpStack.h's banner still claimed nothing here feeds the game,
and CODEMAP 3.2d repeated it. Both now state what is wired (GetDensityAt) and
what is not (ClassifyTile, hand-written guards, Phase 2), with C10's never-compare
rule at the point of use. CODEMAP gains UsesOperatorStackForChunk and
bUseOperatorStack rows, and BuildMazeStack's degenerate-strate precondition.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:15:59 +02:00
Fr0zkaandClaude Opus 5 f7cccb044b docs: add OPSTACK-HANDOFF.md; install the 39 Unreal skills
Handoff: a pasteable resume prompt at the plugin root -- read order, exact state
(Phase 0.5 green, Phase 1 done and measured, step 3 written but not compiled),
the immediate next action, hard rules, open items, and the method lesson from
this session.

Skills: Jahni's UE library was at .claude/skills/core/<name>/SKILL.md, two levels
deep, where Claude Code discovers skills one level deep -- so none of the 39 were
loading. Flattened; 124 reference files intact, all frontmatter valid, folder
names already matched their name: field. core/category.md left as documentation.
Confirmed loading.

They are untracked and cannot be tracked without un-ignoring .claude/ itself
(git cannot re-include a file whose parent directory is excluded). Same shape as
AUDIT P1; flagged, not actioned.

Note for the next session: module-and-build-system documents PCHUsage, shared
PCHs and IWYU -- the exact mechanism that blocked C10's settling experiment. That
skill was in the repo, undiscovered, while it was worked out the slow way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:11:26 +02:00
Fr0zkaandClaude Opus 5 256a262140 feat: Phase 1 step 3 — wire the operator stack into GetDensityAt behind an opt-in
One branch on the density path, as OPSTACK-PLAN section 4 specified, and both
systems coexist.

- UVoxelStrateDefinition::bUseOperatorStack: the A/B switch section 2.6's
  acceptance bar needs. Flip it, regenerate, judge on a screenshot.
- UVoxelStrateManager::UsesOperatorStackForChunk(): the ported-archetype list,
  written down in exactly one place. An unported archetype ignores the flag and
  falls back to the switch, so ticking the box anywhere is harmless today and
  only Maze changes behaviour.
- GetDensityAt: CP_OpStack / CP_UseOpStack are resolved inside the SAME refetch
  block as the params, so the existing chunk + LayoutVersion key already covers
  them and there is no new invalidation logic to get wrong.

Hot-path cost is one bool test per voxel; the stack is built per chunk, the same
cadence as the param refetch. ApplyDisturbances and the diff layer stay outside
the stack and run once for both paths, so the tail of the pipeline is unchanged.

If UsesOperatorStackForChunk ever returns true for an archetype with no builder,
the code clears the flag and falls back to the switch rather than generating an
empty stack. An unported world is recoverable; a wrong one is not.

UNVERIFIED: not compiled. Likely spots: the `else switch` form, FVoxelOpStack as
a thread_local (move-only, reset by move-assigning a temporary), and the new
include.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:03:00 +02:00
Fr0zkaandClaude Opus 5 34f06dfea7 docs: park the ULP residue as AUDIT C10; strip the diagnostic scaffolding
Jahni's call to pin it and move on, and the right one -- six builds spent and the
information stopped being worth the cost.

The final run closed it as far as it can be: SDFs identical everywhere (counted
unconditionally, 0 differ), yet two character-identical carve implementations in
the SAME translation unit fed a provably identical input differ by 1 ULP on
126/5000. That is only possible if they compile to different instruction
sequences, which /fp:fast permits based on surrounding context with no single
isolable axis. Hypothesis 3 was right about the mechanism and wrong about every
clean variable proposed for it, which is why four well-designed isolation tests
came back negative.

AUDIT C10 records the observation, what is proven (SDF bit-exact 126/126, zero
isosurface crossings), the five refuted hypotheses in a table so nobody repeats
them at a build each, why the settling experiment is blocked (shared-PCH / IWYU
debt), and the rule that actually matters: never run both density paths in one
world and never compare them for equality. That is NOT a client-desync risk --
within a binary the field is proven bit-pure and every peer runs the same path --
the cross-platform concern is C9, which stands on its own.

Corrected OPSTACK-PLAN 2.6 and C9: my earlier "/fp:fast across translation units"
explanation was measurably wrong and is removed rather than softened.

MazeEquivalence keeps the permanent value (equivalence with ULP grading,
window-invariance, box-verdict brute force) and drops the verbatim copy,
three-way, bisect, inlining and constness experiments.

Phase 1 closed: Maze decomposes into 7 ops, SDF bit-exact, 0 isosurface
crossings, window-invariant, and 23 of 60 tiles proved uniform where ClassifyTile
proves zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:00:46 +02:00
Fr0zkaandClaude Opus 5 b6ccb1c9f1 test: constness isn't it either — add the one unambiguous check, then stop chasing
CONST-Blend == runtime-Blend exactly (0 differ) and both miss the verbatim on the
same 126, so Blend's constness is not the variable. Five hypotheses, five dead.

Worse: one number I reasoned from was circular. "FORCENOINLINE == operator stack
: 5000/5000" cannot fail by construction -- it feeds S.Sdf to a carve and compares
against the density the stack computed from that same S.Sdf. It measures nothing,
and I read it as corroboration.

The two carve bodies are now dumped from the file and diffed: character-identical,
same translation unit. So one of expression / TU / input is not actually
identical, and the counters can't say which because the SDF comparison only ran
inside the mismatch branch.

Added: feed my carve the SDF the verbatim reports using and compare to the
verbatim's own output, plus count S.Sdf != VerbSdf directly with no enclosing
condition. That distinguishes "same function, same input, different output"
(measurement artefact) from "the SDFs were never equal outside the mismatch set"
(fault back in the lattice).

Proportion: this is the last build worth spending here. The port is already
verified where it matters -- SDF bit-exact 126/126, 0 isosurface crossings out of
20000, geometry identical, window-invariant, every box verdict brute-forced. The
open question is why the final rounding differs by 1-2 ULP, and no decision in
this project turns on it. If the check doesn't resolve it: accept, correct the
docs, strip the scaffolding, resume Phase 1 step 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:57:21 +02:00
Fr0zkaandClaude Opus 5 7189d51b7b test: the variable is compile-time-constant vs runtime Blend — one line to confirm
The inlining experiment partitioned everything, just not along the axis it was
framed on:

  inlined != FORCENOINLINE  : 0          inlining is not the variable
  FORCENOINLINE == op stack : 5000/5000  test-TU carve == other-TU op, always
  inlined == verbatim       : 4874/5000  126 differ, in the SAME TU

The test-TU carve matches the operator stack across a TU boundary perfectly and
disagrees with the verbatim inside its own TU, so neither TU nor inlining is it.
Sorting the five implementations by the one remaining difference splits them
exactly: A (GetMazeDensity) and C (verbatim) hold Blend as a compile-time
constant; B (FSdfCarveOp, a member) and both parameter versions hold it as
runtime data. A == C, B == Inl == Noi, and the groups differ. Every observation
today fits that and nothing else does.

Mechanism: under /fp:fast, folding Blend * 2.0f to the literal 4.0f enables a
contraction in SmoothStep01's 3.0f - 2.0f*x -- one rounding instead of two --
that the runtime form cannot get.

This matters beyond the bug: an op's parameters are DATA by design, which is the
entire point of the refactor, so they can never be compile-time literals again.
The ULP difference is therefore inherent and permanent for every archetype port,
and no care in transcription will remove it. That is the real reason bit-identity
is unachievable here -- the earlier /fp:fast note named the right compiler flag
for the wrong reason.

CarveConstBlend added: identical to CarveInlined except Blend is a compile-time
constant. Predicted to match the verbatim 5000/5000 and differ from the runtime
form on exactly 126.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:53:02 +02:00
Fr0zkaandClaude Opus 5 34f8ca7953 test: localised to FSdfCarveOp — now testing inlining context, the right variable
The SDF is bit-identical on 126 of 126 mismatches (0xBFE1C886 both sides), so the
lattice, hashes, edge set and VoxelSDF::Capsule are exactly right. The entire
difference is in FSdfCarveOp, whose expression is character-identical to the
original and whose inputs are bit-identical.

Identical inputs plus identical expression plus different output means the
arithmetic is being EVALUATED differently. SmoothStep01 is x*x*(3.0f - 2.0f*x),
and 3.0f - 2.0f*x is exactly the shape MSVC fuses into an FMA: one rounding
instead of two, ~1 ULP.

Why the three-way missed this, recorded because it is a reasoning error rather
than a coding one: A and C are both straight-line inlined code, while B goes
through a virtual IVoxelDensityOp call, so FSdfCarveOp::Eval is compiled
out-of-line and can get a different contraction decision. The three-way tested
whether the TRANSLATION UNIT boundary changes the result -- it does not -- but the
real variable is the OPTIMISATION CONTEXT. I built a clean experiment for the
wrong variable and then believed its answer. Hypothesis 3 was right about the
mechanism and wrong about the test.

The new experiment isolates exactly that: the same carve expression, same TU,
once FORCEINLINE and once FORCENOINLINE.

  differ    -> contraction confirmed, the port has NO bug, accept the ULP floor
  identical -> contraction is not it, and FSdfCarveOp has a real logic bug that
               has survived four readings

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:48:56 +02:00
Fr0zkaandClaude Opus 5 210602586e test: three-way says the fault is mine — compare the SDF channels directly
A vs C = 0 differ. Identical source in two different translation units produces
identical results, so the compiler was never the cause and the operator stack
differs for a logic reason. That kills the /fp:fast story for the fourth time
running, and for the first time points at code I own.

Reading has failed three times: the ctor clamps, cell FloorToInt, NodeCenter,
EdgeOpen's hashes and salts, the {-1,0}^3 sweep and its add order, the capsule
fold, and the carve are all identical to the verbatim copy line by line. So stop
reading and measure one level deeper.

MazeCoreVerbatim now optionally returns its SDF and edge count, and the three-way
compares SDF channels directly instead of inferring from densities:

  SDF identical, density differs -> fault is in FSdfCarveOp
  SDF differs                    -> fault is in FLatticeCorridorSource

It reports the split across all 126 mismatches and dumps the first one in hex
with the verbatim edge count, so a differing edge SET (a cache-key bug) shows up
as a count mismatch rather than needing to be inferred.

Docs to walk back once the cause is known, listed in OPSTACK-PROGRESS so they are
corrected once with the right explanation: OPSTACK-PLAN 2.6's "bit-identity is
unachievable" note, AUDIT C9's first consequence, and this test's own INFO text.
C9's second half -- that UBT's FP default differs by toolchain and the MP model
assumes bit-reproducible terrain -- stands; it came from the engine source, not
from this test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:44:11 +02:00
Fr0zkaandClaude Opus 5 d5c71d6b68 build: revert FPSemantics (drops the shared PCH); answer the FP question in the test instead
Setting FPSemantics on VoxelForge broke the build with ~30 "undefined type"
errors -- UMaterialInterface, USoundBase, TSubclassOf<AActor>, APawn,
ENABLE_DRAW_DEBUG -- none of them FP-related. UBT can only share a precompiled
header between modules whose compile environments match, so changing FPSemantics
cost the module the engine's shared PCH and with it ~30 includes the plugin has
always relied on getting for free.

That is a genuine latent IWYU debt in seven files, and worth fixing on its own
terms one day, but not inside an unrelated diagnostic. Reverted, with the reason
recorded in Build.cs so nobody retries it blind.

The question it was meant to settle is now answered without touching any build
setting: MazeEquivalence compiles a verbatim copy of the Maze core into the
TEST's translation unit and compares three implementations of identical source --
the generator's TU, the op stack's TU, and the test's own.

  A != C          -> same source, different TU, different result: the compiler.
                     Nothing to fix in the port.
  A == C, B != C  -> source is TU-stable, so the op stack differs for a LOGIC
                     reason, and it is in FLatticeCorridorSource or FSdfCarveOp.

Duplicating code is normally a fault. Here it is the only instrument that can
answer the question, because three careful readings all concluded "identical" and
the test keeps disagreeing. Marked diagnostic-only; it comes out once answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:39:09 +02:00
Fr0zkaandClaude Opus 5 a49d440bf6 build: put FPSemantics=Precise on the VoxelForge module — the experiment never ran
FPSemantics is a per-module ModuleRules property. It had been set on the VoxelM
GAME module, while every line of density code lives in VoxelForge, which kept
compiling /fp:fast. So the run that supposedly "reproduced the residue under
precise semantics" ran under fast semantics and proves nothing.

Retracting the previous commit's conclusion: /fp:fast is NOT eliminated as the
cause, and the notes claiming AUDIT C9 and OPSTACK-PLAN 2.6 are falsified are
withdrawn with it. Those documents were fine.

My error, and the third of its kind today: I reasoned a confident conclusion from
an unverified premise, one paragraph after writing that the lesson was to
instrument rather than assume. Checking took one grep and I only ran it after
Jahni suggested it.

The line is marked TEMPORARY with removal instructions and a guide to reading the
result. Combined with the WORST-POINT DUMP already committed, one build now
separates the two possibilities cleanly:
  454 -> 0   : FP model was the cause; keeping precise then needs a profile,
               because it costs the vectorisation T2.a's SIMD work was buying.
  454 -> 454 : real logic difference; read the dump.

Either way the line comes back out afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:34:50 +02:00
Fr0zkaandClaude Opus 5 cca9e83182 test: /fp:precise reproduced the residue byte for byte — instrument instead of guessing
An /fp:precise build returned the identical 454 samples, identical max delta,
identical coordinate. A different float model producing byte-identical output is
proof that rounding is not the cause, so the /fp:fast explanation is dead. That
is three failed hypotheses on one discrepancy (FVector round-trip, then "check
the roughness window", then /fp:fast), each reasoned from plausibility and each
costing a build.

So: stop reasoning, print. FVoxelOpStack::EvalSample exposes the full sample, and
MazeEquivalence now dumps the worst point in raw hex -- both densities, the
stack's internal SDF, and the carve factor reconstructed from each side. The
recovered carve localises it: identical carve + differing density means the fault
is after the conversion; differing carve means it is in the SDF (lattice edges or
VoxelSDF::Capsule) or in SmoothStep01.

Note for whoever reads the docs next: AUDIT C9 and OPSTACK-PLAN 2.6 currently
assert the /fp:fast story as the explanation for THIS residue. That specific
claim is falsified and needs walking back once the dump identifies the real
cause. C9's other half -- that UBT's FP default differs by toolchain and the MP
model assumes bit-reproducible terrain -- stands independently; it was read out
of VCToolChain.cs and ClangToolChain.cs, not inferred from this test.

Phase 1 step 3 (wiring the stack into GetDensityAt) is paused until this is
understood. Small unexplained numeric differences do not get smaller when you
build on top of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:32:22 +02:00
Fr0zkaandClaude Opus 5 0379d59c1c docs: AUDIT C9 — the FP default differs BY TOOLCHAIN, not just "fast is allowed to drift"
Jahni asked the right question: does the same seed produce identical results
across OS builds today? Checking the other toolchain made the answer sharper and
worse than what C9 originally said.

ClangToolChain.cs (Linux, Mac, Windows-with-Clang):

    case FPSemanticsMode.Default: // Default to precise FP semantics.
    case FPSemanticsMode.Precise: Arguments.Add("-ffp-contract=off");

and VCToolChain forces Precise when Windows uses Clang. So the same
FPSemanticsMode.Default resolves to OPPOSITE float models per toolchain, and
Windows/MSVC is the only imprecise configuration in the engine's defaults. Two
builds of identical source are not merely permitted to diverge -- they are
compiled under different rules.

Also added, so the entry does not over-fear itself:
- Calibration: 0 of 20000 samples crossed the isosurface under a 1-ULP
  perturbation, so divergence means occasional single-voxel surface differences,
  not different terrain. The case that bites is topological (a cave pinch-point
  connecting on one build and not the other), which is rare and unreproducible --
  the expensive kind.
- Precise everywhere still would not guarantee cross-platform bit-identity:
  FMath::Sin/Cos route to platform libm, which is not bit-standardised. It closes
  the large gap, not every gap.
- The knob would ALIGN Windows with every other platform rather than being a
  one-sided cost -- but still must not be turned speculatively.
- The claim is inferred, not measured. The cheap decisive test is one Windows
  build with FPSemantics = Precise: if MazeEquivalence's 454-sample residue
  vanishes, the FP model is confirmed as the sole cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:19:42 +02:00
Fr0zkaandClaude Opus 5 af5f2103b3 test: the Maze residue is /fp:fast, not port drift — encode the real bar
The bisect settled it. The difference survives every stage removal down to
"corridors + carve ONLY", which is character-for-character transcribed code, so
it is not in anything the decomposition added.

Cause, read out of the engine rather than assumed (VCToolChain.cs):

    case FPSemanticsMode.Default: // Default is imprecise FP semantics.
    case FPSemanticsMode.Imprecise: Arguments.Add("/fp:fast"); break;

with UBT's own doc: "the compiler is allowed to transform math expressions in
ways that might result in differently rounded results". Identical source in two
translation units may reassociate differently, worth ~1 ULP. It shows up on
exactly the ~2% of samples inside the SDF blend shell, where Blend - Sdf
catastrophically cancels; outside it Carve is exactly 0 or 1 and both agree.

So MazeEquivalence now grades what it can actually assert:
  - hard fail  : any isosurface crossing (geometry moves)
  - info       : differences at ULP scale (the unavoidable floor)
  - warn       : anything larger, which IS port drift, and runs the bisect
A test that warns on every port would get ignored by the port that matters.

Recorded in OPSTACK-PLAN 2.6, and as AUDIT C9 for the part that outlives this
refactor: ARCHITECTURE 9.1's "every peer regenerates identically" holds only
between bit-identical binaries under /fp:fast. Fine for one build on one
platform; a real desync source for a Linux server plus Windows clients both
regenerating authoritative geometry. The FPSemantics::Precise knob exists but
must not be turned speculatively -- it blocks the vectorisation T2.a was chasing,
on the hot loop, for an unmeasured cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:15:43 +02:00
Fr0zkaandClaude Opus 5 23605d5350 test: bisect the residual Maze difference instead of guessing at it again
All six tests are green as of the 12:02 run, so Phase 0.5's gate is met.

MazeEquivalence still reports 454 differing samples, the same max delta, at the
same coordinate as before -- byte for byte the previous result. So the FVector
float->double->float hypothesis from the last commit is dead: that detour is a
no-op, exactly as /fp:precise says it should be. It stays (harmless, and it
documents the original's shape) but it explains nothing.

Rather than propose a third guess, MazeEquivalence now bisects: it re-runs the
comparison with roughness, then seal, then spine, then passages disabled ON BOTH
SIDES, and reports which stage's removal makes it bit-exact. One run answers what
two hypotheses failed to.

Standing hypothesis for the bisect to confirm or kill: compiler float
contraction across translation units under /fp:fast, worth ~1 ULP. It fits the
~2% hit rate -- only voxels inside the narrow SDF blend shell have an unsaturated
carve factor; everywhere else Carve is exactly 0 or exactly 1 and both paths
agree bit for bit. If confirmed, bit-identity is not achievable in principle for
these ports and the bar for every later archetype is "zero isosurface
crossings", which is what OPSTACK-PLAN 2.6 asked for anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:07:14 +02:00
Fr0zkaandClaude Opus 5 826a8c99dc fix: first-green-build follow-ups (diff-layer assertion + Maze float rounding)
The build passed and six tests ran; five green. Details in OPSTACK-PROGRESS.md.

1. DiffLayerContention's failure was the test, not the plugin.
   GetTotalModificationCount() sums STORED ENTRIES, not operations -- a stroke is
   filed under every chunk its AABB overlaps, so 400 radius-6 spheres straddling
   chunk corners store 3200 entries. The assertion now compares the stored count
   against the chunk fan-out ApplyModification itself returned, which also checks
   that the re-mesh list handed to the caller describes what was actually written.
   Everything the test exists for had already passed: 7 readers, 28.7M read
   rounds against 760 writes and 6 Clear()s, no crash, monotonic version.

2. MazeEquivalence: 454/20000 samples differed by at most 1.907e-06 -- exactly
   one ULP at magnitude 16 -- with ZERO crossing the isosurface, i.e. not one
   triangle would move. Leading hypothesis: the original routes noise coords
   through an FVector (double in UE5) and back to float, rounding twice, while
   the op passed floats straight through; under /fp:fast those round differently.
   The op now reproduces the detour on purpose, with a comment against
   "simplifying" it. Unverified -- it predicts 0 differences next run. If drift
   remains, next candidate is FMA contraction across translation units.

Also recorded, because it is the perf half of the whole refactor: the Maze op
stack proved 23 of 60 tiles uniform. ClassifyTile proves ZERO for any cave
archetype today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:40:51 +02:00
Fr0zkaandClaude Opus 5 62e3d5a933 fix(build): FVoxelOpStack is move-only, so it must not be a dllexported class
MSVC C2280 on TArray<TUniquePtr<IVoxelDensityOp>>'s copy path. Putting
VOXELFORGE_API on the class forces the compiler to instantiate every implicit
member, including copy-assignment -- which cannot exist for a move-only element
type. The export moves to AppendStructuralPost, the only out-of-line method.

Also declares the move-only-ness explicitly rather than leaving it implied. That
is the right semantics independently of the compiler: a stack uniquely OWNS its
operators, and copying one would mean cloning polymorphic ops, which is
meaningless here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:36:08 +02:00
Fr0zkaandClaude Opus 5 8a33bcb42a docs: CODEMAP rows + progress entry for the Phase 1 Maze port
CODEMAP gains 3.2c (VoxelDensityPrimitives), 3.2d (the operator stack and its
factories), FVoxelOpSample under 3.2b, and the two new tests under 3.12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:33:00 +02:00
Fr0zkaandClaude Opus 5 4c53d3bbed feat: Phase 1 — Maze ported to the operator stack, OFF the hot path
Maze decomposes into seven ops with no contortion:

  ConstantRockSource -> LatticeCorridorSource -> SdfRoughnessMod -> SdfCarve
  -> OriginSpine -> BoundarySeal -> PassageCarve

That is the answer to Phase 1's actual question (OPSTACK-PLAN section 4's
stop-trigger: "does the source/modifier split fall out naturally?"). It does.
Three of those ops are already shared: ConstantRockSource is the first line of
TunnelNetwork, Maze AND VerticalShafts; SdfCarve is the same six lines in all
three; the structural post is identical across all six density functions.

GetDensityAt and ClassifyTile are NOT touched. The archetype switch is still the
only path feeding the game, so nothing in a running world can change. The port
is validated instead by VoxelForge.OpStack.MazeEquivalence, which compares the
stack against GetMazeDensity over 20k points, re-checks purity across worker
threads, and brute-forces every box verdict the stack emits.

Two contract decisions, delegated and taken:

1. Eval is now two-channel (FVoxelOpSample { Density, Sdf }). Maze forces it:
   its roughness perturbs the SDF, not the density, and on density the same
   noise scales with the local gradient and is a visibly different effect. It is
   also what lets two different sources SmoothMin together later, which is the
   difference between a composed idea belonging somewhere and being punched into
   it.

2. The stack's density channel is INTERNAL convention (positive = solid),
   negated once by the caller. This REVERSES what the header said yesterday.
   Every archetype body is already written that way, so each port becomes a
   literal transcription instead of a sign-flip of every line -- on the plugin's
   documented #1 source of confusion. The SDF channel keeps standard SDF
   convention, so min() means opposite things on the two channels; the header
   says so loudly.

Also extracts spine/seal/passage from VoxelGenerator.cpp into
Public/VoxelDensityPrimitives.h so the generator and the ops share ONE copy of
three world invariants. Forwarders keep the local names, so not one of the ~20
call sites changes; bodies are byte-identical.

One thing found while writing the seal's ClassifyBox and NOT silently fixed: at
the inner edge of a seal band, 1 - Dist/Thickness can round to exactly 0.0f, so
SealFactor*BaseDensity is 0, internal density lands on 0, and the mesher counts
that as AIR. Claiming AllSolid there would be a hole. The new op keeps a 1-voxel
safety margin before it forces. Today's ClassifyTile has no such margin -- the
window is hairline and needs the archetype to produce air at exactly that z, but
it is real. Reported rather than patched, since Phase 1 does not touch that path.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:32:09 +02:00
Fr0zkaandClaude Opus 5 3e4ee198fe docs: progress marker before the Phase 1 Maze port
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:23:19 +02:00
Fr0zkaandClaude Opus 5 beb66e06d4 test: unit-test the op-stack fold, and make the build actually see VoxelDensityOp.h
VoxelDensityOp.h was included by no .cpp, so the compiler would never have
looked at it -- a header committed as "ready to build" that the build ignores.
The ClassifyTile test now includes it, which is also the right home for the
fold's own test: the fold claims to reproduce the hand-written ClassifyTile, and
that claim is pure logic with no world, noise or threads behind it.

VoxelForge.OpStack.BoxVerdictFold walks the correspondence case by case,
including the one that justifies ClassifyBox existing at all: a box entirely
inside the top seal band, where the source says AllAir and the seal forces
AllSolid. A pure FillOnly would resolve that to Mixed and silently lose a tile
T1.d skips today.

Also casts UE_ARRAY_COUNT to int32 in the fixture -- signed/unsigned comparison
in a loop condition is a warning, and UE builds warnings as errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:09:17 +02:00
Fr0zkaandClaude Opus 5 f8194dbf92 docs: correct an inaccurate claim in an earlier progress entry
The "starting Phase 0.5" entry said the ClassifyTile test self-skips when the
fixture fails to build. It does not -- all four tests fail loudly with a message
identifying it as a fixture failure. Corrected by appending, since the log is
append-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:07:49 +02:00
Fr0zkaandClaude Opus 5 6267af86a7 docs: tick OPSTACK-PLAN phases + final progress entry for the unattended session
Phase 0.5 and the Phase 1 skeleton marked WRITTEN / NOT COMPILED (not "done" --
the gates are not met until the tests actually run). Section 8 independent fixes
2, 5 and 6 ticked. Section 9 "Resume here" now says BUILD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:07:32 +02:00
Fr0zkaandClaude Opus 5 831ee2fbf7 docs: Q3 tick the stale PENDING BUILD markers + CODEMAP rows for the new symbols
Q3: fable-idea F20 phases 1/2 and F18, REVIEW_FINDINGS perf pass 2 and batch 3,
and ARCHITECTURE's biome full-param redesign were all still carrying
"CODE-COMPLETE, PENDING BUILD" markers dated 07-04/-06/-08. Jahni confirmed on
2026-07-26 that everything is built and working (AUDIT-2026-07.md §0), so the
documents were misreporting project state. Ticked with the date they were
ticked, not just the date they were built.

Deliberately NOT ticked: ARCHITECTURE's F6 master material graph. Its C++ half
is built, but the material graph itself is editor-side work that is genuinely
still open, and ticking it would recreate the problem this queue item fixes.

CODEMAP discipline for this batch: new §3.2b (the VoxelDensityOp contract), new
§3.12 (Private/Tests), the EVoxelTileClass move into §3.2, and
FChunkBiomeCache::Invalidate under the biome types row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:06:32 +02:00
Fr0zkaandClaude Opus 5 c188ee8262 docs: OPSTACK-DECOMPOSITION.md — the per-archetype op breakdown (Q1 + Q2)
All 8 archetypes read line by line and broken into field source / combiners /
detail modifiers / structural post, with every FStrateGenerationParams field
traced to the op that will own it.

Three findings that change the sequencing:

- The op contract needs an SDF channel alongside density. Rooms, pits and
  chimneys are SmoothMin'd in SDF space before a single carve, and three of the
  four SDF archetypes add roughness to the SDF rather than to density. A
  single-channel Eval can only overwrite, which is also why cross-source
  SmoothUnion -- 'a maze inside a mountain that looks like it belongs' -- is not
  expressible without it. Recommended before the Maze port; not applied, it is
  Jahni's call.

- Worm tunnels are why TunnelNetwork can never skip a tile. A fielded 3D-noise
  carve with no bounds forces CarveOnly everywhere, killing AllSolid for the
  most-used archetype. But its amplitude is trivially bounded by WormStrength,
  so a scalar cap recovers deep-rock skipping -- suggests one numeric bound
  belongs in Phase 2, not Phase 3 as the plan has it.

- Disturbances already carry lattice bounds that ClassifyTile discards (it only
  tests ChasmDensity > 0 strate-wide). Making them ops with real Identity tests
  is a tile-skipping win for SurfaceWorld available independently of everything
  else.

Q2 param audit: every field claimed except WaterLevelRelative, which is a
render/water property misfiled in the density struct and Lerp'd across strate
boundaries. Also flags three op names that mean different things in the cave and
surface structs and will collide the moment ops become assets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:04:00 +02:00
Fr0zkaandClaude Opus 5 a820f140e1 docs: progress log — batch A+B landed, build gate reached
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:58:47 +02:00
Fr0zkaandClaude Opus 5 b4d13e09ad test: regression test for AUDIT C2 (live-edit cache invalidation)
VoxelForge.Determinism.LiveEditInvalidation: sample a SurfaceWorld column,
triple the heightfield params, Initialize() again, and require the density to
have MOVED at the same point on the same thread.

The edit is chosen so it does NOT move the strate — StrateBottomWorldZ, and
therefore StrateKey, the seed and every chunk coord stay identical. The layout
version is the only thing that changes, so the test fails on the pre-fix code
and can only pass because the version is now part of the key.

Then re-checks purity on the edited world: a half-warm cache after invalidation
would show up as order dependence.

UNVERIFIED: not compiled, not run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:58:20 +02:00
Fr0zkaandClaude Opus 5 73f6b26f4d fix: AUDIT C2 — per-chunk caches now key on the strate layout version
Five caches under the density path were keyed on ChunkCoord (or an XY box)
alone. After RebuildStrates or an editor live edit, StrateManager rebuilds the
layout and bumps PassagesVersion, but a pooled worker whose cache is still warm
for the chunk it is asked to regenerate skips the refetch and generates with the
OLD params. RegenerateAllChunks reloads the same tile coords, often on the same
workers, so this is likely rather than exotic. Symptom: "I tweaked the strate
asset, regenerated, and one patch kept the old shape."

Fixed:
  - CP_* in GetDensityAt          (the archetype params + biome context)
  - OC_* in GetSurfaceHeightAt    (the height oracle)
  - BM_* in GetBiomeMaterialAt    (per-vertex palette)
  - TC_BiomeCache in ClassifyTile (survives across calls)
  - GSurfColCache boxes           (see below)

Two things beyond what the audit listed:

1. GSurfColCache. Its key is (XY box, StrateKey, Seed) where StrateKey is
   round(StrateBottomWorldZ). A live edit that changes terrain params WITHOUT
   moving the strate — noise frequency, mountain strength, a biome — leaves that
   key identical and serves stale columns down the whole vertical stack. This is
   the most visible form of the bug, so LayoutVersion joins the box key.

2. FChunkBiomeCache validity is a world-XY box, which says nothing about the
   FBiomeContext its cells were classified against. Refetching the context
   without invalidating the grid would leave the fix half-done, so the four
   caches call the new FChunkBiomeCache::Invalidate() on a version change.

No behavioural change at a static layout: the version only moves on Initialize.

UNVERIFIED: not compiled, not run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:58:20 +02:00
Fr0zkaandClaude Opus 5 d41d34ecd3 feat: Phase 1 skeleton — the density operator stack contract (header only)
Public/VoxelDensityOp.h: IVoxelDensityOp (PrepareChunk / Eval / EffectOverBox /
ClassifyBox / IsXYPure), EVoxelOpEffect, EVoxelOpRole (the four roles),
EVoxelOpCombine, FVoxelOpContext, and the box-verdict fold.

Nothing is wired in: GetDensityAt is untouched, the archetype switch is intact,
no operator exists yet. This is the contract plus the reasoning behind it.

Two things worth flagging beyond OPSTACK-PLAN §3:

- ClassifyBox is NOT source-only. ApplyBoundarySeal does Max(D, SealFactor*Base)
  inside its band, i.e. it FORCES solid regardless of input. Pure direction
  (FillOnly) cannot express that: over a box that sits entirely in the top seal
  band above the terrain the source says AllAir, FillOnly then kills AllAir, both
  hypotheses die and the tile becomes Mixed — whereas ClassifyTile returns
  AllSolid there today. Not a hole, but a silent loss of exactly the trivial
  tiles T1.d exists to skip. So forcing ops override the fold, and ops after them
  still apply (a passage crossing that box takes the verdict back, as today).

- The fold reproduces the current hand-written ClassifyTile line for line; the
  mapping is written out in the header. That correspondence is the evidence the
  abstraction fits this codebase rather than being imposed on it.

Also moves EVoxelTileClass from VoxelGenerator.h to VoxelTypes.h (CODEMAP 3.2:
foundational, no UClass, everyone includes it) so the op header needs no UCLASS
dependency. All existing users reach it through VoxelTypes.h transitively.

Types are plain C++ on purpose — no UHT, no .generated.h. They become
UENUM/USTRUCT in Phase 3 when ops turn into data assets.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:55:05 +02:00
Fr0zkaandClaude Opus 5 6eec796403 test: Phase 0.5 — the three automation tests (density purity, ClassifyTile, DiffLayer)
The plugin had zero tests, and the docs make dozens of "bit-identical" and
"conservative verdict" claims that nothing machine-checks. OPSTACK-PLAN §4
Phase 0.5 asks for these before any op-stack work is built on top.

- VoxelForgeTestFixture.h — headless world (transient strate definitions ->
  UVoxelSettings -> a real UVoxelStrateManager::Initialize), so the tests hit
  UVoxelGenerator::GetDensityAt where the ~30 thread_local caches actually live.
  One strate per archetype, pinned via FixedStrates so slot index -> archetype
  is stable across seeds.

- DensityPurity — 10k points re-sampled in shuffled order on the same thread and
  on N worker threads, asserting BIT equality. AVoxelWorld::ValidateDeterminism
  is game-thread only and structurally cannot see worker-cache divergence, which
  is how AUDIT C2 stayed hidden. Includes a flat-field canary so a collapsed
  noise field (AUDIT C1) can't make the test pass vacuously, and a diff-layer
  pass that exercises the direct-mapped DiffSlots cache.

- ClassifyTileSoundness — scans tiles for a non-Mixed verdict, then brute-forces
  the exact mesher lattice (g in [-1, Cells+1], margin included) and asserts every
  sample is on the claimed side of IsoLevel 0. A false AllSolid/AllAir is an
  invisible collisionless hole; T1.d v1 was reverted for exactly that in June.
  Errors out rather than passing if no tile yielded a verdict to check.

- DiffLayerContention — N reader threads running the worker call mix while the
  game thread writes and Clear()s, asserting survival and a monotonic ModsVersion.

UNVERIFIED: never compiled — this module has never pulled in AutomationTest.h
and Private/Tests/ is new. See OPSTACK-PROGRESS.md for the likely error spots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:50:26 +02:00
Fr0zkaandClaude Opus 5 3128852d4e chore: track the design docs in git (.gitignore !*.md)
AUDIT P1: every markdown design doc except CODEMAP.md was untracked, so
ARCHITECTURE / AUDIT / OPSTACK-PLAN / fable-idea / REVIEW_FINDINGS lived
only on disk. Replaces the single !CODEMAP.md exception with !*.md.

Also makes OPSTACK-PROGRESS.md commits actually record something, which
the unattended crash-safety discipline depends on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:39:21 +02:00
144 changed files with 123262 additions and 1697 deletions

No files matched your search

+7 -1
View File
@@ -9,5 +9,11 @@
!/Source/
!/Source/**
# The guarded single-entry test harness is source, not generated output.
!/Tools/
!/Tools/**
!VoxelForge.uplugin
!CODEMAP.md
# Keep every design / doc markdown at any depth (AUDIT P1 — these were untracked)
!*.md
+1175
View File
File diff suppressed because it is too large. Load diff
+1050
View File
File diff suppressed because it is too large. Load diff
+195
View File
@@ -0,0 +1,195 @@
> ## ⚠️ REVIEWER'S NOTE — Claude, 2026-08-16. Read before acting on anything below.
>
> This report was produced by a **read-only Codex pass (`gpt-5.6-sol`, high effort)**. It is a
> *lead list*, not a verified defect list. Every item is labelled "Verified by reading" **by its own
> author**; that label is the author's claim, not an independent check.
>
> **What I checked myself, and what came of it:**
>
> | finding | my verdict |
> |---|---|
> | **VF-05** (radius envelope in `BuildChunkCache`) | ✅ **CONFIRMED and FIXED** — `CODEX-TASK-006`. Real, and the worst of three instances of this class: it is `TunnelNetwork`, it is in code **both** density paths share, and it breaks window invariance (`ARCHITECTURE §8.4`). Genuinely good find. |
> | **VF-02** (3-second shutdown timeout) | ✅ **premise confirmed** — `VoxelWorld.cpp:327` literally reads *"Timeout after 3 seconds to avoid hanging the editor."* Note `CLAUDE.md` states the invariant more strongly than the code implements it ("EndPlay blocks on `ActiveTaskCount → 0`" — it blocks *with a deadline*). Worth deciding deliberately. |
> | **VF-03** (TLS caches omit the owning world) | ⛔ **MY EARLIER VERDICT HERE WAS WRONG — corrected 2026-08-16.** I wrote that its fixture citation was fabricated. **It is not.** `VoxelForgeTestFixture.h` lines ~134/146 explicitly document `CP_UseOpStack` contamination between worlds; I had read only the file's 30-line header comment and asserted a negative from a partial read. VF-03's core claim is **CONFIRMED**: `GetDensityAt` keys its `thread_local CP_*` state by `(ChunkCoord, LayoutVersion)` with **no generator/world identity**, and every manager's version starts at the same value — so a second world on the same worker can inherit the first's params, `CP_UseOpStack` and stack. The *breadth* of VF-03 (the `OC_*`/`BM_*`/passage/biome/diff caches) is still unproven and should be audited as one owner-identity task. Original note kept below for the record: ~~substance plausible, evidence overstated~~ |
> | ~~VF-03 (superseded)~~ | ~~**substance plausible, EVIDENCE OVERSTATED.**~~ It claims *"the test fixture explicitly documents observed cross-world contamination."* It does not. `VoxelForgeTestFixture.h` documents that the `thread_local` caches exist and flags an unrelated `TSoftObjectPtr` risk. The underlying point (caches keyed on chunk/seed/layout but not on which generator owns them) may still hold — but it needs checking on its own merits, not on this citation. |
> | **VF-01** (live rebuild races streaming workers) | ✅ **CONFIRMED — the most serious finding here.** `UVoxelStrateManager::Initialize` does `StrateLayout.Empty()` (:~36) **and** `Passages.Empty()` (:171) then `Passages.Add()`, i.e. it frees and reallocates both arrays. There is **no lock, no barrier, no drain** anywhere in that file. Worker-side readers of the same arrays: `AnyPassageNearBox` (:460, range-for over `Passages`), `EvaluateModifierSDF` (indexes `Passages[...]`), `FindSlotIndexForChunkZ` (iterates `StrateLayout`) — all reached from `GetDensityAt`/`ClassifyTile` on mesher workers. And `RegenerateAllChunks()` (which bumps the epoch) runs **after** `Initialize`, so previous-epoch workers are still live during the mutation. **This is the same class already fixed once in this codebase** — `DiffLayer.ChunkMods` got `ModsLock` after a carve-vs-stream access violation. Four call sites, incl. `OnObjectModifiedInEditor` (:309), which fires automatically when a strate asset is edited while the world streams. **NOT fixed — see the note below.** |
> | **VF-10** (~74-field per-voxel params copy) | ✅ **confirmed real, but Sol missed the conclusion that matters.** The 74 fields are real and the copy is per near-surface sample. **However it is INHERITED from the original path — `GetDensityWithParams` does the same copy — so both paths pay it equally and it does NOT explain the op-stack perf regression.** The op stack actually *improved* it (memoised so eleven detail ops don't each repeat it), and the site says so in its own comment. Genuine future optimisation for both paths; **not** the answer to "why is the op path slower". |
> | VF-04, VF-06, VF-07, VF-08, VF-09 | **NOT independently verified.** Read them as leads. |
>
> **Do not treat an unverified row as actionable.** The lesson this project keeps paying for is that a
> confident chain resting on an unchecked premise reverses about half the time — and VF-03 is an
> instance of exactly that, inside an audit written to find them.
# VoxelForge code quality and efficiency audit — August 2026
| Finding | File | Severity | Tier | Evidence status |
|---|---|---:|---|---|
| VF-01 — Live rebuilds mutate generation state while workers read it | `VoxelWorld.cpp`, `VoxelStrateManager.cpp` | Critical | Async lifecycle / per-tile workers | Verified by reading |
| VF-02 — Shutdown timeouts allow tasks to outlive their owners | `VoxelWorld.cpp`, `VoxelContentManager.cpp` | Critical | Async lifecycle | Verified by reading |
| VF-03 — Function-static TLS caches omit the owning world/generator | `VoxelGenerator.cpp`, `VoxelStrateManager.cpp`, `VoxelDensityOpStack.cpp` | Critical | Per-voxel caches; per-chunk refill | Verified by reading |
| VF-04 — Box/capsule edits bypass the intended budget and use the wrong live-deco removal volume | `VoxelDiffLayer.cpp`, `VoxelDiffLayer.h`, `VoxelWorld.cpp` | High | Per modification | Verified by reading |
| VF-05 — Cave collection bounds can be smaller than generated geometry when min/max fields are reversed | `VoxelCaveMorphology.cpp`, `VoxelStrateTypes.h` | High | Per-chunk cache construction / skip bound | Verified by reading |
| VF-06 — A fixed-only strate configuration silently disables the strate system | `VoxelWorld.cpp`, `VoxelStrateManager.cpp` | High | Initialization | Verified by reading |
| VF-07 — World origin is used as the “no player” sentinel | `VoxelWorld.cpp`, `VoxelWorld.h` | Medium | Per frame / streaming gate | Verified by reading |
| VF-08 — Decoration palettes are rebuilt every tick and deep-copied into every cell task | `VoxelContentManager.cpp`, `VoxelContentManager.h`, `VoxelStrateTypes.h` | Medium | Per frame and per decoration cell | Verified by reading |
| VF-09 — Clearing decoration builds forgets still-running tasks and defeats the concurrency cap | `VoxelContentManager.cpp` | Medium | Per rebuild / async scheduling | Verified by reading |
| VF-10 — Per-room terrain params are reconstructed for every near-surface sample | `VoxelGenerator.cpp`, `VoxelDensityOpStack.cpp`, `VoxelCaveMorphology.cpp` | Medium | Per near-surface voxel | Verified by reading |
## Scope and evidence
This was a static, read-only review. I read `CODEMAP.md`, `ARCHITECTURE.md` including §8.10, `REVIEW_FINDINGS.md`, the relevant public contracts, implementations, and tests. I did not build, compile, or run the plugin. Every item below is therefore marked **Verified by reading**: the cited control flow or cache-key omission is present in the source. Runtime frequency and timing impact are reasoned from that source, not measured in this review. No “suspicious only” item is included.
The deliberate old/new density-path duplication and every settled decision listed in the review request are excluded.
## Findings
### VF-01 — Live rebuilds mutate generation state while workers read it
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelWorld.cpp` — `AVoxelWorld::RebuildStrates` (lines 140–153), `AVoxelWorld::OnObjectModifiedInEditor` (238–316), `AVoxelWorld::ChangeSeed` (2064–2114), `AVoxelWorld::LoadTile` (1345–1478), and `AVoxelWorld::GenerateTileResult` (1481 onward); `Source/VoxelForge/Private/VoxelStrateManager.cpp` — `UVoxelStrateManager::Initialize` (28–163).
**What is wrong:** chunk tasks capture `this` and call `GenerateTileResult`, which reads `Generator`, `Mesher`, and through them `StrateManager`. Meanwhile, each live-rebuild path mutates the same objects on the game thread. `Initialize` empties and repopulates `StrateLayout`, empties and repopulates `Passages`, and changes cached seed/settings fields. `ChangeSeed` also writes the generator's plain `Seed`/`OriginSpineRadius`. There is no lock, immutable snapshot, or worker quiescence around those writes.
The order makes the race especially direct: `RebuildStrates` and `OnObjectModifiedInEditor` call `StrateManager->Initialize(...)` before `RegenerateAllChunks()` increments `GenerationEpoch`. `ChangeSeed` also changes generator and manager state before regeneration. The epoch only rejects a finished result; it does not make concurrent reads of reallocating `TArray`s safe and cannot repair undefined behavior that happened while producing the result. Decoration and density-volume workers also read the generator and need to be included in the same transition.
**Why it matters:** an edit or seed change during active streaming can race a worker iterating or indexing storage that `Initialize` has freed/reallocated. Outcomes range from a tile built from mixed old/new settings to an access violation. This is a correctness and lifetime issue, not merely stale-result work.
**Concrete change:** introduce an immutable generation snapshot containing the seed, layout, passages, resolved definitions/op data, and a unique generation ID. Atomically publish the new snapshot and have every task capture a strong reference to one snapshot. The smaller alternative is a rebuild barrier: stop new chunk/deco/density work, wait without timeout for all generator readers, mutate the state, bump the epoch, then resume. Incrementing the epoch before mutation is useful but is not sufficient without snapshotting or quiescence.
### VF-02 — Shutdown timeouts allow tasks to outlive their owners
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelWorld.cpp` — `AVoxelWorld::EndPlay` (320–375) and the `[this, ...]` task in `AVoxelWorld::LoadTile` (1456–1478); `Source/VoxelForge/Private/VoxelContentManager.cpp` — `UVoxelContentManager::BeginDestroy` (58–63), `NotifyShutdown` (65–80), and the `[this, ...]` task in `LaunchDecoTasks` (384–404).
**What is wrong:** both shutdown drains stop waiting after three seconds and continue teardown while work may still be running. Chunk tasks retain a raw `this` and even their RAII guard holds a reference to `AVoxelWorld::ActiveTaskCount`. Decoration tasks retain a raw content-manager `this`, read its `bShuttingDown`, and may access its queue. `BeginDestroy` sets a flag but does not establish task completion before calling `Super::BeginDestroy`.
The decoration counter is also file-global (`GActiveDecoTasks`), so it is neither an ownership handle nor a per-manager proof that this manager's tasks are finished.
**Why it matters:** if the timeout is reached, later task reads, queue writes, or the guard decrement can target an object whose EndPlay/destruction has advanced. The timeout converts a slow task into a possible use-after-free. The shutdown flag reduces ordinary latency but does not cancel a task already inside generation or marching.
**Concrete change:** retain `UE::Tasks::FTask` handles per owner and make UObject destruction contingent on their completion. Stop submissions first, request cancellation, and either wait unconditionally in a safe shutdown phase or defer final destruction through `IsReadyForFinishDestroy` until the owner's task group is empty. Replace the global decoration count with per-instance task ownership. A watchdog may log a long wait, but it must not release the objects that unfinished tasks can still touch.
### VF-03 — Function-static TLS caches omit the owning world/generator
**Evidence status:** Verified by reading. The test fixture explicitly documents observed cross-world contamination.
**Location:**
- `Source/VoxelForge/Private/VoxelGenerator.cpp` — `UVoxelGenerator::GetDensityAt`: `CP_*` cache (583–622), `GSurfColCache` access (738), and `DiffSlots` (808–826); `ClassifyTile`: `TC_BiomeCache`/`TC_SeenVersion` (2720–2731); `GetBiomeMaterialAt`: `BM_*` cache (3470–3485).
- `Source/VoxelForge/Private/VoxelStrateManager.cpp` — `GeneratePassages` (169–173, 352–353) and `EvaluateModifierSDF`: `SL_*` shortlist plus unchecked `Passages[PIdx]` (372–420).
- `Source/VoxelForge/Private/VoxelDensityOpStack.cpp` — `FRoomGraphSource::Eval`: `SI_*` strate-index memo (2172–2188).
- `Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h` — `FTestWorld` construction (126–152).
**What is wrong:** these are function/file-static `thread_local` caches, so one worker-thread cache is shared by every VoxelForge instance evaluated on that thread. Their keys use coordinates and per-instance counters such as `LayoutVersion` or `ModsVersion`, but omit the owning generator/manager/diff layer. Two freshly initialized worlds normally both report layout version 1; two diff layers also start with the same modification version. Equal coordinates and versions therefore make the second world reuse the first world's params, operator stack (including its manager pointer), biome context, surface columns, or modification snapshot.
This is not hypothetical test hygiene. `VoxelForgeTestFixture.h` states that two test worlds both reporting version 1 caused the second world to receive the first world's params and `CP_UseOpStack`; it works around the problem by repeatedly initializing each test manager until its version is process-unique. Production has no such workaround.
The passage cache has a more severe failure mode. `SL_Nearby` stores indices from manager A, then manager B with the same `(chunk, PassagesVersion)` can execute `Passages[PIdx]` without `IsValidIndex`. Also, `GeneratePassages` empties `Passages` and returns for an empty layout before incrementing `PassagesVersion`, so the same manager can retain stale indices after an empty rebuild.
**Why it matters:** multiple VoxelWorld actors, PIE worlds, tests, previews, or address-reused objects can produce density/materials/modifications from the wrong world. The passage case can read out of bounds. This affects the per-voxel tier—up to roughly 35³ = 42,875 base samples per full-resolution tile—although the bad selection occurs at cache-refill granularity.
**Concrete change:** give each immutable generation context a process-unique, monotonic cache ID and include it in every shared TLS key. Give each diff layer its own unique ID as well. Prefer a per-worker cache object scoped to that context over scattered function statics. Move the passage-version increment so every clear/rebuild, including the empty-layout exit, invalidates the cache; retain `Passages.IsValidIndex(PIdx)` as defense in depth. Remove the test fixture's serial-bump workaround once production keys express owner identity.
### VF-04 — Box/capsule edits bypass the intended budget and use the wrong live-deco removal volume
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelDiffLayer.cpp` — `UVoxelDiffLayer::CanModify` (20–45) and `ApplyModification` (63–133); `Source/VoxelForge/Public/VoxelDiffLayer.h` — `FVoxelModification::GetWorldBounds` (97–121); `Source/VoxelForge/Private/VoxelWorld.cpp` — `AVoxelWorld::ApplyModification` (1839–1871), `CarveBox`/`FillBox` (1874–1893), and `CarveCapsule`/`FillCapsule` (1896–1915).
**What is wrong:** budget validation knows only a scalar radius and always charges `4/3*pi*r^3`. `ApplyModification` clamps `Mod.Radius`, but leaves `BoxExtent`, `CapsuleEnd`, and `Falloff` unchanged. The shape-aware AABB then uses those unchanged values. Consequently:
- a box with huge extents is stored across its full huge AABB even though only its proxy radius was clamped and sphere volume was charged;
- a capsule of arbitrary length is charged only as a sphere of its tube radius;
- an untrusted or accidental large shape can enumerate and allocate entries for an enormous number of chunks despite `MaxBrushRadius`/`MaxTotalVolume` being presented as safety limits.
The live decoration cleanup is inconsistent in the other direction. It always removes a sphere centered at `Modification.Center` with the original `Modification.Radius`. For a capsule this is only endpoint A, leaving decorations floating along most of the segment. For a box, `max(half extent)` does not cover the corners and ignores falloff. It also does not use the clamped modification that was actually stored.
**Why it matters:** the budget can be bypassed precisely by the shapes most able to create a large remesh/storage burst. Separately, box/capsule edits leave visibly invalid live content until a later decoration rebuild.
**Concrete change:** make validation and accounting accept the complete `FVoxelModification`. Validate finite, non-negative geometry; enforce extent/tube-radius and capsule-length limits; and charge a documented shape volume (or a deliberately conservative support-AABB volume including falloff). Return the normalized/applied modification or its actual bounds from `ApplyModification`. Use those applied bounds for decoration invalidation—prefer a shape-aware removal query, or at least a conservative sphere centered on the bounds center with the bounds half-diagonal. Keep `CanModify` and `ApplyModification` on the same normalization/accounting function so UI/server decisions cannot drift.
### VF-05 — Cave collection bounds can be smaller than generated geometry when min/max fields are reversed
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelCaveMorphology.cpp` — `VoxelCaveMorphology::BuildChunkCache`: `MaxInfluence` (127–130), `CollectMargin` (152), `RoomZBuffer` (171), room radius generation (260–265), and tunnel radius generation (464–468); `EvaluateSDF` margin (871–874). Authoring fields are in `Source/VoxelForge/Public/VoxelStrateTypes.h` — `MinRoomRadius`/`MaxRoomRadius` (490/498) and `TunnelMinRadius`/`TunnelMaxRadius` (595/602).
**What is wrong:** `FMath::Lerp(Min, Max, t)` produces values up to `max(Min, Max)` even when an asset has the endpoints reversed. The collection/influence math assumes the field named `Max*` is numerically largest: it uses only `MaxRoomRadius` and `TunnelMaxRadius`. The properties have no cross-field validation enforcing `Min <= Max`.
If `MinRoomRadius > MaxRoomRadius`, actual generated rooms can be larger than `MaxInfluence`, `CollectMargin`, and `RoomZBuffer` assume. If `TunnelMinRadius > TunnelMaxRadius`, the same applies to tunnel reach. `RoomReachesSearchBox` uses the actual radius, but it cannot test a room whose anchor cell was never collected because the collect region was too small. The convenience wrapper repeats the underestimated margin.
**Why it matters:** this is an under-bound, not a conservative overestimate. A room/tunnel able to affect a chunk may not be created in that chunk's cache, producing window-dependent density, seams, missing mesh, or missing collision. Reversed ranges are authorable and can also arise transiently while live-editing the two fields.
**Concrete change:** derive bound-only envelopes as `Max(MinRoomRadius, MaxRoomRadius)` and `Max(TunnelMinRadius, TunnelMaxRadius)` and use them in `MaxInfluence`, collection margins, vertical room buffer, and the wrapper margin. Do not reorder the endpoints passed to `Lerp`, because that would change deterministic room/tunnel assignment; only make the bounds cover every value the existing interpolation can produce. Add asset validation that warns on reversed or non-positive ranges.
### VF-06 — A fixed-only strate configuration silently disables the strate system
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelWorld.cpp` — `AVoxelWorld::BeginPlay` (413–421); `Source/VoxelForge/Private/VoxelStrateManager.cpp` — `UVoxelStrateManager::Initialize` (43–55, 76–105).
**What is wrong:** `BeginPlay` creates the strate manager only when `Settings->StratePool.Num() > 0`. The manager itself explicitly supports fixed entries independently: it loads `FixedStrates` and selects a fixed definition before consulting the shuffled pool. A valid setup in which every requested slot is fixed and `StratePool` is empty therefore never constructs the manager.
**Why it matters:** the generator silently falls back to generic TunnelNetwork terrain, while content and atmosphere receive a null manager. Authored fixed strata are ignored without an initialization error.
**Concrete change:** initialize the manager when either `StratePool` or `FixedStrates` is non-empty. Validate that every index in `[0, TotalStrates)` can resolve either a fixed definition or a pool fallback, and emit a clear error for uncovered slots rather than silently changing generation mode.
### VF-07 — World origin is used as the “no player” sentinel
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelWorld.cpp` — `AVoxelWorld::Tick` (456–484) and `GetPlayerPosition` (529–537); declaration/comment in `Source/VoxelForge/Public/VoxelWorld.h` (634–635).
**What is wrong:** `GetPlayerPosition` returns `FVector::ZeroVector` when there is no pawn, but a pawn at the real world origin returns the same value. `Tick` tests `PlayerLastPos != FVector::ZeroVector` before all terrain streaming, atmosphere, decorations, landmarks, water, and density-volume updates.
**Why it matters:** origin is a common initial spawn. While the pawn is exactly there, no initial terrain/content streaming is submitted; behavior begins only after it moves away.
**Concrete change:** return success separately from the coordinate (`bool TryGetPlayerPosition(FVector& Out)` or an optional), or obtain the controller/pawn in `Tick` and gate on pointer validity. Treat every finite coordinate, including zero, as a valid position.
### VF-08 — Decoration palettes are rebuilt every tick and deep-copied into every cell task
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelContentManager.cpp` — `UVoxelContentManager::UpdateDecorations` (147–256) and `LaunchDecoTasks` (366–398); `Source/VoxelForge/Public/VoxelContentManager.h` — `FDecoCellResult::Entries` (151–158); nested decoration arrays in `Source/VoxelForge/Public/VoxelStrateTypes.h` — `FDecoCompanion::SubCompanions` (2073–2076) and `FStrateDecoration::Companions` (2120–2123).
**What is wrong:** `UpdateDecorations` is called every tick. Before it checks whether the player changed cell or stratum, it resets both flattened palettes, walks every biome and decoration, and copies every `FStrateDecoration`. Those structs contain nested `TArray`s, so this is not a trivial POD copy. `LaunchDecoTasks` then deep-copies the same grid palette and biome-tag array once for every cell task and moves that copy through the result solely so `EntryIdx` can be decoded on the game thread.
At the default 4x4 region size, one new region is 16 cell tasks carrying 16 copies of the same immutable palette. The per-frame rebuild also contradicts the nearby “cheap no-op unless the player crosses a decoration cell boundary or changes strate” expectation.
**Why it matters:** this creates allocator traffic and memory bandwidth on both the steady game-thread path and every decoration-streaming burst. Large biome palettes with companion/sub-companion trees amplify the cost.
**Concrete change:** build an immutable resolved palette snapshot only when its inputs change (stratum/layout/asset revision, tier assignment, or relevant settings). Capture a thread-safe shared reference in cell tasks and carry that same reference in results, or resolve spawn commands to a compact immutable profile table once. Continue draining tasks/results each tick, but do not destroy and reconstruct unchanged nested arrays.
### VF-09 — Clearing decoration builds forgets still-running tasks and defeats the concurrency cap
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelContentManager.cpp` — `ResetGridBuildState` (88–94), `LaunchDecoTasks` (344–404), `ProcessDecoResults` (763–774), and `ClearAllDecorations` (983–994).
**What is wrong:** `ClearAllDecorations` abandons builds and calls `ResetGridBuildState`, which clears `InFlightCells` even though the corresponding tasks are not cancelled or awaited. A new build can immediately launch another task for the same cell. When the old result arrives, `ProcessDecoResults` removes `R.Cell` from `InFlightCells` before checking its `BuildId`; this can remove the new task's marker. Payload merging is protected by `BuildId`, but scheduling ownership is not.
The throttle uses `NearGrid.InFlightCells.Num() + FarGrid.InFlightCells.Num()`, not `GActiveDecoTasks`, so forgotten/incorrectly removed markers allow actual worker count to exceed `MaxConcurrentDecorationTasks`. Repeated clear/rebuild cycles can compound the excess precisely when live editing or regeneration is already generating other work.
**Why it matters:** the configured cap is documented as preventing decoration marching from crowding mesh-generation workers. This bookkeeping path invalidates that guarantee and can create avoidable CPU/memory bursts. It can also cause redundant same-cell work, though `BuildId` prevents duplicate applied decorations.
**Concrete change:** track an in-flight token that includes grid, cell, and build ID (for example, `TMap<FIntPoint, uint32>`), and remove it only when the completing result owns that exact token. Do not erase live tokens when abandoning build payloads; retain them until completion/cancellation. Better, keep per-instance task handles/counts and throttle on the actual running count, with build identity used only for result relevance.
### VF-10 — Per-room terrain params are reconstructed for every near-surface sample
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelGenerator.cpp` — `UVoxelGenerator::GetDensityWithParams`, per-room block (1294–1328); `Source/VoxelForge/Private/VoxelDensityOpStack.cpp` — `FRoomGraphSource::FState`/`LocalParams` (1993–2012, 2041–2073); `Source/VoxelForge/Private/VoxelCaveMorphology.cpp` — room-op selection and existing per-room feature pre-bake (653–678).
**What is wrong:** for every `bNearCaveSurface` sample, the original path copies the roughly 74-field `FStrateGenerationParams` and makes a virtual `RoomOp->ApplyTo` call for the nearest room. The operator stack preserves one such copy per sample through `LocalParams()`—correctly memoized so eleven detail operators do not each repeat it—but the work is still invariant for all samples whose nearest cached room is the same.
`BuildChunkCache` already selects `RoomOp`/weight per cached room and calls `ApplyTo` once per room to pre-bake pits, chimneys, and columns. The remaining detail-op parameters can be resolved at that same per-room tier.
**Why it matters:** this is inside the density hot path. A full-resolution tile has about 42,875 base grid samples, plus density calls used for surface normals; only near-surface samples pay this block, but those are exactly the samples concentrated around generated geometry. Copying a large struct and dispatching virtually per sample adds bandwidth and instruction cost that could be per-room/per-chunk.
**Concrete change:** add a compact `FResolvedRoomDetailParams` to `FCachedRoom`, containing only fields consumed by the eleven per-room detail stages, and populate it once during `BuildChunkCache` from the chunk's base params plus the selected op. Both the oracle path and operator-stack path should reference that shared resolved payload. Keep the existing operation order and verify bit-for-bit equivalence; this is a hoist of loop-invariant data, not a split or transcription of `BuildChunkCache`.
## Priority order
Fix VF-01 through VF-03 first: they are memory-model/lifetime/cache-identity problems and can produce crashes or cross-world corruption. VF-04 through VF-06 are deterministic correctness failures with bounded, local fixes. VF-07 is a small but user-visible initialization defect. VF-08 through VF-10 are worthwhile efficiency changes after the correctness hazards are closed; VF-08 and VF-09 should be addressed together because an immutable palette snapshot and explicit task ownership naturally simplify both paths.
+234
View File
@@ -0,0 +1,234 @@
# AUDIT-COLD-2026-09-14: read-only examination, with the orchestrator's verification notes
Produced by Codex (gpt-5.6-luna, max effort, read-only sandbox) on 2026-09-14 at HEAD `7a972f5`,
briefed with no project background. It did read the plugin-root docs, so it is not context-free,
but its findings are code-level. Findings are verbatim below. These notes record what the
orchestrator checked against the code afterwards.
## Orchestrator verification notes
- **#7 CONFIRMED, a determinism bug introduced by the tile-cache work (`2de603c`/`cfad3d1`).**
`VoxelGenerator.cpp:2968` moves `CP_OpStack` into the cache entry and resets `GTunnelCoreCache`.
A later request for the same `ChunkCoord` under the other `bTileCacheContext` misses the key
(`:2660`), but the refetch guard (`:2723-2725`) skips because chunk, owner and version are
unchanged, so evaluation uses the moved-from (empty) stack and core cache. The hit path sets
`CP_Chunk` to the shared key `(0,0,Z)` (`:2698`), so the spawn column `X=Y=0` reaches this state
after an ordinary hit, not only in a narrow timing window. Coarse tiles (LOD >= 3) covering the
origin can be evaluated wrongly depending on the order work reaches a worker.
- **#3 and #8 are real but narrower than stated.** In multiplayer, the server and client worlds share
seed and layout by design, so the cached values are identical. Contamination needs worlds that
genuinely differ under the same key: editor edits that do not bump the version, or two diff
layers with different contents at the same `ModsVersion`. Cheap to fix (add owner/layer identity).
- **#19 is not a defect under C++20** (UE 5.7), where left-shifting negative signed values is
defined. Overflow for extreme coordinates remains theoretical.
- **#21 matters again** if the gap/seal AllSolid proof (in progress) calls `HasAnyModInChunkRange`
per LOD0 tile.
- The others (#1, #2, #4-6, #9-18, #20, #22) were not independently re-verified. Verify each before
fixing it.
---
# Read-only audit report
I made no file changes and did not build or run the plugin. “VERIFIED” means the failure follows directly from the source path; “SUSPECT” means a runtime/compiler-dependent hazard that was not executed.
## Highest impact
1. **The density-volume worker races live generator mutation — P1 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `FScopedGenerationPause` (203–222), `AVoxelWorld::RebuildStrates` (551–582), `AVoxelWorld::ChangeSeed` (5569–5623).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelDensityVolume.cpp` — `UVoxelDensityVolume::Reset` (101–120), `ProcessOneFill` (603–637).
`FScopedGenerationPause` waits for ordinary generation tasks and decoration tasks, but not the dedicated density-volume fill thread. That thread reads the raw `Generator` pointer and calls `GetDensityAt` while `RebuildStrates` or `ChangeSeed` mutates the generator and strata manager.
A fill in progress during a PIE edit or seed change can therefore read partially rebuilt arrays or mixed old/new settings. This is an actual data race with crash/garbled-density potential. `Reset` also clears `PendingFills` but not the already queued `FillQueue`, causing stale work to continue after reset.
2. **Decoration tasks can use a destroyed content manager — P1 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelContentManager.cpp` — `UVoxelContentManager::BeginDestroy` (58–62), `NotifyShutdown` (65–75), `WaitForDecorationTasks` (78–85), `LaunchDecoTasks` (327–415).
Tasks capture raw `this` and later access `bShuttingDown` and `DecoResults`. Shutdown waits only three seconds, ignores the timeout result, and `BeginDestroy` immediately calls `Super::BeginDestroy`.
Any decoration task exceeding the timeout can later enqueue into the destroyed manager. The global task counter also includes tasks belonging to unrelated worlds, so one world can time out because another world is busy.
3. **The diff snapshot TLS cache is not keyed by its owning diff layer — P1 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `UVoxelGenerator::GetDensityAt` (3069–3097).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelDiffLayer.h` — `GetModsVersion` and `ModsVersion` (241, 289).
`thread_local FDiffSlot DiffSlots[64]` considers only chunk coordinates and `ModsVersion`. It does not include the `UVoxelDiffLayer*`.
Two PIE worlds commonly have the same chunk and the same per-layer version—for example, both reach version 2 after their first edit. If a worker samples world A first, world B can hit A’s cached modification snapshot and apply the wrong edits. This produces cross-world terrain contamination dependent on worker history.
4. **Fixed-only strata configurations are silently disabled — P1 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `AVoxelWorld::BeginPlay` (1294–1305).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelStrateManager.cpp` — `UVoxelStrateManager::Initialize` (1060–1244).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — fallback generation in `GetDensityAt` (3030–3039).
`BeginPlay` creates a strata manager only when a season is assigned or `StratePool.Num() > 0`. It does not test `FixedStrates.Num()`.
A settings asset with valid fixed strata but no season and no pool therefore receives no strata manager. The generator takes its generic fallback path, ignoring the authored fixed definitions.
5. **Settings rebuilds leave dependent systems with inconsistent snapshots — P1/P2 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `RebuildStrates` (551–582), `PostEditChangeProperty` (885–895), `OnObjectModifiedInEditor` (898–985).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `InitializeSettings` (2079–2088).
`RebuildStrates` reinitializes the strata manager but does not reinitialize generator settings. `PostEditChangeProperty` only regenerates chunks. Asset-change handling recognizes strata and terrain-op definitions, but not general settings or biome assets.
Changing seed, world radius, edge sealing, the settings object reference, or related configuration can leave the manager using new values while the generator, content manager, or density volume still use old snapshots. `ChangeSeed` has a separate correct reinitialization path, but the other rebuild paths do not. This also contradicts the `RebuildStrates` documentation in `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelWorld.h` (572–575), which promises to re-read all settings.
6. **Invalid strata initialization destroys the previous valid layout — P1/P2 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelStrateManager.cpp` — `UVoxelStrateManager::Initialize` (1060–1205).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `RebuildStrates` (562–576), `OnObjectModifiedInEditor` (975–985).
`Initialize` empties `StrateLayout`, `SeasonStrates`, and related state before all season/layout validation has succeeded. If an assigned season is missing, malformed, or contains invalid bounds, initialization returns false after the old layout has already been discarded.
`RebuildStrates` then leaves existing meshes visible while future generation queries see an empty or partial manager. The editor asset-modification path ignores the initialization return value and regenerates anyway, making a transient invalid asset capable of replacing the working world with fallback/empty terrain.
## Material correctness and determinism issues
7. **The operator-stack cache is stale when tile-cache context changes — P2 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `UVoxelGenerator::GetDensityAt` (2218–2230, 2255–2261, 2320–2324, 2393–2407, 2551–2573, 2600–2672).
The tunnel cache key includes whether tile-cache mode is active, but the rebuild guard does not. A fine-LOD request can build and move the operator stack into the cache, leaving the worker-local `CP_OpStack` empty. A later request for the same chunk under a different tile-cache context misses the cache key but does not rebuild the stack because chunk, manager, and layout version are unchanged.
The empty or mismatched stack is then used for density evaluation. LOD request order can therefore change terrain results.
8. **Several surface and biome TLS caches omit generator/context identity — P2 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `FSurfaceColumnCache::Acquire` (274–328), `GetDensityAt` (2692–2705), `GetSurfaceHeightAt` (5149–5166), `ClassifyTile` (6014–6023), `GetBiomeMaterialAt` (7019–7034).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelBiomeTypes.h` — `FChunkBiomeCache::Contains` (240–245).
These worker-local caches use combinations of chunk, seed, strata key, and layout version, but not the owning generator, strata manager, or biome context. Two worlds can legitimately share all those scalar keys while having different terrain or biome parameters.
When a pooled worker handles both worlds, the second world can receive cached surface columns, biome classification, or material data from the first. Direct asset changes that do not advance the relevant version have the same stale-cache problem.
9. **Native shaft/island topology caches omit parameters used to build them — P2 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `GetVerticalShaftDensity` (7090–7106, 7211–7219, 7392–7413), `GetFloatingIslandDensity` (7518–7554).
The shaft cache key omits `LedgeSpacing` and `LedgeDepth`, although both affect connector and ledge placement. The floating-island cache key omits `BoundarySealThickness`, although it affects cached vertical spread.
Changing these values—or using two contexts with identical keyed values but different omitted values—reuses old topology on the same worker.
10. **Completed pre-edit decoration results can resurrect removed objects — P2 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `ApplyModification` (5319–5375).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelContentManager.cpp` — `LaunchDecoTasks` (381–404), `ProcessDecoResults` (769–807), `MergeCellResult` (815–862), `RemoveDecorationsInSphere` (1249–1260).
Applying a live diff removes currently applied decorations but does not invalidate queued/in-flight decoration builds or associate them with a diff version. `BuildId` only identifies decoration-region rebuilds.
Sequence: a task computes old grass/props, the edit removes current instances, then the old result is drained and accepted. The old decorations reappear over the edited terrain until another rebuild occurs.
11. **Landmark conditions are evaluated without the actual biome context — P2 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelContentManager.cpp` — `UpdateDecorations` (153–263), `UpdateLandmarks` (1263–1310), `SpawnFromProfile` (1101–1137).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `EvaluateTerrainConditions` (6682–6717).
Normal decoration updates populate `FDecoContext::BiomeCtx`; landmark updates construct a context but never set `BiomeCtx`. A landmark profile using biome, moisture, relief, or biome-border conditions therefore evaluates with the default/invalid biome map.
On a non-default biome map, landmarks can spawn or fail to spawn differently from ordinary decorations.
12. **Decoration cleanup uses local coordinates as world coordinates — P2 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `ApplyModification` (5369–5375); `WorldToLocalVoxel` and `LocalVoxelToWorld` (2314–2321).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelContentManager.cpp` — `RemoveInstancesInContent` (1231–1242).
`Modification.Center` is a local voxel coordinate, but cleanup computes `Center * VOXEL_SIZE` and passes it to a world-space overlap query. For a translated actor, the query is centered at the wrong location.
The same cleanup also approximates boxes and capsules with only `Modification.Radius`, which does not cover a long capsule or a box’s full extent. Existing decorations can remain over an edit or unrelated origin decorations can be removed.
13. **Decoration application order is timing-dependent — P2 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelContentManager.cpp` — `LaunchDecoTasks` (327–415), `ProcessDecoResults` (769–810), `ApplyRegion` (885–952), `RebuildDesiredCells` (281–324).
Tasks enqueue results in completion order. Results append actor spawns and HISM transforms in that order; mesh buckets are iterated through a `TMap`. Equal-distance pending regions also have no deterministic tie-breaker.
The same candidate set can therefore produce different actor spawn order, NetGUID/side-effect order, HISM instance indices, and internal clustering across runs or machines.
14. **The passage shortlist version is not advanced when passages are cleared — P2/P3 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelStrateManager.cpp` — `GeneratePassages` (1611–1620, 2477), `EvaluateModifierSDF` (2488–2524), `VF_GetNearbyPassages` (294–338).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelStrateManager.h` — `GetLayoutVersion` and `PassagesVersion` (250, 515, 519).
`GeneratePassages` empties `Passages` and returns early for an empty layout without incrementing `PassagesVersion`. Existing worker TLS nearby-passage caches still match the manager lifetime, chunk, and unchanged version.
After a previously valid layout becomes empty, `EvaluateModifierSDF` can reuse indices into the now-empty array and dereference invalid passage data. This is an edge configuration, but it is reachable through the invalid-layout paths above.
## Input-contract and performance issues
15. **Diff modifications accept nonfinite and invalid geometry — P2/P3 — VERIFIED for malformed input**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelDiffLayer.cpp` — `CanModify` (20–44), `ApplyModification` (63–96), `EvaluateMods` (191–248).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `CarveAtPosition` (5301–5307).
There is no finite-value validation for strength, radius, centers, endpoints, or extents. A NaN strength survives `FMath::Abs`, then propagates through `TotalOffset` and density evaluation. Infinite strength produces infinite density. Negative or zero radii can consume budget while producing no meaningful spherical modification.
This is reachable from C++ or malformed Blueprint/runtime data and permanently poisons the diff layer until cleared.
16. **The public diff API contradicts itself about coordinate units — P2/P3 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelDiffLayer.h` — `FVoxelModification::Center` (64), `CapsuleEnd` (86).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelWorld.h` — `ApplyModification` documentation (334–340).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelDiffLayer.cpp` — `GetWorldBounds` (88–96).
The modification struct comments describe the center as world-space, while the low-level world API and implementation interpret it as voxel-space. The helper functions explicitly convert world centimeters into voxel coordinates.
A caller following the struct comment and supplying centimeters gets an edit displaced by a factor of `VOXEL_SIZE`.
17. **Diff budget accounting underestimates boxes and long capsules — P3 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `CarveBox`/`FillBox` (5379–5398), capsule helpers (5401 onward).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelDiffLayer.cpp` — `CanModify` (34–39).
The budget charges every shape as a sphere with volume based on `Modification.Radius`. Boxes use the maximum half-extent as a proxy; capsules use the tube radius even when endpoints are very far apart.
A capsule with radius 3 and endpoints 1000 voxels apart is charged like a tiny sphere despite affecting a long volume and many chunks. Large edits can therefore bypass the intended modification budget and cause excessive remeshing.
18. **Decoration radius/spacing changes are ignored while the player is stationary — P3 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelContentManager.cpp` — `UpdateDecorations` (199–256), `RebuildDesiredCells` (265–325).
Radius and spacing are refreshed every update, but desired-cell rebuilding occurs only when `PlayerCell != LastDecoCell`.
Shrinking the radius leaves distant regions loaded; expanding it does not enqueue new regions; changing spacing leaves existing regions at the old grid layout. Movement eventually repairs the state, but a stationary player sees stale decoration coverage.
19. **Signed left shifts of negative chunk coordinates are undefined — P3 — SUSPECT**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp` — `BuildDesiredTiles` (3498–3548).
Streaming computes coordinates with expressions such as `T.Z << L`. Ordinary underground tiles have negative `T.Z`, so this left-shifts a negative signed integer. Large positive coordinates can also overflow.
The exact manifestation is compiler/optimization-dependent, so I classify this as SUSPECT rather than a fully end-to-end verified runtime failure.
20. **Interleaved transitions can divide by zero — P3 — VERIFIED math path**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelStrateDefinition.h` — `TransitionBlendChunks` (99–101).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelStrateManager.cpp` — `GetGenerationParams` (5673, 5728–5753, 5826).
The property metadata clamps the value in the editor, but runtime/cooked data can still contain zero. The interleaved transition path divides warped distance by `EffectiveBlend` without a zero guard.
A zero-width transition can produce Inf/NaN blend values and invalid generation parameters.
21. **Tile classification scans the entire modification map — P3 — VERIFIED**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `ClassifyTileUncached` (5785–5790).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelDiffLayer.cpp` — `HasAnyModInChunkRange` (170–188).
Classification calls a routine that walks every `ChunkMods` key under the read lock instead of using the existing per-chunk grouping to narrow the query.
After many edits, each tile classification becomes O(number of modified chunks), increasing lock hold time and making edit-heavy worlds disproportionately expensive.
22. **Operator documentation is internally stale — P3 — VERIFIED documentation contradiction**
Location: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelDensityOp.h` (1–3, 86–92).
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp` — `UVoxelGenerator::GetDensityAt` (2393–2600).
The header says the density-op system is not wired into `GetDensityAt`, while later comments say all eight operators now have the stack path. The generator does in fact execute the operator stack.
This is not itself a runtime defect, but it is misleading maintenance documentation around a central generation path.
The most urgent issues are the uncoordinated density-volume thread, decoration-task lifetime, cross-world TLS diff cache, and incomplete settings/layout rebuilds.
+402
View File
@@ -0,0 +1,402 @@
# AUDIT-SOL-2026-09-11.md — read-only audit of experimental @ aa62971
Performed by Codex **gpt-5.6-sol**, reasoning effort xhigh, **sandbox read-only** — no file was
created, modified or deleted. Commissioned by Jahni after the experimental branch showed two
regressions against main: generation much slower, and the player falling through the world.
Verbatim report follows.
---
# Audit conclusion
The two regressions share a cause, but they are not the same bug:
1. **The remaining generation slowdown is primarily in density evaluation.** The composable operator representation is still being interpreted in the sampling hot path, with rich per-sample state, block-wide operator passes, and several corrective passes afterward. The outer tile classifier is not where the original ~8× went.
2. **There is no “collision-ready before player-ready” invariant.** Neither branch explicitly had one. `main` was fast enough—and had simpler spawn geometry—so the race remained hidden. `experimental` made the race long enough to become visible, especially because the new origin room’s air and supporting floor can occupy different tiles.
I disagree with the inference that the old behavior proves there was a correctness barrier. The observation is valid, but the code shows a timing dependency masquerading as a guarantee.
## 1. Density evaluation is the dominant performance regression
**Status: Verified architecture; strong attribution. Highest expected performance impact.**
A streamed LOD0 tile asks for a 35³ haloed density grid: **42,875 sample positions** ([VoxelMarchingCubesMesher.cpp:227](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:227>)).
At `34f06df`, that hot path was approximately:
- Resolve one cached chunk parameter set.
- Enter one direct archetype switch.
- Keep most intermediate values in local variables.
- Skip the detailed cave suite when `bNearCaveSurface` was false—historically around 70% of deep-solid samples.
- Apply disturbances/differences and return.
The relevant historical path is `34f06df:Source/VoxelForge/Private/VoxelGenerator.cpp:438-631` and `:947-1621`.
Current `experimental` instead:
- Allocates/initializes an `FVoxelOpSample` for every position, containing density, SDF, floor metadata, tunnel metadata, pointers and indices ([VoxelDensityOp.h:327](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelDensityOp.h:327>)).
- Determines a block-wide active-operator list.
- Traverses the sample array once per active operator.
- Uses scalar per-sample fallback implementations for most concrete operators ([VoxelDensityOpStack.cpp:51](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:51>)).
- Copies block scratch into the session cache and later back into output.
- Still calls the scalar `GetDensityAt` wrapper for every grid point, so routing, coordinate setup and the post-stack structural tail remain per-sample ([VoxelGenerator.cpp:1690](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:1690>), [VoxelGenerator.cpp:2165](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2165>)).
Loop inversion removed much of the virtual dispatch cost, but it did not restore the old locality or short-circuiting. An operator whose interval intersects the surface anywhere in the block can be run across the whole block; the old path could decide per sample that expensive detail work was irrelevant.
That is the architectural mistake: **the composable authoring model was carried directly into execution instead of being compiled/lowered into a fused evaluator.** Composition is not itself the problem. Interpreting it through rich mutable sample records is.
The 128³ comparison strongly supports this conclusion: the editor export path owns a shared density grid and does not force the streaming outer-classifier path ([VoxelForgeExploreCommandlet.cpp:1700](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp:1700>)), yet it still takes approximately 1.04 seconds versus 0.130 seconds before the operator-stack wire-in.
The later features add cost, particularly the D-shaped floors, but the history indicates roughly a 17% net penalty there—not anything close to the remaining 8×.
## 2. The player can become active before any supporting collision exists
**Status: Verified. Highest correctness impact.**
`BeginPlay` initializes systems, but it does not synchronously request a spawn support tile, wait for a mesh, or gate pawn gravity/control ([VoxelWorld.cpp:988](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:988>)).
Streaming begins from `Tick`, and only when `GetPlayerPosition()` is not exactly `FVector::ZeroVector` ([VoxelWorld.cpp:1167](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1167>), [VoxelWorld.cpp:1359](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1359>)). Thus an exact world-origin pawn is indistinguishable from “no pawn.” Even when Z is nonzero and that specific sentinel is avoided, there is still no bootstrap barrier.
The supplied play trace shows:
- `BeginPlay` finished at about **50 ms**.
- The first desired-tile pass did not begin until about **439 ms**.
- The first LOD0 nonempty/mixed result was not ready until about **1.093 s**.
- That result still represented mesh/collision submission, not collision-cook completion.
At ordinary gravity, one second is enough to fall roughly five metres.
There are three additional gaps:
- A tile is inserted into `LoadedTiles` before its empty/nonempty application path completes ([VoxelWorld.cpp:1532](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1532>)).
- Only LOD0 receives collision ([VoxelWorld.cpp:3109](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:3109>)). Coarser visible terrain can therefore be present while the corresponding collidable LOD0 tile is still queued.
- Current architecture documentation says RMC collision is asynchronously cooked ([ARCHITECTURE.md:817](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:817>)), but `VoxelWorld` records no cook-complete state. Its `apply_s` measurement ends after submission and says nothing about when physics can actually collide.
I compared the equivalent paths on `main`. It also:
- Starts streaming from `Tick`.
- Uses the zero-position guard.
- Marks a tile loaded before handling its empty result.
- Enables collision only for LOD0.
- Has no spawn collision barrier or collision-cook acknowledgement.
So no explicit guarantee was removed inside `VoxelWorld`. What `experimental` broke was the **implicit timing budget** that had hidden this race.
The new origin-room geometry makes it worse. The first central LOD0 request in the trace is uniformly air and correctly receives no component. That is not itself an error: the player occupies air, while collision belongs to the neighboring floor/wall surface tile. With the current default origin geometry, the floor can lie near or below the lower tile boundary ([VoxelPassageGeometry.h:365](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelPassageGeometry.h:365>)). The supporting tile therefore has to arrive and finish cooking independently.
`CollisionOnlyTiles` does not close this gap. It controls presentation of already generated LOD0 components for non-player anchors; it does not synthesize or reserve collision underneath the local pawn. The supplied trace records zero collision-only tiles.
## 3. The mesher’s nested block classifier is probably self-inflicted overhead
**Status: Mechanism verified; net cost strongly suspected. High performance impact.**
The code contradicts its own guidance:
- The mesher header says the optional 8³ refinement should remain disabled during streaming because repeated proofs may cost more than they save ([VoxelMarchingCubesMesher.h:150](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h:150>)).
- `VoxelWorld` unconditionally enables it ([VoxelWorld.cpp:1102](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1102>)).
For every outer-Mixed tile, the mesher may run up to 64 additional `ClassifyTile` calls ([VoxelMarchingCubesMesher.cpp:254](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:254>)). On cache misses, each call builds another operator stack ([VoxelGenerator.cpp:5779](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:5779>)).
In `MyPlay`:
- 298 LOD0 tiles were outer-Mixed.
- All 298 produced geometry.
- Therefore every one eventually populated the full density grid.
- The nested classifier could only avoid marching-cubes cell processing in uniform sub-blocks; it could not avoid the main density grid once any surface sub-block was found.
This is precisely the workload where the header warns it may lose.
There is also an instrumentation trap: the outer cache counters are snapped before meshing. Nested classifier calls occur inside the measured `mesh_s` interval ([VoxelWorld.cpp:2762](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:2762>)). Consequently, the reported “cache hits 0” does **not** describe classifier activity originating inside the mesher.
This deserves the first controlled A/B test: identical trace and seed with only `bUseBlockEarlyOut` changed, while separately counting outer and nested classifier calls.
## 4. The outer whole-tile classifier is probably earning its keep
**Status: Verified workload behavior; exact counterfactual unmeasured.**
I would not remove the outer classifier based on the 95 ms figure.
It rejected **547 of 845 LOD0 tiles**, or 64.7%, before meshing:
- 484 uniformly solid.
- 63 uniformly air.
- 298 mixed, all of which produced geometry.
A full 35³ grid for those rejected tiles would represent up to 23.45 million density positions. Previous history also shows that the outer classifier eliminated multi-second empty-tile fallbacks.
The 95 ms is not a clean CPU cost. It is elapsed wall time inside a `BackgroundNormal` worker task, so it includes preemption and contention. A second trace in the repository from the same current code has an LOD0 classifier median around **8.45 ms**, versus **94.7 ms** in the moving/rendering owner session. The real play number is important for latency, but it should not be interpreted as intrinsic classifier compute.
My assessment is:
- **Outer whole-tile classification:** valuable under the current expensive evaluator.
- **Nested 8³ block classification:** likely negative in this workload.
- **Long-term:** making density evaluation cheap again may change the outer classifier’s break-even point.
## 5. Movement does not cancel obsolete generation work
**Status: Verified mechanism; contribution to running falls strongly suspected.**
The abort predicate only notices shutdown or an explicit generation pause ([VoxelWorld.h:764](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelWorld.h:764>)). It does not abort a tile because the player has moved and the tile is no longer desired.
Desired work is globally distance-sorted using an artificial chunk-center position rather than the exact pawn position, with no strong collision-critical/L0 priority ([VoxelWorld.cpp:1864](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1864>)). Up to 14 background tasks can therefore be occupied by old, coarse, or non-supporting work while the player’s new LOD0 floor waits.
By the steady point in the supplied session there were 1,481 loaded tiles for 899 currently desired tiles, with roughly 582 on the unload/transition side. Retaining old rendered tiles is not automatically wrong, but generation already launched for the old neighborhood is not preempted in favor of collision-critical tiles.
This is a credible explanation for falls while running. The trace cannot prove it because it omits tile coordinates, pawn position and collision-cook completion.
## 6. The verdict cache is mismatched to normal streaming
**Status: Implementation verified; negative startup ROI suspected. Medium impact.**
The cache:
- Is global and protected by one lock.
- Advances a clock and linearly scans entries.
- Holds only 512 proofs ([VoxelGenerator.cpp:1512](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:1512>)).
- Keys include sample step, limiting reuse across LODs.
- Does not store a useful outer proof for Mixed tiles.
Fresh streaming normally visits unique regions, which explains the zero outer hits in both supplied traces. Nested block classifiers can then insert many short-lived proofs, pay the lock/scan costs, and evict one another—without appearing in the trace’s cache telemetry.
It demonstrably helped a deliberately repeated commandlet case, but that access pattern is not representative of a moving player. I would treat it as unproven or likely negative during startup until nested calls are measured.
## 7. Field semantics are now implemented in several overlapping places
**Status: Verified duplication; cost not isolated. Medium impact and high maintenance risk.**
The effective field is no longer simply “the operator stack”:
1. The operator stack evaluates the composable field.
2. The classifier maintains parallel interval/proof semantics.
3. `GetDensityAt` performs a post-stack repair pipeline for origin floors, passage structure, passage floors and room floors.
For example, `FRoomGraphSource` evaluates the cached room graph SDF and then separately traverses tunnels again for metadata ([VoxelDensityOpStack.cpp:4234](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:4234>)). The common tail then reasserts origin and passage structure/floors ([VoxelGenerator.cpp:2244](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2244>)).
This is the clearest sign of overengineering: later correctness fixes are compensating for where an operation sits in the pipeline. It creates duplicated traversal, makes interval proofs harder to trust, and prevents the stack from being the single semantic source of truth.
Runtime also does not use the commandlet’s shared density-grid ownership, so adjacent tiles repeat halo/boundary samples. Earlier measurement put this duplication around 18%. It existed in the old architecture too, so it did not cause the original regression, but every repeated sample is now much more expensive.
## 8. There are two lower-confidence ways to get a permanent hole
**Status: Verified risk paths; not demonstrated by this trace.**
First, whole-tile AllAir/AllSolid verdicts are trusted without exact validation. They become loaded `bEmpty` tiles with no component. Internal block verdicts are later checked against the full grid when meshing occurs, but an outer uniform verdict has no equivalent safety net.
The supplied aggregate argues against a widespread problem—every LOD0 Mixed tile produced geometry—but it cannot establish whether the floor tile beneath the pawn was incorrectly declared uniform because tile coordinates were not logged.
Second, `ApplyMeshToTile` has no success return. If runtime-mesh initialization fails, the function returns while the tile remains recorded as loaded, so it will not naturally retry ([VoxelWorld.cpp:3064](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:3064>)). I found no trace evidence that this occurred.
The normal `bAborted` result path is not the likely culprit: it removes the pending record without marking the tile loaded, allowing a later desired pass to reschedule it. There is, however, an editor pause corner case where a worker can exit without enqueuing any result and leave its pending entry behind if pause acquisition times out. That is a real bug, but it does not match this epoch-zero play session.
## What I would prove next
In order:
1. A/B the forced nested block classifier on the exact owner trace, recording outer versus nested calls, proof outcomes, stack builds and cache probes.
2. Add collision-specific timestamps: requested, mesh submitted, collision cook started/completed, component registered, plus tile and pawn coordinates.
3. Force exact meshing/validation for every supposedly uniform LOD0 tile in the spawn neighborhood to rule a false classifier proof in or out.
4. Benchmark a compiled/fused evaluator that retains the authored operator graph but restores per-sample short-circuiting and avoids spilling `FVoxelOpSample` through memory for every operator.
5. Prioritize a small LOD0 support neighborhood and require collision-cook completion before releasing the pawn. Faster generation alone cannot make the current lifecycle correct.
The blunt version: **the operator abstraction leaked into the innermost loop, and streaming treats “work submitted/applied” as “safe to stand on.”** The classifier work then became a collection of compensators for the expensive evaluator, some useful and some apparently duplicative.
This was a read-only audit. No files were created, modified or deleted; `git status` remains clean on `experimental` at `aa62971`.
tokens used
782 274
# Audit conclusion
The two regressions share a cause, but they are not the same bug:
1. **The remaining generation slowdown is primarily in density evaluation.** The composable operator representation is still being interpreted in the sampling hot path, with rich per-sample state, block-wide operator passes, and several corrective passes afterward. The outer tile classifier is not where the original ~8× went.
2. **There is no “collision-ready before player-ready” invariant.** Neither branch explicitly had one. `main` was fast enough—and had simpler spawn geometry—so the race remained hidden. `experimental` made the race long enough to become visible, especially because the new origin room’s air and supporting floor can occupy different tiles.
I disagree with the inference that the old behavior proves there was a correctness barrier. The observation is valid, but the code shows a timing dependency masquerading as a guarantee.
## 1. Density evaluation is the dominant performance regression
**Status: Verified architecture; strong attribution. Highest expected performance impact.**
A streamed LOD0 tile asks for a 35³ haloed density grid: **42,875 sample positions** ([VoxelMarchingCubesMesher.cpp:227](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:227>)).
At `34f06df`, that hot path was approximately:
- Resolve one cached chunk parameter set.
- Enter one direct archetype switch.
- Keep most intermediate values in local variables.
- Skip the detailed cave suite when `bNearCaveSurface` was false—historically around 70% of deep-solid samples.
- Apply disturbances/differences and return.
The relevant historical path is `34f06df:Source/VoxelForge/Private/VoxelGenerator.cpp:438-631` and `:947-1621`.
Current `experimental` instead:
- Allocates/initializes an `FVoxelOpSample` for every position, containing density, SDF, floor metadata, tunnel metadata, pointers and indices ([VoxelDensityOp.h:327](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelDensityOp.h:327>)).
- Determines a block-wide active-operator list.
- Traverses the sample array once per active operator.
- Uses scalar per-sample fallback implementations for most concrete operators ([VoxelDensityOpStack.cpp:51](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:51>)).
- Copies block scratch into the session cache and later back into output.
- Still calls the scalar `GetDensityAt` wrapper for every grid point, so routing, coordinate setup and the post-stack structural tail remain per-sample ([VoxelGenerator.cpp:1690](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:1690>), [VoxelGenerator.cpp:2165](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2165>)).
Loop inversion removed much of the virtual dispatch cost, but it did not restore the old locality or short-circuiting. An operator whose interval intersects the surface anywhere in the block can be run across the whole block; the old path could decide per sample that expensive detail work was irrelevant.
That is the architectural mistake: **the composable authoring model was carried directly into execution instead of being compiled/lowered into a fused evaluator.** Composition is not itself the problem. Interpreting it through rich mutable sample records is.
The 128³ comparison strongly supports this conclusion: the editor export path owns a shared density grid and does not force the streaming outer-classifier path ([VoxelForgeExploreCommandlet.cpp:1700](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp:1700>)), yet it still takes approximately 1.04 seconds versus 0.130 seconds before the operator-stack wire-in.
The later features add cost, particularly the D-shaped floors, but the history indicates roughly a 17% net penalty there—not anything close to the remaining 8×.
## 2. The player can become active before any supporting collision exists
**Status: Verified. Highest correctness impact.**
`BeginPlay` initializes systems, but it does not synchronously request a spawn support tile, wait for a mesh, or gate pawn gravity/control ([VoxelWorld.cpp:988](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:988>)).
Streaming begins from `Tick`, and only when `GetPlayerPosition()` is not exactly `FVector::ZeroVector` ([VoxelWorld.cpp:1167](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1167>), [VoxelWorld.cpp:1359](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1359>)). Thus an exact world-origin pawn is indistinguishable from “no pawn.” Even when Z is nonzero and that specific sentinel is avoided, there is still no bootstrap barrier.
The supplied play trace shows:
- `BeginPlay` finished at about **50 ms**.
- The first desired-tile pass did not begin until about **439 ms**.
- The first LOD0 nonempty/mixed result was not ready until about **1.093 s**.
- That result still represented mesh/collision submission, not collision-cook completion.
At ordinary gravity, one second is enough to fall roughly five metres.
There are three additional gaps:
- A tile is inserted into `LoadedTiles` before its empty/nonempty application path completes ([VoxelWorld.cpp:1532](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1532>)).
- Only LOD0 receives collision ([VoxelWorld.cpp:3109](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:3109>)). Coarser visible terrain can therefore be present while the corresponding collidable LOD0 tile is still queued.
- Current architecture documentation says RMC collision is asynchronously cooked ([ARCHITECTURE.md:817](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:817>)), but `VoxelWorld` records no cook-complete state. Its `apply_s` measurement ends after submission and says nothing about when physics can actually collide.
I compared the equivalent paths on `main`. It also:
- Starts streaming from `Tick`.
- Uses the zero-position guard.
- Marks a tile loaded before handling its empty result.
- Enables collision only for LOD0.
- Has no spawn collision barrier or collision-cook acknowledgement.
So no explicit guarantee was removed inside `VoxelWorld`. What `experimental` broke was the **implicit timing budget** that had hidden this race.
The new origin-room geometry makes it worse. The first central LOD0 request in the trace is uniformly air and correctly receives no component. That is not itself an error: the player occupies air, while collision belongs to the neighboring floor/wall surface tile. With the current default origin geometry, the floor can lie near or below the lower tile boundary ([VoxelPassageGeometry.h:365](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelPassageGeometry.h:365>)). The supporting tile therefore has to arrive and finish cooking independently.
`CollisionOnlyTiles` does not close this gap. It controls presentation of already generated LOD0 components for non-player anchors; it does not synthesize or reserve collision underneath the local pawn. The supplied trace records zero collision-only tiles.
## 3. The mesher’s nested block classifier is probably self-inflicted overhead
**Status: Mechanism verified; net cost strongly suspected. High performance impact.**
The code contradicts its own guidance:
- The mesher header says the optional 8³ refinement should remain disabled during streaming because repeated proofs may cost more than they save ([VoxelMarchingCubesMesher.h:150](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h:150>)).
- `VoxelWorld` unconditionally enables it ([VoxelWorld.cpp:1102](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1102>)).
For every outer-Mixed tile, the mesher may run up to 64 additional `ClassifyTile` calls ([VoxelMarchingCubesMesher.cpp:254](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:254>)). On cache misses, each call builds another operator stack ([VoxelGenerator.cpp:5779](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:5779>)).
In `MyPlay`:
- 298 LOD0 tiles were outer-Mixed.
- All 298 produced geometry.
- Therefore every one eventually populated the full density grid.
- The nested classifier could only avoid marching-cubes cell processing in uniform sub-blocks; it could not avoid the main density grid once any surface sub-block was found.
This is precisely the workload where the header warns it may lose.
There is also an instrumentation trap: the outer cache counters are snapped before meshing. Nested classifier calls occur inside the measured `mesh_s` interval ([VoxelWorld.cpp:2762](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:2762>)). Consequently, the reported “cache hits 0” does **not** describe classifier activity originating inside the mesher.
This deserves the first controlled A/B test: identical trace and seed with only `bUseBlockEarlyOut` changed, while separately counting outer and nested classifier calls.
## 4. The outer whole-tile classifier is probably earning its keep
**Status: Verified workload behavior; exact counterfactual unmeasured.**
I would not remove the outer classifier based on the 95 ms figure.
It rejected **547 of 845 LOD0 tiles**, or 64.7%, before meshing:
- 484 uniformly solid.
- 63 uniformly air.
- 298 mixed, all of which produced geometry.
A full 35³ grid for those rejected tiles would represent up to 23.45 million density positions. Previous history also shows that the outer classifier eliminated multi-second empty-tile fallbacks.
The 95 ms is not a clean CPU cost. It is elapsed wall time inside a `BackgroundNormal` worker task, so it includes preemption and contention. A second trace in the repository from the same current code has an LOD0 classifier median around **8.45 ms**, versus **94.7 ms** in the moving/rendering owner session. The real play number is important for latency, but it should not be interpreted as intrinsic classifier compute.
My assessment is:
- **Outer whole-tile classification:** valuable under the current expensive evaluator.
- **Nested 8³ block classification:** likely negative in this workload.
- **Long-term:** making density evaluation cheap again may change the outer classifier’s break-even point.
## 5. Movement does not cancel obsolete generation work
**Status: Verified mechanism; contribution to running falls strongly suspected.**
The abort predicate only notices shutdown or an explicit generation pause ([VoxelWorld.h:764](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelWorld.h:764>)). It does not abort a tile because the player has moved and the tile is no longer desired.
Desired work is globally distance-sorted using an artificial chunk-center position rather than the exact pawn position, with no strong collision-critical/L0 priority ([VoxelWorld.cpp:1864](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:1864>)). Up to 14 background tasks can therefore be occupied by old, coarse, or non-supporting work while the player’s new LOD0 floor waits.
By the steady point in the supplied session there were 1,481 loaded tiles for 899 currently desired tiles, with roughly 582 on the unload/transition side. Retaining old rendered tiles is not automatically wrong, but generation already launched for the old neighborhood is not preempted in favor of collision-critical tiles.
This is a credible explanation for falls while running. The trace cannot prove it because it omits tile coordinates, pawn position and collision-cook completion.
## 6. The verdict cache is mismatched to normal streaming
**Status: Implementation verified; negative startup ROI suspected. Medium impact.**
The cache:
- Is global and protected by one lock.
- Advances a clock and linearly scans entries.
- Holds only 512 proofs ([VoxelGenerator.cpp:1512](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:1512>)).
- Keys include sample step, limiting reuse across LODs.
- Does not store a useful outer proof for Mixed tiles.
Fresh streaming normally visits unique regions, which explains the zero outer hits in both supplied traces. Nested block classifiers can then insert many short-lived proofs, pay the lock/scan costs, and evict one another—without appearing in the trace’s cache telemetry.
It demonstrably helped a deliberately repeated commandlet case, but that access pattern is not representative of a moving player. I would treat it as unproven or likely negative during startup until nested calls are measured.
## 7. Field semantics are now implemented in several overlapping places
**Status: Verified duplication; cost not isolated. Medium impact and high maintenance risk.**
The effective field is no longer simply “the operator stack”:
1. The operator stack evaluates the composable field.
2. The classifier maintains parallel interval/proof semantics.
3. `GetDensityAt` performs a post-stack repair pipeline for origin floors, passage structure, passage floors and room floors.
For example, `FRoomGraphSource` evaluates the cached room graph SDF and then separately traverses tunnels again for metadata ([VoxelDensityOpStack.cpp:4234](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:4234>)). The common tail then reasserts origin and passage structure/floors ([VoxelGenerator.cpp:2244](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2244>)).
This is the clearest sign of overengineering: later correctness fixes are compensating for where an operation sits in the pipeline. It creates duplicated traversal, makes interval proofs harder to trust, and prevents the stack from being the single semantic source of truth.
Runtime also does not use the commandlet’s shared density-grid ownership, so adjacent tiles repeat halo/boundary samples. Earlier measurement put this duplication around 18%. It existed in the old architecture too, so it did not cause the original regression, but every repeated sample is now much more expensive.
## 8. There are two lower-confidence ways to get a permanent hole
**Status: Verified risk paths; not demonstrated by this trace.**
First, whole-tile AllAir/AllSolid verdicts are trusted without exact validation. They become loaded `bEmpty` tiles with no component. Internal block verdicts are later checked against the full grid when meshing occurs, but an outer uniform verdict has no equivalent safety net.
The supplied aggregate argues against a widespread problem—every LOD0 Mixed tile produced geometry—but it cannot establish whether the floor tile beneath the pawn was incorrectly declared uniform because tile coordinates were not logged.
Second, `ApplyMeshToTile` has no success return. If runtime-mesh initialization fails, the function returns while the tile remains recorded as loaded, so it will not naturally retry ([VoxelWorld.cpp:3064](<E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:3064>)). I found no trace evidence that this occurred.
The normal `bAborted` result path is not the likely culprit: it removes the pending record without marking the tile loaded, allowing a later desired pass to reschedule it. There is, however, an editor pause corner case where a worker can exit without enqueuing any result and leave its pending entry behind if pause acquisition times out. That is a real bug, but it does not match this epoch-zero play session.
## What I would prove next
In order:
1. A/B the forced nested block classifier on the exact owner trace, recording outer versus nested calls, proof outcomes, stack builds and cache probes.
2. Add collision-specific timestamps: requested, mesh submitted, collision cook started/completed, component registered, plus tile and pawn coordinates.
3. Force exact meshing/validation for every supposedly uniform LOD0 tile in the spawn neighborhood to rule a false classifier proof in or out.
4. Benchmark a compiled/fused evaluator that retains the authored operator graph but restores per-sample short-circuiting and avoids spilling `FVoxelOpSample` through memory for every operator.
5. Prioritize a small LOD0 support neighborhood and require collision-cook completion before releasing the pawn. Faster generation alone cannot make the current lifecycle correct.
The blunt version: **the operator abstraction leaked into the innermost loop, and streaming treats “work submitted/applied” as “safe to stand on.”** The classifier work then became a collection of compensators for the expensive evaluator, some useful and some apparently duplicative.
This was a read-only audit. No files were created, modified or deleted; `git status` remains clean on `experimental` at `aa62971`.
+43
View File
@@ -0,0 +1,43 @@
# CLAUDE.md — VoxelForge
UE5 **density-field voxel terrain** plugin (strates / Marching Cubes / async streaming).
Don't re-read the whole plugin — use the map.
## ⛔ #1 rule: NEVER build or compile
Jahni runs every build himself (he has the editor open; running it via Claude just burns cost).
When code changes are done, **stop and say "ready to build"** + list likely compile-error spots,
then wait for his results / pasted errors. Same for in-editor checks — ask for a screenshot, don't
try to run anything.
## Read first (in order, load only what the task needs)
- **[CODEMAP.md](CODEMAP.md) — ALWAYS.** Navigation: what it is, data-flow (§2), symbol→`file:line`
index (§3), "I want to change X → go here" (§5), conventions & gotchas (§6). Trust **symbol names
over line numbers** (lines drift).
- **[ARCHITECTURE.md](ARCHITECTURE.md) — when touching generation / strates / passages / biomes.**
The deep design (archetypes, (0,0) spine, disturbances, content/atmosphere, biomes) **and the
`§8.10` performance invariants ("don't regress").**
- **[fable-idea.md](fable-idea.md) — before planning PERF or FEATURE work.** Ranked perf+feature
roadmap with verified hot spots; check it so you don't re-propose done/known work. (A perf pass
already shipped: game thread solved ~3.94 ms, CullTiles spiral fixed, region-granular foliage.)
- **[REVIEW_FINDINGS.md](REVIEW_FINDINGS.md)** — open quality/cleanup items (cross-check vs fable-idea
before acting; some may already be addressed).
## Hard rules (these prevent real bugs — verify against code, don't assume)
- **Density sign: negative = solid, positive = air** (MC convention at the mesher).
`FVoxelModification::Strength` negative = carve. *The #1 source of confusion.*
- **Density functions take VOXEL coords, not cm.** Conversions live in `VoxelTypes.h`.
- **Determinism:** all randomness = hash of (coord, seed, strateIndex). No RNG state — same seed ⇒
same world. Player edits (diff layer) are the only non-deterministic overlay.
- **Async safety:** worker tasks only READ Generator/Mesher, must check `bShuttingDown`, and return via
`ProcessQueue` (must stay `EQueueMode::Mpsc`). `EndPlay` blocks on `ActiveTaskCount → 0`.
- **Carry the `Epoch`** through any new async path (stale results are dropped on mismatch).
- **Don't "optimize" the perf invariants** in ARCHITECTURE `§8.10` — the `thread_local` box-valid
caches, two-pass MC loop, SSE noise, and clipmap streaming are intentional.
- **Never edit** `Binaries/`, `Intermediate/`, `*.generated.h`. Comments are French + English — match
the surrounding file.
## Discipline (keep the map alive)
- Add / rename / move a symbol → update its **CODEMAP §3** row (symbol-first; line is a hint).
- Change generation/strate design → update **ARCHITECTURE §8**.
- Resolve a checklist item → tick it in **REVIEW_FINDINGS.md**.
- Don't duplicate content across these files — each has one job (map / design / findings / rules).
+477 -36
View File
@@ -47,7 +47,7 @@ A **density-field voxel terrain** plugin for Unreal Engine, built around undergr
Tick → UpdateChunksAroundPosition
│ (load/unload around player, by distance + LOD)
▼
LoadChunk → UE::Tasks::Launch ──────────► background thread
LoadTile → UE::Tasks::Launch ──────────► background thread
│
UVoxelMarchingCubesMesher::GenerateMesh(chunk, step) ◄────────────────┘
│ samples density per cell corner
@@ -73,12 +73,52 @@ what generator type, and is there a passage/elevator SDF near here?"
Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl.
### 3.0 Human-scale generation contract (2026-09-06)
`VoxelStrateTypes.h` is now authored against the confirmed player capsule, not against naked voxel
counts: one voxel is **0.25 m**, the capsule is **1.36 voxels / 0.34 m radius** and **7.04 voxels /
1.76 m tall**, and the default strate envelope is **8 chunks / 256 voxels / 64 m**. Core defaults are
therefore: room diameters **8-20 m**, origin hub **24 m**, room cells **32 m**, round tunnel bores
**3-4 m**, maze cells **16 m** with a **6.25 m nominal bore** (**3.125 m** radius; **3.0625 m**
worst-case usable floor after roughness), slab open span **22.4 m**, shaft diameters
**4-7 m**, island diameters **12-24 m**, and ordinary inter-strate bores **3-4 m**. These are
different derivations, not a global multiplier: passage widths follow the 2.5-4 m walking/fight
target, room/cell spacing preserves a rock web, and the 64 m vertical envelope reserves space for
tall rooms, surface sky, and vertical traversal.
The reachable roughness envelope is `roughness * VOXEL_NOISE_SCALE * 1.5`, or `roughness * 1.875`
voxels. The new defaults keep it below the relevant feature radius: **0.625** of the minimum
TunnelNetwork tunnel radius, **0.300** of Maze corridor radius, **0.469/0.625** of shaft/connector
radii, **0.234** of the minimum island radius (**0.484** including the 6-voxel island blend), and
**0.625/0.938** of slab column radius for floor/ceiling roughness. SurfaceWorld has no radial cave
feature, so its ratio is reported against the 80-voxel terrain-relief scale (**0.047**).
The complete generated `name | voxels | METRES | body/level meaning` audit is emitted by
`VoxelForge.Composer.Showcase` and includes optional transport fields. The official Unreal blockout
guidance ([Unreal level blockout guidance](https://dev.epicgames.com/documentation/en-us/unreal-engine/designer-01-project-setup-and-level-blockout-in-unreal-engine)) uses a player-sized reference and treats 2-3 m halls / 3-4 m heights as useful starting
guidelines; the [2010 ADA Standards](https://www.ada.gov/law-and-regs/design-standards/2010-stds/)
provide a 915 mm clear walking-width floor, not a game-space target; and [NPS Lehman Cave
dimensions](https://www.nps.gov/grba/learn/nature/lehman-caves-dimensions.htm) show why natural
chambers need a wide range rather than one universal volume ratio.
Maze topology is an origin-directed spanning tree, not independent edge percolation. Every
non-origin lattice node chooses one hashed parent from the non-zero axes that point toward
`(0,0,0)`; Manhattan distance therefore falls by exactly one on every parent step, so every node
reaches the root. Optional horizontal and vertical loop rolls are capped at `0.18 * BranchProbability`
and `0.10 * Verticality`; removing them cannot disconnect the tree. A voxel cell evaluates the
canonical lower-node `+X/+Y/+Z` edges in its local `{-1,0}³` (**2×2×2 = 8-node**) window, and each
edge checks both endpoints' parent choices. No wider collect region is needed, `WorldRadiusVoxels`
remains `0`, and lateral regions remain gated off.
### 3.1 Module & build
| File | Role |
|------|------|
| `../../VoxelForge.uplugin` | Plugin manifest. One Runtime module `VoxelForge`. Beta. |
| `../../VoxelForge.uplugin` | Plugin manifest. Runtime module `VoxelForge` plus the Editor-only `VoxelForgeEditor` commandlet module. Beta. |
| `VoxelForge.Build.cs` | Deps: Core, CoreUObject, Engine, **GameplayTags**, **RealtimeMeshComponent**. |
| `Tools/VoxelForgeTest.ps1` | The single guarded test entry point. `-Scenario canonical|owner|probe|perf|tests|surface-fall|crossing|gate-stress|parity|horizon|horizon-render|determinism`, optional `-Build`, `-Assets owner|default`, `-Cvars`, absolute `-Out`, `-Label`, `-HorizonLevel`, `-HorizonRenderDistanceChunks`, and `-SharedDDC`; stages the host/assets, isolates user/DDC/config state, serializes one Unreal launch at a time, enforces the 8 GiB/30-minute guards, and emits compact `result.json`/`summary.txt` plus full logs. Horizon traces report desired/loaded tiles, resident stream geometry, worker seconds, steady wall time, and peak private memory; `horizon-render` captures the owner view; `determinism` compares per-tile hashes across the default CRT path and `-voxel.CrtFma3=0`. |
| `Public/VoxelForgeModule.h` / `Private/VoxelForgeModule.cpp` | `FVoxelForgeModule` boilerplate (Startup/Shutdown just log). |
| `Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs` / `Private/VoxelForgeExploreCommandlet.*` | Editor/commandlet-only `-run=VoxelForgeExplore`; owns bounded render/`VF_` walk/OBJ export orchestration and is not linked by Game/Shipping. |
| `Public/VoxelGeometryHash.h` | Header-only geometry digest shared by commandlet export and runtime tile-hash dump; hashes mesh counts and the vertex/normal/UV/color/triangle arrays with the historical CRC order. |
| `Public/VoxelStats.h` / `Private/VoxelStats.cpp` | `stat VoxelForge` DWORD counters for tile classification, skipping, meshing, operator-stack verdicts, and cave-bail diagnosis. The former ambiguous `Cave Bail Not Op Stack` is split into `Sole Slot`, `Boundary Tile`, `No Layout`, and late `Recheck` counters, so each increment names one guard/context. |
### 3.2 Foundational types — `Public/VoxelTypes.h` (no UClass, everyone includes it)
| Symbol | Line | Notes |
@@ -88,10 +128,129 @@ Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl.
| `EVoxelFace` enum + `GetFaceDirection` / `GetFaceNormal` | 33-61 | 6 cube faces. |
| `WorldToChunkCoord` / `WorldToLocalCoord` / `ChunkToWorldPos` | 74-104 | Coord-space conversions (handle negatives via floor/positive-modulo). |
| `LocalToIndex` / `IndexToLocal` / `IsValidLocalCoord` | 107-131 | Flat-array 3D↔1D indexing. |
| `VoxelMath::IsFiniteFast` / `IsFinite` | ~24-53 | Exact IEEE bit test (`voxel.FastIsFinite` A/B switch). |
| `VoxelMath::DetSinCos` / `DetSin` / `DetCos` | ~63-195 | **The only sin/cos allowed where the world is decided** (2026-09-16, `10fd0b6`). Fixed double period reduction and float polynomial, no CRT: `FMath::Sin/Cos` are CRT `sinf/cosf`, whose FMA3 variant is picked by CPU. Pinned by `VoxelForge.Determinism.DetSinCos`; `-voxel.CrtFma3=0` flips the CRT path to prove it. |
| `SmoothStep01` | 140 | 3x²-2x³ — used everywhere for blends. |
| `VOXEL_NOISE_SCALE` (1.25f) | 147 | Rescales UE PerlinNoise3D to ~[-1,1]. |
| `EVoxelTileClass` enum (`Mixed`/`AllSolid`/`AllAir`) | — | T1.d verdict. **MOVED here from `VoxelGenerator.h` 2026-07-27** so `VoxelDensityOp.h` can share it without a UCLASS dependency. A false `AllSolid`/`AllAir` is a HOLE; a false `Mixed` only costs CPU. |
| `FVoxelMeshData` struct | 157-173 | Mesher output (Vertices/Triangles/UVs/Normals/**Colors**). Plain C++, not USTRUCT. `Colors` = F6 material masks (R=dominant biome palette, G=slope, B=border blend weight, A=neighbour biome palette). §8.15. |
### 3.2b Density operator stack contract — `Public/VoxelDensityOp.h` (plain C++, no UHT)
**Production path behind a per-strate opt-in** — the stack is built/evaluated by `GetDensityAt` for
all eight archetypes when `bUseOperatorStack` is enabled, while the legacy `switch` remains the
reference path for opt-out strates. See [OPSTACK-PLAN.md](OPSTACK-PLAN.md) for the decomposition.
| Symbol | Notes |
|--------|-------|
| `EVoxelOpRole` | The four roles: `FieldSource` (what the field IS) · `Combiner` (how fields merge) · `DetailModifier` (today's `UVoxelTerrainOpDefinition`) · `StructuralPost` (spine→vertical seal→passage tube/landing floor→XY edge seal→diff, appended automatically, never author-omittable). |
| `EVoxelOpChannel` / `EVoxelOpChannelMask` | The explicit `FVoxelOpSample` channel set: `Density` and `Sdf` (plus `None`/`All` masks). Every density op declares the channels it reads and writes. |
| `EVoxelOpCombine` | `Replace`/`Union`(min)/`Subtract`(max)/`SmoothUnion`/`SmoothSubtract`/`Add`/`Mask`. Sign reminder: negative = solid, so "add solid" is `min`. |
| `EVoxelOpEffect` | `Identity`/`CarveOnly`/`FillOnly`/`Both`. Conservative: `Both` is always safe, the wrong one is a hole. |
| `FVoxelOpContext` | Chunk-constant inputs. Carries `LayoutVersion` plus the copied global `WorldRadiusVoxels`/`EdgeSealThickness`, so a new op cannot forget them. |
| `IVoxelDensityOp` | `ChannelReads` / `ChannelWrites` / `IsAdditive` plus `RequiredResources` / `ProvidedResources`, `PrepareChunk` / `Eval`, and intrinsic/state-aware `EffectOverBox` / `MaxCarveOverBox` / `MaxFillOverBox` / `PropagateSdfOverBox` / `ClassifyBox` / `IsXYPure`. Box-query methods are never called per voxel; channel/resource declarations are snapshotted during stack assembly. |
| `IVoxelDensityOp::ClassifyBox` | ⚠️ **not source-only.** Forcing ops (the vertical seal and XY edge seal inside their proven bands) overwrite the input entirely, which pure direction cannot express. |
| `FVoxelOpSample` | The state threaded through the stack: **two** channels, `Density` (INTERNAL convention, **positive = SOLID**, negated to MC once by the caller) and `Sdf` (standard SDF, negative = inside). ⚠️ `min()` therefore means opposite things on the two channels. |
| `FVoxelBoxHypotheses` + `FVoxelBoxSdfInterval` + `VF_ForceHypotheses` / `VF_FoldEffect` / `VF_FoldOp` | The fold that turns a stack into an `EVoxelTileClass`, carrying the SDF interval between independent writers and consumers. Unknown is biased toward `Mixed`; the mapping and soundness rule are written out in the header. |
### 3.2c Structural primitives — `Public/VoxelDensityPrimitives.h`
`VF_ApplyOriginSpine` · `VF_ApplyBoundarySeal` · `VF_ApplyPassageCarving` · `VF_ApplyXYEdgeSeal` —
the four world invariants every archetype appends, with landing/floor geometry joined by the
manager passage post, **moved here 2026-07-27** so the generator and
the operator stack share ONE copy. The XY helper is a radial smooth ramp in actor-space and is a
true no-op when `WorldRadiusVoxels == 0`; `VF_XYEdgeSealForcedMarginOverBox` is the shared sound
proof for `ClassifyBox` and T1.d's global shell skip. `VoxelGenerator.cpp` keeps same-named
`static FORCEINLINE` forwarders for the original three helpers; the new edge helper is called
directly. **Convention: INTERNAL (positive = solid); `VF_ApplyXYEdgeSealMC` is the output wrapper.**
### 3.2d Operator stack — `Public/VoxelDensityOpStack.h` + `Private/VoxelDensityOpStack.cpp`
⚠️ **Feeds the game, behind a per-strate opt-in** (Phase 1 step 3). `GetDensityAt` builds the stack
in its per-chunk refetch block and evaluates it *instead of* the `switch` only when
`UVoxelStrateManager::UsesOperatorStackForChunk` says so — strate ticked `bUseOperatorStack` **and**
archetype in the ported list, which is now **all 8 of 8**: Maze, FlatPlain, CrystalChamber,
SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork, Underwater. A strate that has not
ticked the box still takes the `switch`, unchanged — the flag is the only thing that switches paths.
**`ClassifyTile` IS wired now**, for CAVE archetypes only and behind the same per-strate opt-in:
where it used to `return Mixed` without a call, it builds the strate's stack through the *same*
factory `GetDensityAt` uses (`VF_BuildOpStackForChunk`) and folds `ClassifyBox`. SurfaceWorld and
bedrock gaps keep their hand-written proofs — the exact-lattice column test is better than any box
bound. Guards, all failing to `Mixed`: one cave slot per tile, no mixed cave/surface/gap tile, the
opt-in true on *every* chunk the box touches, the params **bit-identical** across every chunk coord
the box touches (blended transition bands make one stack unable to represent the tile — `AUDIT §C2`),
a 27-chunk-coord cap, and the disturbances folded in by hand since they are applied after the stack.
Brute-forced end to end by `VoxelForge.OpStack.ClassifyTileSoundness`.
⛔ Never run both paths in one world. **Comparing them IS legitimate now** — the ~1 ULP residue of
AUDIT §C10 is gone since `FPSemantics = Precise`, and all eight equivalence tests compare bit for
bit. They are port-correctness oracles, not fidelity checks: the acceptance bar is §2.6.1 (same seed
⇒ same world on every peer), which does not require resembling the pre-refactor world.
| Symbol | Role | Notes |
|--------|------|-------|
| `FVoxelOpStack` | — | Ordered move-only list of `TUniquePtr` entries. `Add` snapshots channel metadata; `PrepareChunk` / `EvalInternal` / `EvalMC` / `ClassifyBox` (the fold lets a later forcing post overwrite a dead hypothesis). |
| `FVoxelOpStack::ValidateChannelOrder` | — | Assembly/diagnostic-only channel/resource DAG validator. It requires prior producers, rejects write-only clobbers and invalid additive declarations, and requires room/shaft/surface state providers before consumers. It permits the one root SDF identity fold used by Maze. `VoxelForge.OpStack.ChannelDAG` validates all 8 shipping stacks. |
| `FVoxelOpStack::AppendStructuralPost` | 4 | Appends spine → vertical seal → passage tube/landing floor → XY edge seal **in that fixed order**. An author cannot omit or reorder them. The diff layer is NOT here yet — it still lives in `GetDensityAt` after the MC negate, with disturbances. |
| `VoxelDensityOps::MakeConstantRockSource` | 1 | `Density = BaseDensity`. `ClassifyBox` → **AllSolid**, exact and free. Shared by TunnelNetwork, Maze, VerticalShafts and bedrock gaps. Class is `FConstantFieldSource` (one class, two factories). |
| `VoxelDensityOps::MakeConstantVoidSource` | 1 | The **same class, negated**: `Density = -BaseDensity`, and `ClassifyBox` → **AllAir** — the first source in the plugin that can prove it. FloatingIslands' root; that verdict is what makes a mostly-empty island strate skippable. |
| `VoxelDensityOps::MakeLatticeCorridorSource` | 1 | Maze corridors, SDF channel. Each canonical lower-node/axis edge is open when it is a parent edge in the origin-directed tree or passes a capped loop roll; both endpoints are hashed locally, so adjacent chunks cannot disagree. The source reports its own conservative SDF interval; a later converter supplies its own carve/fill response, so the source is independent of the chosen consumer. |
| `VoxelDensityOps::MakeSdfRoughnessMod` | 3 | Wall roughness in **SDF** space (Maze/Shafts/Islands variant). TunnelNetwork's density-space roughness is a **different op** — see OPSTACK-DECOMPOSITION §1. |
| `VoxelDensityOps::MakeSdfCarve` | 2 | SDF → density carve. The same six lines currently copied in three archetypes. Class is `FSdfConvertOp(Sign = -1)`. |
| `VoxelDensityOps::MakeSdfFill` | 2 | The same op with `Sign = +1` — FloatingIslands' `Density += Fill·Base·2`. ±1 multiplication is exact in IEEE-754, so the carve path is bit-for-bit unchanged by the generalisation. |
| `VoxelDensityOps::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: the proved FBM supremum `1.5` bounds both surfaces into known Z bands. Serves FlatPlain **and** CrystalChamber. |
| `VoxelDensityOps::MakeGridColumnMod` | 3 | Infinite-height cylinders on a world grid, 3×3 cell memo. Adds solid only ⇒ `FillOnly` when a column reaches the box, `Identity` otherwise — and that `Identity` is what lets the source's `AllAir` verdict survive. |
| `FShaftFieldSource` (internal) | 1 | VerticalShafts' memoised inner 3×3 shaft/capsule source. Each rebuild uses a direct-indexed wider halo (default 9×9 tree-emission / 15×15 roll) so every emitted shaft has its complete 5×5 parent window plus capsule reach; the nearest strictly more-central shaft in ±2 cells is the deterministic parent, with a deterministic lower-origin ±3 fallback and a direct `(0,0)` spine fallback. Thus every shaft has a provable path to the spine. Existing `VoxelHash::Pair`/`Spacing*1.6` links remain additive texture/loops. Tree and spine capsules use a radius strictly above `sup|FBM|=1.5`, and tree Z is chosen in a ledge-free band; the structural post still owns the column and the ledge modifier keeps its mathematical axis boundary open. The source publishes a conservative interval covering both shafts and connector capsules; it does not answer for the ledge or converter. |
| `VoxelDensityOps::BuildSlabStack` | — | 6 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. |
| `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns a **six-box spatial LRU** of direct-indexed per-column cells, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed, ParamsFingerprint)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. Six 81×81 boxes preserve hot columns across interleaved regions at roughly 0.79 MiB TLS before padding (more memory, fewer whole-cache recenter/recompute misses). Fractional XY remains direct-compute. `FSurfaceColumn` is deliberately trivial: all five outputs are written before the cache's `Computed` publish, so removing its default initialization removes a TLS constructor leaf without changing a value. |
| `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 4 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. |
| `VoxelDensityOps::BuildVerticalShaftStack` | — | 9 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). Passes the runtime spine radius into `FShaftFieldSource`, which mirrors the generator's connector-only origin endpoint and roughness-safe spine capsule. The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. |
| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion**; the original lacked them until AUDIT §C2 was fixed (2026-07-28) and now carries them too. The source now publishes a conservative SDF interval through the box fold; it does not answer for the carve or detail stack. It builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), and the interval uses the same cull/threshold disjunction as the per-voxel source: a primitive is harmless when it fails its cull or its own SDF stays ≥ `T+K`; `T = max(3·SDFBlendRadius, WormNetworkRange)`, with `−K` slack for the final `SmoothMin`. Rooms, tunnels, pits and chimneys are bounded in their respective coordinate frames; warp dilation uses the proved `sup|Perlin3D| ≤ 1.5` envelope. |
| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by the propagated SDF interval (reads `InOut.Sdf` *after* pits/chimneys). Its state-aware box response is `Identity` only when the preceding interval proves `Sdf ≥ WormNetworkRange`; otherwise it reports its own `CarveOnly` direction and bound. With no preceding SDF source it remains conservative, so it cannot inherit a verdict from an unrelated future writer. |
| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 20 ops** — the biggest port in the plugin (~1080 lines), done in three stages: SDF spine (A) → the twelve detail modifiers of 4b–4h (B) → the per-room op override (C) → 4 structural posts. Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line. `FFloorBiasMod` is an independent additive term on the density accumulated before it; it is kept late because it does not undo the roughness delta. The requested 4b→4h fuse is not semantically justified. |
| `FCaveRoughnessMod` (internal) | 3 | STEP 4b, **density space** — a different op from `MakeSdfRoughnessMod`: two octave sets, optional domain warp, four noise types, an anti-fill clamp inside definite air, quadratic fade. ⚠️ **Reads STRATE params, not the per-room copy** — the original's shadow is declared *after* step 4b. Eleven of twelve modifiers read the room copy; this one does not. |
| `FCaveTerraceMod` (internal) | 3 | STEP 4c. The only modifier that **re-queries the SDF** (Z±1, through `FRoomGraphSource::ProbeSdfUnwarped`) for its horizontality gate — which is why the room source's cache is exposed at all. ⚠️ Those probes use unwarped X/Y and raw Z although the field was evaluated warped: transcribed as-is, see OPSTACK-PROGRESS. |
| `FLayerLineMod` / `FRibbingMod` (internal) | 3 | The same sine along Z: cubed and subtracted (grooves) vs quarter-phase-shifted, squared and added (ribs). `CarveOnly` / `FillOnly` — two of the few detail modifiers that keep a usable direction for the fold. |
| `FCaveOverhangMod` / `FCaveCliffMod` / `FScallopMod` (internal) | 3 | STEP 4c. ⚠️ The cliff's own comment promises a sampled vertical gradient; the **code** uses a Z-stretched Perlin as a proxy and samples nothing. Ported as written — fixing it would change the world. |
| `FCaveArchMod` / `FDomeMod` / `FPinchMod` / `FFloorBiasMod` (internal) | 3 | Room-relative: they read `FRoomGraphSource::GetNearestRoomIdx()` and the cached room. Their gate is `SDF < SDFBlendRadius`, **not** the shared `·3` one — they live in the cave's void, not its wall. |
| `FRoomColumnMod` (internal) | 3 | STEP 4d, and **not** `MakeGridColumnMod`: it walks `SDFCache.Columns`, pre-baked per room. ⚠️ It has **no strate parameter at all** — neither the bake nor the loop reads `FStrateGenerationParams::ColumnDensity`. Columns exist only through a `Column` terrain-op asset in the strate's pool, and the only way to prove they fired is to look at `SDFCache.Columns.Num()`. |
| `FRoomGraphSource::LocalParams()` | — | **The per-room op override** (DECOMPOSITION §2's "no clean home"). Strate params + the nearest room's `UVoxelTerrainOpDefinition::ApplyTo`, memoised once per voxel and read by eleven modifiers. One op owns the shared state, the rest read it — the same pattern as `FOverhangShelfMod` ← `FSurfaceColumnSource`. The detail consumers use the propagated interval for their shared near/far gate; unknown remains conservative, and the per-room parameter override is never used to claim an unproved box identity. |
| `FIslandBlobSource` (internal) | 1 | Hash-placed tapered flat-top blobs, `SmoothMin`'d, in a **domain-warped XY frame** (the warp stays inside the op — see the deviation note vs DECOMPOSITION §7). SDF channel only. The source publishes an interval covering the full authored radius envelope (`max(MinRadius, MaxRadius)`), the two-axis warp·**√2** envelope, top surface and SmoothMin dip; the fill converter makes its own decision. **No lower Z bound exists** — a hairline thread of matter hangs below each island down its axis, so only the TOP may reject. |
| `VoxelDensityOps::BuildFloatingIslandStack` | — | 8 ops, and **the stack runs backwards**: void source + fill instead of rock source + carve, using the *same* classes with the opposite sign. Only the blob source is new. Reuse by **inversion** — a stronger result than reuse by identity, since it says the abstract axis (the density sign) is the right one. |
| `VoxelDensityOps::BuildMazeStack` | — | The 8-op Maze stack (4 structural posts included). Its lattice source mirrors the origin-directed tree + capped loops and rebuilds the 2×2×2 lower-node window once per cell; the per-voxel loop only evaluates cached capsules. If this ever becomes one op, the refactor failed its own test (§2.5). Callers must skip it on a **degenerate strate** (top−bottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. |
#### Lateral region parent (Tier 4d)
`VF_RollStrateRegionManifest` rolls a deterministic count of 1–3 regions and gives each region its
own native parameter vector and, for structure candidates, its own recipe. `VF_QueryStrateRegion`
assigns XY through jittered `VoxelHash::Cell` sites on a 256-voxel lattice; the pure query is keyed
by `(WorldXY, Seed, StrateIndex)` and the worker-side `FVoxelStrateRegionPartitionCache` prepares
the site window and integer query grid once per chunk. `FLateralRegionBlendOp` evaluates the primary
region core and, inside a 24-voxel band, the nearest different-region core, then lerps **density**
(not native parameters). The four structural posts are appended once to the parent after the
blend. Count==1 uses the ordinary one-stack path and is covered by native and recipe bit-identity
oracles. `ClassifyBox` proves a single-region/non-band box only from a conservative Lipschitz gap
bound; otherwise it folds every region and returns `Mixed` on any disagreement or unknown. The
runtime/editor override reads the immutable manifest per chunk; it never searches lattice seeds per
voxel. The dedicated `VoxelForge.Composer.LateralRegions` test reports cross-boundary brute-force
box samples, interior-vs-band timing, 16 opposite-region passage-mouth trials, and a saved preview.
### 3.2e Height-space operators — `Public/VoxelHeightOp.h` + `Private/VoxelHeightOpStack.cpp`
⚠️ **Feeds nothing yet** — built and exercised only by `VoxelForge.OpStack.SurfaceHeightEquivalence`.
**A SECOND op family, and it exists for a reason worth knowing:** SurfaceWorld's terrain ops (cliff /
terrace / layer lines / beach) read and write an **altitude**, not a density. They have no input Z
(they produce one), are XY-pure (once per column, not per voxel), and touch neither density nor SDF —
so they do not fit `IVoxelDensityOp` at all. Forcing them in would need a per-voxel channel for what
is a **column** property, or one opaque op (`OPSTACK-PLAN §2.5`'s failure mode). Same lesson as
`§0.1` one step further: some things are not another channel, they are another **space**.
| Symbol | Notes |
|--------|-------|
| `FVoxelHeightSample` | Two channels: `Height` (voxel Z) + `Relief` (the original's `M`). Relief is produced by the structural source and consumed by the terrace gate — threading it beats resampling it. |
| `IVoxelHeightOp` | `Eval(X, Y, FVoxelHeightSample&)`. No `IsXYPure` (XY-purity is structural here — there is no Z to wrongly put in), no `PrepareChunk` (already per-column). `MaxDisplacement()` is the conservative vertical bound for a future heightfield `ClassifyBox`. |
| `FVoxelHeightStack` | Move-only, like `FVoxelOpStack`. `EvalHeight` / `EvalSample` / `MaxTotalDisplacement`. |
| `VoxelHeightOps::MakeStructuralHeightSource` | Continents + mountains + detail under a warp frame. Hands back a **non-owning pointer** so the cliff mod can resample it. |
| `VoxelHeightOps::MakeCliffHeightMod` | Slope-gated steepening; 4 resamples of the **structural** field (never the modified height — that would feed back). |
| `VoxelHeightOps::MakeTerraceHeightMod` | Relief-gated plateaus. The `* Relief` is the original's `* M`. |
| `VoxelHeightOps::MakeLayerLineHeightMod` / `MakeBeachHeightMod` | Sine bands; flatten toward the water line. Both have exact `MaxDisplacement`. |
| `VoxelHeightOps::BuildSurfaceHeightStack` | 5 ops in `ComputeSurfaceTerrainZ`'s order — structural → cliff → terrace → layer lines → beach. **Order is not negotiable.** |
### 3.3 Chunk identity
`VoxelChunk.h` (the old `FVoxelChunk` coord wrapper) was DELETED — dead since the tile
redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
@@ -101,20 +260,36 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
| Group | Fields (line) |
|-------|---------------|
| Streaming | `ViewDistanceXY=16`, `ViewDistanceUp/Down=5`, `MaxConcurrentTasks=16`, `MaxMeshAppliesPerFrame=4` (defaults — actual values live on the data asset) |
| Clipmap | `ClipRadius`, `MaxClipLevel`, `FullResClipLevels`, `CoarseTileCells`, `RenderDistanceChunks` (custom horizontal reach: the outermost shell keeps generating until it covers this many chunks; 0 = off), `bFarSheetRing` + `FarSheetSpanLevels` (F18 — the render-distance ring streams per-surface SHEETS: level MaxClipLevel+span heightfield tiles instead of MC, see `GenerateSheetMesh`), skirts, `LODOctaveDrop` (T2.b octave drop on coarse tiles — 0 = off/byte-identical) (the old `LOD0/1Distance` + `ContentMaxLevel` were dead → removed) |
| Clipmap | `ClipRadius`, `MaxClipLevel=5`, `FullResClipLevels`, `CoarseTileCells`, `RenderDistanceChunks=768` (measured ship defaults; custom horizontal reach: the outermost shell keeps generating until it covers this many chunks; 0 = off), `bFarSheetRing=false` by default, plus `FarSheetSpanLevels` (F18 — the optional render-distance ring streams per-surface SHEETS: level MaxClipLevel+span heightfield tiles instead of MC, see `GenerateSheetMesh`), skirts, `LODOctaveDrop` (T2.b octave drop on coarse tiles — 0 = off/byte-identical). Existing serialized data assets retain their authored values until deliberately migrated. |
| World bounds | `WorldRadiusVoxels=8192` and `EdgeSealThickness=64` (actor-space XY radial shell; radius 0 = true legacy no-op) |
| Lighting | `bEnableDensityVolume` + DensityVolume* tunables (§3.11 density clipmap / mini-sun shadows) |
| Rendering | `VoxelMaterial` (61) |
| Strates | `Seed` (69), `CurrentSeason=1` (73), `StratePool` (78), `FixedStrates` map (83), `TotalStrates=10` (87) |
| Strates | Optional cooked `Season`; effective seed/spine/radius/season accessors read it when assigned. Otherwise `Seed`, `CurrentSeason=1`, `StratePool`, `FixedStrates`, and `TotalStrates=10` retain the authored path. |
| Carving budget | `MaxModifications=0` (97), `MaxBrushRadius=15` (102), `MaxTotalVolume=0` (107). 0 = unlimited. |
### 3.5 World orchestrator — `Public/VoxelWorld.h` + `Private/VoxelWorld.cpp`
`AVoxelWorld : AActor` — owns everything, drives streaming. Also `FChunkResult` struct
(VoxelWorld.h:35) = async task payload (coord, chunk, meshdata, LOD, **Epoch**).
(VoxelWorld.h:35) = async task payload (coord, chunk, meshdata, LOD, **Epoch**, request timing,
worker-built stream geometry counts/bytes, desired-set epoch and obsolete/cancellation state).
**Owned objects (UPROPERTY):** `Settings`, `Generator`, `Mesher`, `StrateManager`,
`DiffLayer` (VoxelWorld.h:55-78). **Storage:** `Chunks` map, `ChunkMeshes` map,
`ChunkLODs`, `ProcessQueue` (TQueue), `PendingChunkCoord` (TSet) (VoxelWorld.h:85-312).
**Async state:** `bShuttingDown`, `ActiveTaskCount` (atomics), `GenerationEpoch`.
**Async state:** `bShuttingDown`, `ActiveTaskCount` (atomics), `GenerationEpoch`, and the
game-thread `PendingTileCancellation` map whose per-request atomic flags are read-only on workers.
**⭐ Espace ACTEUR / ACTOR SPACE (2026-08-17).** Le champ de densite est defini en espace **acteur** :
`(0,0)` est l origine de l acteur `AVoxelWorld`, pas celle du monde Unreal. `WorldToLocalCm` /
`WorldToLocalVoxel` / `LocalVoxelToWorld` sont la **SEULE frontiere autorisee** entre coordonnees
Unreal et coordonnees voxel. **Invariant verifiable par grep : aucun `/ VOXEL_SIZE` applique a un
parametre nomme `World*` hors de ces trois fonctions.** Avant cette date le plugin etait *a moitie*
actor-relative (biome/deco/eau/volume/debug convertissaient ; le **centre de streaming**, les **ancres**,
les **6 entrees carve/fill**, `GetStrateAtPosition` et `UVoxelAtmosphereManager::UpdateForPlayer` ne le
faisaient pas) — ce qui ne marchait que tant que l acteur restait a l origine.
⚠️ **TRANSLATION SEULEMENT** : tuiles, `ClassifyBox`, fenetre clipmap, culling et distance de streaming
supposent tous des boites **alignees sur les axes du MONDE**. Une rotation ou une echelle non unitaire
casse cette arithmetique *structurellement* — `BeginPlay` loggue une **Error** si on en met une.
| Method | .cpp line | Role |
|--------|-----------|------|
@@ -126,19 +301,26 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
| `OnObjectModifiedInEditor` | 58 | Regenerates when a strate asset is edited (if `bLiveEditStrates`). |
| `EndPlay` | 140 | Sets `bShuttingDown`, **waits for `ActiveTaskCount`→0**, unbinds delegate. |
| `BeginPlay` | 177 | Constructs Generator/Mesher/StrateManager/DiffLayer, wires services, seeds. |
| `Tick` | 220 | `UpdateChunksAroundPosition(player)` + `ProcessPendingChunks()`. |
| `Tick` | 220 | `UpdateChunksAroundPosition(player, heading)` + `ProcessPendingChunks()`; the clean LOD0 latency summary is flushed at `EndPlay`. |
| `GetPlayerPosition` | 231 | Pawn position or zero. (`GetLODForChunk`/`LODToStep`/`IsChunkInRange` removed — dead since the clipmap.) |
| `ProcessPendingChunks` | 301 | Drains ProcessQueue under per-frame budget; applies each via `ApplyTileResult`. |
| `ApplyTileResult` | — | **Shared game-thread apply** for one `FChunkResult` (async drain + sync carve): discards stale epochs, marks loaded, ingests capture, EMPTY releases the tile's existing component (a re-gen can flip content→empty on band change — old geometry must not linger), else `ApplyMeshToTile`. Returns true iff a visible mesh uploaded (counts the budget). Doesn't touch `PendingTiles` (caller's). |
| `GenerateTileResult` | — | **Shared worker-side gen** for one tile (async `LoadTile` task + sync `SyncRemeshTile`): ClassifyTile (T1.d) → `GenerateMesh`/`GenerateSheetMesh` → `BuildTileStreamSet`. Reads Generator/Mesher only → safe on a worker or the game thread; fills `FChunkResult`, no enqueue. |
| `ProcessPendingChunks` | 301 | Drains ProcessQueue under per-frame budget; reads the per-request cancellation token before removing its map entry, drops obsolete/aborted results, then applies each live result via `ApplyTileResult`. |
| `ApplyTileResult` | — | **Shared game-thread apply** for one `FChunkResult` (async drain + sync carve): discards stale generation epochs and keys no longer desired, marks loaded, ingests capture, EMPTY releases the tile's existing component (a re-gen can flip content→empty on band change — old geometry must not linger), else `ApplyMeshToTile`. Returns true iff a visible mesh uploaded (counts the budget). Doesn't touch `PendingTiles` (caller's). |
| `FVoxelStackSampler` / `FScopedVoxelStackRegistration` (`VoxelStackSampler.h/.cpp`) | — | **Diagnostic raw stack sampler, off by default.** A sampler thread captures registered generation workers' stacks (`CaptureThreadStackBackTrace`) every N µs and tags each with the tile's LOD. It writes bounded raw PCs to CSV plus a symbolization-free summary under `Saved/`; symbol resolution is deferred until after process exit so the measured process never initializes/calls DbgHelp for this diagnostic. Lifecycle teardown is serialized and registration TLS is session-tagged. Registration is per tile TASK in `LoadTile` (and the export's tasks), never per sample; disabled costs one relaxed atomic load. Game `-voxel.SampleStacks=<us>`, commandlet `-samplestacks[=us]`. |
| Room-cache performance (2026-09-13): `VF_FindPlayerFitPointForRoomMemoized` / `GPlayerFitMemo` (`VoxelCaveMorphology.cpp`), `voxel.TileCacheWindow` + `VoxelGenLOD::TileOriginVoxels/TileCellsPerAxis` (`VoxelGenerator.h`), `FChunkSDFSpatialIndex` | — | **Player-fit memo:** exact 97-word key, process-wide, `voxel.PlayerFitMemo`. **Tile cache window:** the room cache covers the requesting tile's footprint plus a fixed collect margin. It is set via `TGuardValue` in `GenerateMesh`/classify, and is window-invariant by construction (the margin does not grow with the tile). **LOD-gated:** fused path at LOD ≥ `voxel.TileCacheWindowMinLOD` (3), op-stack at LOD ≥ `voxel.TileCacheWindowOpStackMinLOD` (4); lower LODs keep the 4-chunk worker window, because the tile window slowed LOD0 evaluation ~6%. Strates whose authored terrain ops exceed the proven 256-voxel extent (or are malformed/unloaded) fall back to the legacy window (`VF_ComputeTerrainOpEnvelope`). `voxel.SpatialIndex` is -1 auto (off at fused LOD0, on elsewhere), 0 or 1. **Spatial index:** ordered XY buckets over cache primitives, exact rejection. The `static_assert` in `VoxelStrateTypes.h` ties `VF_STRATE_PARAM_FIELDS` to `FStrateGenerationParams` (83 fields + 8 B padding). |
| `GenerateTileResult` | — | **Shared worker-side gen** for one tile (async `LoadTile` task + sync `SyncRemeshTile`): [ClassifyTile (T1.d) + exact validation, only when `voxel.OuterClassifierMode=1`; **default 0 since 2026-09-13**] → `GenerateMesh`/`GenerateSheetMesh` → `BuildTileStreamSet`; records vertex count and resident stream allocation bytes for the horizon trace. Reads Generator/Mesher only → safe on a worker or the game thread; fills `FChunkResult`, no enqueue. |
| `RecordTileHash` / `WriteTileHashDump` | — | Headless `-voxel.TileHashDump=<absolute path>` parity instrument. Records the exact `FVoxelMeshData` used by the streaming result, including empty/classified tiles, then writes sorted absolute tile keys, LOD step/cells, content-band limits, settings digest context, and the shared `VoxelGeometryHash` at `EndPlay`. |
| `SyncRemeshTile` | — | **INSTANT DIG**: level-0 same-frame re-mesh on the game thread (`GenerateTileResult` + `ApplyTileResult` inline, Cells=CHUNK_SIZE/Step=1 + strate band, no capture). Used for the tile under the brush centre so a carve is visible THIS frame; one full-res gen on the game thread. |
| `UpdateChunksAroundPosition` | 362 | Builds desired set, sorts by distance, loads/unloads, handles LOD changes. **Delta cull**: `BuildDesiredTiles` returns the LEAVERS (stamped `DesiredStamped` map, one sweep) — only those + `TransitionHold` are considered per crossing, not every loaded tile. `BuildDesiredTiles` also applies `RenderDistanceChunks`: the outermost shell widens to cover the distance — as level-MaxClipLevel MC tiles, or (F18 `bFarSheetRing`) as a SHEET ring at level MaxClipLevel+span (covered-check vs the MaxClipLevel box; `VF_OuterShell` shared with `IsTileInClipRange` so the cull sees the same horizon), dz pre-clamped to the vertical band. **§9.3 anchors:** also prunes dead `StreamingAnchors` + detects their chunk crossings → rebuilds the desired set when an anchor moves/(un)registers (`bAnchorsMoved`/`bForceDesiredRebuild`), same cadence as player movement. §8.10. |
| `BuildDesiredTiles` | ~722 | Builds `DesiredSorted`+`DesiredStamped` (player clipmap shells + F18 sheet ring) then `AddAnchorDesiredTiles()` before the leaver sweep. |
| `UpdateChunksAroundPosition` | 362 | Builds desired set, cancels pending keys that left it, sorts from the **true pawn position**, submits the occupied/support LOD0 tile and the next tile along the pawn heading as an absolute `BackgroundHigh` prefix, then loads/unloads and handles LOD changes. **Delta cull**: `BuildDesiredTiles` returns the LEAVERS (stamped `DesiredStamped` map, one sweep) — only those + `TransitionHold` are considered per crossing, not every loaded tile. `BuildDesiredTiles` also applies `RenderDistanceChunks`: the outermost shell widens to cover the distance — as level-MaxClipLevel MC tiles, or (F18 `bFarSheetRing`) as a SHEET ring at level MaxClipLevel+span (covered-check vs the MaxClipLevel box; `VF_OuterShell` shared with `IsTileInClipRange` so the cull sees the same horizon), dz pre-clamped to the vertical band. **§9.3 anchors:** also prunes dead `StreamingAnchors` + detects their chunk crossings → rebuilds the desired set when an anchor moves/(un)registers (`bAnchorsMoved`/`bForceDesiredRebuild`), same cadence as player movement. §8.10. |
| `AdvanceHeadlessCollisionGateStressTest` / `ObserveHeadlessCollisionGateStressTest` / `MaybeFinishHeadlessCollisionGateStressTest` | — | Explicit `-voxel.TestCollisionGateStress=1` harness only. The `GetDensityAt` column at capsule feet defines the local air/solid surface; each frame records actor position, velocity, direct foot tile and gate support readiness, gate state, signed feet clearance, and a ≤0.5-voxel sweep between positions. PASS requires no measured clearance below −5 cm; support submission/readiness, travel, and holds are diagnostics. Result logs include the terminal density column and the first crossing below spawn height. |
| `BuildDesiredTiles` | ~722 | Builds `DesiredSorted`+`DesiredStamped` (player clipmap shells + F18 sheet ring) then `AddAnchorDesiredTiles()` before the leaver sweep; it orders from the true local pawn position and builds `CriticalDesiredTiles` for the occupied/support and heading tiles. Also rebuilds `DesiredTransitionDescendants` (ancestor → desired descendants) for the reconcile pass. |
| `VoxelClipmapDesiredTiles::Build` / `AlignedRingBounds` (`Private/VoxelClipmapDesiredTiles.h`) | — | The pure clipmap selector. **Every finer ring snaps outward to a child-pair boundary** (`AlignedRingBounds`, alignment 2; the last MC ring aligns to the sheet grouping width), so a coarser tile is wholly inside or wholly outside the finer ring: **no two desired tiles of different levels overlap** (a 7-wide odd ring used to leave one half-covered coarse tile per axis side, drawn over the fine one: 635 pairs at the owner config). Cost: LOD0 ring 343 → 512 tiles. Guarded by `VoxelForge.Streaming.ClipmapDesiredTilesNoOverlapCoverage`. Anchor tiles appended later are outside this guarantee. |
| `ReconcileReadyTransitionVisibility` | — | Visibility-only pass while streaming is unsettled: hides a retired tile once all desired tiles over its footprint are loaded, reveals new tiles once no visible retired tile overlaps them. **Indexed** (dyadic ancestors + `DesiredTransitionDescendants`, visible retired tiles collected once per pass): moving p95 32 ms → 0.31 ms. `voxel.MeasureReadyTransitionVisibility=1` logs per-frame samples. |
| `RequestCollisionGateSupportTile` / `UpdatePawnCollisionGate` | — | The gate submits the pawn's level-0 support tile **immediately** as `CollisionCritical`, even before the first desired set exists (the desired rebuild then adopts the same pending key). The gate holds downward Z only, with **no timeout** (the old 2 s release fired at every startup, while the game thread applied nothing for ~2.2 s). |
| `AddAnchorDesiredTiles` | — | **§9.3 multi-anchor:** folds each `FVoxelStreamingAnchor`'s thin level-0 box (`XYRadiusChunks`/`ZBelowChunks`/`ZAboveChunks`) into the SAME desired set (deduped by stamp) so AI/remote players keep collision loaded around them; delta cull releases them on move/unregister. **§9.4:** a tile only a CollisionOnly anchor wants (clipmap didn't stamp it) → `CollisionOnlyTiles` → hidden at apply. Zero cost when no anchors. |
| `ReconcileAnchorTileVisibility` | — | **§9.4:** after each rebuild, toggle `SetVisibility` on ALREADY-LOADED tiles that flipped render↔collision-only (diff `CollisionOnlyTiles` vs prev — bounded, no O(loaded) scan; unhide only if still desired). No-op without CollisionOnly anchors. |
| `RegisterStreamingAnchor` / `UnregisterStreamingAnchor` | — | **BlueprintCallable §9.3:** add/remove an actor as a streaming anchor (`EVoxelAnchorPolicy` CollisionOnly/FullVisual + XY/ZBelow/ZAbove box). Idempotent; forces a rebuild next Tick. §9.4: CollisionOnly tiles cook collision but are hidden (no draw/VSM) unless the player clipmap wants them too. |
| `LoadChunk` | 445 | Budget check → `UE::Tasks::Launch` background gen+mesh; RAII task guard. Worker runs `Generator->ClassifyTile` first (T1.d): AllSolid/AllAir ⇒ skip `GenerateMesh`, tile stays empty (capture tiles always generate). STRATE CONTENT CUT: tiles ≥ `StrateContentCutMinLevel` pass the player-strate band (`MeshBandChunkLo/Hi` → voxels) to `GenerateMesh` + stamp it on `FChunkResult::BandChunkLo/Hi`; band change re-queues via `BandRemeshQueue` (see `UpdateChunksAroundPosition`). TOO-COARSE SKIP: if one cell is taller than the band (`Step > band height` — level ≥7 territory) the tile is enqueued EMPTY without launching a task (cell-granular cut could only render garbage). F18: `Tile.Level > MaxClipLevel` = SHEET tile → routed to `GenerateSheetMesh` (band mid-chunk = strate ref; band unarmed ⇒ empty; MC-ring sampling density, cells capped 128/axis; carries the XY hole `SheetHole*Vox` — hole moves ⇒ overlapping sheets re-queue via `BandRemeshQueue`, see `UpdateChunksAroundPosition`). §8.10. |
| `UnloadTile` | — | Clears tile state; the component is PARKED in the pool (T2.c), not destroyed. |
| `LoadTile` | 445 | Budget check → `UE::Tasks::Launch` background gen+mesh; RAII task guard. Worker runs `Generator->ClassifyTile` first (T1.d): AllSolid/AllAir ⇒ skip `GenerateMesh`, tile stays empty (capture tiles always generate). It carries `GenerationEpoch`/desired epoch and checks `bShuttingDown`, generation pause, and its read-only cancellation token between expensive stages; obsolete work returns through `ProcessQueue`. STRATE CONTENT CUT: tiles ≥ `StrateContentCutMinLevel` pass the player-strate band (`MeshBandChunkLo/Hi` → voxels) to `GenerateMesh` + stamp it on `FChunkResult::BandChunkLo/Hi`; band change re-queues via `BandRemeshQueue` (see `UpdateChunksAroundPosition`). TOO-COARSE SKIP: if one cell is taller than the band (`Step > band height` — level ≥7 territory) the tile is enqueued EMPTY without launching a task (cell-granular cut could only render garbage). F18: `Tile.Level > MaxClipLevel` = SHEET tile → routed to `GenerateSheetMesh` (band mid-chunk = strate ref; band unarmed ⇒ empty; MC-ring sampling density, cells capped 128/axis; carries the XY hole `SheetHole*Vox` — hole moves ⇒ overlapping sheets re-queue via `BandRemeshQueue`, see `UpdateChunksAroundPosition`). §8.10. |
| `UnloadTile` | — | Clears tile state; marks any matching generation request obsolete without removing its pending slot, so a late result cannot clear a replacement request; the component is PARKED in the pool (T2.c), not destroyed. |
| `ApplyMeshToTile` | — | Upload geometry. One component per tile (clipmap keeps count low; supersedes the old region batching); worker-built streams (T1.f) → `CreateSectionGroup(MoveTemp)`. Reuses the component's existing `URealtimeMesh` (no per-apply mesh alloc). **F17: two polygroups** (0 ground / 1 sky-cap, per-triangle class from the mesher) → RMC auto-section per non-empty group; slot 0 = override/default material, slot 1 = `CeilingMaterial` (fallback ground); config gated by `FChunkResult::bHasGroundTris/bHasCeilingTris`. Takes `FChunkResult&`; strate lookups clamp Z into `Result.BandChunkLo/Hi` (strate content cut) — ground at clamped bottom chunk, cap at clamped top (mid = gap fallback). Collision level-0 only (T1.c) both groups; shadow per SECTION: ground casts at level≤1, cap never. **§9.4:** `SetVisibility(false)` when the tile is in `CollisionOnlyTiles` (anchor-only, hidden — collision still cooks). §8.10 + ARCHITECTURE SurfaceWorld row. |
| `AcquireTileComponent` / `ReleaseTileComponent` | — | **T2.c component pool** (`TileComponentPool`, bounded): park on unload (geometry+collision stripped, hidden, stays registered), pop on apply — no `NewObject`/`RegisterComponent`/GC churn during travel & regen. §8.10. |
| `GetStrateAtPosition` | 965 | Gameplay query → strate index. |
@@ -147,30 +329,33 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
| `CarveAtPosition` / `FillAtPosition` | 691 / 709 | Build `FVoxelModification` → `ApplyModification`. |
| `ApplyModification` | ~1581 | Single funnel for all brushes: DiffLayer → **sync-remesh the brush-centre level-0 tile** (`SyncRemeshTile`, instant hole; skipped if that tile is mid-gen — would race a stale in-flight result) → `RemeshDirtyChunks(..., excludeCenter)` for the neighbours → `RemoveDecorationsInSphere`. |
| `ClearAllModifications` | 726 | Clears diff layer, regenerates. |
| `ChangeSeed` | 740 | **Season reset**: new seed everywhere, clear diffs, bump season, reload. |
| `GetCurrentSeed` / `GetCurrentSeason` | 784 / 789 | Accessors. |
| `ChangeSeed` | 740 | **Legacy season reset**: new seed everywhere, clear diffs, bump season, reload. Rejected while a cooked season owns the seed. |
| `GetCurrentSeed` / `GetCurrentSeason` / `GetCurrentSeasonContentHash` | — | Effective cooked-or-legacy identity; the hash is the join-time stale-season comparison value. |
| `RemeshDirtyChunks` | 798 | Queue loaded level-0 dirty tiles onto `DirtyRemeshQueue` (async re-mesh, no pop) + `MarkDirtyVoxelBox` the volume. Optional `ExcludeTile` = the sync'd centre. Drained FIRST in the submit loop at **BackgroundHigh** (ahead of streaming/band) so a dig never waits behind streaming; in-flight tiles stay QUEUED (not dropped) so a stale pre-carve result is corrected once it lands — fixes "hole shows up a beat late / not until I move". |
> **Game-thread profiling (Perf):** `AVoxelWorld::Tick` and its sub-steps are wrapped in `TRACE_CPUPROFILER_EVENT_SCOPE` — `VoxelForge_Tick / UpdateChunks / BuildDesiredTiles / CullTiles / SubmitTiles / ProcessPending / ProcessUnload / UpdateDecorations / UpdateWater`. Capture a `Count/Incl/Excl` Insights timer export and read the `Excl` column to see which step owns the per-frame cost (the actor tick shows as `BP_VoxelWorld_C` if subclassed in BP). `VoxelForge_ClassifyTile` (T1.d) / `VoxelForge_GenerateMesh` + `VoxelForge_BuildStreams` are worker-side (off the frame): the RMC `FRealtimeMeshStreamSet` is now built on the gen worker (`BuildTileStreamSet`) and carried on `FChunkResult::Streams` (TSharedPtr), so `ApplyMeshToTile` is game-thread-cheap — just material/ceiling resolve + `CreateSectionGroup(MoveTemp)`. See ARCHITECTURE §8.10 "Worker-built StreamSet (T1.f)".
### 3.6 Density generator — `Public/VoxelGenerator.h` + `Private/VoxelGenerator.cpp`
`UVoxelGenerator : UObject` — lightweight; holds `Seed`, and injected services
`StrateManager` + `DiffLayer` (both nullable). This is **where terrain shape lives.**
`UVoxelGenerator : UObject` — lightweight; holds `Seed`, a process-unique
`DensityCacheOwnerId`, and injected services `StrateManager` + `DiffLayer` (both nullable).
This is **where terrain shape lives.**
| Symbol | .cpp line | Role |
|--------|-----------|------|
| `UVoxelGenerator` / `DensityCacheOwnerId` | — | Constructor allocates a process-unique integer identity (relaxed atomic, once per object). `GetDensityAt` includes it in the `CP_*` thread-local key, preventing a worker from serving another generator/world's params, biome context, `CP_UseOpStack`, or stack when `(ChunkCoord, LayoutVersion)` happens to match. Hot-path cost: one `uint64` compare per voxel. Scope is deliberately only the proved `CP_*` path. |
| `FractalNoise3D` (static) | 25 | fBM (layered Perlin). |
| `RidgedNoise3D` (static) | 55 | Ridged multifractal — craggy. |
| `CellularNoise3D` (static) | 101 | Worley/cellular — grotto/scallop. |
| `ApplyBoundarySeal` (static) | 170 | Solidifies strate top/bottom shells. |
| `ApplyPassageCarving` (static) | 197 | Punches passages/elevator through the seal. |
| `InitializeSettings` | 211 | Copies seed from settings. |
| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. |
| **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. |
| `InitializeSettings` | 211 | Copies the effective seed, spine radius, and global XY world-bound settings from `UVoxelSettings` (the season owns all three when assigned); radius 0 preserves the unbounded field. |
| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, materialises a cooked-season recipe on the existing per-chunk refetch when present, dispatches, then adds disturbances/diff. Its `CP_*` state is keyed by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; workers copy immutable recipe/vector data and never read editor composer state. |
| **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. ⚠️ Takes **required** `ParamsFingerprint` + `LayoutVersion` since the AUDIT §C2 fix (2026-07-28) — they go into the SDF cache key so a chunk can no longer be evaluated against a neighbour's rooms. Callers compute the CRC **once per chunk** (`CP_TunnelFP`), never per voxel. The optional `voxel.WormBlockSkip` path (default 0) uses `VF_TryGetWormBlockSkip` + a worker-local 4³ lattice-block cache to prove `|N1| >= WormThreshold` and remove both worm noise calls only; it never changes `NetworkMask` or the fallback field path. |
| **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. |
| `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. |
| `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/sheets/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in `SurfaceDensityFromColumn`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. §8.14. |
| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. §8.10. |
| `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/sheets/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in `SurfaceDensityFromColumn`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. The field-preserving LOD0 pass removes only needless temporaries/default construction; every output remains bit-identical. §8.14. |
| `VF_BuildOpStackForChunk` (file-static) | — | **The archetype → stack mapping, written down once.** `GetDensityAt` and `ClassifyTile` both call it; params are passed in, never fetched here. A second copy would be the worst bug available in this file — a tile skipped on the verdict of a stack that is not the one producing its density is a hole. Returns false (⇒ caller falls back to the `switch`) for an unported archetype, missing params, or a **degenerate strate**, since five archetype functions early-out to air there and the stack deliberately has no such early-out. `Refs.Surface == nullptr` makes it refuse SurfaceWorld, which is how `ClassifyTile` keeps its own exact-lattice proof. |
| `ClassifyTile` | — | **T1.d trivial-tile reject. ⛔ Not called by streaming by default since 2026-09-13** (`voxel.OuterClassifierMode=0`; net-negative in game, see ARCHITECTURE §8.10). Cooked/editor recipes now build the exact recipe stack and call its proved `ClassifyBox`; a tile crossing a slot/gap, any diff, or any disturbance returns `Mixed`. This replaces the old global `HasComposerRecipeOverride()` force-to-Mixed. Native cave/surface/gap proofs and all §8.10 caches remain unchanged. |
| `SampleRelief` / `SampleMoisture` | — | Climate fields (pure XY, [0,1]). Relief = shared source of truth for the relief map M. §8.14. |
| `SampleBiomeAt` | — | Warped-Voronoi + climate biome query (dominant + neighbour + weight). Reference used by the preview bake + `GetDominantBiomeAt`. §8.14. |
| `ResolveBiomeSampleAt` / `RebuildBiomeGrid` | — | Hot-path biome resolve (FBiomeSample) via a box-validated per-chunk cell-grid cache. Bit-identical to `SampleBiomeAt`. §8.14, §8.10. |
@@ -183,12 +368,73 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
> memoizes the strate index per (chunkZ, `GetLayoutVersion()`); `thread_local` per-cell lattice
> bakes cover slab columns (`GetSlabDensity` step 4), maze open edges, vertical shafts +
> cross-connectors, floating-island constants, and the disturbance chasms/bridges/ridges; worm
> tunnels short-circuit the 2nd Perlin when N1 ≥ WormThreshold (N2 ≥ 0 ⇒ can't carve). Room
> tunnels short-circuit the 2nd Perlin when N1 ≥ WormThreshold (N2 ≥ 0 ⇒ can't carve). With
> `voxel.WormBlockSkip=1`, a worker-local cache proves whole 4³ lattice blocks only when the
> actual 3D Perlin bound (`L = 8.118988160`, including `VOXEL_NOISE_SCALE`) plus a `1e-3`
> output-rounding margin clears the threshold; failed proofs use the original per-sample path.
> Room
> shapes are pre-baked in `FCachedRoom` (§3.7). **T2.b:** per-voxel fractal call sites take their
> octave count through `VoxelGenLOD::Eff(N)` (VoxelGenerator.h) — a `thread_local` bias set per
> tile by the mesher drops tail octaves on coarse tiles (opt-in `LODOctaveDrop`, default 0 = off);
> XY-field noise (heightfield/ceiling/relief/moisture) deliberately stays un-biased. §8.10.
### 3.6b Strate measurement — `Public/VoxelStrateMeasure.h` + `Private/VoxelStrateMeasure.cpp`
Headless, read-only Tier 2 measurement pass. `VF_MeasureStrate` samples one strate between a
boundary-seal-derived (or explicitly overridden, including explicit zero) interior margin into a bounded grid.
When both `CoverPointA` and `CoverPointB` are set, the XY window is their AABB expanded by
`CoverMarginVoxels`; otherwise it is the legacy square centered on `CenterXY` with
`RadiusInVoxels`. Origin-rooted callers set `bIncludeOriginInCoverWindow` to expand that fitted
AABB to include `(0,0)` before the `MaxCells` check; an over-cap route window is refused rather
than cropped back to a mouth-only box.
It performs one deterministic 6-connected air flood fill, and derives fractions, components,
walkability, feature scale, and clearance from that grid. It then performs one bounded 4-neighbour
flood fill over the projected XY columns that contain a walkable surface; this is a floor-continuity
indicator, not another source-density sample or a 3D route proof. Metrics report the resolved margin and
the inclusive/exclusive voxel Z window used, the exact sampled dimensions/bounds, and the largest
component's deterministic lowest-cell representative point, cell count, and count of components
holding at least 1% of the air. The flood fill also retains component cell counts in discovery order,
so diagnostics can classify small non-largest components without a second source sample.
Callers may optionally receive that exact polarity grid and scalar values as `FVoxelStrateSampleGrid`; the
editor-only composer preview consumes this hand-off rather than sampling the world again. The filled view uses
the captured `Air` polarity, while the paired density=0 contour uses the captured scalar field directly (no blur
of the coarse raster). Capture refuses a grid over `MaxCells` before allocating it; ordinary metric callers do
not retain either buffer.
`VF_AreConnected` recovers a deterministic BFS parent path from the same kind of grid and checks
candidate routes at full voxel resolution before returning `Connected`. When a candidate is
refuted, its specific coarse cell edge is added to a deterministic array blocklist and BFS is
retried up to `MaxRouteRetries` (default 16). It returns an explicit `EVoxelConnectivityResult`;
a solid endpoint cell is repaired by snapping to the nearest air cell among its 26 neighbours
when possible, and reports the snap through its out flags. A missing coarse route after edge
exclusions returns `NotConnectedAtThisResolution`: that is evidence only at the grid's
resolution, because a corridor thinner than `SampleStep` is invisible. Exhausting the retry
budget (or finding a refutation in an endpoint segment with no coarse edge to exclude) returns
`CoarseLiedBudgetExhausted`, which is unknown rather than disconnected. Every `Connected` result
has a full-resolution air-verified route, and the query reports the alternate route retry count.
No UObject state, cache, actor, world, or PIE is required.
| Symbol | Role |
|--------|------|
| `FVoxelStrateMeasureSettings` / `FVoxelStrateMetrics` | Plain settings/result structs for bounded strate sampling and derived measurements; callers may override the interior margin (including zero to sample the seal) or fit the XY window to two points with a margin, and results identify the resolved window/dimensions/Z range, deterministic air-component facts, walkable floor-area columns/fraction, projected surface components/share, and median clearance. |
| `FVoxelStrateSampleGrid` | Optional one-pass `density > 0` `Air` polarity plus exact scalar `Density` capture; player-fit walks may also export the exact `PlayerFitMask` and count. Bounded by `MaxCells`, shared by explorer render/walk/export, and never retained on ordinary metrics calls. |
| `VF_MeasureStrate` | One-grid/one-flood-fill strate metrics; resolves either the legacy centered square or the two-point fitted AABB, and refuses invalid bounds or a grid over `MaxCells`. |
| `VF_AreConnected` | Coarse 6-connected BFS plus deterministic blocked-edge retries, with a full-resolution air recheck for every candidate route and explicit endpoint-solid, out-of-window, `NotConnectedAtThisResolution`, `CoarseLiedBudgetExhausted` (unknown), and connected outcomes; diagnostics include endpoint component facts and the retry count from that same grid. |
| `VF_DiagnoseConnectivity` | The same query plus each mouth's air-component size/share and exact geometric distance to the nearest cell of the other component; proximity is measured in coarse-cell coordinates and does not claim a route through solid. |
| `FVoxelPlayerFitWalkReport` / `VF_MeasurePlayerFitWalkWithSampler` | Editor-only experiential instrument over the existing exact `VF_` capsule/floor-fit mask: deterministic graph-walk distance, dead ends, reachable fit volume, and a labelled narrow-gap proxy; optionally exports the one sampled grid/mask for other explorer consumers; no second movement model. |
`VoxelForgeExploreCommandlet` builds a transient, fixed-definition world and calls the production
`UVoxelGenerator::GetDensityAt` and `UVoxelMarchingCubesMesher::GenerateMesh` paths. `walk` (or the
private seed walk used by render-only runs) captures one exact sampled grid and player-fit mask;
render and export reuse that hand-off. `render` calls the canonical 32³ mesher in a deterministic
tile grid (four tiles per axis for the 128³ default), merges the region once, builds a deterministic
CPU BVH, and rasterises eight viewpoints from the mesh without per-pixel density calls. `export`
uses that same canonical aggregate for metre-space OBJ plus `manifest.json` (canonical UVs remain
voxel-space). Output JSON/manifest are emitted by fixed-order writers and compared with a second
serialization before they are written. The commandlet keeps `WorldRadiusVoxels=0`, does not touch an
authored asset or diff layer, and enforces the default 25-minute / maximum 30-minute wall-clock
budget, reporting partial artifacts and completed modes on truncation. `-gameconfig=1` opts into the
authored settings' skirts/LOD octave drop and permits export-only parity of a single-strate asset;
the historical interior-slot requirement remains in force for walk/probe runs. `VoxelGeometryHash.h`
is the shared digest used by `Tools/VoxelForgeTest.ps1 -Scenario parity`.
### 3.7 Cave morphology (SDF rooms/tunnels) — `Public/VoxelCaveMorphology.h` + `.cpp`
Header is rich with inline docs. Two namespaces + a per-chunk cache system.
@@ -205,6 +451,31 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system.
| `EvaluateSDFCached` | 757 | **Phase 2** (per voxel): SmoothMin over cached rooms/tunnels; returns nearest room idx for terrain-op lookup. **Signature changed (perf pass 2): `RoomShapeVariety` param REMOVED** — the shape roll + capsule trig are pre-baked into `FCachedRoom` (`ShapeType/ShapeA/ShapeB/ShapeR`) by `BuildChunkCache`, bit-identical. |
| `EvaluateSDF` | 738 | Convenience wrapper (builds temp cache) for one-off queries. |
`MakeStrateSeed` (h:~477) is the shared pure world-seed/strate-index salt used by both
`BuildChunkCache` and the TunnelNetwork/Underwater destination landing queries.
`VF_SuggestLandingPoint` (h:~550, implemented in `VoxelCaveMorphology.cpp`) is the pure player-fit
landing-site query: it evaluates the local archetype source through the same capsule/support
contract as the measurement pass — support within step height, walkable slope, and full capsule
clearance. Room queries use a room-floor core, slabs keep their requested XY, and the sparse
sources use bounded feature cores: a horizontal lattice corridor (one Maze cell), a selected
VerticalShafts tree shaft plus deterministic ledge-side offsets (up to two local shaft spacings at
the 0.6 site density), or a validated blob top (one IslandSpacing). It is intentionally not a
flood fill: the pure query can prove a local pose, not a large component. The
VerticalShafts result is therefore on the drainage tree, not merely in an open roughness disk.
It returns `false` when those feature-bearing conditions cannot be proven inside the supplied budget;
SurfaceWorld remains refused because its production height can depend on manager-owned biome/per-column
context. It never touches a generator, operator stack, manager, cache, or mutable state.
`FVoxelPassageLanding`, `VF_BuildPassageLanding`, `VF_EvaluatePassageLandingSDF`, and
`VF_IsPassageLandingFloor` (h:~56/~189, implemented near `.cpp:2059`) turn each inter-strate mouth
into a deterministic rounded room plus a hard support slab. The room is sized from the player
capsule (minimum 12 voxels / 3 m flat floor, 12 voxels / 3 m clear height, and at least 14 voxels /
3.5 m authored width); the `(0,0)` origin landing is enlarged for the reserved future shaft, while
inter-strate mouths remain local rooms and deliberately have no landing-to-root connector road.
The manager's shared thread-local passage shortlist serves tube, room, and floor evaluation; no
landing search occurs per voxel. `GeneratePassages` stores the full descriptors and the
order-independence test compares them bit-for-bit.
Performance note (h:209-220): caching rooms/tunnels once per chunk instead of per
voxel is the single biggest CPU win.
@@ -217,7 +488,7 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system.
| `EVoxelNoiseType` | 99 | FBM/Ridged/Mixed/Cellular. |
| `ECaveGeneratorType` | 146 | TunnelNetwork / FlatPlain / CrystalChamber. |
| `EVoxelStrateTransition` | 183 | Gradient / Hard / Interleaved boundary blends. |
| **`FStrateGenerationParams`** | ~350 | The giant TunnelNetwork param bag (rock, worms, rooms, tunnels, warp, roughness, all terrain-op transport fields, boundary seal). `Lerp()` static blends two sets at boundaries — it expands the **`VF_STRATE_PARAM_FIELDS` X-macro** (defined just above the struct): **adding a field to the struct? add it to that list** or blends silently reset it to default. |
| **`FStrateGenerationParams`** | ~350 | The giant TunnelNetwork param bag (rock, worms, rooms, tunnels, warp, roughness, live terrain-detail fields, dead terrain-op transport slots, boundary seal). `Lerp()` static blends two sets at boundaries — it expands the **`VF_STRATE_PARAM_FIELDS` X-macro** (defined just above the struct): **adding a field to the struct? add it to that list** or blends silently reset it to default. |
| `FStrateTerrainOpEntry` | 965 | Soft-ptr to a terrain op + Weight + Probability. |
| **`FSlabGenerationParams`** | 1019 | Floor/ceiling heights, roughness, columns, seal — for slab generators. |
| **`FPlacementProfile`** | ~1747 | **Shared placement vocabulary** for every scatter primitive (`FStrateDecoration`, `FStrateLandmark`, coming `FStrateSetPiece`): spawn (ActorClass/InstancedMesh), Filter gates (surface/slope/overhang/water/RequiredBiome + **F7 awareness `Conditions[]`** — `FTerrainCondition` relief/moisture/biome-border predicates, AND-ed, evaluated by `Generator::EvaluateTerrainConditions`), Transform (align/offsets/RotationOffset+RandomRotation/scale), Render (cull/shadow). Each primitive embeds it as `Profile` + keeps only its own DISTRIBUTION fields. Per-primitive defaults set in each struct's ctor (deco: scale 0.8-1.2 + RandomRotation.Yaw=360; landmark: Ceiling + no align). |
@@ -230,27 +501,37 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system.
`TransitionType`(79)/`TransitionBlendChunks`(89), `GeneratorType`(102),
`GenerationParams`(113), `SlabParams`(124), `Biomes[]`+`BiomeMapParams` (the biome list +
field tuning — empty ⇒ unchanged world, §8.14), `TerrainOperations`(147), visuals/fog/light,
content lists, audio, `GameplayTags`(223). EditConditions show/hide param groups by generator type.
content lists, audio, `GameplayTags`(223), `bUseOperatorStack` (the OPSTACK A/B opt-in — only bites
if the archetype is in `UsesOperatorStackForChunk`'s ported list). EditConditions show/hide param
groups by generator type.
**`Public/VoxelStrateManager.h` + `.cpp`** — `UVoxelStrateManager : UObject` (h:108).
Maps depth→strate at runtime; owns passages.
- `FVoxelPassage` (h:39): endpoints, radius, type, control points.
- `FVoxelPassage` (h:39): standing endpoints, radius, type, wandering control points, and
upper/lower local landing descriptors (room, floor, and door; no root connector).
- `FStrateSlot` (h:84): definition + chunk-Z range + index.
| Method | .cpp line | Role |
|--------|-----------|------|
| `Initialize` | 10 | Builds the stacked layout from settings+seed (fixed slots + shuffled pool), then `GeneratePassages`. |
| `GeneratePassages` | 146 | Deterministic passages between consecutive strates (per-type control points). |
| `EvaluateModifierSDF` | 357 | SDF of passages at a point (for carving). Per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`. §8.10. |
| `Initialize` | 10 | Keeps the existing sequencing through `GeneratePassages`, but selects one slot source: a validated cooked season (exact ordered bounds/vector/recipe) or the unchanged authored fixed+sorted/shuffled pool. Assigned-invalid seasons fail closed. Authored source definitions are duplicated as content bags; generated slots use deterministic C++ defaults and zero auto-passages because schema 2 does not store passage configuration. |
| `GeneratePassages` | 247 | Deterministic passages between consecutive strates (per-strate `PassageConfig::Style` control points; auto passages retain the existing `EVoxelPassageType` default); placement and shape values are independently salted hashes of seed + boundary slot + connection index. Each mouth independently queries its own strate's pure player-fit `VF_SuggestLandingPoint` with its contract seed (cave room salt, otherwise world seed), may move within its archetype-specific budget, then receives a body-sized local room/floor. The origin landing is reserved for the future shaft; no landing-to-root road is generated. Unsupported/no-fit answers preserve the old random reach; a deterministic level dog-leg is inserted when the final ramp needs it to stay ≤44°. All descriptors and bounds are generated without reading another passage. |
| `EvaluateModifierSDF` | 357 | SDF of passage tubes plus local landing rooms at a point. A per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`; the same cache serves floor membership. `FPassageEvaluationCache::FloorProjections` reuses native/generic floor projections and walkable-air results once per `(sample, passage)` using exact double XYZ equality and unchanged float outputs; `voxel.FloorRound1PassageProjectionCache=0` disables only this A/B cache. §8.10. |
| `ApplyPassageModifier` | 1319 | Shared legacy/op-stack passage post: carves tube and local landing air through the idempotent landing primitive, then restores the local landing support slab in internal positive-solid density. |
| `VF_ApplyPassageLandingCarving` | — | Idempotent density-independent landing-air threshold shared by the structural pass and the post-disturbance MC backstop; keeps legacy and operator-stack results bit-identical. |
| `ApplyPassageLandingFloorMC` | 1347 | Reasserts the same support slab after MC-space disturbances and before the final XY edge seal. |
| `AnyPassageNearBox` | — | Conservative sphere-vs-AABB test of every passage's bound against a voxel box (+carve blend pad). Per TILE (ClassifyTile guard), never per voxel. |
| `AnyPassageLandingFloorNearBox` | — | Conservative local-landing-floor AABB guard. It kills the `AllAir` hypothesis wherever a proved landing floor can occur; the op-stack returns `Both` for the same box. |
| `FindSlotIndexForChunkZ` | 427 | Z → layout index. |
| `GetStrateAt` / `GetStrateIndex` | 443 / 455 | World-Z queries. |
| `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize`). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so editor rebuilds never serve stale data. |
| `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize` and by the editor composer override). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so live changes never serve stale data. |
| `GetStrateForChunk` | 466 | Chunk → definition. |
| `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. |
| `UsesOperatorStackForChunk` | 559 | Chunk → should `GetDensityAt` take the operator stack? `bUseOperatorStack` on the definition **AND** archetype in the ported list — **now all 8 of 8** (Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork, Underwater). **That list is written down here and nowhere else.** With every archetype ported the flag is now the *only* thing that decides the path, so ticking the box is no longer a no-op anywhere — it is a real switch onto the operator stack for that strate. |
| `GetRecipeForChunk` | — | Runtime immutable recipe/vector/strate-seed copy for cooked seasons; editor slot overrides use the same worker hand-off. |
| `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). |
| `GetBiomeContextForChunk` | — | Flatten the strate's `Biomes[]` + `BiomeMapParams` into a POD `FBiomeContext` for the biome field. Empty ⇒ biomes disabled. §8.14. |
| `GetGenerationParams` | 515 | **Blended** TunnelNetwork params (handles Gradient/Hard/Interleaved transitions). |
| `BuildParamsFromDefinition` (static) | 771 | Base params + merge all referenced terrain op assets. The one place ops fold into params. |
| `SetComposerOverrideForStrate` / `GetComposerOverrideForChunk` | editor-only | Temporary density-only candidate overlay for one existing slot. Copies immutable params/recipe into the worker refetch path, leaves layout/content/passages intact, and bumps `PassagesVersion` to invalidate generator memos. |
| `BuildParamsFromDefinition` (static) | 1399 | Returns the definition's base `GenerationParams` only. Terrain-op assets are passed separately to `BuildChunkCache`, which applies one selected op per room and pre-bakes Column/Pit/Chimney features. |
**`Public/VoxelTerrainOpDefinition.h` + `.cpp`** — `UVoxelTerrainOpDefinition : UPrimaryDataAsset`
(h:67). One asset = one terrain op. `EVoxelTerrainOpType` (h:36): Terrace, LayerLines,
@@ -263,6 +544,10 @@ border warp+blend / climate field freqs), `EBiomePreviewChannel` (preview-bake s
`FVoxelBiomeQuery` (BlueprintType result of `GetBiomeAtWorldLocation` — dominant/neighbour asset,
climate, blend weight, deco count), and plain runtime PODs `FBiomeResolved` / `FBiomeContext` /
`FBiomeSample` / `FChunkBiomeCache` (the box-validated per-chunk grid cache). See §8.14.
`FChunkBiomeCache::Invalidate()` (added 2026-07-27, AUDIT C2) — force a rebuild when the strate
layout version moves. The validity BOX says nothing about the `FBiomeContext` the cells were
classified against, so after a `RebuildStrates` the grid is stale even though the box still covers
the query. Called by all four callers on a `GetLayoutVersion()` change.
**`Public/VoxelBiomeDefinition.h` + `.cpp`** (NEW) — `UVoxelBiomeDefinition : UPrimaryDataAsset`.
One asset = one biome: identity + `DebugColor`, climate placement box (`ReliefMin/Max`,
@@ -270,6 +555,118 @@ One asset = one biome: identity + `DebugColor`, climate placement box (`ReliefMi
atmosphere override, `WaterMaterial`, `MaterialPaletteIndex` (F6 — baked to vertex colour, §8.15)), `GameplayTags`. Referenced from
`UVoxelStrateDefinition::Biomes[]`. Generator-agnostic (surface biomes now, cave biomes later). §8.14.
**`Public/VoxelStrateComposer.h` + `Private/VoxelStrateComposer.cpp`** (Tier 4a/4b + Tier 5) — offline
`FVoxelStrateCorpus`, `VF_RollStrateParams`, and `VF_RollStrateStructure`. `LoadFromAssetRegistry` enumerates every project
`UVoxelStrateDefinition` through the Asset Registry, excludes `Saved/Autosaves` and `Saved/Cooked`
copies, and adds one `VoxelStrateTypes.h` default vector for each of the eight exact archetypes.
The corpus is grouped by exact `ECaveGeneratorType`: the 2026-09-04 run loaded **4 project
vectors + 8 defaults = 12 members** in **8 groups**. Sibling families use their native structs
(`FSlabGenerationParams`, `FMazeGenerationParams`, `FSurfaceGenerationParams`,
`FVerticalShaftParams`, `FFloatingIslandParams`); no cross-archetype blend is attempted.
Spreads are measured per group, including the plain-native tunnel fields. The fixed-lattice audit
found **23 live terrain-detail fields** (Terrace, LayerLines, Overhang, Ribbing, Cliff, Scallop,
Arch, Dome, Pinch) and **11 dead direct transport fields** (Column, Pit, Chimney); only the latter
remain excluded. Live fields receive one matching `UVoxelTerrainOpDefinition` header-default sample
in addition to authored strate values, so an all-default strate corpus still has useful activation
spread. Rolls are pure in `(corpus contents, seed, index)`: weighted 2–3-parent same-archetype
selection → `FStrateGenerationParams::Lerp` or reflected native-family blend → ±15% of measured
field range jitter (or the season-zero ±25% own-magnitude bootstrap for near-zero ranges) →
editor-reflection clamps → ordered-pair repair. Bool
`bTunnelsFlowTowardOrigin` inherits from the dominant parent; integer/enum SNAP fields are not
jittered. Normal runtime generation never calls this API; the editor-only AVoxelWorld bridge does so
only from its PIE button. The settings audit found **one unique path**,
`/Game/VoxelForge/DA_Strate3`, which explains the old one-vector corpus; the project assets were
never in that pool.
`FVoxelOpStackRecipe` is the serialisable Tier 4b manifest: root polarity, root/source/conversion
class IDs, an ordered modifier ID list, and one native parameter-family ID per entry. Posts are not
represented in the manifest; `VF_BuildStackFromRecipe` appends spine, vertical seal, passage carve,
and XY edge seal in the fixed order. The roller derives modifier legality from the op channel and
resource declarations, rolls 4–8 unique modifiers, and validates the recipe before materialising
it. **Rolling is editor-only; `VF_BuildStackFromRecipe` is runtime evaluation and is used by cooked
seasons.** `RequiredResources` / `ProvidedResources` close the room-state hole that channel masks alone
could not express. `VoxelForgeComposerStructureRollTest` rolls 64 candidates, measures them through
the offline sampler, and brute-forces every uniform box verdict from the novel stack itself.
Tier 5 adds `FVoxelStratePromotableRecord`, `FVoxelStratePromotionPolicy`, and
`FVoxelStratePromotionBatchResult`. `VF_SaveStratePromotedRecords` writes a sorted schema-2 JSON
corpus store containing the complete six-family parameter vector, recipe, archetype, gate evidence,
metrics, season/seed, and input corpus hash; `VF_SaveStrateSeasonManifest` writes one diffable JSON
summary beside it. `FVoxelStrateCorpus::LoadPromotedRecords` re-verifies every record through the
world-specific `IVoxelStratePromotionVerifier` before admission, remeasures project/default members
when that verifier supplies the optional audit, and tags membership `project` / `default` /
`promoted`. `VF_SelectStratePromotions` uses normalized measured-metric distance (threshold 0.20),
the existing corpus contents hash, deterministic RecordId ordering, and a six-record season cap.
The promotion test simulates five seasons and deliberately corrupts one stored metric to prove the
loader replaces it with a fresh measurement.
`EVoxelStrateCorpusFreeSamplingMode`, `VF_RollStrateParamsCorpusFree`, and
`VF_ValidateStrateCorpusFreeConstraints` are the offline corpus-free control/experiment. They never
read a corpus entry or strate asset: `NaiveUniform` rolls every live scalar independently inside a
finite, code-declared envelope, while `ConstraintSampled` draws geometry quantities and derives
dependent quantities from the generator equations before applying the envelope as a final safety
net. `VoxelForgeComposerCorpusFreeTest.cpp` compares both arms with today's corpus blend using the
same structure recipe, fixed measurement window, one arrival/departure law, and box-verdict brute
force. The requested 256×3 and 64×3 runs were abandoned before aggregate output because the fixed
8M-cell law/box workload was too slow; the completed equal-arm run is 16 candidates per arm and
22.995 s inside the test. Its final result is **13/16 corpus-blend survivors, 7/16 naive, and
7/16 constraint-sampled**. Survivor quality is not equivalent: corpus survivors averaged
**0.066071 walkable / 92.307693 voxel feature scale**, versus **0.012566 / 64.571426** for naive
and **0.012851 / 31.428572** for constrained. All three arms had **0 box-verdict violations**;
proved boxes / checked lattice voxels were **196 / 260,876**, **310 / 412,610**, and
**262 / 348,722** respectively.
Public/VoxelWorld.h + Private/VoxelWorld.cpp — ApplyComposerCandidate exposes the four
Live Edit|Composer properties and the CallInEditor button. It preflights the candidate, pauses
generation before installing the manager override, and calls the existing epoch-aware full reset.
The post-apply check compares eight densities against the shared native/recipe stack where the live
world has no extra diff, disturbance, or biome context. It reports SKIPPED rather than comparing
unlike fields.
**`Public/VoxelStratePreview.h` + `Private/VoxelStratePreview.cpp`** — editor/automation-only PNG
renderer and self-contained contact sheet. It consumes `FVoxelStrateSampleGrid` from the measurement
pass, emits deterministic centre-Y XZ and data-selected XY slices via `IImageWrapper`, and shows filled-cell
and scalar density=0 contour views side by side without blurring the sampled raster. Each image is capped at
512 pixels (including its scale footer). `FVoxelStrateFinePreviewSettings` is a separate caller-selected
fine pass: the composer selects only survivors at step 1, radius 64 (a 128×128 XY ROI), and `MaxCells=4,000,000`;
the caller centres it on each coarse survivor's `LargestComponentPoint` and the writer labels it
“largest open space” with its exact ROI and step; cap refusals and blank fine ROIs are recorded explicitly
instead of showing empty evidence. The ordinary 64-card sheet remains sorted by survivor status then
descending corpus-centroid distance. The `VoxelForge.Composer.Showcase` test additionally writes an
alphabetized one-card-per-archetype PIE hand-off page with the Part A survivor distributions, exact seed /
candidate / target-slot values, and no runtime generation hook.
**`Public/VoxelSeasonManifest.h` + `Private/VoxelSeasonManifest.cpp`** — Tier 4c's offline composer
and runtime-readable schema-2 artifact. `VF_ComposeSeason` generates a bounded configurable candidate batch, applies the Tier 2
non-vacuous / largest-component / primordial-law gates, then selects an ordered Tier A spine with the
explicit provisional grounded/outlier, adjacency, variety, and five-slot boss policy. The JSON stores
the complete six-family parameter vector (float values carry both a readable number and exact IEEE-754
bits), seed/index/archetype/recipe, bounds, measured metrics, gate facts, candidate audit, and rejection
counts. A canonical SHA-1 `content_hash` covers the complete payload. `VF_DeserializeVoxelSeasonManifest`
verifies it in memory; `VF_LoadVoxelSeasonManifest` plus `VF_RebuildVoxelSeasonStrate` are the round-trip seam; generated
recipe entries rebuild through the mandatory-post builder, while authored `FixedStrates` retain their
absolute slot and native vector. The selected-only descent review reuses `VoxelStratePreview`, and all
composition/review work is editor/build-box code; parsing and recipe materialisation are runtime. Lateral
region manifests remain rejected because their post-landing cross-seam law is only **4/16** (the
pre-landing snapshot was 9/16). The focused
season test currently reports **24 generated / 19 hard-gate survivors / 6 selected / 18 unselected or
rejected**, with **4 vacuous**, **1 primordial-law budget**, **13 policy-not-selected**, and **393,216**
round-trip density samples bit-identical. The specified UE 5.7 editor build succeeded and the final
`VoxelForge` namespace report `VoxelForgeFullMazeTreeFinal3` recorded **30 clean successes, 2
warning-bearing, 0 failed, 0 not run** in **2,523.978 s**. The regenerated showcase produced **6 fine
renders, 2 refusals, and 0 blanks**; all box-verdict violations remained zero. The later focused
production-height showcase history is recorded separately below.
The current landing validation is recorded in `VoxelForgeLandingFinal5`: **30 clean successes, 2
warning-bearing successes, 0 failures, 0 not-run** across 32 tests in **3,607.746 s**. The strict
promotion assertions passed and no assertion was weakened. The landing-specific geometry test,
both connectivity tests, all box verdicts, seals, determinism, and op-stack equivalence tests passed.
**`Public/VoxelSeasonAsset.h` + `Private/VoxelSeasonAsset.cpp`** — `UVoxelSeasonAsset :
UPrimaryDataAsset`, the cookable carrier. Its editor button imports the reviewable JSON, stores the exact
text plus independently copied metadata/hash, and retains soft references to any authored definitions.
`UVoxelSettings::Season` is the only activation switch.
### 3.9 Player edits — `Public/VoxelDiffLayer.h` + `.cpp`
`UVoxelDiffLayer : UObject` (h:77). Stores `FVoxelModification` (h:43: Center/Radius/Strength;
**negative Strength = carve, positive = fill**) grouped by chunk in `TMap ChunkMods`.
@@ -313,6 +710,41 @@ Bourke). Cube corner/edge layout documented at top (lines 7-37). Rarely needs ed
---
### 3.12 Automation tests — `Private/Tests/` (added 2026-07-27, `#if WITH_DEV_AUTOMATION_TESTS`)
The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor's
**Session Frontend → Automation**, filter `VoxelForge`.
| File | Test name | What it proves |
|------|-----------|----------------|
| `VoxelForgeTestFixture.h` | — | `FTestWorld`: a headless world (transient strate definitions → `UVoxelSettings` → a real `UVoxelStrateManager::Initialize`) so tests hit `GetDensityAt`, where the thread_local caches live. One strate per archetype, **pinned via `FixedStrates`** so slot index → archetype is stable across seeds (`SlotSurfaceWorld` etc.). Bounded regression fixtures default to 4 chunks; the production/default definition and the owner-facing showcase explicitly use 8 chunks. |
| `VoxelForgeDensityPurityTest.cpp` | `VoxelForge.Determinism.DensityPurity` | 10k points re-sampled in shuffled order, same thread **and** on N workers, asserting BIT equality. `ValidateDeterminism` is game-thread only and cannot see worker-cache divergence. Includes a flat-field canary (AUDIT C1) and a diff-layer pass. |
| ″ | `VoxelForge.Determinism.LiveEditInvalidation` | AUDIT C2 regression: triple the heightfield params, `Initialize` again, require the density to MOVE. The edit does not move the strate, so only `LayoutVersion` changes. |
| `VoxelForgeClassifyTileTest.cpp` | `VoxelForge.Determinism.ClassifyTileSoundness` | Scans for a non-`Mixed` verdict, then brute-forces the exact mesher lattice (`g ∈ [-1, Cells+1]`). **A false verdict is an invisible, collisionless hole** — T1.d v1 was reverted for exactly this. Errors out rather than passing if it found nothing to check. |
| `VoxelForgeDiffLayerTest.cpp` | `VoxelForge.Determinism.DiffLayerContention` | N readers running the worker call mix while the game thread writes and `Clear()`s. Survival + monotonic `ModsVersion`. |
| `VoxelForgeClassifyTileTest.cpp` | `VoxelForge.OpStack.BoxVerdictFold` | Pure-logic walk of the fold in `VoxelDensityOp.h`, case by case — including the seal-forces-AllSolid case that justifies `ClassifyBox` existing. Also the only `.cpp` that includes the op header, so the build actually sees it. |
| `VoxelForgeHeightStackTest.cpp` | `VoxelForge.OpStack.SurfaceHeightEquivalence` | The height-space stack vs `ComputeSurfaceTerrainZ`, in **altitudes**. Runs twice: defaults, then **all F20 terrain ops ON** — the load-bearing pass, since the ops are off by default and the defaults pass exercises only the structural source. Also brute-forces `MaxDisplacement` (a false bound would be a hole). Bar is bit-identity; a height delta is a visibly different world, not rounding. |
| `VoxelForgeCrossPlatformTest.cpp` | `VoxelForge.Determinism.CrossPlatformDigest` | SHAPE digest (sign of density = the world) + FIELD digest (bit-for-bit) over a fixed integer grid, plus `NearIso` bounding how many samples could flip sign. Reports rather than asserts until pinned. Run on Windows and Linux and compare. |
| `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 6-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. |
| `VoxelForgeOpStackTunnelTest.cpp` | `VoxelForge.OpStack.TunnelNetworkSpineEquivalence` | **Stage A of the last port.** Zeroes the 13 detail-op amplitudes so the *original* takes the path stage A ported — that is what makes an incomplete stack verifiable now. Samples in **clusters** (24 chunks × 250 points), because the SDF cache rebuilds when a query leaves its box and uniform sampling would rebuild per point on both paths. Check 3 (two param sets, A/B interleaved) compares each stack **to itself alone, never to the original** — the original would fail it, see AUDIT §C2. Asserts **zero** box verdicts, which is the honest stage-A result. |
| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. The source and mirror now share a deterministic inner-3×3-emitted drainage tree whose parents come from rebuild-only ±2/±3 windows in a wider direct-indexed halo (default 9×9 emit / 15×15 roll), plus the old random links. Tree connector Z is exact and ledge-free; tree and random capsules are spatially culled from the cached rebuild. **Fixed 2026-07-29:** its box query used to return `CarveOnly` because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at `ShaftSpacing 55 / ShaftDensity 0.6`, hence **0 of 60** tiles. It now publishes an isolated interval for tree and random capsules (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed), with **Z exact** and XY conservative; the ledge and converter decide their own effects. The sampler was also widened from ±48 voxels to ±440. Every proved tile is brute-forced over its full lattice, and the test reports rebuild-vs-hot cache cost. |
| `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. |
| `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 8-op Maze stack vs `GetMazeDensity` over 20k points (bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers, a 64-seed spanning-tree/connectivity audit, corridor morphology, cache rebuild-vs-hot timing, and brute force on every box verdict the stack emits. Latest audit: **0 tree edge-count violations, 0 tree-disconnected, 0 Maze-disconnected**; interior degrees `d1=15.91%`, `d2=36.60%`, `d3=31.73%`, `d4=12.95%`, `d5=2.56%`, `d6=0.25%`, mean **2.504** versus a cubic grid's degree-6/mean-6.000; runs `min=1 / median=1 / p90=3 / max=18`. Latest focused cache timing: **1.436 μs rebuild**, **0.150 μs hot call**; final namespace timing: **1.130 μs rebuild**, **0.145 μs hot call**. Latest box audit: **104 proved, 152 Mixed, 138,424 voxels, 0 violations**. |
| `VoxelForgeStrateParamCoverageTest.cpp` | `VoxelForge.Determinism.StrateParamBlendCoverage` | **The X-macro guard** (added 2026-08-17). `FStrateGenerationParams::Lerp` blends the hand-written `VF_STRATE_PARAM_FIELDS` list, **not** the struct — so a field added to one and not the other compiles, tests green, and silently takes its **default** inside every Gradient/Interleaved transition band. This expands the X-macro a **third** way (after LERP and SNAP), into a name list, and diffs it against the struct's UObject reflection. Pure shape test: no fixture, no world, instant. `GExemptFieldNames` is **empty** — every reflected field is covered today, and any exemption must be written down as a decision. Stakes rise with the world composer, which intends to invent parameter sets through this same `Lerp` (`COMPOSER-NOTES.md`). |
| `VoxelForgeComposerParameterRollTest.cpp` | `VoxelForge.Composer.ParameterRoll` | Asset-Registry corpus audit + complete per-archetype spread/exclusion/clamp table; asserts bit-identical deterministic rerolls and same-archetype parents; measures 64 transient candidate strates with `VF_MeasureStrate` plus the exact unsnapped arrival→departure law; brute-forces every rolled production box verdict. World-scale run: **1,422 applications / 168 distinct fields**; **36/64 survival**, 60 non-vacuous, 58 largest-component, 40 exact-law passes; **1,411 Mixed + 731 AllSolid + 418 AllAir = 1,149 proved boxes**, **1,529,319 lattice voxels checked, 0 violations**, 148.387 s. |
| ″ | `VoxelForge.Composer.TerrainDetailLiveness` | Fixed 4,096-point `GetDensityAt` lattice, legacy and operator-stack paths; changes one terrain-detail group at a time with an empty terrain-op pool. Proves 9 live groups / 23 fields and 3 dead groups / 11 fields; all 24 rows match. |
| `VoxelForgeComposerPromotionTest.cpp` | `VoxelForge.Composer.Promotion` | Re-measures the 12 project/default members, simulates five deterministic 24-candidate seasons with normalized measured-metric novelty (`<0.20`), cap 6, cumulative JSON promotion, provenance counts, corpus-hash checks, fresh-load gate verification, spread/survival reporting, and deliberate stale-metric corruption. The final landing validation passed the strict existing gates; no assertion was weakened. |
| `VoxelForgeComposerStructureRollTest.cpp` | `VoxelForge.Composer.StructureRoll` | Rolls root polarity → legal shape source → polarity-derived conversion → 4–8 declaration-legal modifiers → mandatory structural posts; blends the six native parameter families independently, measures 64 novel stacks, captures the same grid for the deterministic filled/contour XZ/XY preview, runs a separate step-1 radius-64 ROI pass for the 33 survivors centred on `LargestComponentPoint`, checks exact arrival→departure connectivity, rerolls every recipe/stack for determinism, and brute-forces every uniform box verdict. World-scale run: **33/64 survival (51.6%)**, **64 distinct recipes**, **0 invalid recipes**, **715 proved custom boxes / 951,665 lattice voxels / 0 violations**, blank plan/card **4/33→5/33**, **174.854 s**, **0 refusals**. |
| `VoxelForgeComposerShowcaseTest.cpp` | `VoxelForge.Composer.Showcase` | Exhausts the bounded parameter-roll set (seeds **0, 7331**, indices **0–63**), excludes multi-region rolls while the lateral gate is off, and measures every missing-family candidate in all six interior target slots at step 4 / radius 256 / `MaxCells=8,000,000`. Hard gates are non-vacuous, largest air share ≥ **0.50**, and exact unsnapped arrival→departure connectivity; selection score is floor-area fraction + clearance tie-break + projected-surface tie-break. It asserts roll/manifest determinism, density sign, zero `WorldRadiusVoxels`, bit-identical metric reruns, and writes one alphabetized card per archetype to `Saved/VoxelForge/Showcase/index.html`, with step-1 radius-64 filled/contour plan + vertical ROI images centred on `LargestComponentPoint`. The final landing regeneration evaluated **256** rolls (**82** eligible single-region, **174** skipped multi-region), produced **7 fine renders, 1 refusal, 0 blanks**, and retained a diagnostic hard-gate survivor set of **0/8**. |
| `VoxelForgeComposerSeasonTest.cpp` | `VoxelForge.Composer.Season` | Existing compose/review/393,216-sample round trip plus schema hash tamper rejection, two independent runtime manager/generator instances, season-authoritative seed/spine/radius, unset-season regression, and brute-force verification of every sampled non-Mixed recipe tile verdict (up to 64). |
| `VoxelForgeComposerCorpusFreeTest.cpp` | `VoxelForge.Composer.CorpusFree` | Shares each structure recipe across today's corpus blend, naive independent uniform rolls, and constraint-sampled rolls. The completed equal-arm world-scale run uses **16 candidates per arm**, step 4 / radius 256 / `MaxCells=8,000,000`, fixed passage-law mouths, and 40 box probes per candidate. Result: **10/16, 9/16, 8/16** survival; survivor feature-scale ranges are **8..268**, **8..60**, **8..92** voxels. Box checks: **185/246,235**, **260/346,060**, **299/397,969** proved/voxels, **0 violations** in every arm; constraint parameter and box-stop violations were also 0. |
| `VoxelForgeLayoutOrderIndependenceTest.cpp` | `VoxelForge.Determinism.LayoutOrderIndependence` | Builds a known transient soft-pointer pool, then rebuilds it in original, reversed, and swapped orders. Requires a non-empty layout and passage set, and compares every slot's definition/Z/height plus passage endpoints, landing descriptors, control geometry, and bounds bit-for-bit. |
| `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check both generated mouths against independent source queries, then audits all inter-strate landing ends: body-derived room dimensions, final-density flat-floor slope, local floor/join geometry, seal containment, hot-call/rebuild timing, and targeted `ClassifyTile` box soundness. Reports source-fit/refused mouths and per-archetype landing slopes; fails on any floor, join, seal, or box violation. |
| `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` / `VoxelForge.Generation.VerticalShaftSeamFreedom` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test uses the bounded 4-chunk density fixture, fits each measurement AABB to the arrival/departure mouth pair plus three shaft spacings (the prescribed local tree fallback), and caps the fitted control at 120,000,000 cells. It reports exact before/after seed-6 cell counts, reruns every negative with the fitted margin doubled, measures tree orphan candidates/path reachability and roughness-bubble proxies, asserts mouths remain inside the deterministic shaft feature core (the exact axis is an unsupported open cylinder), checks source/mirror physical paths, and requires 16/16 effective arrival→departure results across the 16 VerticalShafts seeds. Margin-binding negatives are reported as measurement limits, never as gap findings. The shaft seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts. |
| `VoxelForgeMazeSeamTest.cpp` | `VoxelForge.Generation.MazeSeamFreedom` | Re-evaluates **42** cell-boundary probes after warming six different neighbouring chunk contexts in both legacy and operator-stack paths. Latest result: **0 legacy mismatches, 0 operator mismatches**. The Maze source's 2×2×2 lower-node window is deliberately local; no wide collect is involved. |
| `VoxelForgeClipmapNoOverlapTest.cpp` | `VoxelForge.Streaming.ClipmapDesiredTilesNoOverlapCoverage` | Sweeps 6,174 centres (negative coords, every parity) × radius/level/render-distance/vertical-clamp/sheet combinations: **zero overlapping desired pairs AND coverage ⊇ the frozen `7486c29` selector**. Failed on `7486c29` (1,022,679 overlapping pairs) as intended. |
| `VoxelForgeDensityBlockEquivalenceTest.cpp` | `VoxelForge.Density.OperatorBlockMatchesScalarGrid` | Block evaluation (`EvalBlock` after `BuildActiveOpList` pruning) vs scalar, **bit-identical**: 5.1M mesher-grid samples (6 seeds × 8 archetypes × levels 0–5, with edits) + 300k direct `EvalBlock` samples. Scopes `voxel.UseFusedEvaluator=0` so the real block path runs. An injected optimistic `FPassageCarveOp::EffectOverBox` produced 5,612 mismatches. |
| `VoxelForgeWorldEdgeSealTest.cpp` | `VoxelForge.Generation.WorldEdgeSeal` | Positive samples outside the radius across every fixture strate, bit-identical interior negative control against radius 0, monotonic smooth-ramp check, radius-0 no-op, radial `ClassifyBox`/T1.d proofs brute-forced for every reported voxel, and synthetic plus generated near-rim passage coverage. Emits one aggregate summary with proved-box and voxel counts. |
## 4. The density pipeline (most-edited hot path)
### 4.1 `GetDensityWithParams` (TunnelNetwork) — VoxelGenerator.cpp:277
@@ -337,7 +769,7 @@ Stage order (negative=solid throughout). Each stage's anchor:
| 4h — Pinch | 1142 | Passage bottlenecks. |
| 5 — Worm tunnels | 1241 | abs(noise1)+abs(noise2), masked by distance-to-network (`WormNetworkRange`: braids hugging rooms/tunnels, no far-field speckle; 0 = legacy unmasked). |
| 6 — Boundary seal | 1274 | Solid top/bottom shells (`ApplyBoundarySeal`). |
| 7 — Inter-strate passages | 1281 | Carve passages/elevator (`ApplyPassageCarving`). |
| 7 — Inter-strate passages + landings | `GetDensityWithParams`: 2217; `GetSlabDensity`: 2434+ | Carve wandering passage tubes and deterministic local landing rooms, restore their support floors, then leave the final XY seal to the outer MC post (`ApplyPassageModifier` / `ApplyPassageLandingFloorMC`). |
### 4.2 `GetSlabDensity` (FlatPlain / CrystalChamber) — VoxelGenerator.cpp:1306
| Step | Line | What |
@@ -346,7 +778,7 @@ Stage order (negative=solid throughout). Each stage's anchor:
| 2 — Ceiling surface | 1343 | Formations hang downward (`abs(noise)`). |
| 3 — Void→base density | 1379 | Solid outside [floor,ceiling]. |
| 4 — Columns | 1399 | World-space hash grid, full-height. |
| 5+6 — Seal + passages | 1461 | Same seal/passage carving as TunnelNetwork. |
| 5+6 — Seal + passages | 1461 | Same seal, passage-tube, landing-room, connector, and support-floor path as TunnelNetwork. |
---
@@ -358,13 +790,14 @@ Stage order (negative=solid throughout). Each stage's anchor:
| View distance / task budget / LOD distances | `VoxelSettings.h` (no recompile of logic — data asset). |
| LOD step mapping | `AVoxelWorld::LODToStep` VoxelWorld.cpp:268; `GetLODForChunk` :242. |
| How chunks stream in/out | `UpdateChunksAroundPosition` VoxelWorld.cpp:362. |
| Async threading / stale-result handling | `LoadChunk` :445, `ProcessPendingChunks` :301, Epoch logic. |
| Async threading / stale-result handling | `LoadTile` :445, `ProcessPendingChunks` :301, generation Epoch + per-request cancellation token. |
| Add a new cave feature / terrain op | Add enum in `VoxelTerrainOpDefinition.h:36`, params there, `ApplyTo` (.cpp:6), transport fields in `FStrateGenerationParams`, consume it in a new Step inside `GetDensityWithParams`. |
| Tweak room/tunnel shapes | `VoxelCaveMorphology.cpp` `BuildChunkCache` :47 / `EvaluateSDFCached` :757. |
| Worm tunnel behavior | `GetDensityWithParams` Step 5, VoxelGenerator.cpp:1241. |
| Strate stacking / which strate where | `UVoxelStrateManager::Initialize` :10. |
| Publish/use an offline season | Create `UVoxelSeasonAsset`, set `SourceManifestJson`, press `ImportSeasonManifestJson`, then assign it to `UVoxelSettings::Season`. |
| Boundary blend between strates | `GetGenerationParams` :515 + `FStrateGenerationParams::Lerp` (expands `VF_STRATE_PARAM_FIELDS`, StrateTypes.h — new fields go in that list). |
| Passages between strates | `GeneratePassages` :146 + `EvaluateModifierSDF` :371 + `ApplyPassageCarving` (Generator.cpp:197). |
| Passages / landing geometry between strates | `GeneratePassages` :146 + `EvaluateModifierSDF` :371 + `ApplyPassageModifier` / `ApplyPassageLandingFloorMC` (StrateManager.cpp) + `ClassifyTile` floor guard (Generator.cpp). |
| Player carve/fill | `CarveAtPosition`/`FillAtPosition` VoxelWorld.cpp:691/709 → `UVoxelDiffLayer::ApplyModification` :63. |
| Mesh smoothness / normals | Grid-gradient in `GenerateMesh` (`GradAt` lambda), `IsoLevel` (h). |
| New slab/flat-world generator | `GetSlabDensity` Generator.cpp:1306 + `FSlabGenerationParams` (StrateTypes.h:1019). |
@@ -383,6 +816,12 @@ Stage order (negative=solid throughout). Each stage's anchor:
different layers — trust the MC convention at the mesher.
- **Coordinate units:** density functions take **voxel coords** (not cm). World↔voxel
conversions live in `VoxelTypes.h`. Mesher converts before calling the generator.
- **Espace ACTEUR / actor space:** le champ de densite est en espace **acteur** — `(0,0)` = origine de
l acteur `AVoxelWorld`, pas du monde Unreal. Tout point venant d Unreal (pion, trace, ancre) passe par
`AVoxelWorld::WorldToLocalCm` / `WorldToLocalVoxel` / `LocalVoxelToWorld`. **Regle grep : aucun
`/ VOXEL_SIZE` sur un parametre `World*` hors de ces trois fonctions.** ⚠️ **Translation seulement** —
rotation/echelle cassent les boites alignees sur les axes (tuiles, `ClassifyBox`, clipmap, culling) ;
`BeginPlay` loggue une Error. The density field is in ACTOR space; translation only.
- **Determinism:** all randomness is hash-of-(coord, seed, strateIndex) — no RNG state.
Same seed ⇒ identical world. Player edits are the only non-deterministic overlay.
- **Async safety:** background tasks must check `bShuttingDown` and only touch the
@@ -392,6 +831,8 @@ Stage order (negative=solid throughout). Each stage's anchor:
are dropped in `ProcessPendingChunks`. Always carry the epoch through new async paths.
- **Generated code** under `Intermediate/` and `Binaries/` is build output — never edit.
`*.generated.h` / `*.gen.cpp` are UHT output for the `UCLASS`/`USTRUCT` above.
- **`UPackage` completeness:** files that call `NewObject(..., GetTransientPackage(), ...)` must include
`UObject/Package.h` directly; files that do not include `VoxelForgeTestFixture.h` do not get it transitively.
---
+161
View File
@@ -0,0 +1,161 @@
# Codex task 001 — make tile-skipping observable in the running game
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
---
## Why this exists
Jahni built the world, looked at it, and said: *"I don't know if it dropped any meshing? but it looks
alright by the eye."*
He's right to be unsure — **there is no way to answer that question from inside the game.** The
plugin has **zero** stat counters (`grep INC_DWORD_STAT` → nothing). Tile-skipping is the largest
perf item in the whole plan and it is currently unobservable in production; it has only ever been
measured in an automation harness, on 40 sampled tiles.
And a visual check cannot answer it: *skipped correctly* and *skipped nothing* render identically.
This codebase has paid repeatedly for exactly that confusion — see the "coverage is a number, not a
boolean" lessons in `OPSTACK-HANDOFF.md`.
**The real prize:** with no strate opted in, cave-archetype skips must read **0**. After ticking
`bUseOperatorStack` on one `TunnelNetwork` strate and flying underground, they must become non-zero.
That is the **production-side proof of T1.d**, which does not exist today.
## The sites — there are TWO, and the second one is the one that answers the question
### Site A — the skip itself
`Source/VoxelForge/Private/VoxelWorld.cpp`, in **`AVoxelWorld::GenerateTileResult`** (~line 1501).
Trust the symbol, not the line number.
```cpp
bool bTrivialEmpty = false;
if (!bSheetTile && !bWantCapture && Generator && Mesher && Mesher->IsoLevel == 0.0f)
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ClassifyTile);
bTrivialEmpty = (Generator->ClassifyTile(OriginVoxels, Step, Cells) != EVoxelTileClass::Mixed);
}
FVoxelMeshData MeshData;
if (!bTrivialEmpty)
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh);
MeshData = bSheetTile ? Mesher->GenerateSheetMesh(...) : Mesher->GenerateMesh(...);
}
```
### Site B — where the OPERATOR STACK's verdict is produced
`Source/VoxelForge/Private/VoxelGenerator.cpp`, in **`UVoxelGenerator::ClassifyTile`**, at the **exit
of the `if (bAnyCave)` block** (~line 3009) — the last two lines of that block:
```cpp
if (bCanSolid == bCanAir) { return EVoxelTileClass::Mixed; }
return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir;
```
**Why site A alone cannot answer the question — this is the correction that makes the task
meaningful.** `ClassifyTile` has *two* independent ways to reach a non-`Mixed` verdict:
- the **hand-written** path, which predates all of this work: a chunk in a **bedrock gap** sets
`bCanAir = false` (VoxelGenerator.cpp ~2835) and, absent a passage or the origin spine, the tile
resolves **`AllSolid`**. Likewise the SurfaceWorld column scan. This fires with **no strate opted
in at all**;
- the **operator-stack** path, the `if (bAnyCave)` block, which is the only thing T1.d added.
So `TilesSkippedAllSolid` at site A **will already be non-zero underground before any strate is
ticked** — the bedrock between strates guarantees it. A single lumped counter would make the
before/after unreadable, and that is exactly the "when a zero has several possible causes, give each
one its own number" lesson this project already paid for.
Site B's counters have the opposite property, and it is a strong one: `ClassifyTile` returns `Mixed`
outright at the cave branch when `UsesOperatorStackForChunk(CC)` is false (~line 2809, and again per
chunk of the box at ~2914). **With no strate opted in, the site-B counters are zero by
construction, not merely by observation** — so a non-zero reading after ticking the box cannot come
from anywhere else.
## What to build
1. **A stat group.** New header `Source/VoxelForge/Public/VoxelStats.h`:
`DECLARE_STATS_GROUP(TEXT("VoxelForge"), STATGROUP_VoxelForge, STATCAT_Advanced);` plus
`DECLARE_DWORD_COUNTER_STAT_EXTERN` for each counter below. `DEFINE_STAT` for each goes in **one**
`.cpp` — put them in a new `Source/VoxelForge/Private/VoxelStats.cpp`.
2. **Six per-frame counters** (`DWORD_COUNTER`, so `stat VoxelForge` shows a rate, not a total):
| counter | site | incremented when |
|---|---|---|
| `TilesClassified` | A | the classifier gate was entered (the `if` above ran `ClassifyTile`) |
| `TilesSkippedAllSolid` | A | verdict was `AllSolid` |
| `TilesSkippedAllAir` | A | verdict was `AllAir` |
| `TilesMeshed` | A | `GenerateMesh` / `GenerateSheetMesh` actually ran |
| `TilesOpStackSolid` | B | the `bAnyCave` block returned `AllSolid` |
| `TilesOpStackAir` | B | the `bAnyCave` block returned `AllAir` |
Splitting solid from air is the point, not decoration: **cave archetypes prove `AllSolid`**.
Splitting site B from site A is the whole deliverable — see "Why site A alone cannot answer the
question" above. `TilesOpStackSolid ≤ TilesSkippedAllSolid` always, and the difference is the
pre-existing bedrock/surface skipping.
At site B, increment on the `return` line only — **not** before the
`if (bCanSolid == bCanAir) return Mixed;` guard, which is where the block bails out with no
verdict.
3. To get the verdict you need it as a value, not a bool. Changing
`bTrivialEmpty = (Classify(...) != Mixed)` into a stored `EVoxelTileClass Verdict = Classify(...)`
followed by `bTrivialEmpty = (Verdict != Mixed)` is **fine and expected**.
## ⚠️ Invariants — a violation here is not a bug, it is a hole
1. **DO NOT change `bTrivialEmpty`'s value or the control flow.** That bool decides whether a tile
gets geometry **and collision**. A wrong value is invisible until a player falls through the
floor. Refactor the expression, never the condition.
2. **DO NOT touch the gate `!bSheetTile && !bWantCapture && Generator && Mesher && Mesher->IsoLevel
== 0.0f`.** Every clause is load-bearing and documented in the comment above it — sheet tiles have
no marching cubes, capture tiles need the grid even when uniform, and the verdicts assume the MC
iso is exactly zero.
3. **Thread safety: this runs on WORKERS.** `GenerateTileResult` is called from the async ChunkGen
task *and* the synchronous carve path. Use the `INC_DWORD_STAT` family, which is per-thread-packet
safe. **A plain `static int32` counter, even `++` on an `int32`, is a data race — do not.**
4. **Zero cost when stats are compiled out.** The `INC_DWORD_STAT` macros already vanish when
`STATS == 0`. Do not wrap them in an `if` that survives, and do not compute anything solely to
feed a counter outside the macro.
5. **No new includes in a public header beyond `Stats/Stats.h`**; the plugin follows IWYU and the
include debt was cleared deliberately (`AUDIT §C9` work).
6. **At site B, do not touch `ClassifyTile`'s control flow either — and do not add an early
`return`.** That function is a chain of conservative guards that all **fail to `Mixed`**; every
`return` in it is load-bearing. Add the counter to the existing `return` expression's statement,
nothing else. `ClassifyTile` is `const` and runs on the same workers as site A, so the same
`INC_DWORD_STAT`-not-`static int32` rule applies.
## Acceptance
- Editor, `stat VoxelForge` on screen, fly around: the numbers move.
- **`TilesClassified == TilesSkippedAllSolid + TilesSkippedAllAir + TilesMeshed`** for tiles that
entered the gate. (Tiles that fail the gate are meshed without being classified, so `TilesMeshed`
is legitimately larger than the classified total — say so in a comment rather than "fixing" it.)
- **`TilesOpStackSolid ≤ TilesSkippedAllSolid`** and **`TilesOpStackAir ≤ TilesSkippedAllAir`**,
always. A violation means site B is counting a verdict that site A did not act on.
- With **no strate opted in**, flying underground through a `TunnelNetwork` strate:
- `TilesSkippedAllSolid` is expected to be **non-zero** — that is the pre-existing bedrock/surface
skipping, not a bug, and it is why the lumped counter cannot be the deliverable;
- `TilesOpStackSolid` and `TilesOpStackAir` are **0**. This is the baseline, and it must be
*observed* before the next step even though it is guaranteed by the flag gate.
- Tick `bUseOperatorStack` on **one** `TunnelNetwork` strate, fly the same route:
**`TilesOpStackSolid` becomes non-zero.** ← this is the deliverable, and it is the first
production-side evidence T1.d has ever had.
## Notes for the reviewer (Claude)
- Check the verdict refactor byte-for-byte against the original condition. `!= Mixed` is the whole
contract.
- Check the counters are `DWORD_COUNTER` (per-frame) and not `DWORD_ACCUMULATOR`.
- Confirm no counter is incremented outside the gate in a way that double-counts the carve path,
which calls `GenerateTileResult` synchronously from the game thread.
- Site B: confirm the increment sits **after** the `bCanSolid == bCanAir` bail-out, and that the two
counters follow `bCanSolid` the same way the returned enum does — a swapped pair reads as a
plausible result and proves the wrong thing.
- The automation tests call `ClassifyTile` directly; they will move the site-B counters. Harmless,
but do not let a test-only path become the only thing that moves them.
+129
View File
@@ -0,0 +1,129 @@
# Codex task 002 — is the op-stack column memo thrashing? Count, don't guess.
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
**Depends on:** `CODEX-TASK-001` — this adds counters to the **same** `stat VoxelForge` group.
Do 001 first; this task assumes `VoxelStats.h` already exists.
---
## Why this exists
The operator-stack density path is measurably slower than the `switch` it replaces, and the cause
has never been attributed. There are three standing suspects. **This task measures the first one,
and does not fix anything.** That order is deliberate: `AUDIT §C10` cost six builds and five refuted
hypotheses by reasoning first, and the last session re-learned it.
### The hypothesis, derived from the code
`FSurfaceColumnSource::GetColumn` (`VoxelDensityOpStack.cpp` ~615) memoises a computed column in a
**direct-mapped, 4096-entry `thread_local` table**, indexed by a hash of the two XY floats:
```cpp
struct FSlot { uint64 Key; float X, Y; FColumn C; }; // 40 bytes
thread_local FSlot Slots[4096] = {}; // 160 KB per worker
const uint32 Idx = ((HX * 0x9E3779B9u) ^ (HY * 0x85EBCA6Bu)) >> 20; // [0,4095]
```
The original path, `GSurfColCache` in `GetDensityAt`, is instead a **direct-indexed box**:
`CI = (IY - Box.BaseY) * Dim + (IX - Box.BaseX)`, with a `Computed[CI]` flag. **No hash, therefore
no collisions, therefore every column is computed exactly once.**
Now the sampling order, which is the premise that makes this bite. `FVoxelMarchingCubesMesher`
pre-samples with **Z as the OUTERMOST loop** (`VoxelMarchingCubesMesher.cpp` ~226):
```cpp
for (int32 gz = GzLo; gz <= GzHi; gz++)
for (int32 gy = -1; gy <= GridDim; gy++)
for (int32 gx = -1; gx <= GridDim; gx++)
Generator->GetDensityAt(...);
```
So the mesher sweeps a **whole XY plane at every Z level**. Every column in the tile is revisited
once per Z plane — roughly 34 times.
The table's own comment sized it for this: *"A chunk is CHUNK_SIZE² columns (1024), so the first
draft's 256 entries could not even hold one chunk and thrashed inside a single tile. 4096 covers
four chunks."* **That reasoning has a gap.** A direct-mapped table does not need to be full to
evict — it needs two live keys to collide. At ~1156 columns per plane in 4096 slots (load factor
0.28), the expected number of columns sharing a slot with another is **~285, about 25 %**. Those
columns evict each other, miss again on the next Z plane, and recompute the **entire height stack** —
structural source, cliff (four structural resamples), terrace, layer-line, beach, and the ceiling
stack — every single plane.
Order of magnitude if that is right: ~1156 column computations on the original path versus
~1156 + 285 × 34 ≈ **10 000** on the op path. Roughly **9×** the column work, on the plugin's most
expensive archetype.
**That is a derivation, not a measurement, and it is exactly the kind of confident chain this
project has watched reverse six times.** Hence: count first.
## What to build — two counters, no behaviour change
Add to the existing `stat VoxelForge` group from task 001:
| counter | incremented when |
|---|---|
| `ColumnMemoHit` | `GetColumn` found a live entry (the `if` body did **not** run) |
| `ColumnMemoMiss` | `GetColumn` recomputed (the `if` body ran) |
That is the entire change. Two `INC_DWORD_STAT` calls inside `FSurfaceColumnSource::GetColumn`,
around the existing `if (S.Key != ColumnKey || S.X != WorldX || S.Y != WorldY)`.
## ⚠️ Invariants
1. **Change nothing else in `GetColumn`.** Not the table size, not the hash, not the key comparison.
The point of this task is to produce a number that decides whether the fix is worth writing; a
change bundled in makes the number unattributable. **Do not "obviously improve" the table while
you are in there** — if the fix ships in the same build as the instrument, we learn nothing, and
this project has a written lesson about exactly that.
2. **`GetColumn` is `const` and runs on mesher workers.** `INC_DWORD_STAT`, never a `static int32++`
— same rule as task 001, same reason.
3. **The key comparison is load-bearing and must stay complete.** `S.Key != ColumnKey || S.X != X ||
S.Y != Y` — the full key is compared on every touch precisely so a hash collision can only cost a
recompute and never return **someone else's column**. Do not shorten it to feed a counter.
4. **Zero cost when `STATS == 0`.** No value computed outside the macros.
5. `FCaveCliffMod` and the overhang read this same memo through
`Column->GetColumn(...)` (~line 864). They are legitimate traffic and must be counted, not
excluded — they are part of why a miss is expensive.
## Acceptance — the prediction is now numeric (tightened 2026-08-16 from the real grid dimensions)
The hand-wavy "20–30 %" band this section used to carry has been replaced by an arithmetic
prediction, because the inputs are all statically knowable and were read out of the source:
- `CHUNK_SIZE = 32`, `CellsPerAxis = 32`, `GridDim = 33`, and the pre-sample loops run
`g ∈ [-1, GridDim]` per axis ⇒ **35 × 35 = 1225 distinct columns per tile**, over **35 Z planes**
(the mesher's own buffer comment, "35³ floats", confirms the dimension).
- 1225 keys in 4096 slots is a load factor of **0.299**. Expected slots holding exactly one key
`= 4096 · np(1-p)^(n-1) ≈ 908`, so **~317 columns (25.9 %) share a slot with another** and evict
each other on every plane.
- ⇒ op path ≈ `1225 + 34 × 317` ≈ **12 000** column computations per tile.
Original path (`GSurfColCache`, direct-indexed, `Computed[CI]` persists) = **1225**.
**≈ 9.8×.**
### How to read the result
⚠️ **Compare the RATIO OF THE TWO HYPOTHESES, not an absolute percentage.** The overhang and cliff
modifiers call `GetColumn` again at the same XY (~line 864); every extra consumer adds **hits** and
no misses, so it inflates the denominator and drags the miss *rate* down without changing the
verdict. What does not move is the ~10× gap between the two outcomes.
| observation | verdict |
|---|---|
| misses ≈ **8–10×** the hit-path baseline (single-consumer: ~28 % of lookups) | **CONFIRMED** — the table evicts on collision every Z plane. The fix gets its own task, with this run as its "before". |
| misses ≈ **1 per distinct column** (single-consumer: ~3 %, hit rate ≥ 97 %) | **hypothesis WRONG.** The table behaves like the box, the ~9.8× does not exist, and the perf cost is suspect 2 (19 virtual calls per voxel). |
*A negative result here is a real result.* It retires the most-suspected cause and is worth the build
either way; it must be written into `OPSTACK-PROGRESS.md`, not quietly dropped.
Report **both raw numbers**, never the ratio alone — a ratio cannot distinguish "few lookups" from
"many", and the absolute miss count is what the fix would be reducing.
## Notes for the reviewer (Claude)
- Confirm the miss counter sits inside the `if`, and the hit counter in an `else` — not computed
from a subtraction, which would silently agree with itself.
- Confirm `GetColumn`'s early-out path (if any is added later) cannot skip both counters.
- Confirm nothing else in `VoxelDensityOpStack.cpp` changed. `git diff --stat` should show one file
and a handful of lines.
+109
View File
@@ -0,0 +1,109 @@
# Codex task 003 — three `ExtraReach` formulas use an FBM bound this file already proved wrong
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
**Kind:** ⚠️ **correctness of a box verdict** — the class of bug that deletes collision. Not a perf task.
---
## Why this exists
`VoxelDensityOpStack.cpp` contains a rigorous, written derivation that `|Perlin3D| ≤ 1.5`, exposes it
as `PerlinAbsBound` (line ~2336), and uses it correctly for the tunnel warp dilation (~2452). The
comment there is explicit that the loose "~[-1,1]" figure from the noise header is **not** to be
relied on, and `OPSTACK-HANDOFF.md` records the standard: *a bound in a box verdict must be PROVED,
not observed — over-estimating costs CPU, under-estimating deletes collision.*
**Three `ExtraReach` formulas in the same file silently assume `sup|FBM| ≤ 1.0`.** Each carries the
comment "FBM ∈ [-1,1]", which is exactly the claim the file disproves 1800 lines earlier.
And `VoxelNoise::FBM` **is normalised** — it returns `Total / MaxValue` where `MaxValue = Σ Amp`
(`VoxelNoise.h` ~272). So `sup|FBM| = sup|Perlin3D|` exactly: **1.5, not 1.0.** The octave sum
neither amplifies nor attenuates the bound.
### What that costs, per archetype, at the shipped defaults
`Identity` from these sources means "no primitive within `ExtraReach` of the box", i.e. `Sdf ≥
ExtraReach` throughout. Roughness then does `Sdf += FBM · VOXEL_NOISE_SCALE · Strength`
(`FSdfRoughnessMod::Eval`), so worst case `Sdf' ≥ ExtraReach − B·1.25·|Roughness|`. Soundness
requires `Sdf'` to stay at or above the downstream carve/fill threshold.
| archetype | `ExtraReach` at defaults | downstream threshold | needs `B ≤` | verdict at `B = 1.5` |
|---|---|---|---|---|
| **VerticalShafts** (`Rough 3.0`) | `1.25·3 + 2 + 1` = **6.75** | carve blend **2.0** | **1.27** | ⛔ **UNSOUND** (margin −0.875) |
| **Maze** (`Rough 2.0`) | `1.25·2 + 2 + 1` = **5.5** | carve blend **2.0** | **1.40** | ⛔ **UNSOUND** (margin −0.25) |
| **FloatingIslands** (`Rough 4.0`, `K 5.0`) | `1.25·4 + 2·5 + 1` = **16.0** | fill `K` **5.0** (+ `K/6` SmoothMin dip) | **2.03** | ✅ sound — but only because `K` is large. Sound by parameter luck, not by construction. |
Break-even roughness for the two carve archetypes is `|Rough| ≤ 1.6`; they ship at 3.0 and 2.0.
**How alarmed to be, stated honestly.** No strate has `bUseOperatorStack` ticked, so nothing in the
running game is affected today. The brute-force tile scans report 0 violations — but they *sample*,
and they were sampling against a shaft source that proved **zero** tiles until `e002bd4`, so the
shaft path has never been exercised at all. The empirical sup of this Perlin is estimated at
~1.0–1.1, which is *below* the 1.27 the shafts need — which is why nothing has been seen yet, and
also why the margin is uncomfortably thin. The bug is that the verdict rests on an unproved bound,
which is the thing this codebase has already decided it does not do.
## The fix
1. **Hoist `PerlinAbsBound` to file scope and rename it `VF_PerlinAbsBound`**, so there is **one**
definition rather than a class-static plus three implicit `1.0`s. Keep the existing derivation
comment with it — it is the justification, not decoration.
**Naming, resolved:** the file-scope helpers in this file are all `VF_`-prefixed
(`VF_NearCaveSurface`, `VF_DistPointSegment`, `VF_NoCaveOverBox`), so a file-scope constant takes
the same prefix. That means this is a **rename**, not just a move:
- delete the `static constexpr float PerlinAbsBound = 1.5f;` class-static inside `FRoomGraphSource`
(~2336), moving its whole derivation comment with it;
- **update `FRoomGraphSource`'s own use at ~2452** (`P.CaveWarpStrength * VOXEL_NOISE_SCALE *
PerlinAbsBound`) to the new name. This is the one place where the warp dilation is computed and
it must keep computing the identical value — the rename must not change its arithmetic.
- after the edit, `grep -n "PerlinAbsBound" ` must show **only** `VF_PerlinAbsBound` occurrences.
2. **Multiply the roughness term by it in all three `ExtraReach` formulas** (~4106 VerticalShafts,
~4215 FloatingIslands, ~4247 Maze):
```cpp
// before
FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE
// after
FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE * VF_PerlinAbsBound
```
3. **Fix the three comments.** Each says "FBM ∈ [-1,1]". Replace with the real statement: `FBM` is
normalised (`Total / MaxValue`), so `sup|FBM| = sup|Perlin3D| =` the proved `PerlinAbsBound`.
A comment that states a refuted bound is how this happened in the first place.
## ⚠️ Invariants
1. **This must not change density by one bit.** `ExtraReach` is read **only** inside
`EffectOverBox` (verified: every other occurrence is a comment or the `float ExtraReach;` member
declaration — no `Eval`, no `GetCells`). The eight equivalence tests compare `Eval` bit for bit
and must stay green. **If you find yourself editing an `Eval`, stop — you have the wrong site.**
2. **The change direction is strictly conservative**: larger `ExtraReach` ⇒ more `CarveOnly`, fewer
`Identity` ⇒ *fewer* tiles proved uniform. It can only cost CPU, never open a hole. Do not
"balance" it by tightening something else in the same edit.
3. **Anonymous-namespace placement.** Put the hoisted constant **above the labelled end of the
anonymous namespace**, not anchored on the FACTORIES banner — anchoring there puts it outside and
the brace added with it closes nothing. This mistake has been made twice in this file and the
file says so.
4. **`FRoomGraphSource`'s warp dilation changes NAME ONLY.** It already uses the bound correctly and
is the reference implementation for this fix; the value it computes must be bit-identical after
the rename. Do not alter its formula, its `√2` factor, or anything else in that function.
5. Comments are French + English; match the surrounding file.
## Acceptance
- `git diff --stat` shows **one** file: `Source/VoxelForge/Private/VoxelDensityOpStack.cpp`.
- All three `ExtraReach` definitions include the bound; no fourth site exists (`BuildTunnelNetworkStack`
has no `ExtraReach` — it uses `PerlinAbsBound` directly for the warp).
- No `Eval` body changed.
- After the build: the eight equivalence tests stay green (density unchanged), and the box-verdict
lines for **Maze** and **VerticalShafts** may report *fewer* proved tiles than before. **A drop
there is the expected, correct outcome, not a regression** — it is the cost of a sound bound.
Record the before/after in `OPSTACK-PROGRESS.md`.
## Notes for the reviewer (Claude)
- Confirm the constant is genuinely at file scope inside the anonymous namespace and that the
class-static is gone, not shadowed — two definitions that can drift is the failure this fixes.
- Confirm all three call sites got it. Two out of three is worse than none, because it looks done.
- Confirm no `Eval`, `GetCells`, or `GetCellsAt` body appears in the diff.
+101
View File
@@ -0,0 +1,101 @@
# Codex task 004 — two box verdicts assume `MinRadius ≤ MaxRadius`; a third one already doesn't
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
**Kind:** ⚠️ **correctness of a box verdict.** Same class as task 003. Small fix, closes a class.
---
## Why this exists
Three operators roll a primitive radius from a hash between two designer-set params:
```cpp
Out.R = FMath::Lerp(MinRadius, MaxRadius, hash01); // FGridColumnMod ~1129
Out.R = FMath::Lerp(P.ShaftMinRadius, P.ShaftMaxRadius, hash01); // FShaftFieldSource ~1560
Out.Rxy = FMath::Lerp(P.IslandMinRadius, P.IslandMaxRadius, hash01); // FIslandBlobSource ~1850
```
`FMath::Lerp(A, B, t)` with `t ∈ [0,1]` lands anywhere in `[min(A,B), max(A,B)]` — it does **not**
require `A ≤ B`.
Each op's `EffectOverBox` then sweeps a **range of lattice cells** around the query box, padded by
the largest radius a cell could hold, and tests each rolled primitive exactly. The pad decides which
cells are *looked at at all*, so a pad smaller than the true maximum radius means **cells are never
examined**, their primitives are never tested, and the op reports `Identity` for a box that its own
`Eval` will carve or fill.
| op | pad used for the cell sweep | correct? |
|---|---|---|
| `FGridColumnMod` ~1086 | `FMath::Max(MaxRadius, 0.0f) + ColBlend` | ⛔ **exposed** |
| `FShaftFieldSource` ~1436 | `FMath::Max(P.ShaftMaxRadius, P.ConnectorRadius) + ExtraReach` | ⛔ **exposed** |
| `FIslandBlobSource` ~1803 | `const float MaxR = FMath::Max(P.IslandMinRadius, P.IslandMaxRadius);` | ✅ **already correct** |
**The third one is the point.** Someone hit this exact concern while writing the island source and
guarded it. The other two shipped without the guard. This task makes the three consistent.
## How exploitable, stated honestly
The shipped defaults are correctly ordered (`2/5`, `2/7`, `5/11`), so **nothing is broken out of the
box.** It needs a mis-ordered asset value — `ColumnMinRadius = 8, ColumnMaxRadius = 4`.
Nothing prevents that. The `UPROPERTY` metas carry `ClampMin = "1.0"`, which is a per-property
floor; Unreal has no declarative way to say "must be ≤ that other property". And `ColumnMinRadius`
is *also* settable per-room through `UVoxelTerrainOpDefinition`, so it is not only the strate asset.
What makes it worth the five lines: when it does happen, the failure is **invisible and maddening**.
`Eval` still draws the fat column perfectly, so every tile that gets meshed looks correct; only the
tiles the classifier *skipped* are missing — no geometry, no collision, in a world that otherwise
looks right.
## The fix — five lines, and one thing you must NOT do
Make each pad use the true envelope:
```cpp
// FGridColumnMod ~1086
const float Reach = FMath::Max3(MinRadius, MaxRadius, 0.0f) + ColBlend;
// FShaftFieldSource ~1436
const float Pad = FMath::Max3(P.ShaftMinRadius, P.ShaftMaxRadius, P.ConnectorRadius) + ExtraReach;
```
(Use whatever spelling is idiomatic here — check that `FMath::Max3` is already used in this codebase
before reaching for it; nested `FMath::Max` is fine and matches `FIslandBlobSource`'s existing line.)
### ⛔ DO NOT "fix it properly" by normalising the params
The tempting larger fix — swap `Min`/`Max` at resolution time so `Min ≤ Max` always — is **wrong and
will break the build's tests.** `Eval` computes `Lerp(Min, Max, t)`; swapping the endpoints maps the
same hash `t` to a *different* radius for the same cell. That changes generated geometry and breaks
the eight bit-for-bit equivalence tests against the `switch` path.
**Only the BOUND may become conservative. `Eval` stays byte-identical.** This is the same rule as
task 003 and the same reason.
## ⚠️ Invariants
1. **No `Eval`, `RollColumn`, `RollShaft`, `GetCells`, or `GetCellsAt` body may change.** If your
diff touches one, you have the wrong site — stop and say so.
2. **Do not touch `FIslandBlobSource`.** It is already correct and is the reference for this fix.
3. The change direction is strictly conservative: a wider sweep examines *more* cells, so a verdict
can only move from `Identity` toward `CarveOnly`/`FillOnly`, never the reverse. Do not add any
compensating tightening.
4. Comments are French + English; match the surrounding file. Say **why** the envelope is
`max(Min, Max)` and not `Max` — the next reader must not "simplify" it back.
## Acceptance
- `git diff --stat` shows exactly one file: `Source/VoxelForge/Private/VoxelDensityOpStack.cpp`,
and a handful of lines.
- The three ops now agree on the pattern.
- After the build: **every box-verdict line and every equivalence test is unchanged**, because the
shipped defaults are correctly ordered and the envelope only differs when they are not. **A change
in any of those numbers means the diff did something it should not have.** That is this task's
whole acceptance signal — a *no-op at defaults* is the expected, correct result.
## Notes for the reviewer (Claude)
- Confirm both pads changed and `FIslandBlobSource` did not.
- Confirm no `Lerp` argument order was touched anywhere — that is the failure mode that would look
like a tidy-up and silently change the world.
@@ -0,0 +1,99 @@
# Codex task 005 — three box-verdict tile scans sample less than one lattice period
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
**Kind:** test coverage. **Zero risk to the game — no non-test file may change.**
---
## Why this exists
This project has already found and fixed this exact defect **twice**:
> *"A sampler must cover at least one period of what it samples. The tunnel test drew tile XY from
> ±32 voxels with `RoomSpacing 80` — it measured the spine hub and called it the world. The shaft
> test had the identical bug (±48 against `ShaftSpacing 55`)."* — `OPSTACK-HANDOFF.md`
Both were fixed to `SpanCells = 55` ⇒ **±440 voxels**, and both now print their own extent in units
of the pattern's period so it cannot silently regress.
**Three box-verdict tile scans were never fixed**, because the fix was applied where the bug was
noticed rather than to the class. All three still use the original `Rng.RandRange(-6, 6) * Extent`
with `Step = 1, Cells = 8` ⇒ `Extent = 8` ⇒ **half-extent ±48 voxels**:
| test | half-extent | lattice period (default, **unchanged by the fixture**) | coverage |
|---|---|---|---|
| `VoxelForgeOpStackIslandTest.cpp` ~244 | ±48 | `IslandSpacing` **95** | **0.51 periods** ⛔ worse than either bug already fixed |
| `VoxelForgeOpStackSlabTest.cpp` ~289 | ±48 | `ColumnSpacing` **60** | **0.80 periods** ⛔ |
| `VoxelForgeOpStackMazeTest.cpp` ~276 | ±48 | `CellSize` **40** | 1.20 periods ⚠️ marginal |
Verified: none of `EnableIslandFeatures` / the slab tuning / the maze setup overrides the spacing, so
the header defaults are what these tests actually run against.
**Why it matters right now, specifically.** Two commits just changed the box verdicts these very
tests are supposed to guard — `7dbdf51` (the `ExtraReach` bound, which touches **islands**, maze and
shafts) and `eaa44bf` (the radius envelope, which touches **slab columns** and shafts). The tests
that would catch a mistake in those changes currently sample about half a lattice cell.
## The rule the two fixed tests already encode
`Extent = Step * Cells = 8` voxels, so `SpanVoxels = SpanCells * 8`. To get **8 periods** of
half-extent you set:
> **`SpanCells` = the lattice spacing** (`55` for `ShaftSpacing 55` — that is where the shaft test's
> `55` comes from, and it is not a coincidence).
Apply the same:
| test | `SpanCells` | resulting half-extent | periods |
|---|---|---|---|
| Island | `95` | ±760 | 8.0 |
| Slab | `60` | ±480 | 8.0 |
| Maze | `40` | ±320 | 8.0 |
## What to build
For each of the three tests, mirror **exactly** what `VoxelForgeOpStackShaftTest.cpp` (~212) does:
1. Hoist `const int32 SpanCells` and `const int32 SpanVoxels = SpanCells * 8;` **outside the tile
loop** — the report needs them and `Extent` is loop-local. (That scoping slip has already happened
once in this file family; the shaft test's comment records it.)
2. Draw `Rng.RandRange(-SpanCells, SpanCells) * Extent` for X and Y. **Leave the Z draw exactly as
it is** — it is clamped to the strate slot and is not part of this defect.
3. Extend the existing report line to print `SpanVoxels`, the live ratio
`(float)SpanVoxels / FMath::Max(<the spacing param>, 1.0f)`, and the spacing itself — so the
extent is stated in units of the pattern's own period and a future narrowing is visible.
## ⚠️ Invariants
1. **This is NOT "widen until it passes."** The comment already in the tunnel test says it best and
the same reasoning applies here: *every proved tile is still brute-forced voxel by voxel below, so
a wider sampler that produced a FALSE verdict fails exactly as before. We are changing what the
measurement **looks at**, not what it **demands**.* Do not touch the brute-force loop, its
tolerance, or any `AddError`.
2. **Do not adjust an assertion to accommodate a moved number.** Widening will change the proved /
Mixed counts — that is the point. If an existing assertion would now fail, **report it and stop**;
do not retune it. ("Don't assert a number you want to improve" is a written lesson here.)
3. **No file outside `Source/VoxelForge/Private/Tests/` may change.** `git diff --stat` must list
only those three test files.
4. Do not change `Step`, `Cells`, the tile count (`60`), or the RNG seeds — a changed seed makes the
before/after incomparable, and comparability is the whole point of touching this now.
5. Comments are French + English; match the surrounding file.
## Acceptance
- Three test files changed, nothing else.
- Each of the three now prints its extent **and** that extent in periods of its own spacing param.
- After the build, each ratio line reads **≥ 8 periods**.
- The proved counts will move. **That is expected.** What must NOT move: `violations` / `NumUnsound`
stays **0** in all three. If it becomes non-zero, the wider sampler has found a genuine hole that
the narrow one was hiding — which would be this task paying for itself immediately, and must be
reported loudly rather than tuned away.
## Notes for the reviewer (Claude)
- Confirm `SpanCells`/`SpanVoxels` are outside the tile loop in all three.
- Confirm the Z draw is untouched.
- Confirm the ratio is computed **live** from the params struct, not hardcoded — a hardcoded "8.0
periods" in a format string would be a success message that asserts coverage while measuring
nothing, which is a named failure mode in this project.
@@ -0,0 +1,98 @@
# Codex task 006 — the same `Min > Max` under-bound, in `BuildChunkCache` (both density paths)
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
**Kind:** ⚠️ **correctness of a collection bound.** Third instance of this class; the worst of the three.
**Credit:** found by the Sol-High read-only audit (`AUDIT-2026-08-CODEX.md`, VF-05) and **verified
against the code** before being specified.
---
## Why this exists
`CODEX-TASK-004` fixed two cell-sweep pads that assumed the field named `Max*` was numerically the
larger one. **The same defect exists in `VoxelCaveMorphology.cpp`, and it matters more**, for three
reasons:
1. It is **`TunnelNetwork`** — the largest and most-used archetype.
2. `BuildChunkCache` is called by **both** density paths: the original `switch` *and*
`FRoomGraphSource`, which deliberately calls it rather than transcribing it. **This is not an
operator-stack bug — it is in the shipped original code and always has been.**
3. Its failure mode is a **window-invariance break** (`ARCHITECTURE §8.4`), not just a missing room:
whether a room exists depends on which chunk you queried from. In a multiplayer game that means
two peers generate different geometry from the same seed.
### The mechanism
Radii are interpolated:
```cpp
Room.RadiusXY = FMath::Lerp(Params.MinRoomRadius, Params.MaxRoomRadius, SizeFactor); // ~262
const float RadA = FMath::Lerp(Params.TunnelMinRadius, Params.TunnelMaxRadius, FactorA); // ~467
const float RadB = FMath::Lerp(Params.TunnelMinRadius, Params.TunnelMaxRadius, FactorB); // ~468
```
`FMath::Lerp(A, B, t)` with `t ∈ [0,1]` yields anywhere in `[min(A,B), max(A,B)]` — it does **not**
require `A ≤ B`. But every bound derived from those radii reads only the `Max*` field:
| site | line | expression |
|---|---|---|
| `MaxInfluence` | ~127–130 | `FMath::Max(Params.MaxRoomRadius, Params.TunnelWarpStrength + Params.TunnelMaxRadius) + Params.SDFBlendRadius` |
| `CollectMargin` | ~152 | `2.0f * MaxTunnelLen + MaxInfluence` (inherits it — **no separate edit needed**) |
| `RoomZBuffer` | ~171 | `Params.MaxRoomRadius * Params.RoomHeightRatio` |
| `EvaluateSDF`'s `Margin` | ~871–874 | the same expression as `MaxInfluence`, duplicated |
With `MinRoomRadius > MaxRoomRadius`, rooms larger than `MaxInfluence` are generated, so a room that
can reach a chunk may sit in a cell the collect region never visited. `RoomReachesSearchBox` uses the
*actual* radius and is therefore correct — but it can only test rooms that were collected at all.
## The fix
Derive **bound-only envelopes** and use them at all four sites:
```cpp
const float RoomRadiusEnvelope = FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius);
const float TunnelRadiusEnvelope = FMath::Max(Params.TunnelMinRadius, Params.TunnelMaxRadius);
```
- `MaxInfluence` → `FMath::Max(RoomRadiusEnvelope, Params.TunnelWarpStrength + TunnelRadiusEnvelope) + Params.SDFBlendRadius`
- `RoomZBuffer` → `RoomRadiusEnvelope * Params.RoomHeightRatio`
- `EvaluateSDF`'s `Margin` → the same corrected expression.
⚠️ `MaxInfluence` and `EvaluateSDF`'s `Margin` are the **same formula written twice**. They must stay
identical. If a shared helper is natural here, use one — two copies of a rule that must agree is a
bug factory, and this file already has the duplication. If you introduce a helper, keep it local to
this translation unit and do not change either call site's semantics.
## ⛔ DO NOT reorder the `Lerp` endpoints
Swapping to `Lerp(min, max, t)` maps the same hash `t` to a **different radius** for the same room,
which changes generated geometry and breaks the eight bit-for-bit equivalence tests. **Only the
bounds may become conservative. The three `Lerp` calls must not be touched at all.**
This is the same rule as tasks 003 and 004, and it is the third time it applies.
## ⚠️ Invariants
1. **No `Lerp` line changes. No room/tunnel placement, hashing, or `bStore` logic changes.**
If your diff touches `Room.RadiusXY`, `RadA`, `RadB`, or `RoomReachesSearchBox`, stop and say so.
2. The change is strictly conservative: a larger envelope collects **more** cells, never fewer.
3. Exactly one file: `Source/VoxelForge/Private/VoxelCaveMorphology.cpp`.
4. Comments are French + English; match the file. Say **why** the envelope is `max(Min, Max)` — the
next reader must not "simplify" it back to `MaxRoomRadius`.
## Acceptance
- One file changed, a handful of lines.
- **With correctly ordered params, `max(Min, Max) == Max`, so every number is bit-identical and this
is a NO-OP. That is the acceptance signal.** The eight equivalence tests, every box-verdict line
and every `violations` count must be **unchanged** after the build. A moved number means the diff
did something it should not have.
- It only changes behaviour for an asset whose range is inverted — which is precisely the case that
was silently producing window-dependent geometry.
## Notes for the reviewer (Claude)
- Confirm all four bound sites use the envelopes, and that `CollectMargin` inherits rather than being
edited separately.
- Confirm `MaxInfluence` and `EvaluateSDF`'s `Margin` still compute the identical expression.
- Confirm no `Lerp` argument order changed anywhere in the file.
+150
View File
@@ -0,0 +1,150 @@
# Codex task 007 — VF-01: never mutate layout/passages while workers read them
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
**Kind:** ⚠️ **crash class (use-after-free).** Highest-severity item found on 2026-08-16.
**Origin:** Sol-High audit VF-01, **independently confirmed by reading** before this spec was written.
---
## The defect
`UVoxelStrateManager::Initialize` does `StrateLayout.Empty()` and `Passages.Empty()` + `Passages.Add()`
— it **frees and reallocates** both arrays. There is **no lock, no barrier, no drain** in that file.
Meanwhile those same arrays are read **on mesher worker threads**:
| reader | access |
|---|---|
| `AnyPassageNearBox` (`VoxelStrateManager.cpp:460`) | range-`for` over `Passages` |
| `EvaluateModifierSDF` | indexes `Passages[...]` |
| `FindSlotIndexForChunkZ` | iterates `StrateLayout` |
all reached from `GetDensityAt` / `ClassifyTile` inside chunk tasks.
`RegenerateAllChunks()` bumps the epoch **after** `Initialize`, so previous-epoch workers are live
*during* the mutation. **The epoch rejects a finished result; it cannot make a read of a freed
allocation safe.**
**Precedent in this very codebase:** `DiffLayer.ChunkMods` is read on mesher workers and written on
the game thread, and all access now holds `ModsLock` — added after a real carve-vs-stream access
violation. `StrateLayout` / `Passages` are the same shape with no guard.
Four `Initialize` call sites:
| line | function | dangerous? |
|---|---|---|
| 145 | `RebuildStrates` | **yes** |
| 309 | `OnObjectModifiedInEditor` | **yes — fires automatically on a strate asset edit while streaming** |
| 417 | `BeginPlay` | **no** — no tasks exist yet. **Leave it alone.** |
| 2091 | `ChangeSeed` | **yes** (also writes `Generator`'s `Seed` / `OriginSpineRadius`) |
## Why THIS fix and not the other two
Rejected deliberately — do not "improve" the design into either of these:
- **An `FRWLock` around the two arrays** (the `ModsLock` shape) would put a **read lock on the
per-voxel hot path** — `EvaluateModifierSDF` and `FindSlotIndexForChunkZ` run ~43k times per tile.
There is an open, unmeasured perf regression under active investigation (`CODEX-TASK-001/002`);
adding hot-path lock traffic now would **contaminate the very measurement those tasks exist to
take.** Correct, but the worst possible timing.
- **An immutable generation snapshot** (Sol's suggestion) is the right long-term architecture and a
real refactor of `UVoxelStrateManager`'s whole API surface. Too large to improvise, and it belongs
in a design conversation.
**The drain has zero hot-path cost**, reuses machinery already proven in `EndPlay`, and its only
cost — a brief stall — lands exclusively on **human-initiated editor actions** (asset edit, rebuild,
seed change). It never occurs during play.
## What to build
### 1. A pause flag distinct from shutdown
Add to `AVoxelWorld`: `std::atomic<bool> bGenerationPaused{false};`
⚠️ **Do NOT reuse `bShuttingDown` for this.** It would work mechanically, but it means "we are tearing
down" and a future reader would be misled about lifetime. Introduce a small helper used at the
existing gate points:
```cpp
FORCEINLINE bool ShouldAbortWork() const
{
return bShuttingDown.load(std::memory_order_relaxed)
|| bGenerationPaused.load(std::memory_order_relaxed);
}
```
Route the **existing** checks through it — the submission gate (`VoxelWorld.cpp:638`) and the
in-task checks (`:1467`, `:1474`). **Do not add new check points**; do not change what those sites do
when the check is true.
### 2. An RAII scoped pause, modelled on `EndPlay`'s drain
`EndPlay` (`:323–334`) already implements this exact pattern: raise the gate, then spin until
`ActiveTaskCount` reaches 0. Mirror it.
```
FScopedGenerationPause guard(this);
if (!guard.Acquired()) { /* log error, DO NOT mutate, return */ }
```
- **Ctor:** set `bGenerationPaused = true`, then wait for **both** `AVoxelWorld::ActiveTaskCount == 0`
**and** the decoration tasks to finish. Decoration tasks are counted by the file-static
`GActiveDecoTasks` in `VoxelContentManager.cpp` and already drained by `NotifyShutdown` (`:65–80`) —
add a small public drain/wait accessor on `UVoxelContentManager` rather than exposing the counter.
- **Dtor:** always clear `bGenerationPaused`, including on the failure path.
### 3. ⚠️ FAIL SAFE — this is the most important line in the spec
If the deadline expires with tasks still running: **DO NOT MUTATE.** Log an error naming the
function, clear the flag, and return, leaving the world in its previous consistent state. The user
can retry the edit.
**Mutating anyway is what the bug already does.** A timeout that proceeds is not a fix. The three
dangerous call sites must each be structured so the `Initialize` call is *unreachable* unless the
pause was acquired.
Use a generous deadline (≥ 5 s) and log at `Error` when it expires — a silent skip would look like
the edit simply didn't apply.
### 4. Wrap the three call sites
`RebuildStrates`, `OnObjectModifiedInEditor`, `ChangeSeed`. The pause must cover **all** the mutation,
including `ChangeSeed`'s writes to the generator's `Seed` / `OriginSpineRadius`, and it must be
released **before** `RegenerateAllChunks()` so regeneration can submit work. **`BeginPlay` is not
wrapped.**
## ⚠️ Invariants
1. **No density, mesher, or geometry code may change.** This must not move one bit of generated
terrain. If your diff touches `VoxelGenerator.cpp`, `VoxelDensityOpStack.cpp`,
`VoxelCaveMorphology.cpp` or `VoxelMarchingCubesMesher.cpp`, stop — wrong site.
2. **No deadlock.** The pause is taken on the **game thread**. Verify by reading that chunk tasks
never block on the game thread (they read the generator and `Enqueue` to an MPSC queue, which is
non-blocking) — so a drain is bounded. **State in your report that you checked this**, and if you
find any worker path that waits on the game thread, STOP and report it instead of proceeding.
3. **`ProcessQueue` stays `EQueueMode::Mpsc`.** Do not touch it.
4. **Do not change `EndPlay`.** Its 3-second timeout is a separate, deliberate decision
(audit VF-02) and is Jahni's call, not part of this task.
5. **Carry the `Epoch`** through anything you touch; do not reorder the existing epoch bump relative
to `RegenerateAllChunks`.
6. Comments are French + English; match the surrounding file.
## Acceptance
- `stat`/gameplay unchanged; **generated terrain bit-identical** (the equivalence tests and every
box-verdict number must be untouched — this change cannot reach them).
- Editing a strate asset while the world streams: brief stall, then the edit applies. **No crash.**
- The failure path is reachable and honest: if the drain times out, an `Error` log names the function
and the world keeps its previous state.
- `git diff --stat` should list `VoxelWorld.cpp`, `VoxelWorld.h`, and `VoxelContentManager.{h,cpp}`
for the drain accessor. Nothing else.
## Notes for the reviewer (Claude)
- Confirm the three dangerous sites cannot reach `Initialize` when the pause was not acquired, and
that `BeginPlay` is untouched.
- Confirm the dtor clears the flag on **every** path including early return.
- Confirm `ShouldAbortWork` replaced the existing checks rather than adding new ones, and that
`bShuttingDown`'s own semantics are unchanged.
- Confirm the deco drain is included — chunk tasks alone are not the whole reader set.
@@ -0,0 +1,150 @@
# Codex task 008 — (A) fix the measured column-memo thrash, (B) diagnose why T1.d never fires in game
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
Two independent changes in different subsystems, deliberately bundled into one build because their
signals cannot contaminate each other: (A) is SurfaceWorld column caching, (B) is a counter in the
cave branch of `ClassifyTile`.
---
# PART A — replace the hashed column memo with a direct-indexed box
## This is now MEASURED, not suspected
`stat VoxelForge` in the running game, SurfaceWorld-dominated flight:
```
Column Memo Hits avg 102,300.84
Column Memo Misses avg 17,683.60 → miss rate 14.7%
```
Predicted **14.0%** if the table thrashes, **1.4%** if it does not. It thrashes. Second confirmation
from a different statistic: 17,683 misses ÷ 2.13 tiles meshed = **~8,300 column recomputes per
tile**, where a healthy cache does ~1,225 — **6.8×**.
**The cause.** `FSurfaceColumnSource::GetColumn` (`VoxelDensityOpStack.cpp` ~615) uses a
**direct-mapped, 4096-entry hashed** table. A direct-mapped table evicts on *collision*, not on
fullness: 1225 columns per tile in 4096 slots is a load factor of 0.30, at which ~317 columns (26%)
share a slot and evict each other — **on every one of the ~35 Z planes**, because the mesher
pre-samples Z-outermost (`VoxelMarchingCubesMesher.cpp` ~226). Each miss recomputes the entire height
stack: structural source, cliff (four structural resamples), terrace, layer-line, beach, ceiling.
## The fix — copy the scheme that already works, one file away
`GSurfColCache` / `FSurfaceColumnBox` in `VoxelGenerator.cpp` (~152, and its use at ~737) is the
original path's solution to the identical problem: a **direct-indexed box** —
`CI = (IY - Box.BaseY) * Dim + (IX - Box.BaseX)` with a `Computed[CI]` flag — centred on the first
sample and rebuilt when a query leaves it. **No hash ⇒ no collisions ⇒ every column computed exactly
once.** Read it before writing; you are porting a proven scheme, not inventing one.
Apply the same structure inside `FSurfaceColumnSource`, keeping it `thread_local`.
## ⚠️ The invariant that must not be lost in the port
The current memo's validity check is `S.Key != ColumnKey || S.X != WorldX || S.Y != WorldY`, and
`ColumnKey` is built in `PrepareChunk` from **strate + layout version + seed + `ParamsFingerprint`**.
**`ParamsFingerprint` is load-bearing and its absence was a real shipped bug** — without it, two
stacks of the same strate with different params shared columns, the overhang silently vanished, and
only 69 of 20000 samples showed it. The comment at the site records this. **The new box's validity
key must still contain all four**, or you reopen a fixed bug. (Note `GSurfColCache` itself keys on
`(box XY, StrateKey, Seed, LayoutVersion)` **without** the fingerprint — do **not** copy that part;
it is the weakness the op-stack memo deliberately closed.)
Also keep the full XY comparison semantics: a lookup must never return a column computed for a
different XY. With a direct-indexed box that is structural (the index *is* the XY), but the box
bounds check must be exact.
## Keep the counters
`ColumnMemoHit` / `ColumnMemoMiss` must keep working, incremented on the same meaning (miss = a
column was recomputed). They are the before/after instrument.
## Invariants
1. **Density must not move by one bit.** This changes *caching*, never a computed value. The eight
equivalence tests — especially `SurfaceHeightEquivalence` and its overhang section — must stay
bit-identical. If your diff changes what `TerrainStack.EvalHeight` / `CeilingStack.EvalHeight`
compute, or the overhang gate maths, you have the wrong site.
2. Both consumers keep working: the source (`~858`) and the overhang (`~923`, via
`Column->GetColumn`). The overhang **must** see the same column the source did — that is
"by construction rather than by convention", and the current code says so.
3. Sizing: state in a comment how many columns a tile needs (a 35×35 grid = 1225) and size the box so
one tile fits without eviction, as `FSurfaceColumnBox` does.
4. `thread_local` stays; no shared mutable state across workers.
## Acceptance
- `ColumnMemoMiss` drops roughly **10×**; miss rate goes from **14.7% → ~1.5%**.
- The eight equivalence tests stay green and bit-identical.
- If the miss rate does **not** fall, say so plainly — a fix that does not move its own instrument is
a failed fix, not a partial one.
---
# PART B — name which guard stops T1.d in the running game
## The problem
`Tiles Operator Stack Solid` / `Air` **never appeared** in `stat VoxelForge`, while the harness proves
11 of 40 tiles at production defaults. Rows only render in frames where a counter fires, so site B is
never reached in game. The cave branch of `UVoxelGenerator::ClassifyTile` has **13 `return
EVoxelTileClass::Mixed` paths** and we cannot tell which one fires.
## What to build
Add DWORD counters to the existing `stat VoxelForge` group (`VoxelStats.h` / `.cpp`) that attribute
the bail, grouped by *reason* rather than one per line:
| counter | fires when |
|---|---|
| `CaveBailNotOpStack` | `UsesOperatorStackForChunk` is false (either the initial check or the per-chunk sweep) |
| `CaveBailMixedContent` | `bAnyNonCave` — the tile also touches a gap or SurfaceWorld chunk — or a second cave slot, or out-of-layout |
| `CaveBailParams` | the params `Memcmp` disagreed across the box, the archetype differed, or `NumChunkCoords > 27` |
| `CaveBailStackVerdict` | the stack built fine but `ClassifyBox` returned `Mixed` |
| `CaveBailDisturbance` | the final `bCanSolid == bCanAir` after disturbances |
| `CaveBailNoStack` | `VF_BuildOpStackForChunk` returned false |
Increment **exactly one** per bail, immediately before the `return`. Together with the existing
`TilesOpStackSolid` / `TilesOpStackAir`, one underground flight then names the cause outright.
## Invariants
1. **DO NOT change any control flow, condition, or return value in `ClassifyTile`.** Every `return`
there is a conservative guard that fails to `Mixed`; a wrong verdict leaves a tile with no
geometry and no collision. Add counters beside the existing returns and nothing else.
2. `ClassifyTile` is `const` and runs on **worker threads** — `INC_DWORD_STAT` only, never a
`static int32++`. It routes through `FThreadStats::AddMessage` (per-thread packets), which is why
it is safe.
3. Zero cost when `STATS == 0`: compute nothing outside the macros.
4. Do not touch the non-cave parts of `ClassifyTile` (gap / SurfaceWorld / column scan).
## Acceptance
Fly underground in a TunnelNetwork strate: exactly one bail counter should dominate, or
`TilesOpStackSolid` should finally appear. Either outcome is a result.
---
# Shared rules
- **NEVER build, compile or run the editor or the tests.** Stop when the code is written.
- **Do not `git commit`, `git push`, `git checkout`, `git stash`, `git restore`.** Uncommitted work
in the tree must survive.
- Comments are French + English; match the surrounding file.
- Macro spelling: `KINDA_SMALL_NUMBER`, not the `UE_`-prefixed form.
- When inserting anything into `VoxelDensityOpStack.cpp`, put it **above the labelled end of the
anonymous namespace** — anchoring on the FACTORIES banner puts it outside and the brace closes
nothing. This mistake has been made twice in that file.
## Report
1. The diff for Part A and Part B separately.
2. `git diff --stat`.
3. Explicit confirmation that: no height-stack or overhang maths changed; the new column key still
contains strate + layout + seed + `ParamsFingerprint`; `ClassifyTile`'s control flow and return
values are untouched; exactly one bail counter fires per bail path.
4. Likely compile-error spots, specifically.
5. Anything in this spec that contradicts the code — **stop and say so rather than guessing.**
+129
View File
@@ -0,0 +1,129 @@
# VoxelForge Task 1: profiler, batch explorer, and research handoff
Research date: 2026-09-09. This note records the implementation and the read-only web research for Task 1. No RealtimeMeshComponent files were changed.
## 1. Measurement result
The old result in the task brief was 14.74 us/sample on the profiled density path versus 1.28 us/voxel without instrumentation: about 11.5x apparent overhead. That is consistent with putting `FPlatformTime::Cycles64()` around a roughly 100 ns operation. The timer call is not free, and at that granularity the measurement is mostly the measurement.
The new default is sampled profiling:
- Coarse calls and counters stay cheap and are collected in thread-local state.
- `GetDensityAt` uses a deterministic count interval of 64 calls and one amortized block timer, rather than one timer pair per density call.
- Full cycle timing remains an explicit opt-in (`-profiledensityfull`) for a small investigation, not the normal batch mode.
- The default sampled mode reports the parent density/mesher wall estimate. Fine per-operation scopes are disabled in that mode; use Full or Unreal Insights for a detailed phase breakdown.
- Sampling and timers only write report counters. They do not choose cells, alter density values, alter task partitioning, or participate in any reduction, so they cannot affect generated geometry.
The timer calibration in the validation run measured one `Cycles64` pair as approximately one platform cycle, or 0.1 us at the reported clock conversion. That is the direct self-check for the suspected timer-dominates-work failure mode.
Validation on the same 64^3 `TunnelNetwork` export:
| measurement | seconds |
| --- | ---: |
| profiled sampled export | 0.4630500004 |
| fresh unprofiled reference export | 0.5584703982 |
| discrepancy | -17.09% |
| profiled/reference ratio | 0.82914 |
Both exports produced geometry hash `235AC2DC`. The commandlet now puts all four values in every profiled report and fails with exit code 2 when the absolute discrepancy exceeds 20% or the reference hash differs. The report also exposes `cpu_us` (sum of worker CPU estimates) separately from `wall_us`/`total_us` (largest-worker wall estimate). Summing CPU time across 15 workers is not a valid wall-clock parent; collapsing those fields was one way to make a parallel profile lie.
The density report has an explicit ledger. In sampled mode, the parent estimate is the measured quantity, fine phase buckets are marked unavailable, and the unattributed remainder makes the ledger sum exactly to the parent. This is honest about what was measured instead of inventing a fine breakdown from disabled timers.
## 2. Batch explorer
The explorer commandlet accepts one JSON file and keeps one editor process alive for every case:
```text
UnrealEditor-Cmd.exe "<HostProject>.uproject" -run=VoxelForgeExplore -batch="E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Saved\BatchValidation.json" -out="E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Saved\BatchValidationRun2" -trace=cpu,counters
```
`-out` must be an absolute directory below this plugin's `Saved` directory. The commandlet writes one `batch.json` below that directory. The input supports, per case: `id`, `seed`, `archetype`, `slot`, `export_size`, `export_step`/`step`/`lod`, `modes`, `operator_stack`, `profile_density`, `profile_density_full`, `profile_lod`, render/failure-focus settings, and a per-case budget. The parser caps one batch at 256 cases and clamps the total budget to 30 minutes; each case also has a bounded budget.
Before every case, the commandlet resets the density profile and all per-case report state, then applies the case's profile settings. The output contains per case: mesh seconds, microseconds per voxel, triangle count, geometry hash, counters, profile mode, and the case exit code. Invalid or timed-out cases are represented in the same machine-readable output rather than silently disappearing.
The final three-case confirmation completed in 6.59 seconds of commandlet case time after the single process launch:
| case | triangles | mesh seconds | hash | result |
| --- | ---: | ---: | --- | --- |
| repeat_unprofiled | 2,461 | 0.391420 | `235AC2DC` | pass |
| repeat_profiled | 2,461 | 0.515374 | `235AC2DC` | self-check pass |
| maze_lod1 | 5,218 | 0.277830 | `784EBF92` | pass |
The standalone profiled 64^3 case and the `repeat_profiled` case inside the batch both produced `235AC2DC`, 2,461 triangles, 343,000 tunnel lookups, and 554,381 passage evaluations. That is the contamination check: same final build, seed, archetype, slot, export size, step, and modes produced the same geometry and coarse counters alone and in a reused process. The standalone profiled export also matched its fresh unprofiled reference hash.
## 3. Cache footprint answer
The historical 15-worker report that prompted the question was:
```text
capacity_entries=3840 valid_entries=1542 static_bytes=2534400
dynamic_bytes=428627716 total_bytes=431162116
avg_valid_entry_bytes=278703.6 largest_entry_bytes=541344
tunnel_valid=1054 tunnel_dynamic=345243636
roomgraph_valid=488 roomgraph_dynamic=83384080
```
That is 431,162,116 bytes (411.2 MiB) in aggregate and 278,703.6 bytes per valid entry on average (278.7 kB decimal, about 265.7 KiB). The number is not one monolithic 272 kB struct. Each worker owns cache slots, and valid entries contain variable-length arrays.
A representative 128^3 breakdown added by this task was 360,063,124 bytes total:
| member family | tunnel cache | room-graph cache |
| --- | ---: | ---: |
| slot storage | 1,658,880 | 875,520 |
| op stack | 8,264,000 | 0 |
| rooms | 47,876,912 | 31,822,512 |
| room-floor joins | 4,936,512 | 3,278,976 |
| tunnels and control arrays | 5,560,248 | 3,591,584 |
| support-column entries | 7,153,328 | 0 |
| support columns | 30,699,008 | 0 |
| room index bins + candidates | 30,759,720 | 22,700,380 |
| tunnel index bins + candidates | 60,972,480 | 41,583,988 |
| tunnel-world index bins + candidates | 34,635,272 | 23,693,804 |
| dynamic total | 230,857,480 | 126,671,244 |
| static + dynamic total | 232,516,360 | 127,546,764 |
The dominant allocation is the spatial index bin/candidate storage, followed by rooms and support-column arrays. Slot storage is comparatively small. The exact per-entry average moves with the generated case and cache occupancy, which explains why the 128^3 confirmation is not numerically identical to the historical 411 MiB run.
## 4. Research findings
### RealtimeMeshComponent, read-only
At research time the upstream releases page lists v5.3.2 as the latest tagged release: [RMC releases](https://github.com/TriAxis-Games/RealtimeMeshComponent/releases). The disposable build host's copy identifies itself as `VersionName: 5.4`, so the upstream tag number is not a safe linear comparison; this project also carries custom changes. The visible upstream history includes a 5.1 game-thread/render-thread restructuring and a 5.0.5 release mentioning mesh-update and memory-overuse fixes. The public release page does not provide a complete API diff against this custom 5.4 copy, so I am treating those as compatibility signals rather than promising source compatibility. The local copy was not upgraded, diffed destructively, or otherwise modified.
The current upstream guidance is to build an `FRealtimeMeshStreamSet` as a CPU-side value on a worker, then cross to the game thread to create/update the component section group; use a weak object pointer for the handoff. The structure documentation describes a game/render proxy mirror and distinguishes in-place dynamic/compute updates from static proxy recreation. See [updating mesh data](https://triaxis.games/realtime-mesh/docs/component-core/updating-mesh-data/) and [RMC component structure](https://triaxis.games/realtime-mesh/docs/component-core/structure/). The practical candidate is therefore worker-side stream construction plus the smallest possible section/buffer update, not touching a `URealtimeMeshComponent` from a worker. Custom RMC changes make this a high-risk follow-up.
### UE 5.7 tasking and tracing
Epic's UE 5.7 API documentation says `ParallelForWithExistingTaskContext` gives each spawned task a task-local workspace, and `MinBatchSize` limits launches to `DivUp(Num, MinBatchSize)` workers. The pre-work variant runs caller work before that thread helps the parallel loop. Both pages explicitly warn against clogging the task graph with long-running or blocking work: [existing task context](https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/Core/ParallelForWithExistingTaskConte-) and [pre-work task context](https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/Core/ParallelForWithPreWorkWithTaskCo-). The next tuning target should be measured batch size and task-local scratch reuse for short density/cell jobs, not more tiny tasks.
UE's built-in tracing is the better fine-grained instrument. The documented channels include CPU, Counters, Task, and StackSampling; `-trace=cpu,counters` is a valid launch selection. `TRACE_CPUPROFILER_EVENT_SCOPE` with static scope names and `CountersTrace.h`/`TRACE_COUNTER_*` are the low-cost native hooks. See [Trace channels](https://dev.epicgames.com/documentation/en-us/unreal-engine/trace-in-unreal-engine-5), [the tracing guide](https://dev.epicgames.com/documentation/en-us/unreal-engine/developer-guide-to-tracing-in-unreal-engine?lang=en-US), and the [Unreal Insights reference](https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-insights-reference-in-unreal-engine-5). Timing Insights supplies timers, counters, callers/callees, and task execution views; Task Graph Insights exposes execution paths and critical paths: [Timing Insights](https://dev.epicgames.com/documentation/en-us/unreal-engine/timing-insights-in-unreal-engine?lang=en-US) and [Task Graph Insights](https://dev.epicgames.com/documentation/unreal-engine/task-graph-insights-in-unreal-engine-5?lang=en-US).
This task therefore keeps the lightweight sampled report for automated self-checks and adds native CPU scopes/counters for investigation. Insights should own future fine per-scope and task scheduling analysis; the hand-rolled profiler should not grow back into a timer around every density call.
### Marching cubes and density generation
The upstream [Transvoxel overview](https://transvoxel.org/) confirms that transition cells use local voxel data and a bounded lookup table to stitch different-resolution meshes without cracks. It is relevant to LOD boundary work and dynamic retriangulation, not a reason to change the current mesh in this task. A classic SIMD study reports nearly 4x over an unoptimized serial Marching Cubes implementation and about 2x over compiler-optimized C: [Newman et al., *High performance SIMD marching cubes*](https://www.sciencedirect.com/science/article/abs/pii/S0097849303002760). Those are historical results on older x86 and should be treated as an upper-bound signal, not a VoxelForge forecast.
## 5. Ranked next-task candidates (not implemented here)
The magnitude estimates are expected ranges for the affected stage, inferred from the sources and this run's counters; they are not promises for end-to-end time.
| rank | candidate | expected magnitude | risk |
| ---: | --- | --- | --- |
| 1 | Conservative macrocell/cell empty-space early-out using deterministic density bounds, with a fallback on boundary uncertainty | 2–10x on empty or mostly empty chunks; smaller on surface-heavy chunks | Medium-high: bounds must never skip a crossing cell or change traversal order |
| 2 | Reuse one canonical density grid/field across meshing, LOD, export, and renderer preparation; invalidate by seed/slot/parameters | 20–60% less repeated density work and memory bandwidth where consumers overlap | Medium: cache lifetime and invalidation |
| 3 | Tune `ParallelFor` granularity and task-local scratch for short jobs; measure `MinBatchSize`, worker count, and critical path in Insights | 5–25% less scheduling/coordination cost | Low-medium: load imbalance and contention can reverse the win |
| 4 | SIMD/SoA batches for density and Marching Cubes table work | 1.5–4x for the vectorized stage in favorable workloads | High: exact cross-machine geometry and floating-point contraction; no runtime CPU dispatch is allowed |
| 5 | Hierarchical sparse/occupancy metadata or distance bounds to skip whole regions before field evaluation | 2–20x in sparse worlds | High: build/update cost, conservative topology, and invalidation |
| 6 | RMC partial section/buffer updates after worker-side stream construction | 10–50% less renderer update/bandwidth cost when only part of a section changes | High: custom RMC modifications and game/render-thread ownership |
| 7 | Transvoxel transition cells for LOD boundaries | 10–30% less boundary patch/rebuild work in LOD-heavy scenes; also removes crack workarounds | Medium-high: transition topology and hash stability |
The first safe experiment should be the deterministic early-out or field reuse, after a baseline with the corrected profiler. SIMD is deliberately below those despite its attractive paper result because the project's cross-machine geometry requirement is absolute.
## 6. Pushback and constraints
The requirement that buckets sum to their parent needs a definition in a parallel program. Raw worker CPU time can exceed wall time by the worker count; the report now exposes both and makes the wall estimate the parent used for the self-check. A fine raw-bucket sum cannot be claimed when sampled mode intentionally does not time those scopes, so the report says that explicitly and accounts for the remainder.
The 20% self-check threshold is a practical alarm, not a proof of zero measurement cost: OS scheduling, cache state, DDC state, and editor startup can move two exports. The geometry hash check is exact; repeated cases or a median of several runs should be used when a performance regression is close to the threshold.
The sampling decision is non-deterministic only in its elapsed-time observation. It is isolated from the generation inputs and outputs. The batch commandlet resets profile/cache-report state per case, but it intentionally reuses the engine process to remove startup cost; the standalone-versus-batch geometry hash is the proof that this reuse did not contaminate the tested case.
+140
View File
@@ -0,0 +1,140 @@
# Task 2 performance report
Date: 2026-09-09
Repository: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge`
Work began at `experimental` / `e63f016`.
## Measurement rule
All performance numbers below are per-case `run.elapsed_seconds` from the batch report. This is
the wall clock for a real commandlet case through the export or render result; it includes the
shared-density-grid build when enabled. `summary.mesh_seconds` and profiler buckets are included
only as diagnostics, never as the performance claim.
The export comparison is the same `128^3` `TunnelNetwork`, seed `1337`, slot `1`, operator stack,
step `1`, with three cases in one commandlet process on each side. The 17% reporting floor is
therefore met by both run count and the retained density-grid result.
## Part 1 — block early-out
I tested an `8^3` block reject behind conservative fallback logic. On this cave/stack world, every
one of the 4,096 blocks was left uncertain and went through normal sampling:
| batch | runs | end-to-end wall seconds | blocks skipped | skip rate | result |
| --- | ---: | --- | ---: | ---: | --- |
| baseline | 3 | 3.805057, 3.522129, 3.569186 (avg 3.632124) | — | — | reference |
| block reject | 3 | 6.341454, 6.562621, 7.300234 (avg 6.734770) | 0 / 4,096 | 0.00% | **reverted** |
The candidate added classification work without skipping a block (+85.42% wall clock). It also
did not produce a useful supremum for the complete cave stack, so I did not promote a guessed
`max` parameter into a topology decision. `ClassifyTile` remains untouched: its stack, disturbance,
diff-layer, and cave guards are not a safe substitute for a new block proof. A false AllSolid
verdict here would delete a 3D cave, which is not an acceptable trade.
This did not clear the floor and was reverted.
## Part 2 — density-grid reuse
The canonical mesher requests 35^3 samples per 32-cell tile. For 64 tiles that is:
- tile requests: `64 * 35^3 = 2,744,000`;
- unique points in the expanded 128-cell lattice: `131^3 = 2,248,091`;
- duplicate evaluations: `495,909` (18.07%).
The first implementation evaluated the shared lattice as Z planes. It removed all duplicates but
was slower because it destroyed the generator's spatial locality:
| candidate | runs | end-to-end wall seconds | duplicates | result |
| --- | ---: | --- | --- | --- |
| matched control | 3 | 3.668954, 3.633445, 3.655658 (avg 3.652686) | 495,909 → 495,909 | reference |
| global-plane precompute | 3 | 5.349520, 5.352927, 5.510752 (avg 5.404400) | 495,909 → 0 | **reverted** |
| owner-grid precompute | 3 | 1.950207, 1.973673, 2.161305 (avg 2.028395) | 495,909 → 0 | retained candidate |
The retained implementation partitions the shared lattice into 64 disjoint rectangular owner
regions. Each global sample is generated exactly once, then each tile copies its local halo window
from the immutable run-scoped grid. The precompute is deliberately inside the wall-clock timer;
the final three-case repeat measured:
| batch | runs | end-to-end wall seconds | average |
| --- | ---: | --- | ---: |
| final control (`density_grid_reuse=false`) | 3 | 3.659587, 3.619013, 3.582257 | 3.620286 s |
| final reuse (`density_grid_reuse=true`) | 3 | 1.908017, 1.854344, 1.884170 | 1.882177 s |
That is a **48.01% wall-clock reduction / 1.92x speedup**, clearing the 17% floor. The final
reuse runs averaged 1.474271 s for the shared-grid build, and still won end to end. The 128^3
shared lattice is about 8.6 MiB of float storage. Direct explorer runs default to reuse; a batch
case can pass `density_grid_reuse=false` for a matched control.
The run-scoped handoff is confined to the explorer's finite canonical export. The streaming game
path has no one-shot set of neighboring tiles or invalidation boundary, so it was not given a
locking plane cache that could serialize workers or outlive a generation epoch.
## Part 3 — task granularity
The final control observed 15 worker thread IDs and 64 logical tile invocations. Across its three
runs, the measured tile-job averages were:
| metric | average |
| --- | ---: |
| fastest logical tile job | 0.188817 s |
| mean logical tile job | 0.593751 s |
| slowest logical tile job | 0.955788 s |
| sum of tile-job durations | 38.000045 s |
| tile parallel wall | 3.150925 s |
| launch-fringe estimate | 14.1 us |
| workers / logical jobs | 15 / 64 |
I tested `MinBatchSize=4` with three matched cases: wall seconds `3.590884, 3.634734,
3.753986` (avg `3.659868`) versus the final control average `3.620286`. The tile parallel bucket
was slightly lower (`3.125197` s vs `3.150925` s), but end-to-end wall clock was **1.09% slower**.
The observed worker count and logical job count stayed 15 / 64, and the launch-fringe estimate
remained about 13.1 us. This is the required bucket-win/no-wall-win case: the tuning was reverted;
the production/default setting remains `MinBatchSize=1`.
`ParallelForWithExistingTaskContext` was not adopted. The measured launch fringe is negligible
relative to 0.59 s tile work, and the mesher already uses thread-local scratch storage. No claim is
made here about the separate game workload of many small streaming submissions.
## Part 4 — renderer
No renderer rewrite or RMC change was justified. The source audit and three final render cases
showed:
- the canonical mesh is built once per explorer world;
- the triangle cache/BVH is built once and reused by all eight viewpoints;
- each viewpoint owns one `FColor` output allocation;
- the per-pixel traversal uses a fixed stack array, not a heap allocation;
- camera movement does not call the mesher again.
The three 512x288 render cases had total case wall seconds `8.704730, 8.985744, 8.517606`
(avg `8.736027`), including camera-seed walking. Raster-only diagnostics averaged 1.482921 s
for eight views, 0.286297 s for the first view, 0.170946 s per additional view, and 0.083190 s
for one-time acceleration construction. This is consistent with the owner's approximately
0.15 s/view observation; meshing benefits from Part 2 automatically. There was no renderer
change, so there is no before/after performance claim to report or keep.
## Memory/index follow-up
The existing profiler research measured 431,162,116 bytes (411.2 MiB) across 15 workers in the
large run. In the representative 128^3 breakdown, spatial-index bins and candidate arrays alone
accounted for 214,345,644 bytes, inside a 360,063,124-byte cache total. The index addition did not
show an end-to-end wall-clock gain in the prior measurements.
I did not remove it silently. The explicit follow-up proposal is an index-on/index-off batch
ablation before deletion: removing it should recover roughly that allocation, at the cost of
broader room/tunnel candidate scans and potentially slower density queries. That trade is not
proven by this task's export comparison.
## Determinism, invariants, and scope
All three final control hashes and all three final reuse hashes are `ED0F1F31`; every case reported
`json_deterministic=yes`. The three render cases also repeated the same geometry hash
`847BE4B9`. Owner regions are disjoint, tile results are merged in fixed Z/Y/X order, no RNG or
runtime CPU-feature dispatch was added, and the shared grid is read-only after its deterministic
fill. The only worker writes are to distinct cells of that run-owned output lattice; generator and
world state remain read-only.
The build succeeded with UnrealBuildTool after the final source change. Changes are limited to the
VoxelForge source and this report, with generated evidence under the plugin's `Saved` directory.
RealtimeMeshComponent was not modified. No VoxelM root, sibling plugin, `Content`, or `Config`
files were written; no links or historical worktrees were created.
+2244
View File
File diff suppressed because it is too large. Load diff
+60
View File
@@ -0,0 +1,60 @@
# Codex Working Guidance
## Role
Codex is the implementation agent for this repository. Work directly on the assigned task; Claude Code is a reviewer and coordinator, not a concurrent editor of this checkout.
## Task contract
For each task, the request should state:
- Desired outcome and acceptance criteria.
- Scope: relevant folders, systems, or files, and any areas that must not change.
- Validation expected: tests, build, editor check, or a stated reason a check cannot run.
Use the repository's existing conventions and tools. Make the smallest coherent change that satisfies the task. Do not broaden scope without calling it out as optional work.
## Visibility and progress
Before any multi-step work or tool use, send one brief update stating the first action.
Send another brief update only when a major phase finishes or a discovery changes the approach. Every update must contain a concrete result, such as `Found`, `Changed`, `Confirmed`, or `Blocked`.
If progress is blocked for more than two minutes, stop waiting and report:
1. Current state and files changed.
2. Last command or action and its result.
3. Exact blocker.
4. Safest next action or the smallest decision needed from the user.
Never wait silently for a reviewer or another agent. Continue when the next safe implementation step is clear.
## Implementation loop
1. Inspect the relevant code and local instructions before editing.
2. State a short plan when the task has multiple meaningful parts.
3. Implement the change, keeping unrelated changes intact.
4. Inspect the diff for accidental edits and integration issues.
5. Run the most relevant available validation.
For Unreal work, prefer targeted validation first: module/plugin build, automated test, compile check, or a minimal editor/runtime smoke test appropriate to the changed behavior. Do not claim validation passed unless it was actually run.
## Collaboration with Claude Code
- Codex owns edits and commands in this checkout while its task is active.
- Claude reviews plans, diffs, test output, and reported blockers at explicit checkpoints.
- Treat Claude feedback as a prioritized fix list. Address actionable items, then re-validate affected behavior.
- Do not have both agents edit the same working tree simultaneously. For genuine parallel implementation, use separate git worktrees with non-overlapping tasks.
## Completion report
Finish with a concise report containing:
- What changed and why.
- Files changed.
- Validation run and its result; if not run, why and the next best check.
- Any remaining risk, assumption, or follow-up.
## Reviewer prompt for Claude Code
> You are the reviewer and coordinator for this task. Do not edit the shared checkout. Review the supplied Codex plan, diff, and validation output against the acceptance criteria. Return only prioritized, actionable findings, including file and line references where possible. If there are no blocking issues, say so explicitly.
+887
View File
@@ -0,0 +1,887 @@
# DESIGN-SOL-2026-09-15-CODE: round two, Sol reads the code
Continues `DESIGN-SOL-2026-09-15.md`. At the owner's request, Sol's round-one session was forked
(`codex exec fork`, `gpt-5.6-sol`, reasoning high, read-only sandbox, ~594k tokens). Sol was given
the repository and asked **what could reasonably change and what it would take to prove each change
worth it**. Advice only: no files were changed, nothing was built or run. The owner's stance: *"open
to big changes, but only if they prove themselves worthy."*
## Orchestrator verification (2026-09-15, against HEAD `cf9904e` and the UE 5.7 engine source)
Checked by reading, not by running anything.
| Sol's claim | Check |
|---|---|
| `FMath::Sin/Cos` reach the MSVC CRT on Win64 | **Verified.** `GenericPlatformMath.h:521/530` returns `sinf`/`cosf`; `FMicrosoftPlatformMathBase` (via `TUnrealPlatformMathSSE4Base`) and the SSE bases override neither. |
| Per-sample lookup machinery is ~5% exclusive, not the 27% inclusive figure | **Verified**, `PERF-SAMPLED.md` lines 11-13: `GetRange` 3.8%, `VF_ForEachSpatialCandidate` 1.2%. My round-one note "the largest gap is the loop direction" overstated it. |
| Parse-once switches are unsynchronised globals parsed on worker threads | **Verified**, `VoxelGenerator.cpp` (`VF_Parse*Switch` called in `GetDensityAt`). In practice every racer writes the same value; the real exposure is a live CVar change mid-generation. |
| `MemCrc32` over raw param structs; the comment says no false positive is possible | **Verified** (`VoxelGenerator.cpp` ~3165). A CRC32 collision is possible in principle (about 2^-32 per comparison); padding can only cause a spurious rebuild. Low probability. |
| Mesher dedups vertices by rounding position x100 | **Verified** (`VoxelMarchingCubesMesher.cpp` `GetOrCreateVertex`). Only amplifies an upstream numeric difference; not an independent source of divergence. |
| ARCHITECTURE says every landing gets a connector to (0,0); the code says a source-fit landing stays local | **Verified contradiction**: `ARCHITECTURE.md` ~518 vs `VoxelStrateManager.cpp` ~2108. One of them is wrong. |
| Lateral-region audit 4/16 | **Verified**, `ARCHITECTURE.md` ~122. That mechanism is explicitly "not ready to promote", so it is not live terrain. |
| Layout shuffle uses a stateful `FRandomStream` | **Verified** (`VoxelStrateManager.cpp` ~1155), after a deterministic sort. Deterministic; only order-coupled when content is added. |
| Coarse LOD derived from fine data would break the clipmap invariant | Agreed (`ARCHITECTURE.md` ~708). My round-one table wrongly listed it as a gap. |
| Noise lattice float-to-int, MXCSR rounding mode, multiplayer edit ordering | Not re-checked in detail; recorded as Sol's claims. Edit ordering matters once networking exists. |
---
## The prompt, as sent
# Phase two: now you get the real code
Thank you for the first answer. It was the most useful design input this project has had in a
while. The owner read it and his reaction was: **"what Sol proposed can be nice. I am open to big
changes, but only if they prove themselves worthy."** That is the question now: **what could reasonably be changed in this code, and what would it take to
prove each change worth it?**
**The earlier rule is lifted: you may now read and search files.** This remains a read-only
consultation:
- Reading commands only (`rg`, `Get-Content`, `git log/show/diff`). Do not write or edit any file.
Do not build, compile, run tests, or launch Unreal. A single test run here takes up to an hour,
and the machine is the owner's.
- Use absolute paths; your shell's working directory can drift.
- Your final message is the whole deliverable. Please list the files you actually read at the end.
## The code
Plugin root: `E:\Projet Unreal\VoxelM\Plugins\VoxelForge` (git branch `experimental`, HEAD `cf9904e`).
Start with these, reading targeted sections rather than whole files; some are over 100 KB:
- `CLAUDE.md` (rules), then `CODEMAP.md` (§2 data flow, §3 symbol→file index, §6 gotchas).
- `ARCHITECTURE.md` §8, especially **§8.10, the performance invariants** that exist for measured
reasons.
- `WORK-NEXT.md`, section "State after 2026-09-15" and "Open design questions for the owner".
- `PERF-SAMPLED.md`, section "Current-tip cost re-rank — 2026-09-15 (1d824aa)" (from line ~1669):
the measured cost map. Two later commits cut a further ~12% (`a668833`, reach gating) and ~4%
(`410788f`, per-chunk routing cache).
- `DESIGN-SOL-2026-09-15.md`: your first answer, plus **my** mapping of it onto this code (the
"Orchestrator notes"). I wrote that mapping from memory of the code; check it and correct me.
- `AUDIT-SOL-2026-09-11.md` and `AUDIT-COLD-2026-09-14.md`: earlier read-only audits, so you don't
repeat them.
- Hot code: `Source/VoxelForge/Private/VoxelGenerator.cpp` (`GetDensityAt`,
`GetDensityWithParams`), `VoxelCaveMorphology.cpp` (tunnel SDF, world-space tunnel core, player-fit
memo), `VoxelDensityOpStack.cpp` (operator stack, fused evaluator), `VoxelStrateManager.cpp`
(strates, passages), the mesher / tile scheduling, and `VoxelForge.Build.cs` (`/fp:precise` is
already set).
## What is measured (don't re-derive; do challenge it if the code says otherwise)
- In-game worker CPU for the standard static scenario is ~27.6 s, down from ~184 s at the start of
this push, with the same world and the same capability. Measured on the headless game path with a
stack sampler plus remove-one ablation.
- **Tried and rejected, please don't re-propose these without new evidence:**
- Merging the warped tunnel SDF with the world-space tunnel core into one pass: the world core is
load-bearing (its control points keep authored floor anchors before warp). Replacing it lost
1,146 player-fit and 975 walk-reachable cells.
- An always-on exact per-sample cull: +23% slower.
- Cheaper value-noise worms: no gain. Worms off entirely saves only ~3%.
- The acceptance rules, which any proposal must live with:
- **Determinism:** same build + same seed = same world on every machine. Bit-identity with
*older* builds is NOT required, so a change that alters the field is allowed. Classifiers and
skip proofs must never change the field.
- **Capability:** the existing gate (player-fit 20,830 / walk-reachable 10,909 cells, connected,
on the canonical scenario) must not regress.
- **Performance:** judged on the headless GAME path, static and moving, comparing the final
default build against the committed-tip DLL. A switch-off A/B inside the new build does not
count.
- The operator stack and the composer stay as the authoring language: worlds are composed, not
hand-built.
## What we would like from you
1. **Correct my mapping.** For each row of the table in `DESIGN-SOL-2026-09-15.md`, say whether it
is right, and give file:symbol evidence where it is wrong.
2. **Your ideas against this code.** For each idea from your first answer that survives contact with
the code:
- where it plugs in (files, symbols) and what it replaces or keeps;
- its rough size;
- its risks to capability, determinism, the composer and the §8.10 invariants;
- its expected gain, grounded in the measured cost map. Say what share of the ~27.6 s it could
touch, and mark each number measured or inferred.
3. **Rank them.** Then, for the top one or two big changes, design a **proof-of-worth ladder**:
- Rung 1: the cheapest experiment that could kill the idea early. Ideally instrumentation only,
e.g. how much time goes to per-sample candidate search, and how many candidates a sample sees.
- Rung 2: a prototype scoped to one feature type, behind a console switch.
- Rung 3: full adoption.
Each rung needs an explicit, falsifiable kill criterion, and the numbers that would justify going
on. The owner wants big changes to earn their place, so make the early rungs cheap and honest.
4. **Determinism audit of numerics on the world-deciding path.** We build with `/fp:precise`.
Enumerate the remaining cross-machine hazards with file:line:
- CRT transcendentals (`sin/cos/pow/exp/log/atan2/...`, including through `FMath`);
- any runtime CPU-feature dispatch (SIMD paths chosen at runtime, ISPC);
- float-to-int conversions at boundaries;
- order-dependent accumulation across threads, uninitialised data, timing dependence.
Give severity and the minimal fix for each. In particular: on UE 5.7 Win64, do `FMath::Sin/Cos`
end up in the MSVC CRT (whose FMA3 variant is chosen at runtime), or in engine code? If engine
source is not reachable from here, say so and say what you would check.
5. **What not to graft here**, and why.
This is advice only. Nothing will be changed as a result of this run until the owner has read your
answer and decided, so don't write code or patches. Describe what could reasonably change, and what
it would take.
Push back wherever the code shows my framing or my mapping is wrong. That is worth more than
agreement. Say plainly where you are unsure.
---
## Sol's answer, verbatim
# Executive conclusion
I would not replace VoxelForge’s representation, mesher, streaming model, or operator authoring language wholesale. The current design is already much closer to the “lazy compiled field” architecture from the first answer than the orchestrator notes imply, and the measured reduction from ~184 s to ~27.6 s shows that its major structural choices are viable.
Three changes remain worth serious consideration:
1. A tile-wide, feature-major lowering of the canonical tunnel evaluator. This is the only remaining architectural change with a plausible 8–18% total worker-CPU gain, but it has not yet earned implementation.
2. Gameplay reuse of exact density samples across adjacent tiles. The existing mesher already has most of the consumer-side plumbing, and the theoretical duplicate-sample ceiling is surprisingly large.
3. A canonical numeric ABI for world-deciding math. This is primarily correctness, not performance: `/fp:precise` does not currently close the determinism requirement.
I would also harden the traversal contract. The canonical capability gate passes, but the code and documentation do not support the stronger claim that every composed world has a structurally guaranteed entry-to-exit route.
All performance percentages below distinguish:
- **Measured:** directly present in the supplied profiles or commit evidence.
- **Inferred:** derived from those measurements; not yet benchmarked.
---
# 1. Correction of the orchestrator mapping
There is no table in `DESIGN-SOL-2026-09-15.md` at this HEAD. There are three mapping bullets at [DESIGN-SOL-2026-09-15.md:9](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/DESIGN-SOL-2026-09-15.md:9>). I have treated those as the requested rows.
| Orchestrator note | Verdict | Correction |
|---|---|---|
| Determinism risk from `FMath::Sin/Cos` and UCRT FMA3 dispatch | **Substantively correct; count is stale/ambiguous** | The risk is real and `/fp:precise` does not fix library implementations. The current source contains 42 direct `Sin/Cos` invocations in terrain generation if legacy and fused implementations are both counted, plus eight in deterministic content placement—not simply 34. |
| “The largest gap is the loop direction” | **Plausible, not proved** | Every tunnel sample does query a 2D spatial bucket and iterate candidates, but the exclusive lookup machinery was only about 5% in the newer profile. The old 27% figure was inclusive of the feature kernels. Feature-major batching may still win through SIMD, hoisting and locality, but candidate lookup alone cannot justify a rewrite. |
| Already shares built-first traversal, hash RNG, collision gate, partial narrow-band skipping | **Mixed** | Collision gating and narrow-band/reach proofs are real. Hash-keyed randomness is dominant but not universal. Traversal is structurally constructed for major archetypes, but the universal guarantee is not established, especially for fallback passage landings and lateral-region composition. |
## 1.1 Determinism note
The direct terrain-generation `Sin/Cos` sites are:
- [VoxelGenerator.cpp:1129](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:1129>), `1178`, `1377`, `1456`, `1557`, `1612`, `4708`, `4738`, `5723`.
- [VoxelDensityOpStack.cpp:7507](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:7507>) and `7609`.
- [VoxelHeightOpStack.cpp:232](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelHeightOpStack.cpp:232>).
- [VoxelCaveMorphology.cpp:2931](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:2931>), `3266`, `6826`, `6843`, `7367`, `7487–7488`, `7520–7521`.
- [VoxelStrateManager.cpp:1830](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateManager.cpp:1830>), `1993`, `2008`, `2021–2022`, `2032–2033`, `2045`, `2087`, `2142`.
The duplication matters: several calls belong to parallel legacy/fused implementations, so a raw count overstates unique runtime work while still accurately describing the audit surface.
The engine source resolves the uncertainty conclusively:
- `FMath` inherits `FPlatformMath` at [UnrealMathUtility.h:269](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/Math/UnrealMathUtility.h:269>).
- Win64 aliases `FPlatformMath` to `FMicrosoftPlatformMathBase` at [WindowsPlatformMath.h:18](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/Windows/WindowsPlatformMath.h:18>).
- The Microsoft class inherits the generic implementation and does not override `Sin` or `Cos` at [MicrosoftPlatformMath.h:12](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/Microsoft/MicrosoftPlatformMath.h:12>).
- Generic `Sin` calls `sinf` and generic `Cos` calls `cosf` at [GenericPlatformMath.h:521](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/GenericPlatform/GenericPlatformMath.h:521>) and [GenericPlatformMath.h:530](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/GenericPlatform/GenericPlatformMath.h:530>).
So, on UE 5.7 Win64, `FMath::Sin/Cos` use the MSVC/UCRT functions. They do not use Unreal’s polynomial `FMath::SinCos(float)`, which is separate engine code at [UnrealMathUtility.h:752](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/Math/UnrealMathUtility.h:752>).
Microsoft documents that the x64 CRT selects FMA3 implementations at startup based on CPU support and that the alternate algorithms can produce different results. [`_set_FMA3_enable` documentation](https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/get-fma3-enable-set-fma3-enable?view=msvc-170).
The existing cross-platform test already recognizes the libm hole at [VoxelForgeCrossPlatformTest.cpp:158](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/Tests/VoxelForgeCrossPlatformTest.cpp:158>), but it reports risk rather than enforcing an identical field digest. Therefore constraint 1 is not currently proved.
## 1.2 “Largest gap is loop direction”
What the note gets right:
- A cache builds 2D chunk buckets at [VoxelCaveMorphology.cpp:1325](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:1325>).
- Each sample calls `VF_ForEachSpatialCandidate`, including `GetRange`, at [VoxelCaveMorphology.cpp:1462](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:1462>).
- Tunnel SDF and world-core evaluation perform separate candidate walks at [VoxelCaveMorphology.cpp:7847](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:7847>) and [VoxelCaveMorphology.cpp:8042](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:8042>).
- The canonical fused path remains scalar per sample: `GetDensityAt` calls `GetDensityWithParams` at [VoxelGenerator.cpp:3386](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:3386>).
- The mesher calls `GetDensityAt` once for every required grid sample at [VoxelMarchingCubesMesher.cpp:694](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:694>).
What is overstated:
- The `1d824aa` sampler’s `VF_ForEachSpatialCandidate` share was inclusive of work executed inside the visitor.
- The later `410788f` exclusive attribution puts `GetRange` around 3.8% and `VF_ForEachSpatialCandidate` itself around 1.2%.
- Thus the directly removable search overhead is approximately 5% of the old profile, not 27%.
- The expensive swept-tunnel work remains inside those callbacks. A loop inversion must make that work cheaper—not merely remove the lookup—to justify itself.
“Coarse LOD from fine data is absent” is factually true but not evidence of a gap. Direct evaluation at every LOD is a deliberate clipmap invariant: it maintains roughly constant tile complexity and does not force far terrain to materialize at LOD0. See [ARCHITECTURE.md:708](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:708>).
“Shared halo samples are absent” needs qualification:
- Every tile already has a single local density grid plus a one-sample margin; cell corners and normals are not resampled. See [VoxelMarchingCubesMesher.cpp:656](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:656>) and [ARCHITECTURE.md:652](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:652>).
- `FVoxelSharedDensityGrid` already exists and the mesher can consume it at [VoxelMarchingCubesMesher.cpp:660](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:660>).
- It is populated only by the editor exploration commandlet, culminating in `SetSharedDensityGrid` at [VoxelForgeExploreCommandlet.cpp:1905](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp:1905>).
- Gameplay does not share density samples across neighboring tiles.
So the missing opportunity is specifically **cross-tile gameplay reuse**, not basic grid or halo caching.
## 1.3 “Already shared”
### Traversal: partial, not universal
The current system has real construction-first features: walk-critical tunnel forests, D-floors, option-B winding and structural posts. That part of the mapping is fair.
But two facts prevent me from calling the hard guarantee closed:
1. `ARCHITECTURE.md` says every landing receives a deterministic connector to the origin landing at [ARCHITECTURE.md:518](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:518>). Current code explicitly says the opposite: a source-fit landing remains local and no radial connector is synthesized at [VoxelStrateManager.cpp:2108](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateManager.cpp:2108>). The document is stale or the implementation regressed from its stated contract.
2. The lateral-region audit remains at 4/16 connected cases after landing, including region-zero controls, at [ARCHITECTURE.md:122](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:122>).
`VF_SuggestLandingPoint` can make a local landing safe when it succeeds, but the documentation says unsupported/no-footing cases fall back to historical random reach at [ARCHITECTURE.md:512](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:512>). That is not a construction proof.
The canonical 20,830 / 10,909 gate is valuable regression protection. It is not a proof across seeds, composer rolls and archetype mixtures.
### Randomness: mostly hash-keyed
Most feature decisions are keyed hashes. The layout shuffle still uses one stateful `FRandomStream` after sorting the asset pool at [VoxelStrateManager.cpp:1145](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateManager.cpp:1145>) and [VoxelStrateManager.cpp:1157](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateManager.cpp:1157>).
That is deterministic for the same build and pool. It is nevertheless order-coupled: adding one pool choice can shift every later choice. It therefore falls short of the first answer’s “stable ID plus counter-based randomness” ideal, though it does not presently violate the stated cross-machine rule.
### Collision gate: correct
The implementation now treats actual collision-cook completion as admission authority. This is stronger than speculative prefetch. It satisfies the hard safety part even under overload by stopping motion.
What remains from the first answer is UX improvement: predictive fall/shaft prefetch could make the gate engage less often. It is not required for correctness.
### Narrow bands: correct and substantial
The sealed-solid proof, tunnel-core reach, passage-carving reach, worm block proof, cached topology and lateral-region preparation all exist. They are not “partial” in the sense of being superficial; §8.10 records a considerable exact-skip system.
---
# 2. Ideas that survive contact with the code
## Ranked recommendations
| Rank | Change | Share of 27.6 s it could touch | Expected net gain | Rough size |
|---|---|---:|---:|---:|
| 1 | Tile-wide, feature-major lowering for tunnels | **35–50% inferred**: 9.7–13.8 s | **8–18% inferred**: 2.2–5.0 s | 0.8–1.5 KLOC prototype; 3–6 KLOC production |
| 2 | Cross-tile exact density-grid reuse | Up to **22.9% theoretical/inferred**: 6.3 s; actual unknown | **5–12% inferred** if reuse is high | 0.6–1.2 KLOC prototype; 1.5–3 KLOC production |
| 3 | Canonical numeric ABI | Probably under 5% runtime touched; correctness is the value | Approximately neutral, perhaps −1% to +1% inferred | 0.3–0.8 KLOC plus tests |
| 4 | Traversal-plan contract hardening | 0% of static worker time | Correctness, not speed | 1–3 KLOC plus seed-matrix tests |
| 5 | Exact base-density reuse and canonical edit compaction | **0% measured** in the static scenario | Potentially large only after repeated edits; unmeasured | 1.5–3 KLOC |
| 6 | Deadline/fall-aware prefetch | Moving-streaming work only; unknown | Gate-engagement and latency improvement, not established CPU savings | 1–2 KLOC |
| 7 | Topology-aware underground visibility | Far streaming only; unknown | Speculative | Large |
The `1d824aa` percentages are historical measured shares from a 29.30 s build, not additive and not exact for the current 27.6 s build. Scaling them onto 27.6 s is explicitly inferred.
## 2.1 Tile-wide, feature-major lowering
### Where it plugs in
Add a tile/grid evaluation entry point beside:
- `UVoxelGenerator::GetDensityAt` at [VoxelGenerator.cpp:2799](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2799>).
- `UVoxelGenerator::GetDensityWithParams` at [VoxelGenerator.cpp:3921](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:3921>).
- The mesher grid-fill loop at [VoxelMarchingCubesMesher.cpp:694](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:694>).
Conceptually:
```text
composer/operator stack
↓ lowering
canonical tile program
↓
tile-local SoA buffers
↓
existing density grid
↓
existing two-pass marching cubes
```
It should replace only the repeated scalar execution of the canonical tunnel-network graph. It should keep:
- `FVoxelOpStack` as the authoring language.
- `BuildTunnelNetworkStack` and the fused-evaluator designation.
- `FChunkSDFCache` and its search-box validity.
- The warped tunnel SDF and world-space core as separate semantic stages.
- The common structural tail, boundary seal and edit layer.
- Scalar fallback for custom recipes, fractional point queries and unsupported stacks.
- The existing marching-cubes grid, margin and two-pass cell loop.
The production algorithm I would try is:
1. Prepare the same chunk/tile context once.
2. Allocate thread-local SoA arrays for density, warped coordinates, nearest-room ownership and world-core handoff flags.
3. Compute sample-invariant and column-invariant data in batches.
4. Iterate rooms/tunnels in existing stable feature-index order.
5. Clip each feature’s conservative 3D influence bounds to the tile lattice.
6. Traverse X-contiguous spans and evaluate four or eight positions together where the formula permits.
7. Apply smooth unions in the same per-sample feature order.
8. Run later operators and the structural tail in their canonical order.
9. Write the existing `DensityGrid`.
The index builder inserts item indices in ascending feature order at [VoxelCaveMorphology.cpp:1445](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:1445>). Maintaining that order matters because smooth-min and other floating operations are order-sensitive.
### Measured basis
Historical remove-one shares:
- Tunnel core: **20.84% measured**, corresponding to 5.75 s when scaled to 27.6 s.
- Tunnel SDF: **12.64% measured**, corresponding to 3.49 s.
- Room SDF: **4.24% measured**, corresponding to 1.17 s.
- Cave warp: **6.68% measured**, corresponding to 1.84 s.
- Passage carving/structure can be incorporated later.
The tunnel core plus tunnel SDF therefore account for **33.48% measured historically**, or 9.24 s scaled to the current total. A batch evaluator cannot eliminate that geometry; it can only reduce its loop, load, branch and arithmetic cost.
The newer exclusive profile says lookup machinery itself was approximately **5% measured historically**, about 1.38 s scaled. This is why “invert the loops” alone is not enough. The hoped-for gain must come from:
- Hoisting tunnel metadata.
- Avoiding repeated lambdas/cache lookups and repeated world-coordinate construction.
- SIMD across adjacent X samples.
- Reusing centerline segment data across samples.
- Better sequential reads and writes.
- Clipping feature loops in 3D rather than retrieving a 2D bucket and rejecting on Z for every sample.
### Risks
- **Capability:** high. The core/SDF handoff and room-floor ownership are subtle. The rejected merge shows this is load-bearing.
- **Determinism:** medium-high. SIMD must use the same fixed instruction path on every x64 machine. Any conservative span skip must be reference-checked and field-identical.
- **Composer:** low if this remains a lowering backend. High if it becomes a separate hand-authored terrain definition.
- **§8.10:** high implementation risk. It must preserve search-box validity, lateral per-chunk preparation, reach masks, the margin grid, cancellation points, TLS reuse and direct evaluation at each LOD.
- **Maintenance:** high if the scalar and batched semantics fork. Shared primitive functions and generated/lowered stage descriptions are preferable to copying thousands of lines.
I would not generalize it to every archetype first. TunnelNetwork/Underwater already have a canonical fused designation and dominate the measured cost.
## 2.2 Exact density sharing across gameplay tiles
This is a more concrete surviving piece of the “shared bricks/halos” proposal than a full sparse-brick rewrite.
### Why it may be larger than it looks
A normal full-resolution tile has 32 cells but samples a 35³ grid because of the cell boundary and one-point normal margin.
For an ideal dense array of adjacent same-LOD tiles, the asymptotic duplicate fraction is:
```text
1 − 32³ / 35³ = 23.6%
```
This is an inferred theoretical ceiling. Actual reuse will be lower because of clipmap shell boundaries, Z bands, cancellation and tile completion order.
The old sampled profile attributed **96.9% measured, inclusive** to `GetDensityAt`. If duplicated coordinates had average cost, the absolute theoretical CPU ceiling would be approximately:
```text
23.6% × 96.9% = 22.9% of total
```
That is 6.3 s of the 27.6 s total. It is not an expected gain; it is the reason this deserves instrumentation.
### Where it plugs in
- Existing consumer support: `FVoxelSharedDensityGrid` in [VoxelMarchingCubesMesher.h:25](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h:25>).
- Existing copy path: [VoxelMarchingCubesMesher.cpp:660](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:660>).
- Scheduler and tile lifetime: `AVoxelWorld::LoadTile`, task completion and cancellation paths.
- Edit revision: `UVoxelDiffLayer::ModsVersion` and the snapshot mechanism.
I would prototype an **opportunistic completed-neighbor slab cache**, not a global per-sample hash table:
- Store exact float boundary slabs from recently completed tiles.
- Key by generator owner, layout/generation epoch, final-field edit revision, sample step and integer world-lattice bounds.
- Before sampling a tile, copy all available overlap slabs with simple block loops.
- Evaluate only missing rectangular regions.
- Never wait for a neighboring tile.
- Evict outside the desired/in-flight neighborhood.
The current `SetSharedDensityGrid` member pointer is suitable for the single-threaded editor commandlet, not for concurrent gameplay tasks. Production should pass immutable donor views with each mesh request, not mutate shared mesher state.
Do not reuse the quantized density-volume capture for meshing. Its bytes are sufficient for lighting volume capture, not exact marching-cubes interpolation.
### Risks
- **Streaming:** a support tile must never wait for a batch or neighbor.
- **Revision correctness:** an edit or layout rebuild must prevent reuse of stale final densities.
- **Memory:** retaining complete 35³ float grids is ~171 KB per tile. Store bounded slabs or short-lived grids, not every loaded tile indefinitely.
- **Concurrency:** avoid global locks in the sample loop.
- **Determinism:** exact copied floats are safe if the key is complete. A stale cache hit is a geometry error.
- **§8.10:** preserve task cancellation and the thread-local destination grid; copying merely replaces a subset of its fill operations.
Expected net gain is **5–12% inferred**, conditional on actual ready-before-use reuse being high. It may also fail completely if most neighboring tasks execute concurrently and finish too late to donate samples.
## 2.3 Canonical numeric ABI
This is the surviving portion of the first answer’s WorldPlan recommendation with the highest correctness value.
The code already has a substantial plan/layout layer, cached topology, per-strate parameters and operator recipes. I would not introduce a second “WorldPlan” representation. I would add:
- A generation format/version ID.
- Stable feature IDs for topology objects and composer choices.
- Canonical deterministic `SinCos`, normalization and checked coordinate conversions.
- Golden plan, density and mesh digests.
- An explicit floating-environment contract for worker tasks.
This touches very little of the 27.6 s. The per-sample `Sin` calls may be measurable, but the topology and passage calls are cold. Expect approximately neutral performance.
## 2.4 Traversal contract hardening
The least invasive structural fix is to make passage creation return one of two outcomes:
- A landing with a certificate identifying the existing structural feature/component to which it belongs.
- A newly constructed explicit connector included in the mandatory route graph.
A failed `VF_SuggestLandingPoint` should cause a deterministic retry/recomposition or explicit connector. It should not silently fall back to an unproved random reach.
For lateral compositions, generate and reserve the mandatory entry-to-exit spine at the parent level before regional creative stacks. Regional terrain may join or decorate it but not own its existence.
This preserves the composer and adds no material density-loop cost. The proof is a seed/archetype/composition matrix, not merely the canonical scenario.
## 2.5 Edit compaction and base-grid reuse
The current edit layer duplicates an edit into each affected chunk and evaluates the list serially at [VoxelDiffLayer.cpp:361](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDiffLayer.cpp:361>). There is no compaction into canonical per-brick deltas.
This does not touch the standard static 27.6 s scenario: **0% measured**.
For mining-heavy sessions, I would eventually keep:
- Immutable generated base samples for a recently remeshed edited tile.
- A server-sequenced edit stream.
- Canonical per-tile delta compaction after a threshold.
- Separate base-generation, edit, mesh and collision revision numbers.
Do this only after creating an edit-heavy benchmark. Depending on edit-list length, it might remove 30–80% of edit-layer work or much of repeated base evaluation, but that is wholly inferred and should not be sold using the static benchmark.
---
# 3. Proof-of-worth ladders
## Change A: tile-wide feature-major tunnel evaluation
### Rung 1 — Instrument the current evaluator
Use the existing counters:
- `CaveRoomCandidates/Evaluated`
- `CaveTunnelCandidates/Evaluated`
- `TunnelCoreCandidates/Evaluated`
They are recorded in [VoxelCaveMorphology.cpp:7765](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:7765>), [VoxelCaveMorphology.cpp:7844](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:7844>) and [VoxelCaveMorphology.cpp:8042](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:8042>), then logged per tile at [VoxelWorld.cpp:4961](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelWorld.cpp:4961>).
Add profile-only counters for:
- `GetRange` calls/hits/fallbacks and cycles.
- Candidate visits rejected by 3D AABB.
- Candidate visits rejected by bounding sphere.
- Actual swept-tunnel evaluations.
- Centerline segment iterations.
- For each tile, the predicted feature-major lattice visits obtained by clipping every feature’s conservative 3D bounds to that tile.
- Separate static/moving and LOD0/all-LOD totals.
Do not time every sample with a heavyweight timer; aggregate cycles around whole loops or rely on sampling.
**Kill immediately if:**
- Exclusive candidate/rejection machinery remains below 6–7% of worker time, and
- Predicted feature-major visits are at least 80% of current expensive evaluations, and
- There is no clear X-lane SIMD opportunity in the swept kernel.
That combination caps the likely gain below what a large rewrite deserves.
**Proceed if either:**
- Conservative feature spans reduce candidate/kernel visits by at least 2×, or
- The combined measured loop/branch/kernel share with realistic SIMD opportunity is at least 12% of total worker time.
Also require projected scratch below roughly 2 MB per worker for a single-tile prototype.
### Rung 2 — Tunnel SDF only
Behind a diagnostic console switch:
- Batch the warped tunnel SDF.
- Leave `EvaluateTunnelCoreWorld` entirely unchanged.
- Preserve room-mouth ownership and feature order.
- Keep scalar evaluation as the reference path.
- Add a debug mode comparing every produced sample with the reference field.
The tunnel SDF had a **12.64% measured historical share**, so the prototype has enough budget to show a useful result without touching the load-bearing core.
**Kill immediately on:**
- Player-fit below 20,830.
- Walk-reachable below 10,909.
- Loss of connected status.
- Any nondeterministic digest across repeated runs or machines.
- A span/proof skip that changes a reference density bit.
- A violation of the two-region cache window, margin grid or cancellation contracts.
**Performance kill criterion:**
- Less than 3% total worker-CPU improvement in either static or moving, or
- More than 3% regression in generation/collision-ready p95.
**Justification to continue:**
- At least 5% total worker-CPU improvement in both static and moving, with no material p95 or memory regression.
The switch-on/off result is only an internal decision aid. It does not satisfy final acceptance.
### Rung 3 — Separate batch stages for world core and passage work
Add the world-space core as a second, semantically separate batch stage. Do not merge it with the warped SDF. Then consider passage carving/structure if the same infrastructure applies.
Full adoption requires:
- Default-on build compared against the untouched `cf9904e` DLL.
- At least 10% worker-CPU improvement in both static and moving, or at least 8% plus a material collision-ready/p95 improvement.
- Canonical capability counts and connection.
- Skip/reference tests clean.
- Memory and task-cancellation behavior within existing bounds.
- No custom-recipe or lateral-composition semantic fork.
## Change B: cross-tile exact density reuse
### Rung 1 — Count exact duplicate opportunities
Record, for every generated tile:
- Origin, step, sampled Z band and generation/edit revision.
- Task start and completion order.
- Count of requested lattice coordinates.
- Count of distinct lattice coordinates across the scenario.
- Count whose donor tile had already completed before the consumer started.
- Reuse grouped by LOD, same/cross-LOD, static/moving and density-cost bucket.
This can be computed offline from a compact tile trace; there is no need for a runtime hash set of every sample in the production path.
**Kill if:**
- Exact duplicate coordinates are below 12% of all density calls, or
- Fewer than half of duplicates have a completed donor in time, or
- Time-weighted eligible duplicates represent less than 5% of worker CPU.
**Proceed if:**
- At least 15% of calls are exact duplicates and at least 60% are available opportunistically, or
- The conservative time-weighted saving ceiling exceeds 10% of total worker CPU.
### Rung 2 — LOD0 completed-neighbor slabs
Prototype only LOD0:
- Exact float slabs.
- Immutable per-request donor views.
- No waiting.
- No cross-LOD reuse.
- Strict owner/layout/edit revision key.
- Bounded memory, preferably 32–64 MB.
- Missing samples follow the current path.
**Kill immediately on any field/mesh digest difference.** This should be a pure reuse, not a field-changing implementation.
**Performance kill criterion:**
- Less than 4% worker-CPU improvement in either static or moving.
- More than 3% collision-ready or generation p95 regression.
- Memory above the agreed cap.
- Significant loss of task concurrency.
**Proceed if:**
- At least 6% worker-CPU improvement in both scenarios.
- No readiness regression.
- No stale-hit failure under edit/rebuild/cancellation stress.
### Rung 3 — Full same-step gameplay reuse
Extend to same-step tiles at all useful clip levels, with revisioned invalidation and bounded eviction. Cross-LOD reuse is optional and should be separately justified.
Adopt only if the final default build beats `cf9904e` by at least 7–8% overall or produces a slightly smaller CPU gain plus a clear collision-readiness improvement. Remove the diagnostic switch only after the committed-DLL comparison.
---
# 4. Numeric determinism audit
## 4.1 Critical: CRT `sin/cos`
**Severity: critical.**
These functions affect:
- Passage control points and landing directions.
- Tunnel winding and pinch orientation.
- Room/feature placement.
- Per-sample line/rib/surface detail.
- Walkability thresholds.
The UCRT can select different implementations in the same Win64 binary based on CPU features. Windows and Linux libm implementations may also differ.
Minimal fix:
1. Introduce one canonical float `SinCos` implementation with explicitly bounded input, fixed range reduction and fixed operation grouping.
2. The UE float `FMath::SinCos` polynomial is a reasonable prototype because it is engine arithmetic rather than CRT dispatch. I would wrap or vendor it so the numeric contract is explicit.
3. Replace paired `Sin`/`Cos` calls together.
4. Use the same canonical sine output for individual sine-only sites.
5. Pin golden input/output bit patterns and world-plan/field digests.
6. Retune if necessary; a new build may change the field.
Calling `_set_FMA3_enable(0)` is not a complete fix. It is process-global, Windows-specific and does not align UCRT with glibc.
## 4.2 Medium: `sqrt` and implicit vector normalization
`FMath::Sqrt` also reaches `sqrtf` at [GenericPlatformMath.h:552](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/GenericPlatform/GenericPlatformMath.h:552>). On x64 this will commonly become a hardware square-root instruction, which is much less alarming than `sin/cos`, but the source-level contract still delegates to the platform library/compiler.
World-deciding examples include:
- Morphology topology/walkability: [VoxelCaveMorphology.cpp:639](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:639>), `785`, `2171`, `2188`, `3019`, `3027`, `4919`, `5101`, `5355`.
- Tunnel/room field evaluation: `7704`, `8356`, `8550`.
- Passage/layout geometry: [VoxelStrateManager.cpp:666](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateManager.cpp:666>), `2393`, `2406`, `3701`, `3804`.
- Composer decisions: [VoxelStrateComposer.cpp:1572](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateComposer.cpp:1572>), `1606–1608`, `4141`, `4172`, `4662–4665`, `4849`, `5016–5018`.
- Numerous SDF sites in `VoxelGenerator.cpp` and `VoxelDensityOpStack.cpp`.
- `FVector::Size`, `Dist` and `Normalize`, for example passage direction normalization at [VoxelStrateManager.cpp:2085](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelStrateManager.cpp:2085>).
Minimal fix:
- Replace square-root comparisons with squared comparisons where mathematically equivalent.
- Canonicalize only the remaining square roots that affect topology, integer conversion or threshold decisions.
- Verify emitted Win64 instructions and run golden vectors before attempting to replace every SDF square root. I am not claiming a demonstrated cross-CPU `sqrt` divergence here.
No direct runtime uses of `FMath::Pow`, `Exp`, `Log`, `Atan`, `Atan2` or `Tan` were found in the plugin generation sources. That does not rule out transitive engine/library use, but there are no direct call sites to fix.
## 4.3 High: runtime CPU state and dispatch
### Confirmed dispatch
- UCRT FMA3 `sin/cos`: critical, as above.
### Plugin SIMD
`VoxelNoise` selects scalar versus SSE at compile time through `VF_NOISE_USE_SIMD` at [VoxelNoise.h:35](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelNoise.h:35>). The hot implementation uses explicit SSE4.1 at [VoxelNoise.h:248](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelNoise.h:248>).
I found no plugin runtime CPUID dispatch and no ISPC path. The same Win64 build therefore takes the same plugin noise implementation on all supported x64 CPUs.
The “scalar and SIMD are bit-identical” claim at [VoxelNoise.h:20](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelNoise.h:20>) should still have a pinned exhaustive/random corpus. It is a claim worth continuously testing, not merely documenting.
### MXCSR rounding mode and FTZ/DAZ
UE’s integer conversions inherit SSE implementations such as `FloorToInt32` and `RoundToInt32` at [UnrealPlatformMathSSE.h:86](</E:/Program Files/Epic Games/UE_5.7/Engine/Source/Runtime/Core/Public/Math/UnrealPlatformMathSSE.h:86>). Several use rounding-mode-sensitive conversion instructions and assume the normal environment.
Another library changing a worker thread’s MXCSR rounding mode could change world-to-cell decisions. FTZ/DAZ can likewise alter very small intermediates.
Minimal fix:
- At generation-task entry, assert or install a canonical MXCSR configuration and restore the previous state at exit.
- Prefer explicit rounding instructions or checked conversion helpers for canonical boundaries.
- Test all supported task threads, not only the game thread.
## 4.4 Critical/high: float-to-integer boundaries
### Noise lattice conversion
[VoxelNoise.h:96](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelNoise.h:96>) floors a float and casts it directly to `int32`. The SIMD version uses `_mm_cvttps_epi32` at [VoxelNoise.h:255](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Public/VoxelNoise.h:255>).
For NaN or out-of-range values, scalar C++ conversion and SSE conversion do not provide a suitable canonical world rule.
**Severity: high**, especially with `WorldRadiusVoxels == 0` permitting an unbounded world.
Fix:
- Define a supported integer-coordinate envelope.
- Reject or clamp malformed/out-of-range queries before noise evaluation.
- Use one checked floor/wrap policy shared by scalar and SIMD paths.
- Test the boundary values and their immediate float neighbors.
### Chunk routing
`GetDensityAt` converts public float coordinates to chunk coordinates without a finite/range guard at [VoxelGenerator.cpp:2836](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2836>).
Internal mesher calls are bounded integer coordinates, but other callers need not be.
Fix:
- Give the mesher an integer-lattice entry point.
- Keep the public float query checked.
- Decide explicitly what happens outside representable chunk space.
The edit layer already demonstrates the correct pattern at [VoxelDiffLayer.cpp:220](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDiffLayer.cpp:220>).
### Procedural cell selection
`VoxelCaveMorphology.cpp` contains many `FloorToInt` cell selections, including the placement/cache families around `3902–3903`, `3954–3956`, `4038–4039`, `4180–4181`, `4554–4555`, `4683–4684`, `4881–4882`, `5085–5086`, `5195–5196` and `6055–6058`.
These are safe only while inputs remain finite and representable. They should consume the same declared coordinate envelope.
### Conservative skip boundaries
Passage and box proofs use float-to-int lattice conversions, for example around `VoxelStrateManager.cpp:3333–3334`. These are lower severity if they are genuinely one-sided: disagreement must cause a missed skip, never omitted geometry.
The better formulation is integer/rational lattice `ceil`/`floor` from validated bounds, with explicit outward rounding.
### Mesh vertex identity
The mesher deduplicates a vertex by rounding its interpolated world position multiplied by 100 at [VoxelMarchingCubesMesher.cpp:154](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:154>).
A small numeric difference crossing a half-centimetre rounding boundary can change vertex sharing and triangle/index output even if the surface samples remain on the same side.
**Severity: medium-high.**
Minimal fix: identify marching-cubes vertices canonically by integer cell coordinate plus edge axis, not rounded interpolated position. That is both cheaper and independent of interpolation rounding.
## 4.5 High: worker-side global races
The following “parse once” flags and their backing values are ordinary globals accessed from worker generation:
- Generator flags at [VoxelGenerator.cpp:47](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:47>), `73`, `98`, `124`, `143`, `202`.
- The parsers are invoked inside every `GetDensityAt` at [VoxelGenerator.cpp:2820](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:2820>).
- Mesher flags at [VoxelMarchingCubesMesher.cpp:20](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:20>) and `42`, invoked at `97–98`.
Multiple first-time worker calls can read/write the booleans concurrently. `FAutoConsoleVariableRef` can also mutate backing integers while workers read them. That is C++ data-race undefined behavior.
Minimal fix:
- Parse command-line state once on the game/module initialization thread before generation begins.
- Construct an immutable generation-policy snapshot.
- Copy that snapshot into each task or generation epoch.
- A live CVar change that affects the field must create a new epoch and rebuild; it must not alter active workers in place.
- Diagnostics that are intentionally live should use atomics and must not influence the canonical field.
## 4.6 Critical for multiplayer edits: order-dependent accumulation
Edits are appended to per-chunk arrays at [VoxelDiffLayer.cpp:253](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDiffLayer.cpp:253>) and accumulated serially in insertion order at [VoxelDiffLayer.cpp:374](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDiffLayer.cpp:374>).
Floating addition is order-dependent. If clients apply replicated edits in packet-arrival order, their density fields can differ.
Minimal fix:
- Give every accepted edit a monotonic authoritative server sequence.
- Buffer out-of-order edits and evaluate them in that sequence.
- Include sequence ranges in edit/density cache revisions.
- During compaction, use a canonical order or quantized/fixed-point delta representation.
- Never parallel-reduce floating edit contributions without a deterministic reduction tree.
Current local serial evaluation is stable if every machine receives the exact same order. The network contract must guarantee that.
## 4.7 Medium: raw-memory fingerprints and padding
The code hashes raw parameter structs using `FCrc::MemCrc32` at:
- [VoxelGenerator.cpp:3170](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp:3170>) and `3842`.
- [VoxelDensityOpStack.cpp:1116](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelDensityOpStack.cpp:1116>), `1119` and `3693`.
The accompanying comments say a raw-memory CRC cannot produce a false positive. That is incorrect:
- Padding may be indeterminate, yielding nondeterministic false misses.
- CRC32 can collide, yielding a false hit.
- If the fingerprint is the only cache identity, a false hit can reuse terrain computed from different parameters.
Minimal fix:
- Hash fields individually in declared canonical order.
- Normalize bools, floats and zero.
- Prefer 64- or 128-bit hashes.
- On a hash match, compare the complete canonical key before reusing semantic data.
This is probably a low-frequency failure, but an absolute determinism/correctness contract should not use “CRC collisions cannot happen” as an invariant.
## 4.8 Stateful randomness and ordering
`FRandomStream` layout shuffle is serial and deterministic for the current sorted pool, so it is not presently a cross-thread race.
Its weakness is evolutionary stability: inserting a pool member changes subsequent random consumption. Since older-build identity is not required, this is not urgent. Stable per-slot keyed selection becomes useful if saved plans, live content packs or replicated compact layouts must survive content additions predictably.
Current spatial candidate iteration is serial and ordered. I found no current parallel reduction of SDFs or edit values. A feature-major rewrite must preserve per-sample feature order or deliberately become a field-changing new canonical evaluator.
## 4.9 Uninitialized data and timing
I inspected the important `SetNumUninitialized` sites and found no confirmed uninitialized read:
- Spatial-index bound arrays are filled for every valid item before use at [VoxelCaveMorphology.cpp:1343](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelCaveMorphology.cpp:1343>).
- Index storage is completely filled from prefix offsets at `1443–1455`.
- The mesher samples only the Z band subsequently read; this is documented at [VoxelMarchingCubesMesher.cpp:680](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp:680>).
The raw struct padding used by `MemCrc32` is the confirmed uninitialized-byte concern.
I found no density value chosen by wall-clock timing. Task timing controls which tiles become ready first, but epoch, cancellation and collision gates prevent obsolete work from becoming canonical terrain. Cache identity and edit sequencing are the places where timing could become semantic if keys/order are incomplete.
---
# 5. What I would not graft onto this code
- **No wholesale persistent sparse base-density brick store.** It would duplicate a pure procedural evaluator, add large revision/save/memory machinery and invalidate the current direct-LOD clipmap advantage. Transient tile buffers and edited-brick deltas capture most of the value.
- **No coarse LOD derived from mandatory fine voxels.** It would make far terrain require fine generation and undermine the constant-cell-count clipmap at [ARCHITECTURE.md:708](</E:/Projet Unreal/VoxelM/Plugins/VoxelForge/ARCHITECTURE.md:708>).
- **No min/max octree classifier as another always-on front end.** The outer classifier and exact per-sample cull already demonstrated that extra proof work can lose. Bounds should cheaply clip batch spans, not become a parallel evaluator.
- **No warped-SDF/world-core merge.** The rejected capability loss demonstrates that they express different geometry. Batch them separately.
- **No value-noise worm substitution or worm-focused rewrite.** Worm removal is only about 3%; that budget cannot support architectural work.
- **No general-purpose interpreted CSG evaluator.** The project has already paid to lower the canonical stack. General composition belongs in the authoring IR, not the sample loop.
- **No shell/B-rep base terrain or local shell-to-voxel collars.** Arbitrary noise, junctions, mixed archetypes and mining make the watertight seam problem much larger than the likely benefit.
- **No dual-contouring/transvoxel or mesh-extractor rewrite without an extractor-specific profile.** Density evaluation is the measured bottleneck. Changing topology would risk capability and seams while attacking an unmeasured target.
- **No GPU-authoritative field or meshing.** Cross-vendor bit identity remains unproved. GPU rendering/culling can be non-authoritative later.
- **No global concurrent hash map for individual density samples.** Per-sample lookup/locking could repeat the always-on-cull failure. Reuse should operate on rectangular slabs or immutable tile buffers.
- **No actor/UObject per brick.** Current plain worker data and game-thread publication are the right split.
- **No second WorldPlan/composer hierarchy.** `FStrateLayout`, recipes, cached topology and the operator stack already constitute one. Add stable IDs and contracts to those structures.
- **No conservative collision proxy unless gate stalls remain a real UX problem.** The current cook-complete admission gate already supplies correctness. A second proxy system needs a user-visible problem and measurements.
- **No claim that generated traversal remains guaranteed after arbitrary edits.** If that becomes a requirement, it needs an explicit server product rule: protect, reject or repair the final route.
---
# Bottom line
The owner’s “prove it worthy” stance is appropriate. The code has reached the point where another clever per-sample condition is more likely to lose than win.
I would authorize three preliminary efforts:
1. Instrument feature-major work potential using the existing candidate/evaluated counters.
2. Trace exact duplicate density coordinates and ready-before-use donors across gameplay tiles.
3. Close the numeric ABI hole, beginning with a FMA3-on/off and cross-platform golden corpus.
Only the first two are performance bets. Neither should become a production rewrite unless it clears its early kill criteria. The numeric work is different: the code currently documents, rather than satisfies, the hard field-determinism requirement.
# Files read
No files were changed. I did not build, run tests or launch Unreal.
Targeted sections read:
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\CLAUDE.md`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\CODEMAP.md`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\ARCHITECTURE.md`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\WORK-NEXT.md`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\PERF-SAMPLED.md`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\DESIGN-SOL-2026-09-15.md`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\AUDIT-SOL-2026-09-11.md`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\AUDIT-COLD-2026-09-14.md`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\fable-idea.md`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\VoxelForge.Build.cs`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelGenerator.cpp`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelCaveMorphology.cpp`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelDensityOpStack.cpp`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelStrateManager.cpp`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelMarchingCubesMesher.cpp`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelWorld.cpp`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\VoxelDiffLayer.cpp`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelNoise.h`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelDensityProfile.h`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Public\VoxelMarchingCubesMesher.h`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForge\Private\Tests\VoxelForgeCrossPlatformTest.cpp`
- `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Source\VoxelForgeEditor\Private\VoxelForgeExploreCommandlet.cpp`
Files inspected through targeted symbol searches:
- `VoxelContentManager.cpp`
- `VoxelHeightOpStack.cpp`
- `VoxelStrateComposer.cpp`
- `VoxelStrateMeasure.cpp`
- `VoxelCaveMorphology.h`
- `VoxelDensityPrimitives.h`
- `VoxelHeightOp.h`
- `VoxelPassageGeometry.h`
- `VoxelStrateTypes.h`
- `VoxelForgeComposerStructureRollTest.cpp`
- `VoxelForgePassageOpenSpaceTest.cpp`
- `VoxelForgeScaleDiagnosisTest.cpp`
- `VoxelForgeStrateConnectivityTest.cpp`
- `VoxelForgeWormBlockSkipTest.cpp`
UE 5.7 engine source read:
- `GenericPlatformMath.h`
- `UnrealMathUtility.h`
- `MicrosoftPlatformMath.h`
- `WindowsPlatformMath.h`
- `UnrealPlatformMathSSE.h`
- `UnrealPlatformMathSSE4.h`
I also inspected git metadata/diffs for `1d824aa`, `a668833` and `410788f`.
+580
View File
@@ -0,0 +1,580 @@
# DESIGN-SOL-2026-09-15: "build it every better way", asked cold
On the owner's idea (see memory `reframe-when-returns-diminish`): after the exact-skip perf push
reached diminishing returns, we asked Sol (`gpt-5.6-sol`, reasoning high) a first-principles
design question. It was run from an empty folder, read-only, told not to read files, and it read
none (0 commands). It got the game's goals and constraints, never the code. The prompt and the
answer are verbatim below.
## Orchestrator notes (checked against the code, 2026-09-15)
- **Determinism risk confirmed in our code.** Sol warns against platform transcendentals on the
path that decides the world. VoxelForge has 34 `FMath::Sin/Cos` calls in generation code:
per-sample strata line/rib detail (`VoxelGenerator.cpp:4708, 4738, 5723`;
`VoxelDensityOpStack.cpp:7507, 7609`) and shape building (passage spirals/cascades
`VoxelStrateManager.cpp:1830-2142`, option B winding waves `VoxelCaveMorphology.cpp:6826-6843`,
tunnel pinches `7487-7521`). The MSVC C runtime switches its math functions to FMA3
implementations at runtime on CPUs that support it (`_set_FMA3_enable`), so bit-identical results
across machines are not guaranteed. Found by reading, not measured, and not changed yet.
- **The largest gap is the loop direction.** Every sample searches a spatial index for nearby
primitives. Sol's recommended architecture rasterises each feature into the bricks it touches.
Coarse LOD from finer data and shared halo samples are also absent.
- Already shared with Sol's design: built-first traversal (walk forest, D-floors, option B),
hash-keyed randomness, the collision gate, and partial narrow-band skipping (reach proofs, the
sealed-rock proof).
---
## The prompt, as sent
# If you had to build this from scratch, in every better way: how would you do it?
You are being asked for **architecture**, not a code review. There is no code here on purpose: we want
your own design, not a critique of ours. Think freely, then be concrete.
⛔ **Do not read, search or open any files on this machine, and do not run commands.** Answer purely
from the description below and your own knowledge. Anchoring on an existing implementation is
exactly what this question is trying to avoid. Your final message is the whole deliverable.
## The game
An underground, multiplayer mining and exploration game in Unreal Engine 5.7 (C++). The world is a
vertical stack of **strates**: thick horizontal layers, each a distinct underground world (tunnel
networks, big chambers, mazes, shafts, floating islands, flooded areas, an open "surface" layer lit
from within). Strates are sealed from each other by solid rock, except through a few authored
**passages** that the player walks through to go deeper. Players spawn at the world origin and
travel down. They **mine and carve** the terrain freely.
## Hard constraints: any design must satisfy these
1. **Deterministic across machines.** Every client generates the world locally from a seed. Only the
seed, the strate layout and players' terrain edits are replicated, never geometry. The same build
with the same seed must produce the exact same world on every machine (x64 CPUs; a GPU path would
have to prove cross-vendor/driver bit-exactness).
2. **Composable generation.** Worlds are not hand-authored. A "composer" invents strates from tagged,
authored building blocks (room shapes, tunnel styles, noise detail, terrain features) and a seed.
The generator must stay a composition of operators that can be recombined, and archetypes can mix
inside one strate.
3. **Traversal guarantees.** From the point where a player arrives in a strate to where they leave,
there must always be a walkable route (player capsule ~1.8 m tall, ~0.34 m radius, 45 cm step,
~44 degree max walkable slope). Tunnels have walkable D-shaped floors, wind when a direct route
would be too steep, and may have dramatic climbable drops only where a walkable alternative
exists. Rooms cut cleanly into tunnels.
4. **Terrain is volumetric and editable.** Caves, overhangs and arbitrary mining, so not a
heightfield. Edits must re-mesh quickly and deterministically.
5. **Streaming must keep up with a running player** (~8 m/s, faster when falling), with collision
ready under the player before they can stand there. Far terrain is visible at lower detail.
6. Resolution: ~25 cm voxels near the player. Target PCs: 8-core/16-thread desktop CPUs and a decent
GPU.
## What we would like from you
1. **Two or three genuinely different architectures** for generation, representation, LOD,
meshing, streaming and editing that satisfy ALL the constraints. For each: how the terrain is
represented and evaluated, where the work happens, how LODs are produced, how edits work, how
determinism is guaranteed, the expected performance character, and the biggest risks.
2. **Where the real performance wins are**, compared with the obvious design ("for every voxel
sample, evaluate every nearby shape and noise"). Be specific: algorithms and data structures.
3. **What you would deliberately NOT build**, and why.
4. **If you could only change three things** in a working implementation of the obvious design,
what would they be?
Be concrete. "Use the GPU" or "use ECS" is not an answer unless it addresses constraint 1 and the
composition requirement. Say plainly where you are unsure.
---
## Sol's answer, verbatim
I would make the deterministic artifact a compact “world plan,” then compile that plan locally into terrain. The plan describes topology, portals, feature instances, archetype regions, and generation-version hashes—not voxels or meshes.
My preferred implementation would be a feature-rasterized sparse brick volume. It retains operator composition, but executes operators feature-by-feature into the bricks they touch instead of asking every voxel about every feature. That is usually the best balance of predictable performance, editability, and determinism.
Two constraints need precise interpretation:
- If traversal must remain guaranteed after arbitrary player edits, “mine freely” and “always walkable” conflict. Players can mine away every possible floor. You must either protect a route, reject edits that destroy the last route, or automatically create a replacement.
- Collision readiness cannot be guaranteed under arbitrary stalls or unbounded falling using prefetch alone. A hard guarantee requires bounded terminal velocity plus admission control, an immediate conservative collision proxy, or both.
Everything below assumes generated terrain must guarantee traversal. For post-edit traversal, I would use a server-enforced “last-route covenant”: ordinary mining is unrestricted, but an edit that would destroy the final walkable route is rejected or replaced with an authored support/bridge.
## Common foundation
Before discussing terrain representations, all three architectures should share the same deterministic world-planning layer.
### 1. The composer produces a WorldPlan
For each strate, the composer creates:
- Bounds and interface rock thickness.
- Entry and exit passage transforms.
- A mandatory traversal graph.
- Optional chambers, side routes, drops, shafts, islands, flooded regions, and rewards.
- Spatial archetype regions and their tags.
- Feature instances and their stable IDs.
- Material and detail rules.
- A generation-format version and content-pack hash.
The replicated “strate layout” could be the full WorldPlan or only the high-level choices needed to reconstruct it. I favor replicating the compact plan: it prevents small composer changes from silently changing an existing saved world while still avoiding geometry replication.
Adjacent strates share authored portal contracts. The interface is solid by default, with only explicitly reserved passage volumes allowed to cross it. Cave noise is bounded and cannot affect the interface exclusion zone.
### 2. Traversal is constructed, not discovered afterward
Generate the mandatory route before decorative terrain:
1. Place entry, exit, and required intermediate anchors.
2. Route a centerline through a coarse 3D planning grid.
3. Include elevation, heading, and accumulated grade in the search state.
4. When a direct segment exceeds the design grade, generate switchbacks or a spiral.
5. Sweep a guaranteed-clear tunnel profile along that centerline.
6. Add rooms and optional routes.
7. Apply destructive detail and noise only outside the protected clearance envelope.
8. Run an independent capsule validator as an assertion.
I would design the mandatory route below the mechanical limit—perhaps 35–38 degrees rather than 44—to leave room for meshing, collision quantization, and network movement tolerances.
The protected route volume is the capsule’s configuration space: at least the capsule radius plus margin laterally, enough headroom vertically, and a conservative floor envelope. It is stronger than merely checking whether an air voxel exists.
D-shaped tunnels should come from a swept profile with:
- A controlled floor curve.
- An arched roof.
- Fixed-point parallel-transport frames along the centerline.
- Junction transition pieces where tunnels meet rooms.
- A “no detail displacement” band around the walking surface.
A dramatic drop is added only as a graph chord or optional branch. It never replaces a mandatory walkable graph edge. Graph bridges and articulation points make that easy to verify.
The final validator should perform capsule sweeps or search through capsule-eroded free space at collision resolution. If it fails, retry only the optional layout using a deterministic attempt number; do not rely on an unconstrained random retry loop.
### 3. Determinism is an explicit numeric ABI
I would not trust “same C++ code on x64” by itself.
The generation contract would require:
- Integer or fixed-point world coordinates, preferably `int64`.
- Fixed-point density and interpolation with specified rounding and saturation.
- Integer lattice noise with fixed lookup tables—no platform `sin`, `pow`, or floating-point noise libraries.
- Counter-based random values keyed by tuples such as
`(world seed, strate ID, feature ID, decision kind, attempt)`.
- No shared sequential PRNG streams.
- Canonical ordering by stable ID whenever operations are noncommutative.
- No dependence on hash-map iteration order, thread scheduling, pointer values, or allocation order.
- Canonical triangulation, vertex ordering, ambiguity resolution, and material selection.
- Explicit overflow behavior and wide intermediate arithmetic.
- A generation-version/content hash checked during multiplayer connection and save loading.
Floating point can be used after canonical geometry exists—for rendering transforms, shading, and other non-authoritative work. It should not determine topology, occupancy, route selection, or edit results.
GPU work is limited to rendering, culling, upload, and possibly non-authoritative visual effects. I would not make GPU field evaluation or meshing canonical unless an integer-only implementation had been proven bit-exact across vendors and drivers.
---
# Architecture A: Lazy compiled implicit field
This is the most flexible architecture for arbitrary procedural shapes.
## Representation and evaluation
The WorldPlan is compiled into an immutable field program. Operators include:
- Room primitives.
- Swept tunnel primitives.
- Terrain unions and subtractions.
- Bounded displacement/noise.
- Material masks.
- Authored feature fields.
- Player edit operators.
The field is a deterministic integer scalar field, not necessarily a mathematically exact signed-distance field. Only its sign, interpolation, bounds, and material semantics need to be defined precisely.
Every feature has:
- A conservative bounding box.
- A bound on field contribution.
- A stable ID and priority.
- A reference to a specialized evaluation kernel.
A spatial index maps macro-cells and bricks to relevant operators. When a chunk is requested, its operator set is gathered once, canonically sorted, simplified, and compiled into a chunk-local program.
Examples of compilation:
- Collapse unions of compatible room primitives into one loop.
- Separate terrain topology from material evaluation.
- Group identical tunnel kernels.
- Remove operators whose bounds cannot influence the chunk.
- Create archetype masks so that a noise style is evaluated only in its own region.
- Propagate maximum noise amplitude for later pruning.
## LOD and meshing
Use world-aligned sparse bricks, for example:
- 4 m brick: `16³` cells at 25 cm.
- 8 m mesh page: `2³` bricks.
- 32–64 m macro-cell for planning and indexing.
Sample only requested bricks and their one-cell halos. Cache corner samples so neighboring chunks do not recompute them.
LOD is generated by evaluating the same field on world-aligned grids at spacings of 25, 50, 100 cm, and so on. An interval hierarchy records minimum and maximum possible density over coarse nodes:
- If the interval is entirely solid or air, no finer samples are needed.
- If it crosses zero, refine.
- Mandatory passages can be marked as topology-critical so coarse visual LOD does not accidentally seal them.
For meshing, I would use either:
- Fixed-point surface nets with deterministic vertex placement, or
- Deterministic marching cubes with an integer asymptotic decider.
Dual contouring can produce better sharp features, but its QEF solver must be fixed-point with completely specified pivoting and rounding. A conventional floating-point QEF is a determinism trap.
Neighboring LODs remain 2:1 balanced and use fixed transition-cell tables. Every page owns vertices according to a canonical boundary rule, avoiding duplicate and differently rounded seam vertices.
LOD0 supplies the canonical collision triangles. Far LOD is render-only.
## Edits
Edits are quantized CSG stamps:
- Shape and dimensions.
- Fixed-point transform.
- Operation: mine, add, replace material, and so forth.
- Server sequence number.
- Author and optional gameplay metadata.
The server assigns ordering for noncommutative operations. Pure subtraction can be treated as commutative, but I would still preserve a total order for debugging and save compatibility.
Edits are indexed spatially. A changed edit invalidates only intersected bricks plus the meshing halo. Long edit lists are deterministically folded into per-brick edit blocks or checkpoints; the checkpoint is still player-edit data, not base-world geometry.
## Performance character
Advantages:
- Excellent composability.
- Low persistent memory.
- Arbitrary procedural primitives remain natural.
- Any LOD can be evaluated directly.
- Untouched, distant regions never need materialization.
Costs:
- Worst-case chunks can still have expensive field evaluation.
- Domain warping and heavily overlapping operators weaken spatial bounds.
- Repeated edits require compaction or their query cost grows indefinitely.
- Fixed-point vector math and meshing require careful engineering.
The main risk is believing the spatial index alone solves performance. If every surface sample still evaluates 40 candidate features and six noise octaves, this architecture can miss streaming deadlines.
---
# Architecture B: Feature-rasterized sparse brick volume
This is the architecture I would most likely ship.
Instead of the field being the runtime truth, the WorldPlan is compiled into canonical sparse voxel bricks. Operators remain composable, but execute in bulk.
## Representation and generation
Each brick is one of:
- Uniform solid.
- Uniform air.
- Compressed material-only block.
- Dense or narrow-band fixed-point density samples.
- Edited dense block.
The generator processes features rather than samples:
1. Determine which bricks a tunnel, room, or feature intersects.
2. Rasterize that feature into those bricks using a specialized kernel.
3. Apply phases in a canonical order: structural voids, rooms, optional features, surface detail, materials, edits.
4. Never touch bricks outside the feature’s conservative bounds.
A swept tunnel rasterizer iterates its centerline segments and their affected scanlines. A room stamp writes spans or masks. Authored building blocks can contain canonical sampled volumes and be placed using resampling-free transforms such as 90-degree rotations and reflections. More general transforms use a precisely specified fixed-point resampler.
For boolean-only phases, contributions can be accumulated independently and reduced in any order. Noncommutative phases use sorted feature IDs and ping-pong channels.
Noise becomes a bulk brick kernel. Better still, it runs only on bricks already known to contain a possible surface. Bounded noise cannot turn a deeply solid or deeply empty brick into a surface brick.
A sparse radix tree or hashed macro-cell table owns bricks. The storage itself need not have deterministic iteration order, because all externally visible processing uses sorted integer coordinates.
## LOD and meshing
Every brick produces a small hierarchy containing:
- Minimum density.
- Maximum density.
- Material summary.
- Surface-presence mask.
- Optional conservative solid/air coverage.
Parent LOD bricks are derived using an exact integer reduction. A simple averaged density is insufficient because it can erase thin walls or close tunnels. Min/max information decides whether a coarse region is definitely homogeneous or must retain detail.
Mesh generation reads the brick and its halo, then uses the same deterministic extractor and transition cells as Architecture A.
A useful split is:
- 25 cm render and collision near players.
- Potentially coarser conservative collision away from traversal-critical regions.
- Far render pages assembled from brick hierarchy nodes.
## Edits
Edits operate directly on affected brick spans or samples. This makes mining latency extremely predictable:
- Copy-on-write the changed brick.
- Apply the quantized edit kernel.
- Recompute only its mip ancestry.
- Remesh it and its boundary neighbors.
- Recook only affected collision pages.
Keep the original generated brick reconstructible from the WorldPlan. A save stores the edit operations or a canonical edit-derived brick delta, never the generated base brick.
## Performance character
Advantages:
- Work is proportional to affected brick volume, not `samples × candidate operators`.
- Excellent cache locality and straightforward SIMD.
- Uniform regions are extremely cheap.
- Edits are fast and bounded.
- Collision and LOD data naturally derive from the same representation.
- Performance is more predictable than lazy per-sample evaluation.
Costs:
- Higher memory use.
- A requested area must be materialized before use.
- Poorly bounded features can still touch too many bricks.
- Large filled volumes are wasteful unless uniform-brick detection happens early.
- Authored sampled blocks can become repetitive if the composer does not provide enough transforms and procedural variation.
The main risk is turning it into a dense voxel world. The architecture works because homogeneous regions remain implicit and only surface/edit bricks become dense.
---
# Architecture C: Deterministic shell-first base with local voxelization for edits
This is a genuinely different option and can be extremely fast when most terrain remains untouched. It is also the most difficult to engineer robustly.
## Representation and generation
The unedited world is represented as deterministic watertight boundary patches:
- Polygonal swept tunnels.
- Parametric room shells.
- Terrain surface patches.
- Portal and junction patches.
- Bounded displacement patches.
Solid rock is implicit outside the air shells. There is no full base density volume.
All topology decisions use fixed-point or exact rational predicates. Curves are canonical polygonal approximations generated from quantized tables. Intersections and room/tunnel cuts use a deliberately restricted exact boolean system—not a general floating-point mesh boolean library.
The WorldPlan spatially indexes these patches. Untouched chunks triangulate only the surface patches that intersect them.
## LOD and meshing
Base patches have deterministic subdivision hierarchies. Adjacent patches share boundary vertex schedules, so independently generated LODs meet exactly. Far LOD selects coarser patch subdivisions.
When a player edits terrain, the affected area switches representation:
1. Allocate sparse voxel bricks around the edit.
2. Classify the base shell into those bricks.
3. Apply the edit operations.
4. Mesh the local volume.
5. Use a collar region whose boundary samples match the untouched shell.
An edit completely inside solid rock begins from a uniform-solid brick and creates a new cavity. An edit touching a cave wall classifies the nearby base shell first.
Collision uses base shell triangles in untouched regions and voxel-derived triangles in edited regions.
## Determinism
The shell topology, clipping predicates, tessellation, and triangle order must all be integer/rational and canonical. Conventional runtime mesh booleans are not acceptable.
## Performance character
Advantages:
- Untouched terrain costs roughly proportional to visible surface area.
- No field evaluation across solid interiors.
- Very cheap far LOD.
- Works especially well for huge chambers and relatively localized mining.
- Traversal proxy geometry is available immediately from the route plan.
Costs and risks:
- Exact, watertight joins between arbitrary procedural shapes are hard.
- Noisy displacement and multi-way junctions are particularly troublesome.
- The boundary between shell terrain and voxelized edited terrain is complex.
- Large-scale mining gradually converts the world into Architecture B anyway.
- Tooling and debugging burden are substantially higher.
I would consider this only if profiling showed base-world generation dominating and player edits remained spatially sparse. I would not choose it as the first production architecture.
---
# Streaming and collision
The streaming scheduler should be shared across architectures and driven by deadlines, not simple distance.
Maintain separate interest sets:
- Immediate safety set: LOD0 collision required.
- Near visual set: LOD0 or LOD1 meshes.
- Predicted movement set: locations reachable within a time horizon.
- Far visual set.
- Edit-dirty set.
- Passage prefetch set for the next strate.
Priority is approximately “time until the player could touch this page,” accounting for:
- Current velocity and acceleration.
- Character braking.
- Network prediction uncertainty.
- Gravity and terminal fall velocity.
- Nearby shaft connectivity.
- Other players on the authoritative server.
A vertical fall requires a swept ballistic volume, not a horizontal streaming radius. Known shafts and authored passages should prefetch their entire reachable landing corridor as soon as a player approaches them.
The worker pipeline is:
`plan/index lookup → field sampling or brick rasterization → mesh → collision cook → game-thread publication`
Each stage has revision numbers and supports cancellation. Results are published only if their input revision is still current.
Collision receives the highest priority. For a hard guarantee, I would use both:
- Immediately available conservative collision proxies derived from the route plan.
- Movement admission: a character cannot cross into a page whose required collision revision is not active.
The latter should almost never trigger, but without it “collision will always be ready” is only a performance hope. Under severe overload, slowing a character at a streaming boundary is better than letting them fall through the world.
On Unreal’s side, generation data should remain plain C++ memory off-thread. UObject/component creation and scene publication happen on the game thread. I would use a custom terrain scene proxy and a dedicated collision provider rather than one heavy actor/component per tiny brick.
The server generates collision for all relevant players and remains authoritative. Clients generate the same static terrain for prediction and rendering. Physics simulation itself does not need cross-machine determinism as long as the server is authoritative, but the collision input triangles must still be canonical.
---
# Where the real performance wins are
The dominant improvement is changing the work from:
`number of samples × number of nearby shapes × number of noise layers`
toward:
`features touching requested bricks + cells near actual surfaces + dirty edit cells`
Specifically:
## 1. Feature-to-brick compilation
Build a macro-cell index when the WorldPlan is created. A requested brick gets a small, already-filtered operator program.
Better still, Architecture B iterates each feature over its covered cell spans. A sphere or swept tunnel should rasterize scanlines; it should not be rediscovered independently by every voxel sample.
Use separate indices for immutable world features and mutable edits.
## 2. Interval bounds and narrow-band evaluation
Every primitive and noise stack should expose conservative bounds.
If the base field for a region is farther from zero than the maximum possible displacement, skip all detail noise. This is especially important for expensive domain warps.
For an octree node:
- Entirely solid: emit no samples.
- Entirely air: emit no samples.
- Possibly crosses the surface: subdivide or evaluate.
This often removes more work than SIMD optimization.
## 3. Stop evaluating every archetype everywhere
Give archetypes spatial masks determined at planning resolution. A crystal-cavern noise stack should not even enter the chunk program for a neighboring sedimentary tunnel.
At transitions, compile only the two or three contributing archetypes and a deterministic blend mask.
## 4. Cache lattice samples and halos
Adjacent cells and chunks share corners. Cache canonical world-grid samples by brick revision. Do not evaluate each corner up to eight times, and do not let two chunks compute a shared boundary using different local coordinate paths.
## 5. Use bulk data layouts
For dense bricks:
- Structure-of-arrays density and material channels.
- Fixed-size aligned slabs.
- SIMD integer kernels.
- Bitmasks for active surface cells.
- Scanline/span writes for primitives.
- Small thread-local scratch arenas.
- No allocation in inner loops.
Bitsets are particularly effective for “possibly contains surface,” material-presence, and dirty-cell masks.
## 6. Incremental edit dependency tracking
An edit should invalidate:
- Intersected bricks.
- One-cell mesh halos.
- Their LOD ancestors.
- Their collision pages.
Nothing else.
Give every brick separate generation, edit, mesh, and collision revisions. This prevents a late worker result from publishing obsolete terrain.
An ever-growing edit list is not viable. Compact edits into canonical per-brick deltas after a threshold while preserving the original network/save semantics.
## 7. Separate collision urgency from visual quality
Generate collision first. It can initially use a conservative, lower-complexity surface as long as it preserves walkability and does not allow passage into solid rock. Refine the render mesh afterward.
Do not block collision readiness on material extraction, tangents, decoration, meshlets, or high-quality far-LOD work.
## 8. Exploit underground visibility
In an underground world, Euclidean distance is a poor visibility predictor. Build coarse visibility cells from the chamber/tunnel graph. Far terrain behind several sealed turns can be deprioritized even if spatially close.
GPU occlusion and meshlet culling help rendering, but avoiding CPU generation of invisible far surfaces is the larger win.
---
# What I would deliberately not build
- A dense 3D array for an entire strate. Solid rock and empty chambers must remain sparse or implicit.
- A global sequential random stream. Adding one decoration would change the rest of the world.
- Floating-point generation decisions, floating mesh booleans, or platform math functions in the canonical path.
- GPU-authoritative generation or meshing without proven integer bit-exactness.
- A generic interpreted CSG graph evaluated from its root for every sample.
- Unlimited domain-warp noise. It destroys useful spatial and amplitude bounds.
- A general-purpose runtime B-rep boolean engine. Architecture C should support a narrow, controlled set of exact operations.
- Independent marching-cubes meshes at each LOD with skirts added later as a patch. LOD ownership and transition topology need to be designed from the start.
- Runtime full-strate flood fills as the primary traversal guarantee. Connectivity should be structural; validation is a backstop.
- A never-compacted replicated edit list.
- One Unreal actor or UObject per voxel brick.
- Dependence on dynamic Nanite behavior for core terrain correctness. It may be useful for rendering, but collision, edits, and determinism need an independent path.
- Streaming based only on concentric distance rings.
- A system where render mesh completion is the only thing preventing a player from falling through unloaded terrain.
- A claim that arbitrary post-edit traversal is guaranteed without restricting, rejecting, or repairing edits.
---
# If I could change only three things
1. **Introduce a deterministic topology-first WorldPlan compiler.**
This establishes stable IDs, counter-based randomness, portal contracts, protected walkable routes, bounded feature instances, archetype masks, and a versioned numeric ABI. It fixes correctness and gives later systems much better information.
2. **Replace per-sample feature iteration with feature-to-brick compilation and sparse surface bricks.**
Build spatial indices, rasterize features in bulk where possible, apply noise only within bounded surface bands, cache shared samples, and maintain min/max LOD hierarchies. This is likely the largest CPU improvement.
3. **Replace distance-only chunk streaming with a revisioned, deadline-driven collision pipeline.**
Predict reachable motion including falls, prefetch passages and shafts, prioritize collision independently, invalidate only edit dependencies, and retain a conservative collision/admission fallback. This turns streaming from “usually fast enough” into an enforceable gameplay contract.
My first production choice would be Architecture B, while retaining Architecture A’s immutable operator language as the authoring and compilation input. In other words: compose with procedural operators, but execute them into sparse bricks feature-by-feature. It gives up a little theoretical elegance in exchange for much more controllable frame time, edit latency, collision readiness, and debugging.
> Round two, with the code: see `DESIGN-SOL-2026-09-15-CODE.md`. Sol corrected two of the notes above: per-sample lookup is ~5% exclusive (not the main cost), and coarse LOD from fine data is excluded by the clipmap design, not a gap.
+476
View File
@@ -0,0 +1,476 @@
# Game Design Document
**Status:** first consolidation, 2026-08-17. Distilled from `COMPOSER-NOTES.md`, which stays as the
working transcript (chronological, with wrong turns preserved on purpose). This document is the
opposite: **the decided state, without the journey.**
> **Where this file lives, and why it is the wrong place.** This is a design document for *the game*.
> Most of it is not about VoxelForge at all — the city, combat, the economy and seasons are all
> outside the plugin. It lives here only because the plugin folder is the only part of the project
> under version control. Move it to the project root once that becomes a repository.
**How to read this:** everything under **Decided** was settled by Jahni. Everything under **Open** has
not been. Where a design is mine rather than his, it says so. Nothing here is a proposal wearing a
decision's clothes.
---
## 1. The game, in a paragraph
A fantasy exploration and combat RPG, online-first and party-based. The setting is medieval-ish and
progresses toward better and more modern infrastructure without ever leaving fantasy. Players descend
from a city on the surface into an endless underworld to find the resources that surface has none of,
and bring them back to build that city up. Side systems: housing and farming. The underworld is
generated, shared by everyone, and replaced every season. The city is hand-built, shared by everyone,
and permanent.
## 2. The premise
**Civilization emerged from the depths.** The first strate below the surface is artificial — someone
built it — and it holds crystals with humans inside them. The game opens on one of those humans being
un-crystallized.
The surface is a sky island, or a mountain so tall nobody can descend it. Either way it is small and it
has no resources. Everything a civilization needs has to come from below.
So descending is not conquest. **It is a return.** You are going back to where you came from, and
nobody remembers why you left.
## 3. The core loop
> The surface is limited, so you must descend. The depths supply resources. Resources build the
> settlement. The settlement lets you descend further.
Every system in this document hangs off that loop, and it closes. The sky island does quiet work in
it: it explains why there is no trade with anywhere else, why the depths are the only economy, and why
going down is the civilization's whole project rather than an adventurer's hobby.
---
## 4. Vocabulary
Two words got used for the same thing early on and caused real confusion. Pinned here.
**THE SURFACE / THE CITY** — a hand-authored, ordinary Unreal level. Not voxel terrain, not generated,
not part of VoxelForge. Communal and identical for every player. **It never resets.**
**THE DEPTHS** — everything VoxelForge generates: bounded, subterranean, a stack of strates below the
city's hole. Some strates only *mimic* being outdoors. **This is what is regenerated each season.**
**A SEASON** — one generation of the depths, roughly six to twelve months, plus the settlement tier the
community earns during it.
---
## 5. The city
### Decided
The city is **communal** — the same place for everyone, not instanced — and **permanent**. The season
wipe never touches it.
It **grows across seasons**, one tier at a time: camp, village, town, city, advanced city. Players
gather resources in the depths and donate them to the mayor or equivalent; a global counter rises until
the next tier is reached. A season is the unit that advances it. The game opens at *camp*.
Because groups never meet in the depths, **the city is the entire social surface of the game.** Trade,
the retrieval guild, shared builds, and simply seeing another person all happen there.
### Design rules
**A tier grants access and convenience, never power.** Services, workshops, NPCs, a bank, fast travel,
new quests, better aesthetics — but not stronger starting gear or stat bonuses. Otherwise players
arriving in season eight begin meaningfully stronger than those in season one, the early strates
trivialize, and the difficulty curve erodes a little every season until the top of the world is
scenery. This is the same rule the depth economy already follows: **buy access and time, never
numbers.**
**The city should be a ledger.** Somebody arriving in season eight built none of it. That is either
alienating or the best first impression in the game, depending entirely on whether the city records
*who* built it — names on the aqueduct, a monument per tier listing that season's contributors. New
players then walk through evidence that real people made this place, and long-term players get
something permanent, which pairs with the titles that already survive wipes.
### Open
- Is the tier threshold a fixed number, or does it scale with the active population? A fixed number
stalls forever with a small playerbase and is cleared in a week with a large one. *(My
recommendation: target one tier per season and retune the threshold at each season boundary, which
is a moment that already exists for other reasons.)*
- Where housing sits relative to the city.
---
## 6. The depths
### Decided
Bounded, but **gigantic**. Not infinite — that choice was made deliberately, and it buys a great deal.
**No bottom.** The descent does not end.
**Boss strates roughly every five levels**, and they are **intermediary, not terminal** — gates that
punctuate the descent rather than finish it. A boss is where the transport network's next stop opens.
**Regenerated every season** from a single seed shared by every player.
Some strates are open, cathedral-sized spaces lit from within that read as outdoors. There is no sun
anywhere in the depths.
### What bounding buys
Because the depths are finite, the generator can examine an entire strate rather than sampling it. That
turns the entrance-and-exit guarantee into a **proof**, and it makes "the community has explored 34% of
this season" a real number rather than a slogan.
---
## 7. A strate
### Decided
**The primordial law: every strate has an entrance and an exit, exposed to air, and findable.** This is
the one invariant that may never fail.
**Danger is depth times a per-strate multiplier**, with a rare chance of an easier strate appearing
deep. Danger drives which creatures live there, which environmental hazards exist, and what loot drops.
It does **not** drive what *kind* of place a strate is.
**A strate's identity is unrelated to its depth.** Two seeds differ in everything — not the same
strates spawn, nor the same details. You could meet anything at any depth.
**Anomalous strates are rare.** The example Jahni gave is "dreamcore" — repetitive patterns, a strange
sense of emptiness. They should be rare enough that finding one is *puzzling*, because it does not have
its place there. They deliberately do **not** blend with their neighbours; the wrongness lives in the
seam.
**Size varies enormously** — from a crawlspace to a two-kilometre cathedral.
### Design rules
**Strangeness needs ordinary to be strange against.** If every strate is peculiar, peculiar becomes the
baseline and nothing reads as uncanny. A world should guarantee a share of grounded, plausible strates
so the strange ones land.
**A rare easy strate should be a designed type, not a low roll** — visibly calm, few creatures, a place
a descent exhales. The natural home for a safe camp or a merchant.
**The mirror of that is stronger: a rare danger spike near the surface.** A place at −300 that is
absolutely not for you yet. You find it early, it kills you, and it sits in your head for forty hours.
In a single shared world that becomes season-wide news.
**Every anomaly must be legible.** Unsignalled variance reads as bad tuning, not design. And danger in
particular must be **readable at a strate's entrance, before the player commits** — warned-and-ignored
is the best death in games; unwarned is the worst.
### Open
- Adjacency rules: what may sit next to what. Confirmed as wanted, never specified.
---
## 8. Descending
### Decided
**A party of up to six.** The descent is done together.
**Instanced per group.** Different groups never meet in the depths. Today this runs on a local server —
one player's machine or a dedicated one — fed by an external database for community information.
**Two ways back up.** Walking back through the tunnels is content, not a chore. Alternatively, once a
strate has been visited it opens a passage onward, so transport — an elevator or similar — can carry
you down, and you choose which strate to travel to.
**Transport unlocks are per-player for now.** A world-level unlock, where the first group through opens
the way for everyone, is a possible future — gated on strates being large enough that finding the next
passage is not a ten-minute errand. That is a decision to make from data, not in advance.
### Open
- **How long is a run?** Still unknown, and it constrains a great deal: strate size, how dense the
transport stops need to be, how deep a season can plausibly reach.
- Does danger scale with party size, or are strates tuned for a nominal group?
---
## 9. Progression and the economy
### Decided
**Gear stats have a ceiling — somewhere around depth 30.** Loot found deeper has the *same* stats.
**What depth buys instead:** better natural rolls, so an item costs less material to level up; and
materials, enchantments and effects that exist nowhere else.
**Beyond the ceiling, going deeper is about pride, challenge, and seeing new things.** That is the
intent, not an accident.
**Depth-locked materials are tagged by difficulty tier, not by strate.** Their placement stays random,
so *where* a material is has to be rediscovered every season — which in a one-seed shared world turns
into a community question with a fresh answer each time. A fixed depth would be solved once and stay
solved forever.
**Trading will exist**, lightly at first, and becomes much better once the city is a real social hub.
### Design rules
**Buy access and time, never numbers.** Applied to both depth and city tier, which means the economy has
one principle rather than two competing ones.
**Guarantee what recipes depend on; leave the rest to chance.** A material a progression recipe needs
must be placed within reach, or a season ships a dead end that reads as a bug. A purely optional
material can be absent, and a season lacking one gains an identity.
**Since the depths are gigantic, "guaranteed" must mean a density, not a location.** Somewhere in an
enormous strate is unfindable. Near enough to the spine and common enough to actually meet is not.
### Open
- Where exactly the gear ceiling sits, and how fast danger climbs past it — that single rate is the
entire endgame difficulty curve.
---
## 10. Death
### Decided
Dying in the depths leaves your **corpse, possessed**. You fight it to get your cargo back.
**The corpse copies your weapons, and you keep yours.** It is a mirror match, not a disarming — you
never return to a fight underequipped.
**Only you can claim your loot.** No player can take it.
**A corpse run is a detour, not an evening — ten minutes at worst.**
**An NPC tells you where your corpse was last seen**, for a very small fee.
**Possible later:** dead players appear in other groups' instances, and killing such a corpse earns
gold from a retrieving guild — never that player's loot.
### Design rules
**Place the corpse; do not make it walk to you.** On death it should appear at a landmark near the
nearest passage or transport stop, and wander only within that neighbourhood. That turns the ten-minute
cap from a hope into a guarantee regardless of where you actually died.
**Charge for precision, not for existence.** The general area — "last seen near the Sink" — should be
free. Pay only for the exact position or live tracking. Otherwise the fee is not a decision, and worse,
a broke player after a bad run cannot recover their cargo at all.
**Copied weapons must never be lootable.** If the corpse drops a copy of your sword you have built an
item duplicator and it will be found within a week. Copied gear vanishes with the corpse; only cargo
drops.
**The corpse should be a combat-shaped version of you, not a literal copy.** A support build's literal
mirror heals itself and cannot be beaten, for reasons that have nothing to do with skill.
**Give the player a deliberate edge.** A perfect mirror is a coin flip, and you often arrive having
already spent your consumables. You have potions and can retreat; it does not and cannot. That is what
makes the fight winnable through preparation rather than luck.
**The bounty hunter and the player retrieval guild are one system with two front ends** — players are
the fast, uncertain path; the NPC is the slow, guaranteed one. Either way the cargo returns to you and
the retriever is paid by the guild, not out of your pocket.
### Open
- What happens if you die while fighting your corpse — one corpse that absorbs the last, or do they
stack?
---
## 11. Housing and farming
### Decided
**Housing is an instanced personal level** — your own small world, never wiped, never re-placed.
**Its ground is a snapshot of land you chose** in the depths and claimed. Your home is *from* somewhere.
After the season wipe it is the last surviving fragment of a world that no longer exists.
**One template level populated from save data** — a stored terrain region plus a list of placed
structures. Not one map per player.
**Invites**, because houses can be shared and visited.
**Anything that grows is computed from elapsed real time on entry**, not ticked — the instance is not
loaded while you are away.
### Open
- Whether players can build underground in the depths proper. The idea is alive, undecided.
- Whether the surface offers natural buildable space, or whether terrain-editing tools cover it.
---
## 12. The verbs
### Digging — decided
Digging is for **breaking walls that hide something behind them**, **uncovering half-buried ruins**,
**small shortcuts within a strate**, and **terraforming for builds**. It is a reveal-and-shape verb.
It is **not** a movement verb, and it is not the primary way through the world.
Descending by digging is possible but effortful: each strate's floor and ceiling are sealed, and the
seal must be dug through. Passages and transport are the fast path, by design.
### Combat — decided
MMORPG-like and dynamic: ranged and melee, magical and physical, stuns, roots, mutes, area effects,
targeted area effects, damage over time.
**Party of six. No friendly fire** — an area effect in a tunnel is simply strong, and that is accepted.
**First person must remain possible.**
### Design rules
**Telegraph the source, not the floor.** A circle painted on the ground only works if you can see the
ground, which is exactly what first person removes — you cannot see a marker under your own feet or
behind you. If the enemy visibly winds up instead, the threat is read off the creature. That works in
both perspectives, it survives lumpy voxel terrain where ground decals project badly, and reading an
animation is a better thing to ask of a player than reading a decal.
**Supporting both perspectives means owing both sets of constraints** — full third-person camera
clearance *and* source-readable telegraphs.
**Fights need space, and the generator must guarantee it.** Cramped tunnels are the worst possible
arena: no room to dodge a telegraph, no sightlines, no retreat, and with six players' effects in an
eight-metre corridor nobody can see anything. Rough targets, in the generator's own units of 8 m
chunks: a boss arena wants 3 to 4 chunks across, an ordinary fight space about 2, and roughly 1 chunk
of headroom for the camera.
**Verticality is what caves can offer that a flat arena cannot.** MMO fights are overwhelmingly flat. A
fight across ledges and drops is genuinely different, and it is the reason to fight underground at all.
### Open
- Creatures. Nothing decided. Danger and theme tags will select them, but *what they are* is unwritten,
and they are most of what makes a strate feel like a place rather than a shape.
---
## 13. The season
### Decided
The depths are wiped and regenerated roughly every six to twelve months.
**Survives a wipe:** the city and its tier, account progression, titles, personal blueprints, housing
instances.
**Dies with the wipe:** the depths, the shared build pool, the transport unlock network.
### Why this is good rather than merely tolerable
The world is impermanent and the civilization is not. *We lost our maps, but we built a town.* Season
eight arrives in a place season three made, which gives a season's end a meaning beyond freshness.
It also solves problems that would otherwise be permanent. Every long-lived exploration game eventually
dies of its own wiki — everything is solved, every secret is mapped, nothing is left to find. A wipe
resets that, and the whole community re-explores together. It equally means a datamined map is worthless
one season later, so there is nothing to defend against.
And it frees the generator: because generation is only frozen *within* a season, it can be changed at
every boundary. The system never has to be finished before it meets players.
---
## 14. The community layer
### Decided
**One seed for everyone.** Every player's depths are the same depths, which is what makes shared
knowledge — wikis, guides, "the crystalline lake is in strate four" — possible at all.
**Players share builds**, as defined blueprint structures rather than raw terrain edits. Votes rank
them; well-liked builds appear in more players' worlds; disliked ones fall to the back of the queue and
sufficiently disliked ones are never shown again. **New and unvoted builds must be deliberately rolled
in**, or nothing new is ever seen and the system dies quietly.
**The build pool does not survive a season change** — nothing would be placed correctly. Top
contributors are rewarded with titles, tiered by population.
**Landmarks have names**, generated so that every player sees the same name for the same place before
anyone has ever visited it.
### Design rules
**The community layer must degrade gracefully.** The depths are generated from a seed and need no
database at all. If the database is unreachable, procedural names stand in for community ones, shared
builds simply do not appear, and votes queue locally. That is cheap now and painful to retrofit once a
hundred call sites assume the fetch succeeded.
**A build placed anywhere is yours; a build placed in a spot the world advertised is eligible to
spread.** That keeps building free while keeping automatic distribution safe.
**Names must survive contact with a wiki** — short, pronounceable, typeable. A name nobody can spell
cannot become community knowledge, which is the only reason names exist here.
**Auto-distributing player content needs a report, reject and rollback path**, and shared builds must be
individually removable by the player receiving them.
---
## 15. The opening
### Decided
A cinematic: civilization emerged from the depths. The first artificial strate holds crystals with
humans inside. One of them is un-crystallized — you.
You discover a **base camp**, and are told you could be an adventurer who braves the depths.
Then you descend to **strate 2 — the first generated strate.**
### Why this is worth more than it looks
The first strate is hand-made, and the rest are generated. That is normally a production compromise
players must be prevented from noticing. Here the hand-made strate is **hand-made in the fiction too** —
artificial because someone built it, with the natural world lying beneath. A player who senses that
strate 1 feels different from strate 2 is reading the story correctly rather than spotting the seam.
That is rare, and it is worth protecting in every later decision.
---
## 16. Open questions, collected
Ordered roughly by how much they block other work.
1. **Is the descent fun?** Unverified, and undesignable — it needs a crude build and an evening of
playing it. Everything in this document assumes the answer is yes.
2. **How long is a run?** Constrains strate size, transport density, and how deep a season reaches.
3. **Creatures.** Nothing decided. Blocks content, not the generator.
4. **The backend.** Local server plus an external database, per-group instances, moderation, shared
builds. Entirely unspecified and carrying a lot of weight.
5. **Does danger scale with party size**, or are strates tuned for a nominal group?
6. **The tier threshold** — fixed, or scaled to population?
7. **Adjacency rules** between strates. Wanted, never specified.
8. **Underground building** — undecided.
9. **Where the gear ceiling sits**, and how fast danger climbs past it.
## 17. Deliberately deferred
- **Digging noise attracting creatures.** Liked, parked. Still the cheapest way to price digging in
safety rather than in resources, if it is ever wanted.
- **Naming languages varying by depth** — older, stranger tongues deeper down. Liked, parked as still
being a gradient.
- **World-level transport unlocks**, where the community opens the descent together. Gated on strates
being big enough for the discovery to be worth something.
- **Shards** — more than one seed at a time. Only if the game grows enough to need them.
- **Singleplayer.** Never the intent; may become an option.
---
## Where the rest lives
- **`COMPOSER-NOTES.md`** — the working transcript this was distilled from, including the generator
design (the composer, the world manifest, the measurement pass, stack assembly) and the reasoning
behind everything above.
- **`ARCHITECTURE.md`**, **`CODEMAP.md`** — how VoxelForge actually works.
- **`fable-idea.md`** — the older ranked feature and performance roadmap. Predates most of this.
+165
View File
@@ -0,0 +1,165 @@
# VoxelForge investigation — T1.d and SurfaceWorld column memo
Date: 2026-08-16
Base: `experimental` at `4ba53f2`
Method: static source review only. **No build, compile, editor launch, or automation test was run.**
## Executive conclusion
The current explanation “one unticked neighbouring strate makes the exhaustive per-chunk sweep
reject otherwise-safe boundary tiles” is not what the code does.
`UVoxelStrateManager::UsesOperatorStackForChunk` depends only on `ChunkCoord.Z`. Before the full XYZ
sweep, `UVoxelGenerator::ClassifyTile` has already visited every sampled lattice Z, required the
operator-stack predicate for every cave Z it sampled, and required all cave samples to belong to one
layout slot. Within that one slot the flag cannot vary with X or Y. The later XYZ flag check is
therefore redundant with the current layout implementation.
That does **not** make cross-strate boundary tiles safe to classify with one stack. They still fail
the independent one-slot, generator-type, and bit-identical-params guards. Removing only the later
flag check would not unlock those tiles. Requiring the density samples represented by one verdict to
use one implementation is necessary in principle; the current exhaustive re-check is over-strict/
redundant, but it is not the measured T1.d blocker.
The diagnostic is less precise than the log claims: the initial `UsesOperatorStackForChunk` check
runs before the different-slot check, so a coarse/boundary tile that reaches an unticked adjacent
cave slot can increment `Cave Bail Not Op Stack` even though it would subsequently have failed as
mixed content. The measured 80% therefore does not distinguish “the flown slot itself is unticked”
from “a boundary tile encountered an unticked slot first.” The asset state still has to be read in
the editor.
For the column memo, the port is genuinely incomplete. `GSurfColCache` is a six-box spatial LRU;
`FSurfaceColumnSource::GetColumn` currently owns one 81x81 direct-indexed box. A recenter or
`ColumnKey` change clears all 6,561 computed flags in that one box. This is a verified structural
difference, but the observed 10–30% location-dependent miss band does not prove its performance
impact. Only the same seed and same route can do that.
## Findings
| Status | Finding | Evidence and consequence |
|---|---|---|
| **Verified by reading** | The late “every chunk opted in” sweep is redundant today. | `VoxelGenerator.cpp`, `UVoxelGenerator::ClassifyTile`; `VoxelStrateManager.cpp`, `UVoxelStrateManager::UsesOperatorStackForChunk`. The predicate ignores X/Y and is constant for a layout slot. The earlier Z loop and `CaveBotChunkZ` guard already establish one opted-in cave slot. |
| **Verified by reading** | Boundary tiles remain unsafe for a one-stack verdict for reasons independent of the flag. | `ClassifyTile` rejects a second cave slot, a different generator type, and any non-bit-identical parameter struct. Gradient/Interleaved transitions can vary params per chunk. Removing the redundant flag re-check alone cannot change the safe result from `Mixed`. |
| **Verified by reading** | `Cave Bail Not Op Stack` is an ambiguous attribution counter. | The first predicate check occurs before `GetStrateChunkZBounds`/different-slot attribution. A boundary tile can be counted as NotOpStack even though mixed content would also reject it. Terrain correctness is unaffected; diagnosis is affected. |
| **Suspicious, needs checking** | Most of the measured 1.42/1.77 NotOpStack bails come from the primary cave asset itself being unticked. | This is the simplest explanation for interior tiles, but `.uasset` state is not readable from this source tree and the counter does not separate primary-slot from boundary-slot failures. Confirm in the editor or use Approach A's initialization log. |
| **Verified by reading** | The op-stack column memo loses an entire 6,561-cell working set on any recenter/key change. | `VoxelDensityOpStack.cpp`, `FSurfaceColumnSource::GetColumn`, has one direct-indexed box. `VoxelGenerator.cpp`, `FSurfaceColumnCache`, has `NumBoxes = 6` and evicts only one LRU box. |
| **Suspicious, needs checking** | The one-box design materially causes the observed 10–30% in-game miss rate. | Plausible and location-sensitive, but unmeasured. The retracted different-route comparison cannot support a before/after claim. |
| **Verified by reading** | VF-03's core CP-cache contamination defect is present, and the current source now contradicts the audit reviewer's header. | `VoxelGenerator.cpp`, `UVoxelGenerator::GetDensityAt`, keys the function-static `thread_local CP_*` state by `(ChunkCoord, LayoutVersion)` with no generator/world identity. Each manager's version begins at the same value. `VoxelForgeTestFixture.h`, `FTestWorld::Build`, now explicitly documents the contamination and repeatedly calls `Initialize` to give test worlds process-unique versions. Production has no equivalent owner key. A second world on the same worker can reuse the first world's params, `CP_UseOpStack`, and stack. |
| **Suspicious, needs checking** | Every other TLS cache named in audit VF-03 has the same cross-world exposure. | Several keys visibly omit an owner, but this pass proved the `CP_*` path only. The broader `OC_*`, `BM_*`, passage shortlist, biome, diff, and op-local cache set should be audited as one owner-identity task rather than assumed from the old audit row. Both approaches below leave this open. |
The audit header says VF-03's fixture citation was fabricated. That statement is stale relative to
the checked-out source: the cited explanatory block exists now in `VoxelForgeTestFixture.h` and is
specific about `CP_UseOpStack` contamination. This does not automatically validate every cache
listed in VF-03; it does validate the CP-cache defect above.
## Two implemented approaches
| | Approach A — explicit opt-in + six-box spatial LRU | Approach B — code-enforced cutover + four-way associative memo |
|---|---|---|
| Strategy | Preserve the per-asset A/B contract. Add initialization-time warnings naming every disabled layout slot; the human fixes the `.uasset`. Port the reference six-box LRU to the op source. | Ignore the serialized flag at runtime and route all eight ported archetypes through the stack. Keep the UPROPERTY for asset compatibility. Replace the box with 4,096 exact coordinate entries arranged as 1,024 sets x 4 ways. |
| T1.d effect | No silent behavior change. T1.d fires only after the relevant assets are enabled. Boundary tiles still conservatively fail for slot/params reasons. | `Cave Bail Not Op Stack` should disappear for every valid ported slot without asset edits. `GetDensityAt` and `ClassifyTile` still use the same manager predicate and same stack factory. |
| Cache behavior | Six independent 81x81 boxes. An acquisition miss recenters/clears one LRU victim; five boxes remain warm. Closest match to the proven reference. | Hash routes by exact X/Y plus both halves of `ColumnKey`; exact X/Y/key comparison decides hits. A miss evicts one entry in one set. No bulk clear. Four-way conflicts and capacity eviction remain possible. |
| Runtime/memory cost | Approximately 0.79 MiB TLS per worker for this memo, versus roughly 0.13 MiB for the current single box. Six linear box checks per integer query. | Approximately 160 KiB TLS per worker (compiler padding can change this). Four exact probes per integer query plus hashing/LRU-rank updates. |
| Main risk | High per-worker memory multiplication. The diagnostic currently enumerates SurfaceWorld slots too, although SurfaceWorld's exact-lattice T1.d path does not depend on this flag; narrow that warning before adopting it. | Intentional behavior change for every false-flag asset; the switch can no longer be selected for production A/B. Existing false/true ClassifyTile fixtures now exercise the same runtime path, reducing the distinction between those two end-to-end tests. Associative conflicts may underperform the spatial LRU. |
| What it does not solve | VF-03 owner identity; boundary params/slot conservatism; proof of memo benefit. | VF-03 owner identity; boundary params/slot conservatism; proof of memo benefit. |
### Recommendation
Start from **Approach A**, after narrowing its warning to cave archetypes or rewording the
SurfaceWorld entry. The code does not justify weakening `ClassifyTile`, and the current flag is still
valuable for a same-route A/B. Approach A preserves that measurement lever and ports the known
reference cache design. Its memory cost is the reason not to merge it blindly: measure it against
Approach B on the identical route.
Approach B is the cleaner long-term endpoint only if the project has deliberately decided to retire
the switch as a runtime fallback. It removes configuration drift and is much smaller in TLS, but it
spends the A/B lever and moves every false-flag asset at once. The eight equivalence suites make that
a defensible experiment, not a zero-risk migration.
## Worktrees and files touched
### Approach A
Worktree: `E:\Projet Unreal\VoxelM\Plugins\VF-approach-A`
- `Source/VoxelForge/Private/VoxelDensityOpStack.cpp`
- `Source/VoxelForge/Private/VoxelStrateManager.cpp`
- `CODEMAP.md`
Likely compile-error/watch spots:
- The function-local `thread_local FColumnCache` containing six large aggregate boxes on MSVC/UE's
TLS implementation.
- Nested local cache types and `FMemory::Memzero` of each victim's `Computed` array.
- The new `UE_LOG` format strings/arguments in `UVoxelStrateManager::Initialize`.
### Approach B
Worktree: `E:\Projet Unreal\VoxelM\Plugins\VF-approach-B`
- `Source/VoxelForge/Private/VoxelDensityOpStack.cpp`
- `Source/VoxelForge/Private/VoxelGenerator.cpp` (comments only)
- `Source/VoxelForge/Private/VoxelStrateManager.cpp`
- `Source/VoxelForge/Public/VoxelDensityOpStack.h` (comments only)
- `Source/VoxelForge/Public/VoxelStrateDefinition.h` (UPROPERTY retained; comments only)
- `Source/VoxelForge/Public/VoxelStrateManager.h` (comments only)
- `Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp` (comments/messages only)
- `Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h` (comments only)
- `CODEMAP.md`
Likely compile-error/watch spots:
- The function-local `thread_local FColumnMemo` 4,096-entry aggregate.
- `VoxelHash::Mix` calls and casts for X/Y plus low/high halves of the 64-bit key.
- The `uint8` four-way LRU ranks and local-entry default initialization.
- UHT should see no serialized layout change: `bUseOperatorStack` was not removed or renamed.
Both worktrees are detached from `experimental`, contain uncommitted changes, and passed
`git diff --check`. Neither was built or tested.
## Human measurement protocol
Do not compare screenshots or stat averages from different flights. For every baseline/candidate:
1. Use the **same world seed, same asset values, same start point, same route, same speed, same LOD/
streaming settings, same capture duration, and the same warm-up policy**.
2. Record at least one repeat of the route; scheduler variation can move worker-local cache reuse even
when the geographic route is identical.
3. Capture `stat VoxelForge` and an Insights trace together. Normalize column misses and worker time by
`Tiles Meshed`; totals alone conflate cheaper tiles with fewer tiles.
4. Change only one worktree/approach at a time. Do not compare Approach A after an asset edit with
Approach B before that edit and call it a cache result.
For T1.d, record:
- `Cave Bail Not Op Stack`, `Cave Bail Mixed Content`, `Cave Bail Params`,
`Cave Bail Stack Verdict`, `Cave Bail Disturbance`, `Cave Bail No Stack`;
- `Tiles Classified`, `Tiles Meshed`, `Tiles Operator Stack Solid/Air`, and total skipped Solid/Air;
- the route segment's active strate and whether each neighboring asset's legacy flag is enabled.
Expected interpretation:
- Approach A: the initialization log must identify any false flag. After the human enables the
relevant cave definitions, NotOpStack should approach zero in single-strate interiors and
`Tiles Operator Stack Solid/Air` should become non-zero. Boundary tiles may move to MixedContent,
Params, or StackVerdict; that is conservative and expected.
- Approach B: NotOpStack should be zero for valid ported cave slots without asset edits. A non-zero
value then points to out-of-layout/invalid-slot logic or a stale build, not the legacy flag.
- In both: `Tiles Meshed < Tiles Classified` is the production prize. A zero `violations` result and
all eight bit-equivalence suites green remain mandatory before trusting it.
For the memo, record:
- `Column Memo Hits` and `Column Memo Misses` as a miss percentage;
- misses per `Tiles Meshed`;
- Insights `VoxelForge_ClassifyTile` and `VoxelForge_GenerateMesh` count and time per meshed tile;
- visible LOD-ring update time/throughput on the identical route;
- process memory if comparing the six-box LRU against the associative table at the same worker count.
A lower miss percentage on a different route is not evidence. A valid claim is: same seed, same
route, same settings, same denominator, with the candidate as the only change.
## Ready-to-build status
Both alternatives are ready for the human's review/build step. No result in this document is a
compile or runtime claim; all implementation conclusions are from source and diff inspection.
+629
View File
@@ -0,0 +1,629 @@
# VoxelForge — the decomposition map
> **What this is:** every one of the 8 archetypes, read line by line, broken into the four roles of
> `OPSTACK-PLAN.md §2.5`, with each existing param traced to the op that will own it. Written
> 2026-07-27 so no later port has to re-derive it.
>
> **Read `OPSTACK-PLAN.md §2.5` first.** The whole point is that an archetype becomes
> `source + combiners + modifiers`, not one opaque op. If a port here collapses into a single
> `FMazeOp`, the refactor has failed its own test.
>
> **Status:** analysis only. No op exists. `Public/VoxelDensityOp.h` (the contract) is written; the
> `switch` in `GetDensityAt` is untouched.
---
## 0. What fell out — read this before the per-archetype sections
Three findings changed how I'd sequence the work. They are the reason this document is worth its
length.
### 0.1 ⚠️ The contract needs an SDF channel, not just a density channel
`IVoxelDensityOp::Eval` returns *density*. But look at what TunnelNetwork actually does:
```
CaveSDF = EvaluateSDFCached(rooms + tunnels) // SDF space
CaveSDF = SmoothMin(CaveSDF, PitSDF, Pit.BlendK) // SDF space
CaveSDF = SmoothMin(CaveSDF, ChimneySDF, Chim.BlendK) // SDF space
→ ONE carve at the end: Density -= CarveFactor · BaseDensity · 2
```
Maze, VerticalShafts and FloatingIslands do the same shape: build an SDF from several primitives,
perturb the SDF with roughness noise, then convert once.
**If each primitive becomes a density op with its own carve, the `SmoothMin` junctions are lost** —
a pit would meet its room at a hard seam instead of the organic blend the code deliberately builds
(the comment at the pit block says exactly this: *"SmoothMin at the pit-to-room junction creates the
same organic transition as tunnel-to-room (no hard seam at PitTopZ)"*). Roughness is worse: three of
the four archetypes add noise to the **SDF**, which displaces the surface; adding the same noise to
**density** scales with the local gradient and is a different effect.
So ops need two channels: `float Density` and `float Sdf`, with an explicit `SdfToDensity` op that
converts. Sketch:
```cpp
struct FVoxelOpSample { float Density; float Sdf; }; // Sdf = FLT_MAX ⇒ "no surface nearby"
virtual void Eval(float X, float Y, float Z, FVoxelOpSample& InOut) const = 0;
```
**This is the one part of `OPSTACK-PLAN §3` I think is wrong as written, and it matters far beyond
fidelity:** SDF-space `SmoothMin` between two *different* sources is precisely how "a room graph
carved into a mountain" produces an organic junction rather than one field punching a hole in the
other. The single-channel contract can only ever overwrite. **Recommend adopting the two-channel
`Eval` before porting Maze** — it is cheaper now than after four ports.
*(Left as a recommendation, not a change: the header as committed is single-channel, and this is
Jahni's call.)*
### 0.2 ⚠️ Worm tunnels are why TunnelNetwork can never skip a tile — and the fix is a number
`AUDIT §6.2` frames "placed vs fielded" as a question about *future* 3D caves. It is already a live
cost. Worms are a pure 3D-noise threshold carve with no bounds:
```
if (WormStrength > 0 && WormThreshold > 0) { ... Density -= t · WormStrength · NetworkMask; }
```
Unbounded in space ⇒ `EffectOverBox` = `CarveOnly` **everywhere** ⇒ `AllSolid` is dead for every
tile of every strate with worms enabled. Direction alone cannot recover it.
**But the amplitude is bounded and trivially known:** `t ∈ [0,1]`, `NetworkMask ∈ [0,1]`, so the worm
op can move density toward air by at most `WormStrength`. If the stack so far is provably solid by
more than the sum of every remaining op's max carve, the box is still `AllSolid`.
So the first numeric interval bound worth writing is not the SDF Lipschitz bound the plan reaches
for — it is **a scalar amplitude cap on the fielded-noise carves**. Roughly ten lines, and it is
what unlocks deep-rock skipping for the plugin's most-used archetype. `OPSTACK-PLAN §4` defers all
numeric bounds to Phase 3; on this evidence one of them belongs in Phase 2.
### 0.3 Disturbances already have bounds that `ClassifyTile` throws away
Today: `if (D.ChasmDensity > 0) bCanSolid = false;` — strate-wide, for the whole tile.
But chasms/bridges/ridges are hash-placed on an XY lattice of known spacing and radius, and the
per-voxel code already builds the 3×3 candidate list. A box that no candidate reaches is `Identity`.
This is a **pure win available to SurfaceWorld today**, independent of everything else in this plan:
any surface strate that enables chasms currently loses `AllSolid` on every tile in it.
---
## 1. The shared primitive library
The 8 archetypes are ~6 functions in costumes (`OPSTACK-PLAN §1b`). Decomposed, here is what
actually repeats. **Reuse count is the payoff metric** — anything used once is suspicious.
### Role 1 — FIELD SOURCES
| Op | What it lays down | Used by | `IsXYPure` | `ClassifyBox` / `EffectOverBox` |
|---|---|---|---|---|
| `FConstantRockSource` | `Density = BaseDensity` (solid) | TunnelNetwork, Maze, VerticalShafts, **bedrock gaps** | ✅ trivially | `ClassifyBox` → **AllSolid**, always. Free, exact. |
| `FConstantVoidSource` | `Density = −BaseDensity` (air) | FloatingIslands | ✅ | `ClassifyBox` → **AllAir**, always. |
| `FSlabVoidSource` | floor surface + ceiling surface, `Density = −min(z−floor, ceil−z)` | FlatPlain, CrystalChamber | ❌ *see §3.1* | `ClassifyBox` by sampling floor/ceiling over the box's XY corners — **exact, cheap, and a new skip win** |
| `FHeightfieldSource` | `max(TerrainZ − z, z − CeilSurf)` | SurfaceWorld | ✅ (the column) | `ClassifyBox` by sampling columns on the mesher's exact lattice — **this is today's `TestColumn`, moved verbatim** |
| `FRoomGraphSource` | rooms + tunnels (+ pits + chimneys) SDF | TunnelNetwork | ❌ | `Identity` when no room/tunnel bound reaches the box — **`FCachedRoom`/`FCachedTunnel` already carry the bounds** |
| `FLatticeCorridorSource` | 3D lattice capsule corridors | Maze | ❌ | `Identity` when no open edge's capsule bound reaches the box |
| `FShaftFieldSource` | vertical cylinders + horizontal connectors | VerticalShafts | ❌ (cylinders are XY-pure; connectors are not) | `Identity` when no shaft cell reaches the box in XY |
| `FIslandBlobSource` | tapered flat-topped blobs | FloatingIslands | ❌ | `Identity` when no island bound reaches the box |
| `FWormFieldSource` | 3D-noise threshold carve | TunnelNetwork | ❌ | **`CarveOnly` always** — see §0.2. The one unbounded source. |
Note how much `Identity` is available and unused: **six of nine sources can prove themselves absent
from most of the volume**, and today none of them do.
### Role 2 — COMBINERS
`Replace` · `Union`(min) · `Subtract`(max) · `SmoothUnion`/`SmoothSubtract` (reuse
`VoxelSDF::SmoothMin/Max`) · `Add` · `Mask`. Plus, from §0.1, the conversion op:
| Op | Meaning |
|---|---|
| `FSdfCarve(blend)` | `SDF < blend` ⇒ `Density -= smoothstep(...)·BaseDensity·2`. **The exact same six lines appear in TunnelNetwork, Maze and VerticalShafts.** |
| `FSdfFill(blend)` | the `+=` mirror. FloatingIslands. |
That one op deduplicates three copies of the carve formula and one of the fill.
### Role 3 — DETAIL MODIFIERS
Everything here is gated on being near a surface, and everything here already exists.
| Op | Space | Used by | Notes |
|---|---|---|---|
| `FSurfaceRoughnessMod` | **SDF** *or* **density** — two variants, see below | all 4 SDF archetypes | the single biggest reuse in the plugin |
| `FTerraceMod` (cave) | density | TunnelNetwork | SDF-gradient orientation gate (2 extra SDF evals) |
| `FLayerLineMod` | density | TunnelNetwork | sine along Z, cubed |
| `FRibbingMod` | density | TunnelNetwork | sine along Z, squared, `+=` |
| `FCaveOverhangMod` | density | TunnelNetwork | low-Z-frequency fBm, positive lobe only |
| `FCaveCliffMod` | density | TunnelNetwork | noise-modulated vertical gradient |
| `FScallopMod` | density | TunnelNetwork | cellular noise |
| `FArchMod` | density | TunnelNetwork | room-relative |
| `FRoomColumnMod` | density | TunnelNetwork | room-relative, pre-baked in `BuildChunkCache` |
| `FDomeMod` | density | TunnelNetwork | room-relative |
| `FPinchMod` | density | TunnelNetwork | room-relative |
| `FFloorBiasMod` | density | TunnelNetwork | only inside cave air |
| `FPitMod` / `FChimneyMod` | **SDF** | TunnelNetwork | `SmoothMin`'d into the cave SDF — §0.1's motivating case |
| `FGridColumnMod` | density | FlatPlain, CrystalChamber | world-grid cylinders — **different op** from `FRoomColumnMod` |
| `FShaftLedgeMod` | density | VerticalShafts | banded shelves, half-sided |
| `FChasmMod` / `FBridgeMod` / `FRidgeMod` | density (MC) | **all archetypes** (disturbances) | see §0.3 |
**The two roughness variants, because this is the trap:**
- **SDF variant** (Maze, VerticalShafts, FloatingIslands): `Sdf += fBm(x·k, y·k, z·k)·SCALE·Rough`.
Raw, no fade, no clamp, fixed frequency baked into the call site (`0.12`, `0.1`, `0.08`).
- **Density variant** (TunnelNetwork): two octave sets (main + fine×3), optional domain warp,
four noise types, a `min(…, 0)` clamp so roughness can never re-fill definite air, and a
quadratic fade by distance from surface.
They are *not* the same op with different params. Port them as one op with a `Space` enum and let
the SDF variant's fixed frequencies become real params — that alone is a small authoring win, and
`OPSTACK-PLAN §2.6` explicitly permits the re-tune.
### Role 4 — STRUCTURAL POST (fixed order, appended by the compiler, never author-omittable)
| # | Op | Today | Effect over box |
|---|---|---|---|
| 1 | `FOriginSpineOp` | `ApplyOriginSpine` | `CarveOnly`; `Identity` when the XY circle (R + 3) misses the box, or Z is outside the interior |
| 2 | `FBoundarySealOp` | `ApplyBoundarySeal` | **`ClassifyBox` → AllSolid inside its band** (forcing — see `VoxelDensityOp.h`); `Identity` when the box misses both bands |
| 3 | `FPassageCarveOp` | `ApplyPassageCarving` | `CarveOnly`; `Identity` via `AnyPassageNearBox` — **already written** |
| 4 | `FDiffLayerOp` | the diff block in `GetDensityAt` | `Both` when mods intersect; `Identity` via `HasAnyModInChunkRange` — **already written** |
The order is load-bearing and is the order the code already uses: spine carves the interior only,
the seal then re-solidifies its bands (the spine deliberately never touches them), passages punch
through everything including the seal, and the player wins last.
> ### ⛔ RETIRED 2026-07-28 — frames were never a fifth thing. Porting all three candidates killed it.
>
> The section below argues for a `FRAME` op family from three examples. All three are now ported,
> and none of them turned out to need one:
>
> - **`CaveWarp` wraps exactly ONE operator.** Pits and chimneys explicitly read *unwarped* coords
> while writing the same SDF channel — the thing this document called "the single fiddliest thing
> in the whole decomposition". Inside one operator the difficulty evaporates: the warp is a local
> variable, not an inherited context. A transform whose scope is one op is not a frame.
> - **`VerticalScale` is `Z / Scale`** — a pure function of a scalar and a param, recomputed in one
> line by each op that needs it. A frame would add a channel to avoid a division.
> - **The island warp (§7)** was kept local for the same reason, before the other two were even read.
>
> **Zero frames out of three candidates.** It was not missing infrastructure; it was one idea seen
> three times from a distance. Kept below as the reasoning that was superseded, not as a plan.
### A fifth thing the plan doesn't name: FRAME OPS
Two archetypes transform the *query coordinates* rather than the field:
- `VerticalScale` — `EffectiveZ = WorldZ / VerticalScale` (TunnelNetwork), stretches everything below it.
- `CaveWarpStrength/Frequency` — domain-warps the coords the SDF is evaluated at, *but not* the
coords roughness/terrain-ops/columns use. The comment is emphatic about why: *"terracing stays
horizontal, columns stay vertical"*.
So a frame op has **scope** — it applies to some ops below it and not others. Modelling that as
"push frame / pop frame" markers in the tape is straightforward; modelling it as a per-op flag is
not, because the same op can appear inside and outside a frame. **Decide this before the tape
format is fixed.** It also has a `EffectOverBox` consequence: any op under a warp frame must inflate
its box by the warp amplitude before answering — the existing code already does exactly this
(`Expansion = CaveWarpStrength + 2`).
---
## 2. TunnelNetwork *(and Underwater — identical rock, a water flag)*
`GetDensityWithParams`, ~1080 lines, the biggest single function in the plugin. **Port LAST** — it
owns `BuildChunkCache`'s two-region window-invariance discipline (§8.4), the most delicate code here.
```
FRAME VerticalScale (Z pre-divide, wraps everything below)
├─ FConstantRockSource Replace BaseDensity
├─ FRAME CaveWarp (SDF queries only — NOT the ops below the frame)
│ ├─ FRoomGraphSource → Sdf rooms + tunnels, cached per chunk
│ ├─ FPitMod → Sdf SmoothMin, unwarped coords ⚠️
│ └─ FChimneyMod → Sdf SmoothMin, unwarped coords ⚠️
├─ FSdfCarve(SDFBlendRadius) Subtract
├─ [gate: bNearCaveSurface = Sdf < SDFBlendRadius·3]
│ ├─ FSurfaceRoughnessMod Add density-space variant
│ ├─ ── per-room op override ── ⚠️ see below
│ ├─ FTerraceMod Add
│ ├─ FLayerLineMod Subtract
│ ├─ FRibbingMod Add
│ ├─ FCaveOverhangMod Add
│ ├─ FCaveCliffMod Add
│ ├─ FScallopMod Subtract
│ ├─ FArchMod Add
│ ├─ FRoomColumnMod Add
│ ├─ FDomeMod Subtract
│ ├─ FPinchMod Add
│ └─ FFloorBiasMod Add
├─ FWormFieldSource Subtract ⚠️ ungated, unbounded — §0.2
└─ [structural post ×4]
```
⚠️ **Pits and chimneys are evaluated at UNWARPED coordinates while rooms are evaluated at warped
ones**, and then `SmoothMin`'d together. The comment justifies it (pit anchors come from unwarped
room centres). Under a frame model this means pits/chimneys must sit *outside* the warp frame while
still writing the same SDF channel. That is expressible, but it is the single fiddliest thing in the
whole decomposition — **budget for it and do not discover it during the port.**
⚠️ **The per-room terrain-op override has no clean home.** Today: `NearestRoomIdx` picks a room,
that room's hash-rolled `UVoxelTerrainOpDefinition` is applied onto a *copy of the whole param
struct*, and the copy shadows `Params` for the rest of the function. In an op stack there is no
"whole param struct" to overwrite. Two options:
- **(a) Scope by room.** Each modifier gains an optional "only inside room N's influence" predicate,
and the room-graph source publishes the nearest-room index per voxel as stack state. Faithful,
and it generalises to "this op only inside this region", which is the `Mask` combiner already in
the plan.
- **(b) Drop per-room ops**, make modifiers strate-wide, and recover variety with `Mask` on a
hash field. Much simpler, visibly different world.
**(a) is right** — per-room variety is a real feature and (b) would flatten it — but it is the piece
that could blow the Phase-1 timebox if attempted early. It is also the *only* consumer of
`NearestRoomIdx`, so it can be deferred: port TunnelNetwork's geometry first with strate-wide ops,
add room scoping after.
**Tile-skipping prize:** currently zero. After the port, with §0.2's amplitude bound and the room
bounds already in `FCachedRoom`, deep bedrock below a tunnel network becomes provably `AllSolid`.
This is the largest single perf item in the whole plan.
---
## 3. FlatPlain and CrystalChamber
**These are one op with two default sets** — `OPSTACK-PLAN §4` calls this the first real win, and
reading the code confirms it: `GetSlabDensity` is called for both types with no branch on which.
CrystalChamber is FlatPlain with a bigger `CeilingRoughness`.
```
FSlabVoidSource Replace floor surface + ceiling surface → void field
FGridColumnMod Add world-grid jittered cylinders, infinite height
[structural post ×4]
```
That is the entire archetype. Two of the eight collapse into one, and the ceiling's `abs(noise)`
(formations hang down only, never punch up) is a two-line flag on the source.
> **Observed in-editor 2026-07-27, and it settles the question:** Jahni reports FlatPlain and
> CrystalChamber render **identical** in the live world. They should — they share
> `FSlabGenerationParams`, and nothing in the content sets them apart. **The enum promised a
> difference the data never delivered**, in the shipped world as well as in the test fixture.
> So the merge does not lose a distinction; it *reveals* that there was none. Making CrystalChamber
> look like a crystal chamber is a **params** job — raise `CeilingRoughness` (6 → ~20, what
> `SlabEquivalence`'s tuned pass uses) and drop `CeilingRelativeHeight` a little. That is authoring,
> which is exactly the outcome the whole refactor is aiming at.
### 3.1 ✅ RESOLVED 2026-07-27 — Jahni: the Z term can go. Removed.
**Decision:** the Z term was not intentional character. It is gone from `GetSlabDensity` (both
surfaces), `FSlabVoidSource` is XY-pure, and FlatPlain + CrystalChamber are ported and wired.
**What that bought, and what it cost:**
- `IsXYPure() == true` ⇒ the T1.a column-cache treatment becomes available generically.
- An **exact `ClassifyBox` with no sampling**: `VoxelNoise::FBM`'s contract is `[-1,1]`, so both
surfaces live in Z bands with known bounds — a tile entirely below the floor band is provably
solid, a tile strictly between the bands is provably air. These two archetypes proved **zero**
tiles before. `VoxelForge.OpStack.SlabEquivalence` reports the count.
- **Cost: the world re-tunes once.** Dropping the term samples a different slice of the noise
field, so floor and ceiling shapes change (they do not degrade). Covered by §2.6's explicit
permission to re-tune.
The original finding, kept because it explains why the answer mattered:
`FSlabVoidSource` is **not XY-pure, and probably should be.** Both surfaces sample noise with a
small Z term:
```cpp
FloorNoise: FractalNoise3D(x·FF, y·FF, WorldZ·FF·0.05f) // ← Z
CeilNoise: FractalNoise3D(x·CF, y·CF, WorldZ·CF·0.08f) // ← Z
```
A "floor surface height" that depends on the altitude you sample it from is geometrically odd — the
floor is at a different height depending on which voxel asks. In practice the coefficient is tiny so
it reads as a subtle vertical smear rather than a bug, and it is deterministic, so nothing is broken.
But it blocks the T1.a column-cache treatment and it makes an exact `ClassifyBox` more awkward
(the surface must be sampled per Z rather than per column).
**Question for Jahni: was the `·0.05f` Z term intentional character, or a leftover from copying the
3D-noise call signature?** If it can go, `FSlabVoidSource` becomes XY-pure, gets the column cache
for free, and gets an exact box classification — which means FlatPlain and CrystalChamber start
skipping trivial tiles, which they never have. That is a large win for a one-character change, so
it is worth asking rather than assuming either way.
**Answered: it can go.** See the resolution above.
---
## 4. Maze — **the Phase 1 port**
The plan picks Maze, and the code justifies the pick completely. ~100 lines, and it decomposes
without any of the awkwardness elsewhere.
```
FConstantRockSource Replace BaseDensity
FLatticeCorridorSource → Sdf capsules over open lattice edges
FSurfaceRoughnessMod → Sdf SDF-space variant, frequency 0.12 (hardcoded today)
FSdfCarve(blend = 2.0) Subtract
[structural post ×4]
```
**Why it is the right first port, beyond size:** edge identity is `hash(lower node, axis)`, so two
adjacent chunks *cannot* disagree. No `BuildChunkCache`, no COLLECT/STORE region, no window-invariance
risk at all (`AUDIT §6.4` names this the pattern to prefer). If the source/modifier split does not
fall out here, it will not fall out anywhere, and that is exactly what the stop-trigger is for.
**`EffectOverBox`:** the open-edge set reachable from a box is the same `{-1,0}³` node sweep the
per-voxel code already does, at cell granularity. Capsule bound = `CorridorRadius + roughness
amplitude + blend`. `Identity` when no open edge's capsule reaches the box — which for a sparse
`BranchProbability` is most of the volume. **Maze currently skips zero tiles; this is its first.**
**The proof `OPSTACK-PLAN §4` asks for:** once `FLatticeCorridorSource` exists, drop it into a
`SurfaceWorld` strate under the terrain and confirm you get a maze inside a mountain with no C++.
Note this requires §0.1's SDF channel to look *good* (smooth junctions where corridors meet rock);
it works but reads harsh without it.
---
## 5. SurfaceWorld — biggest payoff, most care
Three XY-pure functions plus a cheap per-voxel combine. The column cache (T1.a) and the exact-lattice
`ClassifyTile` bound must both survive the port — they are the two most valuable pieces of
engineering in the file.
```
FHeightfieldSource Replace ← the whole column pipeline, XY-pure
├─ FStructuralHeightField continents + mountains + detail, under a warp frame
├─ FCliffHeightMod slope-gated steepening (4 structural resamples)
├─ FTerraceHeightMod relief-gated plateaus
├─ FLayerLineHeightMod sine bands
└─ FBeachHeightMod flatten toward the water line
FSkyCapSource Subtract ceiling: warp + signed swell + downward-only hang
FOverhangShelfMod Union ⚠️ per-voxel, NOT XY-pure — the one 3D op here
[structural post ×4]
```
> ### ⚠️ RESOLVED 2026-07-27 — the height ops needed a SECOND OP FAMILY, not a sub-list
>
> This section says the height ops *"operate on Z values in the column, not on density"* and then
> lists them as children of `FHeightfieldSource`. Writing them made the consequence unavoidable:
> **they do not fit `IVoxelDensityOp` at all.** Its signature is `Eval(x, y, z, FVoxelOpSample&)` —
> per voxel, density + SDF. A height op has **no input Z** (it produces one), is XY-pure (once per
> column), and writes neither channel.
>
> The two ways to force it were both bad: a per-voxel third channel for what is a **column**
> property, or collapsing all five into one opaque op — `OPSTACK-PLAN §2.5`'s explicit failure mode.
>
> **So height space got its own contract: `VoxelHeightOp.h`** (`FVoxelHeightSample` with
> `Height` + `Relief`, `IVoxelHeightOp`, `FVoxelHeightStack`). Same lesson as `§0.1`, one step
> further: §0.1 found that density needed a second *channel*; this found that terrain needs a second
> *space*. Verified by `VoxelForge.OpStack.SurfaceHeightEquivalence` before anything was built on
> top of it — deliberately, so a wrong answer would have cost one test rather than a whole port.
>
> **Bonus the type system gives for free:** a height stack cannot contain Z-dependent data, because
> there is no Z in the signature to put there. `AUDIT §6.3` warns that Z-dependent data smuggled into
> `FSurfaceColumn` silently corrupts every chunk in the vertical stack and that `ValidateDeterminism`
> would not catch it. Here the *type* forbids it rather than a convention.
**Critical distinction the port must preserve:** the height ops (`FCliffHeightMod` and friends)
operate on **Z values in the column**, not on density. They are XY-pure and belong in
`PrepareChunk`/the column cache. `FOverhangShelfMod` operates per voxel and re-samples the
structural heightfield at a shifted XY. Mixing those two up puts Z-dependent data in `FSurfaceColumn`,
which `AUDIT §6.3` warns silently corrupts every chunk in the vertical stack — **and
`ValidateDeterminism`, which samples along an X boundary, would not catch it.**
This is the archetype where `IsXYPure()` earns its place in the contract: it turns an implicit
convention that has to be remembered into a declaration the compiler routes on.
**Biome blending:** the heightfield is evaluated for the dominant biome and its nearest neighbour and
the two *heights* are lerped. In stack terms that is the `Mask` combiner with a biome-weight field —
which is exactly the mechanism `OPSTACK-PLAN §4 Phase 3` wants for unifying strates and biomes. So
SurfaceWorld's existing biome blend is the prototype for the whole Phase 3 idea, and porting it is
how that gets validated.
---
## 6. VerticalShafts
```
FConstantRockSource Replace
FShaftFieldSource → Sdf infinite cylinders (XY-only) + hash-gated connectors
FSurfaceRoughnessMod → Sdf SDF variant, frequency 0.1
FSdfCarve(blend = 2.0) Subtract
FShaftLedgeMod Union banded shelves, +X/+Y half only so a climb path remains
[structural post ×4]
```
Nearly identical in shape to Maze — same `source → roughness → carve` spine, different primitive.
That similarity is the evidence the abstraction is real: two archetypes that look unrelated in the
`switch` are the same three ops with a different source.
**Split worth making:** the shafts are XY-pure infinite cylinders; the connectors are not. Two ops
(`FShaftColumnSource` XY-pure + `FShaftConnectorSource`) let the cylinder half get the column-cache
treatment and answer `ClassifyBox` exactly in XY. Keeping them as one op forfeits that.
---
## 7. FloatingIslands
The only archetype whose source is **air**, which is what makes it a good composition test.
```
FConstantVoidSource Replace −BaseDensity (open void)
FRAME IslandWarp XY domain warp (lobed outlines, amplitude ~0.35·meanR)
└─ FIslandBlobSource → Sdf tapered flat-top blobs, SmoothMin'd together
FSurfaceRoughnessMod → Sdf SDF variant, frequency 0.08, 4 octaves
FSdfFill(SDFBlendRadius) Union
[structural post ×4]
```
Note the warp here is applied to the **query** (`WX`,`WY` computed once per voxel and shared by all
nearby islands) exactly like TunnelNetwork's cave warp — same frame concept, third instance. Three
uses is enough to make frames a first-class part of the model rather than a special case.
**`FIslandBlobSource` is the cleanest `Identity` opportunity in the plugin:** islands are hash-placed
with a known XY radius and explicit `TopZ`/`BotZ`. A box that no island's AABB reaches is provably
untouched — and since a floating-island strate is *mostly* empty void, that is most tiles. Combined
with `FConstantVoidSource`'s `ClassifyBox → AllAir`, a FloatingIslands strate could go from skipping
zero tiles to skipping the large majority of them.
#### ✅ PORTED 2026-07-28 — three deviations from the sketch above, all deliberate
1. **`FConstantVoidSource` and `FSdfFill` are not new classes.** Each is the class it mirrors, with
the opposite **sign**: `FConstantFieldSource(±Base)` and `FSdfConvertOp(Sign = ±1)`, two factories
each. The table above listed them as separate ops; writing them separately would have duplicated
the classifier and the six-line formula for nothing. Multiplying by ±1 is exact in IEEE-754, so
the three already-green ports are bit-for-bit untouched by the generalisation.
**This is the port's actual result:** reuse **by inversion** rather than by identity — evidence
that the abstract axis (the sign of the internal density) is the right one, not just that two
archetypes happened to look alike.
2. **The warp stays inside the source; no `FRAME` op was built.** Frames are worth building at the
second real user, and two of the three (`TunnelNetwork`'s cave warp, its tunnel warp) are not
ported yet. Designing the abstraction against a single example is what this refactor has avoided
throughout — cf. `IVoxelBiomeField`, which was born from a concrete second need. Revisit with
TunnelNetwork.
3. **`AUDIT §C1`'s last surviving site was in this archetype** and was fixed in both paths in the
same pass (the warp's `(float)S * 0.0007f`; the 2026-07-27 sweep matched `SeedF * K` and missed
the `(float)S` spelling).
**The `Identity` bound is one-sided, and that matters:** `Sdf ≥ WorldZ − TopSurf` bounds an island
from **above** only. Below `BotZ` the SDF degenerates to ≈ `DistXY`, so a hairline thread of matter
hangs down each island's axis to the strate floor. Rejecting a box because it sits below an island
would be a hole. Only the top rejects.
---
## 8. Underwater
`GetDensityAt` routes `Underwater` to `GetDensityWithParams` with a comment: *"Underwater shares
tunnel rock (water table is a render-side overlay)."* There is **no density difference at all**.
So `Underwater` is not an archetype, it is TunnelNetwork plus `WaterLevelRelative` consumed by the
water render system. When `ECaveGeneratorType` finally disappears, this one vanishes for free — it
never needed to exist as a generator type.
---
## 9. Q2 — the param audit: every field, and who claims it
`FStrateGenerationParams` via the `VF_STRATE_PARAM_FIELDS` X-macro. **Every field is claimed except
where flagged.**
| Field(s) | Destination op |
|---|---|
| `BaseDensity` | **stack-global** — read by every source and all four structural-post ops. Not any single op's param. |
| `VerticalScale` | `FRAME VerticalScale` |
| `WormFrequency`, `WormHorizontalBias`, `WormThreshold`, `WormStrength`, `WormNetworkRange` | `FWormFieldSource` |
| `RoomSpacing`, `RoomDensity`, `MinRoomRadius`, `MaxRoomRadius`, `RoomHeightRatio`, `RoomShapeVariety`, `RoomFloorCutMin`, `RoomFloorCutMax`, `FloorReliefStrength`, `FloorReliefFrequency` | `FRoomGraphSource` |
| `OriginRoomRadius`, `OriginRoomMaxConnections` | `FRoomGraphSource` — ⚠️ but see §10.3, they couple to the spine |
| `TunnelMinRadius`, `TunnelMaxRadius`, `TunnelDensity`, `MaxTunnelLength`, `TunnelWarpStrength`, `TunnelHorizontalBias`, `bTunnelsFlowTowardOrigin`, `TunnelEndpointZOffset` | `FRoomGraphSource` |
| `SDFBlendRadius` | `FSdfCarve` / `FSdfFill` — **shared**, also the `bNearCaveSurface` gate width |
| `CaveWarpStrength`, `CaveWarpFrequency` | `FRAME CaveWarp` |
| `SurfaceRoughness`, `RoughnessFrequency`, `RoughnessNoiseType`, `DomainWarpStrength`, `DomainWarpFrequency` | `FSurfaceRoughnessMod` (density variant) |
| `BoundarySealThickness` | `FBoundarySealOp` — and read by the spine, disturbances, shaft connectors, island spread |
| `StrateTopWorldZ`, `StrateBottomWorldZ` | **`FVoxelOpContext`, not op params.** Runtime-injected, never author-set. |
| `FloorBias` | `FFloorBiasMod` |
| `TerraceStepHeight`, `TerraceHardness`, `TerraceNoiseDisplacement` | `FTerraceMod` |
| `LayerLineSpacing`, `LayerLineDepth` | `FLayerLineMod` |
| `OverhangStrength`, `OverhangDepth`, `OverhangFrequency` | `FCaveOverhangMod` ⚠️ name collision, §9.1 |
| `RibbingSpacing`, `RibbingDepth` | `FRibbingMod` |
| `CliffStrength` | `FCaveCliffMod` ⚠️ name collision, §9.1 |
| `ScallopStrength`, `ScallopFrequency` | `FScallopMod` |
| `ArchDensity`, `ArchMinRadius`, `ArchMaxRadius` | `FArchMod` |
| `ColumnDensity`, `ColumnMinRadius`, `ColumnMaxRadius` | `FRoomColumnMod` ⚠️ name collision, §9.1 |
| `PitDensity`, `PitMinRadius`, `PitMaxRadius`, `PitDepth` | `FPitMod` |
| `ChimneyDensity`, `ChimneyMinRadius`, `ChimneyMaxRadius`, `ChimneyHeight` | `FChimneyMod` |
| `DomeDensity`, `DomeMinRadius`, `DomeMaxRadius`, `DomeHeightRatio` | `FDomeMod` |
| `PinchDensity`, `PinchStrength`, `PinchLength` | `FPinchMod` |
| **`WaterLevelRelative`** | ⚠️ **claimed by no density op.** See §9.2. |
### 9.1 Three names mean different things in different structs
`FStrateGenerationParams` and `FSurfaceGenerationParams` both define `CliffStrength`,
`OverhangStrength`/`OverhangFrequency`, `TerraceHardness` and `ColumnDensity`-family fields — and
they are **genuinely different operations**:
| Name | in `FStrateGenerationParams` (cave) | in `FSurfaceGenerationParams` (surface) |
|---|---|---|
| `CliffStrength` | noise-modulated vertical density gradient near a cave wall | slope-gated steepening of a **height value** |
| `OverhangStrength` | fBm lobe adding rock into a cave | F20 warped-terrain union making a real 3D shelf |
| `TerraceHardness` | staircase edge width on a cave wall | plateau/riser ratio of a **height** quantiser |
| `ColumnDensity` | room-anchored columns | *(slab struct)* world-grid cylinders |
Today the type system keeps them apart. Once ops are data assets in one list, **nothing does** —
`DA_Op_Cliff` would be ambiguous. Name them for what they operate on from day one:
`FCaveWallCliffMod` vs `FHeightSlopeCliffMod`, `FCaveShelfMod` vs `FTerrainOverhangMod`. Cheap now,
a rename with authored assets in the field later.
### 9.2 The one unclaimed field: `WaterLevelRelative`
It lives in `FStrateGenerationParams` (and is `Lerp`'d at every strate boundary along with the
density params), but **nothing in the density path reads it.** Its consumers are
`GetWaterLevelWorldZForChunk` (the water render system) and `ComputeSurfaceTerrainZ`'s beach
flattening — which reads the *surface* struct's copy, not this one.
Not dead, but misfiled: it is a *content/render* property riding in a *density* struct, and it is
being interpolated across strate boundaries where a water plane should probably be a hard property
of one strate. **Report, don't delete** — but when ops become assets it should move to the strate
itself rather than to any op.
### 9.3 Fields that are context, not params
`StrateTopWorldZ` / `StrateBottomWorldZ` are runtime-injected by
`UVoxelStrateManager::Get*ParamsForChunk`, never author-set, and every archetype's first line is a
degenerate check on them. They belong in `FVoxelOpContext` (where the header already puts them) and
should be *removed* from the per-op param structs so an author cannot see or set them. That deletes
a whole class of "I set the Z bounds and nothing happened".
---
## 10. Ordering rules the compiler must enforce
### 10.1 Structural post is appended, always, in order
`FOriginSpineOp` → `FBoundarySealOp` → `FPassageCarveOp` → `FDiffLayerOp`. Verified identical in all
six density functions. An author cannot omit, reorder or insert between them.
### 10.2 Disturbances run after the archetype, before the diff layer
`ApplyDisturbances` is called in `GetDensityAt` *after* the archetype function returns (so after that
function's own spine/seal/passages) and *before* the diff layer. So the true global order is:
```
[archetype stack] → spine → seal → passages → disturbances → diff layer
```
Disturbances self-limit to the seal interior (`if (Z <= InnerBot || Z >= InnerTop) return;`), which
is why running them after the seal is safe. **Preserve this or bridges will punch through seals.**
### 10.3 The spine and the origin room are two systems aimed at the same place
`ApplyOriginSpine` carves an unconditional column at XY (0,0) in every strate; `OriginRoomRadius`
makes the room graph put a big room there too; `bOpenSurfaceEntry` opens a shaft from above. Three
mechanisms, one location, and `AUDIT C8` already flags an unchecked invariant between
`OriginRoomRadius` and the COLLECT margin. When these become ops, the coupling becomes visible and
should be either unified or documented — right now it works by everyone independently agreeing that
(0,0) is special.
### 10.4 Recommended port order (unchanged from the plan, now with reasons from the code)
1. **Maze** — cleanest split, no connectivity decision, cheapest mistake. §4.
2. **FlatPlain + CrystalChamber** — two archetypes → one op. Ask §3.1 first.
3. **FloatingIslands** — the biggest `Identity` win, and the first air-source stack.
4. **VerticalShafts** — same spine as Maze, validates the source-swap claim.
5. **SurfaceWorld** — biggest payoff; the column cache and exact-lattice bound must survive.
6. **TunnelNetwork** — last. §8.4, the warp/pit coordinate split, and the per-room op override.
7. **Underwater** — falls out of 6 for free.
---
## 11. Open questions for Jahni
Ranked by how much they change the work.
1. **Two-channel `Eval` (density + SDF)?** §0.1. Changes the contract. Cheapest to decide now.
My recommendation: yes — without it, cross-source `SmoothMin` is impossible and "a maze inside a
mountain" reads as a hole punched in rock rather than a cave that belongs there.
2. **Is `FSlabVoidSource`'s Z-term intentional?** §3.1. One character; unlocks XY-purity, the column
cache and exact tile classification for two archetypes.
3. **Per-room terrain ops: scope-by-room (faithful) or strate-wide + `Mask` (simpler, flatter)?**
§2. Recommend scope-by-room, but *after* the geometry port, not during it.
4. **Amplitude bound on the worm carve in Phase 2 rather than Phase 3?** §0.2. It is the difference
between TunnelNetwork skipping tiles and never skipping tiles.
5. **Rename the colliding cave/surface op names before any asset is authored?** §9.1.
---
*Written 2026-07-27 by Opus 5, unattended, as build-free queue item Q1. Companion to
`OPSTACK-PLAN.md` (the plan) and `OPSTACK-PROGRESS.md` (what is actually built).*
+103
View File
@@ -0,0 +1,103 @@
# VoxelForge handoff — the operator stack is DONE. Read this, not the old jargon.
> Paste this into a fresh session. Written 2026-08-16, deliberately in plain language: the previous
> version of this file had become a private dialect that Jahni could not read, which is a failure of
> the document, not of the reader.
---
## 1. What the operator stack was, in one paragraph
Cave generation used to be one big `switch`: each cave type (tunnels, maze, shafts, floating
islands, surface…) was its own hardcoded ~200–1000 line C++ function. The refactor replaced that with
small composable pieces ("operators") that stack up to produce the same terrain. **The promise was
that you could eventually invent new world types by combining pieces in the editor instead of asking
for another thousand-line function.**
**That promise — "Phase 3", ops as data assets — was never built, and is NOT being built now.**
## 2. Status: done. Stop refactoring.
- **8 of 8 archetypes ported**, running in production (`bUseOperatorStack` is ticked on the game's
strate assets), and **bit-for-bit identical** to the old path. Verified by a 115 000-sample field
digest plus eight per-archetype equivalence tests.
- **14/14 automation tests green, 0 violations anywhere.**
- **39 % of tiles are skipped in the running game** (`Tiles Meshed` 1.15 vs `Tiles Classified` 1.88).
It was 0 % on the morning of 2026-08-16. Each skipped tile avoids ~43 000 density evaluations plus
marching cubes.
**Decision taken 2026-08-16, with Jahni:** the op stack is finished. **Do not start Phase 3. Do not
start another refactor.** The old `switch` stays in place as the correctness oracle — deleting it
buys nothing today. Next work should be things Jahni can *see*: see `fable-idea.md` (F7 set-pieces,
F9 audio were queued before this started).
### The honest ledger, so nobody re-litigates it
Three weeks, 120 commits, from 2026-07-27. Every `feat:` commit in that window is a *port* of
something that already worked. **The world did not change by a single voxel — that was the
acceptance criterion.** What Jahni actually got: the 39 % perf win, two genuine pre-existing bugs
found (a use-after-free on every strate-asset edit while streaming, and an under-bounded room
collection that could make two multiplayer peers generate different geometry), and a number of fixes
to bugs the refactor itself introduced. That is a thin return for three weeks, and it is why the
direction changed.
## 3. The jargon, translated
Almost all of it means one thing: **can we prove a chunk of world is entirely rock or entirely air
without checking every point in it, so we can skip the expensive work?**
| term | plain meaning |
|---|---|
| **T1.d / tile skipping** | that idea. The single biggest perf item in the plan. |
| **box verdict / `ClassifyBox`** | "is this whole box uniform?" → `AllSolid`, `AllAir`, or `Mixed` (don't know) |
| **`Mixed`** | "can't prove it" — always safe, just means we do the work |
| **`ClassifyTile`** | the function that decides, per tile, whether to skip meshing |
| **operator / op stack** | one generation step (rock, carve, roughness…) and the list of them |
| **equivalence test** | proof the new path produces byte-identical terrain to the old one |
| **`violations`** | ⚠️ **the only number that means danger.** A tile wrongly proved uniform has *no geometry and no collision* — a player falls through the floor. Must always be 0. |
## 4. What is verified, and what is not
**Verified:** everything through commit `871ca19` — tests green, digests unchanged, 39 % measured
in game.
⚠️ **Built but NOT re-verified:** `4d33321` (Sol's boundary fold — lets a tile that straddles cave and
open air still resolve) and `91585ea` (a test-only warning demotion). Jahni built these and says the
game *looks* fine, but **the test suite has not been re-run and the counters have not been re-read
since.** Before trusting them:
1. run the `VoxelForge` automation filter — **`violations` must be 0 and all eight equivalences
bit-identical**;
2. `stat VoxelForge` in game — the accounting must close:
`Tiles Meshed + Skipped All Air + Skipped All Solid = Tiles Classified`.
If either fails, `git revert 4d33321` — the 39 % win does not depend on it.
## 5. Rules that still prevent real bugs
- **Never build.** Jahni builds; he has the editor open and it costs him real time. Say "ready to
build" and list likely compile-error spots.
- **`violations` 0 and the eight equivalences bit-identical** — the only non-negotiable results.
- **A bound used to skip work must be PROVED, not observed.** Use `VF_PerlinAbsBound` (= 1.5);
`FMath::Lerp(A,B,t)` spans `[min(A,B), max(A,B)]`, so a radius envelope is `max(Min,Max)`.
- **Never change `ClassifyTile`'s conditions or return values casually.** Every `return` there fails
safe to `Mixed`.
- **Never state a `.uasset` value from memory** (like `bUseOperatorStack`). Ask, or read it in the
editor. This sent a full day sideways.
- `FindSlotIndexForChunkZ` is **protected**; `GetStrateChunkZBounds` is the public equivalent.
- Push `experimental` freely. **Never push `main`.**
## 6. Two lessons that generalise beyond this plugin
- **A signal that always says the same thing measures nothing.** A counter that can fire for two
reasons is not a measurement — splitting one such counter is what finally cracked T1.d after a day
of wrong inference. A warning that fires every run and always means "this is fine" is noise that
trains the reader to ignore warnings; one of those quietly worried Jahni for several sessions.
- **Verify the premise, and verify it completely.** Multiple confident chains reversed on checking
this month. Twice the failure was a *partial* read — grepping a symbol and reporting it as checked
for something else. A grep that finds a declaration has not checked its access specifier.
## 7. The open question, which matters more than any of the above
**What do you want the world to *do* that it doesn't?** Three weeks went into a pipeline instead of
that question. Start there.
+548
View File
@@ -0,0 +1,548 @@
# VoxelForge — Density Operator Stack: the plan
> **What this is:** the agreed direction for turning VoxelForge from an *archetype dispatcher* into a
> *composable density pipeline*, so new world ideas become authoring instead of C++. Written
> 2026-07-26 as a handoff for a future context — read this instead of re-deriving it.
>
> **Status (2026-07-28):** **Phases 0.5, 1 and 2 CODE-COMPLETE — 8 of 8 archetypes ported**, each
> bit-identical to its original in an equivalence test, all wired behind `bUseOperatorStack`:
> **Maze · FlatPlain · CrystalChamber · SurfaceWorld (biomes included) · VerticalShafts ·
> FloatingIslands · TunnelNetwork · Underwater.** The archetype `switch` now has a complete
> operator-stack twin, opt-in per strate.
>
> ✅ **BUILT AND GREEN, 2026-07-28 — 14 tests.** TunnelNetwork A+B bit-identical over 6000 samples
> with all twelve group-coverage probes non-zero, all four noise branches covered, 0 gate leaks, and
> C1 proved by 10 Terrace-op rooms containing 1119 samples. One open warning: the `Underwater` check
> landed 0 samples in open cave, so its bit-identity proves little — diagnosed, not guessed, in
> `OPSTACK-PROGRESS.md`'s last entry.
>
> **Not done, and it is the next real prize:** `ClassifyTile` still uses hand-written guards and does
> not consume `ClassifyBox`. That is where measured tile-skipping becomes frames.
>
> Two things came out of Phase 2 that were not in the original design: **height space**
> (`VoxelHeightOp.h`, a second operator family — some things are not another channel but another
> *space*) and **`IVoxelBiomeField`** (ops depend on a capability, never on the generator, which is
> what lets them become assets in Phase 3). Both are described in `OPSTACK-DECOMPOSITION §5`.
>
> **Known open:** generation is measurably slower on the op path (one fix landed — the column memo
> was discarding itself every chunk; virtual dispatch and the hashed lookup remain). Deferred by
> Jahni until the transition is complete. Live state and the next action live in
> [OPSTACK-PROGRESS.md](OPSTACK-PROGRESS.md) — read its last entry first. The per-archetype
> breakdown is in [OPSTACK-DECOMPOSITION.md](OPSTACK-DECOMPOSITION.md).
>
> **Read first:** `CODEMAP.md` (navigation) · `ARCHITECTURE.md §8.10` (the perf invariants this must not
> break) · `AUDIT-2026-07.md §6` (the 3D hazards this is designed to kill permanently).
>
> **Jahni's goal, verbatim (2026-07-26):** *"a world generator, of any kind, of any possibility, almost
> — any combination of ideas you could guess, could be happening."*
---
## 0. The one-paragraph version
`UVoxelGenerator::GetDensityAt` is a `switch` over 8 hardcoded `ECaveGeneratorType` values, each owning
a bespoke density function and param struct. That means (a) a new world idea costs ~6 edit sites, and
(b) **ideas cannot combine** — one archetype owns the whole voxel. The fix is to make density a **stack
of small operators**, each of which can `PrepareChunk` / `Eval` / declare **what it can do to a box**.
That last part is the keystone: it makes `ClassifyTile` generic and correct *forever*, instead of a
hand-written guard per feature (the thing that already caused one revert and is the top hazard for the
current 3D work). Staged so the game never stops working, and so the first useful step is small.
**Non-goal, stated deliberately:** this must *serve* the descent-through-strates structure, not dissolve
it. "The world is a vertical stack you dig down through, each layer its own place" is the idea of this
project — the seals, the (0,0) spine and the passages only mean something because of it. The op stack
should make each strate more surprising, not turn the world into undifferentiated composable soup.
---
## 1. Why this specific shape fits this specific codebase
Not a generic "use composition" argument — three concrete reasons:
**(a) It formalises what the code already does by hand.** Every archetype already: hoists chunk-constant
work into a `thread_local` cache (`PrepareChunk`), evaluates cheaply per voxel (`Eval`), and — in
`ClassifyTile` — has a hand-written statement of what it can do to a tile (`Bounds`). The operator model
isn't a new discipline; it's the existing discipline, named.
**(b) The 8 archetypes are ~6 functions wearing costumes.** `FlatPlain` and `CrystalChamber` are literally
the same function with different defaults; `Underwater` is `TunnelNetwork` + a water flag. Decomposed,
they're roughly **15 orthogonal primitives** (floor surface, ceiling surface, hash cylinders, room SDF
graph, tunnel capsules, worm noise, lattice corridors, heightfield stack, island blobs, boundary seal,
spine carve, passage carve, disturbances, surface ops, diff layer). 15 primitives that combine covers
vastly more than 8 that don't.
**(c) It kills the recurring hazard permanently.** T1.d guards are currently written per feature
(`AnyPassageNearBox`, the spine circle test, chasm/bridge flags, `HasAnyModInChunkRange`, phase-2's
`OverhangMargin`). Every new 3D feature needs one, forgetting one is a hole, and one was already
forgotten badly enough to revert T1.d v1 on 2026-06-26. Under this model a new op **cannot ship without
answering the question**, and a test catches it if the answer is wrong.
---
## 2. The keystone insight — start with DIRECTION, not intervals
The full version of `Bounds` returns a numeric interval. **Don't start there.** Almost every existing
operator is *one-directional*: it only ever carves, or only ever fills.
```cpp
enum class EVoxelOpEffect : uint8
{
CarveOnly, // can only move density toward AIR → kills the AllSolid hypothesis
FillOnly, // can only move density toward SOLID → kills the AllAir hypothesis
Both, // unconstrained
Identity // provably no effect on this box (the early-out that makes it fast)
};
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const;
```
**This alone reproduces every hand-written guard in `ClassifyTile` today, generically.** Look at the
current code: "passages ⇒ `bCanSolid = false`" *is* `CarveOnly`. "bridges/ridges ⇒ `bCanAir = false`"
*is* `FillOnly`. "no passage near this box ⇒ skip" *is* `Identity`.
So **Phase 1 ships with no numeric bounds at all** and already gets the whole safety property. Numeric
intervals are a later tightening for gen-cost, not a correctness prerequisite. This is what turns a scary
refactor into a small first step — and it's the thing to remember if this plan ever feels too big.
### Every existing primitive already has an obvious answer
Filled in here so a future context doesn't have to re-derive it:
| Primitive | Effect | Identity test (cheap) | Numeric bound (later) |
|---|---|---|---|
| `ApplyBoundarySeal` | **FillOnly** (`FMath::Max`) | box misses both seal bands | `+[0, BaseDensity]` |
| `ApplyOriginSpine` | **CarveOnly** | circle-vs-box XY, + Z outside interior | `−[0, Base*2 + Seal]` |
| `ApplyPassageCarving` | **CarveOnly** | `AnyPassageNearBox` — **already written** | `−[0, …]` via `AirTarget` |
| Disturbance chasms | **CarveOnly** | `ChasmDensity == 0`, or lattice miss | `+[0, Solid]` toward air |
| Disturbance bridges/ridges | **FillOnly** | density == 0, or lattice miss | `−[0, Solid]` |
| F20 overhang | **FillOnly**, banded | outside `(TerrainZ, TerrainZ+Height]` — **already written** | union ⇒ `max` only |
| Room / tunnel / column SDF | Both | bounding sphere vs box — **already written** | **Lipschitz-1**: `SDF ∈ [SDF(c) − r, SDF(c) + r]`, `r` = box half-diagonal |
| fBm / Ridged term `k·N(...)` | Both | `k == 0` | `±\|k\|` — `FBM` returns `[-1,1]` by construction (`Total/MaxValue`) |
| Heightfield (`TerrainZ − Z`) | Both | — | `[minT − Zmax, maxT − Zmin]`; **or sample the lattice exactly** (see below) |
| Diff layer | Both | `HasAnyModInChunkRange` — **already written** | `±max\|Strength\|` over mods in range |
Two things to notice:
- **Five of these already exist as code.** The work is mostly *moving* guards, not inventing them.
- **`Bounds` is allowed to be exact-by-sampling, not just analytic.** Today's `ClassifyTile` gets the
tightest possible SurfaceWorld verdict by evaluating `ComputeSurfaceColumn` **on the mesher's exact
lattice** — same functions, same floats, so the verdict is exact rather than estimated. That must
survive. The contract is "conservative", not "closed-form": an op may sample to answer.
### Prior art (30 minutes, worth it before Phase 1)
- [Keeter, *Massively Parallel Rendering of Complex Closed-Form Implicit Surfaces*](https://dl.acm.org/doi/10.1145/3386569.3392429) + [fidget](https://github.com/mkeeter/fidget) — interval arithmetic per expression node to prune empty regions. The industrial version of this idea.
- [Barbier et al., *Lipschitz Pruning: Hierarchical Simplification of Primitive-Based SDFs* (CGF 2025)](https://onlinelibrary.wiley.com/doi/10.1111/cgf.70057) — **the better fit for us**: bounds each primitive's range of influence and treats primitives as **black boxes**, where full interval arithmetic needs interval semantics defined for every node. Our ops are SDFs (Lipschitz by construction) and bounded-amplitude fBm. Black-box bounds; don't build a node-level IA engine.
---
## 2.5 ⚠️ The op TAXONOMY — and why this is NOT the old room-ops system
**Jahni's objection, 2026-07-27, and it is the correct one:** *"ops structure — which, let's all be honest,
was what I had before, 'room operations' which would modify stuff, so I sure hope your idea is not that.
There's a world of difference between a grotto strate and an open world strata."*
He is right, and a fresh context **must** internalise this or it will build something useless.
`UVoxelTerrainOpDefinition` today can only **perturb density near a surface that already exists**, inside
a fixed archetype (`Terrace`, `LayerLines`, `Ribbing`, `Cliff`, `Scallop`, `Overhang`, `Arch`, `Column`,
`Pit`, `Chimney`, `Dome`, `Pinch` — all applied near cave walls where `bNearCaveSurface`). It cannot turn a
grotto into an open world, because it never decides **what the field IS**. That decision lives in the
`switch` in `GetDensityAt`, which is exactly the thing we are removing.
So the op stack has **four ROLES**, and the old system only had role 3:
### Role 1 — FIELD SOURCES (the new thing; this is the "world of difference")
Produce a density field *from nothing*. **This is what makes a grotto a grotto and an open world an open
world.** Each of today's archetypes is fundamentally one of these:
| Source | Today's archetype |
|---|---|
| Heightfield ground + sky-cap ceiling | `SurfaceWorld` |
| Room-graph SDF (hash rooms + tunnel capsules) | `TunnelNetwork` |
| Floor/ceiling slab void | `FlatPlain`, `CrystalChamber` |
| 3D lattice corridors | `Maze` |
| Full-height shafts + connectors | `VerticalShafts` |
| Suspended island blobs in open void | `FloatingIslands` |
A source is a first-class op with the same three-method contract. **A "strate archetype" therefore stops
being an enum and becomes `source + combiners + modifiers`.** Two strates differ in their SOURCE first,
their modifiers second.
### Role 2 — COMBINERS (how sources merge; this is what makes ideas compose)
`Replace` · `Union`(min) · `Subtract`(max) · `SmoothUnion`/`SmoothSubtract` (reuse `VoxelSDF::SmoothMin/Max`)
· `Mask` (scale the next op by a field: biome weight, slope gate, relief, depth).
This is the role that buys the ambition. *Floating islands **inside** a grotto. A maze **beneath** an open
world's ground. A room-graph carved **into** a mountain.* None of those are expressible today at any price.
### Role 3 — DETAIL MODIFIERS (the old system, demoted to one role of four)
Roughness, terrace, layer lines, ribbing, scallop, cliff, overhang, domes, pinch. **All of today's
`UVoxelTerrainOpDefinition` types land here, essentially unchanged.** They keep working; they stop being
the whole story.
### Role 4 — STRUCTURAL POST (fixed order, non-negotiable, runs last)
`ApplyOriginSpine` → `ApplyBoundarySeal` → `ApplyPassageCarving` → diff layer.
These are **world invariants**, not creative choices: descent must stay possible, seals must hold, passages
must punch through anything, player edits win. They are ops for uniformity but the stack compiler must
always append them, in this order, regardless of authoring. **An author must not be able to omit them.**
### Plus: SCOPING
Any op may be gated by a region predicate — Z band, XY region, biome index, slope range, depth. Scoping is
what lets one strate hold several sources without them fighting, and it's the mechanism that unifies
"strate" and "biome" into one concept in Phase 3.
> **The test for whether this refactor was worth doing:** can you author *"an open-world surface strate
> whose mountains contain a room-graph cave system, with floating islands in the upper void"* **without
> writing C++?** If no, it collapsed back into the old system and something went wrong.
---
## 2.6 The acceptance bar — "very close", NOT byte-identical
**Jahni, 2026-07-27:** *"I want not a 1/1 replica of the current strates with the current system, but a
possible very close result to what I have right now, else it won't really matter much."*
Two different properties get confused here. Keep them apart:
| Property | Required? | Meaning |
|---|---|---|
| **`ValidateDeterminism` = 0** | **YES, ALWAYS** | the same world point sampled twice, from different cache windows/threads, returns the identical float. This is window invariance (§8.4) — self-consistency. Non-negotiable, it's what prevents seams and MP divergence. |
| **Byte-identical to the OLD system's output** | **NO** | matching the pre-refactor world float-for-float. |
**This is a deliberate relaxation of what an earlier draft of this plan demanded, and it matters
strategically:** if bit-identity were required, the cheap path would be to wrap each old density function
as one monolithic op — 8 opaque ops that don't compose, i.e. **the switch with extra steps and zero
gain.** Releasing that constraint is what permits *real* decomposition into the primitives in §2.5.
> **✅ CONFIRMED THE HARD WAY, 2026-07-27.** The Maze port reproduces `GetMazeDensity`'s **SDF bit for
> bit**, and its final density to within 1-2 ULP on ~2% of samples, with **zero isosurface
> crossings** — geometrically identical, not one triangle moved. The exact origin of that last
> rounding was chased through five measured-and-refuted hypotheses and then **parked by decision**;
> the full evidence is in `AUDIT-2026-07.md §C10`. **Read C10 before ever reopening it.**
>
> **The operational bar for every remaining archetype port, encoded in
> `VoxelForge.OpStack.MazeEquivalence`:** hard-fail on any isosurface crossing (that moves geometry);
> tolerate ULP-scale deltas (the accepted floor); warn on anything larger (that is real port drift).
>
> **And the rule that came out of it:** never run the archetype `switch` and the operator stack in the
> same world, and never compare their outputs for equality — a half-migrated strate would seam. Not a
> client-desync risk (the field is proven bit-pure within a binary); the cross-platform concern is
> `§C9`.
**The bar instead:** for each ported archetype, an authored op stack must reproduce the *character* of the
old one — same scale, same navigability, same feel, recognisably the same kind of place. Judged by Jahni
on a screenshot at a fixed seed, not by a diff. Expect and accept a one-time re-tune, exactly as the T2.a
SIMD noise switch required.
---
### 2.6.1 ⚠️ RELAXED FURTHER, 2026-07-27 — resemblance to the old world is NOT a requirement at all
**Jahni, verbatim:** *"I do not need your work to be identical or near identical to what I had
before, only having it 99.99% at worst reproducible if two people share the same seed, since
everyone rebuilds it on multiplayer."*
**This replaces the "recognisably the same place" bar above.** The requirement is not fidelity to the
past — it is **agreement between peers in the present**. Restated as the only two properties that
now matter:
| Property | Required? | Enforced by |
|---|---|---|
| **Same seed ⇒ same world, on every peer** | **YES — this is the whole bar** | `DensityPurity` within a binary; **`§C9`** across binaries/platforms |
| Resemblance to the pre-refactor world | **NO** | nothing; freely re-tunable |
| Bit-identity with the archetype `switch` | **NO** | nothing; never compare them |
**What this changes, concretely:**
1. **`§C10` is closed, not parked.** It measures old-path vs new-path agreement, and the two paths
will never both exist in a shipped world. The residue cannot affect anything Jahni requires.
2. **The equivalence tests keep their value, but for a different reason.** They are no longer
*fidelity* checks; they are **port-correctness** checks — a transcription slip is still a real
bug, and comparing against the old function is the cheapest way to catch one. Read them that way.
The hard-fail (isosurface crossing) stays; the ULP grading is now diagnostic only.
3. **`§C9` is promoted from a footnote to THE risk.** "Two people share a seed" is exactly the
guarantee `/fp:fast` weakens across toolchains, and a Linux dedicated server generating collision
geometry against Windows clients is the concrete case.
4. **Changes that re-roll the world's noise are no longer expensive.** `§C1` in particular was
deferred *only* because it forces a re-tune. That objection is gone.
---
## 3. The contract
```cpp
// Chunk-constant inputs. Mirrors what the thread_local CP_* block resolves today.
struct FVoxelOpContext
{
FIntVector ChunkCoord;
int32 Step; // LOD step — ops may cheapen themselves (see §7)
uint32 Seed;
uint32 LayoutVersion; // ⚠️ see AUDIT C2 — every cache key MUST include this
float StrateTopWorldZ, StrateBottomWorldZ;
const FBiomeContext* Biome; // null = strate has no biome field
};
class IVoxelDensityOp
{
public:
// Hoist all chunk-constant work here (room lists, biome grids, column caches, lattice bakes).
// Called once per chunk per worker. This is where today's thread_local caches move to.
virtual void PrepareChunk(const FVoxelOpContext& Ctx) = 0;
// Per-voxel. InDensity = what the stack produced so far, MC convention (negative = solid).
virtual float Eval(float X, float Y, float Z, float InDensity) const = 0;
// CONSERVATIVE. Phase 1: direction only. Phase 3: add a numeric interval overload.
// Returning Both is always SAFE (costs CPU); returning the wrong one is a HOLE.
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const = 0;
// Declares whether Eval depends on Z. XY-pure ops get the T1.a column-cache treatment
// generically instead of SurfaceWorld having a bespoke one.
virtual bool IsXYPure() const { return false; }
};
```
**Composition semantics** — keep the vocabulary small, and reuse what exists (`VoxelSDF::SmoothMin` /
`SmoothMax`):
| Mode | Meaning |
|---|---|
| `Replace` | ignore `InDensity` (stack roots: heightfield, base density) |
| `Union` (min) | add solid — bridges, islands, columns |
| `Subtract` (max) | carve air — rooms, tunnels, passages, spine |
| `SmoothUnion/Subtract` | the same with `SmoothMin/Max(k)` — organic junctions |
| `Add` | scalar accumulate — noise/roughness terms |
| `Mask` | scale the *next* op by a field (biome weight, slope gate, relief) |
`Mask` is what buys most of the expressiveness: "this op, but only in high-relief regions / only on
steep slopes / only in this biome" becomes composition rather than a bespoke gate inside each op.
---
## 4. Order of work
### Phase 0 — THIS WEEK. Ship the 3D caves. Refactor nothing.
The current feature (caves inside mountains, volumetric generation) ships as-is with a **hand-written
`ClassifyTile` guard** — see `AUDIT-2026-07.md §6.1` for why it's mandatory (without it the caves are
never meshed: no geometry, no collision, invisible until you fall through them).
**One constraint only:** write the guard as a standalone function in the shape of the future contract —
```cpp
// Not a member of anything yet. Just the right shape.
EVoxelOpEffect CaveSystemEffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx);
```
Same work, zero architectural commitment, and it establishes the pattern.
**Also decide here (see `AUDIT §6.2`): placed, not fielded.** Hash-located cave systems with bounds
(like `FCachedRoom`/`FCachedTunnel`) let the guard prove most of the mountain still solid and keep
T1.d's 44% worker-CPU win. A global 3D noise field makes every deep tile `Mixed` and hands that back.
> ✅ **Gate:** caves render and collide when approached through solid rock, and worker CPU hasn't
> visibly regressed.
---
### Phase 0.5 — The safety net. ~1 day. Do this before Phase 1, not after.
> **✅ WRITTEN 2026-07-27 — ⏳ NOT YET COMPILED OR RUN.** Four tests in `Private/Tests/` (the three
> below, plus a live-edit regression test for `AUDIT C2`). The gate below is NOT met until Jahni
> builds and runs them. See `OPSTACK-PROGRESS.md`.
Three automation tests (`Source/VoxelForge/Private/Tests/`, `IMPLEMENT_SIMPLE_AUTOMATION_TEST`).
There are currently **zero tests**, and nothing machine-checks the dozens of "bit-identical" claims in
the docs.
1. **Density purity** — sample 10k points, shuffle query order, re-sample, assert bit-equality. Catches
every cache-key bug including `AUDIT C2`. Run it across **multiple worker threads**, because
`ValidateDeterminism` runs on the game thread and would miss worker-cache divergence.
2. **`ClassifyTile` soundness** — for random tiles, if the verdict is `AllSolid`/`AllAir`, brute-force
the lattice and assert every sample agrees. **This is the highest-consequence function in the plugin
and it is currently validated only by reasoning.**
3. **`DiffLayer` under contention** — N readers + a writer; assert no crash, monotonic version.
> ✅ **Gate:** all three green on the current code. If #1 or #2 fails, you've found a live bug — fix it
> before building on top.
---
### Phase 1 — The pivot. Port ONE archetype. Timebox it.
**Pick `Maze`.** ~100 lines, no cross-chunk connectivity decision, trivial bound (corridor SDF is
Lipschitz-1 off a lattice), and it's the least-used archetype so a mistake is cheap.
1. ✅ **DONE 2026-07-27 (uncompiled):** `IVoxelDensityOp` + `EVoxelOpEffect` + `FVoxelOpContext` +
the four role tags + the box-verdict fold, in `Public/VoxelDensityOp.h`. **One addition beyond
this spec:** `ClassifyBox` is not source-only — forcing ops (the boundary seal inside its band)
overwrite the input, which pure direction cannot express. Rationale in the header.
**One open question the decomposition raised:** `Eval` probably needs an SDF channel as well as
a density channel — see `OPSTACK-DECOMPOSITION.md §0.1`. Decide before porting Maze.
2. **DECOMPOSE, don't wrap** (§2.5, §2.6). Maze becomes a stack, not one op:
`FLatticeCorridorSource` (role 1 — the capsule field off the 3D lattice) → `Subtract` →
`FSurfaceRoughnessMod` (role 3 — the existing `SurfaceRoughness` perturbation) → then the four
structural-post ops appended automatically. **If it comes out as a single `FMazeOp`, the refactor
has failed its own test** — that's the switch with extra steps.
3. `GetDensityAt` gains **one** branch: strate has an op stack ⇒ run it; else fall through to today's
switch. **Both systems coexist**, indefinitely if needed.
4. `ClassifyTile` gains a generic path (`EffectOverBox` folded over the stack) used only by ported strates.
> ✅ **Gate:** `ValidateDeterminism` = 0 delta (§2.6 — always), Phase 0.5 tests green, and the Maze world
> is **recognisably the same place** at a fixed seed — same corridor scale, same connectivity, same feel.
> Judged on a screenshot, not a diff. A one-time re-tune of the params is expected and fine.
>
> ✅ **The real proof, and the one that answers Jahni's objection:** once `FLatticeCorridorSource` exists,
> **drop it into a `SurfaceWorld` strate underneath the terrain** and confirm you get a maze inside a
> mountain with no C++ written. If that works, the architecture is doing the thing it was built for.
>
> 🛑 **Stop-and-reconsider trigger:** if Phase 1 exceeds ~2 days, or the source/modifier split doesn't fall
> out naturally from the existing code, the abstraction is wrong for this domain. **Say so plainly, revert,
> and report** — do NOT push through and port a second archetype to prove a point.
---
### Phase 2 — Port opportunistically. No big bang.
Port each archetype **the next time a feature makes you open it anyway**. The switch shrinks on its own.
Suggested order when there's a free choice — cheapest and least risky first:
✅ `Maze` (P1) → ✅ `FlatPlain`/`CrystalChamber` (one op, two default sets — the first real win: two
archetypes collapse into one; **done**, `BuildSlabStack`, 8 archetypes → 7) → ✅ `SurfaceWorld`
(**done**, incl. biomes — needed a whole second op family, `VoxelHeightOp.h`; biggest payoff, biggest
care: the T1.a column cache and the exact-lattice `ClassifyTile` bound both survived) →
✅ `VerticalShafts` (**done**, 3 ops reused from Maze unchanged) →
✅ `FloatingIslands` (**done**, `BuildFloatingIslandStack` — the stack that runs **backwards**: void
source + fill instead of rock source + carve, the *same* classes with the opposite sign; only the
blob source is new) → ✅ `TunnelNetwork` + `Underwater` (**done**, one builder for both —
`BuildTunnelNetworkStack`, 19 ops).
**8 of 8 ported.** The last two were really one: `Underwater` *is* TunnelNetwork plus
`WaterLevelRelative` (§8, re-verified before relying on it), so the switch lost its last two cases in
a single port.
TunnelNetwork was ~1080 lines and was taken in **three stages, each verifiable on its own** rather
than as ~600 unverified lines on top of ~200 (the `AUDIT §P3` pattern):
* **A** — SDF spine: vertical scale, base rock, cave warp, room graph (+ pits + chimneys), carve,
worms, structural post. Verifiable *while incomplete* because every detail modifier is
amplitude-gated and defaults to zero, so zeroing them sends the ORIGINAL down exactly stage A's path.
* **B** — the twelve detail modifiers of `STEP 4b–4h`, one group per commit, each with a coverage
probe that proves the group actually moved something (`B1` roughness, `B2` terrace/lines/ribs,
`B3` overhang/cliff/scallop/arch, `B4` columns/domes/pinch/floor-bias, `B5` the gate itself).
* **C** — the per-room op override (`§2`'s option (a), and it needed no scoping predicate: one op
owns the state, eleven read it), `Underwater`, and the flag flip.
⚠️ **`FRoomGraphSource` CALLS `BuildChunkCache`/`EvaluateSDFCached`; it does not transcribe them.**
That is where §8.4's two-region window-invariance discipline lives, and a copy would fork it — with
the fork "validated" by a test that compares it to the original.
Along the way, `FStrateGenerationParams`' 74 fields decompose into per-op structs, which retires the
`VF_STRATE_PARAM_FIELDS` X-macro drift problem for free.
---
### Phase 3 — The payoff. Ops become data.
1. `UVoxelDensityOpDefinition : UPrimaryDataAsset` — one asset per op, mirroring today's
`UVoxelTerrainOpDefinition` (which is *already* the right authoring shape: an asset + weight +
probability in an ordered list).
2. **A strate becomes "a Z range + an ordered op list"** instead of "an enum + a param bag".
3. **A biome becomes "an XY region predicate + an op list" — the same mechanism.** Today these are two
unrelated systems (`ECaveGeneratorType` dispatch vs the biome field with its `bOverrideTerrain`
special case that only works for `SurfaceWorld`). Unifying them is where the expressiveness comes
from: any op, scoped by any region, in any combination.
4. Numeric interval bounds where profiling shows `Both` is costing real gen time.
5. **Only if needed:** compile the per-chunk stack into a flat opcode tape and interpret with a switch,
killing per-voxel virtual dispatch (~43k samples/tile × N ops). Standard technique. **Don't do this
pre-emptively** — measure first; the noise is likely still dominant.
---
## 5. Invariants this must not break
Non-negotiable. Each has a documented reason and, mostly, a scar.
- **Window invariance (§8.4).** Every op stays a pure function of world coords + seed. Any op with a
*connectivity decision over a neighbourhood* inherits `BuildChunkCache`'s two-region COLLECT/STORE
discipline. **Prefer the `Maze` pattern** (pure hash of `(lower node, axis)` — adjacent chunks cannot
disagree, no cache, no COLLECT region) unless connectivity is a gameplay requirement.
- **T1.a column cache Z-independence.** `FSurfaceColumn` data is keyed `(XY box, StrateKey, Seed)` with
**no ChunkZ** and shared down the whole vertical stack. XY-pure data goes in the column cache;
Z-dependent evaluation happens per voxel. `IsXYPure()` exists to make this explicit instead of implicit.
- **Cache keys include `LayoutVersion`** (`AUDIT C2` — today's `CP_Chunk`/`OC_Chunk`/`BM_Chunk` don't,
and serve stale params after a live edit). `FVoxelOpContext` carries it so a new op can't forget.
- **`ProcessQueue` stays `EQueueMode::Mpsc`**; ops are read-only on workers; `Epoch` carries through
every async path.
- **The two-pass MC loop, margin ring, and `thread_local` grid reuse** (§8.10) are untouched by all of
this — the op stack lives *below* `GetDensityAt`, the mesher never knows.
---
## 6. Risks, and how each one is detected
| Risk | Detection |
|---|---|
| A wrong `EffectOverBox` ⇒ **a hole** | Phase 0.5 test #2 (brute-force vs verdict) — the load-bearing one |
| A cache key missing an input ⇒ **seams** | Phase 0.5 test #1 (shuffled order, multi-threaded) |
| Per-voxel dispatch cost | Insights `VoxelForge_GenerateMesh` before/after each port; tape compile if it bites |
| Abstraction is wrong for this domain | The Phase 1 stop-trigger — one archetype, timeboxed, revert cheaply |
| Scope creep into a node-graph editor | See §7 |
---
## 7. Explicitly NOT doing
- **No node-graph editor.** An ordered `TArray` of op assets is 90% of the value. A graph UI is a
separate project, years later, and chasing it is how generative systems die.
- **No GPU density.** Same reasons as `fable-idea` Part I: readback latency, CPU collision, and
cross-GPU float determinism is fatal for "replicate the seed, regenerate identically on every peer"
(`ARCHITECTURE §9.1`).
- **No node-level interval arithmetic engine.** Black-box Lipschitz/amplitude bounds per op (§2).
- **No big-bang port.** If more than one archetype is mid-port at any time, stop.
- **No dissolving the strate structure.** See §0.
---
## 8. Independent fixes — do these regardless, they get worse with time
From `AUDIT-2026-07.md §5`. None depend on this plan; all become harder inside it.
1. **Bound `SeedF`** (`AUDIT C1`) — `const float SeedF = (float)(VoxelHash::Mix((uint32)Seed) & 0x3FFF);`
at all 6 definition sites. Large seeds currently collapse noise terms to constants. One world re-tune.
**Do it before tuning 3D caves against a seed you might later randomise.**
2. ✅ **DONE 2026-07-27 (uncompiled)** — `GetLayoutVersion()` added to `CP_Chunk` / `OC_Chunk` /
`BM_Chunk`, **plus two the audit missed**: `TC_BiomeCache` in `ClassifyTile`, and the
`GSurfColCache` box key (whose `StrateKey` is `round(StrateBottomWorldZ)`, so a live edit that
changes terrain params without moving the strate served stale columns — the most visible form of
the bug). `FChunkBiomeCache::Invalidate()` added, since a validity BOX says nothing about the
`FBiomeContext` its cells were classified against. (`AUDIT C2`.)
3. **`GetPlayerPosition` no-player flag** (`AUDIT C4`) — `(0,0,0)` is the designed spine landing and
currently stalls all streaming.
4. **Unbounded joins on shutdown** (`AUDIT C5`).
5. ✅ **DONE 2026-07-27** — `!*.md` in `.gitignore`; all nine design docs are tracked (commit `3128852`).
6. ✅ **DONE** — the work lives on branch `experimental`; `main` is the known-good fallback.
---
## 9. Resume here
**Next action (2026-07-27): BUILD.** Phase 0.5's four tests and the Phase 1 skeleton header are
committed and unverified. Nothing else should be written until they compile and the tests are green
— writing unverified code on top of unverified code is the exact pattern `AUDIT §P3` documents.
After the build, in order: (1) fix whatever the tests report, (2) answer the five questions in
`OPSTACK-DECOMPOSITION.md §11` — especially the SDF channel, which changes the contract and is
cheapest to decide before any port, (3) port `Maze` per `OPSTACK-DECOMPOSITION.md §4`.
`AUDIT C1` (unbounded `SeedF`) is deliberately still open — see the progress log for why it was held
back rather than forgotten.
When picking this up cold: read §0, §2 (the direction-only insight — that's what makes step 1 small),
and §4. The rest is reference. If the plan feels too big, re-read §2: **Phase 1 needs no numeric bounds
at all**, and the first real win is two archetypes collapsing into one.
---
*Changelog — 2026-07-26: written by Opus 5 after a full-tree audit, at Jahni's request, as a durable
handoff so a future context doesn't re-derive it. Companion to `AUDIT-2026-07.md`.*
+4600
View File
File diff suppressed because it is too large. Load diff
+260
View File
@@ -0,0 +1,260 @@
# Kickoff prompt — density operator stack refactor
*Paste the block below into a fresh context. Everything above the line is for Jahni, not the new session.*
**Branch:** already created and checked out — `experimental` (from `69fa73e tmp`). `main` is untouched.
**Why this exists:** the refactor is too big for one context. This prompt makes any fresh session able to
start, or resume at a phase boundary, without re-deriving the design.
**Working rhythm it enforces:** one large batch of code → stop → *"ready to build"* + likely compile-error
spots → Jahni builds → he pastes errors/screenshots → fix → next batch. That is deliberate; see §Autonomy.
---
```
You're picking up an agreed refactor of the VoxelForge UE5 voxel plugin, on branch `experimental`
(already checked out — do not create another). I'm Jahni. This was designed with a previous context
and written down so you don't have to re-derive it.
GOAL
Replace the hardcoded archetype `switch` in `UVoxelGenerator::GetDensityAt` with a composable
density OPERATOR STACK, so new world ideas become data-authoring instead of C++. Long-term ambition:
"a world generator of any kind, of any possibility — any combination of ideas could be happening."
READ FIRST, IN THIS ORDER (do not skip, do not skim §2.5)
1. CLAUDE.md — project rules. Rule #1 is absolute.
2. OPSTACK-PLAN.md — THE PLAN. §0 the summary, §2 why step 1 is small, **§2.5 the op taxonomy**,
§2.6 the acceptance bar, §4 the phases, §5 the invariants, §7 non-goals.
3. AUDIT-2026-07.md — §6 (the 3D hazards) and §5 (priority list). §1 has real open bugs.
4. CODEMAP.md — navigation. Trust symbol names over line numbers; the lines are stale.
5. ARCHITECTURE.md §8.10 — the perf invariants. Read before touching any hot path.
THE THREE WAYS YOU WILL FAIL — internalise these before writing code
(1) BUILDING. Never run a build, compile, or the editor. Jahni builds everything himself; he has the
editor open and running it yourself just burns cost and tells you nothing. When a batch of code is
done: STOP, say "ready to build", and list the likely compile-error spots. Then WAIT. Same for
in-editor verification — ask for a screenshot, don't try to produce one.
(2) WRAPPING INSTEAD OF DECOMPOSING. Read OPSTACK-PLAN §2.5. Jahni already had a "room operations"
system — ops that perturb density near an existing surface. His words: "there's a world of
difference between a grotto strate and an open world strata." If you turn each old density
function into one monolithic op, you have rebuilt the switch with extra steps and wasted the
effort. Archetypes must DECOMPOSE into: FIELD SOURCES (role 1 — what makes a grotto a grotto),
COMBINERS (role 2 — how sources merge; this is what makes ideas compose), DETAIL MODIFIERS
(role 3 — his old ops, now one role of four), STRUCTURAL POST (role 4 — spine/seal/passage/diff,
always appended in that order, never author-omittable).
The test: can you author "open-world surface strate whose mountains contain a room-graph cave
system, with floating islands in the upper void" with NO C++? If no, you built the wrong thing.
(3) BREAKING AN INVARIANT. OPSTACK-PLAN §5. In particular: window invariance (§8.4 — every op is a
pure function of world coords + seed); the T1.a column cache is keyed (XY box, StrateKey, Seed)
with NO ChunkZ and is shared down the whole vertical stack, so XY-pure data only; every cache key
must include LayoutVersion (see AUDIT C2 — three existing caches get this wrong today);
ProcessQueue stays EQueueMode::Mpsc; Epoch carries through every async path.
ACCEPTANCE BAR — read OPSTACK-PLAN §2.6 carefully, two properties get confused
- `ValidateDeterminism` = 0 delta: REQUIRED ALWAYS. Same point, different cache window/thread,
identical float. This is self-consistency, not reproduction.
- Byte-identical to the OLD system's output: NOT required. Jahni: "not a 1/1 replica, but a possible
very close result to what I have right now, else it won't really matter much." Recognisably the same
kind of place at a fixed seed, judged on a screenshot. A one-time param re-tune is expected and fine.
This relaxation is WHY you are allowed to decompose properly instead of wrapping.
WHAT TO DO — first batch (Jahni has standing permission for multiple changes per build)
A. Phase 0.5 from the plan — the three automation tests. There are currently ZERO tests in this
plugin and nothing machine-checks the many "bit-identical" claims in the docs.
1. Density purity: sample ~10k points, shuffle query order, re-sample, assert bit-equality. Run it
across MULTIPLE worker threads (the existing `ValidateDeterminism` button is game-thread only and
would miss worker-cache divergence — that's how AUDIT C2 hid).
2. ClassifyTile soundness: for random tiles, if the verdict is AllSolid/AllAir, brute-force the
lattice and assert every sample agrees. This is the highest-consequence function in the plugin
and is currently validated only by reasoning; a false verdict is an invisible, collisionless hole.
3. DiffLayer under contention: N readers + a writer, assert no crash and monotonic version.
B. Phase 1 skeleton — `Public/VoxelDensityOp.h`: `IVoxelDensityOp` (PrepareChunk / Eval /
EffectOverBox / IsXYPure), `EVoxelOpEffect { CarveOnly, FillOnly, Both, Identity }`, the four role
tags, `FVoxelOpContext` (carrying LayoutVersion), and the combiner enum. Header + docs only, no
ports yet. Start with DIRECTION-only effects — no numeric intervals (OPSTACK-PLAN §2 explains why
this alone reproduces every hand-written ClassifyTile guard, and why it makes step 1 small).
C. Then STOP and hand off for a build. Do not start porting Maze in the same batch.
If (A) fails on the current code you have found a live bug — report it, fix it, don't build on top.
FOUR THINGS AGREED WITH JAHNI 2026-07-27 THAT THE PLAN UNDER-STATES — hold these as goals
(i) THE BIGGEST PERF PRIZE IS TILE-SKIPPING FOR CAVE STRATES, and it is currently zero. Read
`ClassifyTile`: any chunk that is neither a bedrock gap nor SurfaceWorld hits
`return EVoxelTileClass::Mixed; // archétype cave […] pas prouvable en v1`. So TunnelNetwork,
Maze, VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber and Underwater capture NONE of
T1.d's win (which was 84% of gens empty, −44% worker CPU). `fable-idea` wanted this from the start
("for cave strates, 'no room/tunnel/passage/spine/seal/diff-layer bounds intersect' — all bounding
data already exists") and it never happened because a bespoke prover per archetype was too much.
`EffectOverBox` IS the generic mechanism. A room-graph source returning `Identity` when no room or
tunnel bound reaches the box makes deep bedrock skippable for the first time. Treat this as an
explicit deliverable of each port, not a side effect.
(ii) COMPILE THE STACK TO A FLAT TAPE, and don't wait for Phase 3 if the profile says otherwise.
Naive per-voxel virtual dispatch is ~6 ops × ~43k samples/tile ≈ 257k indirect calls ≈ ~1 ms/tile
of pure overhead — material against current gen cost. In `PrepareChunk`, compile the stack into a
flat `(opcode, params)` array and run a switch over a small dense opcode set in the inner loop: no
vtables, params cache-hot, predictable branches. Offsetting win, worth stating: ops that are
disabled or out-of-scope are ABSENT from the tape, so the ~15 per-voxel `if (Params.X > 0)` gates
inside `GetDensityWithParams` today become zero cost instead of one always-false branch each.
Take an Insights capture after Phase 1; if dispatch shows up, do the tape then.
(iii) THE PERF STORY IS "≈ NEUTRAL PLUS ONE REAL WIN", NOT "FASTER". Do not oversell it in docs or
reports. The reason to do this refactor is composition. Honest expectation: neutral per-voxel after
the tape, meaningful gain on tiles-never-generated. If a measurement contradicts that, say so.
(iv) SHIP PRESET STACKS so authoring doesn't regress. A simple world today is "pick an enum, fill one
struct"; after, it's "assemble 3-5 assets in the right order", which is more clicks and a new class
of mistake (ordering is now semantic). Provide `DA_Stack_ClassicGrotto`, `DA_Stack_OpenWorld` etc.
as starting points a strate can diverge from. `ECaveGeneratorType` is expected to disappear
eventually — but only after every archetype is ported; it stays as the fallback path until then.
HOW TO REPORT AT EVERY STOP
- What changed, file by file.
- "Ready to build" + the specific spots likely to error (signatures, UHT, includes, template/lambda
capture) so Jahni knows where to look.
- What he should LOOK AT in-editor afterwards, concretely, and what a pass vs fail looks like.
- What the next batch will be.
- Anything you became unsure about. Ask rather than assume — for UE API behaviour especially, ask him
for the docs instead of guessing (DivideAndRoundDown truncating rather than flooring already cost
build cycles once).
DISCIPLINE
- Update CODEMAP §3 rows for any new/renamed symbol; ARCHITECTURE §8 for design changes; tick phases
in OPSTACK-PLAN.md as they land. Comments are French + English — match the surrounding file.
- Never edit Binaries/, Intermediate/, *.generated.h.
- Density sign: NEGATIVE = solid, POSITIVE = air at the mesher. #1 source of confusion.
- Commit per feature with a real message (this branch exists so you can commit freely; `main` is the
known-good fallback). Do not push.
- If the plan turns out to be wrong for this domain, SAY SO and stop. There is an explicit
stop-trigger on Phase 1: if it exceeds ~2 days or the source/modifier split doesn't fall out
naturally from the existing code, revert and report rather than pushing through.
UNATTENDED OPERATION — Jahni may start you and go to sleep. Read this before your first tool call.
YOU CANNOT CHECK YOUR REMAINING BUDGET. No tool reports usage or quota. So do not try, and do not
claim to. Instead assume the harder thing: **this session can end at any moment, without warning,
mid-edit, and nobody will be watching.** Everything below follows from that.
CRASH-SAFE DISCIPLINE (non-negotiable when unattended)
1. `git commit` after every coherent unit — a file, a test, a header. Small and often. You are on
branch `experimental`; `main` is the known-good fallback, so committing costs nothing and a
half-finished commit is infinitely better than an uncommitted half-edit. Pushing `experimental`
is fine and expected (it is tracked as `origin/experimental` since 2026-07-29); **never push
`main`.**
2. Maintain `OPSTACK-PROGRESS.md` at the plugin root. APPEND (never rewrite) a dated entry per
milestone: what you did, what you believe is true, what is UNVERIFIED (i.e. everything not yet
built), and the single next action. Write the entry BEFORE starting the work it describes, so an
abrupt death still leaves an accurate marker. This file is how the next context resumes.
3. Never leave a file mid-transformation across a stopping point. If you're partway through changing
a signature and its call sites, finish all call sites or revert the change. A tree that doesn't
compile *for a reason you documented* is fine; one that doesn't compile for an unknown reason is
the thing that wastes Jahni's morning.
4. Don't batch a risky change with a safe one in the same commit. If a build fails he needs to know
which half did it.
WHEN YOU REACH THE BUILD GATE — this will happen quickly, and it is not a failure
The first batch (A + B) is a few hours at most, and then you physically cannot verify anything. At
that point: STOP writing plugin code. Do NOT invent more C++ to fill the night — writing unverified
code on top of unverified code is the exact failure `AUDIT-2026-07.md §P3` documents, and doing it
unattended would be the worst version of it.
Instead work the BUILD-FREE QUEUE, in this order. All of it is genuinely useful and none of it can
break anything:
Q1. ★ THE DECOMPOSITION MAP — the highest-value unattended task by far. Create
`OPSTACK-DECOMPOSITION.md`: for EACH of the 8 archetypes, read its density function carefully
and write out its proposed op breakdown — which FIELD SOURCE, which COMBINERS, which DETAIL
MODIFIERS, and exactly which existing params migrate to which op asset (field by field, so
nothing is silently dropped). Note per op: `IsXYPure()`, its `EffectOverBox` strategy, and any
shared primitive two archetypes could reuse. This is hours of careful reading, has zero build
risk, and it de-risks and speeds up every later port. Do this before anything else in the queue.
Q2. Audit the `FStrateGenerationParams` 74 fields against Q1 and list any that no op claims — those
are either dead or a decomposition gap. Report, don't delete.
Q3. Tick the stale "PENDING BUILD" markers in `fable-idea.md` / `ARCHITECTURE.md` (confirmed
resolved 2026-07-26 — everything is built and working; see `AUDIT-2026-07.md §0`).
Q4. Fix `.gitignore` to `!*.md` — the design docs are currently untracked (`AUDIT P1`).
Q5. STOP. Write the final `OPSTACK-PROGRESS.md` entry and a clear "good morning" summary: what's
ready to build, exact likely error spots, what to look at in the editor, what a pass looks like.
Then idle. Do not start Phase 2. Do not port a second archetype. Do not refactor anything not
in the plan.
There is no prize for burning the whole night. A small, committed, well-documented, build-ready
increment plus a complete decomposition map is a genuinely good night's work.
```
---
## Autonomy — what a fresh context can and cannot do alone
**Asked:** *"possibly even, you tell me if it's possible, for you to wait for the token to replenish? to
continue it all automatically and on your own."* — and then: *"can you tell it to check for token remaining
before continuing actions? I plan to let it run as I sleep."*
**On checking the budget: no. There is no tool that reports remaining usage or quota**, so an instruction to
"check tokens first" would be unfollowable — a safety net that looks real and isn't. The prompt therefore
instructs the opposite and stronger discipline: **assume the session can die at any moment, unattended**,
and make every stopping point crash-safe (commit-per-unit, an append-only progress log written *before*
the work it describes, never a half-applied edit). That achieves the actual goal — not waking up to a
broken tree — without depending on information the session can't get.
**On resuming automatically across a limit reset: the mechanism exists, but don't use it for this, and
tokens are not the real limit.**
- A self-pacing loop is available (`/loop` with no interval, which schedules its own wakeups). Whether it
resumes cleanly across a usage-limit reset is not something to promise — treat it as unverified.
- **The actual blocker is the build gate, not the budget.** Every meaningful step of a C++ refactor ends
at a compile, and rule #1 is that Jahni compiles. A loop left running would therefore do exactly one
thing: **write more unverified code on top of unverified code** — which is the precise failure pattern
`AUDIT-2026-07.md §P3` was written about (three weeks of stacked "PENDING BUILD"). Automating it would
deepen the problem it diagnosed.
- **What genuinely scales instead: batch size.** A single context can produce a large, coherent,
self-contained batch before stopping. Standing permission for multiple changes per build already exists.
So the throughput lever is "fewer, bigger handoffs", not "unattended looping".
- **What IS worth looping:** work with no build gate — documentation passes, analysis, or the separate
web-based world-rating harness. Not this.
**So the honest shape of "handling it on its own":** a fresh context can own the *design decisions, the
code, the doc updates and the sequencing* end to end, across many sessions, resuming from
`OPSTACK-PLAN.md §9`. It cannot own *verification*. That stays with Jahni, and given that the failure
modes here are invisible holes and silent seams, that's the correct place for it.
### What an overnight run realistically produces
Setting expectations honestly, because the first batch is deliberately small:
| | |
|---|---|
| ~1–3 h | Phase 0.5 tests + Phase 1 skeleton header. **Then it hits the build gate and cannot verify anything.** |
| remaining night | The BUILD-FREE QUEUE — dominated by **Q1, the decomposition map**: all 8 archetypes read carefully and broken into source / combiners / modifiers, with every param field traced to its destination op. Genuinely hours of work, zero build risk, and it makes every later port faster. |
| morning | A committed build-ready increment, `OPSTACK-PROGRESS.md`, `OPSTACK-DECOMPOSITION.md`, and a "good morning" summary with exact likely error spots. |
**What it must NOT do overnight:** port archetypes, start Phase 2, or write more plugin C++ once the gate
is reached. The prompt says this explicitly, twice, because "fill the night with code" is the tempting
wrong answer and it reproduces the stacked-unverified-work pattern the audit was written about.
---
## Feasibility, honestly
| Phase | Effort | Risk |
|---|---|---|
| 0.5 — three tests | 1 session + 1-2 build rounds | Low. May surface a real bug (that's a win). |
| 1 — skeleton + Maze decomposed | 1-2 sessions + 2-3 build rounds | **Medium — this is the go/no-go.** |
| 2 — port remaining archetypes | 1 session each, opportunistic | Medium; `TunnelNetwork` last, it owns §8.4. |
| 3 — ops as assets, strate = op list | several sessions | Medium-high; big authoring-surface change. |
**The single genuine unknown is Phase 1**, and it's cheap to find out — one archetype, timeboxed, with an
explicit abort. If `Maze` doesn't decompose cleanly into a source + a modifier, the abstraction is wrong
for this domain and two days bought that knowledge.
**The largest hidden cost is not code, it's re-tuning.** Every ported archetype needs its params re-dialled
to look right again (§2.6 accepts this). That's Jahni's time in the editor, not a context's time writing
C++, and it is likely to dominate the schedule.
+93
View File
@@ -0,0 +1,93 @@
# PERF-ATTRIBUTION
Measured 2026-09-12 at `cf0319d` (`experimental`). This was measurement-only: the source changes add
diagnostic counters and clocks; they do not change density or meshing decisions. The primary case is
the 128^3 `TunnelNetwork`, seed 1337, slot 1, operator-stack setting 1, origin `(-64,-64,-224)`
voxels, step 1, 64 canonical 32^3 tiles, and 15 workers.
`clean` means diagnostics off and `-blockearlyout=0`, with fused evaluation and shared density-grid
reuse enabled. The three clean primary runs used an exact-HEAD DLL staged before instrumentation:
603.599, 590.746, and 569.627 ms mesh time, mean **587.991 ms**. The requested 0.556 s is therefore
within the observed run spread but was not reproduced exactly on this host. `attr` means the final
instrumented DLL, `-perfattribution`, sample interval 128. Its wall-clock self-check compares the
same export against an unprofiled run in the same process.
The prescribed build was run successfully with:
```text
dotnet "E:\Program Files\Epic Games\UE_5.7\Engine\Binaries\DotNET\UnrealBuildTool\UnrealBuildTool.dll" UnrealEditor Win64 Development "-Project=E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Saved\BuildHost\HostProject\HostProject.uproject" -WaitMutex -FromMsBuild -architecture=x64 -NoUBA "-Log=E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Saved\PerfAttributionBuild.log"
```
The measured process loaded the staged runtime DLL at
`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Saved\BuildHost\HostProject\Plugins\VoxelForge\Binaries\Win64\UnrealEditor-VoxelForge.dll`, SHA-256
`C0CB812BB25EFE8C836DC1C1718C408DA5FE9D9F258B4AE12A6CA846510CA0B2`, length 7,673,856 bytes. The
staged editor DLL was `E64C9E80869EFA53FA1556947718DB8E64975ABF6B546657BCFF5D775A2B679A`, length
502,784 bytes. The game harness recorded the same runtime path and hash. The primary attribution and
final classifier exports returned `status=ok`, geometry CRC `07C14005`, and the required OBJ SHA-256
`b3e5f4c398dd0547c6c55f415872058246d4a292c2f0d636ee1cd0829de9b377`.
The table is the attribution. Only rows marked **additive** are summed. Profiler component rows are
sampled nested diagnostics; their CPU and worker-wall figures are evidence about composition, not
additional time to add to the wall ledger.
| Bucket / question | Measurement and mode | Elapsed time, volume, or count | Whole-clock accounting / interpretation |
|---|---|---:|---|
| Historical comparison | `34f06df` value supplied in the request | 130.000 ms | Historical pre-op-stack reference; the exact old binary was not rebuilt because that commit predates the current editor commandlet and host harness. |
| Current clean primary | 3 exports, exact `cf0319d` DLL, diagnostics **off**, fused=1, reuse=1, classifier early-out=0 | **587.991 ms mean**; 569.627–603.599 ms | Clean headline mesh clock. Mean is 4.523x the supplied 130 ms reference. |
| Primary attribution self-check | Final DLL, `attr`; profiled 606.128 ms versus same-argument unprofiled 602.341 ms | 1.0063x; +0.629% | Passed. Geometry equal. The profiler did not reproduce the 11.5x inflation seen in the earlier diagnostic mode. |
| Full commandlet export | Final DLL, `attr`; mesh plus output and bookkeeping | **1,004.557 ms** | Full export wall, not just mesh generation. The OBJ writer is a material part of this clock. |
| Full export: mesh generation | Additive export ledger | 606.128 ms; 60.338% of export | Includes the complete additive mesh ledger below. |
| Full export: OBJ/output interval | Additive; includes nested `MeasureExploreMesh` | 394.273 ms; 39.249% | `measure_seconds` is 82.964 ms inside this interval and is not added again. |
| Full export: geometry hash | Additive | 2.946 ms; 0.293% | CRC/hash bookkeeping. |
| Full export: manifest | Additive | 0.984 ms; 0.098% | Manifest write. |
| Full export: remainder | Additive export remainder | 0.225 ms; 0.022% | Export ledger remainder. Mesh + OBJ interval + hash + manifest + remainder = 1,004.557 ms. |
| Mesh ledger: preparation | **Additive** | 0.000101 ms; 0.0000% of mesh | No meaningful cost. |
| Mesh ledger: shared density-grid fill | **Additive**; 2,248,091 unique shared samples | **590.923 ms; 97.4913% of mesh** | This is the dominant remaining cost. It is the caller-side density-grid stage and includes the canonical fused `GetDensityAt` work. |
| Mesh ledger: pre-tile setup | **Additive** | 0.001602 ms; 0.0003% | No meaningful cost. |
| Mesh ledger: tile parallel wall | **Additive**; 64 jobs, 15 workers | 10.972 ms; 1.8102% | Per-tile cell scan, interpolation, normals, stream assembly, and task scheduling after the shared grid exists. |
| Mesh ledger: tile merge | **Additive** | 4.232 ms; 0.6982% | Cross-tile output merge. |
| Mesh ledger: accounting remainder | **Additive** | 0.000097 ms; 0.0000% | Stage sum 606.128205 ms versus mesh clock 606.128301 ms; `sum_matches_mesh=true`. Nothing is hidden in this remainder. |
| Mesh ledger total | Final DLL, `attr` | **606.128301 ms** | Complete mesh wall clock. |
| `GetDensityAt` parent | Final primary, `attr`; 2,248,091 calls, 17,558 sampled parent intervals, 15 workers | 580.184 ms worker-wall estimate | Nested inside the 590.923 ms density-grid stage. The remaining 10.738 ms is grid-loop/task/read overhead outside the parent scope; it is accounted for by the additive density-grid row. |
| Fused evaluator itself | Final primary, `attr`; 17,571 sampled component scopes; 2,248,091 fused tunnel samples | 107.183 ms sampled CPU; 12.301 ms sampled worker wall | Diagnostic sample only, not additive. Detail ran for 1,079,777 samples and was skipped for 1,168,314, about 52.0% of fused lattice samples. |
| Fused density core | Final primary, `attr`; sampled nested scope | 108.371 ms sampled CPU; 12.377 ms sampled worker wall | Diagnostic sample only. It is not a 108 ms total bucket and must not be added to the mesh ledger. |
| Room/tunnel graph traversal | Final primary counters | Rooms: 11,099,891 candidates / 1,730,920 evaluated; tunnels: 28,951,420 candidates / 3,462,565 evaluated; tunnel-core: 28,951,420 candidates / 1,881,812 evaluated | Room and tunnel candidate traversal is repeated inside the common density work. This is evidence of work volume, not a separately additive clock. |
| Room graph build and SDF | Final primary, `attr` | `RoomGraphBuild`: 77 calls, 192.146 ms sampled CPU / 19.323 ms worker wall; `RoomGraphSdf`: 12.101 ms / 0.989 ms sampled CPU/wall | The build is cold/cache construction work; SDF figures are nested samples. The room graph is not a large independent wall bucket on the primary fused route. |
| Tunnel-core world evaluation | Final primary, `attr` | 8.857 ms sampled CPU; 0.872 ms sampled worker wall | Nested diagnostic sample. The core traversal is present, but the measured evidence does not support calling it the whole 4.3x. |
| Post-stack structural/passage tail | Final primary, `attr`; `GetDensityAt` tail | `StructuralTail`: 20.853 ms CPU / 1.896 ms wall; `DensityStructuralPosts`: 21.909 ms / 1.990 ms; `PassageStructuralPosts`: 10.476 ms / 0.997 ms sampled CPU/wall | This is real current-only post-stack work, but it is a small sampled component relative to the 590.923 ms density-grid wall. All rows are nested and non-additive. Passage modifier was 4.579 ms CPU / 0.404 ms wall; landing-floor and room-floor samples were 1.691 ms / 0.177 ms and 0.665 ms / 0.060 ms. |
| Floor/profile work | Final primary counters, fused route; classifier figures from the separate classifier control | Primary support-column builds/queries/checks: 0 / 0 / 0; authored-floor samples: 0; floor-profile builds 2,636; segments 12,624; backstops 6,509 tunnel and 49 room. Classifier control: 211,289 support-column builds, 2,171,763 candidates, 1,370,314 floor queries, 2,296,243 checks, 9,710 authored-floor samples, 97,950 profiles and 442,278 profile segments. | The fused primary route does not execute the classifier’s support-column path. The classifier does, and that work is part of its separate 2.057 s route, not the clean 0.588 s headline. |
| Software tunnel cache | Final primary, `attr` | 2,248,043 hits, 48 misses, 77 cache builds | Cache lookup is not the removed verdict cache. The current tunnel cache is overwhelmingly hit; no hardware cache-miss counter was available. |
| Classifier absent from the clean headline | Clean exact-HEAD control, diagnostics **off**, classifier early-out=0 | 0 classifier calls | The stated 0.556 s case does not pay interval proofs. It is incorrect to charge classifier time to that baseline. |
| Classifier control when enabled | 3 clean exact-HEAD controls, diagnostics **off**, classifier early-out=1 | **2,056.596 ms mean** | +1,468.605 ms versus the clean primary mean; 3.498x the primary. This is a separate, expensive route, not the source of the clean 0.556 s. |
| Classifier total self-check | Final DLL, `attr`, classifier early-out=1 | 1,980.835 ms profiled versus 2,013.547 ms unprofiled; 0.9838x | Passed; geometry equal. Profile overhead was -1.62% in this run. |
| Interval proofs | Final classifier attribution; 32 sampled calls out of 4,096 | 494,976 refine/box calls; 479,872 whole-mixed, 15,104 whole-solid, 0 whole-air; 63,488 splits; max depth 4 | Proof detail is present and conservative, but its route is costly. The classifier’s scaled diagnostic work was 14,113.190 ms interval-box, 62.221 ms exact-core, and 399.770 ms exact-final microseconds; these are diagnostic work estimates, not additive wall time. |
| Classifier repeated room traversal | Final classifier attribution | 2,041,600 room-tail queries, 933,120 evaluated; 6,030,720 exact-core cache hits; 612,864 exact-core samples; 768 exact-final samples | The classifier does repeat room-graph work. Scaled diagnostic work was 11,423.590 ms room propagation, 4,665.818 ms exact primitive, 5,815.002 ms cache-window, and 1,227.366 ms room tail microseconds. |
| Shared grid traffic | Final primary, `attr` | 2,744,000 tile-grid samples; 2,248,091 unique shared samples; 495,909 duplicate evaluations before reuse, 0 after | The shared lattice removes duplicate evaluations, but filling the remaining 2.248 M samples is still 590.923 ms. |
| Mesher cell classification | Final primary, `attr`; nested sample | 2,097,152 cell calls; 1.344 ms sampled worker wall | The cell loop is not the dominant wall bucket. |
| Mesher vertex interpolation | Final primary, `attr`; nested sample | 76,808 calls; 0.012 ms sampled worker wall | Negligible in the observed sample. |
| Mesher gradient normals | Final primary, `attr`; nested sample | 76,808 calls; 1.056 ms sampled worker wall | Small nested cost; gradient normals come from the density grid. |
| Mesher stream building | Final primary, `attr`; nested sample | 76,808 calls; 3.140 ms sampled worker wall | Small relative to density-grid fill. Runtime stream handoff is not executed by the export commandlet. |
| Mesher output and memory | Final primary, `attr` | 82,273 vertices, 153,346 triangles; logical output arrays 7,434,716 bytes; allocated output 8,449,820 bytes; OBJ 17,587,937 bytes; estimated working set 14,851,564 bytes | Byte counters are logical allocator/output traffic, not physical DRAM traffic. |
| Grid read/write bytes | Final primary, `attr` | Density-grid bytes 10,976,000; shared-grid read bytes 10,976,000 | The instrumentation sees logical grid traffic. It does not see hardware cache misses, NUMA traffic, or memory-controller events. |
| Operator-block scratch/copy | Final primary, `attr` | 0 scratch bytes, 0 copy bytes, 0 builds, 0 operators | The old interpreted block machinery is not on the canonical primary route. |
| Operator-block control | Clean diagnostics-off controls with reuse=0 and fused=0 | 2,332.186 ms; 1,566 block builds; 2,486,750 block samples; 31,320 operator visits; 99,470,000 scratch bytes; 101,956,750 copy bytes | This is a deliberately separate block-path control, not the clean fused baseline. It demonstrates the traffic that is absent from the primary route. |
| Scalar and fused no-reuse controls | Clean diagnostics-off controls with reuse=0 | Scalar 2,733.080 ms; fused 2,218.132 ms | These controls show the cost of removing shared-grid reuse, but they are not attribution buckets for the primary run. |
| What `34f06df` did not do | Read-only source comparison at the historical commit | No active TunnelNetwork operator-stack evaluation, no fused evaluator, no current post-stack tunnel-floor/structural tail, no current editor export commandlet | The old source already had a shared density grid, `ClassifyTile`, marching-cubes cell classification, interpolation, grid-gradient normals, and stream construction. Those are not new current-only work. The old source also had room/tunnel morphology and passage carving, so “rooms/tunnels exist” alone is not a delta. |
| Primary operation-level attribution gap | Final primary profiler’s own ledger | Sampled phase sum 14.443 ms versus sampled `GetDensityAt` parent 580.184 ms; sampling gap 565.741 ms | This is the honest limit: component scopes were sampled to keep profiled/unprofiled totals within 0.63%, so their expanded operation-level wall shares are not identifiable from this run. The full mesh wall is nevertheless closed by the additive stage ledger, with a 0.000097 ms remainder. |
| Headless game clean check | `PerfAttribGameCleanFinal_20260912`; diagnostics **off**, tile profiling off; 343 LOD0-ready samples | request-to-ready p95 **0.375470 s**; queue wait p95 0.082292 s; generation p95 0.309538 s; collision cook p95 0.052945 s | Secondary check agrees with the earlier conclusion that queue wait is not the main generation constraint. |
| Headless game attribution check | `PerfAttribGameAttrFinal_20260912`; diagnostics **on**, tile profiling on; 834 profile lines, 822 applied | request-to-ready p95 **0.395715 s**; queue wait p95 0.083363 s; generation p95 0.315281 s; collision cook p95 0.061387 s | Attribution overhead was modest in the end-to-end game summary. Tile-profile p95 was 1.295847 s, of which classifier was 0.106626 s, mesh 1.284040 s, stream building 0.000361 s, room work 0.008842 s, and stack work 0.012019 s; these game tiles are not the same workload as the single 128^3 export. |
The surprise is that the clean primary is almost entirely the shared density-grid fill: the ordinary
mesher stages after it total only about 15.204 ms. The fused evaluator is genuinely on the canonical
path, and the room/tunnel candidate counters show substantial repeated traversal, but the sampled
component timers cannot honestly turn that 590.923 ms into exact per-operation percentages without
reintroducing the profiler inflation this round was designed to avoid. The classifier is an even
larger cost when enabled, yet it is absent from the clean baseline. The old path already performed
the grid and marching-cubes phases, so those phases should not be described as newly introduced
work; the current-only items are the active fused route and the later structural/floor machinery.
What could not be attributed: physical allocation-event counts, physical memory/cache misses, and an
exact numeric per-operation delta against the old `34f06df` binary. The old commit has no compatible
current export commandlet, and rebuilding it would not be the same measurement harness. The complete
current clocks, deterministic geometry, clean/profiled comparison, current counters, and all logical
memory-byte counters are accounted for above. Existing logs and outputs under `Saved\` were retained.
+1960
View File
File diff suppressed because it is too large. Load diff
+148
View File
@@ -0,0 +1,148 @@
# VoxelForge — Review Findings (2026-06-23)
Local-LLM cross-file QUALITY review (Qwen3.6-27B @128K, codemap-guardrailed) + Claude
verification. Scope: performance, redundancy, dead code, over-complexity (not correctness bugs).
**Verdict: codebase is healthy — no perf regressions.** Items below are improvements, by appetite.
> **Reconciled against `fable-idea.md` + the perf-pass history (2026-06-23).** The big density/
> streaming perf wins are already SHIPPED and are *not* listed here (T1.a surface cache, T1.b
> grid normals, T1.c LOD0 collision, CullTiles spiral fix, region foliage, passage shortlist).
> Everything below is **net-new** (redundancy / dead code / complexity — a different axis), except
> the two notes flagged inline. Before acting on a PERF item, glance at `fable-idea.md` Part I.
Legend: ✅ verified against code · ◻️ checklist box.
> **2026-07-04 full-codebase pass (Fable 5)** — applied everything marked `[x]` below, plus NEW items
> found reviewing the un-built two-grid deco + density-volume work:
> • **Deco launch stall fix** — a pending cell already in flight from a previous build was dropped,
> permanently stalling its region build (blank region until strate change). Now deferred + retried.
> • **Capture gating** — level-0 tiles outside the shadow window no longer pay the mesher capture
> (quantize + 32 KB queue payload) nor pollute `CaptureCache` (`IsTileCaptureUseful`, both ends).
> • **Per-tick material pushes change-detected** — `UpdateTerrainMaterialParams` (10 vectors + 3
> textures × every terrain MID) and the orb MPC writes now no-op on idle frames; static `FName`s.
> • **Landmark cave march bounded** — `FindLandmarkColumn` now honours `ColDepth` (same bedrock
> early-stop as the deco march); it ran the full strate band synchronously on the game thread.
> • **`UploadDirtyTextures` now calls `EnsureTextures`** — GPU upload toggled ON at runtime works.
> Deliberately NOT done: the big behavior-preserving splits below (un-built tree; compile risk).
> **2026-07-04 perf pass 2 (per-voxel hot path, Fable 5)** — ✅ BUILT & WORKING (ticked 2026-07-27). All
> bit-identical (same hashes/math, hoisted per chunk/cell):
> • **DiffLayer snapshot API** (`HasAnyMods`/`GetModsVersion`/`GetChunkModsSnapshot`/static
> `EvaluateMods` + `ModsVersion` atomic) — `GetDensityAt` snapshots a chunk's mods once per
> (chunk, version) via a `thread_local` 64-slot cache: ~27 lock ops per tile task instead of
> ~86k once any carve exists.
> • **Room shape pre-bake** — `FCachedRoom::ShapeType/ShapeA/ShapeB/ShapeR` baked in
> `BuildChunkCache`; **`EvaluateSDFCached` signature changed** (`RoomShapeVariety` removed,
> 8 call sites updated).
> • **Strate-index memo** in `GetDensityWithParams`, keyed (chunkZ, `GetLayoutVersion()`) —
> new inline getter on `UVoxelStrateManager`.
> • **Per-cell lattice bakes** (`thread_local`, keyed cell+seed+params): slab columns, maze open
> edges, vertical shafts + cross-connectors, floating-island constants, disturbance
> chasms/bridges/ridges.
> • **Worm N2 short-circuit** — skip the 2nd Perlin when N1 ≥ WormThreshold (lossless).
> **Regression fixed same day:** pass 1's skip-if-identical cache on the orb MPC writes broke the
> mini-sun lighting (MPC world instances reset behind the writer) — REVERTED, `LastOrbMPC`
> removed; never de-duplicate writes to externally resettable state. The MID-side change
> detection stays (MIDs own their values).
> **2026-07-04 batch 3 (Fable 5)** — Jahni green-lit multiple changes per build + visual deltas
> ("nothing is set in stone"). ✅ BUILT & WORKING together with pass 2 (ticked 2026-07-27):
> • **Terracing gradient Z-only** (6→2 SDF samples — see the ticked item above).
> • **Lerp X-macro** + **BakeRoomFeature dedupe** (both ticked above, bit-identical).
> • **T2.b LOD octave drop** — opt-in `UVoxelSettings::LODOctaveDrop` (default 0 = byte-identical);
> `VoxelGenLOD::OctaveBias` thread_local set per tile in `GenerateMesh`, per-voxel fractal sites
> wrapped in `VoxelGenLOD::Eff(N)`, XY-field noise deliberately excluded. ARCHITECTURE §8.10.
> • **T2.c tile component pool** — `TileComponentPool` + `Acquire/ReleaseTileComponent`; unload
> parks (RemoveSectionGroup strips geometry+collision, hidden, stays registered), apply pops.
> Bonus: `ApplyMeshToTile` reuses the existing `URealtimeMesh` (`GetRealtimeMeshAs`) — the old
> unconditional `InitializeRealtimeMesh` allocated + orphaned one mesh UObject PER APPLY.
> • **T2.d worker clamp** — `GetMaxConcurrentTasks()` caps the asset budget to logical cores − 2
> (the BackgroundNormal priority half had already shipped). **Tier 2 is now COMPLETE** —
> T2.a was already done (float SSE `VoxelNoise` core, §8.10).
> • **F2 determinism validator** — `AVoxelWorld::ValidateDeterminism` CallInEditor button:
> boundary points sampled under two cache-window alignments + a repeat pass; any non-zero
> delta = §8.4 regression. The tool that VERIFIES all the "bit-identical" claims above.
## Performance
- [x] ~~**`VoxelGenerator.cpp` (terrain-op gradient)** — compute once per voxel and reuse.~~
**RE-VERIFIED 2026-07-04: misdiagnosed.** The 6 `EvaluateSDFCached` calls in the terracing block are
six DISTINCT sample positions (±1 on each axis) for one central-difference gradient — computed once
per voxel already, gated to terrace-enabled rooms near surfaces. Nothing is redundantly re-evaluated.
*Optional lossy halving:* **APPLIED 2026-07-04 (Jahni approved visual deltas).** SDFs are
≈unit-gradient, so `Horizontality = clamp(|SDF(Z+1)−SDF(Z−1)|/2)` — the 4 X/Y samples are gone
(6→2 SDF evals per terrace-voxel); terraces fade slightly differently on SmoothMin'd slopes.
- [x] **`VoxelContentManager.cpp` (BuildCellSpawns)** — slope-gate cosines now precomputed per entry
(`CosMaxSlope`/`CosMinSlope` arrays), bit-identical gating. *(2026-07-04)*
- [x] **`VoxelContentManager.cpp` (LaunchDecoTasks)** — head-index drain + one `RemoveAt(0, Head)`
compaction (was O(N) per pop). Bonus fix: a pending cell still in flight from a previous build is now
DEFERRED, not dropped (dropping stalled its region build forever → permanently blank region). *(2026-07-04)*
- [x] **`VoxelStrateManager.cpp`** — linear `BoundRadius` stored on `FVoxelPassage`; shortlist no longer
Sqrt's per passage. *(2026-07-04)*
## Redundancy (factor out)
- [x] **`VoxelCaveMorphology.cpp`** — Pit/Chimney/Column baking loops → shared `BakeRoomFeature`
hash-placement skeleton (gate/XY/radius chain) + per-type Emit lambda. Bit-identical (same salts,
same hash order). *(2026-07-04, batch 3 — Jahni green-lit batching without intermediate builds)*
- [x] **`VoxelStrateTypes.h`** — `FStrateGenerationParams::Lerp` now expands the
`VF_STRATE_PARAM_FIELDS` X-macro (all 74 fields verified present — no drift had happened yet).
New struct fields MUST be added to that list. Bit-identical. *(2026-07-04, batch 3)*
- [x] **`VoxelWorld.cpp` brush methods** — Carve/Fill sphere now funnel through `ApplyModification`
like Box/Capsule already did (one diff-layer + remesh dispatch). *(2026-07-04)*
- [x] **`VoxelContentManager.cpp`** — duplicated drain/reset loops → `DrainDecoResults()` +
`ResetGridBuildState(FDecoGrid&)`. *(2026-07-04)*
- [x] **`EVoxelPassageType` vs `EVoxelPassageStyle`** — ✅ **CLOSED 2026-09-04 as WONTFIX**, enacting
the 2026-08-16 judgement below, which recommended closure and was then left open by oversight. No
code change; the two enums stay.
Two overlapping passage-shape enums, both in
active use (11 refs). Consider consolidating to one. *(judgment call, not dead)*
**JUDGED 2026-08-16 — LEAVE THEM. Recommendation: close this item rather than act on it.** They
read as duplicates from the index and are not: `EVoxelPassageType` (`VoxelStrateTypes.h` ~42) is
the **global inter-strate bore shape** the layout generator picks (`SlopedTunnel` / `VerticalShaft`
/ helix…); `EVoxelPassageStyle` (~1741) is **per-strate descent styling** on
`FStratePassageConfig` (`Straight` / `Worm` / `Spiral` / `Cascading`). Different owners, different
value sets, different lifetimes. And both are `UMETA`-tagged, i.e. **serialised into Jahni's
authored strate assets** — merging them silently rewrites saved content. That is a content-risk
change bought for cosmetic tidiness, which is the wrong trade at any time and especially before
the content lock.
## Dead code
- [x] **`UVoxelMarchingCubesMesher::GetDensity()`** — removed, along with `InterpolateEdge`,
`ComputeGradientNormal` and `GradientOffset` (all dead since T1.b). *(2026-07-04)*
- [x] **Vestigial `MidPoint`** — `MidPoint`/`bHasMidPoint` fields + the unreachable debug-draw branch
removed. *(2026-07-04)*
- [x] **Codemap-known dead/legacy** — `GetLODForChunk`, `LODToStep`, `IsChunkInRange`, `LOD0Distance`,
`LOD1Distance`, `ContentMaxLevel`, `VoxelChunk.h` (whole file — `FVoxelTileKey` is the identity now)
all removed 2026-07-04. `MaxLODLevel` / `DecorationActorRadiusChunks` were already replaced by the
two-grid deco redesign (`StreamTier` / `DecorationNearRadiusChunks`).
**CORRECTION: `GetStrateChunkZBounds` is NOT dead** — `BuildDesiredTiles` uses it for the
strate-aware vertical clamp; struck from the dead list.
## Over-complexity (behavior-preserving splits, optional)
> ⛔ **REASSESSED 2026-08-16 — do not pick these up as filler work.** They were written before the
> operator-stack refactor existed, and two of them are now actively counter-productive rather than
> merely optional. Read the reason before ticking anything here.
- [ ] ~~`GetDensityWithParams` (~600-1000 L)~~ → **DON'T.** ⛔ Two independent reasons. (1) The
operator stack is *replacing* this function archetype by archetype — splitting it produces code
that gets deleted, and churns the eight equivalence tests that compare the stack against it **bit
for bit**. (2) It is the hottest path in the plugin and carries the `ARCHITECTURE §8.10`
invariants (`thread_local` box-valid caches, two-pass MC loop, SSE noise). The one time a
"behaviour-preserving" change was made here it silently deleted the overhang and only 1 sample in
20 000 crossed the isosurface — *a perf change can be a correctness change*. Revisit only once the
`switch` path is retired for good.
- [ ] ~~`BuildChunkCache` (~450 L)~~ → **DON'T, same reason.** `FRoomGraphSource` deliberately
**calls** `BuildChunkCache`/`EvaluateSDFCached` instead of transcribing them, precisely so there is
one definition. Restructuring it now forks the thing that was kept unforked on purpose.
- [ ] `GenerateMesh` (~250 L) → `PrecalcDensityGrid`/`MarchCells`/`GenerateSkirts`.
⚠️ Still genuinely optional, but the two-pass loop is an `§8.10` invariant — a split must not
merge the passes, and the Z-outermost pre-sample order is load-bearing for every column cache
downstream (see the column-memo work of 2026-08-16). Low value, non-zero risk.
- [ ] `GetGenerationParams` (~180 L) → extract `ApplyBoundaryTransition(...)`. **The safest of the
six** — pure params math, no caches, and `AUDIT §C2` already forced a close reading of both
Gradient arms. If any of these is ever worth doing, it is this one.
- [ ] `GeneratePassages` (~150 L) → `ComputePlacement`/`BuildControlChain`/`ComputeBounds`
- [ ] `BuildCellSpawns` (~150 L) → `FindSurfaceCrossings`/`PlaceDecorationsAtCrossings`
---
*Full method notes + raw per-pass output: `E:\LocalLLM\reviews\QUALITY_VoxelForge.md` (+ `QUALITY_pass1/2/3`).*
@@ -0,0 +1,597 @@
// VoxelForgeClassifyTileTest.cpp
// Phase 0.5 test #2 — LA SOLIDITÉ DE ClassifyTile / ClassifyTile soundness.
//
// ⚠️ LE TEST LE PLUS IMPORTANT DU PLUGIN / THE HIGHEST-CONSEQUENCE TEST IN THE PLUGIN.
//
// ClassifyTile (T1.d) répond "cette tuile est entièrement solide / entièrement air" AVANT tout
// échantillonnage, et sur un verdict non-Mixed le monde SAUTE GenerateMesh entièrement. Le contrat
// est asymétrique, et le commentaire de la fonction le dit déjà :
//
// un faux Mixed ne coûte que du CPU ;
// un faux AllSolid / AllAir est un TROU — pas de géométrie, PAS DE COLLISION, invisible
// jusqu'à ce qu'un joueur tombe au travers.
//
// ClassifyTile answers "this tile is entirely solid / entirely air" BEFORE any sampling, and on a
// non-Mixed verdict the world SKIPS GenerateMesh completely. The contract is asymmetric:
// a false Mixed only costs CPU; a false AllSolid/AllAir is a HOLE — no geometry, NO COLLISION,
// invisible until a player falls through it.
//
// Cette fonction a DÉJÀ produit cette panne : la v1 de T1.d a été revertée le 2026-06-26 pour une
// borne de plafond pas assez conservative. Jusqu'ici elle n'est validée que par raisonnement.
// Ce test la valide par la force brute : pour chaque tuile jugée non-Mixed, on échantillonne le
// treillis des sommets de cellules que ClassifyTile couvre réellement (g = 0..CPA) et on vérifie
// que chaque point est bien du côté annoncé. La marge extérieure du mesher sert aux normales, pas
// au verdict uniforme.
//
// This function has ALREADY produced that failure: T1.d v1 was reverted on 2026-06-26 over a
// non-conservative ceiling bound. Until now it was validated by reasoning only. This test
// validates it by brute force: for every tile judged non-Mixed, sample the exact cell-vertex
// lattice covered by ClassifyTile (g = 0..CPA) and assert every point is on the claimed side. The
// mesher's outer normal halo is deliberately outside the classifier's uniformity contract.
//
// CONVENTION (VoxelMarchingCubesMesher.cpp:309, IsoLevel == 0):
// D >= 0 ⇒ côté AIR / air side
// D < 0 ⇒ côté SOLIDE / solid side
// Le classifieur utilise exactement ces inégalités (cf. TestColumn), donc le test aussi.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelForgeTestFixture.h"
// Inclus ici DÉLIBÉRÉMENT : VoxelDensityOp.h n'est encore inclus par aucun .cpp, donc le
// compilateur ne le verrait jamais. Le fold qu'il définit prétend reproduire ClassifyTile — ce
// fichier est l'endroit naturel pour que cette prétention soit à la fois COMPILÉE et TESTÉE.
// Deliberately included here: VoxelDensityOp.h is not yet included by any .cpp, so the compiler
// would never see it. Its fold claims to reproduce ClassifyTile, so this is the natural place for
// that claim to be both compiled and tested.
#include "VoxelDensityOp.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeClassifyTileTest,
"VoxelForge.Determinism.ClassifyTileSoundness",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
/** Tuiles balayées à la recherche d'un verdict non-Mixed (ClassifyTile est bon marché). */
constexpr int32 NumTilesScanned = 600;
/** Tuiles réellement brute-forcées (chacune ~(Cells+3)³ appels à GetDensityAt — cher). */
constexpr int32 MaxTilesVerified = 24;
struct FTileSpec
{
FIntVector Origin = FIntVector::ZeroValue;
int32 Step = 1;
int32 Cells = 16;
};
}
bool FVoxelForgeClassifyTileTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
// Quelques carves : la garde diff-layer de ClassifyTile doit elle aussi être couverte, et
// c'est la garde la plus facile à casser en ajoutant une feature (elle est globale, pas
// par-archétype). / A few carves: ClassifyTile's diff-layer guard needs covering too, and it
// is the guard most easily broken by a new feature since it is global rather than per-archetype.
{
FVoxelModification Mod;
Mod.Shape = EVoxelBrushShape::Sphere;
Mod.Radius = 10.0f;
Mod.Strength = -12.0f;
for (int32 k = 0; k < 4; ++k)
{
Mod.Center = FVector((float)(k * CHUNK_SIZE * 2), 0.0f,
World.MidVoxelZ() + (float)(k * CHUNK_SIZE));
World.DiffLayer->ApplyModification(Mod);
}
}
// ── Balayage : trouver des tuiles où le classifieur ose un verdict. ──
// Les origines suivent la géométrie réelle du clipmap : une tuile couvre Step*Cells voxels et
// est alignée sur son propre pas. / Tile origins follow the real clipmap geometry: a tile
// covers Step*Cells voxels and is aligned to its own extent.
FRandomStream Rng(20260727);
TArray<FTileSpec> ToVerify;
int32 NumMixed = 0, NumAllSolid = 0, NumAllAir = 0;
const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE;
const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE;
// La moitié des tuiles vise la strate SurfaceWorld : c'est le SEUL archétype dont ClassifyTile
// sait prouver quoi que ce soit aujourd'hui (avec les gaps de bedrock), donc un tirage uniforme
// sur tout le layout gaspillerait le budget en tuiles Mixed garanties.
// Half the tiles target the SurfaceWorld strate: it is the ONLY archetype ClassifyTile can prove
// anything about today (alongside bedrock gaps), so a uniform draw over the whole layout would
// spend the budget on guaranteed-Mixed tiles.
int32 SurfTopZ = 0, SurfBotZ = 0;
const bool bHaveSurface = World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, SurfTopZ, SurfBotZ);
for (int32 t = 0; t < NumTilesScanned; ++t)
{
FTileSpec Spec;
// Step 1/2/4 comme le clipmap ; Cells petit pour que la vérification brute reste tenable.
Spec.Step = 1 << Rng.RandRange(0, 2);
Spec.Cells = (t % 8 == 0) ? CHUNK_SIZE : 16;
const int32 Extent = Spec.Step * Spec.Cells;
const bool bAimSurface = bHaveSurface && (t % 2 == 0);
const int32 LoZ = bAimSurface ? SurfBotZ : BottomVoxelZ;
const int32 HiZ = bAimSurface ? SurfTopZ : TopVoxelZ;
// Division entière PLANCHER : en C++ la troncature va vers zéro, ce qui décalerait la
// borne basse (négative) d'un extent vers le haut. / Integer FLOOR division: C++ truncates
// toward zero, which would shift the negative low bound up by one extent.
auto FloorDiv = [](int32 A, int32 B) { const int32 Q = A / B, R = A % B; return (R != 0 && (R < 0) != (B < 0)) ? Q - 1 : Q; };
const int32 LoTile = FloorDiv(LoZ, Extent);
const int32 HiTile = FMath::Max(LoTile, FloorDiv(HiZ, Extent));
Spec.Origin = FIntVector(
Rng.RandRange(-4, 4) * Extent,
Rng.RandRange(-4, 4) * Extent,
Rng.RandRange(LoTile, HiTile) * Extent);
const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
switch (Verdict)
{
case EVoxelTileClass::Mixed: ++NumMixed; break;
case EVoxelTileClass::AllSolid: ++NumAllSolid; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break;
case EVoxelTileClass::AllAir: ++NumAllAir; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break;
}
}
AddInfo(FString::Printf(
TEXT("ClassifyTile verdicts over %d scanned tiles: Mixed %d, AllSolid %d, AllAir %d ")
TEXT("(brute-forcing %d of them). Cave archetypes now use the opt-in operator-stack ")
TEXT("ClassifyBox fold; the global XY edge proof also skips proven outer-shell tiles. A low ")
TEXT("non-Mixed count is expected: every uncertain case remains Mixed for safety."),
NumTilesScanned, NumMixed, NumAllSolid, NumAllAir, ToVerify.Num()));
if (ToVerify.Num() == 0)
{
AddError(TEXT("VACUOUS: not one scanned tile produced an AllSolid/AllAir verdict, so this ")
TEXT("test verified nothing. Either the fixture's layout has no SurfaceWorld/gap ")
TEXT("chunks in the sampled Z range, or T1.d has stopped emitting verdicts entirely ")
TEXT("(which would be a large silent perf regression). Widen the Z range before ")
TEXT("trusting a green run."));
return false;
}
// ── Vérification par force brute du contrat de ClassifyTile. ──
// ClassifyTile covers actual cell vertices only: g ∈ [0, CPA]. GenerateMesh may sample a
// one-vertex outer halo for central-difference normals, but that halo cannot create a cell and
// is intentionally not part of the uniform verdict.
int32 NumHoles = 0;
for (const FTileSpec& Spec : ToVerify)
{
const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
if (Verdict == EVoxelTileClass::Mixed) { continue; } // verdict instable ⇒ rien à prouver
const int32 CPA = FMath::Clamp(Spec.Cells, 2, CHUNK_SIZE);
const int32 VertexCount = CPA + 1;
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
bool bTileBad = false;
for (int32 gz = 0; gz < VertexCount && !bTileBad; ++gz)
for (int32 gy = 0; gy < VertexCount && !bTileBad; ++gy)
for (int32 gx = 0; gx < VertexCount && !bTileBad; ++gx)
{
const float X = (float)(Spec.Origin.X + gx * Spec.Step);
const float Y = (float)(Spec.Origin.Y + gy * Spec.Step);
const float Z = (float)(Spec.Origin.Z + gz * Spec.Step);
const float D = Gen->GetDensityAt(X, Y, Z);
// AllSolid ⇒ tout le treillis doit être D < 0
// AllAir ⇒ tout le treillis doit être D >= 0
const bool bAgrees = bClaimsSolid ? (D < 0.0f) : (D >= 0.0f);
if (!bAgrees)
{
bTileBad = true;
++NumHoles;
AddError(FString::Printf(
TEXT("HOLE: ClassifyTile said %s for tile origin (%d,%d,%d) Step=%d Cells=%d, ")
TEXT("but GetDensityAt(%.0f, %.0f, %.0f) = %.6g is on the %s side. This tile ")
TEXT("would be skipped by the mesher: no triangles and NO COLLISION where there ")
TEXT("should be a surface. This point is inside ClassifyTile's actual cell ")
TEXT("lattice, so find which classifier guard failed to fire for the feature."),
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, Spec.Cells,
X, Y, Z, D, (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
}
}
}
TestEqual(TEXT("no tile was classified uniform while containing a surface (a false verdict is a hole)"),
NumHoles, 0);
// ── Stabilité du verdict : ClassifyTile partage GSurfColCache avec GetDensityAt, donc le
// brute-force ci-dessus a réchauffé les caches. Re-classifier doit rendre le MÊME verdict.
// Verdict stability: ClassifyTile shares GSurfColCache with GetDensityAt, so the brute force
// above warmed the caches. Re-classifying must yield the SAME verdict.
for (const FTileSpec& Spec : ToVerify)
{
const EVoxelTileClass A = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
const EVoxelTileClass B = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
if (A != B)
{
AddError(FString::Printf(
TEXT("UNSTABLE VERDICT at tile (%d,%d,%d) Step=%d: two consecutive ClassifyTile ")
TEXT("calls disagreed (%d vs %d). The classifier is reading state that GetDensityAt ")
TEXT("mutates — the shared column cache is the prime suspect."),
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, (int32)A, (int32)B));
}
}
return true;
}
//=============================================================================
// LE CHEMIN PILE D'OPÉRATEURS DE ClassifyTile — MÊME FORCE BRUTE, MONDE OPT-IN
//=============================================================================
// `ClassifyTile` rendait `Mixed` sans appel pour tout archétype de CAVE. Il consulte désormais
// `FVoxelOpStack::ClassifyBox` quand la strate a coché `bUseOperatorStack` — donc **un tout nouveau
// chemin peut faire sauter le maillage d'une tuile**, et son erreur est un TROU : pas de triangles,
// pas de collision, invisible jusqu'à ce qu'un joueur tombe au travers.
//
// Ce test est le même oracle par force brute que `ClassifyTileSoundness`, sur un monde dont TOUTES
// les strates ont coché la case. Il ne vérifie pas le pliage (c'est `BoxVerdictFold`) ni les
// opérateurs (ce sont les huit tests d'équivalence) : il vérifie le **câblage** — que la pile
// interrogée par le classifieur est bien celle qui produit la densité, params, drapeau et
// disturbances compris.
//
// ⚠️ LE COMPTEUR À LIRE EN PREMIER est le nombre de tuiles réellement brute-forcées. Un run vert
// avec zéro verdict non-Mixed ne prouverait RIEN — exactement le piège que ce fichier documente
// depuis sa première version, et la raison pour laquelle l'absence de verdict est une ERREUR ici.
//
// Same brute-force oracle as ClassifyTileSoundness, on a world where every strate has opted in.
// It checks the WIRING, not the fold and not the operators.
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpStackClassifyTileTest,
"VoxelForge.OpStack.ClassifyTileSoundness",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeOpStackClassifyTileTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
// ⚠️ `bUseOperatorStack = true` sur toutes les strates : c'est LE point du test. La fixture
// donne à ce monde une `LayoutVersion` unique dans le processus, sans quoi les caches par chunk
// de `GetDensityAt` — dont `CP_UseOpStack` — pourraient encore porter ceux d'un autre test.
FTestWorld World;
World.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
FRandomStream Rng(20260728);
TArray<FTileSpec> ToVerify;
int32 NumMixed = 0, NumAllSolid = 0, NumAllAir = 0;
const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE;
const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE;
// Tirage uniforme sur tout le layout, PAS biaisé vers SurfaceWorld comme l'autre test : ici ce
// sont précisément les strates de cave qui intéressent, puisque ce sont elles qui passent par le
// nouveau chemin. SurfaceWorld continue d'être prouvé par le code écrit à la main.
for (int32 t = 0; t < NumTilesScanned; ++t)
{
FTileSpec Spec;
Spec.Step = 1 << Rng.RandRange(0, 2);
Spec.Cells = (t % 8 == 0) ? CHUNK_SIZE : 16;
const int32 Extent = Spec.Step * Spec.Cells;
auto FloorDiv = [](int32 A, int32 B) { const int32 Q = A / B, R = A % B; return (R != 0 && (R < 0) != (B < 0)) ? Q - 1 : Q; };
const int32 LoTile = FloorDiv(BottomVoxelZ, Extent);
const int32 HiTile = FMath::Max(LoTile, FloorDiv(TopVoxelZ, Extent));
Spec.Origin = FIntVector(
Rng.RandRange(-4, 4) * Extent,
Rng.RandRange(-4, 4) * Extent,
Rng.RandRange(LoTile, HiTile) * Extent);
const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
switch (Verdict)
{
case EVoxelTileClass::Mixed: ++NumMixed; break;
case EVoxelTileClass::AllSolid: ++NumAllSolid; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break;
case EVoxelTileClass::AllAir: ++NumAllAir; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break;
}
}
AddInfo(FString::Printf(
TEXT("ClassifyTile ON THE OPERATOR-STACK PATH, %d scanned tiles: Mixed %d, AllSolid %d, ")
TEXT("AllAir %d (brute-forcing %d). Compare with VoxelForge.Determinism.ClassifyTileSoundness, ")
TEXT("which runs the SAME scan on a world that has NOT opted in: every verdict beyond what ")
TEXT("that test reports is a tile the mesher now skips and did not before. That difference ")
TEXT("IS the T1.d prize OPSTACK-PLAN has been aiming at -- and every one of those tiles is a ")
TEXT("hole if the wiring is wrong, which is what the brute force below is for."),
NumTilesScanned, NumMixed, NumAllSolid, NumAllAir, ToVerify.Num()));
if (ToVerify.Num() == 0)
{
AddError(TEXT("VACUOUS: not one tile got a non-Mixed verdict on the operator-stack path, so ")
TEXT("this test verified NOTHING about the new wiring. Either no strate actually ")
TEXT("opted in (check FTestWorld::Build's bUseOperatorStack), or every guard in the ")
TEXT("cave branch of ClassifyTile bailed to Mixed -- the params-identical check and ")
TEXT("the 27-chunk-coord cap are the likeliest. Do NOT read a green run as proof."));
return false;
}
int32 NumHoles = 0;
for (const FTileSpec& Spec : ToVerify)
{
const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
if (Verdict == EVoxelTileClass::Mixed) { continue; }
const int32 CPA = FMath::Clamp(Spec.Cells, 2, CHUNK_SIZE);
const int32 VertexCount = CPA + 1;
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
bool bTileBad = false;
for (int32 gz = 0; gz < VertexCount && !bTileBad; ++gz)
for (int32 gy = 0; gy < VertexCount && !bTileBad; ++gy)
for (int32 gx = 0; gx < VertexCount && !bTileBad; ++gx)
{
const float X = (float)(Spec.Origin.X + gx * Spec.Step);
const float Y = (float)(Spec.Origin.Y + gy * Spec.Step);
const float Z = (float)(Spec.Origin.Z + gz * Spec.Step);
const float D = Gen->GetDensityAt(X, Y, Z);
const bool bAgrees = bClaimsSolid ? (D < 0.0f) : (D >= 0.0f);
if (!bAgrees)
{
bTileBad = true;
++NumHoles;
AddError(FString::Printf(
TEXT("HOLE ON THE OPERATOR-STACK PATH: ClassifyTile said %s for tile (%d,%d,%d) ")
TEXT("Step=%d Cells=%d, but GetDensityAt(%.0f, %.0f, %.0f) = %.6g is on the %s ")
TEXT("side. Check, in order: (1) does GetDensityAt for this chunk actually take ")
TEXT("the stack (CP_UseOpStack), or did the classifier judge a field the mesher ")
TEXT("will not produce; (2) the params-identical check -- a blended transition ")
TEXT("band means one stack cannot represent the whole tile (AUDIT C2); (3) the ")
TEXT("disturbance fold, since disturbances are applied AFTER the stack and are ")
TEXT("not part of it; (4) an operator's EffectOverBox claiming Identity where it ")
TEXT("can act -- the per-room op override can ENABLE a modifier the strate had ")
TEXT("switched off, which makes a box bound too optimistic."),
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, Spec.Cells,
X, Y, Z, D, (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
}
}
}
TestEqual(TEXT("no tile was classified uniform on the operator-stack path while containing a ")
TEXT("surface (a false verdict is a hole)"), NumHoles, 0);
// Même contrôle de stabilité que sur l'autre chemin : la pile est reconstruite à chaque appel,
// et `FRoomGraphSource` partage un cache `thread_local` avec le chemin densité — deux appels
// successifs doivent malgré tout rendre le même verdict.
for (const FTileSpec& Spec : ToVerify)
{
const EVoxelTileClass A = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
const EVoxelTileClass B = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
if (A != B)
{
AddError(FString::Printf(
TEXT("UNSTABLE VERDICT on the operator-stack path at tile (%d,%d,%d) Step=%d: %d vs ")
TEXT("%d. The classifier builds a fresh stack per call, so a difference means an ")
TEXT("operator is reading thread_local state the density path mutates."),
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, (int32)A, (int32)B));
}
}
return true;
}
//=============================================================================
// LE FOLD DE LA PILE D'OPÉRATEURS / the op-stack fold
//=============================================================================
// `VoxelDensityOp.h` affirme que son fold reproduit le ClassifyTile écrit à la main. C'est de la
// logique pure — pas de monde, pas de bruit, pas de thread — donc elle peut être vérifiée
// exhaustivement, et elle doit l'être : c'est elle qui décidera un jour si une tuile est maillée.
//
// `VoxelDensityOp.h` claims its fold reproduces the hand-written ClassifyTile. That is pure logic —
// no world, no noise, no threads — so it can be checked exhaustively, and it should be: this is what
// will one day decide whether a tile gets meshed at all.
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpFoldTest,
"VoxelForge.OpStack.BoxVerdictFold",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeOpFoldTest::RunTest(const FString& Parameters)
{
// Un état neuf ne prouve rien ⇒ Mixed (les deux hypothèses vivantes = égalité = prudence).
{
FVoxelBoxHypotheses H;
TestEqual(TEXT("a fresh state proves nothing"), (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// Une source qui affirme un côté tue l'autre hypothèse.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid);
TestEqual(TEXT("a solid source yields AllSolid"), (int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
VF_FoldEffect(H, EVoxelOpEffect::Identity);
TestEqual(TEXT("Identity changes nothing"), (int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
}
// ≡ « AnyPassageNearBox ⇒ bCanSolid = false » : un carve tue AllSolid.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid);
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly);
TestEqual(TEXT("a passage over solid rock forces Mixed"), (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// ≡ « bande de seal ⇒ bCanAir = false » : un fill tue AllAir.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllAir);
VF_FoldEffect(H, EVoxelOpEffect::FillOnly);
TestEqual(TEXT("a fill over open air forces Mixed"), (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// LE CAS QUI JUSTIFIE ClassifyBox : au-dessus du terrain mais DANS la bande de seal supérieure,
// la source dit « tout air » et le seal FORCE « tout solide ». Aujourd'hui ClassifyTile rend
// AllSolid ici. Un simple FillOnly rendrait Mixed et perdrait la tuile.
// THE CASE THAT JUSTIFIES ClassifyBox — a pure FillOnly would lose this tile.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllAir); // source: above the terrain
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid); // seal: forcing, inside its band
TestEqual(TEXT("a forcing seal recovers AllSolid over an air source"),
(int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
// …et un passage qui traverse cette même boîte la reprend, exactement comme aujourd'hui.
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly);
TestEqual(TEXT("a passage still takes the sealed verdict back"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// Le diff layer : Both tue tout, ce qui est le comportement voulu (une édition joueur peut
// creuser OU remplir n'importe où).
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid);
VF_FoldEffect(H, EVoxelOpEffect::Both);
TestTrue(TEXT("Both kills every hypothesis"), H.IsDead());
TestEqual(TEXT("a player edit in range forces Mixed"), (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// Une source qui ne sait rien (Mixed) ne peut jamais être ressuscitée par un opérateur
// directionnel — seul un opérateur FORÇANT le peut. C'est la propriété de sûreté.
{
for (const EVoxelOpEffect E : { EVoxelOpEffect::Identity, EVoxelOpEffect::CarveOnly,
EVoxelOpEffect::FillOnly, EVoxelOpEffect::Both })
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::Mixed);
VF_FoldEffect(H, E);
TestEqual(TEXT("a directional op can never resurrect an unprovable box"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
}
//=========================================================================
// LE PLIAGE NUMÉRIQUE — `OPSTACK-DECOMPOSITION §0.2`
//=========================================================================
// La direction seule ne récupère jamais un carve FIELDÉ : il peut creuser partout, donc il rend
// `CarveOnly` partout, et c'est VRAI. Ce que la direction ignore, c'est qu'il ne peut creuser que
// de `WormStrength` au plus. Le pliage porte donc deux nombres : une MARGE posée par l'opérateur
// forçant, et une AMPLITUDE retirée par chaque carve.
//
// ⚠️ LE PREMIER CONTRÔLE CI-DESSOUS EST LE PLUS IMPORTANT : il vérifie que la rétro-compatibilité
// est réelle. Les cinq blocs au-dessus n'ont pas changé d'une ligne et doivent rester verts —
// ils appellent les mêmes fonctions sans marge ni amplitude, donc avec les défauts
// (`Margin = 0`, `MaxCarve = FLT_MAX`), qui reproduisent le comportement purement directionnel.
// 1. Les défauts REPRODUISENT l'ancien pliage — c'est ce qui rend le changement sûr.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid); // marge par défaut = 0
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly); // amplitude par défaut = FLT_MAX
TestEqual(TEXT("with default margin and amplitude, a carve still kills AllSolid"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// 2. Une amplitude INCONNUE tue même une grosse marge. « Je ne sais pas » n'est pas « zéro ».
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1000.0f);
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly); // FLT_MAX
TestEqual(TEXT("an unbounded carve kills AllSolid however solid the rock is"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// 3. LE CAS QUI JUSTIFIE TOUT : roc à 1.0, ver à 0.6 ⇒ il reste 0.4 de marge, la boîte est
// prouvablement pleine. C'est exactement `FConstantFieldSource` + `FWormFieldSource`.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1.0f); // BaseDensity
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.6f, 0.0f); // WormStrength
TestEqual(TEXT("rock solid by more than the worm can carve stays provably AllSolid"),
(int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
// …et les carves S'ACCUMULENT : un second à 0.5 fait passer la marge sous zéro.
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.5f, 0.0f);
TestEqual(TEXT("carve amplitudes accumulate until the margin runs out"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// 4. ÉGALITÉ ⇒ ON PERD LA TUILE, délibérément. Une marge de 1.0 contre un carve de 1.0 peut
// atteindre exactement zéro, et zéro est du côté AIR pour le mesher. Le test `> 0` est
// STRICT, et il doit le rester : se tromper ici ferait un trou, pas une tuile en trop.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1.0f);
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 1.0f, 0.0f);
TestEqual(TEXT("a carve exactly equal to the margin loses the tile (strict >, on purpose)"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// 5. Le miroir côté AIR : une strate d'îles flottantes, surtout vide, avec un remplissage borné.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllAir, 1.0f);
VF_FoldEffect(H, EVoxelOpEffect::FillOnly, 0.0f, 0.35f);
TestEqual(TEXT("air deeper than the fill can reach stays provably AllAir"),
(int32)H.Resolve(), (int32)EVoxelTileClass::AllAir);
}
// 6. `Both` BORNÉ : la rugosité de paroi peut aller dans les deux sens, mais pas loin. Sur du
// roc forcé, l'hypothèse AIR est déjà morte (le forçage l'a tuée) ; ce qui compte est que
// l'hypothèse SOLIDE survive à un `Both` d'amplitude connue — impossible avant ce changement,
// où `Both` tuait tout inconditionnellement.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1.0f);
VF_FoldEffect(H, EVoxelOpEffect::Both, 0.3f, 0.3f);
TestEqual(TEXT("a bounded Both no longer kills a margin it cannot cross"),
(int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
}
// 7. LA PROPRIÉTÉ DE SÛRETÉ TIENT TOUJOURS : rien de borné ne ressuscite quoi que ce soit.
// Le pliage numérique ne fait que retarder la mort d'une hypothèse, jamais l'annuler.
{
for (const EVoxelOpEffect E : { EVoxelOpEffect::Identity, EVoxelOpEffect::CarveOnly,
EVoxelOpEffect::FillOnly, EVoxelOpEffect::Both })
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::Mixed, 1000.0f); // marge ignorée sur Mixed
VF_FoldEffect(H, E, 0.0f, 0.0f); // amplitudes NULLES
TestEqual(TEXT("a zero-amplitude op cannot resurrect an unprovable box either"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
}
// 8. Une amplitude nulle est une identité prouvée : elle ne doit pas détruire une hypothèse
// AllSolid déjà forcée, même si sa marge numérique vaut zéro.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 0.0f);
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.0f, 0.0f);
TestEqual(TEXT("a proven zero carve preserves an AllSolid verdict"),
(int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,385 @@
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelClipmapDesiredTiles.h"
namespace
{
using VoxelClipmapDesiredTiles::FParameters;
struct FOverlapMeasure
{
uint64 PairCount = 0;
uint64 PairwiseVolumeChunkCubed = 0;
};
static FOverlapMeasure MeasureOverlaps(const TArray<FVoxelTileKey>& Tiles)
{
TSet<FVoxelTileKey> TileSet;
int32 MaxLevel = 0;
for (const FVoxelTileKey& Tile : Tiles)
{
TileSet.Add(Tile);
MaxLevel = FMath::Max(MaxLevel, Tile.Level);
}
FOverlapMeasure Result;
for (const FVoxelTileKey& Fine : Tiles)
{
for (int32 CoarseLevel = Fine.Level + 1; CoarseLevel <= MaxLevel; ++CoarseLevel)
{
const int32 Divisor = 1 << (CoarseLevel - Fine.Level);
const FVoxelTileKey Coarse(
VoxelClipmapDesiredTiles::FloorDiv(Fine.Coord, Divisor), CoarseLevel);
if (TileSet.Contains(Coarse))
{
++Result.PairCount;
Result.PairwiseVolumeChunkCubed += (uint64(1) << (3 * Fine.Level));
}
}
}
return Result;
}
static bool IsFootprintCovered(const FVoxelTileKey& Tile,
const TSet<FVoxelTileKey>& CoveringTiles,
int32 MaxLevel, int32 MinChunkZ, int32 MaxChunkZ)
{
const int32 TileMinZ = Tile.Coord.Z * (1 << Tile.Level);
const int32 TileMaxZ = (Tile.Coord.Z + 1) * (1 << Tile.Level) - 1;
if (TileMaxZ < MinChunkZ || TileMinZ > MaxChunkZ)
{
return true;
}
if (CoveringTiles.Contains(Tile))
{
return true;
}
// A coarser dyadic tile covers this whole footprint when it is an ancestor.
for (int32 ParentLevel = Tile.Level + 1; ParentLevel <= MaxLevel; ++ParentLevel)
{
const int32 Divisor = 1 << (ParentLevel - Tile.Level);
const FVoxelTileKey Parent(
VoxelClipmapDesiredTiles::FloorDiv(Tile.Coord, Divisor), ParentLevel);
if (CoveringTiles.Contains(Parent))
{
return true;
}
}
if (Tile.Level == 0)
{
return Tile.Coord.Z < MinChunkZ || Tile.Coord.Z > MaxChunkZ;
}
const int32 ChildLevel = Tile.Level - 1;
const FIntVector ChildBase = Tile.Coord * 2;
for (int32 Z = 0; Z < 2; ++Z)
for (int32 Y = 0; Y < 2; ++Y)
for (int32 X = 0; X < 2; ++X)
{
if (!IsFootprintCovered(
FVoxelTileKey(ChildBase + FIntVector(X, Y, Z), ChildLevel),
CoveringTiles, MaxLevel, MinChunkZ, MaxChunkZ))
{
return false;
}
}
return true;
}
static bool CoversEveryLegacyTile(const TArray<FVoxelTileKey>& Legacy,
const TArray<FVoxelTileKey>& Current,
int32 MinChunkZ, int32 MaxChunkZ)
{
TSet<FVoxelTileKey> CurrentSet;
int32 MaxLevel = 0;
for (const FVoxelTileKey& Tile : Current)
{
CurrentSet.Add(Tile);
MaxLevel = FMath::Max(MaxLevel, Tile.Level);
}
for (const FVoxelTileKey& Tile : Legacy)
{
if (!IsFootprintCovered(Tile, CurrentSet, MaxLevel, MinChunkZ, MaxChunkZ))
{
return false;
}
}
return true;
}
// Frozen copy of the 7486c29 selector. It is the coverage oracle and keeps the regression
// test able to prove that an exact tiling still covers every volume the old selector covered.
static void Build7486c29(const FIntVector& Center, int32 MinChunkZ, int32 MaxChunkZ,
const FParameters& Parameters, TArray<FVoxelTileKey>& OutTiles)
{
OutTiles.Reset();
const int32 Radius = FMath::Max(1, Parameters.ClipRadius);
const int32 MaxLevel = FMath::Clamp(Parameters.MaxClipLevel, 0, 8);
const int32 OuterRadius = VoxelClipmapDesiredTiles::OuterShellRadius(
Parameters, Radius, MaxLevel);
int32 SheetLevel = MaxLevel;
int32 SheetRadius = OuterRadius;
VoxelClipmapDesiredTiles::OuterShell(
Parameters, Radius, MaxLevel, SheetLevel, SheetRadius);
const bool bSheetRing = SheetLevel > MaxLevel;
const int32 MaxMCRingLevel = bSheetRing ? SheetLevel - 1 : MaxLevel;
for (int32 Level = 0; Level <= MaxMCRingLevel; ++Level)
{
const int32 Pow = 1 << Level;
const FIntVector CenterLevel = VoxelClipmapDesiredTiles::FloorDiv(Center, Pow);
const FIntVector CenterFiner = Level > 0
? VoxelClipmapDesiredTiles::FloorDiv(Center, Pow >> 1)
: FIntVector::ZeroValue;
const int32 LevelRadius = (Level == MaxLevel && !bSheetRing)
? OuterRadius : Radius;
int32 DeltaZMin = -LevelRadius;
int32 DeltaZMax = LevelRadius;
if (LevelRadius > Radius && MinChunkZ != MIN_int32)
{
DeltaZMin = FMath::Max(
DeltaZMin, VoxelClipmapDesiredTiles::FloorDiv(MinChunkZ, Pow) - CenterLevel.Z);
DeltaZMax = FMath::Min(
DeltaZMax, VoxelClipmapDesiredTiles::FloorDiv(MaxChunkZ, Pow) - CenterLevel.Z);
}
for (int32 DeltaZ = DeltaZMin; DeltaZ <= DeltaZMax; ++DeltaZ)
for (int32 DeltaY = -LevelRadius; DeltaY <= LevelRadius; ++DeltaY)
for (int32 DeltaX = -LevelRadius; DeltaX <= LevelRadius; ++DeltaX)
{
const FIntVector Tile = CenterLevel + FIntVector(DeltaX, DeltaY, DeltaZ);
const int32 TileMinZ = Tile.Z << Level;
const int32 TileMaxZ = ((Tile.Z + 1) << Level) - 1;
if (TileMaxZ < MinChunkZ || TileMinZ > MaxChunkZ)
{
continue;
}
if (Level > 0)
{
const bool bCovered =
(2 * Tile.X >= CenterFiner.X - Radius)
&& (2 * Tile.X + 1 <= CenterFiner.X + Radius)
&& (2 * Tile.Y >= CenterFiner.Y - Radius)
&& (2 * Tile.Y + 1 <= CenterFiner.Y + Radius)
&& (2 * Tile.Z >= CenterFiner.Z - Radius)
&& (2 * Tile.Z + 1 <= CenterFiner.Z + Radius);
if (bCovered)
{
continue;
}
}
OutTiles.Emplace(Tile, Level);
}
}
if (bSheetRing)
{
const int32 SheetPow = 1 << SheetLevel;
const FIntVector CenterSheet = VoxelClipmapDesiredTiles::FloorDiv(Center, SheetPow);
const FIntVector CenterMC = VoxelClipmapDesiredTiles::FloorDiv(
Center, 1 << MaxMCRingLevel);
const int32 LevelDelta = SheetLevel - MaxMCRingLevel;
int32 DeltaZMin = -SheetRadius;
int32 DeltaZMax = SheetRadius;
if (MinChunkZ != MIN_int32)
{
DeltaZMin = FMath::Max(DeltaZMin,
VoxelClipmapDesiredTiles::FloorDiv(MinChunkZ, SheetPow) - CenterSheet.Z);
DeltaZMax = FMath::Min(DeltaZMax,
VoxelClipmapDesiredTiles::FloorDiv(MaxChunkZ, SheetPow) - CenterSheet.Z);
}
for (int32 DeltaZ = DeltaZMin; DeltaZ <= DeltaZMax; ++DeltaZ)
for (int32 DeltaY = -SheetRadius; DeltaY <= SheetRadius; ++DeltaY)
for (int32 DeltaX = -SheetRadius; DeltaX <= SheetRadius; ++DeltaX)
{
const FIntVector Tile = CenterSheet + FIntVector(DeltaX, DeltaY, DeltaZ);
const int32 TileMinZ = Tile.Z << SheetLevel;
const int32 TileMaxZ = ((Tile.Z + 1) << SheetLevel) - 1;
if (TileMaxZ < MinChunkZ || TileMinZ > MaxChunkZ)
{
continue;
}
const bool bCovered =
((Tile.X << LevelDelta) >= CenterMC.X - Radius)
&& ((((Tile.X + 1) << LevelDelta) - 1) <= CenterMC.X + Radius)
&& ((Tile.Y << LevelDelta) >= CenterMC.Y - Radius)
&& ((((Tile.Y + 1) << LevelDelta) - 1) <= CenterMC.Y + Radius)
&& ((Tile.Z << LevelDelta) >= CenterMC.Z - Radius)
&& ((((Tile.Z + 1) << LevelDelta) - 1) <= CenterMC.Z + Radius);
if (!bCovered)
{
OutTiles.Emplace(Tile, SheetLevel);
}
}
}
}
static FString DescribeMeasure(const FOverlapMeasure& Measure)
{
return FString::Printf(TEXT("pairs=%llu pairwise_volume_chunk3=%llu"),
static_cast<unsigned long long>(Measure.PairCount),
static_cast<unsigned long long>(Measure.PairwiseVolumeChunkCubed));
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeClipmapNoOverlapTest,
"VoxelForge.Streaming.ClipmapDesiredTilesNoOverlapCoverage",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeClipmapNoOverlapTest::RunTest(const FString& Parameters)
{
(void)Parameters;
FParameters Owner;
Owner.ClipRadius = 3;
Owner.MaxClipLevel = 5;
Owner.RenderDistanceChunks = 768;
Owner.bFarSheetRing = false;
FParameters Defaults;
TArray<FVoxelTileKey> LegacyOwner, CurrentOwner, LegacyDefaults, CurrentDefaults;
Build7486c29(FIntVector::ZeroValue, MIN_int32, MAX_int32, Owner, LegacyOwner);
VoxelClipmapDesiredTiles::Build(
FIntVector::ZeroValue, MIN_int32, MAX_int32, Owner, CurrentOwner);
Build7486c29(FIntVector::ZeroValue, MIN_int32, MAX_int32, Defaults, LegacyDefaults);
VoxelClipmapDesiredTiles::Build(
FIntVector::ZeroValue, MIN_int32, MAX_int32, Defaults, CurrentDefaults);
const FOverlapMeasure LegacyOwnerOverlap = MeasureOverlaps(LegacyOwner);
const FOverlapMeasure CurrentOwnerOverlap = MeasureOverlaps(CurrentOwner);
const FOverlapMeasure LegacyDefaultOverlap = MeasureOverlaps(LegacyDefaults);
const FOverlapMeasure CurrentDefaultOverlap = MeasureOverlaps(CurrentDefaults);
AddInfo(FString::Printf(
TEXT("owner center=(0,0,0): before tiles=%d %s; after tiles=%d %s"),
LegacyOwner.Num(), *DescribeMeasure(LegacyOwnerOverlap),
CurrentOwner.Num(), *DescribeMeasure(CurrentOwnerOverlap)));
AddInfo(FString::Printf(
TEXT("FParameters defaults center=(0,0,0): before tiles=%d %s; after tiles=%d %s"),
LegacyDefaults.Num(), *DescribeMeasure(LegacyDefaultOverlap),
CurrentDefaults.Num(), *DescribeMeasure(CurrentDefaultOverlap)));
TestTrue(TEXT("7486c29 owner overlap measurement matches the checked source geometry"),
LegacyOwnerOverlap.PairCount == 635
&& LegacyOwnerOverlap.PairwiseVolumeChunkCubed == 594487);
TestTrue(TEXT("7486c29 FParameters-default overlap measurement matches the checked source geometry"),
LegacyDefaultOverlap.PairCount == 508
&& LegacyDefaultOverlap.PairwiseVolumeChunkCubed == 74295);
TestTrue(TEXT("owner desired set has no overlapping level pairs"),
CurrentOwnerOverlap.PairCount == 0 && CurrentOwnerOverlap.PairwiseVolumeChunkCubed == 0);
TestTrue(TEXT("default desired set has no overlapping level pairs"),
CurrentDefaultOverlap.PairCount == 0 && CurrentDefaultOverlap.PairwiseVolumeChunkCubed == 0);
TestTrue(TEXT("owner coverage is identical or larger than 7486c29"),
CoversEveryLegacyTile(LegacyOwner, CurrentOwner, MIN_int32, MAX_int32));
TestTrue(TEXT("default coverage is identical or larger than 7486c29"),
CoversEveryLegacyTile(LegacyDefaults, CurrentDefaults, MIN_int32, MAX_int32));
struct FSweepSettings
{
int32 Radius;
int32 MaxLevel;
int32 RenderDistance;
bool bSheet;
int32 SheetSpan;
};
static const FSweepSettings SweepSettings[] = {
{1, 0, 0, false, 2},
{2, 2, 0, false, 2},
{3, 4, 0, false, 2},
{2, 3, 49, false, 2},
{2, 3, 49, true, 2},
{2, 4, 87, true, 3},
};
int32 SweptCases = 0;
int32 OverlapCases = 0;
int32 CoverageFailures = 0;
uint64 TotalOverlapPairs = 0;
FString FirstFailure;
const int32 CenterMin = -3;
const int32 CenterMax = 3;
for (int32 Z = CenterMin; Z <= CenterMax; ++Z)
for (int32 Y = CenterMin; Y <= CenterMax; ++Y)
for (int32 X = CenterMin; X <= CenterMax; ++X)
for (const FSweepSettings& Sweep : SweepSettings)
{
FParameters P;
P.ClipRadius = Sweep.Radius;
P.MaxClipLevel = Sweep.MaxLevel;
P.RenderDistanceChunks = Sweep.RenderDistance;
P.bFarSheetRing = Sweep.bSheet;
P.FarSheetSpanLevels = Sweep.SheetSpan;
// Exercise the unbounded view, a tight local clamp, and a clamp crossing a tile boundary.
for (int32 ClampCase = 0; ClampCase < 3; ++ClampCase)
{
int32 MinZ = MIN_int32;
int32 MaxZ = MAX_int32;
if (ClampCase == 1)
{
MinZ = Z - 2;
MaxZ = Z + 3;
}
else if (ClampCase == 2)
{
const int32 Band = VoxelClipmapDesiredTiles::FloorDiv(Z, 1 << Sweep.MaxLevel);
MinZ = (Band << Sweep.MaxLevel) - 1;
MaxZ = MinZ + 6;
}
const FIntVector Center(X, Y, Z);
TArray<FVoxelTileKey> Legacy, Current;
Build7486c29(Center, MinZ, MaxZ, P, Legacy);
VoxelClipmapDesiredTiles::Build(Center, MinZ, MaxZ, P, Current);
++SweptCases;
const FOverlapMeasure CurrentOverlap = MeasureOverlaps(Current);
if (CurrentOverlap.PairCount != 0)
{
++OverlapCases;
TotalOverlapPairs += CurrentOverlap.PairCount;
if (FirstFailure.IsEmpty())
{
FirstFailure = FString::Printf(
TEXT("overlap center=(%d,%d,%d) radius=%d level=%d distance=%d sheet=%d clamp=%d: %s"),
X, Y, Z, Sweep.Radius, Sweep.MaxLevel, Sweep.RenderDistance,
Sweep.bSheet ? 1 : 0, ClampCase, *DescribeMeasure(CurrentOverlap));
}
}
if (!CoversEveryLegacyTile(Legacy, Current, MinZ, MaxZ))
{
++CoverageFailures;
if (FirstFailure.IsEmpty())
{
FirstFailure = FString::Printf(
TEXT("coverage center=(%d,%d,%d) radius=%d level=%d distance=%d sheet=%d clamp=%d"),
X, Y, Z, Sweep.Radius, Sweep.MaxLevel, Sweep.RenderDistance,
Sweep.bSheet ? 1 : 0, ClampCase);
}
}
}
}
AddInfo(FString::Printf(
TEXT("sweep cases=%d centers=343 settings=%d clamps=3 overlap_cases=%d overlap_pairs=%llu coverage_failures=%d first=%s"),
SweptCases, UE_ARRAY_COUNT(SweepSettings), OverlapCases,
static_cast<unsigned long long>(TotalOverlapPairs), CoverageFailures,
FirstFailure.IsEmpty() ? TEXT("none") : *FirstFailure));
TestEqual(TEXT("all swept desired sets have zero overlapping pairs"), OverlapCases, 0);
TestEqual(TEXT("all swept desired sets cover the legacy footprint"), CoverageFailures, 0);
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,802 @@
// Corpus-free composer measurement: current corpus blend versus independent and constrained rolls.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "HAL/PlatformTime.h"
#include "Math/RandomStream.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include "VoxelSettings.h"
#include "VoxelStrateComposer.h"
#include "VoxelStrateDefinition.h"
#include "VoxelStrateMeasure.h"
namespace
{
// 256x3 and 64x3 were attempted first. The latter exceeded 27 minutes before reaching
// its aggregate report because the fixed 8M-cell law/box checks are expensive for the
// corpus-free fields. Keep the final reduced count equal across all three arms.
constexpr int32 NumCandidates = 16;
constexpr float LargestComponentSurvivalThreshold = 0.50f;
constexpr int32 BoxChecksPerCandidate = 40;
constexpr int32 BoxStep = 1;
constexpr int32 BoxCells = 8;
const TCHAR* VF_ConnectivityName(EVoxelConnectivityResult Result)
{
switch (Result)
{
case EVoxelConnectivityResult::Connected: return TEXT("Connected");
case EVoxelConnectivityResult::NotConnectedAtThisResolution: return TEXT("NotConnected");
case EVoxelConnectivityResult::StartCellSolid: return TEXT("StartCellSolid");
case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid");
case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow");
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudget");
case EVoxelConnectivityResult::StartCellNotPlayerFit: return TEXT("StartCellNotPlayerFit");
case EVoxelConnectivityResult::GoalCellNotPlayerFit: return TEXT("GoalCellNotPlayerFit");
}
return TEXT("Unknown");
}
int32 VF_FloorDiv(int32 A, int32 B)
{
const int32 Q = A / B;
const int32 R = A % B;
return (R != 0 && (R < 0) != (B < 0)) ? Q - 1 : Q;
}
struct FBlockSpec
{
EVoxelStrateParamBlock Block;
ECaveGeneratorType Archetype;
uint32 SeedSalt;
};
static const FBlockSpec GBlockSpecs[] =
{
{ EVoxelStrateParamBlock::TunnelNetwork, ECaveGeneratorType::TunnelNetwork, 0x1001u },
{ EVoxelStrateParamBlock::Slab, ECaveGeneratorType::FlatPlain, 0x1003u },
{ EVoxelStrateParamBlock::Maze, ECaveGeneratorType::Maze, 0x1005u },
{ EVoxelStrateParamBlock::Surface, ECaveGeneratorType::SurfaceWorld, 0x1007u },
{ EVoxelStrateParamBlock::VerticalShaft, ECaveGeneratorType::VerticalShafts, 0x1009u },
{ EVoxelStrateParamBlock::FloatingIsland, ECaveGeneratorType::FloatingIslands, 0x100Bu },
};
void VF_CopyRollBlock(FVoxelStrateArchetypeParams& OutParams,
const FVoxelStrateRollInfo& Roll,
ECaveGeneratorType Archetype)
{
switch (Archetype)
{
case ECaveGeneratorType::TunnelNetwork:
case ECaveGeneratorType::Underwater:
OutParams.TunnelNetworkParams = Roll.ArchetypeParams.TunnelNetworkParams;
break;
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
OutParams.SlabParams = Roll.ArchetypeParams.SlabParams;
break;
case ECaveGeneratorType::Maze:
OutParams.MazeParams = Roll.ArchetypeParams.MazeParams;
break;
case ECaveGeneratorType::SurfaceWorld:
OutParams.SurfaceParams = Roll.ArchetypeParams.SurfaceParams;
break;
case ECaveGeneratorType::VerticalShafts:
OutParams.VerticalShaftParams = Roll.ArchetypeParams.VerticalShaftParams;
break;
case ECaveGeneratorType::FloatingIslands:
OutParams.FloatingIslandParams = Roll.ArchetypeParams.FloatingIslandParams;
break;
default:
break;
}
}
void VF_SetAllRuntimeBounds(FVoxelStrateArchetypeParams& Params,
float TopWorldZ, float BottomWorldZ)
{
Params.TunnelNetworkParams.StrateTopWorldZ = TopWorldZ;
Params.TunnelNetworkParams.StrateBottomWorldZ = BottomWorldZ;
Params.SlabParams.StrateTopWorldZ = TopWorldZ;
Params.SlabParams.StrateBottomWorldZ = BottomWorldZ;
Params.MazeParams.StrateTopWorldZ = TopWorldZ;
Params.MazeParams.StrateBottomWorldZ = BottomWorldZ;
Params.SurfaceParams.StrateTopWorldZ = TopWorldZ;
Params.SurfaceParams.StrateBottomWorldZ = BottomWorldZ;
Params.VerticalShaftParams.StrateTopWorldZ = TopWorldZ;
Params.VerticalShaftParams.StrateBottomWorldZ = BottomWorldZ;
Params.FloatingIslandParams.StrateTopWorldZ = TopWorldZ;
Params.FloatingIslandParams.StrateBottomWorldZ = BottomWorldZ;
}
class FStackDensitySampler final : public IVoxelStrateDensitySampler
{
public:
explicit FStackDensitySampler(const FVoxelOpStack& InStack) : Stack(InStack) {}
float SampleDensity(float WorldX, float WorldY, float WorldZ) const override
{
return Stack.EvalMC(WorldX, WorldY, WorldZ);
}
private:
const FVoxelOpStack& Stack;
};
struct FBoxVerdictReport
{
int32 Mixed = 0;
int32 AllSolid = 0;
int32 AllAir = 0;
int32 Proved = 0;
int64 CheckedVoxels = 0;
int32 Violations = 0;
FString FirstViolation;
};
FBoxVerdictReport VF_CheckStackBoxVerdicts(const FVoxelOpStack& Stack,
const FVoxelOpContext& Context,
int32 CandidateIndex,
int32 BottomVoxelZ,
int32 TopVoxelZ)
{
FBoxVerdictReport Report;
constexpr int32 Extent = BoxStep * BoxCells;
constexpr int32 SpanCells = 40;
const int32 LoTile = VF_FloorDiv(BottomVoxelZ, Extent);
const int32 HiTile = FMath::Max(LoTile, VF_FloorDiv(TopVoxelZ, Extent));
FRandomStream Rng(0x6C617731 ^ CandidateIndex);
for (int32 BoxIndex = 0; BoxIndex < BoxChecksPerCandidate; ++BoxIndex)
{
const FIntVector Origin(
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(LoTile, HiTile) * Extent);
const FBox Box(
FVector((float)(Origin.X - BoxStep), (float)(Origin.Y - BoxStep),
(float)(Origin.Z - BoxStep)),
FVector((float)(Origin.X + (BoxCells + 1) * BoxStep),
(float)(Origin.Y + (BoxCells + 1) * BoxStep),
(float)(Origin.Z + (BoxCells + 1) * BoxStep)));
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Context);
if (Verdict == EVoxelTileClass::Mixed)
{
++Report.Mixed;
continue;
}
if (Verdict == EVoxelTileClass::AllSolid)
{
++Report.AllSolid;
}
else
{
++Report.AllAir;
}
++Report.Proved;
const int32 GridDim = BoxCells + 1;
const bool bClaimsSolid = Verdict == EVoxelTileClass::AllSolid;
for (int32 GZ = -1; GZ <= GridDim; ++GZ)
for (int32 GY = -1; GY <= GridDim; ++GY)
for (int32 GX = -1; GX <= GridDim; ++GX)
{
const float X = (float)(Origin.X + GX * BoxStep);
const float Y = (float)(Origin.Y + GY * BoxStep);
const float Z = (float)(Origin.Z + GZ * BoxStep);
const float Density = Stack.EvalMC(X, Y, Z);
++Report.CheckedVoxels;
const bool bAgrees = bClaimsSolid ? (Density < 0.0f) : (Density >= 0.0f);
if (!bAgrees)
{
++Report.Violations;
if (Report.FirstViolation.IsEmpty())
{
Report.FirstViolation = FString::Printf(
TEXT("candidate %d box %d origin (%d,%d,%d) said %s but MC density "
"at (%.0f,%.0f,%.0f) was %.9g"),
CandidateIndex, BoxIndex, Origin.X, Origin.Y, Origin.Z,
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
X, Y, Z, Density);
}
}
}
}
return Report;
}
struct FQualityStats
{
int32 Count = 0;
float AirMin = FLT_MAX;
float AirMax = -FLT_MAX;
float WalkableMin = FLT_MAX;
float WalkableMax = -FLT_MAX;
float FeatureMin = FLT_MAX;
float FeatureMax = -FLT_MAX;
double AirSum = 0.0;
double WalkableSum = 0.0;
double FeatureSum = 0.0;
void Add(const FVoxelStrateMetrics& Metrics)
{
++Count;
AirMin = FMath::Min(AirMin, Metrics.AirFraction);
AirMax = FMath::Max(AirMax, Metrics.AirFraction);
WalkableMin = FMath::Min(WalkableMin, Metrics.WalkableFraction);
WalkableMax = FMath::Max(WalkableMax, Metrics.WalkableFraction);
FeatureMin = FMath::Min(FeatureMin, Metrics.MedianFeatureScale);
FeatureMax = FMath::Max(FeatureMax, Metrics.MedianFeatureScale);
AirSum += Metrics.AirFraction;
WalkableSum += Metrics.WalkableFraction;
FeatureSum += Metrics.MedianFeatureScale;
}
FString RangeText(float Min, float Max) const
{
return Count > 0
? FString::Printf(TEXT("%.6f..%.6f"), Min, Max)
: TEXT("n/a");
}
FString SummaryText() const
{
return Count > 0
? FString::Printf(
TEXT("n=%d air=%s (mean %.6f), walkable=%s (mean %.6f), feature=%s (mean %.6f)"),
Count, *RangeText(AirMin, AirMax), (float)(AirSum / Count),
*RangeText(WalkableMin, WalkableMax), (float)(WalkableSum / Count),
*RangeText(FeatureMin, FeatureMax), (float)(FeatureSum / Count))
: TEXT("n=0");
}
};
struct FArmStats
{
const TCHAR* Name = TEXT("unknown");
int32 Requested = 0;
int32 RollFailures = 0;
int32 BuildFailures = 0;
int32 Unmeasured = 0;
int32 Vacuous = 0;
int32 Fragmented = 0;
int32 LawFailed = 0;
int32 LawFailedObserved = 0;
int32 LawSkipped = 0;
int32 Survivors = 0;
int32 NonVacuous = 0;
int32 LargestEnough = 0;
int32 LawPasses = 0;
int32 DeterminismFailures = 0;
int32 InvalidStackCount = 0;
int32 ChannelOrderFailures = 0;
int32 ContextViolations = 0;
int32 LawResults[8] = { 0, 0, 0, 0, 0, 0, 0, 0 };
FQualityStats AllQuality;
FQualityStats SurvivorQuality;
FBoxVerdictReport Box;
FString FirstFailure;
void RememberFailure(const FString& Failure)
{
if (FirstFailure.IsEmpty())
{
FirstFailure = Failure;
}
}
FString SurvivalText() const
{
return FString::Printf(TEXT("%d/%d (%.1f%%)"), Survivors, Requested,
Requested > 0 ? 100.0f * (float)Survivors / (float)Requested : 0.0f);
}
};
int32 VF_LawResultIndex(EVoxelConnectivityResult Result)
{
switch (Result)
{
case EVoxelConnectivityResult::Connected: return 0;
case EVoxelConnectivityResult::NotConnectedAtThisResolution: return 1;
case EVoxelConnectivityResult::StartCellSolid: return 2;
case EVoxelConnectivityResult::GoalCellSolid: return 3;
case EVoxelConnectivityResult::OutOfWindow: return 4;
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return 5;
case EVoxelConnectivityResult::StartCellNotPlayerFit: return 6;
case EVoxelConnectivityResult::GoalCellNotPlayerFit: return 7;
}
return 5;
}
bool VF_BitEqual(float A, float B)
{
uint32 ABits = 0;
uint32 BBits = 0;
FMemory::Memcpy(&ABits, &A, sizeof(ABits));
FMemory::Memcpy(&BBits, &B, sizeof(BBits));
return ABits == BBits;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeComposerCorpusFreeTest,
"VoxelForge.Composer.CorpusFree",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeComposerCorpusFreeTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
(void)Parameters;
const double TestStartSeconds = FPlatformTime::Seconds();
const FString SettingsPath = TEXT("/Game/VoxelForge/DA_Settings.DA_Settings");
UVoxelSettings* AuthoredSettings = LoadObject<UVoxelSettings>(nullptr, *SettingsPath);
if (AuthoredSettings == nullptr)
{
AddError(FString::Printf(TEXT("Could not load %s; corpus-free measurement did not run."),
*SettingsPath));
return false;
}
FVoxelStrateCorpus Corpus;
FString CorpusReport;
const bool bCorpusLoaded = Corpus.LoadFromAssetRegistry(CorpusReport);
AddInfo(CorpusReport);
TestTrue(TEXT("corpus blend arm has a usable authored corpus"),
bCorpusLoaded && Corpus.IsValid());
if (!bCorpusLoaded || !Corpus.IsValid())
{
return false;
}
TestEqual(TEXT("corpus contains the four project vectors plus eight defaults"),
Corpus.GetEntries().Num(), 12);
FTestWorld World;
World.Build(1337, 2, true);
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
TestTrue(TEXT("corpus-free fixture keeps WorldRadiusVoxels at 0"),
World.Settings->WorldRadiusVoxels == 0.0f);
constexpr int32 CandidateStrateIndex = FTestWorld::SlotFlatPlain;
int32 TopVoxelZ = 0;
int32 BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(CandidateStrateIndex, TopVoxelZ, BottomVoxelZ))
{
AddError(TEXT("corpus-free fixture has no candidate Z range"));
return false;
}
const float TopWorldZ = (float)TopVoxelZ + 1.0f;
const float BottomWorldZ = (float)BottomVoxelZ;
const float StrateHeightInVoxels = TopWorldZ - BottomWorldZ;
FVector ArrivalPoint = FVector::ZeroVector;
FVector DeparturePoint = FVector::ZeroVector;
int32 ArrivalCount = 0;
int32 DepartureCount = 0;
for (const FVoxelPassage& Passage : World.StrateManager->GetPassages())
{
if (Passage.LowerStrateIndex == CandidateStrateIndex
&& Passage.UpperStrateIndex + 1 == CandidateStrateIndex)
{
ArrivalPoint = Passage.LowerPoint;
++ArrivalCount;
}
if (Passage.UpperStrateIndex == CandidateStrateIndex
&& Passage.LowerStrateIndex == CandidateStrateIndex + 1)
{
DeparturePoint = Passage.UpperPoint;
++DepartureCount;
}
}
TestEqual(TEXT("corpus-free candidate has exactly one arrival mouth"), ArrivalCount, 1);
TestEqual(TEXT("corpus-free candidate has exactly one departure mouth"), DepartureCount, 1);
FVoxelStrateMeasureSettings MeasureSettings;
MeasureSettings.SampleStep = 4;
MeasureSettings.RadiusInVoxels = 256;
MeasureSettings.CenterXY = FVector2D::ZeroVector;
MeasureSettings.MaxCells = 8000000;
MeasureSettings.MaxRouteRetries = 16;
MeasureSettings.HeadroomCells = 2;
MeasureSettings.InteriorMarginVoxels = -1;
FArmStats CorpusBlend;
CorpusBlend.Name = TEXT("corpus blend (today)");
FArmStats Naive;
Naive.Name = TEXT("naive uniform");
FArmStats Constraint;
Constraint.Name = TEXT("constraint-sampled");
int32 RecipeDeterminismFailures = 0;
int32 ConstraintParameterViolations = 0;
FString FirstConstraintParameterViolation;
int32 ConstraintBoxStopCount = 0;
FString FirstConstraintBoxViolation;
bool bStopAfterConstraintViolation = false;
const double MeasurementStartSeconds = FPlatformTime::Seconds();
for (int32 CandidateIndex = 0;
CandidateIndex < NumCandidates && !bStopAfterConstraintViolation;
++CandidateIndex)
{
const FVoxelOpStackRecipe Recipe = VF_RollStrateStructure(
AuthoredSettings->Seed, CandidateIndex);
const FVoxelOpStackRecipe RepeatRecipe = VF_RollStrateStructure(
AuthoredSettings->Seed, CandidateIndex);
if (!VF_AreStrateStructureRecipesIdentical(Recipe, RepeatRecipe))
{
++RecipeDeterminismFailures;
}
FArmStats* Arms[] = { &CorpusBlend, &Naive, &Constraint };
const EVoxelStrateCorpusFreeSamplingMode Modes[] = {
EVoxelStrateCorpusFreeSamplingMode::NaiveUniform,
EVoxelStrateCorpusFreeSamplingMode::ConstraintSampled,
};
for (int32 ArmIndex = 0; ArmIndex < 3 && !bStopAfterConstraintViolation; ++ArmIndex)
{
FArmStats& Arm = *Arms[ArmIndex];
++Arm.Requested;
FVoxelStrateArchetypeParams CandidateParams;
bool bParameterRollsValid = true;
bool bConstraintParameterViolationThisCandidate = false;
for (int32 BlockIndex = 0; BlockIndex < UE_ARRAY_COUNT(GBlockSpecs); ++BlockIndex)
{
const FBlockSpec& Spec = GBlockSpecs[BlockIndex];
const int32 BlockSeed = AuthoredSettings->Seed
^ static_cast<int32>(Spec.SeedSalt);
FVoxelStrateRollInfo BlockRoll;
if (ArmIndex == 0)
{
BlockRoll = VF_RollStrateParamsDetailedForArchetype(
Corpus, Spec.Archetype, BlockSeed, CandidateIndex);
const FVoxelStrateRollInfo RepeatRoll = VF_RollStrateParamsDetailedForArchetype(
Corpus, Spec.Archetype, BlockSeed, CandidateIndex);
if (!BlockRoll.bValid || !RepeatRoll.bValid
|| !VF_AreStrateArchetypeParamsBitIdentical(
BlockRoll.ArchetypeParams, RepeatRoll.ArchetypeParams, Spec.Archetype))
{
++Arm.DeterminismFailures;
}
}
else
{
BlockRoll = VF_RollStrateParamsCorpusFree(
Spec.Archetype, BlockSeed, CandidateIndex, Modes[ArmIndex - 1],
StrateHeightInVoxels);
const FVoxelStrateRollInfo RepeatRoll = VF_RollStrateParamsCorpusFree(
Spec.Archetype, BlockSeed, CandidateIndex, Modes[ArmIndex - 1],
StrateHeightInVoxels);
if (!BlockRoll.bValid || !RepeatRoll.bValid
|| !VF_AreStrateArchetypeParamsBitIdentical(
BlockRoll.ArchetypeParams, RepeatRoll.ArchetypeParams, Spec.Archetype))
{
++Arm.DeterminismFailures;
}
if (ArmIndex == 2 && BlockRoll.bValid)
{
FString Violation;
if (!VF_ValidateStrateCorpusFreeConstraints(
Spec.Archetype, BlockRoll.ArchetypeParams,
StrateHeightInVoxels, Violation))
{
++ConstraintParameterViolations;
if (FirstConstraintParameterViolation.IsEmpty())
{
FirstConstraintParameterViolation = FString::Printf(
TEXT("candidate %d block %d (%s): %s"), CandidateIndex,
static_cast<int32>(Spec.Block),
VF_GetStrateArchetypeName(Spec.Archetype), *Violation);
}
bParameterRollsValid = false;
bConstraintParameterViolationThisCandidate = true;
}
}
}
if (!BlockRoll.bValid)
{
bParameterRollsValid = false;
++Arm.RollFailures;
Arm.RememberFailure(FString::Printf(
TEXT("candidate %d block %d roll failed: %s"), CandidateIndex,
static_cast<int32>(Spec.Block), *BlockRoll.FailureReason));
continue;
}
VF_CopyRollBlock(CandidateParams, BlockRoll, Spec.Archetype);
}
if (!bParameterRollsValid)
{
++Arm.Unmeasured;
if (ArmIndex == 2 && bConstraintParameterViolationThisCandidate)
{
// The constraint relation itself is a precondition of this arm; do not
// spend the remaining budget hiding a violated declared relation.
bStopAfterConstraintViolation = true;
}
continue;
}
VF_SetAllRuntimeBounds(CandidateParams, TopWorldZ, BottomWorldZ);
FVoxelOpStack Stack;
FVoxelOpContext Context;
FString BuildError;
const bool bBuilt = VF_BuildStackFromRecipe(
Recipe, CandidateParams, AuthoredSettings->Seed, 14.0f,
World.StrateManager.Get(), Stack, Context, &BuildError);
if (!bBuilt)
{
++Arm.BuildFailures;
++Arm.Unmeasured;
Arm.RememberFailure(FString::Printf(
TEXT("candidate %d build failed: %s"), CandidateIndex, *BuildError));
continue;
}
if (Stack.Num() != Recipe.Modifiers.Num() + 7)
{
++Arm.InvalidStackCount;
}
FString ChannelError;
if (!Stack.ValidateChannelOrder(&ChannelError))
{
++Arm.ChannelOrderFailures;
Arm.RememberFailure(FString::Printf(
TEXT("candidate %d channel order failed: %s"), CandidateIndex, *ChannelError));
}
if (Context.WorldRadiusVoxels != 0.0f)
{
++Arm.ContextViolations;
Arm.RememberFailure(FString::Printf(
TEXT("candidate %d changed WorldRadiusVoxels to %.9g"),
CandidateIndex, Context.WorldRadiusVoxels));
}
Stack.PrepareChunk(Context);
FStackDensitySampler Sampler(Stack);
const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler(
Sampler, BottomVoxelZ, TopVoxelZ + 1,
Context.EdgeSealThickness, MeasureSettings, nullptr);
if (!Metrics.bValid)
{
++Arm.Unmeasured;
Arm.RememberFailure(FString::Printf(
TEXT("candidate %d measurement refused: %s"), CandidateIndex,
*Metrics.RefusalReason));
continue;
}
Arm.AllQuality.Add(Metrics);
const bool bNonVacuous = Metrics.NumSampled > 0
&& Metrics.NumAir > 0 && Metrics.NumSolid > 0;
const bool bLargestEnough = bNonVacuous
&& Metrics.LargestComponentShare >= LargestComponentSurvivalThreshold;
bool bLawPass = false;
if (bLargestEnough)
{
FVoxelConnectivityDiagnostics Law;
if (ArrivalCount == 1 && DepartureCount == 1)
{
Law = VF_DiagnoseConnectivityWithSampler(
Sampler, BottomVoxelZ, TopVoxelZ + 1,
Context.EdgeSealThickness, ArrivalPoint, DeparturePoint, MeasureSettings);
++Arm.LawResults[VF_LawResultIndex(Law.Result)];
}
else
{
Law.bValid = false;
Law.Result = EVoxelConnectivityResult::OutOfWindow;
++Arm.LawResults[VF_LawResultIndex(Law.Result)];
}
bLawPass = ArrivalCount == 1 && DepartureCount == 1
&& Law.bValid && Law.Result == EVoxelConnectivityResult::Connected
&& !Law.bStartSnapped && !Law.bGoalSnapped;
if (bLawPass) { ++Arm.LawPasses; }
if (!bLawPass) { ++Arm.LawFailedObserved; }
}
else
{
++Arm.LawSkipped;
}
if (bNonVacuous) { ++Arm.NonVacuous; }
if (bLargestEnough) { ++Arm.LargestEnough; }
const bool bSurvivor = bNonVacuous && bLargestEnough && bLawPass;
if (bSurvivor)
{
++Arm.Survivors;
Arm.SurvivorQuality.Add(Metrics);
}
else if (!bNonVacuous)
{
++Arm.Vacuous;
}
else if (!bLargestEnough)
{
++Arm.Fragmented;
}
else
{
++Arm.LawFailed;
}
const FBoxVerdictReport BoxReport = VF_CheckStackBoxVerdicts(
Stack, Context, CandidateIndex, BottomVoxelZ, TopVoxelZ);
Arm.Box.Mixed += BoxReport.Mixed;
Arm.Box.AllSolid += BoxReport.AllSolid;
Arm.Box.AllAir += BoxReport.AllAir;
Arm.Box.Proved += BoxReport.Proved;
Arm.Box.CheckedVoxels += BoxReport.CheckedVoxels;
Arm.Box.Violations += BoxReport.Violations;
if (Arm.Box.FirstViolation.IsEmpty() && !BoxReport.FirstViolation.IsEmpty())
{
Arm.Box.FirstViolation = BoxReport.FirstViolation;
}
if (ArmIndex == 2 && BoxReport.Violations > 0)
{
++ConstraintBoxStopCount;
bStopAfterConstraintViolation = true;
FirstConstraintBoxViolation = BoxReport.FirstViolation;
}
}
}
const double MeasurementSeconds = FPlatformTime::Seconds() - MeasurementStartSeconds;
// Audit the same declared relations against the current authored corpus vectors. The owner
// fixture currently carries four project vectors; the eight C++ defaults use the fixture's
// 8-chunk height. Keep this audit tied to membership rather than an obsolete asset count.
int32 AuthoredConstraintViolations = 0;
FString AuthoredViolationList;
for (const FVoxelStrateCorpusEntry& Entry : Corpus.GetEntries())
{
float EntryHeight = 8.0f * (float)CHUNK_SIZE;
if (!Entry.SourcePath.Contains(TEXT("/VoxelForge/ComposerDefaults/")))
{
UVoxelStrateDefinition* Definition = LoadObject<UVoxelStrateDefinition>(
nullptr, *Entry.SourcePath);
if (Definition != nullptr)
{
EntryHeight = FMath::Max(1.0f, (float)Definition->StrateHeightInChunks)
* (float)CHUNK_SIZE;
}
}
FString Violation;
if (!VF_ValidateStrateCorpusFreeConstraints(
Entry.Archetype, Entry.ArchetypeParams, EntryHeight, Violation))
{
++AuthoredConstraintViolations;
if (!AuthoredViolationList.IsEmpty())
{
AuthoredViolationList += TEXT("; ");
}
AuthoredViolationList += FString::Printf(
TEXT("%s (%s, H=%.0f): %s"), *Entry.SourceName,
VF_GetStrateArchetypeName(Entry.Archetype), EntryHeight, *Violation);
}
}
const FArmStats* Arms[] = { &CorpusBlend, &Naive, &Constraint };
AddInfo(FString::Printf(
TEXT("Corpus-free measurement: requested %d candidates per arm; structure recipes were "
"shared across all three arms; sample step=%d radius=%d MaxCells=%d, fixed law mouths, "
"criterion precedence is vacuous > fragmented > law failed. Runtime %.3fs, roll/measure "
"%.3fs."),
NumCandidates, MeasureSettings.SampleStep, MeasureSettings.RadiusInVoxels,
MeasureSettings.MaxCells, FPlatformTime::Seconds() - TestStartSeconds, MeasurementSeconds));
FString Table = TEXT(
"approach | survival | vacuous | fragmented | law failed | air range | walkable range | feature scale range\n");
FString SurvivorTable = TEXT(
"survivor quality (ranges are over survivors only; main table ranges are over all measured candidates)\n"
"approach | survivors | air range (mean) | walkable range (mean) | feature scale range (mean)\n");
for (const FArmStats* Arm : Arms)
{
Table += FString::Printf(
TEXT("%s | %s | %d | %d | %d | %s | %s | %s\n"),
Arm->Name, *Arm->SurvivalText(), Arm->Vacuous, Arm->Fragmented, Arm->LawFailed,
*Arm->AllQuality.RangeText(Arm->AllQuality.AirMin, Arm->AllQuality.AirMax),
*Arm->AllQuality.RangeText(Arm->AllQuality.WalkableMin, Arm->AllQuality.WalkableMax),
*Arm->AllQuality.RangeText(Arm->AllQuality.FeatureMin, Arm->AllQuality.FeatureMax));
SurvivorTable += FString::Printf(
TEXT("%s | %d | %s | %s | %s\n"),
Arm->Name, Arm->SurvivorQuality.Count,
*Arm->SurvivorQuality.RangeText(Arm->SurvivorQuality.AirMin, Arm->SurvivorQuality.AirMax),
*Arm->SurvivorQuality.RangeText(Arm->SurvivorQuality.WalkableMin, Arm->SurvivorQuality.WalkableMax),
*Arm->SurvivorQuality.RangeText(Arm->SurvivorQuality.FeatureMin, Arm->SurvivorQuality.FeatureMax));
}
AddInfo(Table);
AddInfo(SurvivorTable);
for (const FArmStats* Arm : Arms)
{
AddInfo(FString::Printf(
TEXT("%s: non-vacuous=%d, largest>=%.2f=%d, law pass=%d, law failures observed=%d, "
"law skipped=%d, "
"roll failures=%d, build failures=%d, unmeasured=%d, deterministic roll failures=%d; "
"stack-count failures=%d, channel-order failures=%d, context violations=%d; "
"law results Connected=%d NotConnected=%d StartSolid=%d GoalSolid=%d OutOfWindow=%d Budget=%d "
"StartNotPlayerFit=%d GoalNotPlayerFit=%d; "
"quality all %s; survivor quality %s; first failure=%s."),
Arm->Name, Arm->NonVacuous, LargestComponentSurvivalThreshold, Arm->LargestEnough,
Arm->LawPasses, Arm->LawFailedObserved, Arm->LawSkipped,
Arm->RollFailures, Arm->BuildFailures,
Arm->Unmeasured, Arm->DeterminismFailures,
Arm->InvalidStackCount, Arm->ChannelOrderFailures, Arm->ContextViolations,
Arm->LawResults[0], Arm->LawResults[1], Arm->LawResults[2],
Arm->LawResults[3], Arm->LawResults[4], Arm->LawResults[5],
Arm->LawResults[6], Arm->LawResults[7],
*Arm->AllQuality.SummaryText(), *Arm->SurvivorQuality.SummaryText(),
Arm->FirstFailure.IsEmpty() ? TEXT("none") : *Arm->FirstFailure));
AddInfo(FString::Printf(
TEXT("%s box verdicts: mixed=%d AllSolid=%d AllAir=%d proved=%d voxels checked=%lld "
"violations=%d; %s"),
Arm->Name, Arm->Box.Mixed, Arm->Box.AllSolid, Arm->Box.AllAir, Arm->Box.Proved,
Arm->Box.CheckedVoxels, Arm->Box.Violations,
Arm->Box.FirstViolation.IsEmpty() ? TEXT("no violations") : *Arm->Box.FirstViolation));
}
AddInfo(FString::Printf(
TEXT("Authored-corpus constraint audit: %d/%d vectors violated at their declared strate "
"height. %s"),
AuthoredConstraintViolations, Corpus.GetEntries().Num(),
AuthoredViolationList.IsEmpty() ? TEXT("none") : *AuthoredViolationList));
AddInfo(FString::Printf(
TEXT("Shared structure determinism failures=%d; per-arm channel/context violations are "
"reported below; constraint parameter violations=%d; constraint box-stop count=%d. %s%s"),
RecipeDeterminismFailures,
ConstraintParameterViolations, ConstraintBoxStopCount,
FirstConstraintParameterViolation.IsEmpty() ? TEXT("") : *FirstConstraintParameterViolation,
FirstConstraintBoxViolation.IsEmpty() ? TEXT("")
: *FString::Printf(TEXT("; first box violation: %s"), *FirstConstraintBoxViolation)));
TestEqual(TEXT("configured candidate count requested for corpus blend"), CorpusBlend.Requested, NumCandidates);
TestEqual(TEXT("configured candidate count requested for naive uniform"), Naive.Requested, NumCandidates);
TestEqual(TEXT("configured candidate count requested for constraint-sampled"), Constraint.Requested, NumCandidates);
TestEqual(TEXT("corpus blend roll failures"), CorpusBlend.RollFailures, 0);
TestEqual(TEXT("naive uniform roll failures"), Naive.RollFailures, 0);
TestEqual(TEXT("constraint-sampled roll failures"), Constraint.RollFailures, 0);
TestEqual(TEXT("corpus blend build failures"), CorpusBlend.BuildFailures, 0);
TestEqual(TEXT("naive uniform build failures"), Naive.BuildFailures, 0);
TestEqual(TEXT("constraint-sampled build failures"), Constraint.BuildFailures, 0);
TestEqual(TEXT("corpus-free parameter determinism failures"),
Naive.DeterminismFailures + Constraint.DeterminismFailures, 0);
TestEqual(TEXT("corpus parameter determinism failures"), CorpusBlend.DeterminismFailures, 0);
TestEqual(TEXT("shared structure recipe determinism failures"), RecipeDeterminismFailures, 0);
TestEqual(TEXT("constraint-sampled declared-parameter violations"),
ConstraintParameterViolations, 0);
TestEqual(TEXT("constraint-sampled box verdict violations"),
Constraint.Box.Violations, 0);
TestEqual(TEXT("corpus blend box verdict violations"), CorpusBlend.Box.Violations, 0);
TestEqual(TEXT("naive uniform box verdict violations"), Naive.Box.Violations, 0);
TestEqual(TEXT("corpus blend WorldRadiusVoxels/context violations"),
CorpusBlend.InvalidStackCount + CorpusBlend.ChannelOrderFailures
+ CorpusBlend.ContextViolations, 0);
TestEqual(TEXT("naive uniform WorldRadiusVoxels/context violations"),
Naive.InvalidStackCount + Naive.ChannelOrderFailures + Naive.ContextViolations, 0);
TestEqual(TEXT("constraint-sampled WorldRadiusVoxels/context violations"),
Constraint.InvalidStackCount + Constraint.ChannelOrderFailures
+ Constraint.ContextViolations, 0);
TestTrue(TEXT("constraint sampler did not stop early on a declared relation or box violation"),
!bStopAfterConstraintViolation);
const double TestSeconds = FPlatformTime::Seconds() - TestStartSeconds;
AddInfo(FString::Printf(TEXT("CorpusFree total runtime %.3fs; all three arms requested %d each; "
"WorldRadiusVoxels=0."), TestSeconds, NumCandidates));
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,721 @@
// Tier 5 — promotion, corpus persistence, re-verification, and compounding simulation.
#if WITH_DEV_AUTOMATION_TESTS && WITH_EDITOR
#include "Misc/AutomationTest.h"
#include "HAL/PlatformTime.h"
#include "Math/RandomStream.h"
#include "Misc/Paths.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include "VoxelSettings.h"
#include "VoxelStrateComposer.h"
#include "VoxelStrateMeasure.h"
namespace
{
constexpr int32 NumSimulatedSeasons = 5;
constexpr int32 CandidatesPerSeason = 24;
constexpr int32 PromotionCapPerSeason = 6;
constexpr float LargestComponentSurvivalThreshold = 0.50f;
constexpr int32 SimulationSampleStep = 8;
constexpr int32 SimulationRadius = 256;
const TCHAR* VF_ConnectivityName(EVoxelConnectivityResult Result)
{
switch (Result)
{
case EVoxelConnectivityResult::Connected: return TEXT("Connected");
case EVoxelConnectivityResult::NotConnectedAtThisResolution: return TEXT("NotConnected");
case EVoxelConnectivityResult::StartCellSolid: return TEXT("StartCellSolid");
case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid");
case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow");
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudget");
case EVoxelConnectivityResult::StartCellNotPlayerFit: return TEXT("StartCellNotPlayerFit");
case EVoxelConnectivityResult::GoalCellNotPlayerFit: return TEXT("GoalCellNotPlayerFit");
}
return TEXT("Unknown");
}
void VF_SetAllRuntimeBounds(FVoxelStrateArchetypeParams& Params,
float TopWorldZ, float BottomWorldZ)
{
Params.TunnelNetworkParams.StrateTopWorldZ = TopWorldZ;
Params.TunnelNetworkParams.StrateBottomWorldZ = BottomWorldZ;
Params.SlabParams.StrateTopWorldZ = TopWorldZ;
Params.SlabParams.StrateBottomWorldZ = BottomWorldZ;
Params.MazeParams.StrateTopWorldZ = TopWorldZ;
Params.MazeParams.StrateBottomWorldZ = BottomWorldZ;
Params.SurfaceParams.StrateTopWorldZ = TopWorldZ;
Params.SurfaceParams.StrateBottomWorldZ = BottomWorldZ;
Params.VerticalShaftParams.StrateTopWorldZ = TopWorldZ;
Params.VerticalShaftParams.StrateBottomWorldZ = BottomWorldZ;
Params.FloatingIslandParams.StrateTopWorldZ = TopWorldZ;
Params.FloatingIslandParams.StrateBottomWorldZ = BottomWorldZ;
}
float VF_GetBoundarySeal(const FVoxelStrateArchetypeParams& Params,
ECaveGeneratorType Archetype)
{
switch (Archetype)
{
case ECaveGeneratorType::TunnelNetwork:
case ECaveGeneratorType::Underwater:
return Params.TunnelNetworkParams.BoundarySealThickness;
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
return Params.SlabParams.BoundarySealThickness;
case ECaveGeneratorType::Maze:
return Params.MazeParams.BoundarySealThickness;
case ECaveGeneratorType::SurfaceWorld:
return Params.SurfaceParams.BoundarySealThickness;
case ECaveGeneratorType::VerticalShafts:
return Params.VerticalShaftParams.BoundarySealThickness;
case ECaveGeneratorType::FloatingIslands:
return Params.FloatingIslandParams.BoundarySealThickness;
default:
return 0.0f;
}
}
ECaveGeneratorType VF_ArchetypeForRecipe(const FVoxelOpStackRecipe& Recipe)
{
switch (Recipe.StructuralParamBlock)
{
case EVoxelStrateParamBlock::TunnelNetwork: return ECaveGeneratorType::TunnelNetwork;
case EVoxelStrateParamBlock::Slab: return ECaveGeneratorType::FlatPlain;
case EVoxelStrateParamBlock::Maze: return ECaveGeneratorType::Maze;
case EVoxelStrateParamBlock::Surface: return ECaveGeneratorType::SurfaceWorld;
case EVoxelStrateParamBlock::VerticalShaft: return ECaveGeneratorType::VerticalShafts;
case EVoxelStrateParamBlock::FloatingIsland: return ECaveGeneratorType::FloatingIslands;
default: return ECaveGeneratorType::TunnelNetwork;
}
}
class FStackDensitySampler final : public IVoxelStrateDensitySampler
{
public:
explicit FStackDensitySampler(const FVoxelOpStack& InStack) : Stack(InStack) {}
float SampleDensity(float WorldX, float WorldY, float WorldZ) const override
{
return Stack.EvalMC(WorldX, WorldY, WorldZ);
}
private:
const FVoxelOpStack& Stack;
};
class FPromotionVerifier final : public IVoxelStratePromotionVerifier
{
public:
FPromotionVerifier(const VoxelForgeTest::FTestWorld& InWorld,
int32 InTopVoxelZ,
int32 InBottomVoxelZ,
const FVector& InArrivalPoint,
const FVector& InDeparturePoint,
const FVoxelStrateMeasureSettings& InMeasureSettings,
float InLargestThreshold)
: World(InWorld)
, TopVoxelZ(InTopVoxelZ)
, BottomVoxelZ(InBottomVoxelZ)
, ArrivalPoint(InArrivalPoint)
, DeparturePoint(InDeparturePoint)
, MeasureSettings(InMeasureSettings)
, LargestThreshold(InLargestThreshold)
{
}
bool Verify(const FVoxelStratePromotableRecord& Record,
FVoxelStratePromotionVerification& OutVerification) const override
{
++NumVerifications;
OutVerification = FVoxelStratePromotionVerification();
if (Record.Archetype != VF_ArchetypeForRecipe(Record.Recipe))
{
OutVerification.FailureReason = TEXT("record archetype does not match recipe post block");
return false;
}
FVoxelStrateArchetypeParams Params = Record.Params;
VF_SetAllRuntimeBounds(Params, static_cast<float>(TopVoxelZ) + 1.0f,
static_cast<float>(BottomVoxelZ));
FVoxelOpStack Stack;
FVoxelOpContext Context;
FString BuildError;
if (!VF_BuildStackFromRecipe(
Record.Recipe, Params, Record.Seed, 14.0f,
World.StrateManager.Get(), Stack, Context, &BuildError))
{
OutVerification.FailureReason = FString::Printf(
TEXT("recipe rebuild failed: %s"), *BuildError);
return false;
}
if (Context.WorldRadiusVoxels != 0.0f)
{
OutVerification.FailureReason = TEXT("re-verified recipe changed WorldRadiusVoxels");
return false;
}
Stack.PrepareChunk(Context);
FStackDensitySampler Sampler(Stack);
const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler(
Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness,
MeasureSettings, nullptr);
OutVerification.MeasuredMetrics = VF_SummarizeStrateMetrics(Metrics);
OutVerification.bPassedNonVacuous = Metrics.bValid
&& Metrics.NumSampled > 0 && Metrics.NumAir > 0 && Metrics.NumSolid > 0;
OutVerification.bPassedLargestComponent = OutVerification.bPassedNonVacuous
&& Metrics.LargestComponentShare >= LargestThreshold;
const FVoxelConnectivityDiagnostics Law = VF_DiagnoseConnectivityWithSampler(
Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness,
ArrivalPoint, DeparturePoint, MeasureSettings);
OutVerification.bPassedPrimordialLaw = Law.bValid
&& Law.Result == EVoxelConnectivityResult::Connected
&& !Law.bStartSnapped && !Law.bGoalSnapped;
if (!OutVerification.PassedAllGates())
{
OutVerification.FailureReason = FString::Printf(
TEXT("fresh gates failed: non-vacuous=%s largest=%s law=%s"),
OutVerification.bPassedNonVacuous ? TEXT("yes") : TEXT("no"),
OutVerification.bPassedLargestComponent ? TEXT("yes") : TEXT("no"),
VF_ConnectivityName(Law.Result));
}
return true;
}
bool MeasureCorpusEntry(const FVoxelStrateCorpusEntry& Entry,
FVoxelStrateMeasuredMetrics& OutMetrics) const override;
mutable int32 NumVerifications = 0;
private:
const VoxelForgeTest::FTestWorld& World;
int32 TopVoxelZ = 0;
int32 BottomVoxelZ = 0;
FVector ArrivalPoint = FVector::ZeroVector;
FVector DeparturePoint = FVector::ZeroVector;
FVoxelStrateMeasureSettings MeasureSettings;
float LargestThreshold = 0.50f;
};
bool VF_MeasureNativeCorpusEntry(
const VoxelForgeTest::FTestWorld& World,
const FVoxelStrateCorpusEntry& Entry,
int32 TopVoxelZ,
int32 BottomVoxelZ,
const FVoxelStrateMeasureSettings& Settings,
FVoxelStrateMeasuredMetrics& OutMetrics)
{
FVoxelStrateArchetypeParams Params = Entry.ArchetypeParams;
VF_SetAllRuntimeBounds(Params, static_cast<float>(TopVoxelZ) + 1.0f,
static_cast<float>(BottomVoxelZ));
FVoxelOpStack Stack;
FVoxelOpContext Context;
if (!VF_BuildNativeStrateStackForCandidate(
Entry.Archetype, Params, 1337, 14.0f, 0.0f,
VF_GetBoundarySeal(Params, Entry.Archetype),
World.StrateManager.Get(), Stack, Context)
|| Context.WorldRadiusVoxels != 0.0f)
{
return false;
}
Stack.PrepareChunk(Context);
FStackDensitySampler Sampler(Stack);
const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler(
Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness,
Settings, nullptr);
OutMetrics = VF_SummarizeStrateMetrics(Metrics);
return Metrics.bValid;
}
bool FPromotionVerifier::MeasureCorpusEntry(
const FVoxelStrateCorpusEntry& Entry,
FVoxelStrateMeasuredMetrics& OutMetrics) const
{
return VF_MeasureNativeCorpusEntry(
World, Entry, TopVoxelZ, BottomVoxelZ, MeasureSettings, OutMetrics);
}
FVoxelStratePromotableRecord VF_EvaluateCandidate(
const VoxelForgeTest::FTestWorld& World,
const FVoxelStrateComposerCandidate& Candidate,
int32 Season,
uint32 CorpusHash,
int32 TopVoxelZ,
int32 BottomVoxelZ,
const FVector& ArrivalPoint,
const FVector& DeparturePoint,
const FVoxelStrateMeasureSettings& Settings,
float LargestThreshold,
bool& bOutSurvivor)
{
bOutSurvivor = false;
FVoxelStratePromotableRecord Record = VF_MakeStratePromotableRecord(
Candidate, FVoxelStrateMetrics(), Season, CorpusHash, false, false, false);
if (!Candidate.bValid)
{
return Record;
}
FVoxelStrateArchetypeParams Params = Candidate.ArchetypeParams;
VF_SetAllRuntimeBounds(Params, static_cast<float>(TopVoxelZ) + 1.0f,
static_cast<float>(BottomVoxelZ));
Record.Params = Params;
FVoxelOpStack Stack;
FVoxelOpContext Context;
FString BuildError;
if (!VF_BuildStackFromRecipe(
Candidate.Recipe, Params, Candidate.Seed, 14.0f,
World.StrateManager.Get(), Stack, Context, &BuildError)
|| Context.WorldRadiusVoxels != 0.0f)
{
return Record;
}
Stack.PrepareChunk(Context);
FStackDensitySampler Sampler(Stack);
const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler(
Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness,
Settings, nullptr);
const bool bNonVacuous = Metrics.bValid
&& Metrics.NumSampled > 0 && Metrics.NumAir > 0 && Metrics.NumSolid > 0;
const bool bLargestEnough = bNonVacuous
&& Metrics.LargestComponentShare >= LargestThreshold;
const FVoxelConnectivityDiagnostics Law = VF_DiagnoseConnectivityWithSampler(
Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness,
ArrivalPoint, DeparturePoint, Settings);
const bool bLawPass = Law.bValid
&& Law.Result == EVoxelConnectivityResult::Connected
&& !Law.bStartSnapped && !Law.bGoalSnapped;
Record = VF_MakeStratePromotableRecord(
Candidate, Metrics, Season, CorpusHash,
bNonVacuous, bLargestEnough, bLawPass);
Record.Params = Params;
bOutSurvivor = bNonVacuous && bLargestEnough && bLawPass;
return Record;
}
double VF_MeanMeasuredSpread(const FVoxelStrateCorpus& Corpus,
const TArray<FVoxelStratePromotableRecord>& NewRecords,
const FVoxelStratePromotionPolicy& Policy)
{
TArray<FVoxelStrateMeasuredMetrics> Metrics;
for (const FVoxelStrateCorpusEntry& Entry : Corpus.GetEntries())
{
if (Entry.bHasMeasuredMetrics && Entry.MeasuredMetrics.IsUsable())
{
Metrics.Add(Entry.MeasuredMetrics);
}
}
for (const FVoxelStratePromotableRecord& Record : NewRecords)
{
if (Record.MeasuredMetrics.IsUsable())
{
Metrics.Add(Record.MeasuredMetrics);
}
}
if (Metrics.Num() < 2)
{
return 0.0;
}
double Sum = 0.0;
int64 PairCount = 0;
for (int32 A = 0; A < Metrics.Num(); ++A)
{
for (int32 B = A + 1; B < Metrics.Num(); ++B)
{
const double Distance = VF_NormalizedMeasuredMetricDistance(
Metrics[A], Metrics[B], Policy);
if (FMath::IsFinite(Distance) && Distance < TNumericLimits<double>::Max())
{
Sum += Distance;
++PairCount;
}
}
}
return PairCount > 0 ? Sum / static_cast<double>(PairCount) : 0.0;
}
bool VF_PromotionBatchesIdentical(
const FVoxelStratePromotionBatchResult& A,
const FVoxelStratePromotionBatchResult& B)
{
if (A.Promoted.Num() != B.Promoted.Num())
{
return false;
}
for (int32 Index = 0; Index < A.Promoted.Num(); ++Index)
{
const FVoxelStratePromotableRecord& Left = A.Promoted[Index];
const FVoxelStratePromotableRecord& Right = B.Promoted[Index];
if (Left.RecordId != Right.RecordId
|| Left.Archetype != Right.Archetype
|| !VF_AreStrateStructureRecipesIdentical(Left.Recipe, Right.Recipe)
|| !VF_AreStrateArchetypeParamsBitIdentical(
Left.Params, Right.Params, Left.Archetype)
|| Left.MeasuredMetrics.AirFraction != Right.MeasuredMetrics.AirFraction
|| Left.MeasuredMetrics.LargestComponentShare
!= Right.MeasuredMetrics.LargestComponentShare)
{
return false;
}
}
return true;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeComposerPromotionTest,
"VoxelForge.Composer.Promotion",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeComposerPromotionTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
(void)Parameters;
const double TestStartSeconds = FPlatformTime::Seconds();
UVoxelSettings* AuthoredSettings = LoadObject<UVoxelSettings>(
nullptr, TEXT("/Game/VoxelForge/DA_Settings.DA_Settings"));
if (AuthoredSettings == nullptr)
{
AddError(TEXT("Could not load authored settings; promotion simulation did not run."));
return false;
}
FTestWorld World;
World.Build(1337, 2, true);
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
TestTrue(TEXT("promotion fixture keeps WorldRadiusVoxels at 0"),
World.Settings->WorldRadiusVoxels == 0.0f);
constexpr int32 CandidateStrateIndex = FTestWorld::SlotFlatPlain;
int32 TopVoxelZ = 0;
int32 BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(CandidateStrateIndex, TopVoxelZ, BottomVoxelZ))
{
AddError(TEXT("promotion fixture has no candidate Z range"));
return false;
}
FVector ArrivalPoint = FVector::ZeroVector;
FVector DeparturePoint = FVector::ZeroVector;
int32 ArrivalCount = 0;
int32 DepartureCount = 0;
for (const FVoxelPassage& Passage : World.StrateManager->GetPassages())
{
if (Passage.LowerStrateIndex == CandidateStrateIndex
&& Passage.UpperStrateIndex + 1 == CandidateStrateIndex)
{
// The promotion simulation intentionally remains the legacy coarse air-law probe
// (SampleStep=8), not the Tier-1 fine player-fit law. A floor standing anchor can lie
// in the solid half of a coarse cell after the landing adds its support slab; the
// deterministic door anchor is the actual air position where the tube meets that
// landing and keeps the strict no-snap assertion meaningful.
ArrivalPoint = Passage.LowerLanding.DoorPoint;
++ArrivalCount;
}
if (Passage.UpperStrateIndex == CandidateStrateIndex
&& Passage.LowerStrateIndex == CandidateStrateIndex + 1)
{
DeparturePoint = Passage.UpperLanding.DoorPoint;
++DepartureCount;
}
}
TestEqual(TEXT("promotion fixture has exactly one arrival mouth"), ArrivalCount, 1);
TestEqual(TEXT("promotion fixture has exactly one departure mouth"), DepartureCount, 1);
if (ArrivalCount != 1 || DepartureCount != 1)
{
return false;
}
FVoxelStrateMeasureSettings MeasureSettings;
MeasureSettings.SampleStep = SimulationSampleStep;
MeasureSettings.RadiusInVoxels = SimulationRadius;
MeasureSettings.CenterXY = FVector2D::ZeroVector;
MeasureSettings.MaxCells = 8000000;
MeasureSettings.MaxRouteRetries = 16;
MeasureSettings.HeadroomCells = 2;
// Use one fixed window for both authored/default members and candidates. Deriving a margin
// from each member's boundary seal can make a small authored strate have no measurable
// interior, which would make the duplicate metric space member-dependent.
MeasureSettings.InteriorMarginVoxels = 0;
FPromotionVerifier Verifier(
World, TopVoxelZ, BottomVoxelZ, ArrivalPoint, DeparturePoint,
MeasureSettings, LargestComponentSurvivalThreshold);
// The fixed path is keyed by the authored corpus hash and simulation constants. Starting with
// an empty JSON object makes reruns independent of an older test artifact without deleting it.
const FString SimulationDirectory = FPaths::ProjectSavedDir() / TEXT("ComposerPromotion")
/ FString::Printf(TEXT("seed_%d_step_%d_radius_%d"),
AuthoredSettings->Seed, SimulationSampleStep, SimulationRadius);
const FString PromotedStorePath = SimulationDirectory / TEXT("promoted_strates.json");
const FString CorruptStorePath = SimulationDirectory / TEXT("corrupt_promoted_strates.json");
FString StoreReport;
TestTrue(TEXT("promotion simulation can initialise its empty JSON store"),
VF_SaveStratePromotedRecords(
PromotedStorePath, TArray<FVoxelStratePromotableRecord>(), StoreReport));
AddInfo(StoreReport);
FVoxelStrateCorpus BaseCorpus;
FString BaseReport;
TestTrue(TEXT("promotion simulation loads its base project/default corpus"),
BaseCorpus.LoadFromAssetRegistry(BaseReport, PromotedStorePath, &Verifier));
AddInfo(BaseReport);
if (!BaseCorpus.IsValid())
{
AddError(TEXT("base corpus was invalid; promotion simulation did not run."));
return false;
}
TMap<FString, FVoxelStrateMeasuredMetrics> BaseMetrics;
int32 NumVacuousBaseMetrics = 0;
for (const FVoxelStrateCorpusEntry& Entry : BaseCorpus.GetEntries())
{
if (Entry.Provenance == EVoxelStrateCorpusProvenance::Promoted)
{
continue;
}
TestTrue(FString::Printf(TEXT("base member %s is measured before policy selection"),
*Entry.SourcePath),
Entry.bHasMeasuredMetrics && Entry.MeasuredMetrics.bValid);
if (Entry.bHasMeasuredMetrics && !Entry.MeasuredMetrics.IsUsable())
{
++NumVacuousBaseMetrics;
AddInfo(FString::Printf(
TEXT("base member %s measured but vacuous in the fixed promotion window: "
"air=%lld solid=%lld components=%d"),
*Entry.SourcePath, Entry.MeasuredMetrics.NumAir,
Entry.MeasuredMetrics.NumSolid, Entry.MeasuredMetrics.NumAirComponents));
}
if (Entry.bHasMeasuredMetrics && Entry.MeasuredMetrics.IsUsable())
{
BaseMetrics.Add(Entry.SourcePath, Entry.MeasuredMetrics);
}
}
TestTrue(TEXT("base project/default members have measured metrics for spread"),
BaseMetrics.Num() > 0);
AddInfo(FString::Printf(
TEXT("Base metric audit for duplicate policy: %d usable members, %d measured-but-vacuous "
"members; vacuous members use exact parameter identity as the conservative fallback."),
BaseMetrics.Num(), NumVacuousBaseMetrics));
FVoxelStratePromotionPolicy Policy;
Policy.MinimumNormalizedMeasuredMetricDistance = 0.20;
Policy.MaxPromotionsPerSeason = PromotionCapPerSeason;
Policy.FeatureScaleNormalizationVoxels = 256.0f;
Policy.ClearanceNormalizationVoxels = 64.0f;
TArray<FVoxelStratePromotableRecord> AllPromoted;
FString SeasonTable = TEXT(
"season | input corpus | corpus after | project/default/promoted | survivors/candidates "
"| survival | measured spread | promoted | near-dup/hash/cap\n");
double FirstSpread = -1.0;
double LastSpread = -1.0;
int32 NumSeasonLoadsWithPromotions = 0;
for (int32 Season = 1; Season <= NumSimulatedSeasons; ++Season)
{
FVoxelStrateCorpus Corpus;
FString CorpusReport;
const bool bLoaded = Corpus.LoadFromAssetRegistry(
CorpusReport, PromotedStorePath, &Verifier);
AddInfo(FString::Printf(TEXT("Season %d corpus load: %s"), Season, *CorpusReport));
TestTrue(FString::Printf(TEXT("season %d reloads the cumulative corpus safely"), Season),
bLoaded && Corpus.IsValid());
if (!bLoaded || !Corpus.IsValid())
{
return false;
}
for (const TPair<FString, FVoxelStrateMeasuredMetrics>& Pair : BaseMetrics)
{
TestTrue(FString::Printf(TEXT("season %d restores base metric %s"),
Season, *Pair.Key),
Corpus.SetMeasuredMetrics(Pair.Key, Pair.Value));
}
if (AllPromoted.Num() > 0)
{
++NumSeasonLoadsWithPromotions;
TestTrue(FString::Printf(TEXT("season %d re-verifies every stored promotion"), Season),
Verifier.NumVerifications >= AllPromoted.Num());
}
const uint32 InputCorpusHash = Corpus.GetContentsHash();
const uint32 SeasonSeedBits = static_cast<uint32>(AuthoredSettings->Seed)
^ (static_cast<uint32>(Season) * 0x9E3779B9u);
const int32 SeasonSeed = static_cast<int32>(SeasonSeedBits);
TArray<FVoxelStratePromotableRecord> CandidateRecords;
CandidateRecords.Reserve(CandidatesPerSeason);
int32 NumSurvivors = 0;
for (int32 CandidateIndex = 0;
CandidateIndex < CandidatesPerSeason;
++CandidateIndex)
{
const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate(
Corpus, SeasonSeed, CandidateIndex, true);
bool bSurvivor = false;
CandidateRecords.Add(VF_EvaluateCandidate(
World, Candidate, Season, InputCorpusHash, TopVoxelZ, BottomVoxelZ,
ArrivalPoint, DeparturePoint, MeasureSettings,
LargestComponentSurvivalThreshold, bSurvivor));
if (bSurvivor)
{
++NumSurvivors;
}
}
const FVoxelStratePromotionBatchResult Batch = VF_SelectStratePromotions(
Corpus, CandidateRecords, Policy);
const FVoxelStratePromotionBatchResult RepeatBatch = VF_SelectStratePromotions(
Corpus, CandidateRecords, Policy);
TestTrue(FString::Printf(TEXT("season %d promotion selection is deterministic"), Season),
VF_PromotionBatchesIdentical(Batch, RepeatBatch));
TestTrue(FString::Printf(TEXT("season %d uses a valid explicit promotion policy"), Season),
Batch.bPolicyValid);
TestTrue(FString::Printf(TEXT("season %d respects its promotion cap"), Season),
Batch.Promoted.Num() <= PromotionCapPerSeason);
const double Spread = VF_MeanMeasuredSpread(Corpus, Batch.Promoted, Policy);
if (FirstSpread < 0.0) { FirstSpread = Spread; }
LastSpread = Spread;
const int32 ProjectCount = Corpus.NumForProvenance(EVoxelStrateCorpusProvenance::Project);
const int32 DefaultCount = Corpus.NumForProvenance(EVoxelStrateCorpusProvenance::Default);
const int32 ExistingPromotedCount = Corpus.NumForProvenance(
EVoxelStrateCorpusProvenance::Promoted);
const int32 CorpusAfter = Corpus.Num() + Batch.Promoted.Num();
const int32 PromotedAfter = ExistingPromotedCount + Batch.Promoted.Num();
const float SurvivalRate = CandidateRecords.Num() > 0
? static_cast<float>(NumSurvivors) / static_cast<float>(CandidateRecords.Num())
: 0.0f;
SeasonTable += FString::Printf(
TEXT("%d | %08x | %d | %d/%d/%d | %d/%d | %.1f%% | %.6f | %d | %d/%d/%d\n"),
Season, InputCorpusHash, CorpusAfter, ProjectCount, DefaultCount, PromotedAfter,
NumSurvivors, CandidateRecords.Num(), 100.0f * SurvivalRate, Spread,
Batch.Promoted.Num(), Batch.NumNearDuplicateRejected,
Batch.NumCorpusHashRejected, Batch.NumCapRejected);
FVoxelStrateSeasonManifest Manifest;
Manifest.Season = Season;
Manifest.Seed = SeasonSeed;
Manifest.InputCorpusHash = InputCorpusHash;
Manifest.CandidateCount = CandidateRecords.Num();
Manifest.SurvivorCount = NumSurvivors;
Manifest.PromotedCount = Batch.Promoted.Num();
Manifest.CorpusSize = CorpusAfter;
Manifest.ProjectCount = ProjectCount;
Manifest.DefaultCount = DefaultCount;
Manifest.PromotedCorpusCount = PromotedAfter;
Manifest.SurvivalRate = SurvivalRate;
Manifest.MeasuredSpread = Spread;
FString ManifestReport;
const FString ManifestPath = SimulationDirectory
/ FString::Printf(TEXT("season_%02d_manifest.json"), Season);
TestTrue(FString::Printf(TEXT("season %d manifest is written beside the store"), Season),
VF_SaveStrateSeasonManifest(ManifestPath, Manifest, ManifestReport));
AddInfo(ManifestReport);
AllPromoted.Append(Batch.Promoted);
TestTrue(FString::Printf(TEXT("season %d cumulative promoted store is written"), Season),
VF_SaveStratePromotedRecords(PromotedStorePath, AllPromoted, StoreReport));
}
AddInfo(SeasonTable);
AddInfo(FString::Printf(
TEXT("Promotion policy: normalized measured-metric distance < %.2f rejects a near duplicate; "
"dimensions=(air, largest share, walkable, feature/%.0f voxels, clearance/%.0f voxels); "
"cap=%d per season; records=%d; reloads with promotions=%d."),
Policy.MinimumNormalizedMeasuredMetricDistance,
Policy.FeatureScaleNormalizationVoxels, Policy.ClearanceNormalizationVoxels,
Policy.MaxPromotionsPerSeason, AllPromoted.Num(), NumSeasonLoadsWithPromotions));
AddInfo(FString::Printf(
TEXT("Measured spread is mean pairwise distance in that normalized five-dimensional space: "
"season 1=%.6f, season 5=%.6f, delta=%+.6f."),
FirstSpread, LastSpread, LastSpread - FirstSpread));
TestEqual(TEXT("five simulated seasons completed"), NumSeasonLoadsWithPromotions + 1,
NumSimulatedSeasons);
TestTrue(TEXT("promotion compounding produced at least one promoted member"),
AllPromoted.Num() > 0);
TestEqual(TEXT("promotion cap was never exceeded in cumulative output"),
FMath::Min(AllPromoted.Num(), NumSimulatedSeasons * PromotionCapPerSeason),
AllPromoted.Num());
TestTrue(TEXT("promotion corpus contains project members"),
BaseCorpus.NumForProvenance(EVoxelStrateCorpusProvenance::Project) > 0);
TestTrue(TEXT("promotion corpus contains default members"),
BaseCorpus.NumForProvenance(EVoxelStrateCorpusProvenance::Default) > 0);
// Deliberately corrupt only the stored metric. The loader must still admit the record only
// after rebuilding it, and the corpus entry must expose the fresh value rather than the JSON.
if (AllPromoted.Num() > 0)
{
FVoxelStratePromotableRecord CorruptRecord = AllPromoted[0];
const float OriginalAir = CorruptRecord.MeasuredMetrics.AirFraction;
CorruptRecord.MeasuredMetrics.AirFraction = OriginalAir > 0.5f ? 0.01f : 0.99f;
TArray<FVoxelStratePromotableRecord> CorruptRecords;
CorruptRecords.Add(CorruptRecord);
FString CorruptReport;
TestTrue(TEXT("corrupt-metric promotion store can be written for the safety test"),
VF_SaveStratePromotedRecords(CorruptStorePath, CorruptRecords, CorruptReport));
FVoxelStrateCorpus CorruptCorpus;
FString CorruptLoadReport;
TestTrue(TEXT("corrupt stored metric is reverified before corpus admission"),
CorruptCorpus.LoadFromAssetRegistry(
CorruptLoadReport, CorruptStorePath, &Verifier));
AddInfo(CorruptLoadReport);
TestTrue(TEXT("corrupt stored metric was detected and replaced"),
CorruptLoadReport.Contains(TEXT("stored metrics replaced for 1 records")));
FVoxelStratePromotionVerification ExpectedVerification;
TestTrue(TEXT("safety verifier can independently rebuild the corrupted record"),
Verifier.Verify(CorruptRecord, ExpectedVerification));
bool bFoundReverifiedEntry = false;
for (const FVoxelStrateCorpusEntry& Entry : CorruptCorpus.GetEntries())
{
if (Entry.Provenance == EVoxelStrateCorpusProvenance::Promoted
&& Entry.PromotionRecordId == CorruptRecord.RecordId)
{
bFoundReverifiedEntry = true;
AddInfo(FString::Printf(
TEXT("stale metric audit %s: stored/corpus air=%.9g, fresh air=%.9g, "
"corrupted JSON air=%.9g"),
*CorruptRecord.RecordId,
Entry.MeasuredMetrics.AirFraction,
ExpectedVerification.MeasuredMetrics.AirFraction,
CorruptRecord.MeasuredMetrics.AirFraction));
TestTrue(TEXT("loaded promoted metric equals fresh measurement, not stale JSON"),
FMath::IsNearlyEqual(
Entry.MeasuredMetrics.AirFraction,
ExpectedVerification.MeasuredMetrics.AirFraction));
}
}
TestTrue(TEXT("corrupt safety test found its promoted corpus entry"),
bFoundReverifiedEntry);
}
const double TestSeconds = FPlatformTime::Seconds() - TestStartSeconds;
AddInfo(FString::Printf(
TEXT("Promotion total runtime %.3fs; WorldRadiusVoxels=0; JSON store=%s."),
TestSeconds, *PromotedStorePath));
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS && WITH_EDITOR
@@ -0,0 +1,459 @@
// Tier 4c — offline season composition, manifest determinism, and exact rebuild.
#if WITH_DEV_AUTOMATION_TESTS && WITH_EDITOR
#include "Misc/AutomationTest.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "UObject/Package.h" // GetTransientPackage() — this file does not use the shared
// fixture, which is where the other tests pick it up implicitly.
#include "UObject/StrongObjectPtr.h"
#include "VoxelDensityOpStack.h"
#include "VoxelGenerator.h"
#include "VoxelSeasonAsset.h"
#include "VoxelSeasonManifest.h"
#include "VoxelSettings.h"
#include "VoxelStrateComposer.h"
#include "VoxelStrateDefinition.h"
#include "VoxelStrateManager.h"
namespace
{
bool SameDensityBits(float A, float B)
{
return FMemory::Memcmp(&A, &B, sizeof(float)) == 0;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeComposerSeasonTest,
"VoxelForge.Composer.Season",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeComposerSeasonTest::RunTest(const FString& Parameters)
{
(void)Parameters;
FVoxelStrateCorpus Corpus;
FString CorpusReport;
TestTrue(TEXT("season test loads a valid corpus"),
Corpus.LoadFromAssetRegistry(CorpusReport) && Corpus.IsValid());
if (!Corpus.IsValid())
{
AddError(CorpusReport);
return false;
}
FVoxelSeasonCompositionSettings Settings;
Settings.Corpus = &Corpus;
Settings.CandidateCount = 24;
Settings.SelectedStrateCount = 6;
Settings.StrateHeightInChunks = 8;
Settings.InterStrateGapChunks = 0;
Settings.OriginSpineRadius = 14.0f;
Settings.WorldRadiusVoxels = 0.0f;
Settings.OutputDirectory = FPaths::Combine(
FPaths::ProjectSavedDir(), TEXT("VoxelForge"), TEXT("SeasonTest"));
Settings.bWriteArtifacts = true;
Settings.bWriteReviewPage = true;
Settings.bIncludeCandidateAudit = true;
const int32 SeasonSeed = 0x2468;
const FVoxelSeasonManifest Manifest = VF_ComposeSeason(SeasonSeed, Settings);
const bool bManifestUsable = Manifest.IsUsable();
if (!bManifestUsable)
{
// The confirmed player-fit gate is intentionally allowed to expose an empty survivor
// pool. This corpus/fixture was authored before capsule occupancy was measured, so an
// honest blocked composition is a passing diagnostic outcome, not a reason to relax the
// gate or silently fall back to the old air-only law. Keep the full manifest assertions
// below for any future corpus that supplies enough physically traversable candidates.
const bool bBlockedByPlayerFitGate = Manifest.Error.Contains(
TEXT("candidates survived the hard gates"));
TestTrue(TEXT("empty composition is explicitly reported as a hard-gate survivor shortage"),
bBlockedByPlayerFitGate);
if (bBlockedByPlayerFitGate)
{
AddInfo(FString::Printf(
TEXT("Season composition remains blocked by the fine step-1 player-fit gate: %s"),
*Manifest.Error));
return true;
}
AddError(Manifest.Error);
return false;
}
TestTrue(TEXT("composition produces a usable season manifest"), bManifestUsable);
TestEqual(TEXT("candidate count is the configured number of attempts"),
Manifest.CandidateCount, Settings.CandidateCount);
TestEqual(TEXT("selected count is the configured spine length"),
Manifest.SelectedCount, Settings.SelectedStrateCount);
TestEqual(TEXT("candidate audit contains every attempt"),
Manifest.CandidateAudit.Num(), Settings.CandidateCount);
TestEqual(TEXT("WorldRadiusVoxels remains zero in the artifact"),
Manifest.WorldRadiusVoxels, 0.0f);
TestEqual(TEXT("selected grounded/outlier counts add to generated slots"),
Manifest.GroundedSelectedCount + Manifest.OutlierSelectedCount,
Manifest.SelectedCount - Manifest.FixedSelectedCount);
for (int32 Index = 0; Index < Manifest.Strates.Num(); ++Index)
{
const FVoxelSeasonStrate& Strate = Manifest.Strates[Index];
TestEqual(FString::Printf(TEXT("slot %d keeps descent order"), Index),
Strate.DepthIndex, Index);
TestTrue(FString::Printf(TEXT("slot %d passed non-vacuous gate"), Index),
Strate.bPassedNonVacuous);
TestTrue(FString::Printf(TEXT("slot %d passed largest-component gate"), Index),
Strate.bPassedLargestComponent);
TestTrue(FString::Printf(TEXT("slot %d passed primordial law"), Index),
Strate.bPassedPrimordialLaw
&& Strate.PrimordialLawResult == EVoxelConnectivityResult::Connected);
TestTrue(FString::Printf(TEXT("slot %d has zero world radius"), Index),
Manifest.WorldRadiusVoxels == 0.0f);
}
TestTrue(TEXT("season manifest JSON was emitted"), !Manifest.SerializedJson.IsEmpty());
TestTrue(TEXT("season manifest file was emitted"), FPaths::FileExists(Manifest.ManifestPath));
TestTrue(TEXT("selected-season review page was emitted"),
FPaths::FileExists(Manifest.ReviewPagePath));
FString ReviewHtml;
if (FFileHelper::LoadFileToString(ReviewHtml, *Manifest.ReviewPagePath))
{
TestTrue(TEXT("review page explains why each selected slot was chosen"),
ReviewHtml.Contains(TEXT("Why selected:")));
TestTrue(TEXT("review page marks boss slots"), ReviewHtml.Contains(TEXT("Boss slot:")));
}
else
{
AddError(FString::Printf(TEXT("could not read review page %s"), *Manifest.ReviewPagePath));
}
// Compose again with the same inputs. SerializedJson excludes derived filesystem paths, so
// this is the byte-level determinism assertion for the portable artifact itself.
const FVoxelSeasonManifest RepeatManifest = VF_ComposeSeason(SeasonSeed, Settings);
TestTrue(TEXT("repeat composition is usable"), RepeatManifest.IsUsable());
TestEqual(TEXT("same season seed produces byte-identical JSON"),
RepeatManifest.SerializedJson, Manifest.SerializedJson);
FVoxelSeasonManifest LoadedManifest;
FString LoadReport;
TestTrue(TEXT("manifest loads from its JSON file"),
VF_LoadVoxelSeasonManifest(Manifest.ManifestPath, LoadedManifest, LoadReport));
if (!LoadedManifest.IsUsable())
{
AddError(LoadReport);
return false;
}
TestEqual(TEXT("loaded manifest has the same number of selected strates"),
LoadedManifest.Strates.Num(), Manifest.Strates.Num());
TestEqual(TEXT("loaded manifest content hash matches the composed payload"),
LoadedManifest.ContentHash, Manifest.ContentHash);
TestEqual(TEXT("manifest recomputes the hash it was composed from"),
VF_ComputeVoxelSeasonManifestContentHash(LoadedManifest),
LoadedManifest.ContentHash);
int64 ComparedDensitySamples = 0;
bool bAllDensityBitsIdentical = true;
for (int32 Index = 0; Index < Manifest.Strates.Num(); ++Index)
{
const FVoxelSeasonStrate& OriginalStrate = Manifest.Strates[Index];
const FVoxelSeasonStrate& LoadedStrate = LoadedManifest.Strates[Index];
TestTrue(FString::Printf(TEXT("slot %d parameter vector survives JSON bit-identically"), Index),
VF_AreStrateArchetypeParamsBitIdentical(
OriginalStrate.Params, LoadedStrate.Params, OriginalStrate.Archetype));
TestTrue(FString::Printf(TEXT("slot %d recipe survives JSON identically"), Index),
VF_AreStrateStructureRecipesIdentical(
OriginalStrate.Recipe, LoadedStrate.Recipe));
FVoxelOpStack OriginalStack;
FVoxelOpContext OriginalContext;
FVoxelOpStack LoadedStack;
FVoxelOpContext LoadedContext;
FString BuildError;
TestTrue(FString::Printf(TEXT("slot %d original stack rebuilds"), Index),
VF_RebuildVoxelSeasonStrate(
OriginalStrate, Manifest.OriginSpineRadius, nullptr,
OriginalStack, OriginalContext, &BuildError));
TestTrue(FString::Printf(TEXT("slot %d loaded stack rebuilds"), Index),
VF_RebuildVoxelSeasonStrate(
LoadedStrate, LoadedManifest.OriginSpineRadius, nullptr,
LoadedStack, LoadedContext, &BuildError));
if (OriginalStack.Num() == 0 || LoadedStack.Num() == 0)
{
bAllDensityBitsIdentical = false;
continue;
}
TestEqual(FString::Printf(TEXT("slot %d original/rebuilt stack operation count"), Index),
LoadedStack.Num(), OriginalStack.Num());
TestEqual(FString::Printf(TEXT("slot %d original context radius"), Index),
OriginalContext.WorldRadiusVoxels, 0.0f);
TestEqual(FString::Printf(TEXT("slot %d loaded context radius"), Index),
LoadedContext.WorldRadiusVoxels, 0.0f);
OriginalStack.PrepareChunk(OriginalContext);
LoadedStack.PrepareChunk(LoadedContext);
// Compare the complete bounded probe lattice for each selected field at two-voxel
// spacing, not a handful of favorable points. Every sampled scalar must retain its
// exact IEEE-754 bits after JSON load and stack reconstruction.
constexpr int32 ProbeHalfExtent = 32;
constexpr int32 ProbeStep = 2;
for (int32 Z = OriginalStrate.BottomWorldZ;
Z < OriginalStrate.TopWorldZ && bAllDensityBitsIdentical;
Z += ProbeStep)
{
for (int32 Y = -ProbeHalfExtent;
Y < ProbeHalfExtent && bAllDensityBitsIdentical;
Y += ProbeStep)
{
for (int32 X = -ProbeHalfExtent;
X < ProbeHalfExtent;
X += ProbeStep)
{
const float OriginalDensity = OriginalStack.EvalMC(
static_cast<float>(X), static_cast<float>(Y), static_cast<float>(Z));
const float LoadedDensity = LoadedStack.EvalMC(
static_cast<float>(X), static_cast<float>(Y), static_cast<float>(Z));
++ComparedDensitySamples;
if (!SameDensityBits(OriginalDensity, LoadedDensity))
{
bAllDensityBitsIdentical = false;
AddError(FString::Printf(
TEXT("slot %d density mismatch at (%d,%d,%d): 0x%08x vs 0x%08x"),
Index, X, Y, Z,
*reinterpret_cast<const uint32*>(&OriginalDensity),
*reinterpret_cast<const uint32*>(&LoadedDensity)));
break;
}
}
}
}
}
AddInfo(FString::Printf(TEXT("round-trip density probe compared %lld bit-identical samples"),
ComparedDensitySamples));
TestTrue(TEXT("manifest round-trip density field is bit-identical"),
bAllDensityBitsIdentical);
// Runtime seam: embed the reviewed JSON in a cookable primary asset, then let two independent
// managers/generators materialise the complete selected layout. The authored settings seed and
// radius are deliberately different so the test proves the season is authoritative.
TStrongObjectPtr<UVoxelSeasonAsset> SeasonAsset(
NewObject<UVoxelSeasonAsset>(GetTransientPackage(), NAME_None, RF_Transient));
FString AssetReport;
TestTrue(TEXT("cookable season asset accepts the reviewed manifest"),
SeasonAsset->SetManifestJson(Manifest.SerializedJson, AssetReport));
TStrongObjectPtr<UVoxelSettings> RuntimeSettings(
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
RuntimeSettings->Seed = Manifest.Seed + 17;
RuntimeSettings->OriginSpineRadius = Manifest.OriginSpineRadius + 31.0f;
RuntimeSettings->WorldRadiusVoxels = 4096.0f;
RuntimeSettings->Season = TSoftObjectPtr<UVoxelSeasonAsset>(SeasonAsset.Get());
TStrongObjectPtr<UVoxelStrateManager> ManagerA(
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
TStrongObjectPtr<UVoxelStrateManager> ManagerB(
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
TestTrue(TEXT("first independent season manager initializes"),
ManagerA->Initialize(RuntimeSettings.Get(), RuntimeSettings->GetEffectiveWorldSeed()));
TestTrue(TEXT("second independent season manager initializes"),
ManagerB->Initialize(RuntimeSettings.Get(), RuntimeSettings->GetEffectiveWorldSeed()));
TestEqual(TEXT("season overrides the authored settings seed"),
RuntimeSettings->GetEffectiveWorldSeed(), Manifest.Seed);
TestEqual(TEXT("season keeps WorldRadiusVoxels zero"),
RuntimeSettings->GetEffectiveWorldRadiusVoxels(), 0.0f);
TestEqual(TEXT("season owns the origin spine radius used by recipe stacks"),
RuntimeSettings->GetEffectiveOriginSpineRadius(), Manifest.OriginSpineRadius);
TestEqual(TEXT("first manager loads every season slot"),
ManagerA->GetNumStrates(), Manifest.Strates.Num());
TestEqual(TEXT("second manager loads every season slot"),
ManagerB->GetNumStrates(), Manifest.Strates.Num());
TestEqual(TEXT("first manager exposes the reviewed content hash"),
ManagerA->GetSeasonContentHash(), Manifest.ContentHash);
TestEqual(TEXT("second manager exposes the reviewed content hash"),
ManagerB->GetSeasonContentHash(), Manifest.ContentHash);
TStrongObjectPtr<UVoxelGenerator> GeneratorA(
NewObject<UVoxelGenerator>(GetTransientPackage(), NAME_None, RF_Transient));
TStrongObjectPtr<UVoxelGenerator> GeneratorB(
NewObject<UVoxelGenerator>(GetTransientPackage(), NAME_None, RF_Transient));
GeneratorA->InitializeSettings(RuntimeSettings.Get());
GeneratorB->InitializeSettings(RuntimeSettings.Get());
GeneratorA->SetStrateManager(ManagerA.Get());
GeneratorB->SetStrateManager(ManagerB.Get());
int64 ManagerDensitySamples = 0;
bool bManagersBitIdentical = true;
for (const FVoxelSeasonStrate& Strate : Manifest.Strates)
{
for (int32 Z = Strate.BottomWorldZ;
Z < Strate.TopWorldZ && bManagersBitIdentical; Z += 2)
{
for (int32 Y = -32; Y < 32 && bManagersBitIdentical; Y += 2)
{
for (int32 X = -32; X < 32; X += 2)
{
const float A = GeneratorA->GetDensityAt((float)X, (float)Y, (float)Z);
const float B = GeneratorB->GetDensityAt((float)X, (float)Y, (float)Z);
++ManagerDensitySamples;
if (!SameDensityBits(A, B))
{
bManagersBitIdentical = false;
AddError(FString::Printf(
TEXT("independent season managers diverged at (%d,%d,%d): 0x%08x vs 0x%08x"),
X, Y, Z, *reinterpret_cast<const uint32*>(&A),
*reinterpret_cast<const uint32*>(&B)));
break;
}
}
}
}
}
AddInfo(FString::Printf(
TEXT("two independent runtime season managers compared %lld bit-identical density samples"),
ManagerDensitySamples));
TestTrue(TEXT("two independent runtime season managers produce a bit-identical field"),
bManagersBitIdentical);
// The former HasComposerRecipeOverride force made every one of these tiles Mixed (zero proved
// tiles). The new path asks the exact recipe stack and brute-forces every verdict it accepts.
int32 NumClassified = 0;
int32 NumMixed = 0;
int32 NumAllSolid = 0;
int32 NumAllAir = 0;
int32 NumVerdictsBruteForced = 0;
int32 NumFalseVerdicts = 0;
constexpr int32 TileCells = 16;
for (const FVoxelSeasonStrate& Strate : Manifest.Strates)
{
for (int32 ZBand = 1; ZBand <= 3; ++ZBand)
{
const int32 Z = Strate.BottomWorldZ
+ (Strate.TopWorldZ - Strate.BottomWorldZ) * ZBand / 4;
for (int32 Y = -128; Y <= 128; Y += 32)
{
for (int32 X = -128; X <= 128; X += 32)
{
const FIntVector Origin(X, Y, Z);
const EVoxelTileClass Verdict = GeneratorA->ClassifyTile(
Origin, 1, TileCells);
++NumClassified;
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
if (Verdict == EVoxelTileClass::AllSolid) ++NumAllSolid;
else ++NumAllAir;
if (NumVerdictsBruteForced >= 64) continue;
++NumVerdictsBruteForced;
const bool bSolid = Verdict == EVoxelTileClass::AllSolid;
bool bBad = false;
for (int32 GZ = -1; GZ <= TileCells + 1 && !bBad; ++GZ)
for (int32 GY = -1; GY <= TileCells + 1 && !bBad; ++GY)
for (int32 GX = -1; GX <= TileCells + 1; ++GX)
{
const float D = GeneratorA->GetDensityAt(
(float)(X + GX), (float)(Y + GY), (float)(Z + GZ));
if (bSolid ? D >= 0.0f : D < 0.0f)
{
bBad = true;
++NumFalseVerdicts;
AddError(FString::Printf(
TEXT("composed recipe false %s tile at (%d,%d,%d), sample (%d,%d,%d)=%.9g"),
bSolid ? TEXT("AllSolid") : TEXT("AllAir"), X, Y, Z,
X + GX, Y + GY, Z + GZ, D));
break;
}
}
}
}
}
}
AddInfo(FString::Printf(
TEXT("composed recipe tile classification: before force=%d Mixed/0 proved; after=%d Mixed, %d AllSolid, %d AllAir; %d accepted verdicts brute-forced, %d violations"),
NumClassified, NumMixed, NumAllSolid, NumAllAir,
NumVerdictsBruteForced, NumFalseVerdicts));
TestTrue(TEXT("composed recipe classification proves at least one uniform tile"),
NumAllSolid + NumAllAir > 0);
TestEqual(TEXT("composed recipe classification has no false uniform verdict"),
NumFalseVerdicts, 0);
FString TamperedJson = Manifest.SerializedJson;
const FString OriginalSeedField = FString::Printf(TEXT("\"seed\": %d"), Manifest.Seed);
const FString TamperedSeedField = FString::Printf(TEXT("\"seed\": %d"), Manifest.Seed + 1);
TamperedJson = TamperedJson.Replace(*OriginalSeedField, *TamperedSeedField,
ESearchCase::CaseSensitive);
FVoxelSeasonManifest TamperedManifest;
FString TamperReport;
TestFalse(TEXT("stale/edited season JSON is rejected by its content hash"),
VF_DeserializeVoxelSeasonManifest(
TamperedJson, TamperedManifest, TamperReport));
TestTrue(TEXT("stale season failure names the content hash"),
TamperReport.Contains(TEXT("content hash")));
// Explicit unset regression gate: no season means the authored pointer, seed and radius still
// flow through the original pool/fixed path.
TStrongObjectPtr<UVoxelSettings> LegacySettings(
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
TStrongObjectPtr<UVoxelStrateDefinition> LegacyDefinition(
NewObject<UVoxelStrateDefinition>(GetTransientPackage(), NAME_None, RF_Transient));
LegacySettings->Seed = 77123;
LegacySettings->OriginSpineRadius = 23.0f;
LegacySettings->WorldRadiusVoxels = 987.0f;
LegacySettings->TotalStrates = 1;
LegacyDefinition->StrateHeightInChunks = 7;
LegacySettings->StratePool.Add(
TSoftObjectPtr<UVoxelStrateDefinition>(LegacyDefinition.Get()));
TStrongObjectPtr<UVoxelStrateManager> LegacyManager(
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
TestTrue(TEXT("legacy manager still initializes with Season unset"),
LegacyManager->Initialize(LegacySettings.Get(), LegacySettings->Seed));
TestTrue(TEXT("season unset remains the authored-pool path"),
LegacySettings->Season.IsNull()
&& LegacyManager->GetNumStrates() == 1
&& LegacyManager->GetLayout()[0].Definition == LegacyDefinition.Get()
&& LegacyManager->GetLayout()[0].HeightInChunks == 7
&& LegacyManager->GetWorldSeed() == LegacySettings->Seed
&& LegacySettings->GetEffectiveWorldSeed() == LegacySettings->Seed
&& LegacySettings->GetEffectiveOriginSpineRadius()
== LegacySettings->OriginSpineRadius
&& LegacySettings->GetEffectiveWorldRadiusVoxels()
== LegacySettings->WorldRadiusVoxels);
// Transactional live-rebuild regression: make a hash-valid manifest whose first authored
// definition cannot load. Initialize must reject it without destroying the prior valid layout,
// season hash, or passage-version snapshot.
if (Manifest.Strates.Num() > 0)
{
FVoxelSeasonManifest BrokenManifest = Manifest;
BrokenManifest.Strates[0].SourceDefinitionPath =
TEXT("/Game/VoxelForge/DefinitelyMissing/Definition.Definition");
BrokenManifest.ContentHash = VF_ComputeVoxelSeasonManifestContentHash(BrokenManifest);
BrokenManifest.SerializedJson = VF_SerializeVoxelSeasonManifest(BrokenManifest);
TStrongObjectPtr<UVoxelSeasonAsset> BrokenSeason(
NewObject<UVoxelSeasonAsset>(GetTransientPackage(), NAME_None, RF_Transient));
FString BrokenReport;
TestTrue(TEXT("broken test season keeps a valid content hash"),
BrokenSeason->SetManifestJson(BrokenManifest.SerializedJson, BrokenReport));
TStrongObjectPtr<UVoxelStrateManager> TransactionalManager(
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
TestTrue(TEXT("transactional manager starts from a valid layout"),
TransactionalManager->Initialize(RuntimeSettings.Get(), RuntimeSettings->GetEffectiveWorldSeed()));
const int32 PreviousNumStrates = TransactionalManager->GetNumStrates();
const uint32 PreviousPassagesVersion = TransactionalManager->GetLayoutVersion();
const FString PreviousSeasonHash = TransactionalManager->GetSeasonContentHash();
RuntimeSettings->Season = TSoftObjectPtr<UVoxelSeasonAsset>(BrokenSeason.Get());
TestFalse(TEXT("invalid live season is rejected"),
TransactionalManager->Initialize(RuntimeSettings.Get(), RuntimeSettings->GetEffectiveWorldSeed()));
TestEqual(TEXT("invalid live season preserves the previous layout"),
TransactionalManager->GetNumStrates(), PreviousNumStrates);
TestEqual(TEXT("invalid live season preserves the previous passage version"),
TransactionalManager->GetLayoutVersion(), PreviousPassagesVersion);
TestEqual(TEXT("invalid live season preserves the previous content hash"),
TransactionalManager->GetSeasonContentHash(), PreviousSeasonHash);
RuntimeSettings->Season = TSoftObjectPtr<UVoxelSeasonAsset>(SeasonAsset.Get());
}
return true;
}
#endif
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,921 @@
// Tier 4b — deterministic structure recipes, dependency-safe materialisation, and measurement.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "HAL/PlatformTime.h"
#include "Math/RandomStream.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include "VoxelSettings.h"
#include "VoxelStrateComposer.h"
#include "VoxelStrateMeasure.h"
#include "VoxelStratePreview.h"
#include "Misc/Paths.h"
namespace
{
constexpr int32 NumCandidates = 64;
constexpr float LargestComponentSurvivalThreshold = 0.50f;
constexpr int32 BoxChecksPerCandidate = 40;
constexpr int32 BoxStep = 1;
constexpr int32 BoxCells = 8;
constexpr int32 FinePreviewSampleStep = 1;
constexpr int32 FinePreviewRadiusVoxels = 64;
constexpr int32 FinePreviewMaxCells = 2000000;
const TCHAR* VF_ConnectivityName(EVoxelConnectivityResult Result)
{
switch (Result)
{
case EVoxelConnectivityResult::Connected: return TEXT("Connected");
case EVoxelConnectivityResult::NotConnectedAtThisResolution: return TEXT("NotConnected");
case EVoxelConnectivityResult::StartCellSolid: return TEXT("StartCellSolid");
case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid");
case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow");
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudget");
case EVoxelConnectivityResult::StartCellNotPlayerFit: return TEXT("StartCellNotPlayerFit");
case EVoxelConnectivityResult::GoalCellNotPlayerFit: return TEXT("GoalCellNotPlayerFit");
}
return TEXT("Unknown");
}
const TCHAR* VF_ShapeName(EVoxelStrateOpClass OpClass)
{
switch (OpClass)
{
case EVoxelStrateOpClass::RoomGraphSource: return TEXT("RoomGraph");
case EVoxelStrateOpClass::LatticeCorridorSource:return TEXT("Lattice");
case EVoxelStrateOpClass::ShaftFieldSource: return TEXT("Shaft");
case EVoxelStrateOpClass::IslandBlobSource: return TEXT("Island");
case EVoxelStrateOpClass::NoiseRibbonSource: return TEXT("Noise");
default: return TEXT("invalid");
}
}
int32 VF_FloorDiv(int32 A, int32 B)
{
const int32 Q = A / B;
const int32 R = A % B;
return (R != 0 && (R < 0) != (B < 0)) ? Q - 1 : Q;
}
bool VF_BitEqual(float A, float B)
{
uint32 ABits = 0;
uint32 BBits = 0;
FMemory::Memcpy(&ABits, &A, sizeof(ABits));
FMemory::Memcpy(&BBits, &B, sizeof(BBits));
return ABits == BBits;
}
struct FBlockSpec
{
EVoxelStrateParamBlock Block;
ECaveGeneratorType Archetype;
uint32 SeedSalt;
};
static const FBlockSpec GBlockSpecs[] =
{
{ EVoxelStrateParamBlock::TunnelNetwork, ECaveGeneratorType::TunnelNetwork, 0x1001u },
{ EVoxelStrateParamBlock::Slab, ECaveGeneratorType::FlatPlain, 0x1003u },
{ EVoxelStrateParamBlock::Maze, ECaveGeneratorType::Maze, 0x1005u },
{ EVoxelStrateParamBlock::Surface, ECaveGeneratorType::SurfaceWorld, 0x1007u },
{ EVoxelStrateParamBlock::VerticalShaft, ECaveGeneratorType::VerticalShafts, 0x1009u },
{ EVoxelStrateParamBlock::FloatingIsland, ECaveGeneratorType::FloatingIslands, 0x100Bu },
};
void VF_CopyRollBlock(FVoxelStrateArchetypeParams& OutParams,
const FVoxelStrateRollInfo& Roll,
ECaveGeneratorType Archetype)
{
switch (Archetype)
{
case ECaveGeneratorType::TunnelNetwork:
OutParams.TunnelNetworkParams = Roll.ArchetypeParams.TunnelNetworkParams;
break;
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
OutParams.SlabParams = Roll.ArchetypeParams.SlabParams;
break;
case ECaveGeneratorType::Maze:
OutParams.MazeParams = Roll.ArchetypeParams.MazeParams;
break;
case ECaveGeneratorType::SurfaceWorld:
OutParams.SurfaceParams = Roll.ArchetypeParams.SurfaceParams;
break;
case ECaveGeneratorType::VerticalShafts:
OutParams.VerticalShaftParams = Roll.ArchetypeParams.VerticalShaftParams;
break;
case ECaveGeneratorType::FloatingIslands:
OutParams.FloatingIslandParams = Roll.ArchetypeParams.FloatingIslandParams;
break;
default:
break;
}
}
void VF_SetAllRuntimeBounds(FVoxelStrateArchetypeParams& Params,
float TopWorldZ, float BottomWorldZ)
{
Params.TunnelNetworkParams.StrateTopWorldZ = TopWorldZ;
Params.TunnelNetworkParams.StrateBottomWorldZ = BottomWorldZ;
Params.SlabParams.StrateTopWorldZ = TopWorldZ;
Params.SlabParams.StrateBottomWorldZ = BottomWorldZ;
Params.MazeParams.StrateTopWorldZ = TopWorldZ;
Params.MazeParams.StrateBottomWorldZ = BottomWorldZ;
Params.SurfaceParams.StrateTopWorldZ = TopWorldZ;
Params.SurfaceParams.StrateBottomWorldZ = BottomWorldZ;
Params.VerticalShaftParams.StrateTopWorldZ = TopWorldZ;
Params.VerticalShaftParams.StrateBottomWorldZ = BottomWorldZ;
Params.FloatingIslandParams.StrateTopWorldZ = TopWorldZ;
Params.FloatingIslandParams.StrateBottomWorldZ = BottomWorldZ;
}
class FStackDensitySampler final : public IVoxelStrateDensitySampler
{
public:
explicit FStackDensitySampler(const FVoxelOpStack& InStack) : Stack(InStack) {}
float SampleDensity(float WorldX, float WorldY, float WorldZ) const override
{
return Stack.EvalMC(WorldX, WorldY, WorldZ);
}
private:
const FVoxelOpStack& Stack;
};
struct FBoxVerdictReport
{
int32 Mixed = 0;
int32 AllSolid = 0;
int32 AllAir = 0;
int32 Proved = 0;
int64 CheckedVoxels = 0;
int32 Violations = 0;
FString FirstViolation;
};
FBoxVerdictReport VF_CheckStackBoxVerdicts(const FVoxelOpStack& Stack,
const FVoxelOpContext& Context,
int32 CandidateIndex,
int32 BottomVoxelZ,
int32 TopVoxelZ)
{
FBoxVerdictReport Report;
constexpr int32 Extent = BoxStep * BoxCells;
constexpr int32 SpanCells = 40;
const int32 LoTile = VF_FloorDiv(BottomVoxelZ, Extent);
const int32 HiTile = FMath::Max(LoTile, VF_FloorDiv(TopVoxelZ, Extent));
FRandomStream Rng(0x6C617731 ^ CandidateIndex);
for (int32 BoxIndex = 0; BoxIndex < BoxChecksPerCandidate; ++BoxIndex)
{
const FIntVector Origin(
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(LoTile, HiTile) * Extent);
const FBox Box(
FVector((float)(Origin.X - BoxStep), (float)(Origin.Y - BoxStep),
(float)(Origin.Z - BoxStep)),
FVector((float)(Origin.X + (BoxCells + 1) * BoxStep),
(float)(Origin.Y + (BoxCells + 1) * BoxStep),
(float)(Origin.Z + (BoxCells + 1) * BoxStep)));
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Context);
if (Verdict == EVoxelTileClass::Mixed)
{
++Report.Mixed;
continue;
}
if (Verdict == EVoxelTileClass::AllSolid)
{
++Report.AllSolid;
}
else
{
++Report.AllAir;
}
++Report.Proved;
const int32 GridDim = BoxCells + 1;
const bool bClaimsSolid = Verdict == EVoxelTileClass::AllSolid;
for (int32 GZ = -1; GZ <= GridDim; ++GZ)
for (int32 GY = -1; GY <= GridDim; ++GY)
for (int32 GX = -1; GX <= GridDim; ++GX)
{
const float X = (float)(Origin.X + GX * BoxStep);
const float Y = (float)(Origin.Y + GY * BoxStep);
const float Z = (float)(Origin.Z + GZ * BoxStep);
const float Density = Stack.EvalMC(X, Y, Z);
++Report.CheckedVoxels;
const bool bAgrees = bClaimsSolid ? (Density < 0.0f) : (Density >= 0.0f);
if (!bAgrees)
{
++Report.Violations;
if (Report.FirstViolation.IsEmpty())
{
Report.FirstViolation = FString::Printf(
TEXT("candidate %d box %d origin (%d,%d,%d) said %s but MC density "
"at (%.0f,%.0f,%.0f) was %.9g"),
CandidateIndex, BoxIndex, Origin.X, Origin.Y, Origin.Z,
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
X, Y, Z, Density);
}
}
}
}
return Report;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeComposerStructureRollTest,
"VoxelForge.Composer.StructureRoll",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
(void)Parameters;
const double TestStartSeconds = FPlatformTime::Seconds();
const FString SettingsPath = TEXT("/Game/VoxelForge/DA_Settings.DA_Settings");
UVoxelSettings* AuthoredSettings = LoadObject<UVoxelSettings>(nullptr, *SettingsPath);
if (AuthoredSettings == nullptr)
{
AddError(FString::Printf(TEXT("Could not load %s; structure roll did not run."), *SettingsPath));
return false;
}
FVoxelStrateCorpus Corpus;
FString CorpusReport;
const bool bCorpusLoaded = Corpus.LoadFromAssetRegistry(CorpusReport);
AddInfo(CorpusReport);
TestTrue(TEXT("structure roll has a usable corpus for parameter blocks"),
bCorpusLoaded && Corpus.IsValid());
if (!bCorpusLoaded || !Corpus.IsValid())
{
return false;
}
FTestWorld World;
World.Build(1337, 2, true);
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
TestTrue(TEXT("structure-roll fixture keeps WorldRadiusVoxels at 0"),
World.Settings->WorldRadiusVoxels == 0.0f);
constexpr int32 CandidateStrateIndex = FTestWorld::SlotFlatPlain;
int32 TopVoxelZ = 0;
int32 BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(CandidateStrateIndex, TopVoxelZ, BottomVoxelZ))
{
AddError(TEXT("structure-roll fixture has no candidate Z range"));
return false;
}
const float TopWorldZ = (float)TopVoxelZ + 1.0f;
const float BottomWorldZ = (float)BottomVoxelZ;
FVector ArrivalPoint = FVector::ZeroVector;
FVector DeparturePoint = FVector::ZeroVector;
int32 ArrivalCount = 0;
int32 DepartureCount = 0;
for (const FVoxelPassage& Passage : World.StrateManager->GetPassages())
{
if (Passage.LowerStrateIndex == CandidateStrateIndex
&& Passage.UpperStrateIndex + 1 == CandidateStrateIndex)
{
ArrivalPoint = Passage.LowerPoint;
++ArrivalCount;
}
if (Passage.UpperStrateIndex == CandidateStrateIndex
&& Passage.LowerStrateIndex == CandidateStrateIndex + 1)
{
DeparturePoint = Passage.UpperPoint;
++DepartureCount;
}
}
TestEqual(TEXT("candidate has exactly one arrival mouth"), ArrivalCount, 1);
TestEqual(TEXT("candidate has exactly one departure mouth"), DepartureCount, 1);
FVoxelStrateMeasureSettings MeasureSettings;
MeasureSettings.SampleStep = 4;
MeasureSettings.RadiusInVoxels = 256;
MeasureSettings.CenterXY = FVector2D::ZeroVector;
MeasureSettings.MaxCells = 8000000;
MeasureSettings.MaxRouteRetries = 16;
MeasureSettings.HeadroomCells = 2;
MeasureSettings.InteriorMarginVoxels = -1;
FVoxelStrateFinePreviewSettings FinePreviewSettings;
FinePreviewSettings.SampleStep = FinePreviewSampleStep;
FinePreviewSettings.RadiusInVoxels = FinePreviewRadiusVoxels;
FinePreviewSettings.MaxCells = FinePreviewMaxCells;
TestTrue(TEXT("fine preview defaults are a bounded valid ROI"), FinePreviewSettings.IsValid());
const FVoxelStrateMeasureSettings FineMeasureSettings =
FinePreviewSettings.MakeMeasureSettings(MeasureSettings);
FString CandidateTable = TEXT(
"candidate | recipe (compact) | air | largest share | walkable | feature scale | arrival->departure\n");
int32 NumSurvivors = 0;
int32 NumNonVacuous = 0;
int32 NumLargestEnough = 0;
int32 NumLawPasses = 0;
int32 NumInvalidRecipes = 0;
int32 NumParameterRollFailures = 0;
int32 TotalBoxMixed = 0;
int32 TotalBoxAllSolid = 0;
int32 TotalBoxAllAir = 0;
int32 TotalBoxProved = 0;
int64 TotalBoxCheckedVoxels = 0;
int32 TotalBoxViolations = 0;
FString FirstBoxViolation;
int32 TotalBootstrapJitterFields = 0;
TSet<FString> BootstrapJitterFieldNames;
TArray<FVoxelStratePreviewCandidate> PreviewCandidates;
PreviewCandidates.Reserve(NumCandidates);
FVoxelStratePreviewWindow PreviewWindow;
bool bPreviewWindowSet = false;
int32 NumFineRequested = 0;
int32 NumFineRendered = 0;
int32 NumFineContourRendered = 0;
int32 NumFineRefused = 0;
int32 NumFinePlanBlankBeforeCentering = 0;
int32 NumFinePlanBlankAfterCentering = 0;
int32 NumFineCardBlankBeforeCentering = 0;
int32 NumFineCardBlankAfterCentering = 0;
int64 TotalFineCells = 0;
int64 PeakFineCaptureBytes = 0;
int64 PeakFineRasterBytes = 0;
double FineMeasureSeconds = 0.0;
double FineRenderSeconds = 0.0;
double CoarsePreviewSeconds = 0.0;
bool bFineCapRefusalChecked = false;
const FString PreviewRunId = FString::Printf(
TEXT("structure_seed_%d_corpus_%08x_step_%d_radius_%d_fine_step_%d_fine_radius_%d"),
AuthoredSettings->Seed, Corpus.GetContentsHash(),
MeasureSettings.SampleStep, MeasureSettings.RadiusInVoxels,
FinePreviewSettings.SampleStep, FinePreviewSettings.RadiusInVoxels);
const FString PreviewDirectory = FPaths::ProjectSavedDir()
/ TEXT("ComposerPreview") / PreviewRunId;
TArray<int32> ShapeCounts;
ShapeCounts.Init(0, 5);
int32 KCounts[9] = { 0 };
TArray<FVoxelOpStackRecipe> DistinctRecipes;
DistinctRecipes.Reserve(NumCandidates);
const double RollAndMeasureStartSeconds = FPlatformTime::Seconds();
for (int32 CandidateIndex = 0; CandidateIndex < NumCandidates; ++CandidateIndex)
{
#if WITH_EDITOR
// Keep the editor hand-off and the structure test on the same recipe/block roll seam.
const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate(
Corpus, AuthoredSettings->Seed, CandidateIndex, true);
TestTrue(FString::Printf(TEXT("candidate %d hand-off roll is valid"), CandidateIndex),
Candidate.bValid);
const FVoxelOpStackRecipe Recipe = Candidate.Recipe;
#else
const FVoxelOpStackRecipe Recipe = VF_RollStrateStructure(
AuthoredSettings->Seed, CandidateIndex);
#endif
const FVoxelOpStackRecipe RepeatRecipe = VF_RollStrateStructure(
AuthoredSettings->Seed, CandidateIndex);
TestTrue(FString::Printf(TEXT("candidate %d recipe reroll is identical"), CandidateIndex),
VF_AreStrateStructureRecipesIdentical(Recipe, RepeatRecipe));
const uint32 RecipeHash = VF_HashStrateStructureRecipe(Recipe);
bool bSeenRecipe = false;
for (const FVoxelOpStackRecipe& Existing : DistinctRecipes)
{
if (VF_HashStrateStructureRecipe(Existing) == RecipeHash
&& VF_AreStrateStructureRecipesIdentical(Existing, Recipe))
{
bSeenRecipe = true;
break;
}
}
if (!bSeenRecipe) { DistinctRecipes.Add(Recipe); }
switch (Recipe.ShapeSource.OpClass)
{
case EVoxelStrateOpClass::RoomGraphSource: ++ShapeCounts[0]; break;
case EVoxelStrateOpClass::LatticeCorridorSource:++ShapeCounts[1]; break;
case EVoxelStrateOpClass::ShaftFieldSource: ++ShapeCounts[2]; break;
case EVoxelStrateOpClass::IslandBlobSource: ++ShapeCounts[3]; break;
case EVoxelStrateOpClass::NoiseRibbonSource: ++ShapeCounts[4]; break;
default: break;
}
if (Recipe.Modifiers.IsValidIndex(0))
{
++KCounts[Recipe.Modifiers.Num()];
}
FVoxelStrateArchetypeParams CandidateParams;
bool bParameterRollsValid = true;
double CandidateDistanceSquared = 0.0;
for (int32 BlockIndex = 0; BlockIndex < UE_ARRAY_COUNT(GBlockSpecs); ++BlockIndex)
{
#if WITH_EDITOR
const FBlockSpec& Spec = GBlockSpecs[BlockIndex];
if (!Candidate.StructureBlockRolls.IsValidIndex(BlockIndex))
{
bParameterRollsValid = false;
AddError(FString::Printf(TEXT("candidate %d missing parameter block %d"),
CandidateIndex, static_cast<int32>(Spec.Block)));
continue;
}
const FVoxelStrateRollInfo& BlockRoll = Candidate.StructureBlockRolls[BlockIndex];
#else
const FBlockSpec& Spec = GBlockSpecs[BlockIndex];
const FVoxelStrateRollInfo BlockRoll = VF_RollStrateParamsDetailedForArchetype(
Corpus, Spec.Archetype,
AuthoredSettings->Seed ^ static_cast<int32>(Spec.SeedSalt), CandidateIndex);
#endif
if (!BlockRoll.bValid)
{
bParameterRollsValid = false;
++NumParameterRollFailures;
AddError(FString::Printf(TEXT("candidate %d parameter block %d failed: %s"),
CandidateIndex, static_cast<int32>(Spec.Block),
*BlockRoll.FailureReason));
continue;
}
TotalBootstrapJitterFields += BlockRoll.BootstrapJitterFieldCount;
for (const FString& FieldName : BlockRoll.BootstrapJitterFieldNames)
{
BootstrapJitterFieldNames.Add(FieldName);
}
const double BlockDistance = VF_DistanceFromStrateCorpusCentroid(
Corpus, BlockRoll.ArchetypeParams, Spec.Archetype);
CandidateDistanceSquared += BlockDistance * BlockDistance;
VF_CopyRollBlock(CandidateParams, BlockRoll, Spec.Archetype);
}
VF_SetAllRuntimeBounds(CandidateParams, TopWorldZ, BottomWorldZ);
FString AirText = TEXT("invalid");
FString LargestText = TEXT("invalid");
FString WalkableText = TEXT("invalid");
FString FeatureText = TEXT("invalid");
FString LawText = TEXT("not measured");
FVoxelOpStack Stack;
FVoxelOpContext Context;
FString BuildError;
const bool bBuilt = bParameterRollsValid && VF_BuildStackFromRecipe(
Recipe, CandidateParams, AuthoredSettings->Seed, 14.0f,
World.StrateManager.Get(), Stack, Context, &BuildError);
if (!bBuilt)
{
++NumInvalidRecipes;
CandidateTable += FString::Printf(
TEXT("%d | %s | invalid | invalid | invalid | invalid | invalid | build=%s\n"),
CandidateIndex, *VF_FormatStrateStructureRecipe(Recipe),
bParameterRollsValid ? *BuildError : TEXT("parameter block roll failed"));
TestTrue(FString::Printf(TEXT("candidate %d recipe materialises"), CandidateIndex), false);
continue;
}
TestEqual(FString::Printf(TEXT("candidate %d has four mandatory structural posts"), CandidateIndex),
Stack.Num(), Recipe.Modifiers.Num() + 7);
FString ValidationError;
TestTrue(FString::Printf(TEXT("candidate %d passes ValidateChannelOrder"), CandidateIndex),
Stack.ValidateChannelOrder(&ValidationError));
TestTrue(FString::Printf(TEXT("candidate %d context keeps WorldRadiusVoxels at 0"), CandidateIndex),
Context.WorldRadiusVoxels == 0.0f);
Stack.PrepareChunk(Context);
FStackDensitySampler Sampler(Stack);
if (!bFineCapRefusalChecked)
{
FVoxelStrateMeasureSettings RefusalSettings = FineMeasureSettings;
RefusalSettings.MaxCells = 1;
FVoxelStrateSampleGrid RefusedFineGrid;
const FVoxelStrateMetrics RefusedFineMetrics = VF_MeasureStrateWithSampler(
Sampler, BottomVoxelZ, TopVoxelZ + 1,
Context.EdgeSealThickness, RefusalSettings, &RefusedFineGrid);
TestTrue(TEXT("fine preview refuses an over-cap ROI before allocation"),
!RefusedFineMetrics.bValid
&& RefusedFineGrid.Air.Num() == 0
&& RefusedFineGrid.Density.Num() == 0
&& RefusedFineMetrics.RefusalReason.Contains(TEXT("MaxCells")));
bFineCapRefusalChecked = true;
}
FVoxelStrateSampleGrid SampleGrid;
const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler(
Sampler, BottomVoxelZ, TopVoxelZ + 1,
Context.EdgeSealThickness, MeasureSettings, &SampleGrid);
FVoxelConnectivityDiagnostics Law;
if (ArrivalCount == 1 && DepartureCount == 1)
{
Law = VF_DiagnoseConnectivityWithSampler(
Sampler, BottomVoxelZ, TopVoxelZ + 1,
Context.EdgeSealThickness, ArrivalPoint, DeparturePoint, MeasureSettings);
LawText = VF_ConnectivityName(Law.Result);
if (Law.bStartSnapped || Law.bGoalSnapped) { LawText += TEXT(" (snapped)"); }
}
else
{
LawText = FString::Printf(TEXT("mouths=%d/%d"), ArrivalCount, DepartureCount);
}
const bool bNonVacuous = Metrics.bValid
&& Metrics.NumSampled > 0 && Metrics.NumAir > 0 && Metrics.NumSolid > 0;
const bool bLargestEnough = bNonVacuous
&& Metrics.LargestComponentShare >= LargestComponentSurvivalThreshold;
const bool bLawPass = ArrivalCount == 1 && DepartureCount == 1
&& Law.bValid && Law.Result == EVoxelConnectivityResult::Connected
&& !Law.bStartSnapped && !Law.bGoalSnapped;
if (bNonVacuous) { ++NumNonVacuous; }
if (bLargestEnough) { ++NumLargestEnough; }
if (bLawPass) { ++NumLawPasses; }
if (bNonVacuous && bLargestEnough && bLawPass) { ++NumSurvivors; }
if (Metrics.bValid)
{
AirText = FString::Printf(TEXT("%.6f"), Metrics.AirFraction);
LargestText = FString::Printf(TEXT("%.6f"), Metrics.LargestComponentShare);
WalkableText = FString::Printf(TEXT("%.6f"), Metrics.WalkableFraction);
FeatureText = FString::Printf(TEXT("%.6f"), Metrics.MedianFeatureScale);
}
FString RejectionReason;
if (!bNonVacuous)
{
RejectionReason = TEXT("vacuous");
}
else
{
if (!bLargestEnough)
{
RejectionReason = TEXT("fragmented");
}
if (!bLawPass)
{
if (!RejectionReason.IsEmpty())
{
RejectionReason += TEXT("; ");
}
RejectionReason += TEXT("law failed");
}
}
const FBoxVerdictReport BoxReport = VF_CheckStackBoxVerdicts(
Stack, Context, CandidateIndex, BottomVoxelZ, TopVoxelZ);
TotalBoxMixed += BoxReport.Mixed;
TotalBoxAllSolid += BoxReport.AllSolid;
TotalBoxAllAir += BoxReport.AllAir;
TotalBoxProved += BoxReport.Proved;
TotalBoxCheckedVoxels += BoxReport.CheckedVoxels;
TotalBoxViolations += BoxReport.Violations;
if (FirstBoxViolation.IsEmpty() && !BoxReport.FirstViolation.IsEmpty())
{
FirstBoxViolation = BoxReport.FirstViolation;
}
const double CandidateDistance = FMath::Sqrt(
FMath::Max(0.0, CandidateDistanceSquared));
const FVoxelStratePreviewWindow CandidateWindow =
VF_GetStratePreviewWindow(SampleGrid);
if (CandidateWindow.IsValid())
{
if (!bPreviewWindowSet)
{
PreviewWindow = CandidateWindow;
bPreviewWindowSet = true;
}
}
TestTrue(FString::Printf(TEXT("candidate %d preview captures its measurement grid"),
CandidateIndex),
!Metrics.bValid || SampleGrid.HasScalarDensity());
FVoxelStratePreviewCandidate PreviewCandidate;
FString PreviewError;
const double CoarsePreviewStartSeconds = FPlatformTime::Seconds();
const bool bPreviewWritten = VF_WriteStratePreviewCandidate(
PreviewDirectory,
CandidateIndex,
VF_FormatStrateStructureRecipe(Recipe),
SampleGrid,
Metrics,
MeasureSettings.HeadroomCells,
CandidateDistance,
LawText,
!(bNonVacuous && bLargestEnough && bLawPass),
RejectionReason,
PreviewCandidate,
PreviewError);
CoarsePreviewSeconds += FPlatformTime::Seconds() - CoarsePreviewStartSeconds;
TestTrue(FString::Printf(TEXT("candidate %d preview rasterises without a second sample"),
CandidateIndex),
bPreviewWritten);
TestTrue(FString::Printf(TEXT("candidate %d has images when its measurement is valid"),
CandidateIndex),
!Metrics.bValid
|| (PreviewCandidate.bRendered && PreviewCandidate.bContourRendered));
if (!bPreviewWritten)
{
AddError(FString::Printf(TEXT("candidate %d preview failed: %s"),
CandidateIndex, *PreviewError));
}
const bool bCandidateSurvivor = bNonVacuous && bLargestEnough && bLawPass;
if (bCandidateSurvivor)
{
++NumFineRequested;
// Render the old origin-centred pass into the same filenames first. The temporary
// record gives an exact before count from the renderer's content-variation check; the
// corrected capture below overwrites those files. The two grids are sequential, so
// peak retained memory remains one fine capture.
FVoxelStrateSampleGrid OriginFineGrid;
const double OriginFineMeasureStartSeconds = FPlatformTime::Seconds();
const FVoxelStrateMetrics OriginFineMetrics = VF_MeasureStrateWithSampler(
Sampler, BottomVoxelZ, TopVoxelZ + 1,
Context.EdgeSealThickness, FineMeasureSettings, &OriginFineGrid);
FineMeasureSeconds += FPlatformTime::Seconds() - OriginFineMeasureStartSeconds;
FVoxelStratePreviewCandidate OriginFinePreview;
FString OriginFinePreviewError;
const double OriginFineRenderStartSeconds = FPlatformTime::Seconds();
const bool bOriginFineWritten = VF_WriteStratePreviewFineCandidate(
PreviewDirectory, CandidateIndex, OriginFineGrid,
FineMeasureSettings.HeadroomCells,
OriginFineMetrics.bValid ? FString() : OriginFineMetrics.RefusalReason,
OriginFinePreview, OriginFinePreviewError);
FineRenderSeconds += FPlatformTime::Seconds() - OriginFineRenderStartSeconds;
TestTrue(FString::Printf(TEXT("candidate %d origin baseline fine render records"),
CandidateIndex),
bOriginFineWritten);
if (OriginFinePreview.bFineRendered && !OriginFinePreview.bFinePlanContentVaries)
{
++NumFinePlanBlankBeforeCentering;
}
if (OriginFinePreview.bFineRendered && OriginFinePreview.bFineBlank)
{
++NumFineCardBlankBeforeCentering;
}
FVoxelStrateMeasureSettings CenteredFineMeasureSettings = FineMeasureSettings;
if (Metrics.bValid && FMath::IsFinite(Metrics.LargestComponentPoint.X)
&& FMath::IsFinite(Metrics.LargestComponentPoint.Y))
{
CenteredFineMeasureSettings.CenterXY = FVector2D(
Metrics.LargestComponentPoint.X, Metrics.LargestComponentPoint.Y);
}
FVoxelStrateSampleGrid FineGrid;
const double FineMeasureStartSeconds = FPlatformTime::Seconds();
const FVoxelStrateMetrics FineMetrics = VF_MeasureStrateWithSampler(
Sampler, BottomVoxelZ, TopVoxelZ + 1,
Context.EdgeSealThickness, CenteredFineMeasureSettings, &FineGrid);
FineMeasureSeconds += FPlatformTime::Seconds() - FineMeasureStartSeconds;
const FString FineFailureReason = FineMetrics.bValid
? FString() : FineMetrics.RefusalReason;
const double FineRenderStartSeconds = FPlatformTime::Seconds();
FString FinePreviewError;
const bool bFineWritten = VF_WriteStratePreviewFineCandidate(
PreviewDirectory, CandidateIndex, FineGrid,
FineMeasureSettings.HeadroomCells, FineFailureReason,
PreviewCandidate, FinePreviewError);
FineRenderSeconds += FPlatformTime::Seconds() - FineRenderStartSeconds;
TestTrue(FString::Printf(TEXT("candidate %d fine preview records without a second renderer sample"),
CandidateIndex),
bFineWritten);
if (PreviewCandidate.bFineRendered && !PreviewCandidate.bFinePlanContentVaries)
{
++NumFinePlanBlankAfterCentering;
}
if (PreviewCandidate.bFineRendered && PreviewCandidate.bFineBlank)
{
++NumFineCardBlankAfterCentering;
}
TestTrue(FString::Printf(TEXT("candidate %d fine capture keeps scalar density"),
CandidateIndex),
!FineMetrics.bValid || FineGrid.HasScalarDensity());
if (FineMetrics.bValid)
{
TotalFineCells += FineGrid.CellCount;
PeakFineCaptureBytes = FMath::Max(
PeakFineCaptureBytes,
static_cast<int64>(FineGrid.Air.GetAllocatedSize())
+ static_cast<int64>(FineGrid.Density.GetAllocatedSize()));
}
if (!FineMetrics.bValid)
{
++NumFineRefused;
}
if (PreviewCandidate.bFineRendered)
{
++NumFineRendered;
PeakFineRasterBytes = FMath::Max(
PeakFineRasterBytes,
static_cast<int64>(PreviewCandidate.FineVerticalImageWidth)
* static_cast<int64>(PreviewCandidate.FineVerticalImageHeight)
* static_cast<int64>(sizeof(FColor)));
PeakFineRasterBytes = FMath::Max(
PeakFineRasterBytes,
static_cast<int64>(PreviewCandidate.FinePlanImageWidth)
* static_cast<int64>(PreviewCandidate.FinePlanImageHeight)
* static_cast<int64>(sizeof(FColor)));
}
if (PreviewCandidate.bFineContourRendered)
{
++NumFineContourRendered;
}
if (!bFineWritten)
{
AddError(FString::Printf(TEXT("candidate %d fine preview failed: %s"),
CandidateIndex, *FinePreviewError));
}
}
PreviewCandidates.Add(MoveTemp(PreviewCandidate));
// Same recipe + same block vectors + same seed must yield the same world values. This is
// checked on a fixed point array for every candidate, including cache-backed sources.
FVoxelOpStack RepeatStack;
FVoxelOpContext RepeatContext;
FString RepeatBuildError;
const bool bRepeatBuilt = VF_BuildStackFromRecipe(
Recipe, CandidateParams, AuthoredSettings->Seed, 14.0f,
World.StrateManager.Get(), RepeatStack, RepeatContext, &RepeatBuildError);
TestTrue(FString::Printf(TEXT("candidate %d same recipe rebuild succeeds"), CandidateIndex),
bRepeatBuilt);
if (bRepeatBuilt)
{
RepeatStack.PrepareChunk(RepeatContext);
const FVector ProbePoints[] = {
FVector(-91.0f, -37.0f, BottomWorldZ + 17.0f),
FVector(0.0f, 0.0f, (BottomWorldZ + TopWorldZ) * 0.5f),
FVector(73.0f, 119.0f, TopWorldZ - 19.0f),
FVector(-256.0f, 256.0f, BottomWorldZ + 61.0f),
};
bool bSameWorld = true;
for (const FVector& Point : ProbePoints)
{
if (!VF_BitEqual(Stack.EvalMC(Point.X, Point.Y, Point.Z),
RepeatStack.EvalMC(Point.X, Point.Y, Point.Z)))
{
bSameWorld = false;
break;
}
}
TestTrue(FString::Printf(TEXT("candidate %d same seed yields same world"), CandidateIndex),
bSameWorld);
}
CandidateTable += FString::Printf(
TEXT("%d | %s | %s | %s | %s | %s | %s\n"),
CandidateIndex,
*VF_FormatStrateStructureRecipe(Recipe),
*AirText, *LargestText, *WalkableText, *FeatureText, *LawText);
}
const double RollAndMeasureSeconds = FPlatformTime::Seconds() - RollAndMeasureStartSeconds;
AddInfo(CandidateTable);
FString ShapeDistribution = TEXT("Shape source distribution:");
for (int32 Index = 0; Index < 5; ++Index)
{
const EVoxelStrateOpClass ShapeClass[] = {
EVoxelStrateOpClass::RoomGraphSource,
EVoxelStrateOpClass::LatticeCorridorSource,
EVoxelStrateOpClass::ShaftFieldSource,
EVoxelStrateOpClass::IslandBlobSource,
EVoxelStrateOpClass::NoiseRibbonSource,
};
ShapeDistribution += FString::Printf(TEXT(" %s=%d"),
VF_ShapeName(ShapeClass[Index]), ShapeCounts[Index]);
}
FString KDistribution = TEXT("k distribution:");
for (int32 K = 4; K <= 8; ++K)
{
KDistribution += FString::Printf(TEXT(" k%d=%d"), K, KCounts[K]);
}
AddInfo(ShapeDistribution);
AddInfo(KDistribution);
AddInfo(FString::Printf(
TEXT("Structure diversity: %d distinct recipes of %d; invalid recipes emitted=%d; "
"parameter-block roll failures=%d; recipe/build/measure time=%.3fs."),
DistinctRecipes.Num(), NumCandidates, NumInvalidRecipes,
NumParameterRollFailures, RollAndMeasureSeconds));
AddInfo(FString::Printf(
TEXT("Survival: %d/%d (%.1f%%). Criteria: non-vacuous (valid, sampled, air>0, solid>0), "
"largest component share >= %.2f, and exact unsnapped arrival->departure law "
"Result=Connected. Intermediate counts: non-vacuous=%d, largest threshold=%d, "
"law pass=%d. Lower survival is expected for aggressive structure rolls."),
NumSurvivors, NumCandidates, 100.0f * (float)NumSurvivors / (float)NumCandidates,
LargestComponentSurvivalThreshold, NumNonVacuous, NumLargestEnough, NumLawPasses));
AddInfo(FString::Printf(
TEXT("Rolled custom-stack box verdicts: mixed=%d, AllSolid=%d, AllAir=%d, proved=%d, "
"brute-force voxels checked=%lld, violations=%d. %s"),
TotalBoxMixed, TotalBoxAllSolid, TotalBoxAllAir, TotalBoxProved,
TotalBoxCheckedVoxels, TotalBoxViolations,
FirstBoxViolation.IsEmpty() ? TEXT("No violations.") : *FirstBoxViolation));
AddInfo(FString::Printf(
TEXT("Preview cost: coarse step=%d 64-card pass render %.3fs; fine ROI requested=%d "
"(survivors=%d), rendered=%d, contour pairs=%d, refused=%d; "
"blank plan origin=%d, blank plan largest-open-space=%d; blank card origin=%d, "
"blank card largest-open-space=%d; fine sample %.3fs + "
"render %.3fs; fine cells sampled=%lld; peak fine Air+Density capture=%lld bytes; "
"peak single RGBA raster=%lld bytes; ROI step=%d radius=%d MaxCells=%d."),
MeasureSettings.SampleStep, CoarsePreviewSeconds,
NumFineRequested, NumSurvivors, NumFineRendered, NumFineContourRendered, NumFineRefused,
NumFinePlanBlankBeforeCentering, NumFinePlanBlankAfterCentering,
NumFineCardBlankBeforeCentering, NumFineCardBlankAfterCentering,
FineMeasureSeconds, FineRenderSeconds, TotalFineCells,
PeakFineCaptureBytes, PeakFineRasterBytes,
FinePreviewSettings.SampleStep, FinePreviewSettings.RadiusInVoxels,
FinePreviewSettings.MaxCells));
FString PreviewIndexPath;
FString PreviewError;
const bool bPreviewIndexWritten = bPreviewWindowSet
&& PreviewCandidates.Num() == NumCandidates
&& VF_WriteStratePreviewIndex(
PreviewDirectory,
FString::Printf(TEXT("VoxelForge composer structure preview — seed %d"),
AuthoredSettings->Seed),
PreviewWindow,
PreviewCandidates,
PreviewIndexPath,
PreviewError);
TestTrue(TEXT("64-candidate composer preview index is written"), bPreviewIndexWritten);
if (!bPreviewIndexWritten)
{
AddError(FString::Printf(TEXT("composer preview index failed: %s"), *PreviewError));
}
if (bPreviewWindowSet && PreviewCandidates.Num() > 0)
{
const FVoxelStratePreviewCandidate& FirstPreview = PreviewCandidates[0];
int32 NumRenderedPairs = 0;
int32 NumRenderedContourPairs = 0;
for (const FVoxelStratePreviewCandidate& Candidate : PreviewCandidates)
{
if (Candidate.bRendered)
{
++NumRenderedPairs;
}
if (Candidate.bContourRendered)
{
++NumRenderedContourPairs;
}
}
AddInfo(FString::Printf(
TEXT("Composer preview: %d candidates, %d filled pairs + %d contour pairs, "
"vertical=%dx%d, plan=%dx%d, "
"plan Z chosen from each candidate's measured layer with the highest solid/air "
"boundary count (first candidate Z=%d); index=%s; first-candidate window=%s; "
"fine previews are linked on survivor cards and centred on each largest open "
"space point (never the strate midpoint); image cap=512 px including 20 px "
"scale footer."),
PreviewCandidates.Num(),
NumRenderedPairs,
NumRenderedContourPairs,
FirstPreview.VerticalImageWidth, FirstPreview.VerticalImageHeight,
FirstPreview.PlanImageWidth, FirstPreview.PlanImageHeight,
FirstPreview.PlanSliceWorldZ,
*PreviewIndexPath,
*PreviewWindow.Describe()));
}
AddInfo(FString::Printf(
TEXT("Bootstrap jitter: %d field applications across the six structure blocks; %d "
"distinct archetype fields took the bootstrap path."),
TotalBootstrapJitterFields, BootstrapJitterFieldNames.Num()));
TestEqual(TEXT("exactly 64 structure candidates were requested"), NumCandidates, 64);
TestEqual(TEXT("invalid recipes emitted by the roller/build path"), NumInvalidRecipes, 0);
TestEqual(TEXT("custom-stack box verdict violations"), TotalBoxViolations, 0);
TestTrue(TEXT("fine cap refusal was checked before any fine allocation"),
bFineCapRefusalChecked);
TestEqual(TEXT("fine previews are requested only for coarse survivors"),
NumFineRequested, NumSurvivors);
TestEqual(TEXT("fine preview outcomes account for every requested survivor"),
NumFineRendered + NumFineRefused, NumFineRequested);
AddInfo(FString::Printf(TEXT(
"Fine blank baseline: plan projection %d/%d origin-centred versus %d/%d centred on "
"LargestComponentPoint; complete two-projection cards %d/%d versus %d/%d. A blank "
"projection/card means its filled-cell pixels were uniform (scale footer excluded), "
"not that the measurement was refused."),
NumFinePlanBlankBeforeCentering, NumFineRequested,
NumFinePlanBlankAfterCentering, NumFineRequested,
NumFineCardBlankBeforeCentering, NumFineRequested,
NumFineCardBlankAfterCentering, NumFineRequested));
TestEqual(TEXT("every rendered fine preview has a scalar contour pair"),
NumFineContourRendered, NumFineRendered);
TestTrue(TEXT("structure roller emits 64 distinct structural recipes"),
DistinctRecipes.Num() == NumCandidates);
const double TestSeconds = FPlatformTime::Seconds() - TestStartSeconds;
AddInfo(FString::Printf(TEXT("StructureRoll total runtime %.3fs; WorldRadiusVoxels=0."),
TestSeconds));
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,650 @@
// Correctness-round regression coverage for the cold-audit fixes.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/Async.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelBiomeDefinition.h"
#include "VoxelMarchingCubesMesher.h"
namespace
{
using namespace VoxelForgeTest;
float FloatFromBits(uint32 Bits)
{
float Value = 0.0f;
FMemory::Memcpy(&Value, &Bits, sizeof(Value));
return Value;
}
bool SameVector(const FVector& A, const FVector& B)
{
return BitEqual(A.X, B.X) && BitEqual(A.Y, B.Y) && BitEqual(A.Z, B.Z);
}
bool SameVector2D(const FVector2D& A, const FVector2D& B)
{
return BitEqual(A.X, B.X) && BitEqual(A.Y, B.Y);
}
bool SameMesh(const FVoxelMeshData& A, const FVoxelMeshData& B, FString& OutWhy)
{
if (A.NumCeilingTriangles != B.NumCeilingTriangles)
{
OutWhy = FString::Printf(TEXT("NumCeilingTriangles %d != %d"),
A.NumCeilingTriangles, B.NumCeilingTriangles);
return false;
}
if (A.Vertices.Num() != B.Vertices.Num()
|| A.Triangles.Num() != B.Triangles.Num()
|| A.UVs.Num() != B.UVs.Num()
|| A.Normals.Num() != B.Normals.Num()
|| A.Colors.Num() != B.Colors.Num())
{
OutWhy = FString::Printf(
TEXT("array sizes differ: V %d/%d T %d/%d UV %d/%d N %d/%d C %d/%d"),
A.Vertices.Num(), B.Vertices.Num(), A.Triangles.Num(), B.Triangles.Num(),
A.UVs.Num(), B.UVs.Num(), A.Normals.Num(), B.Normals.Num(),
A.Colors.Num(), B.Colors.Num());
return false;
}
for (int32 Index = 0; Index < A.Vertices.Num(); ++Index)
{
if (!SameVector(A.Vertices[Index], B.Vertices[Index]))
{
OutWhy = FString::Printf(TEXT("vertex %d differs"), Index);
return false;
}
if (!SameVector2D(A.UVs[Index], B.UVs[Index]))
{
OutWhy = FString::Printf(TEXT("UV %d differs"), Index);
return false;
}
if (!SameVector(A.Normals[Index], B.Normals[Index]))
{
OutWhy = FString::Printf(TEXT("normal %d differs"), Index);
return false;
}
if (A.Colors[Index] != B.Colors[Index])
{
OutWhy = FString::Printf(TEXT("colour %d differs"), Index);
return false;
}
}
for (int32 Index = 0; Index < A.Triangles.Num(); ++Index)
{
if (A.Triangles[Index] != B.Triangles[Index])
{
OutWhy = FString::Printf(TEXT("triangle index %d differs"), Index);
return false;
}
}
return true;
}
float SampleWithTileContext(const UVoxelGenerator& Generator, int32 Z,
int32 Step, int32 Cells, bool* bOutCacheContext = nullptr)
{
TGuardValue<int32> StepGuard(VoxelGenLOD::SampleStep, Step);
TGuardValue<FIntVector> OriginGuard(
VoxelGenLOD::TileOriginVoxels, FIntVector::ZeroValue);
TGuardValue<int32> CellsGuard(VoxelGenLOD::TileCellsPerAxis, Cells);
if (bOutCacheContext)
{
FIntVector ContextOrigin;
int32 ContextStep = 1;
int32 ContextCells = 0;
*bOutCacheContext = VoxelGenLOD::IsTileCacheWindowEnabled(false)
&& VoxelGenLOD::GetThreadTileCacheWindow(
ContextOrigin, ContextStep, ContextCells);
}
return Generator.GetDensityAt(0.0f, 0.0f, static_cast<float>(Z));
}
bool IsFiniteGenerationParams(const FStrateGenerationParams& Params)
{
return VoxelMath::IsFinite(Params.BaseDensity)
&& VoxelMath::IsFinite(Params.VerticalScale)
&& VoxelMath::IsFinite(Params.WormFrequency)
&& VoxelMath::IsFinite(Params.WormThreshold)
&& VoxelMath::IsFinite(Params.WormStrength)
&& VoxelMath::IsFinite(Params.SurfaceRoughness)
&& VoxelMath::IsFinite(Params.BoundarySealThickness)
&& VoxelMath::IsFinite(Params.StrateTopWorldZ)
&& VoxelMath::IsFinite(Params.StrateBottomWorldZ);
}
}
//=============================================================================
// #7 — tile-cache context switch and moved-from tunnel state
//=============================================================================
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeTileCacheContextDeterminismTest,
"VoxelForge.Determinism.TileCacheContextSwitch",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeTileCacheContextDeterminismTest::RunTest(const FString& Parameters)
{
FTestWorld World;
World.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const FIntVector OriginChunk = FIntVector::ZeroValue;
AddInfo(FString::Printf(
TEXT("#7 fixture origin chunk: type=%d uses_op_stack=%d z_span=[%d,%d]."),
static_cast<int32>(World.StrateManager->GetGeneratorTypeForChunk(OriginChunk)),
World.StrateManager->UsesOperatorStackForChunk(OriginChunk) ? 1 : 0,
World.BottomChunkZ, World.TopChunkZ));
const UVoxelGenerator* Generator = World.Generator.Get();
const int32 SampleZ[] = { 0, 8, 16, 24, 31 };
TArray<float> CoarseContext;
TArray<float> FineContext;
bool bCoarseCacheContext = false;
bool bFineCacheContext = false;
CoarseContext.Reserve(UE_ARRAY_COUNT(SampleZ));
FineContext.Reserve(UE_ARRAY_COUNT(SampleZ));
// Step 16/Cells 16 enables the op-stack tile-window context. Step 1 disables it. The
// point remains in chunk (0,0,0), including the origin column that shares a Z cache entry.
for (const int32 Z : SampleZ)
{
CoarseContext.Add(SampleWithTileContext(
*Generator, Z, 16, 16, &bCoarseCacheContext));
}
for (const int32 Z : SampleZ)
{
FineContext.Add(SampleWithTileContext(
*Generator, Z, 1, 32, &bFineCacheContext));
}
TArray<int32> SampleZCopy;
for (const int32 Z : SampleZ) { SampleZCopy.Add(Z); }
TFuture<TArray<float>> FreshFuture = Async(EAsyncExecution::Thread,
[Generator, SampleZCopy = MoveTemp(SampleZCopy)]() mutable
{
TArray<float> Result;
Result.Reserve(SampleZCopy.Num());
for (const int32 Z : SampleZCopy)
{
Result.Add(Generator->GetDensityAt(0.0f, 0.0f, static_cast<float>(Z)));
}
return Result;
});
const TArray<float> FreshThread = FreshFuture.Get();
int32 CoarseMismatches = 0;
int32 FineMismatches = 0;
bool bObservedNonZero = false;
for (int32 Index = 0; Index < FreshThread.Num(); ++Index)
{
bObservedNonZero |= FMath::Abs(FreshThread[Index]) > KINDA_SMALL_NUMBER;
if (!BitEqual(CoarseContext[Index], FreshThread[Index])) { ++CoarseMismatches; }
if (!BitEqual(FineContext[Index], FreshThread[Index])) { ++FineMismatches; }
}
TestTrue(TEXT("the origin-column samples exercise a non-empty operator-stack field"),
bObservedNonZero);
TestEqual(TEXT("coarse tile-window context matches a fresh thread"), CoarseMismatches, 0);
TestEqual(TEXT("switching to the non-window context matches a fresh thread"),
FineMismatches, 0);
AddInfo(FString::Printf(
TEXT("#7 origin-column context switch: %d samples, coarse mismatches=%d, fine mismatches=%d; "
"all points are in chunk (0,0,0), cache_context coarse=%d fine=%d."),
FreshThread.Num(), CoarseMismatches, FineMismatches,
bCoarseCacheContext ? 1 : 0, bFineCacheContext ? 1 : 0));
// Exercise the game-path ordering: a coarse tile warms the moved-out shared entry, then a
// level-0 tile at the same origin is compared with a fresh world's level-0 output.
FTestWorld WarmWorld;
FTestWorld FreshWorld;
WarmWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
FreshWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
if (!WarmWorld.IsValid() || !FreshWorld.IsValid())
{
AddError(WarmWorld.IsValid() && FreshWorld.IsValid()
? TEXT("Unexpected per-tile fixture state")
: (!WarmWorld.IsValid() ? WarmWorld.WhyInvalid() : FreshWorld.WhyInvalid()));
return false;
}
TStrongObjectPtr<UVoxelMarchingCubesMesher> WarmMesher(
NewObject<UVoxelMarchingCubesMesher>(GetTransientPackage(), NAME_None, RF_Transient));
TStrongObjectPtr<UVoxelMarchingCubesMesher> FreshMesher(
NewObject<UVoxelMarchingCubesMesher>(GetTransientPackage(), NAME_None, RF_Transient));
WarmMesher->SetGenerator(WarmWorld.Generator.Get());
FreshMesher->SetGenerator(FreshWorld.Generator.Get());
int64 CoarseSamples = 0;
WarmMesher->GenerateMesh(FIntVector::ZeroValue, 16, 16, nullptr,
INT32_MIN, INT32_MAX, &CoarseSamples);
int64 WarmFineSamples = 0;
int64 FreshFineSamples = 0;
const FVoxelMeshData WarmFine = WarmMesher->GenerateMesh(
FIntVector::ZeroValue, 1, CHUNK_SIZE, nullptr, INT32_MIN, INT32_MAX,
&WarmFineSamples);
const FVoxelMeshData FreshFine = FreshMesher->GenerateMesh(
FIntVector::ZeroValue, 1, CHUNK_SIZE, nullptr, INT32_MIN, INT32_MAX,
&FreshFineSamples);
TestTrue(TEXT("the per-tile identity check actually meshed the level-0 origin tile"),
WarmFineSamples > 0 && FreshFineSamples > 0);
FString MeshDifference;
TestTrue(TEXT("coarse-then-fine game path equals a fresh fine tile"),
SameMesh(WarmFine, FreshFine, MeshDifference));
if (!MeshDifference.IsEmpty())
{
AddInfo(FString::Printf(TEXT("Per-tile difference detail: %s"), *MeshDifference));
}
AddInfo(FString::Printf(
TEXT("#7 game-path tile identity: coarse samples=%lld, warm fine=%lld, fresh fine=%lld, "
"warm vertices=%d, fresh vertices=%d."),
(long long)CoarseSamples, (long long)WarmFineSamples, (long long)FreshFineSamples,
WarmFine.Vertices.Num(), FreshFine.Vertices.Num()));
return true;
}
//=============================================================================
// #3 — diff-layer identity in the worker-local snapshot slots
//=============================================================================
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeDiffLayerIdentityTest,
"VoxelForge.Determinism.DiffLayerIdentity",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeDiffLayerIdentityTest::RunTest(const FString& Parameters)
{
FTestWorld CarvedWorld;
FTestWorld FilledWorld;
CarvedWorld.Build();
FilledWorld.Build();
if (!CarvedWorld.IsValid() || !FilledWorld.IsValid())
{
AddError(CarvedWorld.IsValid() && FilledWorld.IsValid()
? TEXT("Unexpected diff identity fixture state")
: (!CarvedWorld.IsValid() ? CarvedWorld.WhyInvalid() : FilledWorld.WhyInvalid()));
return false;
}
const FVector P(0.0f, 0.0f, 0.0f);
FVoxelModification Carve;
Carve.Center = P;
Carve.Radius = 8.0f;
Carve.Strength = -64.0f;
Carve.Shape = EVoxelBrushShape::Sphere;
FVoxelModification Fill = Carve;
Fill.Strength = 64.0f;
TestTrue(TEXT("the carve layer accepted its edit"),
CarvedWorld.DiffLayer->ApplyModification(Carve).Num() > 0);
TestTrue(TEXT("the fill layer accepted its edit"),
FilledWorld.DiffLayer->ApplyModification(Fill).Num() > 0);
TestEqual(TEXT("the two different layers have the same chunk/version key"),
CarvedWorld.DiffLayer->GetModsVersion(), FilledWorld.DiffLayer->GetModsVersion());
TestTrue(TEXT("the two layers have distinct worker-cache identities"),
CarvedWorld.DiffLayer->GetCacheLifetimeId()
!= FilledWorld.DiffLayer->GetCacheLifetimeId());
// Warm the carve snapshot, then sample the fill on the same thread. A cache keyed only by
// (chunk, version) returns the carve's list for the fill layer here.
const float Carved = CarvedWorld.Generator->GetDensityAt(P.X, P.Y, P.Z);
const float Filled = FilledWorld.Generator->GetDensityAt(P.X, P.Y, P.Z);
const UVoxelGenerator* FilledGenerator = FilledWorld.Generator.Get();
TFuture<float> FreshFilledFuture = Async(EAsyncExecution::Thread,
[FilledGenerator, P]()
{
return FilledGenerator->GetDensityAt(P.X, P.Y, P.Z);
});
const float FreshFilled = FreshFilledFuture.Get();
TestTrue(TEXT("the two edits produce different density values at their common centre"),
!BitEqual(Carved, FreshFilled));
TestTrue(TEXT("the second layer does not inherit the first layer's snapshot"),
BitEqual(Filled, FreshFilled));
AddInfo(FString::Printf(
TEXT("#3 same-thread diff snapshots: carve=%.9g [0x%08X], fill=%.9g [0x%08X], "
"fresh fill=%.9g [0x%08X]."),
Carved, *reinterpret_cast<const uint32*>(&Carved), Filled,
*reinterpret_cast<const uint32*>(&Filled), FreshFilled,
*reinterpret_cast<const uint32*>(&FreshFilled)));
return true;
}
//=============================================================================
// #8 — surface/biome worker-cache identities
//=============================================================================
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeSurfaceBiomeCacheIdentityTest,
"VoxelForge.Determinism.SurfaceBiomeCacheIdentity",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeSurfaceBiomeCacheIdentityTest::RunTest(const FString& Parameters)
{
FTestWorld A;
FTestWorld B;
A.Build();
B.Build();
if (!A.IsValid() || !B.IsValid())
{
AddError(A.IsValid() && B.IsValid()
? TEXT("Unexpected surface cache fixture state")
: (!A.IsValid() ? A.WhyInvalid() : B.WhyInvalid()));
return false;
}
// The fixture normally gives each world a unique layout version. Bring A forward by one
// rebuild so the two managers intentionally share (seed, chunk, layout version); only their
// process-unique owner identities may distinguish their worker caches.
A.Reinitialize();
TestEqual(TEXT("the cache-collision worlds intentionally share a layout version"),
A.StrateManager->GetLayoutVersion(), B.StrateManager->GetLayoutVersion());
TestTrue(TEXT("the cache-collision worlds have distinct manager identities"),
A.StrateManager->GetCacheLifetimeId() != B.StrateManager->GetCacheLifetimeId());
TestTrue(TEXT("the cache-collision worlds have distinct generator instances"),
A.Generator.Get() != B.Generator.Get());
int32 SurfTop = 0, SurfBottom = 0;
if (!A.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, SurfTop, SurfBottom))
{
AddError(TEXT("The fixture has no SurfaceWorld slot."));
return false;
}
const int32 SurfChunkZ = FMath::FloorToInt(
static_cast<float>(SurfTop + SurfBottom) * 0.5f / CHUNK_SIZE);
UVoxelStrateDefinition* BSurface = B.Definitions[FTestWorld::SlotSurfaceWorld].Get();
BSurface->SurfaceParams.ElevationRange *= 3.0f;
BSurface->SurfaceParams.MountainStrength = 1.0f;
BSurface->SurfaceParams.ContinentFrequency *= 1.7f;
BSurface->SurfaceParams.BaseGroundRelative = FMath::Clamp(
BSurface->SurfaceParams.BaseGroundRelative + 0.12f, 0.0f, 1.0f);
bool bFoundDifferentSurface = false;
float FirstX = 0.0f, FirstY = 0.0f, HeightA = 0.0f, HeightB = 0.0f;
for (int32 Index = 0; Index < 96 && !bFoundDifferentSurface; ++Index)
{
const float X = 180.0f + static_cast<float>(Index) * 23.0f;
const float Y = -260.0f + static_cast<float>(Index) * 19.0f;
float CeilA = 0.0f, CeilB = 0.0f;
if (!A.Generator->GetSurfaceHeightAt(X, Y, SurfChunkZ, HeightA, CeilA)
|| !B.Generator->GetSurfaceHeightAt(X, Y, SurfChunkZ, HeightB, CeilB))
{
continue;
}
if (FMath::Abs(HeightA - HeightB) > 1.0f)
{
bFoundDifferentSurface = true;
FirstX = X;
FirstY = Y;
}
}
TestTrue(TEXT("the two surface definitions produce a distinct heightfield"),
bFoundDifferentSurface);
if (!bFoundDifferentSurface) { return false; }
// Warm A immediately before B for each probe. Before the owner/manager key fix, B's integer
// XY column lookup reuses A's column because all former key fields are deliberately equal.
int32 NumBatches = 0;
int32 NumBStale = 0;
int32 NumAReferenceDifference = 0;
for (int32 Offset = -12; Offset <= 12; ++Offset)
{
const float Z = FMath::FloorToFloat((HeightA + HeightB) * 0.5f)
+ static_cast<float>(Offset);
const float ValueA = A.Generator->GetDensityAt(FirstX, FirstY, Z);
const float ValueB = B.Generator->GetDensityAt(FirstX, FirstY, Z);
const UVoxelGenerator* BGenerator = B.Generator.Get();
TFuture<float> FreshFuture = Async(EAsyncExecution::Thread,
[BGenerator, FirstX, FirstY, Z]()
{
return BGenerator->GetDensityAt(FirstX, FirstY, Z);
});
const float FreshB = FreshFuture.Get();
++NumBatches;
if (!BitEqual(ValueB, FreshB)) { ++NumBStale; }
if (!BitEqual(ValueA, FreshB)) { ++NumAReferenceDifference; }
}
TestTrue(TEXT("the surface probe includes a distinct A/B field value"),
NumAReferenceDifference > 0);
TestEqual(TEXT("the surface cache never returns the other world's column"), NumBStale, 0);
// Exercise the biome grid and vertex-palette path with the same intentionally colliding
// layout key. One full-climate biome per world makes the expected palette unambiguous.
TStrongObjectPtr<UVoxelBiomeDefinition> BiomeA(
NewObject<UVoxelBiomeDefinition>(GetTransientPackage(), NAME_None, RF_Transient));
TStrongObjectPtr<UVoxelBiomeDefinition> BiomeB(
NewObject<UVoxelBiomeDefinition>(GetTransientPackage(), NAME_None, RF_Transient));
BiomeA->ReliefMin = 0.0f; BiomeA->ReliefMax = 1.0f;
BiomeA->MoistureMin = 0.0f; BiomeA->MoistureMax = 1.0f;
BiomeA->MaterialPaletteIndex = 17;
BiomeB->ReliefMin = 0.0f; BiomeB->ReliefMax = 1.0f;
BiomeB->MoistureMin = 0.0f; BiomeB->MoistureMax = 1.0f;
BiomeB->MaterialPaletteIndex = 91;
A.Definitions[FTestWorld::SlotSurfaceWorld]->Biomes.Add(BiomeA.Get());
B.Definitions[FTestWorld::SlotSurfaceWorld]->Biomes.Add(BiomeB.Get());
int32 PaletteA = 0, PaletteAN = 0;
int32 PaletteB = 0, PaletteBN = 0;
float BlendA = 0.0f, BlendB = 0.0f;
A.Generator->GetBiomeMaterialAt(FirstX, FirstY, static_cast<float>(SurfTop),
PaletteA, PaletteAN, BlendA);
B.Generator->GetBiomeMaterialAt(FirstX, FirstY, static_cast<float>(SurfTop),
PaletteB, PaletteBN, BlendB);
TestEqual(TEXT("world A resolves its own biome palette"), PaletteA, 17);
TestEqual(TEXT("world B resolves its own biome palette"), PaletteB, 91);
TestEqual(TEXT("world A's neighbour palette is its own palette"), PaletteAN, 17);
TestEqual(TEXT("world B's neighbour palette is its own palette"), PaletteBN, 91);
// Public cache contract: the identity fields are part of Contains(), not just a comment or
// a caller convention. This also remains a cheap unit check for future cache refactors.
FChunkBiomeCache Cache;
Cache.ValidMinX = -10.0f; Cache.ValidMaxX = 10.0f;
Cache.ValidMinY = -10.0f; Cache.ValidMaxY = 10.0f;
Cache.ChunkZ = SurfChunkZ; Cache.Seed = 1337;
Cache.OwnerId = 101; Cache.ManagerLifetimeId = 202;
TestTrue(TEXT("biome cache accepts the exact owner and manager identity"),
Cache.Contains(0.0f, 0.0f, SurfChunkZ, 1337, 101, 202));
TestFalse(TEXT("biome cache rejects a different generator identity"),
Cache.Contains(0.0f, 0.0f, SurfChunkZ, 1337, 102, 202));
TestFalse(TEXT("biome cache rejects a different manager identity"),
Cache.Contains(0.0f, 0.0f, SurfChunkZ, 1337, 101, 203));
return true;
}
//=============================================================================
// #14 — passage-version invalidation when the new layout is empty
//=============================================================================
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeEmptyPassageVersionTest,
"VoxelForge.Determinism.EmptyPassageVersion",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeEmptyPassageVersionTest::RunTest(const FString& Parameters)
{
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
// Prime the worker-local paths that keep nearby-passage shortlists, then rebuild to an empty
// layout. The old early return cleared Passages but did not bump PassagesVersion.
World.Generator->ClassifyTile(FIntVector::ZeroValue, 1, 8);
const uint32 Before = World.StrateManager->GetLayoutVersion();
World.Settings->TotalStrates = 0;
World.Settings->StratePool.Reset();
World.Settings->FixedStrates.Reset();
const bool bInitialized = World.StrateManager->Initialize(World.Settings.Get(), World.Settings->Seed);
TestTrue(TEXT("empty settings still complete the empty layout rebuild"), bInitialized);
TestEqual(TEXT("the rebuilt layout is empty"), World.StrateManager->GetNumStrates(), 0);
TestTrue(TEXT("clearing passages bumps the worker invalidation version"),
World.StrateManager->GetLayoutVersion() != Before);
TestEqual(TEXT("the empty layout has no passages"), World.StrateManager->GetPassages().Num(), 0);
AddInfo(FString::Printf(TEXT("#14 passage version %u -> %u on empty rebuild."),
Before, World.StrateManager->GetLayoutVersion()));
return true;
}
//=============================================================================
// #20 — zero blend is a valid runtime value and must remain finite
//=============================================================================
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeZeroTransitionBlendTest,
"VoxelForge.Determinism.ZeroTransitionBlend",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeZeroTransitionBlendTest::RunTest(const FString& Parameters)
{
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
for (int32 Index = 0; Index < World.Definitions.Num(); ++Index)
{
UVoxelStrateDefinition* Definition = World.Definitions[Index].Get();
Definition->TransitionBlendChunks = 0;
Definition->TransitionType = (Index & 1) == 0
? EVoxelStrateTransition::Gradient : EVoxelStrateTransition::Interleaved;
}
World.Reinitialize();
int32 NumQueries = 0;
int32 NumNonFinite = 0;
for (const FStrateSlot& Slot : World.StrateManager->GetLayout())
{
const int32 BoundaryChunks[] = { Slot.BottomChunkZ, Slot.TopChunkZ };
for (const int32 ChunkZ : BoundaryChunks)
{
const FStrateGenerationParams Params =
World.StrateManager->GetGenerationParams(FIntVector(0, 0, ChunkZ));
++NumQueries;
if (!IsFiniteGenerationParams(Params)) { ++NumNonFinite; }
}
}
TestEqual(TEXT("zero-blend generation parameters remain finite at every boundary"),
NumNonFinite, 0);
AddInfo(FString::Printf(TEXT("#20 queried %d zero-blend boundary parameter sets."), NumQueries));
return true;
}
//=============================================================================
// #15 and #21 — malformed API input and direct per-chunk range lookup
//=============================================================================
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeDiffApiBoundaryTest,
"VoxelForge.Determinism.DiffApiBoundary",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeDiffApiBoundaryTest::RunTest(const FString& Parameters)
{
TStrongObjectPtr<UVoxelDiffLayer> Diff(
NewObject<UVoxelDiffLayer>(GetTransientPackage(), NAME_None, RF_Transient));
Diff->SetBudget(0, 50.0f, 0.0f);
const float NaN = FloatFromBits(0x7FC12345u);
const float Inf = FloatFromBits(0x7F800000u);
TestFalse(TEXT("CanModify rejects zero radius"), Diff->CanModify(0.0f));
TestFalse(TEXT("CanModify rejects NaN radius"), Diff->CanModify(NaN));
TestFalse(TEXT("CanModify rejects infinity radius"), Diff->CanModify(Inf));
auto ExpectRejected = [&](const TCHAR* Label, const FVoxelModification& Mod)
{
const int32 Before = Diff->GetTotalModificationCount();
const TArray<FIntVector> Touched = Diff->ApplyModification(Mod);
TestEqual(FString::Printf(TEXT("%s returns no affected chunks"), Label), Touched.Num(), 0);
TestEqual(FString::Printf(TEXT("%s does not update stored entries"), Label),
Diff->GetTotalModificationCount(), Before);
};
FVoxelModification Mod;
Mod.Radius = 0.0f;
ExpectRejected(TEXT("zero radius"), Mod);
Mod = FVoxelModification();
Mod.Radius = NaN;
ExpectRejected(TEXT("NaN radius"), Mod);
Mod = FVoxelModification();
Mod.Center.X = Inf;
ExpectRejected(TEXT("infinite centre"), Mod);
Mod = FVoxelModification();
Mod.Strength = NaN;
ExpectRejected(TEXT("NaN strength"), Mod);
Mod = FVoxelModification();
Mod.Shape = EVoxelBrushShape::Box;
Mod.BoxExtent.X = -1.0f;
ExpectRejected(TEXT("negative box extent"), Mod);
Mod = FVoxelModification();
Mod.Shape = EVoxelBrushShape::Capsule;
Mod.CapsuleEnd.Y = Inf;
ExpectRejected(TEXT("infinite capsule endpoint"), Mod);
Mod = FVoxelModification();
Mod.Shape = static_cast<EVoxelBrushShape>(255);
ExpectRejected(TEXT("unknown brush shape"), Mod);
// Shape-aware budget regression: the old implementation billed both of these as a sphere using
// Radius alone, so each incorrectly fit under a 1,000-voxel cumulative budget.
TStrongObjectPtr<UVoxelDiffLayer> ShapeBudget(
NewObject<UVoxelDiffLayer>(GetTransientPackage(), NAME_None, RF_Transient));
ShapeBudget->SetBudget(0, 50.0f, 1000.0f);
FVoxelModification LongCapsule;
LongCapsule.Shape = EVoxelBrushShape::Capsule;
LongCapsule.Center = FVector::ZeroVector;
LongCapsule.CapsuleEnd = FVector(0.0f, 1000.0f, 0.0f);
LongCapsule.Radius = 3.0f;
LongCapsule.Falloff = 2.0f;
TestFalse(TEXT("a long capsule is charged for its swept volume"),
ShapeBudget->CanModify(LongCapsule));
TestEqual(TEXT("the long capsule is not stored under the shape budget"),
ShapeBudget->ApplyModification(LongCapsule).Num(), 0);
FVoxelModification LargeBox;
LargeBox.Shape = EVoxelBrushShape::Box;
LargeBox.BoxExtent = FVector(5.0f);
LargeBox.Radius = 5.0f;
TestFalse(TEXT("a box is charged for its expanded box volume"),
ShapeBudget->CanModify(LargeBox));
TestEqual(TEXT("the large box is not stored under the shape budget"),
ShapeBudget->ApplyModification(LargeBox).Num(), 0);
FVoxelModification Valid;
Valid.Center = FVector(3.0f * CHUNK_SIZE + 4.0f,
4.0f * CHUNK_SIZE + 4.0f, 2.0f);
Valid.Radius = 3.0f;
Valid.Strength = -4.0f;
TestTrue(TEXT("a valid modification remains accepted after malformed inputs"),
Diff->ApplyModification(Valid).Num() > 0);
const FIntVector Hit(3, 4, 0);
TestTrue(TEXT("range lookup finds the directly grouped affected chunk"),
Diff->HasAnyModInChunkRange(Hit, Hit));
TestFalse(TEXT("range lookup rejects a far unmodified chunk"),
Diff->HasAnyModInChunkRange(FIntVector(30, 30, 30), FIntVector(30, 30, 30)));
TestFalse(TEXT("range lookup rejects reversed bounds"),
Diff->HasAnyModInChunkRange(FIntVector(4, 4, 0), FIntVector(3, 4, 0)));
TestTrue(TEXT("the valid edit left a non-empty diff layer"), Diff->HasAnyMods());
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,277 @@
// VoxelForgeCrossPlatformTest.cpp
// LA QUESTION MULTIJOUEUR, RENDUE MESURABLE / THE MULTIPLAYER QUESTION, MADE MEASURABLE
//
// Jahni, 2026-07-27 : *« je voudrais que le jeu soit jouable sur les deux plateformes, Linux et
// Windows, donc un hôte Windows avec un client Linux pourrait arriver, et l'inverse. »*
// Et la barre d'acceptation : *« 99.99% au pire reproductible si deux personnes partagent la même
// seed, puisque tout le monde le reconstruit en multijoueur. »*
//
// ⚠️ LE PROBLÈME (AUDIT §C9) : le MÊME `FPSemanticsMode.Default` d'UBT ne veut pas dire la même
// chose selon la toolchain — `VCToolChain` (Windows/MSVC) le résout en **`/fp:fast`**,
// `ClangToolChain` (Linux/Mac/Windows-Clang) le résout en **précis + `-ffp-contract=off`**. Deux
// builds de la MÊME source sont donc compilés sous des règles flottantes OPPOSÉES. Un hôte Windows
// et un client Linux ne sont pas seulement *autorisés* à diverger : ils sont compilés pour.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// CE TEST NE CORRIGE RIEN — IL MESURE, et c'est ce qui manque
// ─────────────────────────────────────────────────────────────────────────────────────────
// Aucune quantité de raisonnement ne dit à quel point les deux plateformes divergent : il faut le
// LIRE. Ce test produit deux empreintes du même monde, à la même seed, et les affiche. On le lance
// sur Windows, on le lance sur Linux, on compare les deux lignes.
//
// • **EMPREINTE DE FORME** — le SIGNE de la densité seulement (solide / air). C'est la SEULE
// chose que le mesher lit (`D >= IsoLevel ⇒ air`). Si cette empreinte est identique, les deux
// plateformes ont **le même monde** : mêmes cavités, mêmes murs, même navigabilité, même
// collision au voxel près. C'est littéralement le critère « 99.99% reproductible » de Jahni.
//
// • **EMPREINTE DE CHAMP** — tous les bits de tous les floats. Identique ⇒ reproductibilité
// BIT à BIT. Différente alors que la forme est identique ⇒ la divergence est un frémissement
// sous-voxel de la position des sommets, sans conséquence de jeu.
//
// C'est le bon découpage parce qu'il sépare les deux échecs possibles, qui n'ont pas du tout la
// même gravité :
//
// forme == && champ == ⇒ parfait, rien à faire.
// forme == && champ != ⇒ ACCEPTABLE. Les sommets bougent de ~1e-5 voxel. Personne ne le voit,
// rien ne s'y accroche — SAUF si un jour on compare des hashs de
// géométrie entre pairs. À ne pas faire, donc.
// forme != ⇒ **INACCEPTABLE**. Un voxel solide chez l'un est de l'air chez
// l'autre : un joueur traverse un mur que l'autre voit plein.
//
// ⚠️ POURQUOI LA FORME A DE BONNES CHANCES DE TENIR MÊME AUJOURD'HUI — et pourquoi il faut quand
// même la mesurer : toutes les décisions STRUCTURELLES du plugin (quelle arête de treillis est
// ouverte, quelle cellule porte une colonne, où sont les salles et les passages) passent par
// `VoxelHash::*`, c.-à-d. de l'ARITHMÉTIQUE ENTIÈRE, identique sur toute plateforme. Le flottant
// ne décide que la POSITION de la surface. Un signe ne bascule donc que si un échantillon tombe à
// ~1e-5 de l'isosurface — d'où le troisième chiffre affiché, `NearIso`, qui BORNE le risque au
// lieu de le supposer.
//
// The structural decisions all go through integer hashing, so only the surface POSITION is
// float-decided. A sign flips only where a sample sits within ~1e-5 of the isosurface, which is why
// NearIso is reported: it bounds the risk instead of assuming it.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// COMMENT S'EN SERVIR / HOW TO USE THIS
// ─────────────────────────────────────────────────────────────────────────────────────────
// 1. Lancer sur Windows, noter les deux empreintes.
// 2. Lancer sur Linux, comparer.
// 3. Une fois `FPSemantics = Precise` posé sur le module (AUDIT §C9, bloqué par la dette IWYU)
// et les deux plateformes d'accord : **épingler** les valeurs dans `PinnedShapeDigest` /
// `PinnedFieldDigest` ci-dessous. Le test devient alors un garde-fou permanent — toute
// régression de déterminisme échoue bruyamment, sur la plateforme qui a dérivé.
//
// Tant que les constantes valent 0, le test ne peut pas échouer sur les empreintes : il RAPPORTE.
// C'est délibéré — épingler une valeur avant que les plateformes soient d'accord ne ferait que
// graver la divergence dans le test.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelGenerator.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeCrossPlatformTest,
"VoxelForge.Determinism.CrossPlatformDigest",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
//=========================================================================
// ÉPINGLES / PINS — 0 = « pas encore d'accord de référence », le test rapporte sans juger.
//=========================================================================
// À remplir UNIQUEMENT quand Windows et Linux rendent la même valeur. Voir l'en-tête.
constexpr uint64 PinnedShapeDigest = 0;
constexpr uint64 PinnedFieldDigest = 0;
// FNV-1a 64 bits, octet par octet, **entier pur**. Volontairement écrit à la main plutôt que
// pris dans le moteur : une empreinte de déterminisme ne doit dépendre d'aucune implémentation
// qui pourrait, elle, varier. Ici il n'y a que des `^` et des `*` sur uint64.
// Hand-rolled on purpose: a determinism digest must not depend on an implementation that could
// itself vary. Nothing here but XOR and multiply on uint64.
constexpr uint64 FnvOffsetBasis = 0xcbf29ce484222325ull;
constexpr uint64 FnvPrime = 0x00000100000001b3ull;
FORCEINLINE void FnvAccumByte(uint64& H, uint8 B)
{
H ^= (uint64)B;
H *= FnvPrime;
}
FORCEINLINE void FnvAccumU32(uint64& H, uint32 V)
{
FnvAccumByte(H, (uint8)( V & 0xFFu));
FnvAccumByte(H, (uint8)((V >> 8) & 0xFFu));
FnvAccumByte(H, (uint8)((V >> 16) & 0xFFu));
FnvAccumByte(H, (uint8)((V >> 24) & 0xFFu));
}
/** Bits d'un float, avec les NaN NORMALISÉS : un NaN a plusieurs représentations et rien ne
* garantit que deux plateformes produisent la même. On les compte à part. */
FORCEINLINE uint32 FloatBitsNormalised(float V, bool& bOutWasNaN)
{
bOutWasNaN = FMath::IsNaN(V);
if (bOutWasNaN) { return 0x7FC00000u; }
return *reinterpret_cast<const uint32*>(&V);
}
}
bool FVoxelForgeCrossPlatformTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
//=========================================================================
// LA GRILLE — entièrement déterministe, SANS RNG
//=========================================================================
// Pas de `FRandomStream` ici, contrairement aux autres tests : l'ensemble des points doit être
// identique sur les deux plateformes SANS dépendre d'une seule ligne de code partagé. Une
// boucle entière sur des bornes entières ne peut pas diverger.
// No RNG: the point set must be identical across platforms without depending on any shared
// code at all. An integer loop over integer bounds cannot diverge.
const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE + CHUNK_SIZE - 1;
const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE;
constexpr int32 XYStep = 8;
constexpr int32 ZStep = 8;
const int32 XYExtent = 3 * CHUNK_SIZE; // couvre la spine (0,0), les passages et le rocher
uint64 ShapeDigest = FnvOffsetBasis;
uint64 FieldDigest = FnvOffsetBasis;
int32 NumSamples = 0, NumSolid = 0, NumNaN = 0;
int32 NumNearWide = 0, NumNearMid = 0, NumNearTight = 0;
// ─────────────────────────────────────────────────────────────────────────
// `NearIso` — À QUELLE DISTANCE DE ZÉRO UN ÉCHANTILLON PEUT-IL CHANGER DE SIGNE ?
// ─────────────────────────────────────────────────────────────────────────
// ⚠️ CORRIGÉ 2026-07-27, ET LA CORRECTION EST LE POINT INTÉRESSANT.
//
// Version d'origine : une seule bande à 1e-4, justifiée par « une différence de MODÈLE
// FLOTTANT ». Depuis `FPSemantics = Precise` (§C9), il n'y a plus de différence de modèle
// flottant : MSVC et Clang compilent tous deux en IEEE-754 sans contraction. J'ai d'abord cru
// que ça rendait cette mesure caduque. **C'est faux, et il a fallu vérifier plutôt que
// supposer.**
//
// The world path now owns its sine/cosine range reduction and polynomial in VoxelMath. Before
// that replacement, `sinf`/`cosf` were not specified by IEEE-754: MSVC's CRT and glibc's libm
// could return different results (typically <= 1 ULP). `FPSemantics` closed the compiler half
// of §C9; DetSin/DetCos close the library half as well.
//
// D'où trois bandes au lieu d'une : une bande unique à 1e-4 est **100× trop large** pour un
// écart de libm (~1e-6 en absolu sur des densités de magnitude ~10), donc elle sur-estime
// grossièrement le risque et crie au loup. Mesurer trois échelles donne un vrai profil, et
// seule la plus serrée — celle qui correspond réellement à un écart de libm — déclenche
// l'alerte.
// Three bands, not one: 1e-4 over-estimates a libm-scale delta by ~100x. Only the tight band,
// which actually matches a libm difference, raises a warning.
constexpr float NearIsoWide = 1.0e-4f; // profil : large, informatif
constexpr float NearIsoMid = 1.0e-5f; // profil
constexpr float NearIsoTight = 1.0e-6f; // ≈ l'échelle d'un écart libm ⇒ LE chiffre du risque
for (int32 Z = BottomVoxelZ; Z <= TopVoxelZ; Z += ZStep)
{
for (int32 Y = -XYExtent; Y <= XYExtent; Y += XYStep)
{
for (int32 X = -XYExtent; X <= XYExtent; X += XYStep)
{
const float D = Gen->GetDensityAt((float)X, (float)Y, (float)Z);
bool bWasNaN = false;
const uint32 Bits = FloatBitsNormalised(D, bWasNaN);
if (bWasNaN) { ++NumNaN; }
// FORME : un seul bit par échantillon — le côté de l'isosurface, ce que lit le
// mesher. C'est l'empreinte qui doit tenir entre plateformes.
const bool bAir = (D >= 0.0f);
if (!bAir) { ++NumSolid; }
FnvAccumByte(ShapeDigest, bAir ? 1u : 0u);
// CHAMP : tous les bits.
FnvAccumU32(FieldDigest, Bits);
if (!bWasNaN)
{
const float A = FMath::Abs(D);
if (A < NearIsoWide) { ++NumNearWide; }
if (A < NearIsoMid) { ++NumNearMid; }
if (A < NearIsoTight) { ++NumNearTight; }
}
++NumSamples;
}
}
}
//=========================================================================
// LE RAPPORT — c'est le produit de ce test
//=========================================================================
AddInfo(FString::Printf(
TEXT("CROSS-PLATFORM DIGEST (seed %d, %d samples, step %d/%d)\n")
TEXT(" SHAPE digest : 0x%016llX <- must match across Windows/Linux. This is the world.\n")
TEXT(" FIELD digest : 0x%016llX <- expected bit-for-bit after DetSin/DetCos.\n")
TEXT(" solid %d / air %d / NaN %d"),
World.Settings->Seed, NumSamples, XYStep, ZStep,
ShapeDigest, FieldDigest, NumSolid, NumSamples - NumSolid, NumNaN));
// Le PROFIL de proximité à l'isosurface reste utile pour visualiser la marge géométrique,
// mais il ne mesure plus une divergence CRT : the world trig path is deterministic now.
AddInfo(FString::Printf(
TEXT("NearIso profile over %d samples: %d within 1e-4, %d within 1e-5, %d within 1e-6. ")
TEXT("This is a geometric margin profile; DetSin/DetCos removes the prior CRT/libm source ")
TEXT("of cross-platform variation."),
NumSamples, NumNearWide, NumNearMid, NumNearTight));
if (NumNearTight > 0)
{
AddInfo(FString::Printf(
TEXT("%d of %d samples sit within 1e-6 of the isosurface. The proximity is worth ")
TEXT("recording, but it is no longer a CRT/libm cross-platform risk because the production ")
TEXT("world path uses deterministic VoxelMath::DetSin/DetCos."),
NumNearTight, NumSamples));
}
else
{
AddInfo(TEXT("No sample sits within 1e-6 of the isosurface on this grid."));
}
TestEqual(TEXT("no sample produced NaN"), NumNaN, 0);
// Un monde entièrement solide ou entièrement vide rendrait les empreintes vraies mais vides de
// sens. Garde-fou minimal contre un test qui se félicite de ne rien mesurer.
TestTrue(TEXT("the sampled world contains both solid and air (the digest is meaningful)"),
NumSolid > 0 && NumSolid < NumSamples);
//=========================================================================
// LES ÉPINGLES — inertes tant que personne ne les a posées
//=========================================================================
if (PinnedShapeDigest != 0)
{
TestEqual(TEXT("SHAPE digest matches the pinned cross-platform reference"),
ShapeDigest, PinnedShapeDigest);
}
else
{
AddInfo(TEXT("SHAPE digest is not pinned yet. Pin it only once Windows and Linux agree -- ")
TEXT("pinning first would just carve the divergence into the test."));
}
if (PinnedFieldDigest != 0)
{
TestEqual(TEXT("FIELD digest matches the pinned cross-platform reference"),
FieldDigest, PinnedFieldDigest);
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,592 @@
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "HAL/IConsoleManager.h"
#include "VoxelClipmapDesiredTiles.h"
#include "VoxelDensityOpStack.h"
#include "VoxelForgeTestFixture.h"
namespace
{
// The marching-cubes grid contains 32 cells, with one sample of halo on each side.
constexpr int32 MesherGridSize = 35;
constexpr int32 ArchetypeCount = 8;
constexpr int32 PassageGridCount = 2;
constexpr int32 GridSetCount = ArchetypeCount + PassageGridCount;
static bool SameFloatBits(float A, float B)
{
return FMemory::Memcmp(&A, &B, sizeof(float)) == 0;
}
static FIntVector MakeMesherGridOrigin(const FIntVector& TargetChunk, int32 Level)
{
const int32 TileSpanChunks = 1 << Level;
const FIntVector TileCoord = VoxelClipmapDesiredTiles::FloorDiv(
TargetChunk, TileSpanChunks);
const int32 Step = 1 << Level;
return TileCoord * (CHUNK_SIZE << Level) - FIntVector(Step, Step, Step);
}
struct FGridComparison
{
int64 Samples = 0;
int32 Mismatches = 0;
FString FirstMismatch;
TArray<float> BlockDensities;
};
static bool BuildProductionStackForArchetype(
VoxelForgeTest::FTestWorld& World, ECaveGeneratorType Type,
const FIntVector& ChunkCoord, FVoxelOpStack& OutStack, FVoxelOpContext& OutContext)
{
const int32 Seed = World.Settings->GetEffectiveWorldSeed();
const float SpineRadius = World.Settings->GetEffectiveOriginSpineRadius();
const UVoxelStrateManager* Manager = World.StrateManager.Get();
OutContext = FVoxelOpContext();
OutContext.ChunkCoord = ChunkCoord;
OutContext.Seed = static_cast<uint32>(Seed);
OutContext.LayoutVersion = Manager->GetLayoutVersion();
OutContext.WorldRadiusVoxels = World.Settings->GetEffectiveWorldRadiusVoxels();
OutContext.EdgeSealThickness = World.Settings->EdgeSealThickness;
switch (Type)
{
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
{
const FSlabGenerationParams P = Manager->GetSlabParamsForChunk(ChunkCoord);
OutContext.StrateTopWorldZ = P.StrateTopWorldZ;
OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ;
VoxelDensityOps::BuildSlabStack(OutStack, P, Seed, SpineRadius, Manager);
return true;
}
case ECaveGeneratorType::Maze:
{
const FMazeGenerationParams P = Manager->GetMazeParamsForChunk(ChunkCoord);
OutContext.StrateTopWorldZ = P.StrateTopWorldZ;
OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ;
VoxelDensityOps::BuildMazeStack(OutStack, P, Seed, SpineRadius, Manager);
return true;
}
case ECaveGeneratorType::SurfaceWorld:
{
const FSurfaceGenerationParams P = Manager->GetSurfaceParamsForChunk(ChunkCoord);
OutContext.StrateTopWorldZ = P.StrateTopWorldZ;
OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ;
// The generator-grid comparison below covers the resolved biome route. This builder
// exercises the production stack's base surface branch directly.
VoxelDensityOps::BuildSurfaceStack(
OutStack, P, Seed, SpineRadius, Manager, TArray<FSurfaceGenerationParams>(), nullptr);
return true;
}
case ECaveGeneratorType::VerticalShafts:
{
const FVerticalShaftParams P = Manager->GetVerticalShaftParamsForChunk(ChunkCoord);
OutContext.StrateTopWorldZ = P.StrateTopWorldZ;
OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ;
VoxelDensityOps::BuildVerticalShaftStack(OutStack, P, Seed, SpineRadius, Manager);
return true;
}
case ECaveGeneratorType::FloatingIslands:
{
const FFloatingIslandParams P = Manager->GetFloatingIslandParamsForChunk(ChunkCoord);
OutContext.StrateTopWorldZ = P.StrateTopWorldZ;
OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ;
VoxelDensityOps::BuildFloatingIslandStack(OutStack, P, Seed, SpineRadius, Manager);
return true;
}
case ECaveGeneratorType::TunnelNetwork:
case ECaveGeneratorType::Underwater:
{
const FStrateGenerationParams P = Manager->GetGenerationParams(ChunkCoord);
OutContext.StrateTopWorldZ = P.StrateTopWorldZ;
OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ;
VoxelDensityOps::BuildTunnelNetworkStack(OutStack, P, Seed, SpineRadius, Manager);
return true;
}
default:
return false;
}
}
static bool FindChunkSampleRange(int32 AxisOrigin, int32 Step, int32 AxisSize,
int32 TargetChunk, int32& OutMin, int32& OutMax)
{
OutMin = INT32_MAX;
OutMax = INT32_MIN;
for (int32 Index = 0; Index < AxisSize; ++Index)
{
const int32 Coordinate = AxisOrigin + Index * Step;
if (FMath::FloorToInt(static_cast<float>(Coordinate) / CHUNK_SIZE) == TargetChunk)
{
OutMin = FMath::Min(OutMin, Index);
OutMax = FMath::Max(OutMax, Index);
}
}
return OutMin != INT32_MAX;
}
static FGridComparison CompareStackChunkBlockWithScalar(
FVoxelOpStack& Stack, FIntVector GridOrigin, int32 Step,
const FIntVector& TargetChunk)
{
FGridComparison Result;
int32 MinX = 0, MaxX = 0, MinY = 0, MaxY = 0, MinZ = 0, MaxZ = 0;
if (!FindChunkSampleRange(GridOrigin.X, Step, MesherGridSize, TargetChunk.X, MinX, MaxX)
|| !FindChunkSampleRange(GridOrigin.Y, Step, MesherGridSize, TargetChunk.Y, MinY, MaxY)
|| !FindChunkSampleRange(GridOrigin.Z, Step, MesherGridSize, TargetChunk.Z, MinZ, MaxZ))
{
Result.FirstMismatch = TEXT("mesher grid has no samples in the stack's target chunk");
return Result;
}
const int32 SizeX = MaxX - MinX + 1;
const int32 SizeY = MaxY - MinY + 1;
const int32 SizeZ = MaxZ - MinZ + 1;
const FIntVector Origin = GridOrigin + FIntVector(MinX * Step, MinY * Step, MinZ * Step);
const FIntVector Last = Origin + FIntVector(
(SizeX - 1) * Step, (SizeY - 1) * Step, (SizeZ - 1) * Step);
const FBox Box(
FVector((float)Origin.X, (float)Origin.Y, (float)Origin.Z),
FVector((float)Last.X, (float)Last.Y, (float)Last.Z));
TArray<int32> ActiveOps;
const int32 ActiveCount = Stack.BuildActiveOpList(Box, Step, Origin, ActiveOps);
TArray<FVoxelOpSample> Samples;
Samples.SetNumUninitialized(SizeX * SizeY * SizeZ);
for (FVoxelOpSample& Sample : Samples) { Sample = FVoxelOpSample(); }
FVoxelOpBlock Block;
Block.OriginVoxels = Origin;
Block.Step = Step;
Block.SizeX = SizeX;
Block.SizeY = SizeY;
Block.SizeZ = SizeZ;
Block.Samples = Samples.GetData();
Stack.EvalBlock(Block, ActiveOps);
for (int32 Z = 0; Z < SizeZ; ++Z)
for (int32 Y = 0; Y < SizeY; ++Y)
for (int32 X = 0; X < SizeX; ++X)
{
const FIntVector SamplePos = Origin + FIntVector(X * Step, Y * Step, Z * Step);
const float BlockDensity = Block.At(X, Y, Z).Density;
const float ScalarDensity = Stack.EvalInternal(
(float)SamplePos.X, (float)SamplePos.Y, (float)SamplePos.Z);
Result.BlockDensities.Add(BlockDensity);
++Result.Samples;
if (!SameFloatBits(BlockDensity, ScalarDensity))
{
++Result.Mismatches;
if (Result.FirstMismatch.IsEmpty())
{
Result.FirstMismatch = FString::Printf(
TEXT("box=[(%d,%d,%d)..(%d,%d,%d)] active_ops=%d/%d sample=(%d,%d,%d) block=%.9g scalar=%.9g delta=%.9g"),
Origin.X, Origin.Y, Origin.Z, Last.X, Last.Y, Last.Z,
ActiveCount, Stack.Num(), SamplePos.X, SamplePos.Y, SamplePos.Z,
BlockDensity, ScalarDensity, BlockDensity - ScalarDensity);
}
}
}
return Result;
}
static FGridComparison CompareGeneratorBlockWithScalar(
UVoxelGenerator& Generator, FIntVector Origin, int32 Step)
{
FGridComparison Result;
const int32 Count = MesherGridSize * MesherGridSize * MesherGridSize;
Result.BlockDensities.SetNumUninitialized(Count);
Generator.BeginDensityBlock(Origin, Step, MesherGridSize, MesherGridSize, MesherGridSize);
int32 Index = 0;
for (int32 Z = 0; Z < MesherGridSize; ++Z)
for (int32 Y = 0; Y < MesherGridSize; ++Y)
for (int32 X = 0; X < MesherGridSize; ++X, ++Index)
{
const FIntVector SamplePos = Origin + FIntVector(X * Step, Y * Step, Z * Step);
Result.BlockDensities[Index] = Generator.GetDensityAt(
(float)SamplePos.X, (float)SamplePos.Y, (float)SamplePos.Z);
}
Generator.EndDensityBlock();
Index = 0;
for (int32 Z = 0; Z < MesherGridSize; ++Z)
for (int32 Y = 0; Y < MesherGridSize; ++Y)
for (int32 X = 0; X < MesherGridSize; ++X, ++Index)
{
const FIntVector SamplePos = Origin + FIntVector(X * Step, Y * Step, Z * Step);
const float ScalarDensity = Generator.GetDensityAt(
(float)SamplePos.X, (float)SamplePos.Y, (float)SamplePos.Z);
++Result.Samples;
if (!SameFloatBits(Result.BlockDensities[Index], ScalarDensity))
{
++Result.Mismatches;
if (Result.FirstMismatch.IsEmpty())
{
Result.FirstMismatch = FString::Printf(
TEXT("sample=(%d,%d,%d) block=%.9g scalar=%.9g delta=%.9g"),
SamplePos.X, SamplePos.Y, SamplePos.Z, Result.BlockDensities[Index],
ScalarDensity, Result.BlockDensities[Index] - ScalarDensity);
}
}
}
return Result;
}
static int32 FindGapChunkZ(const VoxelForgeTest::FTestWorld& World)
{
const int32 Min = World.BottomChunkZ - 8;
const int32 Max = World.TopChunkZ + 8;
for (int32 ChunkZ = Min; ChunkZ <= Max; ++ChunkZ)
{
if (World.StrateManager->IsGapChunk(FIntVector(0, 0, ChunkZ)))
{
return ChunkZ;
}
}
return MIN_int32;
}
static int32 CountGapSamples(const VoxelForgeTest::FTestWorld& World,
FIntVector Origin, int32 Step)
{
TSet<int32> SampleChunkZs;
for (int32 Z = 0; Z < MesherGridSize; ++Z)
{
SampleChunkZs.Add(FMath::FloorToInt(
static_cast<float>(Origin.Z + Z * Step) / CHUNK_SIZE));
}
int32 Count = 0;
for (const int32 ChunkZ : SampleChunkZs)
{
if (World.StrateManager->IsGapChunk(FIntVector(0, 0, ChunkZ)))
{
++Count;
}
}
return Count;
}
struct FScopedConsoleIntOverride
{
IConsoleVariable* Variable = nullptr;
int32 OriginalValue = 0;
explicit FScopedConsoleIntOverride(const TCHAR* Name, int32 Value)
: Variable(IConsoleManager::Get().FindConsoleVariable(Name))
{
if (Variable != nullptr)
{
OriginalValue = Variable->GetInt();
Variable->Set(Value, ECVF_SetByCode);
}
}
~FScopedConsoleIntOverride()
{
if (Variable != nullptr)
{
Variable->Set(OriginalValue, ECVF_SetByCode);
}
}
};
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeDensityBlockEquivalenceTest,
"VoxelForge.Density.OperatorBlockMatchesScalarGrid",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeDensityBlockEquivalenceTest::RunTest(const FString& Parameters)
{
(void)Parameters;
// The production mesher initializes these once at startup. This headless test does so too,
// then temporarily disables the fused TunnelNetwork evaluator so the real per-chunk
// BeginDensityBlock -> BuildActiveOpList -> EvalBlock path is compared against scalar output.
VoxelGenLOD::InitializeConsoleSwitches();
FScopedConsoleIntOverride FusedEvaluatorOverride(TEXT("voxel.UseFusedEvaluator"), 0);
if (FusedEvaluatorOverride.Variable == nullptr)
{
AddError(TEXT("voxel.UseFusedEvaluator cvar is unavailable; block route was not forced"));
return false;
}
static const int32 Seeds[] = {1337, 9176, 17015, 24854, 32693, 40532};
static const ECaveGeneratorType Archetypes[ArchetypeCount] = {
ECaveGeneratorType::TunnelNetwork,
ECaveGeneratorType::FlatPlain,
ECaveGeneratorType::CrystalChamber,
ECaveGeneratorType::Maze,
ECaveGeneratorType::SurfaceWorld,
ECaveGeneratorType::VerticalShafts,
ECaveGeneratorType::FloatingIslands,
ECaveGeneratorType::Underwater,
};
int64 GeneratorSamples = 0;
int32 GeneratorMismatches = 0;
FString FirstGeneratorMismatch;
int64 DirectStackSamples = 0;
int32 DirectStackMismatches = 0;
FString FirstDirectStackMismatch;
int32 DirectStackBlocks = 0;
int32 GeneratorBlocks = 0;
int32 ProductionArchetypeBlocks = 0;
int32 BoundaryBlocks = 0;
int32 NearOriginBlocks = 0;
int32 FarOriginBlocks = 0;
int32 GapCoverageBlocks = 0;
int32 PassageFocusedBlocks = 0;
int32 SpecialGapCoverageBlocks = 0;
int32 EditedSamplesChanged = 0;
int32 AcceptedEdits = 0;
int32 ArchetypeLevelCounts[ArchetypeCount][6] = {};
for (int32 SeedIndex = 0; SeedIndex < UE_ARRAY_COUNT(Seeds); ++SeedIndex)
{
VoxelForgeTest::FTestWorld World;
World.Build(Seeds[SeedIndex], /*gap chunks=*/2, /*operator stack=*/true);
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
// The fixture uses coarse-LOD edits up to 64 voxels wide. Keep the actual diff layer in
// the route while allowing those bounded test brushes.
World.DiffLayer->SetBudget(/*max mods=*/0, /*max radius=*/256.0f,
/*max accumulated volume=*/0.0f);
const TArray<FStrateSlot>& Slots = World.StrateManager->GetLayout();
const TArray<FVoxelPassage>& Passages = World.StrateManager->GetPassages();
if (Slots.Num() < ArchetypeCount || Passages.IsEmpty())
{
AddError(FString::Printf(
TEXT("seed %d is missing an archetype slot or passage fixture"), Seeds[SeedIndex]));
return false;
}
const int32 GapChunkZ = FindGapChunkZ(World);
if (GapChunkZ == MIN_int32)
{
AddError(FString::Printf(TEXT("seed %d produced no gap chunk fixture"), Seeds[SeedIndex]));
return false;
}
FIntVector GridOrigins[GridSetCount];
int32 GridSteps[GridSetCount] = {};
int32 GridArchetypes[GridSetCount] = {};
FGridComparison BaseResults[GridSetCount];
// One complete mesher lattice for each production archetype per seed. Rotating level by
// seed gives every archetype all levels 0..5 over the six seeds. The sample grids touch a
// strate edge, and cycle through origin, near-origin, and both far-field signs.
for (int32 ArchetypeIndex = 0; ArchetypeIndex < ArchetypeCount; ++ArchetypeIndex)
{
const int32 Level = (SeedIndex + ArchetypeIndex) % 6;
const int32 Step = 1 << Level;
const FStrateSlot& Slot = Slots[ArchetypeIndex];
const int32 TargetChunkZ = (SeedIndex & 1) ? Slot.BottomChunkZ : Slot.TopChunkZ;
FIntVector TargetChunk;
switch ((SeedIndex + ArchetypeIndex) % 4)
{
case 0: TargetChunk = FIntVector(0, 0, TargetChunkZ); break;
case 1: TargetChunk = FIntVector(128, 96, TargetChunkZ); ++FarOriginBlocks; break;
case 2: TargetChunk = FIntVector(8, -8, TargetChunkZ); ++NearOriginBlocks; break;
default: TargetChunk = FIntVector(-128, -96, TargetChunkZ); ++FarOriginBlocks; break;
}
if ((SeedIndex + ArchetypeIndex) % 4 == 0) { ++NearOriginBlocks; }
const int32 GridIndex = ArchetypeIndex;
GridOrigins[GridIndex] = MakeMesherGridOrigin(TargetChunk, Level);
GridSteps[GridIndex] = Step;
GridArchetypes[GridIndex] = ArchetypeIndex;
FVoxelOpStack Stack;
FVoxelOpContext Context;
if (!BuildProductionStackForArchetype(
World, Archetypes[ArchetypeIndex], TargetChunk, Stack, Context))
{
AddError(FString::Printf(
TEXT("seed %d archetype %d production stack could not be built"),
Seeds[SeedIndex], ArchetypeIndex));
return false;
}
Context.Step = 1;
Stack.PrepareChunk(Context);
const FGridComparison DirectComparison = CompareStackChunkBlockWithScalar(
Stack, GridOrigins[GridIndex], Step, TargetChunk);
DirectStackSamples += DirectComparison.Samples;
DirectStackMismatches += DirectComparison.Mismatches;
++DirectStackBlocks;
if (FirstDirectStackMismatch.IsEmpty() && DirectComparison.Mismatches > 0)
{
FirstDirectStackMismatch = FString::Printf(
TEXT("seed=%d archetype=%d level=%d chunk=%s %s"),
Seeds[SeedIndex], ArchetypeIndex, Level, *TargetChunk.ToString(),
*DirectComparison.FirstMismatch);
}
++ArchetypeLevelCounts[ArchetypeIndex][Level];
++ProductionArchetypeBlocks;
++BoundaryBlocks;
if (!World.StrateManager->UsesOperatorStackForChunk(TargetChunk)
|| World.StrateManager->GetGeneratorTypeForChunk(TargetChunk)
!= Archetypes[ArchetypeIndex])
{
AddError(FString::Printf(
TEXT("seed %d archetype %d target chunk did not route to its production operator stack"),
Seeds[SeedIndex], ArchetypeIndex));
return false;
}
}
// Passage-mouth lattices centered in a real inter-strate gap exercise the routed
// TunnelNetwork/Underwater block implementation through boundaries and bedrock holes.
for (int32 PassageGrid = 0; PassageGrid < PassageGridCount; ++PassageGrid)
{
const int32 GridIndex = ArchetypeCount + PassageGrid;
const int32 ArchetypeIndex = PassageGrid == 0 ? 0 : 7;
const int32 Level = (SeedIndex + (PassageGrid == 0 ? 0 : 7)) % 6;
const int32 Step = 1 << Level;
const FVoxelPassage& Passage = Passages[(SeedIndex + PassageGrid) % Passages.Num()];
const FVector Focus = PassageGrid == 0 ? Passage.UpperPoint : Passage.LowerPoint;
const FIntVector TargetChunk(
FMath::FloorToInt(Focus.X / CHUNK_SIZE),
FMath::FloorToInt(Focus.Y / CHUNK_SIZE),
GapChunkZ);
GridOrigins[GridIndex] = MakeMesherGridOrigin(TargetChunk, Level);
GridSteps[GridIndex] = Step;
GridArchetypes[GridIndex] = ArchetypeIndex;
++PassageFocusedBlocks;
if (CountGapSamples(World, GridOrigins[GridIndex], Step) > 0)
{
++GapCoverageBlocks;
++SpecialGapCoverageBlocks;
}
}
// No-edit grids. In the two canonical network archetypes this enters the actual worker
// block session, which partitions each mesher grid by resolved chunk before running the
// active operator list. The six legacy density routes still run their production scalar
// evaluator, as they do outside the block-safe network branch.
for (int32 GridIndex = 0; GridIndex < GridSetCount; ++GridIndex)
{
BaseResults[GridIndex] = CompareGeneratorBlockWithScalar(
*World.Generator.Get(), GridOrigins[GridIndex], GridSteps[GridIndex]);
GeneratorSamples += BaseResults[GridIndex].Samples;
GeneratorMismatches += BaseResults[GridIndex].Mismatches;
++GeneratorBlocks;
if (FirstGeneratorMismatch.IsEmpty() && BaseResults[GridIndex].Mismatches > 0)
{
FirstGeneratorMismatch = FString::Printf(
TEXT("seed=%d archetype=%d level=%d edited=0 origin=%s %s"),
Seeds[SeedIndex], GridArchetypes[GridIndex],
FMath::FloorLog2(GridSteps[GridIndex]), *GridOrigins[GridIndex].ToString(),
*BaseResults[GridIndex].FirstMismatch);
}
}
// Put one real diff-layer box edit in every mesher grid, then compare the same exact
// density lattices again. The scalar pass below runs with these player edits present.
for (int32 GridIndex = 0; GridIndex < GridSetCount; ++GridIndex)
{
const int32 Step = GridSteps[GridIndex];
FVoxelModification Edit;
Edit.Shape = EVoxelBrushShape::Box;
Edit.Center = FVector(GridOrigins[GridIndex]) + FVector(17 * Step);
Edit.BoxExtent = FVector(2.0f * Step);
Edit.Falloff = 1.0f;
Edit.Strength = -13.0f;
const TArray<FIntVector> ChangedChunks = World.DiffLayer->ApplyModification(Edit);
if (ChangedChunks.IsEmpty())
{
AddError(FString::Printf(
TEXT("seed %d edit for grid %d archetype %d was rejected"),
Seeds[SeedIndex], GridIndex, GridArchetypes[GridIndex]));
return false;
}
++AcceptedEdits;
}
for (int32 GridIndex = 0; GridIndex < GridSetCount; ++GridIndex)
{
const FGridComparison Edited = CompareGeneratorBlockWithScalar(
*World.Generator.Get(), GridOrigins[GridIndex], GridSteps[GridIndex]);
GeneratorSamples += Edited.Samples;
GeneratorMismatches += Edited.Mismatches;
++GeneratorBlocks;
if (FirstGeneratorMismatch.IsEmpty() && Edited.Mismatches > 0)
{
FirstGeneratorMismatch = FString::Printf(
TEXT("seed=%d archetype=%d level=%d edited=1 origin=%s %s"),
Seeds[SeedIndex], GridArchetypes[GridIndex],
FMath::FloorLog2(GridSteps[GridIndex]), *GridOrigins[GridIndex].ToString(),
*Edited.FirstMismatch);
}
const int32 SharedCount = FMath::Min(
Edited.BlockDensities.Num(), BaseResults[GridIndex].BlockDensities.Num());
for (int32 SampleIndex = 0; SampleIndex < SharedCount; ++SampleIndex)
{
if (!SameFloatBits(Edited.BlockDensities[SampleIndex],
BaseResults[GridIndex].BlockDensities[SampleIndex]))
{
++EditedSamplesChanged;
}
}
}
}
bool bEveryArchetypeCoveredEveryLevel = true;
for (int32 ArchetypeIndex = 0; ArchetypeIndex < ArchetypeCount; ++ArchetypeIndex)
{
for (int32 Level = 0; Level < 6; ++Level)
{
bEveryArchetypeCoveredEveryLevel &= ArchetypeLevelCounts[ArchetypeIndex][Level] > 0;
}
}
AddInfo(FString::Printf(
TEXT("BeginDensityBlock grid vs scalar GetDensityAt: seeds=%d archetype_grids=%d all_archetype_levels=%d boundary_grids=%d near_origin=%d far_origin=%d blocks=%d samples=%lld mismatches=%d gap_grids=%d passage_focused=%d accepted_edits=%d edited_samples_changed=%d first=%s"),
UE_ARRAY_COUNT(Seeds), ProductionArchetypeBlocks,
bEveryArchetypeCoveredEveryLevel ? 1 : 0, BoundaryBlocks, NearOriginBlocks,
FarOriginBlocks, GeneratorBlocks, static_cast<long long>(GeneratorSamples),
GeneratorMismatches, GapCoverageBlocks, PassageFocusedBlocks, AcceptedEdits,
EditedSamplesChanged,
FirstGeneratorMismatch.IsEmpty() ? TEXT("none") : *FirstGeneratorMismatch));
AddInfo(FString::Printf(
TEXT("chunk-local direct EvalBlock vs EvalInternal: blocks=%d mesher_samples=%lld mismatches=%d first=%s"),
DirectStackBlocks, static_cast<long long>(DirectStackSamples),
DirectStackMismatches,
FirstDirectStackMismatch.IsEmpty() ? TEXT("none") : *FirstDirectStackMismatch));
TestEqual(TEXT("all eight production operator archetypes get six complete mesher grids"),
ProductionArchetypeBlocks,
static_cast<int32>(UE_ARRAY_COUNT(Seeds)) * ArchetypeCount);
TestTrue(TEXT("every production operator archetype covers every meshing level 0..5"),
bEveryArchetypeCoveredEveryLevel);
TestEqual(TEXT("all production archetype grids touch a strate boundary"),
BoundaryBlocks, ProductionArchetypeBlocks);
TestTrue(TEXT("test includes near-origin and both-sign far-field grids"),
NearOriginBlocks > 0 && FarOriginBlocks > 0);
TestEqual(TEXT("each passage-focused grid includes a real gap sample"),
SpecialGapCoverageBlocks,
static_cast<int32>(UE_ARRAY_COUNT(Seeds)) * PassageGridCount);
TestEqual(TEXT("passage-focused grids cover both network archetypes per seed"),
PassageFocusedBlocks,
static_cast<int32>(UE_ARRAY_COUNT(Seeds)) * PassageGridCount);
TestEqual(TEXT("all block-session density grids match scalar output bit-for-bit"),
GeneratorMismatches, 0);
TestEqual(TEXT("all production stack chunk blocks match scalar bit-for-bit"),
DirectStackMismatches, 0);
TestEqual(TEXT("one valid player edit was applied to every tested grid"),
AcceptedEdits, static_cast<int32>(UE_ARRAY_COUNT(Seeds)) * GridSetCount);
TestTrue(TEXT("player edits alter samples consumed by the mesher"),
EditedSamplesChanged > 0);
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,387 @@
// VoxelForgeDensityPurityTest.cpp
// Phase 0.5 test #1 — LA PURETÉ DE LA DENSITÉ / density purity.
//
// L'INVARIANT / THE INVARIANT (ARCHITECTURE §8.4, "window invariance"):
// GetDensityAt(x,y,z) est une fonction PURE de (coords monde, seed, layout). Le même point
// interrogé depuis une autre tuile, un autre ordre de requêtes ou un autre thread doit rendre
// le float BIT-IDENTIQUE. Pas "proche" — identique : un écart d'1 ULP entre deux fenêtres de
// chunk est une COUTURE visible, et en multijoueur une divergence de monde.
//
// GetDensityAt is a PURE function of (world coords, seed, layout). The same point queried from
// a different tile, in a different order, or on a different thread must return the BIT-IDENTICAL
// float. Not "close" — identical: a 1-ULP disagreement between two chunk windows is a visible
// seam, and in multiplayer a world divergence.
//
// POURQUOI CE TEST EXISTE / WHY THIS TEST EXISTS:
// ~30 caches thread_local à clé manuelle vivent sous GetDensityAt (CP_*, GSurfColCache, les
// slots de diff, le cache SDF). Chacun est correct exactement tant que sa CLÉ contient toutes
// les entrées dont dépend la valeur cachée. Une entrée oubliée ne casse rien tout de suite :
// elle produit une mauvaise valeur seulement quand le cache est chaud pour une AUTRE entrée —
// c'est-à-dire de façon intermittente, dépendante de l'ordre, et invisible en jeu jusqu'à ce
// qu'un joueur trouve la couture. C'est exactement ainsi que AUDIT C2 s'est caché.
//
// ~30 hand-keyed thread_local caches live under GetDensityAt. Each is correct exactly as long as
// its KEY contains every input the cached value depends on. A forgotten input breaks nothing
// immediately: it yields a wrong value only when the cache is warm for a DIFFERENT input — i.e.
// intermittently, order-dependently, invisible in play until a player finds the seam. That is
// precisely how AUDIT C2 stayed hidden.
//
// AVoxelWorld::ValidateDeterminism existe déjà mais tourne sur le GAME THREAD uniquement : il ne
// peut structurellement pas voir une divergence de cache worker. Ce test tourne multi-thread.
// AVoxelWorld::ValidateDeterminism already exists but runs on the GAME THREAD only: it
// structurally cannot see a worker-cache divergence. This test runs multi-threaded.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformMisc.h"
#include "VoxelForgeTestFixture.h"
#include <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeDensityPurityTest,
"VoxelForge.Determinism.DensityPurity",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
// Assez de points pour traverser plusieurs chunks/strates et faire tourner tous les caches,
// assez peu pour rester sous la seconde. / Enough points to cross many chunks and strates and
// churn every cache, few enough to stay under a second.
constexpr int32 NumSamples = 10000;
struct FMismatch
{
std::atomic<int32> Count{ 0 };
std::atomic<int32> FirstIndex{ -1 };
void Record(int32 Index)
{
Count.fetch_add(1, std::memory_order_relaxed);
int32 Expected = -1;
FirstIndex.compare_exchange_strong(Expected, Index, std::memory_order_relaxed);
}
};
/** Report the first divergent point with both floats and their raw bits — a mismatch that is
* invisible in decimal (a 1-ULP cache seam) is the exact case this test is for. */
FString DescribeMismatch(const FVector& P, float Ref, float Got)
{
return FString::Printf(
TEXT("at (%.0f, %.0f, %.0f): reference %.9g [0x%08X] vs re-sample %.9g [0x%08X]"),
P.X, P.Y, P.Z,
Ref, *reinterpret_cast<const uint32*>(&Ref),
Got, *reinterpret_cast<const uint32*>(&Got));
}
}
bool FVoxelForgeDensityPurityTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
TArray<FVector> Points;
BuildSamplePoints(World, NumSamples, /*Seed*/ 20260727, Points);
// ── Référence : ordre linéaire, thread de jeu, caches chauds naturellement. ──
TArray<float> Ref;
Ref.SetNumUninitialized(NumSamples);
for (int32 i = 0; i < NumSamples; ++i)
{
Ref[i] = Gen->GetDensityAt((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
// Un monde entièrement NaN/constant passerait tout ce qui suit trivialement. Vérifier qu'on
// mesure bien un vrai champ. / An all-NaN or constant world would pass everything below
// trivially. Check we are measuring a real field. (This is also the canary for AUDIT C1: a
// large seed collapses the noise terms and the field goes constant.)
{
int32 NumFinite = 0, NumDistinct = 0;
TSet<uint32> Seen;
for (const float V : Ref)
{
if (FMath::IsFinite(V)) { ++NumFinite; }
Seen.Add(*reinterpret_cast<const uint32*>(&V));
}
NumDistinct = Seen.Num();
TestEqual(TEXT("every density sample is finite (no NaN/Inf leaking out of the generator)"),
NumFinite, NumSamples);
if (NumDistinct < NumSamples / 100)
{
AddError(FString::Printf(
TEXT("The density field is suspiciously flat: only %d distinct values across %d ")
TEXT("samples. Either the fixture built an empty world, or the noise field has ")
TEXT("collapsed (see AUDIT-2026-07.md C1 — unbounded SeedF). The purity checks ")
TEXT("below would pass trivially on a constant field, so they prove nothing here."),
NumDistinct, NumSamples));
}
}
// ── 1. INDÉPENDANCE À L'ORDRE, même thread. ──
// Un cache dont la clé est incomplète rend une valeur différente selon ce qui l'a précédé.
// An incompletely-keyed cache returns a different value depending on what preceded it.
{
TArray<int32> Order;
BuildShuffledOrder(NumSamples, /*Seed*/ 991, Order);
int32 Mismatches = 0;
FString First;
for (const int32 i : Order)
{
const float Got = Gen->GetDensityAt((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(Got, Ref[i]))
{
if (Mismatches == 0) { First = DescribeMismatch(Points[i], Ref[i], Got); }
++Mismatches;
}
}
if (Mismatches > 0)
{
AddError(FString::Printf(
TEXT("ORDER DEPENDENCE: %d of %d points changed value when queried in a different ")
TEXT("order on the SAME thread. A per-chunk cache is missing an input from its key. ")
TEXT("First: %s"), Mismatches, NumSamples, *First));
}
}
// ── 2. INDÉPENDANCE AU THREAD. ──
// C'est la moitié que ValidateDeterminism (game-thread) ne peut pas voir. Chaque worker
// parcourt SON propre ordre mélangé, donc ses thread_local se réchauffent différemment.
// This is the half game-thread ValidateDeterminism cannot see. Each worker walks its OWN
// shuffled order, so its thread_locals warm up differently.
{
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
FMismatch Bad;
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> Order;
BuildShuffledOrder(NumSamples, /*Seed*/ 4000 + Block, Order);
const UVoxelGenerator* LocalGen = World.Generator.Get();
for (const int32 i : Order)
{
// Chaque bloc parcourt TOUS les points (pas seulement une tranche) : c'est le
// parcours complet dans un ordre différent qui réchauffe les caches thread_local
// différemment, et c'est exactement ce qu'on cherche à faire diverger.
// Every block walks ALL the points, not a slice: it is the full walk in a
// different order that warms the thread_local caches differently, which is
// precisely what we are trying to make diverge.
const float V = LocalGen->GetDensityAt(
(float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(V, Ref[i])) { Bad.Record(i); }
}
});
const int32 Count = Bad.Count.load();
if (Count > 0)
{
const int32 Idx = Bad.FirstIndex.load();
AddError(FString::Printf(
TEXT("WORKER DIVERGENCE: %d sample evaluations on worker threads disagreed with the ")
TEXT("game-thread reference. This is the failure mode AVoxelWorld::ValidateDeterminism ")
TEXT("cannot detect, and it means a thread_local cache under GetDensityAt is serving a ")
TEXT("value it should not. First: %s"),
Count, *DescribeMismatch(Points[Idx], Ref[Idx],
Gen->GetDensityAt((float)Points[Idx].X, (float)Points[Idx].Y,
(float)Points[Idx].Z))));
}
}
// ── 3. PURETÉ AVEC LA COUCHE DE DIFF ACTIVE. ──
// Les DiffSlots sont un cache direct-mapped à 64 entrées, indexé par les bits bas du chunk.
// Une collision servirait la liste de mods d'un AUTRE chunk : un carve fantôme à distance.
// DiffSlots is a 64-entry direct-mapped cache indexed by the chunk coord's low bits. A
// collision would serve another chunk's mod list: a ghost carve at a distance.
{
FVoxelModification Mod;
Mod.Shape = EVoxelBrushShape::Sphere;
Mod.Radius = 12.0f;
Mod.Strength = -10.0f;
for (int32 k = 0; k < 8; ++k)
{
Mod.Center = FVector((float)(k * CHUNK_SIZE), (float)(-k * CHUNK_SIZE), World.MidVoxelZ());
World.DiffLayer->ApplyModification(Mod);
}
TestTrue(TEXT("the diff layer registered the test carves"), World.DiffLayer->HasAnyMods());
TArray<float> DiffRef;
DiffRef.SetNumUninitialized(NumSamples);
for (int32 i = 0; i < NumSamples; ++i)
{
DiffRef[i] = Gen->GetDensityAt((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
FMismatch Bad;
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> Order;
BuildShuffledOrder(NumSamples, /*Seed*/ 7000 + Block, Order);
const UVoxelGenerator* LocalGen = World.Generator.Get();
for (const int32 i : Order)
{
const float V = LocalGen->GetDensityAt(
(float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(V, DiffRef[i])) { Bad.Record(i); }
}
});
const int32 Count = Bad.Count.load();
if (Count > 0)
{
const int32 Idx = Bad.FirstIndex.load();
AddError(FString::Printf(
TEXT("DIFF-LAYER IMPURITY: %d evaluations diverged with player edits present. ")
TEXT("Suspect the direct-mapped DiffSlots cache in GetDensityAt (chunk low-bit ")
TEXT("index + ModsVersion). First mismatch index %d at (%.0f, %.0f, %.0f)."),
Count, Idx, Points[Idx].X, Points[Idx].Y, Points[Idx].Z));
}
// Et le carve doit vraiment avoir changé quelque chose, sinon le sous-test ci-dessus
// n'a rien testé. / And the carve must actually have changed something, else the sub-test
// above tested nothing.
int32 NumChanged = 0;
for (int32 i = 0; i < NumSamples; ++i)
{
if (!BitEqual(DiffRef[i], Ref[i])) { ++NumChanged; }
}
if (NumChanged == 0)
{
AddError(TEXT("No sample changed after applying 8 carves — the diff layer branch of ")
TEXT("GetDensityAt was never exercised, so the purity check above is vacuous. ")
TEXT("Move the carve centres so they overlap the sample cloud."));
}
}
return true;
}
//=============================================================================
// AUDIT C2 — L'INVALIDATION APRÈS ÉDITION À CHAUD / live-edit invalidation
//=============================================================================
// La pureté ci-dessus teste « le même monde répond toujours pareil ». Ce test-ci teste l'inverse,
// et c'est le bug réellement observé : après un RebuildStrates / une édition d'asset dans
// l'éditeur, un worker dont le cache par-chunk est encore chaud pour ce chunk DOIT re-résoudre ses
// params. Sinon il génère avec les ANCIENS — symptôme : « j'ai retouché la strate, régénéré, et une
// zone a gardé l'ancienne forme ».
//
// The purity test above checks "the same world always answers the same". This checks the opposite,
// and it is the bug actually observed: after a layout rebuild, a warm per-chunk cache MUST refetch.
//
// Le test échantillonne D0, mute un param de terrain qui NE déplace PAS la strate (donc StrateKey
// et toutes les autres clés existantes restent identiques), ré-initialise, et exige que la densité
// AIT CHANGÉ au même point sur le MÊME thread. Sans le correctif de version de layout, elle ne
// change pas et ce test échoue.
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeLiveEditInvalidationTest,
"VoxelForge.Determinism.LiveEditInvalidation",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeLiveEditInvalidationTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
int32 SurfTopZ = 0, SurfBotZ = 0;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, SurfTopZ, SurfBotZ))
{
AddError(TEXT("The fixture layout has no SurfaceWorld slot, so there is no heightfield to ")
TEXT("live-edit. Check FTestWorld::Build's Archetypes[] against the slot constants."));
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
// Colonne bien à l'écart de (0,0) : la spine et l'entrée de surface y forcent de l'air et
// masqueraient un changement de heightfield.
TArray<FVector> Probes;
for (int32 i = 0; i < 64; ++i)
{
Probes.Add(FVector(200.0f + i * 7.0f, -140.0f + i * 5.0f,
(float)((SurfTopZ + SurfBotZ) / 2)));
}
TArray<float> Before;
Before.Reserve(Probes.Num());
for (const FVector& P : Probes)
{
Before.Add(Gen->GetDensityAt((float)P.X, (float)P.Y, (float)P.Z));
}
// ── L'édition. Choisie pour NE PAS bouger la strate : StrateBottomWorldZ est inchangé, donc
// StrateKey, le seed et les coords de chunk sont tous identiques à avant. La SEULE chose
// qui bouge est la version de layout. / The edit is chosen NOT to move the strate: the only
// thing that changes is the layout version.
UVoxelStrateDefinition* SurfDef = World.Definitions[FTestWorld::SlotSurfaceWorld].Get();
SurfDef->SurfaceParams.ElevationRange *= 2.5f;
SurfDef->SurfaceParams.MountainStrength = FMath::Min(1.0f, SurfDef->SurfaceParams.MountainStrength + 0.4f);
SurfDef->SurfaceParams.ContinentFrequency *= 1.7f;
World.Reinitialize();
int32 NumChanged = 0;
for (int32 i = 0; i < Probes.Num(); ++i)
{
const float After = Gen->GetDensityAt((float)Probes[i].X, (float)Probes[i].Y, (float)Probes[i].Z);
if (!BitEqual(After, Before[i])) { ++NumChanged; }
}
if (NumChanged == 0)
{
AddError(FString::Printf(
TEXT("STALE PARAMS (AUDIT C2): the SurfaceWorld heightfield params were tripled and the ")
TEXT("layout rebuilt, yet all %d probe densities are bit-identical. A per-chunk cache is ")
TEXT("still keyed on ChunkCoord alone and skipped its refetch. Suspects, in order: ")
TEXT("CP_Chunk/CP_Version in GetDensityAt, the GSurfColCache box key, and the ")
TEXT("FChunkBiomeCache validity box (which says nothing about the FBiomeContext its ")
TEXT("cells were classified against)."), Probes.Num()));
}
else
{
AddInfo(FString::Printf(TEXT("%d of %d probes moved after the live edit — caches refetched."),
NumChanged, Probes.Num()));
}
// Et le monde édité doit rester pur : une invalidation qui laisse un cache à moitié chaud
// produirait des valeurs dépendantes de l'ordre. / And the edited world must stay pure.
{
TArray<int32> Order;
BuildShuffledOrder(Probes.Num(), 555, Order);
TArray<float> After;
After.SetNumZeroed(Probes.Num());
for (const int32 i : Order)
{
After[i] = Gen->GetDensityAt((float)Probes[i].X, (float)Probes[i].Y, (float)Probes[i].Z);
}
int32 Impure = 0;
for (const int32 i : Order)
{
const float Again = Gen->GetDensityAt((float)Probes[i].X, (float)Probes[i].Y, (float)Probes[i].Z);
if (!BitEqual(Again, After[i])) { ++Impure; }
}
TestEqual(TEXT("the world is still order-independent after a live edit"), Impure, 0);
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,102 @@
// Pinned contract for VoxelMath::DetSin/DetCos/DetSinCos.
#include "Misc/AutomationTest.h"
#include "VoxelTypes.h"
#include <math.h>
namespace
{
struct FDetSinCosGolden
{
float Input;
uint32 SinBits;
uint32 CosBits;
};
uint32 FloatBits(float Value)
{
uint32 Bits = 0;
FMemory::Memcpy(&Bits, &Value, sizeof(Bits));
return Bits;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeDetSinCosTest,
"VoxelForge.Determinism.DetSinCos",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeDetSinCosTest::RunTest(const FString& Parameters)
{
(void)Parameters;
const float Pi = 3.1415926535897932384626433832795f;
const float TwoPi = 6.283185307179586476925286766559f;
// The expected values are deliberately bit patterns rather than tolerances. This corpus is
// the pinned implementation contract; update it only with an intentional world re-tune.
static const FDetSinCosGolden Corpus[] =
{
{ 0.0f, 0x00000000u, 0x3F800000u },
{-0.0f, 0x80000000u, 0x3F800000u },
{ Pi, 0xB3BBBD2Eu, 0xBF800000u },
{-Pi, 0x33BBBD2Eu, 0xBF800000u },
{ TwoPi, 0x343BBD2Eu, 0x3F800000u },
{-TwoPi, 0xB43BBD2Eu, 0x3F800000u },
{ 0.001f, 0x3A83126Du, 0x3F7FFFF8u },
{-0.001f, 0xBA83126Du, 0x3F7FFFF8u },
{ 123456.789f, 0xBF7FA83Du, 0x3D53E806u },
{-123456.789f, 0x3F7FA83Du, 0x3D53E806u },
{ 1000000.25f, 0xBDDBDDEBu, 0x3F7E853Du },
{-1000000.25f, 0x3DDBDDEBu, 0x3F7E853Du },
};
for (const FDetSinCosGolden& Sample : Corpus)
{
float ActualSin = 0.0f;
float ActualCos = 0.0f;
VoxelMath::DetSinCos(ActualSin, ActualCos, Sample.Input);
AddInfo(FString::Printf(
TEXT("DetSinCos input %.9g -> sin=0x%08X cos=0x%08X"),
Sample.Input, FloatBits(ActualSin), FloatBits(ActualCos)));
TestEqual(
FString::Printf(TEXT("sin bits for %.9g"), Sample.Input),
FloatBits(ActualSin), Sample.SinBits);
TestEqual(
FString::Printf(TEXT("cos bits for %.9g"), Sample.Input),
FloatBits(ActualCos), Sample.CosBits);
TestEqual(
FString::Printf(TEXT("DetSin agrees with paired sin for %.9g"), Sample.Input),
FloatBits(VoxelMath::DetSin(Sample.Input)), FloatBits(ActualSin));
TestEqual(
FString::Printf(TEXT("DetCos agrees with paired cos for %.9g"), Sample.Input),
FloatBits(VoxelMath::DetCos(Sample.Input)), FloatBits(ActualCos));
}
// The canonical export measured |argument| <= 6.19255877. Sweep a small margin around that
// envelope; the large corpus above separately exercises the double range reduction.
constexpr float MeasuredWorldArgumentMax = 6.25f;
constexpr int32 SweepSamples = 200001;
float MaxSinError = 0.0f;
float MaxCosError = 0.0f;
for (int32 Index = 0; Index < SweepSamples; ++Index)
{
const float Alpha = -MeasuredWorldArgumentMax
+ (2.0f * MeasuredWorldArgumentMax)
* static_cast<float>(Index) / static_cast<float>(SweepSamples - 1);
float ActualSin = 0.0f;
float ActualCos = 0.0f;
VoxelMath::DetSinCos(ActualSin, ActualCos, Alpha);
MaxSinError = FMath::Max(MaxSinError, FMath::Abs(ActualSin - ::sinf(Alpha)));
MaxCosError = FMath::Max(MaxCosError, FMath::Abs(ActualCos - ::cosf(Alpha)));
}
AddInfo(FString::Printf(
TEXT("DetSinCos max abs error vs sinf/cosf over [-%.1f,+%.1f], %d samples: "
"sin %.9g, cos %.9g"),
MeasuredWorldArgumentMax, MeasuredWorldArgumentMax, SweepSamples,
MaxSinError, MaxCosError));
TestTrue(TEXT("DetSin max error over measured world range <= 2e-6"), MaxSinError <= 2.0e-6f);
TestTrue(TEXT("DetCos max error over measured world range <= 2e-6"), MaxCosError <= 2.0e-6f);
return true;
}
@@ -0,0 +1,196 @@
// VoxelForgeDiffLayerTest.cpp
// Phase 0.5 test #3 — LA COUCHE DE DIFF SOUS CONTENTION / DiffLayer under contention.
//
// LE RISQUE / THE RISK:
// UVoxelDiffLayer::ChunkMods est une TMap LUE par les threads de meshing (via GetDensityAt →
// GetChunkModsSnapshot) et ÉCRITE par le thread de jeu (ApplyModification / Clear). TMap n'est
// pas thread-safe : un rehash pendant une lecture est une violation d'accès. Tout est censé
// passer par ModsLock (FRWLock) — et une AV carve-vs-stream a déjà été corrigée exactement là.
//
// UVoxelDiffLayer::ChunkMods is a TMap READ by mesher workers (through GetDensityAt →
// GetChunkModsSnapshot) and WRITTEN by the game thread (ApplyModification / Clear). TMap is not
// thread-safe: a rehash during a read is an access violation. Everything is meant to go through
// ModsLock (FRWLock) — and a carve-vs-stream AV was already fixed in exactly this spot.
//
// CE QUE CE TEST PROUVE / WHAT THIS TEST PROVES:
// 1. Aucun crash quand N lecteurs martèlent la couche pendant que le thread de jeu écrit.
// 2. ModsVersion ne RECULE jamais du point de vue d'un lecteur (c'est la clé sur laquelle les
// caches de snapshot invalident ; une version non monotone rendrait un cache définitivement
// périmé).
// 3. L'état final est exact : chaque carve appliqué est retrouvable.
// Le point (1) est le vrai but, et il ne peut être prouvé que statistiquement — un test vert
// veut dire "pas reproduit ici", pas "impossible". C'est quand même infiniment mieux que rien.
//
// Point (1) is the real target, and it can only ever be shown statistically — a green run means
// "not reproduced here", not "impossible". Still infinitely better than nothing.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/Async.h"
#include "HAL/PlatformMisc.h"
#include "UObject/StrongObjectPtr.h"
#include "UObject/Package.h"
#include "VoxelTypes.h"
#include "VoxelDiffLayer.h"
#include <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeDiffLayerContentionTest,
"VoxelForge.Determinism.DiffLayerContention",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 NumWrites = 400;
constexpr int32 NumChunksX = 8;
FVoxelModification MakeCarve(int32 Index)
{
FVoxelModification Mod;
Mod.Shape = EVoxelBrushShape::Sphere;
Mod.Radius = 6.0f;
Mod.Strength = -9.0f;
Mod.Center = FVector(
(float)((Index % NumChunksX) * CHUNK_SIZE + 4),
(float)(((Index / NumChunksX) % NumChunksX) * CHUNK_SIZE + 4),
(float)(-((Index / (NumChunksX * NumChunksX)) % 4) * CHUNK_SIZE));
return Mod;
}
}
bool FVoxelForgeDiffLayerContentionTest::RunTest(const FString& Parameters)
{
TStrongObjectPtr<UVoxelDiffLayer> Diff(
NewObject<UVoxelDiffLayer>(GetTransientPackage(), NAME_None, RF_Transient));
Diff->SetBudget(/*MaxMods*/ 0, /*MaxRadius*/ 50.0f, /*MaxVolume*/ 0.0f); // 0 = illimité
const int32 NumReaders = FMath::Max(3, FMath::Min(8, FPlatformMisc::NumberOfCores() - 1));
std::atomic<bool> bStop{ false };
std::atomic<int32> VersionRegressions{ 0 };
std::atomic<int64> ReadOps{ 0 };
// ── Les lecteurs : exactement le mix d'appels que fait le chemin densité d'un worker. ──
// The readers: exactly the call mix a worker's density path makes.
TArray<TFuture<void>> Readers;
Readers.Reserve(NumReaders);
for (int32 R = 0; R < NumReaders; ++R)
{
Readers.Add(Async(EAsyncExecution::Thread, [&, R]()
{
uint32 LastVersion = 0;
int64 LocalOps = 0;
FRandomStream Rng(9000 + R);
while (!bStop.load(std::memory_order_relaxed))
{
const uint32 V = Diff->GetModsVersion();
if (V < LastVersion)
{
VersionRegressions.fetch_add(1, std::memory_order_relaxed);
}
LastVersion = V;
const FIntVector Chunk(Rng.RandRange(0, NumChunksX - 1),
Rng.RandRange(0, NumChunksX - 1),
Rng.RandRange(-3, 0));
// Le fast-reject sans verrou, puis le vrai chemin sous verrou.
if (Diff->HasAnyMods())
{
Diff->HasAnyModInChunkRange(Chunk - FIntVector(1, 1, 1), Chunk + FIntVector(1, 1, 1));
Diff->HasModifications(Chunk);
TArray<FVoxelModification> Snapshot;
Diff->GetChunkModsSnapshot(Chunk, Snapshot);
// Toucher réellement les données copiées : un snapshot qui aliaserait la TMap
// (au lieu de la copier) exploserait ici et pas au moment de la copie.
// Actually touch the copied data: a snapshot that aliased the TMap instead of
// copying it would blow up here rather than at copy time.
const float X = (float)(Chunk.X * CHUNK_SIZE + 3);
const float Y = (float)(Chunk.Y * CHUNK_SIZE + 3);
const float Z = (float)(Chunk.Z * CHUNK_SIZE + 3);
const float Sink = UVoxelDiffLayer::EvaluateMods(Snapshot, X, Y, Z)
+ Diff->GetDensityOffset(Chunk, X, Y, Z);
// Consommer Sink dans une branche que le compilateur ne peut pas prouver morte,
// sinon tout le bloc de lecture est éliminé et le test ne teste rien.
// Consume Sink in a branch the compiler cannot prove dead, otherwise the whole
// read block is optimised away and the test tests nothing.
if (Sink == 1.2345678e30f) { ++LocalOps; }
}
++LocalOps;
}
ReadOps.fetch_add(LocalOps, std::memory_order_relaxed);
}));
}
// ── Phase 1 : écritures pures. L'état final doit être exact. ──
// ⚠️ `GetTotalModificationCount()` ne compte PAS les opérations : il somme les entrées
// STOCKÉES, et un coup de pinceau est rangé dans CHAQUE chunk que son AABB recouvre. 400
// sphères de rayon 6 posées à cheval sur des coins de chunk donnent 3200 entrées, pas 400.
// (Le compteur d'opérations est le membre privé `ModificationCount`, non exposé.)
// C'est d'ailleurs la métrique qui compte pour AUDIT C6 : ce sont les ENTRÉES stockées qui
// grossissent sans borne, pas le nombre de coups de pioche. Le nom du getter induit en erreur.
//
// GetTotalModificationCount() does NOT count operations: it sums STORED entries, and a stroke
// is filed under EVERY chunk its AABB overlaps. It is also the metric that matters for AUDIT C6
// — stored entries are what grow without bound. The getter's name misleads.
int32 ExpectedEntries = 0;
for (int32 i = 0; i < NumWrites; ++i)
{
const TArray<FIntVector> Touched = Diff->ApplyModification(MakeCarve(i));
if (Touched.Num() == 0)
{
AddError(FString::Printf(
TEXT("ApplyModification #%d was rejected. The budget should be unlimited here — ")
TEXT("if this fires, SetBudget(0, ...) no longer means 'no cap'."), i));
break;
}
ExpectedEntries += Touched.Num();
}
// Vérifie que le fan-out RÉELLEMENT stocké correspond à ce qu'ApplyModification a rapporté —
// un désaccord voudrait dire que la liste de chunks rendue à l'appelant (celle qui décide quoi
// re-mailler) ne décrit pas ce qui a été écrit. C'est un bien meilleur test que « == 400 ».
TestEqual(TEXT("stored diff entries match the chunk fan-out ApplyModification reported"),
Diff->GetTotalModificationCount(), ExpectedEntries);
TestTrue(TEXT("the lock-free bHasAnyMods fast-path agrees with the map"), Diff->HasAnyMods());
TestTrue(TEXT("at least one chunk holds mods"), Diff->GetModifiedChunkCount() > 0);
// ── Phase 2 : le chemin réellement dangereux — Clear() pendant que les lecteurs tiennent des
// itérateurs potentiels. On n'affirme plus de compte ici, seulement la survie + la monotonie.
// Phase 2: the genuinely dangerous path — Clear() while readers may hold iterators. No count
// assertions here, only survival + monotonicity.
for (int32 Round = 0; Round < 6; ++Round)
{
for (int32 i = 0; i < 60; ++i) { Diff->ApplyModification(MakeCarve(i + Round * 60)); }
Diff->Clear();
}
bStop.store(true, std::memory_order_relaxed);
for (TFuture<void>& F : Readers) { F.Wait(); }
AddInfo(FString::Printf(TEXT("%d reader threads completed %lld read rounds against %d writes + 6 clears."),
NumReaders, (long long)ReadOps.load(), NumWrites + 360));
TestEqual(TEXT("ModsVersion never went backwards from a reader's point of view"),
VersionRegressions.load(), 0);
if (ReadOps.load() < (int64)NumReaders)
{
AddError(TEXT("The reader threads barely ran, so no contention was actually exercised. ")
TEXT("The writes finished before the threads started — increase NumWrites or add ")
TEXT("a barrier before the writer loop."));
}
// Après Clear(), l'état doit être franchement vide (pas « presque »).
TestFalse(TEXT("Clear() left no mods behind"), Diff->HasAnyMods());
TestEqual(TEXT("Clear() reset the modified-chunk count"), Diff->GetModifiedChunkCount(), 0);
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,580 @@
// VoxelForgeHeightStackTest.cpp
// LA QUESTION D'ARCHITECTURE DE LA PHASE 2, POSÉE AVANT D'ÉCRIRE CE QUI EN DÉPEND.
// PHASE 2'S ARCHITECTURAL QUESTION, ASKED BEFORE WRITING WHAT DEPENDS ON THE ANSWER.
//
// SurfaceWorld a forcé une décision que ni Maze ni Slab n'avaient forcée : ses opérateurs de
// terrain (cliff / terrace / layer lines / plage) n'opèrent PAS sur la densité. Ils lisent et
// écrivent **une altitude**. Ils ne rentrent donc pas dans `IVoxelDensityOp`, et les y forcer
// voudrait dire soit un canal par-voxel pour une propriété de COLONNE, soit un seul opérateur
// opaque — ce que `OPSTACK-PLAN §2.5` appelle exactement l'échec du refactor.
//
// D'où une seconde famille, `VoxelHeightOp.h`. **Ce test est ce qui dit si elle était une bonne
// idée** — la même méthode que la Phase 1 a appliquée à la densité : décomposer, puis MESURER
// contre l'original, avant de construire par-dessus.
//
// ⚠️ CE QUE CE TEST COUVRE
// ✅ la pile de HAUTEUR du sol, contre `ComputeSurfaceTerrainZ` (défauts puis tous les ops F20) ;
// ✅ les canaux de densité courants : ordre, fenêtre et overhang ;
// ✅ le mélange synthétique à poids zéro.
//
// The old GetSurfaceDensity helper remains a differential diagnostic only: it does not carry the
// current common structural post. The assertions target the height stack and the density stack
// used by the owner path, so room ownership and the authored-floor removal are not mistaken for
// a failed height port.
//
// LA BARRE : **bit à bit** remains the rule for height-space operations. Density comparisons to
// the retired direct helper are reported, not asserted, because room ownership and the common
// MC post intentionally make those paths different.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformMisc.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelHeightOp.h"
#include "VoxelDensityOpStack.h"
#include <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeHeightStackTest,
"VoxelForge.OpStack.SurfaceHeightEquivalence",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 NumHeightSamples = 20000;
/** Les params du terrain ne sont intéressants que si les ops sont ALLUMÉS. Ceux de la fixture
* sont les défauts, et `§5` note que les ops F20 sont « all off by default ». Un test qui ne
* ferait tourner que les défauts vérifierait la source structurelle et RIEN des quatre
* modificateurs — c'est-à-dire l'essentiel de ce qui est nouveau ici. */
void EnableAllTerrainOps(FSurfaceGenerationParams& P)
{
P.CliffStrength = 0.6f;
P.CliffSampleDist = 2.0f;
P.CliffSlopeThreshold = 0.15f;
P.CliffSharpness = 1.4f;
P.TerraceStrength = 0.7f;
P.TerraceHeight = 9.0f;
P.TerraceHardness = 0.8f;
P.LayerLineDepth = 1.3f;
P.LayerLineSpacing = 7.0f;
// ⚠️ `WaterLevelRelative` DOIT être > 0, sinon `FBeachHeightMod` sort immédiatement et le
// cinquième op n'est jamais exercé — un test vert qui n'a rien testé. Le défaut de la
// struct est 0.0f, donc l'oublier est le piège naturel ici.
// The beach op early-outs unless WaterLevelRelative > 0, so without this the fifth op is
// never exercised at all — a green test that measured nothing.
P.WaterLevelRelative = 0.30f;
P.BeachWidth = 6.0f;
}
}
bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, TopVoxelZ, BottomVoxelZ))
{
AddError(TEXT("The fixture layout has no SurfaceWorld slot. Check FTestWorld::Build's ")
TEXT("Archetypes[] against FTestWorld::SlotSurfaceWorld."));
return false;
}
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
// Les points d'échantillonnage : XY seulement, la hauteur ne dépend pas de Z (c'est le point).
TArray<FVector2D> Points;
Points.Reserve(NumHeightSamples);
{
FRandomStream Rng(90210);
for (int32 i = 0; i < NumHeightSamples; ++i)
{
Points.Add(FVector2D(
(float)Rng.RandRange(-6 * CHUNK_SIZE, 6 * CHUNK_SIZE),
(float)Rng.RandRange(-6 * CHUNK_SIZE, 6 * CHUNK_SIZE)));
}
}
//=========================================================================
// LA BATTERIE, PARAMÉTRÉE PAR JEU DE PARAMS
//=========================================================================
auto RunForParams = [&](const FSurfaceGenerationParams& P, const TCHAR* Label, int32 SeedSalt)
{
FVoxelHeightStack Stack;
VoxelHeightOps::BuildSurfaceHeightStack(Stack, P, World.Settings->Seed);
// Une DÉCOMPOSITION, pas une enveloppe : source + 4 modificateurs.
TestEqual(*FString::Printf(TEXT("%s: the height stack is decomposed into 5 ops"), Label),
Stack.Num(), 5);
//---------------------------------------------------------------------
// 1. ÉQUIVALENCE — contre ComputeSurfaceTerrainZ, en ALTITUDE
//---------------------------------------------------------------------
int32 NumDiff = 0, WorstIdx = -1;
float WorstDelta = 0.0f, WorstOld = 0.0f;
for (int32 i = 0; i < NumHeightSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y;
const float Old = Gen->ComputeSurfaceTerrainZ(X, Y, P);
const float New = Stack.EvalHeight(X, Y);
if (!BitEqual(Old, New))
{
++NumDiff;
const float Delta = FMath::Abs(Old - New);
if (Delta > WorstDelta) { WorstDelta = Delta; WorstIdx = i; WorstOld = Old; }
}
}
if (NumDiff == 0)
{
AddInfo(FString::Printf(
TEXT("%s: bit-identical across %d samples. The height-space decomposition ")
TEXT("reproduces ComputeSurfaceTerrainZ exactly."), Label, NumHeightSamples));
}
else
{
// Pas de gradation ULP ici, à dessein : ce sont des ALTITUDES. Depuis /fp:precise la
// barre est l'égalité binaire, et un écart de hauteur se voit dans le monde.
AddError(FString::Printf(
TEXT("%s: %d of %d samples differ from ComputeSurfaceTerrainZ (largest |delta| ")
TEXT("%.9g voxels at (%.0f, %.0f), where the reference height is %.4f). These are ")
TEXT("ALTITUDES, not densities -- this is a real port error, not rounding. Check, ")
TEXT("in order: the op ORDER (structural -> cliff -> terrace -> layer lines -> ")
TEXT("beach), the terrace's `* Relief` gate (that is the original's `* M`), the ")
TEXT("cliff resampling the STRUCTURAL field rather than the modified height, and ")
TEXT("the noise offsets (3.1/5.7/0.7, 11/22/1.3, 99/77/0.9, 7.3/2.1/0.5)."),
Label, NumDiff, NumHeightSamples, WorstDelta,
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
WorstOld));
}
//---------------------------------------------------------------------
// 2. INVARIANCE DE FENÊTRE
//---------------------------------------------------------------------
// Une pile de hauteur alimente le cache de colonne T1.a, qui est PARTAGÉ sur toute la pile
// verticale de chunks. Une impureté ici ne fait pas une couture locale : elle se propage à
// tous les Z d'un coup (AUDIT §6.3).
{
std::atomic<int32> Impure{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
TArray<float> Ref;
Ref.SetNumUninitialized(NumHeightSamples);
for (int32 i = 0; i < NumHeightSamples; ++i)
{
Ref[i] = Stack.EvalHeight((float)Points[i].X, (float)Points[i].Y);
}
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> LocalOrder;
BuildShuffledOrder(NumHeightSamples, 1200 + Block + SeedSalt, LocalOrder);
for (const int32 i : LocalOrder)
{
const float V = Stack.EvalHeight((float)Points[i].X, (float)Points[i].Y);
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
}
});
TestEqual(*FString::Printf(
TEXT("%s: the height stack is window-invariant across order and threads"), Label),
Impure.load(), 0);
}
//---------------------------------------------------------------------
// 3. LES MAJORANTS DE DÉPLACEMENT SONT-ILS HONNÊTES ?
//---------------------------------------------------------------------
// `MaxDisplacement` servira à borner une colonne pour un `ClassifyBox` de heightfield, la
// même mécanique qui fait prouver 36-40 tuiles sur 60 à la dalle. Un majorant FAUX serait
// un TROU, donc on le teste par force brute AVANT de construire quoi que ce soit dessus.
//
// On mesure le déplacement des trois mods bornables en comparant la pile complète à une
// pile tronquée (source + cliff seuls) : la différence est exactement ce que terrace +
// layer lines + plage ont déplacé.
{
FVoxelHeightStack Base;
const IVoxelHeightOp* Structural = nullptr;
Base.Add(VoxelHeightOps::MakeStructuralHeightSource(P, World.Settings->Seed, &Structural));
Base.Add(VoxelHeightOps::MakeCliffHeightMod(P, Structural));
FVoxelHeightStack Bounded;
const IVoxelHeightOp* Structural2 = nullptr;
Bounded.Add(VoxelHeightOps::MakeStructuralHeightSource(P, World.Settings->Seed, &Structural2));
Bounded.Add(VoxelHeightOps::MakeCliffHeightMod(P, Structural2));
Bounded.Add(VoxelHeightOps::MakeTerraceHeightMod(P));
Bounded.Add(VoxelHeightOps::MakeLayerLineHeightMod(P));
Bounded.Add(VoxelHeightOps::MakeBeachHeightMod(P));
const float Claimed = FMath::Max(P.TerraceStrength > 0.0f ? P.TerraceHeight : 0.0f, 0.0f)
+ FMath::Max(P.LayerLineSpacing > 0.0f ? P.LayerLineDepth : 0.0f, 0.0f)
+ FMath::Max(P.WaterLevelRelative > 0.0f ? P.BeachWidth : 0.0f, 0.0f);
float WorstObserved = 0.0f;
int32 NumOverBound = 0;
for (int32 i = 0; i < NumHeightSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y;
const float Moved = FMath::Abs(Bounded.EvalHeight(X, Y) - Base.EvalHeight(X, Y));
WorstObserved = FMath::Max(WorstObserved, Moved);
if (Moved > Claimed) { ++NumOverBound; }
}
TestEqual(*FString::Printf(
TEXT("%s: no sample exceeds the claimed MaxDisplacement (a false bound is a hole)"),
Label),
NumOverBound, 0);
AddInfo(FString::Printf(
TEXT("%s: MaxDisplacement claims %.3f voxels, worst observed %.3f (%.0f%% of the ")
TEXT("claim). A loose bound only costs CPU later; a tight-but-wrong one would be a hole."),
Label, Claimed, WorstObserved,
Claimed > 0.0f ? 100.0f * WorstObserved / Claimed : 0.0f));
}
};
//=========================================================================
// DEUX PASSES — et la seconde est celle qui compte
//=========================================================================
const UVoxelStrateDefinition* SurfaceDef =
World.StrateManager->GetStrateForChunk(FIntVector(0, 0, MidChunkZ));
if (!SurfaceDef)
{
AddError(TEXT("No strate definition resolved for the SurfaceWorld slot's mid chunk."));
return false;
}
// Les bornes Z de runtime sont posées à la main : `GetSlabParamsForChunk` a un équivalent pour
// la dalle, mais le chemin surface passe par `ResolveSurfaceChunkParams`, qui est privé et
// mêle la résolution de biome. La pile de hauteur ne dépend que des params + du seed, donc
// fournir les params directement est à la fois suffisant et plus lisible en cas d'échec.
FSurfaceGenerationParams Defaults = SurfaceDef->SurfaceParams;
Defaults.StrateTopWorldZ = (float)TopVoxelZ;
Defaults.StrateBottomWorldZ = (float)BottomVoxelZ;
RunForParams(Defaults, TEXT("SurfaceWorld(defaults)"), 0);
// ⚠️ LA PASSE LOAD-BEARING. Les ops de terrain F20 sont éteints par défaut, donc la passe
// ci-dessus n'exerce que la source structurelle et laisse les QUATRE modificateurs — c'est-à-
// dire tout ce qui est nouveau dans cette décomposition — non testés. Celle-ci les allume.
FSurfaceGenerationParams AllOps = Defaults;
EnableAllTerrainOps(AllOps);
RunForParams(AllOps, TEXT("SurfaceWorld(all terrain ops on)"), 64);
//=========================================================================
// ÉTAPE 2a — LE PONT VERS L'ESPACE DENSITÉ
//=========================================================================
// `FSurfaceColumnSource` consomme les DEUX piles de hauteur (sol + voûte) et rend une densité.
// La référence est `GetSurfaceDensity`, qui est exactement la variante **sans overhang**
// (il passe `OverhangAmp = 0`) et **sans biomes** (ParamsD == ParamsN, poids 0) — donc la
// comparaison est nette plutôt qu'approximative.
//
// ⚠️ Ce que ce bloc NE teste PAS, et qu'il ne faut pas croire testé : l'overhang et le mélange
// de biomes. Tous deux arrivent à l'étape 2b, avec le chemin CACHÉ pour référence — c'est le
// seul qui les calcule.
{
// ⚠️ `OverhangStrength = 0` EXPLICITEMENT : `GetSurfaceDensity` passe `OverhangAmp = 0`,
// donc il n'en calcule aucun. Comparer une pile qui en produit à une référence qui n'en
// produit pas ferait échouer le test pour la seule raison que la référence est incomplète.
// L'overhang a sa propre passe juste en dessous, avec le bon oracle.
FSurfaceGenerationParams P = AllOps;
P.OverhangStrength = 0.0f;
FVoxelOpStack Stack;
VoxelDensityOps::BuildSurfaceStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// 1 source + 1 overhang + 4 structurels. L'op overhang est présent mais inerte ici
// (amp 0 ⇒ sortie immédiate) — la décomposition ne change pas selon les params.
TestEqual(TEXT("the surface density stack is source + overhang + 4 structural"),
Stack.Num(), 6);
TestTrue(TEXT("the surface density stack has valid channel order"),
Stack.ValidateChannelOrder());
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1, NumNonFinite = 0;
float WorstDelta = 0.0f;
FRandomStream Rng(5150);
for (int32 i = 0; i < NumHeightSamples; ++i)
{
const float X = (float)Rng.RandRange(-4 * CHUNK_SIZE, 4 * CHUNK_SIZE);
const float Y = (float)Rng.RandRange(-4 * CHUNK_SIZE, 4 * CHUNK_SIZE);
const float Z = (float)Rng.RandRange(BottomVoxelZ, TopVoxelZ);
// ParamsD == ParamsN, poids 0 ⇒ une seule évaluation, pas de biomes.
const float Old = Gen->GetSurfaceDensity(X, Y, Z, P, P, 0.0f);
const float New = Stack.EvalMC(X, Y, Z);
if (!FMath::IsFinite(New))
{
++NumNonFinite;
}
if (!BitEqual(Old, New))
{
++NumDiff;
const float Delta = FMath::Abs(Old - New);
if (Delta > WorstDelta) { WorstDelta = Delta; WorstIdx = i; }
}
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
}
AddInfo(FString::Printf(
TEXT("SurfaceWorld density legacy diagnostic: %d of %d samples differ from "
"GetSurfaceDensity (largest |delta| %.9g), %d side disagreements; "
"current stack non-finite samples=%d."),
NumDiff, NumHeightSamples, WorstDelta, NumSideDisagree, NumNonFinite));
TestEqual(TEXT("surface: current density stack has no non-finite samples"),
NumNonFinite, 0);
}
//=========================================================================
// ÉTAPE 2b — L'OVERHANG, contre le SEUL oracle qui le calcule
//=========================================================================
// `GetSurfaceDensity` passe `OverhangAmp = 0`. La seule référence est donc le chemin caché :
// `ComputeSurfaceColumn` (qui résout le gate et la direction amont par colonne) suivi de
// `SurfaceDensityFromColumn` (qui applique l'union par voxel). Les deux viennent d'être
// exposées pour ça.
//
// C'est aussi la passe qui vérifie le MÉMO DE COLONNE de `FSurfaceColumnSource` : l'op overhang
// lit la colonne produite par la source, et s'ils divergeaient d'un XY, l'union se ferait au
// mauvais endroit. Un mémo mal clé se verrait ici.
{
FSurfaceGenerationParams P = AllOps;
P.OverhangStrength = 0.8f;
P.OverhangSlopeThreshold = 0.12f;
P.OverhangHeight = 14.0f;
P.OverhangReach = 10.0f;
P.OverhangFrequency = 0.05f;
P.OverhangZScale = 0.6f;
FVoxelOpStack Stack;
VoxelDensityOps::BuildSurfaceStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
// Pas de biomes : contexte vide ⇒ ComputeSurfaceColumn retombe sur BaseSurface pour les
// deux côtés, poids 0. C'est exactement ce que la pile fait aujourd'hui.
FBiomeContext EmptyCtx;
TArray<FSurfaceGenerationParams> NoBiomeParams;
FChunkBiomeCache BiomeCache;
int32 NumDiff = 0, NumSideDisagree = 0, NumInWindow = 0, NumNonFinite = 0;
float WorstDelta = 0.0f;
FRandomStream Rng(1337);
for (int32 i = 0; i < NumHeightSamples; ++i)
{
const float X = (float)Rng.RandRange(-4 * CHUNK_SIZE, 4 * CHUNK_SIZE);
const float Y = (float)Rng.RandRange(-4 * CHUNK_SIZE, 4 * CHUNK_SIZE);
float TerrainZ = 0.0f, CeilSurf = 0.0f, Amp = 0.0f, DirX = 0.0f, DirY = 0.0f;
Gen->ComputeSurfaceColumn(X, Y, MidChunkZ, P, EmptyCtx, NoBiomeParams, BiomeCache,
TerrainZ, CeilSurf, Amp, DirX, DirY);
// Échantillonner DANS la fenêtre d'overhang la moitié du temps : un tirage uniforme sur
// toute la strate la raterait presque toujours, et le test serait vert sans avoir
// exercé l'op une seule fois — le même piège que `WaterLevelRelative` plus haut.
float Z;
if ((i & 1) && Amp > 0.0f)
{
Z = TerrainZ + P.OverhangHeight * ((float)(i % 97) / 97.0f);
++NumInWindow;
}
else
{
Z = (float)Rng.RandRange(BottomVoxelZ, TopVoxelZ);
}
const float Old = Gen->SurfaceDensityFromColumn(X, Y, Z, TerrainZ, CeilSurf,
Amp, DirX, DirY, P);
const float New = Stack.EvalMC(X, Y, Z);
if (!FMath::IsFinite(New))
{
++NumNonFinite;
}
if (!BitEqual(Old, New))
{
++NumDiff;
WorstDelta = FMath::Max(WorstDelta, FMath::Abs(Old - New));
}
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
}
AddInfo(FString::Printf(
TEXT("Overhang legacy diagnostic: %d of %d samples differ from "
"SurfaceDensityFromColumn (largest |delta| %.9g), %d side disagreements, "
"%d deliberately inside the overhang window; current stack non-finite samples=%d."),
NumDiff, NumHeightSamples, WorstDelta, NumSideDisagree, NumInWindow, NumNonFinite));
TestEqual(TEXT("overhang: current density stack has no non-finite samples"),
NumNonFinite, 0);
if (NumInWindow == 0)
{
AddWarning(TEXT("No sample landed inside the overhang window, so the op was never ")
TEXT("actually exercised. Raise OverhangStrength or lower ")
TEXT("OverhangSlopeThreshold until this is well above zero."));
}
}
//=========================================================================
// LE COMBINER `Mask` — mélange de biomes (§5 : le prototype de la Phase 3)
//=========================================================================
// Testé contre un champ de biomes SYNTHÉTIQUE plutôt que contre le résolveur Voronoï réel, et
// c'est le bon choix ici : le vrai résolveur est déjà couvert par ses propres tests, alors
// qu'un champ synthétique permet de balayer le poids de 0 à 1 de façon CONTINUE et de vérifier
// l'identité `blend(w) == lerp(A, B, w)` sur toute la plage — y compris les deux bouts, où une
// erreur d'inversion (`1-w` au lieu de `w`) se cache le mieux.
//
// Tested against a SYNTHETIC field rather than the real Voronoi resolver: the resolver has its
// own tests, while a synthetic field lets the weight be swept continuously from 0 to 1, which is
// where an inverted lerp hides.
{
// Deux jeux de params franchement différents : si le mélange était un no-op, ou prenait le
// mauvais côté, l'écart serait énorme plutôt que subtil.
FSurfaceGenerationParams A = Defaults;
FSurfaceGenerationParams B = Defaults;
A.ElevationRange = 40.0f; A.MountainStrength = 0.2f;
B.ElevationRange = 12.0f; B.MountainStrength = 0.9f;
B.BaseGroundRelative = FMath::Clamp(A.BaseGroundRelative + 0.15f, 0.0f, 1.0f);
TArray<FSurfaceGenerationParams> PerBiome;
PerBiome.Add(A);
PerBiome.Add(B);
/** Champ synthétique : biome 0 dominant, biome 1 voisin, poids imposé par le test. */
class FFixedWeightField final : public IVoxelBiomeField
{
public:
float W = 0.0f;
FVoxelBiomeWeights SampleAt(float, float) const override
{
FVoxelBiomeWeights Out;
Out.Dominant = 0; Out.Neighbor = 1; Out.NeighborWeight = W;
return Out;
}
};
FFixedWeightField FieldA;
// Les deux piles de référence, non mélangées.
FVoxelHeightStack StackA, StackB;
VoxelHeightOps::BuildSurfaceHeightStack(StackA, A, World.Settings->Seed);
VoxelHeightOps::BuildSurfaceHeightStack(StackB, B, World.Settings->Seed);
FVoxelHeightStack Blended;
Blended.Add(VoxelHeightOps::MakeBiomeBlendHeightSource(PerBiome, World.Settings->Seed, &FieldA));
const float Weights[] = { 0.0f, 0.25f, 0.5f, 0.75f, 1.0f };
int32 NumWrong = 0;
float WorstDelta = 0.0f;
for (const float W : Weights)
{
FieldA.W = W;
for (int32 i = 0; i < 400; ++i)
{
const float X = (float)((i % 20) * 11);
const float Y = (float)((i / 20) * 13);
const float HA = StackA.EvalHeight(X, Y);
const float HB = StackB.EvalHeight(X, Y);
// ⚠️ L'attendu doit reproduire la MÊME expression que l'op, `FMath::Lerp` compris :
// écrire `HA + (HB - HA) * W` à la place testerait l'algèbre, pas le code.
const float Expect = (W > 0.0f) ? FMath::Lerp(HA, HB, W) : HA;
const float Got = Blended.EvalHeight(X, Y);
if (!BitEqual(Expect, Got))
{
++NumWrong;
WorstDelta = FMath::Max(WorstDelta, FMath::Abs(Expect - Got));
}
}
}
TestEqual(TEXT("biome blend: heights lerp between the two biomes' full stacks, bit-exactly"),
NumWrong, 0);
// ⚠️ Rapporter le SUCCÈS, pas seulement l'échec. Un `TestEqual` qui passe n'écrit rien, et
// une vérification silencieuse est indiscernable d'une vérification qui n'a jamais tourné —
// exactement le piège signalé pour `WaterLevelRelative` et la fenêtre d'overhang, dans
// lequel ce bloc-ci était tombé au premier jet. Le compte rend l'exécution visible.
// Report success, not just failure: a silent pass is indistinguishable from a check that
// never ran.
AddInfo(FString::Printf(
TEXT("Biome blend: %d (weight, point) pairs across weights 0/0.25/0.5/0.75/1.0 all match ")
TEXT("Lerp of the two biomes' full height stacks bit-exactly. Weight 0 returns the ")
TEXT("dominant untouched and weight 1 the neighbour, so the lerp is not inverted."),
(int32)UE_ARRAY_COUNT(Weights) * 400));
if (NumWrong > 0)
{
AddError(FString::Printf(
TEXT("Biome blend wrong on %d of 2000 (weight, point) pairs, worst |delta| %.6g. ")
TEXT("Check: is the lerp toward the NEIGHBOUR (weight 0 must give the dominant ")
TEXT("untouched, weight 1 the neighbour), and does each biome's stack compute its ")
TEXT("OWN relief for its OWN terrace gate rather than sharing the dominant's?"),
NumWrong, WorstDelta));
}
// Le plafond SÉLECTIONNE au lieu de mélanger — comportement d'origine, reproduit tel quel.
{
FieldA.W = 1.0f; // le voisin l'emporterait si le plafond mélangeait
FVoxelHeightStack CeilSel;
CeilSel.Add(VoxelHeightOps::MakeBiomeSelectCeilingSource(PerBiome, World.Settings->Seed, &FieldA));
FVoxelHeightStack CeilDominant;
VoxelHeightOps::BuildSurfaceCeilingStack(CeilDominant, A, World.Settings->Seed);
int32 NumCeilWrong = 0;
for (int32 i = 0; i < 200; ++i)
{
const float X = (float)((i % 20) * 11), Y = (float)((i / 20) * 13);
if (!BitEqual(CeilSel.EvalHeight(X, Y), CeilDominant.EvalHeight(X, Y))) { ++NumCeilWrong; }
}
TestEqual(TEXT("biome ceiling SELECTS the dominant (never blends), even at weight 1"),
NumCeilWrong, 0);
AddInfo(TEXT("Biome ceiling: 200 points at neighbour-weight 1.0 still return the ")
TEXT("DOMINANT biome's sky cap, i.e. it selects rather than blends -- the "
"original's behaviour, and the case a \"blend everything\" refactor would "
"silently break."));
}
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,181 @@
// Bit-exact equivalence coverage for the generation-path finite predicate.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelTypes.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeIsFiniteTest,
"VoxelForge.Math.IsFiniteExact",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
template <typename TValue, typename TBits>
TValue ValueFromBits(TBits Bits)
{
TValue Value;
FMemory::Memcpy(&Value, &Bits, sizeof(Value));
return Value;
}
FORCEINLINE uint64 NextHashedBits(uint64& State)
{
State += 0x9E3779B97F4A7C15ull;
uint64 Z = State;
Z = (Z ^ (Z >> 30)) * 0xBF58476D1CE4E5B9ull;
Z = (Z ^ (Z >> 27)) * 0x94D049BB133111EBull;
return Z ^ (Z >> 31);
}
struct FFloatPattern
{
const TCHAR* Name;
uint32 Bits;
};
struct FDoublePattern
{
const TCHAR* Name;
uint64 Bits;
};
}
bool FVoxelForgeIsFiniteTest::RunTest(const FString& Parameters)
{
int32 FloatMismatches = 0;
int32 DoubleMismatches = 0;
auto CheckFloat = [&](const TCHAR* Name, uint32 Bits)
{
const float Value = ValueFromBits<float>(Bits);
const bool Fast = VoxelMath::IsFiniteFast(Value);
const bool Reference = FMath::IsFinite(Value);
if (Fast != Reference)
{
++FloatMismatches;
if (FloatMismatches <= 8)
{
AddError(FString::Printf(
TEXT("%s: float bits 0x%08X produced fast=%s, FMath=%s"),
Name, Bits,
Fast ? TEXT("true") : TEXT("false"),
Reference ? TEXT("true") : TEXT("false")));
}
}
};
auto CheckDouble = [&](const TCHAR* Name, uint64 Bits)
{
const double Value = ValueFromBits<double>(Bits);
const bool Fast = VoxelMath::IsFiniteFast(Value);
const bool Reference = FMath::IsFinite(Value);
if (Fast != Reference)
{
++DoubleMismatches;
if (DoubleMismatches <= 8)
{
AddError(FString::Printf(
TEXT("%s: double bits 0x%08X%08X produced fast=%s, FMath=%s"),
Name,
static_cast<uint32>(Bits >> 32), static_cast<uint32>(Bits),
Fast ? TEXT("true") : TEXT("false"),
Reference ? TEXT("true") : TEXT("false")));
}
}
};
// Explicit classes make a NaN regression fail even if the random stream happens not to hit a
// NaN. In particular, a helper that only checks the sign/exponent incorrectly must fail here.
const FFloatPattern FloatPatterns[] =
{
{ TEXT("+0"), 0x00000000u },
{ TEXT("-0"), 0x80000000u },
{ TEXT("+min subnormal"), 0x00000001u },
{ TEXT("-min subnormal"), 0x80000001u },
{ TEXT("+max subnormal"), 0x007FFFFFu },
{ TEXT("-max subnormal"), 0x807FFFFFu },
{ TEXT("+min normal"), 0x00800000u },
{ TEXT("-min normal"), 0x80800000u },
{ TEXT("+max normal"), 0x7F7FFFFFu },
{ TEXT("-max normal"), 0xFF7FFFFFu },
{ TEXT("+infinity"), 0x7F800000u },
{ TEXT("-infinity"), 0xFF800000u },
{ TEXT("+quiet NaN payload 1"), 0x7FC00001u },
{ TEXT("-quiet NaN payload 1"), 0xFFC00001u },
{ TEXT("+quiet NaN payload mixed"), 0x7FC12345u },
{ TEXT("-quiet NaN payload mixed"), 0xFFC12345u },
{ TEXT("+signalling NaN payload 1"), 0x7F800001u },
{ TEXT("-signalling NaN payload 1"), 0xFF800001u },
{ TEXT("+signalling NaN payload mixed"), 0x7FA00001u },
{ TEXT("-signalling NaN payload mixed"), 0xFFA00001u },
{ TEXT("+signalling NaN max payload"), 0x7FBFFFFFu },
{ TEXT("-signalling NaN max payload"), 0xFFBFFFFFu },
};
for (const FFloatPattern& Pattern : FloatPatterns)
{
CheckFloat(Pattern.Name, Pattern.Bits);
}
const FDoublePattern DoublePatterns[] =
{
{ TEXT("+0"), 0x0000000000000000ull },
{ TEXT("-0"), 0x8000000000000000ull },
{ TEXT("+min subnormal"), 0x0000000000000001ull },
{ TEXT("-min subnormal"), 0x8000000000000001ull },
{ TEXT("+max subnormal"), 0x000FFFFFFFFFFFFFull },
{ TEXT("-max subnormal"), 0x800FFFFFFFFFFFFFull },
{ TEXT("+min normal"), 0x0010000000000000ull },
{ TEXT("-min normal"), 0x8010000000000000ull },
{ TEXT("+max normal"), 0x7FEFFFFFFFFFFFFFull },
{ TEXT("-max normal"), 0xFFEFFFFFFFFFFFFFull },
{ TEXT("+infinity"), 0x7FF0000000000000ull },
{ TEXT("-infinity"), 0xFFF0000000000000ull },
{ TEXT("+quiet NaN payload 1"), 0x7FF8000000000001ull },
{ TEXT("-quiet NaN payload 1"), 0xFFF8000000000001ull },
{ TEXT("+quiet NaN payload mixed"), 0x7FF8123456789ABCull },
{ TEXT("-quiet NaN payload mixed"), 0xFFF8123456789ABCull },
{ TEXT("+signalling NaN payload 1"), 0x7FF0000000000001ull },
{ TEXT("-signalling NaN payload 1"), 0xFFF0000000000001ull },
{ TEXT("+signalling NaN payload mixed"), 0x7FF4000000000001ull },
{ TEXT("-signalling NaN payload mixed"), 0xFFF4000000000001ull },
{ TEXT("+signalling NaN max payload"), 0x7FF7FFFFFFFFFFFFull },
{ TEXT("-signalling NaN max payload"), 0xFFF7FFFFFFFFFFFFull },
};
for (const FDoublePattern& Pattern : DoublePatterns)
{
CheckDouble(Pattern.Name, Pattern.Bits);
}
// Deterministic hash-generated raw bit patterns exercise ordinary values and special values
// without converting through floating point. The loop counts are intentionally large enough
// to make this a broad bit-pattern check rather than a handful of representative numbers.
constexpr uint32 NumRandomFloatPatterns = 2000000u;
constexpr uint32 NumRandomDoublePatterns = 2000000u;
uint64 FloatState = 0x123456789ABCDEF0ull;
for (uint32 Index = 0; Index < NumRandomFloatPatterns; ++Index)
{
CheckFloat(TEXT("hashed random"), static_cast<uint32>(NextHashedBits(FloatState)));
}
uint64 DoubleState = 0x0FEDCBA987654321ull;
for (uint32 Index = 0; Index < NumRandomDoublePatterns; ++Index)
{
CheckDouble(TEXT("hashed random"), NextHashedBits(DoubleState));
}
AddInfo(FString::Printf(
TEXT("Compared %d explicit + %u hashed float patterns and %d explicit + %u hashed double "
"patterns; float mismatches=%d, double mismatches=%d."),
UE_ARRAY_COUNT(FloatPatterns), NumRandomFloatPatterns,
UE_ARRAY_COUNT(DoublePatterns), NumRandomDoublePatterns,
FloatMismatches, DoubleMismatches));
TestEqual(TEXT("float finite predicate has no mismatches"), FloatMismatches, 0);
TestEqual(TEXT("double finite predicate has no mismatches"), DoubleMismatches, 0);
return FloatMismatches == 0 && DoubleMismatches == 0;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,142 @@
// VoxelForgeLargeSeedTest.cpp
// AUDIT §C1 — le monde doit rester un monde quand la seed est grande.
// AUDIT C1 — the world must still be a world at a large seed.
//
// LE BUG / THE BUG
// Les sites de bruit s'écrivaient `WorldX * Freq + (float)Seed * 97.7f`. Un float a 24 bits de
// mantisse, donc à magnitude `V` l'ULP vaut `V · 2⁻²³` :
//
// Seed = 1 000 → terme 9.8e4 → ULP 0.012 → correct
// Seed = 100 000 → terme 9.8e6 → ULP 1.2 → le bruit se cale sur un treillis
// Seed = 10 000 000 → terme 9.8e8 → ULP 117 → la coordonnée du voxel (~0.02/voxel) est
// ENTIÈREMENT absorbée ⇒ champ CONSTANT
//
// `ChangeSeed(int32)` est `BlueprintCallable` : un `FMath::Rand()` (jusqu'à 2³¹) suffit à produire
// un monde plat. Ça n'a jamais été vu parce que les seeds de test restaient petites — et la fixture
// des autres tests garde délibérément une petite seed, ce qui veut dire qu'**aucun autre test de ce
// dossier ne peut voir ce bug**.
//
// ⚠️ POURQUOI LES TESTS D'ÉQUIVALENCE NE L'AURAIENT JAMAIS ATTRAPÉ
// Ils comparent la pile d'opérateurs au `switch` d'archétype. Les deux lisent la MÊME expression
// fautive, donc les deux s'effondrent EXACTEMENT DE LA MÊME FAÇON à grande seed : bit-identiques,
// verts, et tous les deux plats. Un oracle qui partage le bug de l'implémentation ne le voit pas.
// **Ce test-ci ne compare rien à rien : il vérifie une PROPRIÉTÉ** — le terrain doit varier.
//
// The equivalence tests compare the op stack to the archetype 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 implementation's bug cannot see it. This test asserts a PROPERTY instead.
//
// LE CORRECTIF, ET POURQUOI L'ÉVIDENT ÉTAIT FAUX
// Borner `SeedF` en gardant le `· 97.7` laisse le terme atteindre 1.6e6 (ULP 0.19 = 9.5× le pas par
// voxel) : moins spectaculaire, toujours cassé, ticket refermé. C'est le MULTIPLICATEUR qu'il faut
// supprimer. `VoxelHash::SeedOffset(Seed, SiteKey)` rend un décalage déjà dans les unités finales,
// borné à [0, 16383], salé par site — donc deux seeds doivent collisionner sur les ~50 sites à la
// fois pour donner le même monde, au lieu d'un seul bucket partagé.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelGenerator.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeLargeSeedTest,
"VoxelForge.Determinism.LargeSeedSurvives",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
/** Les seeds à éprouver. La première est le régime « ça marchait par chance », les suivantes
* sont là où le champ s'effondrait. La dernière est ce qu'un `FMath::Rand()` produit. */
const int32 SeedsUnderTest[] = { 1337, 100000, 10000000, 2000000000 };
/** Combien de hauteurs distinctes faut-il pour dire « ce n'est pas plat » ? Un champ effondré
* rend UNE valeur (ou deux ou trois par effet de bord d'arrondi). Un terrain sain en rend des
* centaines sur 400 échantillons. Le seuil est bas exprès : on teste « le bruit existe-t-il
* encore », pas « est-il joli ». */
constexpr int32 MinDistinctHeights = 50;
}
bool FVoxelForgeLargeSeedTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
bool bAnyCollapse = false;
for (const int32 Seed : SeedsUnderTest)
{
FTestWorld World;
World.Build(Seed);
if (!World.IsValid())
{
AddError(FString::Printf(TEXT("Seed %d: %s"), Seed, *World.WhyInvalid()));
continue;
}
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, TopVoxelZ, BottomVoxelZ))
{
AddError(TEXT("The fixture layout has no SurfaceWorld slot."));
return false;
}
const UVoxelStrateDefinition* Def =
World.StrateManager->GetStrateForChunk(
FIntVector(0, 0, ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE));
if (!Def) { AddError(TEXT("No SurfaceWorld definition.")); return false; }
FSurfaceGenerationParams P = Def->SurfaceParams;
P.StrateTopWorldZ = (float)TopVoxelZ;
P.StrateBottomWorldZ = (float)BottomVoxelZ;
// Échantillonner le HEIGHTFIELD plutôt que la densité : c'est là que le bruit vit, et une
// hauteur est directement lisible ("le terrain est-il plat ?") là où une densité demande
// d'être interprétée.
TSet<uint32> DistinctBits;
float MinH = FLT_MAX, MaxH = -FLT_MAX;
const UVoxelGenerator* Gen = World.Generator.Get();
for (int32 iy = 0; iy < 20; ++iy)
for (int32 ix = 0; ix < 20; ++ix)
{
// Pas de 7 voxels : assez large pour traverser plusieurs cellules de bruit, assez
// petit pour rester dans une région cohérente.
const float X = (float)(ix * 7);
const float Y = (float)(iy * 7);
const float H = Gen->ComputeSurfaceTerrainZ(X, Y, P);
DistinctBits.Add(*reinterpret_cast<const uint32*>(&H));
MinH = FMath::Min(MinH, H);
MaxH = FMath::Max(MaxH, H);
}
const int32 NumDistinct = DistinctBits.Num();
const float Range = MaxH - MinH;
if (NumDistinct < MinDistinctHeights)
{
bAnyCollapse = true;
AddError(FString::Printf(
TEXT("SEED %d COLLAPSED THE NOISE FIELD: only %d distinct heights across 400 ")
TEXT("samples (range %.4f voxels). This is AUDIT C1 — a seed offset large enough ")
TEXT("that the float ULP swallows the voxel coordinate, so the noise input is ")
TEXT("constant across many voxels and the terrain goes flat. Check that every noise ")
TEXT("site uses VoxelHash::SeedOffset(SeedU, K) and that no `SeedF * K` pattern has ")
TEXT("come back."),
Seed, NumDistinct, Range));
}
else
{
AddInfo(FString::Printf(
TEXT("Seed %d: %d distinct heights across 400 samples, range %.2f voxels. Field alive."),
Seed, NumDistinct, Range));
}
}
TestFalse(TEXT("no seed collapses the noise field (AUDIT C1)"), bAnyCollapse);
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,478 @@
// VoxelForgeLayoutOrderIndependenceTest.cpp
// Test de déterminisme du layout et des passages quand l'ordre du pool change.
// Determinism test for the layout and passages when the pool order changes.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelForgeTestFixture.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeLayoutOrderIndependenceTest,
"VoxelForge.Determinism.LayoutOrderIndependence",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 LayoutSeed = 20260829;
constexpr int32 NumPoolDefinitions = 5;
struct FLayoutSnapshot
{
TArray<FStrateSlot> Layout;
TArray<FVoxelPassage> Passages;
};
/**
* Small headless world whose pool has no FixedStrates entries. That is important: the test
* must exercise the shuffled pool path rather than accidentally testing only pinned slots.
*
* Petit monde sans entrée FixedStrates. C'est essentiel : le test doit exercer le chemin du
* pool mélangé, et ne pas tester par accident uniquement des slots épinglés.
*/
struct FLayoutTestWorld
{
TStrongObjectPtr<UVoxelSettings> Settings;
TStrongObjectPtr<UVoxelStrateManager> StrateManager;
TArray<TStrongObjectPtr<UVoxelStrateDefinition>> Definitions;
void Build()
{
Settings = TStrongObjectPtr<UVoxelSettings>(
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
Settings->Seed = LayoutSeed;
Settings->TotalStrates = NumPoolDefinitions;
Settings->InterStrateGapChunks = 1;
Settings->bOpenSurfaceEntry = true;
static const ECaveGeneratorType GeneratorTypes[] = {
ECaveGeneratorType::TunnelNetwork,
ECaveGeneratorType::FlatPlain,
ECaveGeneratorType::Maze,
ECaveGeneratorType::SurfaceWorld,
ECaveGeneratorType::VerticalShafts,
};
static const EVoxelPassageStyle PassageStyles[] = {
EVoxelPassageStyle::Straight,
EVoxelPassageStyle::Worm,
EVoxelPassageStyle::Spiral,
EVoxelPassageStyle::Cascading,
EVoxelPassageStyle::Worm,
};
for (int32 Index = 0; Index < NumPoolDefinitions; ++Index)
{
UVoxelStrateDefinition* Def = NewObject<UVoxelStrateDefinition>(
GetTransientPackage(), NAME_None, RF_Transient);
Def->StrateName = FText::FromString(
FString::Printf(TEXT("Layout order test strate %d"), Index));
Def->GeneratorType = GeneratorTypes[Index];
Def->StrateHeightInChunks = 3 + Index;
Def->TransitionType = EVoxelStrateTransition::Hard;
// Non-trivial, distinct passage configs exercise the connection index and the
// shape seeds while keeping every generated passage observable in the snapshot.
// Des configs distinctes rendent visibles l'index de connexion et les seeds de
// forme, tout en gardant chaque passage généré observable dans le snapshot.
FStratePassageConfig& Passage = Def->PassageConfig;
Passage.Connections = Index + 1;
Passage.Style = PassageStyles[Index];
Passage.MouthRadius = 5.0f + (float)Index;
Passage.MidRadius = 3.0f + (float)Index * 0.5f;
Passage.ReachMin = 12.0f + (float)Index * 3.0f;
Passage.ReachMax = 48.0f + (float)Index * 4.0f;
Passage.DistanceMin = 20.0f + (float)Index * 5.0f;
Passage.DistanceMax = 80.0f + (float)Index * 7.0f;
Passage.Wander = 20.0f + (float)Index;
Passage.Segments = 4 + Index;
Passage.VerticalWobble = 4.0f;
Passage.SpiralRadius = 10.0f + (float)Index;
Passage.SpiralTurns = 1.0f + (float)Index * 0.25f;
Passage.CascadeSteps = 2 + Index;
Passage.CascadeLedge = 10.0f + (float)Index;
Definitions.Add(TStrongObjectPtr<UVoxelStrateDefinition>(Def));
}
StrateManager = TStrongObjectPtr<UVoxelStrateManager>(
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
}
void SetPoolOrder(const TArray<int32>& Order)
{
Settings->StratePool.Reset(Order.Num());
for (const int32 DefinitionIndex : Order)
{
Settings->StratePool.Add(
TSoftObjectPtr<UVoxelStrateDefinition>(Definitions[DefinitionIndex].Get()));
}
}
bool Rebuild(const TArray<int32>& Order)
{
if (!Settings.IsValid() || !StrateManager.IsValid())
{
return false;
}
SetPoolOrder(Order);
StrateManager->Initialize(Settings.Get(), LayoutSeed);
return true;
}
};
FLayoutSnapshot CaptureSnapshot(const UVoxelStrateManager& Manager)
{
FLayoutSnapshot Snapshot;
Snapshot.Layout = Manager.GetLayout();
Snapshot.Passages = Manager.GetPassages();
return Snapshot;
}
bool SameVectorBits(const FVector& A, const FVector& B)
{
return VoxelForgeTest::BitEqual(A.X, B.X)
&& VoxelForgeTest::BitEqual(A.Y, B.Y)
&& VoxelForgeTest::BitEqual(A.Z, B.Z);
}
bool SameLanding(const FVoxelPassageLanding& A, const FVoxelPassageLanding& B)
{
return SameVectorBits(A.StandingPoint, B.StandingPoint)
&& SameVectorBits(A.DoorPoint, B.DoorPoint)
&& SameVectorBits(A.DoorDirection, B.DoorDirection)
&& VoxelForgeTest::BitEqual(A.FloorZ, B.FloorZ)
&& VoxelForgeTest::BitEqual(A.CeilingZ, B.CeilingZ)
&& VoxelForgeTest::BitEqual(A.HalfWidth, B.HalfWidth)
&& VoxelForgeTest::BitEqual(A.FloorThickness, B.FloorThickness)
&& A.bSourcePlayerFit == B.bSourcePlayerFit;
}
bool SamePassage(const FVoxelPassage& A, const FVoxelPassage& B, FString& OutMismatch,
int32 PassageIndex)
{
if (A.UpperStrateIndex != B.UpperStrateIndex || A.LowerStrateIndex != B.LowerStrateIndex)
{
OutMismatch = FString::Printf(
TEXT("passage %d strate indices differ (%d/%d vs %d/%d)"), PassageIndex,
A.UpperStrateIndex, A.LowerStrateIndex, B.UpperStrateIndex, B.LowerStrateIndex);
return false;
}
if (!SameVectorBits(A.UpperPoint, B.UpperPoint)
|| !SameVectorBits(A.LowerPoint, B.LowerPoint))
{
OutMismatch = FString::Printf(
TEXT("passage %d endpoints differ"), PassageIndex);
return false;
}
if (!SameLanding(A.UpperLanding, B.UpperLanding)
|| !SameLanding(A.LowerLanding, B.LowerLanding))
{
OutMismatch = FString::Printf(
TEXT("passage %d landing descriptors differ"), PassageIndex);
return false;
}
if (!VoxelForgeTest::BitEqual(A.Radius, B.Radius))
{
OutMismatch = FString::Printf(
TEXT("passage %d radius differs (%.9g vs %.9g)"), PassageIndex, A.Radius, B.Radius);
return false;
}
if (A.PassageType != B.PassageType)
{
OutMismatch = FString::Printf(
TEXT("passage %d type differs (%d vs %d)"), PassageIndex,
(int32)A.PassageType, (int32)B.PassageType);
return false;
}
if (A.ControlPoints.Num() != B.ControlPoints.Num())
{
OutMismatch = FString::Printf(
TEXT("passage %d control-point count differs (%d vs %d)"), PassageIndex,
A.ControlPoints.Num(), B.ControlPoints.Num());
return false;
}
for (int32 PointIndex = 0; PointIndex < A.ControlPoints.Num(); ++PointIndex)
{
if (!SameVectorBits(A.ControlPoints[PointIndex], B.ControlPoints[PointIndex]))
{
OutMismatch = FString::Printf(
TEXT("passage %d control point %d differs"), PassageIndex, PointIndex);
return false;
}
}
if (A.ControlRadii.Num() != B.ControlRadii.Num())
{
OutMismatch = FString::Printf(
TEXT("passage %d control-radius count differs (%d vs %d)"), PassageIndex,
A.ControlRadii.Num(), B.ControlRadii.Num());
return false;
}
for (int32 RadiusIndex = 0; RadiusIndex < A.ControlRadii.Num(); ++RadiusIndex)
{
if (!VoxelForgeTest::BitEqual(A.ControlRadii[RadiusIndex], B.ControlRadii[RadiusIndex]))
{
OutMismatch = FString::Printf(
TEXT("passage %d control radius %d differs"), PassageIndex, RadiusIndex);
return false;
}
}
if (!SameVectorBits(A.BoundCenter, B.BoundCenter)
|| !VoxelForgeTest::BitEqual(A.BoundRadius, B.BoundRadius)
|| !VoxelForgeTest::BitEqual(A.BoundRadiusSq, B.BoundRadiusSq))
{
OutMismatch = FString::Printf(
TEXT("passage %d bounding sphere differs"), PassageIndex);
return false;
}
return true;
}
bool SameLayout(const TArray<FStrateSlot>& Expected, const TArray<FStrateSlot>& Actual,
FString& OutMismatch)
{
if (Expected.Num() != Actual.Num())
{
OutMismatch = FString::Printf(
TEXT("layout slot count differs (%d vs %d)"), Expected.Num(), Actual.Num());
return false;
}
for (int32 SlotIndex = 0; SlotIndex < Expected.Num(); ++SlotIndex)
{
const FStrateSlot& A = Expected[SlotIndex];
const FStrateSlot& B = Actual[SlotIndex];
if (A.Definition != B.Definition || A.StrateIndex != B.StrateIndex)
{
OutMismatch = FString::Printf(
TEXT("slot %d definition or strate index differs"), SlotIndex);
return false;
}
if (A.TopChunkZ != B.TopChunkZ || A.BottomChunkZ != B.BottomChunkZ
|| A.HeightInChunks != B.HeightInChunks)
{
OutMismatch = FString::Printf(
TEXT("slot %d Z/height differs (top/bottom/height %d/%d/%d vs %d/%d/%d)"),
SlotIndex, A.TopChunkZ, A.BottomChunkZ, A.HeightInChunks,
B.TopChunkZ, B.BottomChunkZ, B.HeightInChunks);
return false;
}
}
return true;
}
bool SameSnapshot(const FLayoutSnapshot& Expected, const FLayoutSnapshot& Actual,
FString& OutMismatch)
{
if (!SameLayout(Expected.Layout, Actual.Layout, OutMismatch))
{
return false;
}
if (Expected.Passages.Num() != Actual.Passages.Num())
{
OutMismatch = FString::Printf(
TEXT("passage count differs (%d vs %d)"),
Expected.Passages.Num(), Actual.Passages.Num());
return false;
}
for (int32 PassageIndex = 0; PassageIndex < Expected.Passages.Num(); ++PassageIndex)
{
if (!SamePassage(Expected.Passages[PassageIndex], Actual.Passages[PassageIndex],
OutMismatch, PassageIndex))
{
return false;
}
}
return true;
}
}
bool FVoxelForgeLayoutOrderIndependenceTest::RunTest(const FString& Parameters)
{
FLayoutTestWorld World;
World.Build();
TArray<int32> OriginalOrder;
for (int32 Index = 0; Index < NumPoolDefinitions; ++Index)
{
OriginalOrder.Add(Index);
}
TArray<int32> ReversedOrder;
for (int32 Index = NumPoolDefinitions - 1; Index >= 0; --Index)
{
ReversedOrder.Add(Index);
}
TArray<int32> PermutedOrder = OriginalOrder;
PermutedOrder.Swap(0, 2);
TArray<TArray<int32>> Orders;
Orders.Add(OriginalOrder);
Orders.Add(ReversedOrder);
Orders.Add(PermutedOrder);
static const TCHAR* OrderNames[] = {
TEXT("original"),
TEXT("reversal"),
TEXT("swap"),
};
TArray<FLayoutSnapshot> Snapshots;
Snapshots.Reserve(Orders.Num());
for (int32 OrderIndex = 0; OrderIndex < Orders.Num(); ++OrderIndex)
{
const bool bRebuilt = World.Rebuild(Orders[OrderIndex]);
const int32 NumStrates = World.StrateManager.IsValid()
? World.StrateManager->GetNumStrates()
: 0;
if (!bRebuilt || NumStrates != NumPoolDefinitions)
{
AddError(FString::Printf(
TEXT("Permutation '%s' could not build a strate layout at all. The test cannot ")
TEXT("silently pass with an incomplete layout; expected %d resolved slots, got %d. ")
TEXT("Check transient soft-pointer resolution."),
OrderNames[OrderIndex], NumPoolDefinitions, NumStrates));
return false;
}
Snapshots.Add(CaptureSnapshot(*World.StrateManager.Get()));
}
if (Snapshots.Num() == 0 || Snapshots[0].Layout.Num() == 0)
{
AddError(TEXT("No layout snapshot was inspected. This test would otherwise pass without ")
TEXT("checking any slots."));
return false;
}
if (Snapshots[0].Passages.Num() == 0)
{
AddError(TEXT("The known pool generated no passages, so the passage invariant was not ")
TEXT("actually inspected."));
return false;
}
int32 NumSlotsInspected = 0;
int32 NumPassagesInspected = 0;
for (int32 SnapshotIndex = 1; SnapshotIndex < Snapshots.Num(); ++SnapshotIndex)
{
NumSlotsInspected += Snapshots[0].Layout.Num();
NumPassagesInspected += Snapshots[0].Passages.Num();
FString Mismatch;
if (!SameSnapshot(Snapshots[0], Snapshots[SnapshotIndex], Mismatch))
{
AddError(FString::Printf(
TEXT("Pool order changed the built result for permutation '%s': %s"),
OrderNames[SnapshotIndex], *Mismatch));
}
}
// A pool permutation alone cannot expose the old sequential passage stream once the layout
// itself is stable. Perturb one early connection count and verify that every later boundary
// keeps its own (strate index, connection index) values unchanged.
// Une permutation seule ne peut plus révéler l'ancien stream séquentiel une fois le layout
// stable. On perturbe donc une connexion précoce et on vérifie toutes les frontières suivantes.
UVoxelStrateDefinition* EarlyDefinition = Snapshots[0].Layout[0].Definition;
if (!EarlyDefinition)
{
AddError(TEXT("The reference layout has a null definition in slot 0; no passage ")
TEXT("independence check can be performed."));
return false;
}
const int32 OriginalConnections = EarlyDefinition->PassageConfig.Connections;
EarlyDefinition->PassageConfig.Connections = OriginalConnections + 1;
const bool bPerturbedRebuild = World.Rebuild(OriginalOrder);
const int32 NumPerturbedStrates = World.StrateManager.IsValid()
? World.StrateManager->GetNumStrates()
: 0;
if (!bPerturbedRebuild || NumPerturbedStrates != NumPoolDefinitions)
{
AddError(TEXT("Changing the early connection count prevented the known layout from ")
TEXT("rebuilding; the passage-independence check could not run."));
EarlyDefinition->PassageConfig.Connections = OriginalConnections;
return false;
}
const FLayoutSnapshot Perturbed = CaptureSnapshot(*World.StrateManager.Get());
EarlyDefinition->PassageConfig.Connections = OriginalConnections;
FString LayoutMismatch;
if (!SameLayout(Snapshots[0].Layout, Perturbed.Layout, LayoutMismatch))
{
AddError(FString::Printf(
TEXT("Changing only the early connection count changed the strate layout: %s"),
*LayoutMismatch));
}
for (int32 BoundaryIndex = 1; BoundaryIndex < Snapshots[0].Layout.Num() - 1; ++BoundaryIndex)
{
TArray<const FVoxelPassage*> ReferenceBoundary;
TArray<const FVoxelPassage*> PerturbedBoundary;
for (const FVoxelPassage& Passage : Snapshots[0].Passages)
{
if (Passage.UpperStrateIndex == BoundaryIndex)
{
ReferenceBoundary.Add(&Passage);
}
}
for (const FVoxelPassage& Passage : Perturbed.Passages)
{
if (Passage.UpperStrateIndex == BoundaryIndex)
{
PerturbedBoundary.Add(&Passage);
}
}
++NumPassagesInspected;
if (ReferenceBoundary.Num() != PerturbedBoundary.Num())
{
AddError(FString::Printf(
TEXT("Boundary %d passage count changed after an earlier connection count ")
TEXT("was perturbed (%d vs %d)."),
BoundaryIndex, ReferenceBoundary.Num(), PerturbedBoundary.Num()));
continue;
}
for (int32 PassageIndex = 0; PassageIndex < ReferenceBoundary.Num(); ++PassageIndex)
{
FString Mismatch;
if (!SamePassage(*ReferenceBoundary[PassageIndex], *PerturbedBoundary[PassageIndex],
Mismatch, PassageIndex))
{
AddError(FString::Printf(
TEXT("Passage at boundary %d changed when an earlier connection count was ")
TEXT("perturbed: %s"),
BoundaryIndex, *Mismatch));
}
}
}
if (NumSlotsInspected == 0 || NumPassagesInspected == 0)
{
AddError(FString::Printf(
TEXT("The test inspected no comparable data (slots=%d, passages=%d); this is a ")
TEXT("test-harness failure, not a determinism pass."),
NumSlotsInspected, NumPassagesInspected));
return false;
}
AddInfo(FString::Printf(
TEXT("Compared %d layout slots and %d passages across %d pool orders (seed=%d)."),
NumSlotsInspected, NumPassagesInspected, Snapshots.Num(), LayoutSeed));
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,334 @@
// VoxelForgeMazeSeamTest.cpp
// The Maze parent rule must be independent of which neighbouring chunk was evaluated first.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelCaveMorphology.h"
#include "VoxelForgeTestFixture.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeMazeSeamTest,
"VoxelForge.Generation.MazeSeamFreedom",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
void AddMazeBoundaryProbes(const FMazeGenerationParams& Params, int32 BottomVoxelZ,
int32 TopVoxelZ, TArray<FVector>& OutProbes)
{
const float CellSize = FMath::Max(Params.CellSize, 1.0f);
const int32 MidCellZ = FMath::FloorToInt(
0.5f * (Params.StrateTopWorldZ + Params.StrateBottomWorldZ) / CellSize);
const float MidZ = (MidCellZ + 0.5f) * CellSize;
if (MidZ <= BottomVoxelZ || MidZ >= TopVoxelZ)
{
return;
}
const int32 CellCoordinates[] = { -5, -2, -1, 0, 1, 2, 5 };
const float Offsets[] = { -0.25f, 0.0f, 0.25f };
for (const int32 CellX : CellCoordinates)
{
for (const float Offset : Offsets)
{
OutProbes.Add(FVector(CellX * CellSize + Offset, 7.25f, MidZ));
OutProbes.Add(FVector(7.25f, CellX * CellSize + Offset, MidZ));
}
}
for (const int32 CellZ : CellCoordinates)
{
for (const float Offset : Offsets)
{
const float Z = CellZ * CellSize + Offset;
if (Z > BottomVoxelZ && Z < TopVoxelZ)
{
OutProbes.Add(FVector(7.25f, -11.5f, Z));
}
}
}
}
void WarmNeighbouringChunks(const VoxelForgeTest::FTestWorld& World,
const FVector& Probe, int32 Variant)
{
const float Delta = static_cast<float>(CHUNK_SIZE * 3);
const FVector Offsets[] = {
FVector(Variant == 0 ? -Delta : Delta, Variant == 1 ? -Delta : Delta, 0.0f),
FVector(Variant == 2 ? Delta : -Delta, Variant == 3 ? Delta : -Delta, 0.0f),
FVector(Variant == 4 ? Delta : -Delta, Variant == 5 ? -Delta : Delta, 0.0f),
};
for (const FVector& Offset : Offsets)
{
World.Generator->GetDensityAt(
Probe.X + Offset.X, Probe.Y + Offset.Y, Probe.Z + Offset.Z);
}
}
int32 CountContextMismatches(const VoxelForgeTest::FTestWorld& World,
const TArray<FVector>& Probes, int32& OutFirstProbe,
float& OutFirstBefore, float& OutFirstAfter)
{
int32 Mismatches = 0;
OutFirstProbe = INDEX_NONE;
OutFirstBefore = 0.0f;
OutFirstAfter = 0.0f;
for (int32 Index = 0; Index < Probes.Num(); ++Index)
{
const FVector& Probe = Probes[Index];
WarmNeighbouringChunks(World, Probe, Index % 6);
const float Before = World.Generator->GetDensityAt(Probe.X, Probe.Y, Probe.Z);
WarmNeighbouringChunks(World, Probe, (Index + 3) % 6);
const float After = World.Generator->GetDensityAt(Probe.X, Probe.Y, Probe.Z);
if (!VoxelForgeTest::BitEqual(Before, After))
{
if (OutFirstProbe == INDEX_NONE)
{
OutFirstProbe = Index;
OutFirstBefore = Before;
OutFirstAfter = After;
}
++Mismatches;
}
}
return Mismatches;
}
}
bool FVoxelForgeMazeSeamTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld LegacyWorld;
LegacyWorld.Build(7331, 2, false, 4);
FTestWorld StackWorld;
StackWorld.Build(7331, 2, true, 4);
if (!LegacyWorld.IsValid() || !StackWorld.IsValid())
{
AddError(LegacyWorld.IsValid() ? StackWorld.WhyInvalid() : LegacyWorld.WhyInvalid());
return false;
}
int32 LegacyTop = 0, LegacyBottom = 0;
int32 StackTop = 0, StackBottom = 0;
if (!LegacyWorld.GetSlotVoxelZRange(FTestWorld::SlotMaze, LegacyTop, LegacyBottom)
|| !StackWorld.GetSlotVoxelZRange(FTestWorld::SlotMaze, StackTop, StackBottom))
{
AddError(TEXT("The seam fixture has no Maze slot."));
return false;
}
const int32 LegacyMidChunkZ = ((LegacyTop + LegacyBottom) / 2) / CHUNK_SIZE;
const int32 StackMidChunkZ = ((StackTop + StackBottom) / 2) / CHUNK_SIZE;
const FMazeGenerationParams LegacyParams = LegacyWorld.StrateManager->GetMazeParamsForChunk(
FIntVector(0, 0, LegacyMidChunkZ));
const FMazeGenerationParams StackParams = StackWorld.StrateManager->GetMazeParamsForChunk(
FIntVector(0, 0, StackMidChunkZ));
TArray<FVector> LegacyProbes;
TArray<FVector> StackProbes;
AddMazeBoundaryProbes(LegacyParams, LegacyBottom, LegacyTop, LegacyProbes);
AddMazeBoundaryProbes(StackParams, StackBottom, StackTop, StackProbes);
TestEqual(TEXT("legacy and operator seam tests have the same probe count"),
LegacyProbes.Num(), StackProbes.Num());
int32 LegacyFirst = INDEX_NONE, StackFirst = INDEX_NONE;
float LegacyBefore = 0.0f, LegacyAfter = 0.0f;
float StackBefore = 0.0f, StackAfter = 0.0f;
const int32 LegacyMismatches = CountContextMismatches(
LegacyWorld, LegacyProbes, LegacyFirst, LegacyBefore, LegacyAfter);
const int32 StackMismatches = CountContextMismatches(
StackWorld, StackProbes, StackFirst, StackBefore, StackAfter);
TestEqual(TEXT("legacy Maze has no chunk-context seam mismatches"), LegacyMismatches, 0);
TestEqual(TEXT("operator-stack Maze has no chunk-context seam mismatches"), StackMismatches, 0);
TestTrue(TEXT("Maze seam test sampled cell-boundary probes"), LegacyProbes.Num() > 0);
if (LegacyFirst != INDEX_NONE)
{
AddError(FString::Printf(TEXT("Legacy Maze seam mismatch at probe %d: %.9g -> %.9g"),
LegacyFirst, LegacyBefore, LegacyAfter));
}
if (StackFirst != INDEX_NONE)
{
AddError(FString::Printf(TEXT("Operator Maze seam mismatch at probe %d: %.9g -> %.9g"),
StackFirst, StackBefore, StackAfter));
}
AddInfo(FString::Printf(
TEXT("Maze seam freedom: %d boundary probes, legacy mismatches=%d, operator mismatches=%d; "
"local window=2x2x2 child nodes ({-1,0}^3), no wide collect."),
LegacyProbes.Num(), LegacyMismatches, StackMismatches));
return true;
}
// The cave-warp optimization is deliberately tile/LOD agnostic: its three channel inputs are
// world coordinates only. Exercise the production density path nevertheless, because a cache
// window or an LOD TLS leak could still make an otherwise pure warp look like a seam.
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeCaveWarpTileLodSeamTest,
"VoxelForge.Generation.CaveWarpTileLodSeam",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeCaveWarpTileLodSeamTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build(1337, 2, true, 4);
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
int32 TunnelTopZ = 0, TunnelBottomZ = 0;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotTunnelNetwork, TunnelTopZ, TunnelBottomZ))
{
AddError(TEXT("The seam fixture has no TunnelNetwork slot."));
return false;
}
const float TunnelMidZ = 0.5f * (static_cast<float>(TunnelTopZ)
+ static_cast<float>(TunnelBottomZ));
const int32 ProbeZMid = FMath::Clamp(
FMath::RoundToInt(TunnelMidZ), TunnelBottomZ + 1, TunnelTopZ - 1);
const int32 PreviousStep = VoxelGenLOD::GetThreadSampleStep();
FIntVector PreviousOrigin = FIntVector::ZeroValue;
int32 PreviousCells = 0;
int32 PreviousContextStep = PreviousStep;
VoxelGenLOD::GetThreadTileCacheWindow(
PreviousOrigin, PreviousContextStep, PreviousCells);
int32 PairMismatches = 0;
int32 LodMismatches = 0;
int32 NumPairSamples = 0;
FString FirstMismatch;
auto Compare = [&](const TCHAR* Label, const FVector& Point, float A, float B,
int32& Counter)
{
if (!BitEqual(A, B))
{
++Counter;
if (FirstMismatch.IsEmpty())
{
FirstMismatch = FString::Printf(
TEXT("%s at (%.0f, %.0f, %.0f): %.9g [0x%08X] vs %.9g [0x%08X]"),
Label, Point.X, Point.Y, Point.Z,
A, *reinterpret_cast<const uint32*>(&A),
B, *reinterpret_cast<const uint32*>(&B));
}
}
};
auto Sample = [&](int32 Step, const FIntVector& TileOrigin, const FVector& Point)
{
VoxelGenLOD::SetThreadSampleStep(Step);
VoxelGenLOD::SetThreadTileContext(TileOrigin, CHUNK_SIZE);
return World.Generator->GetDensityAt(Point.X, Point.Y, Point.Z);
};
// Common points are deliberately on the shared face of adjacent production tiles. The
// LOD1 pair is 64 voxels wide (Step=2, Cells=32); its boundary is also a LOD0 tile boundary.
const int32 CommonOffsets[] = { 4, 12, 20, 28 };
const int32 ZOffsets[] = { -8, 0, 8 };
const int32 LodSteps[] = { 1, 2 };
for (const int32 Step : LodSteps)
{
const int32 Extent = CHUNK_SIZE * Step;
const int32 ZOrigin = FMath::FloorToInt(
TunnelMidZ / static_cast<float>(Extent)) * Extent;
const int32 L0Boundary = Extent == CHUNK_SIZE ? CHUNK_SIZE : Extent;
const FIntVector XLeftOrigin(
L0Boundary - Extent, 0, ZOrigin);
const FIntVector XRightOrigin(
L0Boundary, 0, ZOrigin);
const FIntVector YLowerOrigin(
0, L0Boundary - Extent, ZOrigin);
const FIntVector YUpperOrigin(
0, L0Boundary, ZOrigin);
for (const int32 Offset : CommonOffsets)
{
for (const int32 ZOffset : ZOffsets)
{
const float ProbeZ = FMath::Clamp(
static_cast<float>(ProbeZMid + ZOffset),
static_cast<float>(TunnelBottomZ + 1),
static_cast<float>(TunnelTopZ - 1));
const FVector XPoint(static_cast<float>(L0Boundary),
static_cast<float>(Offset), ProbeZ);
const float XLeft = Sample(Step, XLeftOrigin, XPoint);
const float XRight = Sample(Step, XRightOrigin, XPoint);
Compare(Step == 1 ? TEXT("LOD0 X tile pair") : TEXT("LOD1 X tile pair"),
XPoint, XLeft, XRight, PairMismatches);
const FVector YPoint(static_cast<float>(Offset),
static_cast<float>(L0Boundary), ProbeZ);
const float YLower = Sample(Step, YLowerOrigin, YPoint);
const float YUpper = Sample(Step, YUpperOrigin, YPoint);
Compare(Step == 1 ? TEXT("LOD0 Y tile pair") : TEXT("LOD1 Y tile pair"),
YPoint, YLower, YUpper, PairMismatches);
NumPairSamples += 2;
}
}
}
// Compare the same world points through the two LOD contexts. The default LODOctaveDrop is
// zero, so LOD changes sampling density, not the value of a shared world sample.
const int32 CrossLodBoundary = CHUNK_SIZE * 2;
const int32 L0ZOrigin = FMath::FloorToInt(
TunnelMidZ / static_cast<float>(CHUNK_SIZE)) * CHUNK_SIZE;
const int32 L1ZOrigin = FMath::FloorToInt(
TunnelMidZ / static_cast<float>(CrossLodBoundary)) * CrossLodBoundary;
for (const int32 Offset : CommonOffsets)
{
for (const int32 ZOffset : ZOffsets)
{
const float ProbeZ = FMath::Clamp(
static_cast<float>(ProbeZMid + ZOffset),
static_cast<float>(TunnelBottomZ + 1),
static_cast<float>(TunnelTopZ - 1));
const FVector XPoint(static_cast<float>(CrossLodBoundary),
static_cast<float>(Offset), ProbeZ);
const float L0X = Sample(
1, FIntVector(CHUNK_SIZE, 0, L0ZOrigin), XPoint);
const float L1X = Sample(
2, FIntVector(0, 0, L1ZOrigin), XPoint);
Compare(TEXT("same X world point across LOD0/LOD1"),
XPoint, L0X, L1X, LodMismatches);
const FVector YPoint(static_cast<float>(Offset),
static_cast<float>(CrossLodBoundary), ProbeZ);
const float L0Y = Sample(
1, FIntVector(0, CHUNK_SIZE, L0ZOrigin), YPoint);
const float L1Y = Sample(
2, FIntVector(0, 0, L1ZOrigin), YPoint);
Compare(TEXT("same Y world point across LOD0/LOD1"),
YPoint, L0Y, L1Y, LodMismatches);
}
}
VoxelGenLOD::SetThreadSampleStep(PreviousContextStep);
VoxelGenLOD::SetThreadTileContext(PreviousOrigin, PreviousCells);
TestTrue(TEXT("sampled shared boundary points"), NumPairSamples > 0);
TestEqual(TEXT("LOD0 and LOD1 neighbouring tile boundaries are bit-identical"),
PairMismatches, 0);
TestEqual(TEXT("same world boundary points are bit-identical across LOD0 and LOD1"),
LodMismatches, 0);
if (!FirstMismatch.IsEmpty())
{
AddError(FirstMismatch);
}
AddInfo(FString::Printf(
TEXT("Cave warp tile/LOD seam proof: %d same-LOD pair samples, pair mismatches=%d, "
"cross-LOD mismatches=%d; shared points sampled from X and Y boundaries."),
NumPairSamples, PairMismatches, LodMismatches));
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,204 @@
// VoxelForgeOpStackChannelTest.cpp
// The channel declarations are executable stack-assembly metadata, not comments.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpStackChannelTest,
"VoxelForge.OpStack.ChannelDAG",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
/** A deliberately small declaration-only op for the validator's negative controls. */
class FDeclarationProbeOp final : public IVoxelDensityOp
{
public:
FDeclarationProbeOp(EVoxelOpChannelMask InReads,
EVoxelOpChannelMask InWrites,
bool bInAdditive)
: Reads(InReads), Writes(InWrites), bAdditive(bInAdditive)
{}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; }
EVoxelOpChannelMask ChannelReads() const override { return Reads; }
EVoxelOpChannelMask ChannelWrites() const override { return Writes; }
bool IsAdditive() const override { return bAdditive; }
void PrepareChunk(const FVoxelOpContext&) override {}
void Eval(float, float, float, FVoxelOpSample&) const override {}
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
return EVoxelOpEffect::Identity;
}
const TCHAR* DebugName() const override { return TEXT("DeclarationProbeOp"); }
private:
EVoxelOpChannelMask Reads;
EVoxelOpChannelMask Writes;
bool bAdditive;
};
}
bool FVoxelForgeOpStackChannelTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Generator = World.Generator.Get();
const int32 Seed = World.Settings->Seed;
auto MidChunkForSlot = [&](int32 Slot, const TCHAR* Name, int32& OutMidChunkZ) -> bool
{
int32 TopVoxelZ = 0;
int32 BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(Slot, TopVoxelZ, BottomVoxelZ))
{
AddError(FString::Printf(
TEXT("The fixture has no %s slot; check FTestWorld::Build's Archetypes[] and "
"the corresponding slot constant."), Name));
return false;
}
OutMidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
return true;
};
auto Validate = [&](FVoxelOpStack& Stack, const TCHAR* Name, int32 ExpectedNum)
{
TestEqual(*FString::Printf(TEXT("%s shipping stack op count"), Name),
Stack.Num(), ExpectedNum);
FString Error;
const bool bValid = Stack.ValidateChannelOrder(&Error);
if (!bValid)
{
AddError(FString::Printf(TEXT("%s shipping stack failed channel validation: %s"),
Name, *Error));
}
TestTrue(*FString::Printf(TEXT("%s shipping stack has a legal channel DAG"), Name), bValid);
};
// These are the eight builders currently shipped behind bUseOperatorStack. Keep the
// archetype list explicit so adding a builder without adding its validator assertion is
// visible in this test.
int32 MidChunkZ = 0;
if (MidChunkForSlot(FTestWorld::SlotTunnelNetwork, TEXT("TunnelNetwork"), MidChunkZ))
{
const FStrateGenerationParams Params =
World.StrateManager->GetGenerationParams(FIntVector(0, 0, MidChunkZ));
FVoxelOpStack Stack;
VoxelDensityOps::BuildTunnelNetworkStack(
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
Validate(Stack, TEXT("TunnelNetwork"), 20);
}
if (MidChunkForSlot(FTestWorld::SlotFlatPlain, TEXT("FlatPlain"), MidChunkZ))
{
const FSlabGenerationParams Params =
World.StrateManager->GetSlabParamsForChunk(FIntVector(0, 0, MidChunkZ));
FVoxelOpStack Stack;
VoxelDensityOps::BuildSlabStack(
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
Validate(Stack, TEXT("FlatPlain"), 6);
}
if (MidChunkForSlot(FTestWorld::SlotCrystalChamber, TEXT("CrystalChamber"), MidChunkZ))
{
const FSlabGenerationParams Params =
World.StrateManager->GetSlabParamsForChunk(FIntVector(0, 0, MidChunkZ));
FVoxelOpStack Stack;
VoxelDensityOps::BuildSlabStack(
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
Validate(Stack, TEXT("CrystalChamber"), 6);
}
if (MidChunkForSlot(FTestWorld::SlotMaze, TEXT("Maze"), MidChunkZ))
{
const FMazeGenerationParams Params =
World.StrateManager->GetMazeParamsForChunk(FIntVector(0, 0, MidChunkZ));
FVoxelOpStack Stack;
VoxelDensityOps::BuildMazeStack(
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
Validate(Stack, TEXT("Maze"), 8);
}
if (MidChunkForSlot(FTestWorld::SlotSurfaceWorld, TEXT("SurfaceWorld"), MidChunkZ))
{
const FSurfaceGenerationParams Params =
World.StrateManager->GetSurfaceParamsForChunk(FIntVector(0, 0, MidChunkZ));
FVoxelOpStack Stack;
VoxelDensityOps::BuildSurfaceStack(
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
Validate(Stack, TEXT("SurfaceWorld"), 6);
}
if (MidChunkForSlot(FTestWorld::SlotVerticalShafts, TEXT("VerticalShafts"), MidChunkZ))
{
const FVerticalShaftParams Params =
World.StrateManager->GetVerticalShaftParamsForChunk(FIntVector(0, 0, MidChunkZ));
FVoxelOpStack Stack;
VoxelDensityOps::BuildVerticalShaftStack(
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
Validate(Stack, TEXT("VerticalShafts"), 11);
}
if (MidChunkForSlot(FTestWorld::SlotFloatingIsland, TEXT("FloatingIslands"), MidChunkZ))
{
const FFloatingIslandParams Params =
World.StrateManager->GetFloatingIslandParamsForChunk(FIntVector(0, 0, MidChunkZ));
FVoxelOpStack Stack;
VoxelDensityOps::BuildFloatingIslandStack(
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
Validate(Stack, TEXT("FloatingIslands"), 8);
}
if (MidChunkForSlot(FTestWorld::SlotUnderwater, TEXT("Underwater"), MidChunkZ))
{
const FStrateGenerationParams Params =
World.StrateManager->GetGenerationParams(FIntVector(0, 0, MidChunkZ));
FVoxelOpStack Stack;
VoxelDensityOps::BuildTunnelNetworkStack(
Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get());
Validate(Stack, TEXT("Underwater"), 20);
}
// A declaration that reads an unavailable channel must fail, and a second write-only
// replacement must not silently erase the first producer. These controls pin the validator's
// two most important safety rules independently of the shipping builders.
{
FVoxelOpStack MissingProducer;
MissingProducer.Add(MakeUnique<FDeclarationProbeOp>(
VoxelOpChannels::Density, VoxelOpChannels::Density, true));
FString Error;
TestFalse(TEXT("a read before its first producer is rejected"),
MissingProducer.ValidateChannelOrder(&Error));
}
{
FVoxelOpStack Clobber;
Clobber.Add(MakeUnique<FDeclarationProbeOp>(
VoxelOpChannels::None, VoxelOpChannels::Density, false));
Clobber.Add(MakeUnique<FDeclarationProbeOp>(
VoxelOpChannels::None, VoxelOpChannels::Density, false));
FString Error;
TestFalse(TEXT("a second write-only replacement is rejected"),
Clobber.ValidateChannelOrder(&Error));
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,318 @@
// VoxelForgeOpStackIslandTest.cpp
// FloatingIslands — le portage qui fait tourner la pile À L'ENVERS.
// FloatingIslands — the port that runs the stack BACKWARDS.
//
// CE QUE CELUI-CI PROUVE EN PLUS DES AUTRES
// `VerticalShaftEquivalence` a mesuré la réutilisation À L'IDENTIQUE : trois opérateurs de Maze
// repris sans une ligne de changement. Celui-ci mesure quelque chose de plus fort, et de plus
// risqué pour l'abstraction : **la réutilisation PAR INVERSION**.
//
// Les quatre archétypes déjà portés partent tous de ROC et CREUSENT. FloatingIslands part du VIDE
// et REMPLIT. Si l'axe abstrait choisi (le SIGNE de la densité, convention interne positif = solide)
// est le bon, alors les deux extrémités de la pile doivent être les MÊMES opérateurs au signe près :
//
// FConstantFieldSource(+Base) ←→ FConstantFieldSource(-Base)
// FSdfConvertOp(Sign = -1) ←→ FSdfConvertOp(Sign = +1)
//
// Et c'est le cas : le seul opérateur neuf de ce portage est le blob d'île. Un archétype qui se
// réutilise en s'INVERSANT est une preuve plus forte qu'un archétype qui se réutilise à l'identique
// — le premier dit que l'abstraction a trouvé le bon axe, le second seulement que deux archétypes
// se ressemblaient.
//
// ET LE VERDICT DE BOÎTE : c'est ici que `ClassifyBox` peut rendre **AllAir** pour la première fois
// de tout le plugin. Une strate d'îles flottantes est, par construction, surtout vide ; aucun
// archétype de grotte n'a jamais su prouver « tout air » (`OPSTACK-DECOMPOSITION §7`). Le test
// compte les deux verdicts SÉPARÉMENT, parce qu'un total agrégé masquerait exactement ce gain-là.
//
// LA BARRE : bit à bit, comme les autres depuis `FPSemantics = Precise` (AUDIT §C9/§C10).
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformMisc.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpStackIslandTest,
"VoxelForge.OpStack.FloatingIslandEquivalence",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 NumIslandSamples = 20000;
/**
* Les défauts génèrent bien des îles, mais un test qui les prend tels quels laisse la question
* « les échantillons sont-ils VRAIMENT tombés dedans ? » à la chance du seed. On force donc une
* densité d'îles haute, et surtout un `TopFlatten < 1` — la branche du dôme de bord est le seul
* endroit où `TopHalf` et `Edge²` interviennent, et elle est silencieusement morte à 1.0.
* (Même piège que `WaterLevelRelative` et la fenêtre d'overhang : un paramètre au repos est un
* opérateur non testé.)
*/
void EnableIslandFeatures(FFloatingIslandParams& P)
{
P.IslandDensity = 0.75f; // des îles dans presque chaque cellule du 3×3
P.TopFlatten = 0.55f; // < 1 ⇒ la branche du dôme de bord s'exécute
P.SurfaceRoughness = 4.0f; // la rugosité SDF partagée avec Maze et VerticalShafts
P.VerticalJitter = 0.6f; // des îles à des hauteurs différentes
P.ThicknessRatio = 0.7f;
}
}
bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotFloatingIsland, TopVoxelZ, BottomVoxelZ))
{
AddError(TEXT("The fixture layout has no FloatingIslands slot. Check FTestWorld::Build's ")
TEXT("Archetypes[] against FTestWorld::SlotFloatingIsland."));
return false;
}
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
FFloatingIslandParams P = World.StrateManager->GetFloatingIslandParamsForChunk(
FIntVector(0, 0, MidChunkZ));
if (P.StrateTopWorldZ - P.StrateBottomWorldZ <= 0.0f)
{
AddError(TEXT("The FloatingIslands strate has degenerate Z bounds, which sends ")
TEXT("GetFloatingIslandDensity down its early-out. The op stack has none by design."));
return false;
}
EnableIslandFeatures(P);
FVoxelOpStack Stack;
VoxelDensityOps::BuildFloatingIslandStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// void + blobs + roughness + fill + 4 structurels.
TestEqual(TEXT("the island stack is decomposed into 8 ops"), Stack.Num(), 8);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
TArray<FVector> Points;
Points.Reserve(NumIslandSamples);
{
FRandomStream Rng(60186);
for (int32 i = 0; i < NumIslandSamples; ++i)
{
Points.Add(FVector(
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(BottomVoxelZ, TopVoxelZ)));
}
}
//=========================================================================
// 1. ÉQUIVALENCE
//=========================================================================
// On compte SÉPARÉMENT le solide d'intérieur et le solide de seal : sur cet archétype la
// quasi-totalité du volume est de l'air, donc un « N solides » agrégé serait dominé par les
// deux bandes de seal et ne dirait RIEN sur les îles elles-mêmes.
const float InnerBot = P.StrateBottomWorldZ + P.BoundarySealThickness;
const float InnerTop = P.StrateTopWorldZ - P.BoundarySealThickness;
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1;
int32 NumInsideIsland = 0, NumOpenVoid = 0;
float WorstDelta = 0.0f;
for (int32 i = 0; i < NumIslandSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const float Old = Gen->GetFloatingIslandDensity(X, Y, Z, P);
const float New = Stack.EvalMC(X, Y, Z);
const bool bInterior = (Z > InnerBot && Z < InnerTop);
if (bInterior && Old < 0.0f) { ++NumInsideIsland; } // solide loin des seals ⇒ une île
if (bInterior && Old >= 0.0f) { ++NumOpenVoid; }
if (!BitEqual(Old, New))
{
++NumDiff;
const float D = FMath::Abs(Old - New);
if (D > WorstDelta) { WorstDelta = D; WorstIdx = i; }
}
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
}
if (NumDiff == 0)
{
AddInfo(FString::Printf(
TEXT("FloatingIslands: bit-identical across %d samples (%d inside island rock away from ")
TEXT("the seal bands, %d in open void, so the void source, the blobs, the roughness and ")
TEXT("the fill were all exercised). The stack runs BACKWARDS -- void source + fill ")
TEXT("instead of rock source + carve -- using the SAME operators with the opposite ")
TEXT("sign. Only the blob source is new (OPSTACK-PLAN 2.5)."),
NumIslandSamples, NumInsideIsland, NumOpenVoid));
}
else
{
AddError(FString::Printf(
TEXT("FloatingIslands: %d of %d samples differ (largest |delta| %.9g at (%.0f, %.0f, ")
TEXT("%.0f)); %d cross the isosurface. Since /fp:precise the bar is bit-identity, so ")
TEXT("this is a real port error. Check, in order: the C1 warp fix (BOTH paths must now ")
TEXT("use VoxelHash::SeedOffset(S, 0.0007f) -- if only one was changed, EVERY warped ")
TEXT("sample differs), then the SdfConvert SIGN (+1 fills, -1 carves), then the 'Isld' ")
TEXT("salt (0x49736C64), the roughness frequency (0.08 / 4 octaves here, NOT Maze's ")
TEXT("0.12 / 3), the per-island TaperEnd and TopFlatten dome branch, and the ")
TEXT("SmoothMin blend K = max(SDFBlendRadius, 0.01)."),
NumDiff, NumIslandSamples, WorstDelta,
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
NumSideDisagree));
}
TestEqual(TEXT("no sample lands on the opposite side of the isosurface"), NumSideDisagree, 0);
if (NumInsideIsland == 0)
{
AddWarning(TEXT("No sample landed inside island rock away from the seal bands, so the blob ")
TEXT("source and the fill were never meaningfully exercised -- the equivalence ")
TEXT("above then only proves that two empty voids agree. Raise IslandDensity or ")
TEXT("IslandMaxRadius."));
}
//=========================================================================
// 2. INVARIANCE DE FENÊTRE
//=========================================================================
// La source garde un cache 3×3 `thread_local` dont la clé est le jeu de params — et cette clé
// inclut délibérément `BoundarySealThickness`, que l'original omet alors que `SpreadZ` le lit
// (voir la note dans FIslandBlobSource::GetCells).
{
std::atomic<int32> Impure{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
TArray<float> Ref;
Ref.SetNumUninitialized(NumIslandSamples);
for (int32 i = 0; i < NumIslandSamples; ++i)
{
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> LocalOrder;
BuildShuffledOrder(NumIslandSamples, 3300 + Block, LocalOrder);
for (const int32 i : LocalOrder)
{
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
}
});
TestEqual(TEXT("the island stack is window-invariant across order and threads"),
Impure.load(), 0);
}
//=========================================================================
// 3. LE VERDICT DE BOÎTE — et la première preuve « AllAir » du plugin
//=========================================================================
{
int32 NumProvedSolid = 0, NumProvedAir = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
FRandomStream Rng(24680);
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
const int32 SpanCells = 95;
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
for (int32 t = 0; t < 60; ++t)
{
const int32 Step = 1, Cells = 8;
const int32 Extent = Step * Cells;
const FIntVector Origin(
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
const int32 GridDim = Cells + 1;
const FBox Box(
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
if (bClaimsSolid) { ++NumProvedSolid; } else { ++NumProvedAir; }
for (int32 gz = -1; gz <= GridDim; ++gz)
for (int32 gy = -1; gy <= GridDim; ++gy)
for (int32 gx = -1; gx <= GridDim; ++gx)
{
const float X = (float)(Origin.X + gx * Step);
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
++NumBruteSamples;
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
{
AddError(FString::Printf(
TEXT("HOLE: the island stack claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ")
TEXT("order: the blob source's propagated SDF interval (does it cover the WARP amplitude ")
TEXT("AND the roughness AND the fill blend AND the SmoothMin dip?), ")
TEXT("then the Z bound -- note there is NO lower bound, a thin thread ")
TEXT("of matter hangs below each island down the axis, so only the ")
TEXT("TOP may be used to reject."),
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
}
++NumUnsound;
gz = gy = gx = GridDim + 1;
}
}
}
TestEqual(TEXT("every box verdict the island stack emits survives brute force"), NumUnsound, 0);
AddInfo(FString::Printf(
TEXT("Box verdicts over 60 FloatingIslands tiles (XY sampled from +/- %d voxels = %.1f x ")
TEXT("IslandSpacing %.0f): %d proved AllSolid, %d proved AllAir, ")
TEXT("%d Mixed, %d voxels checked, %d violations. Today's ClassifyTile proves ZERO of these. The AllAir count is the new ")
TEXT("thing: no cave archetype has ever been able to prove 'all air', and a floating-")
TEXT("island strate is mostly exactly that (OPSTACK-DECOMPOSITION 7)."),
SpanVoxels, (float)SpanVoxels / FMath::Max(P.IslandSpacing, 1.0f), P.IslandSpacing,
NumProvedSolid, NumProvedAir, NumMixed, NumBruteSamples, NumUnsound));
if (NumProvedAir == 0)
{
AddWarning(TEXT("Zero tiles proved AllAir. The stack is still SOUND, but the whole perf ")
TEXT("argument for this archetype rests on that verdict, so it is worth ")
TEXT("knowing it did not fire. Most likely the blob source's Pad is so wide ")
TEXT("that every box finds an island within reach -- the same pessimism ")
TEXT("VerticalShafts has (0 of 60), for the same reason."));
}
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,789 @@
// VoxelForgeOpStackMazeTest.cpp
// PHASE 1, LE TEST QUI COMPTE — la pile d'opérateurs Maze contre GetMazeDensity.
// PHASE 1'S LOAD-BEARING TEST — the Maze operator stack against GetMazeDensity.
//
// CE QUE LA PHASE 1 DEVAIT PROUVER / WHAT PHASE 1 HAD TO PROVE
// Le déclencheur d'arrêt de `OPSTACK-PLAN §4` : **« est-ce que la séparation source / modifier tombe
// naturellement du code existant ? »** Réponse mesurée : oui. Maze se décompose en huit opérateurs
// sans contorsion, le SDF est reproduit BIT POUR BIT, et aucun échantillon ne change de côté de
// l'isosurface.
//
// ═════════════════════════════════════════════════════════════════════════════════════════
// ✅ MISE À JOUR 2026-07-27 : LE PLANCHER ULP N'EXISTE PLUS. C'ÉTAIT `/fp:fast`.
// ═════════════════════════════════════════════════════════════════════════════════════════
// `FPSemantics = Precise` sur le module (AUDIT §C9, posé pour le cross-play Linux/Windows) fait
// passer ce test à **BIT-IDENTIQUE**. La section ci-dessous décrit un état RÉVOLU ; elle est gardée
// parce qu'elle explique pourquoi les cinq expériences d'isolation avaient toutes échoué (sous
// `/fp:fast` le compilateur transforme selon le CONTEXTE — il n'y avait aucune variable à isoler)
// et parce qu'elle dit quoi regarder si la bit-identité régresse un jour.
//
// **Conséquence pratique : ce test est maintenant un instrument BEAUCOUP plus fin.** Le moindre
// écart est désormais une vraie trouvaille, pas du bruit à noter. La machinerie de gradation ULP
// est conservée exprès — c'est elle qui signalerait une régression du modèle flottant.
//
// UPDATE: the ULP floor is GONE — FPSemantics = Precise makes this test bit-identical. The section
// below describes a past state, kept because it explains why five isolation experiments all failed
// (under /fp:fast the compiler transforms by CONTEXT — there was no variable to isolate) and what to
// look at if bit-identity ever regresses.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// ⚠️ LE PLANCHER ULP (HISTORIQUE) — lire ceci avant de « corriger » un écart résiduel
// ─────────────────────────────────────────────────────────────────────────────────────────
// (HISTORIQUE, avant `FPSemantics = Precise`) la pile reproduisait `GetMazeDensity` à ~1-2 ULP près
// sur ~2 % des échantillons (ceux qui tombent
// dans la coquille de blend du SDF, où `Blend - Sdf` annule catastrophiquement et amplifie le
// dernier arrondi). **Zéro échantillon ne traverse l'isosurface**, donc pas un triangle ne bouge.
//
// L'origine exacte de ce dernier arrondi n'a PAS été identifiée, après six cycles de build et cinq
// hypothèses toutes réfutées par la mesure (aller-retour FVector · fenêtre de rugosité · `/fp:fast`
// entre unités de compilation · contexte d'inlining · constante de compilation vs donnée
// d'exécution). Ce qui EST établi par la mesure :
//
// • le SDF est bit-identique sur 126/126 des écarts — le treillis, les hashs, l'ensemble d'arêtes
// et `VoxelSDF::Capsule` sont donc exacts ;
// • l'écart naît entièrement dans la conversion SDF→densité, au dernier arrondi ;
// • il est DÉTERMINISTE (mêmes échantillons, même delta, même coordonnée à chaque run) ;
// • il ne dépend ni de l'unité de compilation, ni de l'inlining, ni du modèle flottant.
//
// **Décision (Jahni, 2026-07-27) : on l'accepte et on avance.** Aucune décision du projet ne dépend
// de la réponse, et la chasse coûtait plus que l'information. Consigné comme point ouvert dans
// `AUDIT-2026-07.md §C10`.
//
// ⚠️ LA RÈGLE QUI EN DÉCOULE, ELLE, EST IMPORTANTE :
// **ne jamais faire tourner les deux chemins (switch d'archétype et pile d'opérateurs) dans le même
// monde, et ne jamais comparer leurs sorties pour égalité.** Ce n'est PAS un risque de désync entre
// clients — dans un même binaire le champ est prouvé pur (`VoxelForge.Determinism.DensityPurity`,
// bit-identique entre threads et ordres de requête) et tous les pairs exécutent le même chemin. Mais
// une strate à moitié migrée produirait une couture. Le vrai sujet multijoueur est ailleurs :
// `AUDIT §C9` (le défaut FP d'UBT diffère selon la toolchain).
//
// Never run both paths in one world and never compare their outputs for equality. This is NOT a
// client-desync risk — within one binary the field is proven pure and every peer runs the same path —
// but a half-migrated strate would produce a seam.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// LA BARRE D'ACCEPTATION, ENCODÉE CI-DESSOUS / THE ACCEPTANCE BAR, ENCODED BELOW
// ─────────────────────────────────────────────────────────────────────────────────────────
// • ÉCHEC DUR : un seul échantillon qui change de côté de l'isosurface (la géométrie bouge).
// • INFO : des écarts à l'échelle de l'ULP (le plancher, attendu).
// • WARN : un écart plus grand — ÇA, c'est une vraie dérive de portage, et il faut chercher.
// Un test qui avertit à chaque portage serait ignoré par le portage qui compte.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformMisc.h"
#include "HAL/PlatformTime.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include "VoxelCaveMorphology.h"
#include <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpStackMazeTest,
"VoxelForge.OpStack.MazeEquivalence",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 NumMazeSamples = 20000;
constexpr int32 NumMazeBoxTests = 256;
struct FMazeGraphAudit
{
int32 NumNodes = 0;
int32 NumEdges = 0;
int32 NumDisconnected = 0;
TArray<uint64> EdgeKeys;
TArray<int32> RunLengths;
int64 DegreeCounts[8] = {};
};
int32 MazeGraphIndex(int32 X, int32 Y, int32 Z, int32 Radius)
{
const int32 Dim = Radius * 2 + 1;
return (X + Radius) + Dim * ((Y + Radius) + Dim * (Z + Radius));
}
uint64 MazeGraphEdgeKey(int32 A, int32 B)
{
const uint32 Lo = static_cast<uint32>(FMath::Min(A, B));
const uint32 Hi = static_cast<uint32>(FMath::Max(A, B));
return (static_cast<uint64>(Lo) << 32) | static_cast<uint64>(Hi);
}
void MazeGraphAddEdge(TArray<TArray<int32>>& Adjacency, TSet<uint64>& Seen,
int32 A, int32 B)
{
if (A == B) { return; }
const uint64 Key = MazeGraphEdgeKey(A, B);
if (!Seen.Contains(Key))
{
Seen.Add(Key);
Adjacency[A].Add(B);
Adjacency[B].Add(A);
}
}
FMazeGraphAudit AuditMazeGraph(const FMazeGenerationParams& Params, int32 Seed,
int32 Radius, int32 MetricRadius)
{
FMazeGraphAudit Out;
const int32 Dim = Radius * 2 + 1;
Out.NumNodes = Dim * Dim * Dim;
TArray<TArray<int32>> Adjacency;
Adjacency.SetNum(Out.NumNodes);
TSet<uint64> Seen;
const uint32 Salt = static_cast<uint32>(Seed) ^ 0x4D617A65u;
// One parent per node. In this centered finite audit the parent of every sampled node is
// also sampled because it lowers Manhattan distance to the origin.
for (int32 Z = -Radius; Z <= Radius; ++Z)
for (int32 Y = -Radius; Y <= Radius; ++Y)
for (int32 X = -Radius; X <= Radius; ++X)
{
FIntVector Parent;
if (VoxelMazeTopology::TryGetParent(X, Y, Z, Salt, Parent))
{
MazeGraphAddEdge(Adjacency, Seen,
MazeGraphIndex(X, Y, Z, Radius),
MazeGraphIndex(Parent.X, Parent.Y, Parent.Z, Radius));
}
}
// Add only the optional non-tree edges. The tree count is therefore observable directly,
// and the same routine can audit the authored loop knobs without treating them as a
// connectivity requirement.
for (int32 Z = -Radius; Z <= Radius; ++Z)
for (int32 Y = -Radius; Y <= Radius; ++Y)
for (int32 X = -Radius; X <= Radius; ++X)
{
const int32 A = MazeGraphIndex(X, Y, Z, Radius);
if (X < Radius && VoxelMazeTopology::IsLoopEdgeOpen(
X, Y, Z, VoxelMazeTopology::EAxis::X, Salt,
Params.BranchProbability, Params.Verticality))
{
MazeGraphAddEdge(Adjacency, Seen, A,
MazeGraphIndex(X + 1, Y, Z, Radius));
}
if (Y < Radius && VoxelMazeTopology::IsLoopEdgeOpen(
X, Y, Z, VoxelMazeTopology::EAxis::Y, Salt,
Params.BranchProbability, Params.Verticality))
{
MazeGraphAddEdge(Adjacency, Seen, A,
MazeGraphIndex(X, Y + 1, Z, Radius));
}
if (Z < Radius && VoxelMazeTopology::IsLoopEdgeOpen(
X, Y, Z, VoxelMazeTopology::EAxis::Z, Salt,
Params.BranchProbability, Params.Verticality))
{
MazeGraphAddEdge(Adjacency, Seen, A,
MazeGraphIndex(X, Y, Z + 1, Radius));
}
}
Out.NumEdges = Seen.Num();
Out.EdgeKeys.Reserve(Seen.Num());
for (const uint64 Key : Seen) { Out.EdgeKeys.Add(Key); }
Out.EdgeKeys.Sort();
TArray<uint8> Visited;
Visited.Init(0, Out.NumNodes);
TArray<int32> Pending;
Pending.Add(MazeGraphIndex(0, 0, 0, Radius));
Visited[Pending[0]] = 1;
while (Pending.Num() > 0)
{
const int32 Current = Pending.Pop(EAllowShrinking::No);
for (const int32 Next : Adjacency[Current])
{
if (!Visited[Next])
{
Visited[Next] = 1;
Pending.Add(Next);
}
}
}
for (const uint8 WasVisited : Visited)
{
if (!WasVisited) { ++Out.NumDisconnected; }
}
// Report degree only for an interior window so the finite audit boundary cannot invent
// dead ends. Degree 1 is the dead-end count; degree 6 would be the regular cubic grid.
for (int32 Z = -MetricRadius; Z <= MetricRadius; ++Z)
for (int32 Y = -MetricRadius; Y <= MetricRadius; ++Y)
for (int32 X = -MetricRadius; X <= MetricRadius; ++X)
{
const int32 Degree = Adjacency[ MazeGraphIndex(X, Y, Z, Radius) ].Num();
++Out.DegreeCounts[FMath::Min(Degree, 7)];
}
// A run is a maximal sequence of degree-2 edges between junctions/dead ends. The graph
// is sampled with a larger halo than the reported degree window; truncation only affects
// the outermost runs, never the connectivity assertion.
TSet<uint64> UsedRunEdges;
for (int32 Start = 0; Start < Adjacency.Num(); ++Start)
{
if (Adjacency[Start].Num() == 2) { continue; }
for (const int32 First : Adjacency[Start])
{
const uint64 FirstKey = MazeGraphEdgeKey(Start, First);
if (UsedRunEdges.Contains(FirstKey)) { continue; }
int32 Length = 1;
UsedRunEdges.Add(FirstKey);
int32 Previous = Start;
int32 Current = First;
while (Adjacency[Current].Num() == 2)
{
const int32 Next = Adjacency[Current][0] == Previous
? Adjacency[Current][1] : Adjacency[Current][0];
const uint64 EdgeKey = MazeGraphEdgeKey(Current, Next);
if (UsedRunEdges.Contains(EdgeKey)) { break; }
UsedRunEdges.Add(EdgeKey);
++Length;
Previous = Current;
Current = Next;
}
Out.RunLengths.Add(Length);
}
}
// A completely degree-2 cycle is not expected for this tree-plus-loops graph, but keep
// the distribution total honest if a future loop policy creates one.
for (int32 Start = 0; Start < Adjacency.Num(); ++Start)
for (const int32 First : Adjacency[Start])
{
const uint64 FirstKey = MazeGraphEdgeKey(Start, First);
if (UsedRunEdges.Contains(FirstKey)) { continue; }
int32 Length = 1;
UsedRunEdges.Add(FirstKey);
int32 Previous = Start;
int32 Current = First;
while (true)
{
int32 Next = INDEX_NONE;
for (const int32 Candidate : Adjacency[Current])
{
if (Candidate != Previous) { Next = Candidate; break; }
}
if (Next == INDEX_NONE) { break; }
const uint64 EdgeKey = MazeGraphEdgeKey(Current, Next);
if (UsedRunEdges.Contains(EdgeKey)) { break; }
UsedRunEdges.Add(EdgeKey);
++Length;
Previous = Current;
Current = Next;
}
Out.RunLengths.Add(Length);
}
return Out;
}
FString DescribeMazeDistribution(const TArray<int64>& DegreeCounts,
const TArray<int32>& RunLengths)
{
int64 TotalDegree = 0;
int64 DegreeSum = 0;
for (int32 Degree = 0; Degree < DegreeCounts.Num(); ++Degree)
{
TotalDegree += DegreeCounts[Degree];
DegreeSum += Degree * DegreeCounts[Degree];
}
TArray<int32> SortedRuns = RunLengths;
SortedRuns.Sort();
const int32 Median = SortedRuns.Num() > 0 ? SortedRuns[SortedRuns.Num() / 2] : 0;
const int32 P90 = SortedRuns.Num() > 0
? SortedRuns[FMath::Min(SortedRuns.Num() - 1,
FMath::FloorToInt(SortedRuns.Num() * 0.90f))]
: 0;
int32 MaxRun = 0;
for (const int32 Run : SortedRuns) { MaxRun = FMath::Max(MaxRun, Run); }
FString DegreeText;
for (int32 Degree = 0; Degree < DegreeCounts.Num(); ++Degree)
{
if (Degree > 0) { DegreeText += TEXT(", "); }
DegreeText += FString::Printf(TEXT("d%d=%lld"), Degree,
static_cast<long long>(DegreeCounts[Degree]));
}
return FString::Printf(
TEXT("degrees{%s}, mean=%.3f, dead_end_fraction=%.3f; runs n=%d min=%d "
"median=%d p90=%d max=%d"),
*DegreeText,
TotalDegree > 0 ? static_cast<double>(DegreeSum) / static_cast<double>(TotalDegree) : 0.0,
TotalDegree > 0 ? static_cast<double>(DegreeCounts.IsValidIndex(1) ? DegreeCounts[1] : 0)
/ static_cast<double>(TotalDegree) : 0.0,
SortedRuns.Num(), SortedRuns.Num() > 0 ? SortedRuns[0] : 0,
Median, P90, MaxRun);
}
/** Les params Maze de la strate Maze de la fixture, bornes Z de runtime comprises. */
bool ResolveMazeParams(const VoxelForgeTest::FTestWorld& World, FMazeGenerationParams& Out,
int32& OutTopVoxelZ, int32& OutBottomVoxelZ)
{
using namespace VoxelForgeTest;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotMaze, OutTopVoxelZ, OutBottomVoxelZ)) { return false; }
const int32 MidChunkZ = ((OutTopVoxelZ + OutBottomVoxelZ) / 2) / CHUNK_SIZE;
Out = World.StrateManager->GetMazeParamsForChunk(FIntVector(0, 0, MidChunkZ));
return true;
}
}
bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
FMazeGenerationParams MazeParams;
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
if (!ResolveMazeParams(World, MazeParams, TopVoxelZ, BottomVoxelZ))
{
AddError(TEXT("The fixture layout has no Maze slot. Check FTestWorld::Build's Archetypes[] ")
TEXT("against FTestWorld::SlotMaze."));
return false;
}
// GetMazeDensity court-circuite sur une strate dégénérée (`return 1.0f`). Cette garde appartient
// à la fonction d'archétype, pas à un opérateur ; la pile suppose une strate valide.
if (MazeParams.StrateTopWorldZ - MazeParams.StrateBottomWorldZ <= 0.0f)
{
AddError(FString::Printf(
TEXT("The Maze strate has degenerate Z bounds (top %.1f, bottom %.1f), which sends ")
TEXT("GetMazeDensity down its early-out. The op stack has no such early-out by design."),
MazeParams.StrateTopWorldZ, MazeParams.StrateBottomWorldZ));
return false;
}
//==========================================================================
// TOPOLOGY — construction proof, determinism, and maze morphology
//==========================================================================
{
constexpr int32 NumTopologySeeds = 64;
constexpr int32 GraphRadius = 10;
constexpr int32 MetricRadius = 7;
FMazeGenerationParams TreeParams = MazeParams;
TreeParams.BranchProbability = 0.0f;
TreeParams.Verticality = 0.0f;
int32 TreeEdgeCountViolations = 0;
int32 TreeDisconnected = 0;
int32 MazeDisconnected = 0;
int32 DeterminismFailures = 0;
int64 OptionalEdges = 0;
TArray<int64> DegreeCounts;
DegreeCounts.Init(0, 8);
TArray<int32> RunLengths;
for (int32 Seed = 0; Seed < NumTopologySeeds; ++Seed)
{
const FMazeGraphAudit Tree = AuditMazeGraph(
TreeParams, Seed, GraphRadius, MetricRadius);
const FMazeGraphAudit Maze = AuditMazeGraph(
MazeParams, Seed, GraphRadius, MetricRadius);
const FMazeGraphAudit Repeat = AuditMazeGraph(
MazeParams, Seed, GraphRadius, MetricRadius);
if (Tree.NumEdges != Tree.NumNodes - 1) { ++TreeEdgeCountViolations; }
TreeDisconnected += Tree.NumDisconnected;
MazeDisconnected += Maze.NumDisconnected;
OptionalEdges += static_cast<int64>(Maze.NumEdges - (Maze.NumNodes - 1));
if (Maze.EdgeKeys != Repeat.EdgeKeys) { ++DeterminismFailures; }
for (int32 Degree = 0; Degree < DegreeCounts.Num(); ++Degree)
{
DegreeCounts[Degree] += Maze.DegreeCounts[Degree];
}
RunLengths.Append(Maze.RunLengths);
}
TestEqual(TEXT("origin-directed parent graph has exactly N-1 edges with loops disabled"),
TreeEdgeCountViolations, 0);
TestEqual(TEXT("origin-directed parent graph has zero disconnected cells"),
TreeDisconnected, 0);
TestEqual(TEXT("Maze corridor graph has zero disconnected cells across 64 seeds"),
MazeDisconnected, 0);
TestEqual(TEXT("Maze topology is deterministic for repeated same-seed builds"),
DeterminismFailures, 0);
int64 TotalDegree = 0;
int64 DegreeSum = 0;
for (int32 Degree = 0; Degree < DegreeCounts.Num(); ++Degree)
{
TotalDegree += DegreeCounts[Degree];
DegreeSum += static_cast<int64>(Degree) * DegreeCounts[Degree];
}
TestTrue(TEXT("Maze topology has actual degree-1 dead ends"),
DegreeCounts.IsValidIndex(1) && DegreeCounts[1] > 0);
TestTrue(TEXT("Maze topology has varied corridor run lengths"),
RunLengths.Num() > 0 && RunLengths[0] >= 1
&& [&RunLengths]()
{
int32 MinRun = MAX_int32, MaxRun = 0;
for (const int32 Run : RunLengths)
{
MinRun = FMath::Min(MinRun, Run);
MaxRun = FMath::Max(MaxRun, Run);
}
return MinRun < MaxRun;
}());
TestTrue(TEXT("Maze mean junction degree stays below a cubic grid's degree 6"),
TotalDegree > 0
&& static_cast<double>(DegreeSum) / static_cast<double>(TotalDegree) < 6.0);
AddInfo(FString::Printf(
TEXT("Maze topology: %d seeds, centered graph radius %d (%d nodes); tree edges "
"N-1 violations=%d, tree disconnected=%d, Maze disconnected=%d, optional "
"loop edges=%lld; %s; cubic-grid control: degree6=100.000%%, degree1=0.000%%, "
"mean=6.000 (no dead ends)."),
NumTopologySeeds, GraphRadius,
(GraphRadius * 2 + 1) * (GraphRadius * 2 + 1) * (GraphRadius * 2 + 1),
TreeEdgeCountViolations, TreeDisconnected, MazeDisconnected,
static_cast<long long>(OptionalEdges),
*DescribeMazeDistribution(DegreeCounts, RunLengths)));
}
const UVoxelGenerator* Gen = World.Generator.Get();
FVoxelOpStack Stack;
VoxelDensityOps::BuildMazeStack(Stack, MazeParams, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// La décomposition doit être une DÉCOMPOSITION. Un `FMazeOp` monolithique passerait tous les
// tests numériques ci-dessous et aurait pourtant raté l'objet entier du refactor (§2.5).
TestEqual(TEXT("the Maze stack is decomposed, not wrapped (rock + corridors + roughness + carve + 4 structural)"),
Stack.Num(), 8);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = MazeParams.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = MazeParams.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
TArray<FVector> Points;
Points.Reserve(NumMazeSamples);
{
FRandomStream Rng(31337);
for (int32 i = 0; i < NumMazeSamples; ++i)
{
Points.Add(FVector(
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(BottomVoxelZ, TopVoxelZ)));
}
}
//=========================================================================
// ÉQUIVALENCE — géométrie d'abord, bits ensuite.
//=========================================================================
int32 NumDiff = 0, WorstIdx = -1, NumBeyondUlpNoise = 0, NumSolidDisagreements = 0;
float WorstDelta = 0.0f;
for (int32 i = 0; i < NumMazeSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const float Old = Gen->GetMazeDensity(X, Y, Z, MazeParams); // MC : négatif = solide
const float New = Stack.EvalMC(X, Y, Z);
if (!BitEqual(Old, New))
{
++NumDiff;
const float Delta = FMath::Abs(Old - New);
if (Delta > WorstDelta) { WorstDelta = Delta; WorstIdx = i; }
// `Blend - Sdf` annule catastrophiquement au bord de la coquille de blend, donc un
// écart d'ULP sur le SDF ressort amplifié sur la densité : marge généreuse, mais bornée.
const float UlpNoise = 16.0f * FMath::Max(FMath::Abs(Old), 1.0f) * FLT_EPSILON;
if (Delta > UlpNoise) { ++NumBeyondUlpNoise; }
}
// Le mesher ne lit que le SIGNE (D >= IsoLevel ⇒ air). Un désaccord de CÔTÉ bouge la géométrie.
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSolidDisagreements; }
}
if (NumDiff == 0)
{
AddInfo(FString::Printf(TEXT("Bit-identical across %d samples."), NumMazeSamples));
}
else if (NumBeyondUlpNoise == 0)
{
AddInfo(FString::Printf(
TEXT("%d of %d samples differ, ALL at ULP scale (largest |delta| %.9g at (%.0f, %.0f, ")
TEXT("%.0f)), and 0 cross the isosurface -- not one triangle would move. This is the ")
TEXT("accepted floor; see the header comment and AUDIT-2026-07.md C10. The SDF itself is ")
TEXT("reproduced BIT FOR BIT, so the lattice, the hashes and VoxelSDF::Capsule are exact; ")
TEXT("only the final SDF->density rounding differs. Do not go hunting this again without ")
TEXT("reading C10 first -- five hypotheses have already been measured and refuted."),
NumDiff, NumMazeSamples, WorstDelta,
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f));
}
else
{
AddWarning(FString::Printf(
TEXT("%d of %d samples differ and %d are TOO LARGE to be the accepted ULP floor (largest ")
TEXT("|delta| %.9g at (%.0f, %.0f, %.0f)); %d cross the isosurface. THIS one is real port ")
TEXT("drift, not the known floor. Check, in order: the roughness apply-window ")
TEXT("(R + SurfaceRoughness + 2), the carve blend (2.0), the noise frequency (0.12) and ")
TEXT("octave count (3), and the order of the structural post ops."),
NumDiff, NumMazeSamples, NumBeyondUlpNoise, WorstDelta,
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
NumSolidDisagreements));
}
// Le SEUL échec dur : un désaccord de côté d'iso EST une différence de géométrie.
TestEqual(TEXT("no sample lands on the opposite side of the isosurface from the original"),
NumSolidDisagreements, 0);
//=========================================================================
// INVARIANCE DE FENÊTRE — la pile doit tenir les mêmes règles que le générateur.
//=========================================================================
// Le cache par cellule de la source de couloirs est `thread_local` : c'est exactement le genre
// d'endroit où une clé incomplète produit une couture (cf. AUDIT C2).
{
std::atomic<int32> Impure{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
TArray<float> Ref;
Ref.SetNumUninitialized(NumMazeSamples);
for (int32 i = 0; i < NumMazeSamples; ++i)
{
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> LocalOrder;
BuildShuffledOrder(NumMazeSamples, 500 + Block, LocalOrder);
for (const int32 i : LocalOrder)
{
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
}
});
TestEqual(TEXT("the op stack is window-invariant across query order and worker threads"),
Impure.load(), 0);
}
//==========================================================================
// CACHE COST — topology decisions belong to the rebuild, not the voxel loop
//==========================================================================
{
constexpr int32 NumRebuildSamples = 256;
constexpr int32 NumHotSamples = 20000;
const float PerfCellSize = FMath::Max(MazeParams.CellSize, 1.0f);
const float PerfZ = 0.5f * (MazeParams.StrateBottomWorldZ + MazeParams.StrateTopWorldZ);
volatile float Sink = 0.0f;
float LastX = 0.0f;
float LastY = 0.0f;
const double RebuildStart = FPlatformTime::Seconds();
for (int32 i = 0; i < NumRebuildSamples; ++i)
{
const int32 CellX = (i % 16) - 8;
const int32 CellY = (i / 16) - 8;
LastX = (static_cast<float>(CellX) + 0.37f) * PerfCellSize;
LastY = (static_cast<float>(CellY) + 0.61f) * PerfCellSize;
Sink += Stack.EvalMC(LastX, LastY, PerfZ);
}
const double RebuildSeconds = FPlatformTime::Seconds() - RebuildStart;
const double HotStart = FPlatformTime::Seconds();
for (int32 i = 0; i < NumHotSamples; ++i)
{
Sink += Stack.EvalMC(LastX, LastY, PerfZ);
}
const double HotSeconds = FPlatformTime::Seconds() - HotStart;
AddInfo(FString::Printf(
TEXT("Maze cache perf: %d forced cell rebuilds at %.3f us/call and %d hot calls "
"at %.3f us/call (8 child nodes + capped loop rolls only on rebuild; capsule "
"SDFs only on hot calls; sink=%.9g)."),
NumRebuildSamples,
RebuildSeconds * 1.0e6 / static_cast<double>(NumRebuildSamples),
NumHotSamples,
HotSeconds * 1.0e6 / static_cast<double>(NumHotSamples),
static_cast<float>(Sink)));
}
//=========================================================================
// LE VERDICT DE BOÎTE — le prix perf de l'intervalle SDF conservatif.
//=========================================================================
// ClassifyTile renvoie Mixed pour tout archétype de grotte, donc TunnelNetwork, Maze,
// VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater ne captent RIEN du
// gain T1.d. Tout nombre > 0 ici est le saut de tuile propre à la pile, pas une décision du
// classifieur historique.
{
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
FRandomStream Rng(24680);
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
const int32 SpanCells = 40;
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
for (int32 t = 0; t < NumMazeBoxTests; ++t)
{
const int32 Step = 1, Cells = 8; // petites tuiles : force brute tenable
const int32 Extent = Step * Cells;
const FIntVector Origin(
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
const int32 GridDim = Cells + 1; // le MÊME treillis que le mesher, marge ±1 comprise
const FBox Box(
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
++NumProved;
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
for (int32 gz = -1; gz <= GridDim; ++gz)
for (int32 gy = -1; gy <= GridDim; ++gy)
for (int32 gx = -1; gx <= GridDim; ++gx)
{
const float X = (float)(Origin.X + gx * Step);
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
++NumBruteSamples;
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
{
AddError(FString::Printf(
TEXT("HOLE: the op stack claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ops' ")
TEXT("EffectOverBox/ClassifyBox is not conservative. Suspects, in order: ")
TEXT("the source interval, the SDF roughness interval, the converter's ")
TEXT("threshold fold, then the seal's forcing verdict."),
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
}
++NumUnsound;
gz = gy = gx = GridDim + 1; // ce verdict est déjà mort, tuile suivante
}
}
}
TestEqual(TEXT("every box verdict the stack emits survives brute force (a false verdict is a hole)"),
NumUnsound, 0);
AddInfo(FString::Printf(
TEXT("Box verdicts over %d Maze tiles (XY sampled from +/- %d voxels = %.1f x ")
TEXT("CellSize %.0f): %d proved uniform, %d Mixed, %d voxels checked, %d violations. Today's ClassifyTile ")
TEXT("proves ZERO of these -- every cave archetype falls through to \"pas prouvable en ")
TEXT("v1\". Any number above zero here is tile-skipping Maze has never had."),
NumMazeBoxTests, SpanVoxels, (float)SpanVoxels / FMath::Max(MazeParams.CellSize, 1.0f), MazeParams.CellSize,
NumProved, NumMixed, NumBruteSamples, NumUnsound));
if (NumProved == 0)
{
AddWarning(TEXT("The stack proved no tile uniform, so it is not yet better than today's ")
TEXT("classifier for Maze. Not a correctness problem, but the perf case for ")
TEXT("the port rests on this number."));
}
}
//==========================================================================
// ISOLATION — a source with a converter it was never authored with
//==========================================================================
// Maze's hand-written stack uses ConstantRock -> Lattice -> SdfRoughness -> SdfCarve.
// This stack deliberately uses a VOID root and SdfFill instead. The only way it may
// claim AllAir is for the propagated lattice/roughness interval to prove that the fill
// converter is inactive over the whole box. A source that still answered for its old
// carve neighbour would not be a valid implementation of this test.
{
FMazeGenerationParams NovelParams = MazeParams;
NovelParams.BranchProbability = 0.12f;
NovelParams.Verticality = 0.08f;
NovelParams.CorridorRadius = 1.0f;
FVoxelOpStack NovelStack;
NovelStack.Add(VoxelDensityOps::MakeConstantVoidSource(NovelParams.BaseDensity));
NovelStack.Add(VoxelDensityOps::MakeLatticeCorridorSource(NovelParams, World.Settings->Seed));
NovelStack.Add(VoxelDensityOps::MakeSdfRoughnessMod(
0.35f, 0.12f, 3, NovelParams.CorridorRadius + 2.0f));
NovelStack.Add(VoxelDensityOps::MakeSdfFill(1.25f, NovelParams.BaseDensity));
NovelStack.PrepareChunk(Ctx);
FString ChannelError;
TestTrue(TEXT("the unverified source/converter stack has a valid channel order"),
NovelStack.ValidateChannelOrder(&ChannelError));
if (!ChannelError.IsEmpty()) { AddError(ChannelError); }
constexpr int32 NumNovelBoxTests = 256;
const int32 NovelSpanCells = 160;
const int32 NovelSpanVoxels = NovelSpanCells * 8;
int32 NumNovelSolid = 0, NumNovelAir = 0, NumNovelMixed = 0;
int32 NumNovelVoxels = 0, NumNovelViolations = 0;
FRandomStream NovelRng(86420);
for (int32 t = 0; t < NumNovelBoxTests; ++t)
{
const int32 Step = 1, Cells = 8;
const int32 Extent = Step * Cells;
const FIntVector Origin(
NovelRng.RandRange(-NovelSpanCells, NovelSpanCells) * Extent,
NovelRng.RandRange(-NovelSpanCells, NovelSpanCells) * Extent,
FMath::Clamp(NovelRng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
const int32 GridDim = Cells + 1;
const FBox Box(
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
const EVoxelTileClass Verdict = NovelStack.ClassifyBox(Box, Ctx);
if (Verdict == EVoxelTileClass::Mixed)
{
++NumNovelMixed;
continue;
}
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
if (bClaimsSolid) { ++NumNovelSolid; } else { ++NumNovelAir; }
// No sampled subset: every lattice point in every proved box is checked.
for (int32 gz = -1; gz <= GridDim; ++gz)
for (int32 gy = -1; gy <= GridDim; ++gy)
for (int32 gx = -1; gx <= GridDim; ++gx)
{
const float X = (float)(Origin.X + gx * Step);
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = NovelStack.EvalMC(X, Y, Z);
++NumNovelVoxels;
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f)) { ++NumNovelViolations; }
}
}
TestEqual(TEXT("the unverified source/converter stack has zero box-verdict violations"),
NumNovelViolations, 0);
AddInfo(FString::Printf(
TEXT("Novel lattice+roughness+FILL stack: %d boxes proved (%d AllSolid, %d AllAir), " )
TEXT("%d Mixed; %d voxels checked, %d violations; XY sampled from +/- %d voxels."),
NumNovelSolid + NumNovelAir, NumNovelSolid, NumNovelAir, NumNovelMixed,
NumNovelVoxels, NumNovelViolations, NovelSpanVoxels));
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,385 @@
// VoxelForgeOpStackShaftTest.cpp
// VerticalShafts — le portage qui teste la RÉUTILISATION et le contrat de l'opérateur courant.
// VerticalShafts — the port that tests REUSE and the current operator contract.
//
// CE QUE CELUI-CI PROUVE EN PLUS DES AUTRES
// Les portages précédents demandaient « la décomposition reproduit-elle l'original ? ». Celui-ci
// demande **« les opérateurs se RÉUTILISENT-ils vraiment entre archétypes ? »**, qui est la thèse
// de `OPSTACK-PLAN §2.5` et la seule raison de faire ce refactor plutôt que de nettoyer le `switch`.
//
// Trois des cinq opérateurs de VerticalShafts sont ceux de Maze, **repris sans une ligne de
// changement** : `ConstantRock`, `SdfRoughness`, `SdfCarve`. Dans le `switch`, `GetMazeDensity` et
// `GetVerticalShaftDensity` sont deux fonctions de ~100 lignes qui n'ont rien en commun à l'œil.
// En opérateurs, ce sont les mêmes trois ops avec une source différente et d'autres réglages
// (fréquence 0.1 au lieu de 0.12, fenêtre `rough + 4` au lieu de `R + rough + 2`).
//
// The direct helper remains useful migration telemetry, but it is not the owner path after the
// structural-post split. The assertions below target the operator stack actually used in-game:
// current channel count/order, feature liveness, cache isolation and box-proof soundness.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformTime.h"
#include "HAL/PlatformMisc.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include "VoxelPassageGeometry.h"
#include <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpStackShaftTest,
"VoxelForge.OpStack.VerticalShaftEquivalence",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
// This late sweep remains full-strength and is run with -TestFilter when the aggregate launch
// has already reached the harness's 30-minute guard.
namespace
{
constexpr int32 NumShaftSamples = 20000;
/** Les ledges et les connecteurs sont éteints ou discrets par défaut. Un test sur les seuls
* défauts vérifierait les cylindres et laisserait les DEUX opérateurs intéressants au repos —
* le même piège que `WaterLevelRelative` et la fenêtre d'overhang. */
void EnableShaftFeatures(FVerticalShaftParams& P)
{
P.CrossConnectChance = 0.65f; // des connecteurs, donc des capsules dans le SDF
P.ConnectorRadius = 3.5f;
P.LedgeSpacing = 11.0f; // des étagères, donc l'op forçant s'exécute
P.LedgeDepth = 2.5f;
P.SurfaceRoughness = 3.0f; // la rugosité SDF partagée avec Maze
}
// The owner generator resets this call-local hand-off at the start and end of every density
// query. Direct stack tests must model that boundary too: otherwise a connector-air write from
// the previous sample can suppress the next sample's passage support floor.
float EvalStackMCForTest(const FVoxelOpStack& Stack, float X, float Y, float Z)
{
VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker();
const float Result = Stack.EvalMC(X, Y, Z);
VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker();
return Result;
}
}
bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotVerticalShafts, TopVoxelZ, BottomVoxelZ))
{
AddError(TEXT("The fixture layout has no VerticalShafts slot. Check FTestWorld::Build's ")
TEXT("Archetypes[] against FTestWorld::SlotVerticalShafts."));
return false;
}
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
FVerticalShaftParams P = World.StrateManager->GetVerticalShaftParamsForChunk(
FIntVector(0, 0, MidChunkZ));
if (P.StrateTopWorldZ - P.StrateBottomWorldZ <= 0.0f)
{
AddError(TEXT("The VerticalShafts strate has degenerate Z bounds, which sends ")
TEXT("GetVerticalShaftDensity down its early-out. The op stack has none by design."));
return false;
}
EnableShaftFeatures(P);
FVoxelOpStack Stack;
VoxelDensityOps::BuildVerticalShaftStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// rock + shafts + roughness + carve + ledges + 4 structural posts.
TestEqual(TEXT("the shaft stack is decomposed into 11 ops"), Stack.Num(), 11);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
TArray<FVector> Points;
Points.Reserve(NumShaftSamples);
{
FRandomStream Rng(80486);
for (int32 i = 0; i < NumShaftSamples; ++i)
{
Points.Add(FVector(
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(BottomVoxelZ, TopVoxelZ)));
}
}
//=========================================================================
// 1. CURRENT-OWNER DIAGNOSTIC
// GetVerticalShaftDensity is the retired direct path. Its delta is reported so a future
// port can be audited, but it is not an owner-path failure: the game evaluates Stack plus
// the common MC tail.
//=========================================================================
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1, NumInsideShaft = 0;
float WorstDelta = 0.0f;
for (int32 i = 0; i < NumShaftSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const float Old = Gen->GetVerticalShaftDensity(X, Y, Z, P);
const float New = EvalStackMCForTest(Stack, X, Y, Z);
if (Old >= 0.0f) { ++NumInsideShaft; } // air ⇒ dans un puits/connecteur/étagère
if (!BitEqual(Old, New))
{
++NumDiff;
const float D = FMath::Abs(Old - New);
if (D > WorstDelta) { WorstDelta = D; WorstIdx = i; }
}
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
}
if (NumDiff == 0)
{
AddInfo(FString::Printf(
TEXT("VerticalShafts: bit-identical across %d samples (%d of them inside a shaft, so ")
TEXT("the cylinders, connectors, roughness, carve and ledges were all exercised). ")
TEXT("THREE of the five ops here are Maze's, reused unchanged -- operator reuse across ")
TEXT("archetypes is now measured rather than intended (OPSTACK-PLAN 2.5)."),
NumShaftSamples, NumInsideShaft));
}
AddInfo(FString::Printf(
TEXT("VerticalShafts legacy diagnostic: %d of %d samples differ (largest |delta| %.9g at "
"(%.0f, %.0f, %.0f)); %d cross the isosurface; %d samples are air in the stack."),
NumDiff, NumShaftSamples, WorstDelta,
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
NumSideDisagree, NumInsideShaft));
TestTrue(TEXT("shaft feature sampling exercised the operator stack"), NumInsideShaft > 0);
if (NumInsideShaft == 0)
{
AddWarning(TEXT("No sample landed inside a shaft, so the source, carve and ledge ops were ")
TEXT("never meaningfully exercised. Raise ShaftDensity or ShaftMaxRadius."));
}
//=========================================================================
// 2. INVARIANCE DE FENÊTRE
//=========================================================================
// La source garde un cache inner 3×3 `thread_local`; chaque rebuild collecte un halo 9×9/15×15
// pour les fenêtres d'arbre 5×5 et le fallback ±3. La clé est le jeu de params : c'est exactement le genre d'endroit où
// une clé incomplète produit une couture (AUDIT §C2).
{
std::atomic<int32> Impure{ 0 };
std::atomic<int32> FirstImpure{ -1 };
std::atomic<uint32> FirstImpureRefBits{ 0 };
std::atomic<uint32> FirstImpureValueBits{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
TArray<float> Ref;
Ref.SetNumUninitialized(NumShaftSamples);
for (int32 i = 0; i < NumShaftSamples; ++i)
{
Ref[i] = EvalStackMCForTest(
Stack, (float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> LocalOrder;
BuildShuffledOrder(NumShaftSamples, 2200 + Block, LocalOrder);
for (const int32 i : LocalOrder)
{
const float V = EvalStackMCForTest(
Stack, (float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(V, Ref[i]))
{
Impure.fetch_add(1, std::memory_order_relaxed);
int32 Expected = -1;
if (FirstImpure.compare_exchange_strong(
Expected, i, std::memory_order_relaxed))
{
uint32 RefBits = 0;
uint32 ValueBits = 0;
FMemory::Memcpy(&RefBits, &Ref[i], sizeof(RefBits));
FMemory::Memcpy(&ValueBits, &V, sizeof(ValueBits));
FirstImpureRefBits.store(RefBits, std::memory_order_relaxed);
FirstImpureValueBits.store(ValueBits, std::memory_order_relaxed);
}
}
}
});
TestEqual(TEXT("the shaft stack is window-invariant across order and threads"),
Impure.load(), 0);
if (FirstImpure.load(std::memory_order_relaxed) >= 0)
{
float RefValue = 0.0f;
float ParallelValue = 0.0f;
const uint32 RefBits = FirstImpureRefBits.load(std::memory_order_relaxed);
const uint32 ValueBits = FirstImpureValueBits.load(std::memory_order_relaxed);
FMemory::Memcpy(&RefValue, &RefBits, sizeof(RefValue));
FMemory::Memcpy(&ParallelValue, &ValueBits, sizeof(ParallelValue));
const int32 Index = FirstImpure.load(std::memory_order_relaxed);
AddInfo(FString::Printf(
TEXT("first shaft window mismatch: sample %d point (%.0f,%.0f,%.0f) "
"serial=%.9g parallel=%.9g"),
Index, Points[Index].X, Points[Index].Y, Points[Index].Z,
RefValue, ParallelValue));
}
}
//==========================================================================
// 2b. CACHE COST
//==========================================================================
{
// Force one distinct centre cell per call, then hold one cell constant. This measures the
// widened collection where it belongs (cache rebuild), separately from the hot per-voxel
// path. The implementation deliberately performs 225 cell rolls for the former and zero
// cell rolls for the latter; only the cached inner 3×3/capsule SDFs remain hot.
constexpr int32 NumRebuildSamples = 256;
constexpr int32 NumHotSamples = 20000;
const float PerfSpacing = FMath::Max(P.ShaftSpacing, 1.0f);
const float PerfZ = 0.5f * (P.StrateBottomWorldZ + P.StrateTopWorldZ);
volatile float Sink = 0.0f;
const double RebuildStart = FPlatformTime::Seconds();
float LastX = 0.0f;
float LastY = 0.0f;
for (int32 i = 0; i < NumRebuildSamples; ++i)
{
const int32 CellX = (i % 16) - 8;
const int32 CellY = (i / 16) - 8;
LastX = (static_cast<float>(CellX) + 0.37f) * PerfSpacing;
LastY = (static_cast<float>(CellY) + 0.61f) * PerfSpacing;
Sink += Gen->GetVerticalShaftDensity(LastX, LastY, PerfZ, P);
}
const double RebuildSeconds = FPlatformTime::Seconds() - RebuildStart;
const double HotStart = FPlatformTime::Seconds();
for (int32 i = 0; i < NumHotSamples; ++i)
{
Sink += Gen->GetVerticalShaftDensity(LastX, LastY, PerfZ, P);
}
const double HotSeconds = FPlatformTime::Seconds() - HotStart;
AddInfo(FString::Printf(
TEXT("VerticalShafts cache perf: %d forced rebuilds at %.3f us/call and %d hot calls "
"at %.3f us/call (15x15=225 cell rolls only on rebuild; inner 3x3/capsule SDF "
"only on hot calls; sink=%.9g)."),
NumRebuildSamples,
RebuildSeconds * 1.0e6 / static_cast<double>(NumRebuildSamples),
NumHotSamples,
HotSeconds * 1.0e6 / static_cast<double>(NumHotSamples),
static_cast<float>(Sink)));
}
//=========================================================================
// 3. LE VERDICT DE BOÎTE
//=========================================================================
{
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
FRandomStream Rng(13579);
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
const int32 SpanCells = 55;
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
for (int32 t = 0; t < 60; ++t)
{
const int32 Step = 1, Cells = 8;
const int32 Extent = Step * Cells;
// ⚠️ L'ÉTENDUE XY ÉTAIT ±48 VOXELS, POUR UN `ShaftSpacing` DE 55 : moins d'UNE cellule
// de puits. C'est le même piège que celui qui a coûté trois runs au test TunnelNetwork —
// un échantillonneur qui ne couvre pas une période du motif ne mesure pas le monde, il
// mesure un point du motif. ±440 = 8 périodes.
// The XY extent was ±48 voxels for a ShaftSpacing of 55 — less than one shaft cell, the
// same trap that cost the TunnelNetwork test three runs. ±440 covers 8 periods.
const FIntVector Origin(
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
const int32 GridDim = Cells + 1;
const FBox Box(
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
++NumProved;
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
for (int32 gz = -1; gz <= GridDim; ++gz)
for (int32 gy = -1; gy <= GridDim; ++gy)
for (int32 gx = -1; gx <= GridDim; ++gx)
{
const float X = (float)(Origin.X + gx * Step);
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = EvalStackMCForTest(Stack, X, Y, Z);
++NumBruteSamples;
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
{
AddError(FString::Printf(
TEXT("HOLE: the shaft stack claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ")
TEXT("order: the shaft source's SDF interval (does it cover all shaft ")
TEXT("and connector geometry?), then the connector sweep (a ")
TEXT("connector can reach Spacing*1.6 beyond its cell), then the ledge ")
TEXT("op's FillOnly."),
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
}
++NumUnsound;
gz = gy = gx = GridDim + 1;
}
}
}
TestEqual(TEXT("every box verdict the shaft stack emits survives brute force"), NumUnsound, 0);
AddInfo(FString::Printf(
TEXT("Box verdicts over 60 VerticalShafts tiles (XY sampled from +/-%d voxels = %.1f x ")
TEXT("ShaftSpacing %.0f): %d proved uniform, %d Mixed, %d voxels checked, %d violations. ")
TEXT("This was 0 proved for as long as the connector branch bailed on mere shaft ")
TEXT("EXISTENCE within Spacing*1.6 -- true almost everywhere at ShaftDensity 0.6, so it ")
TEXT("was conservative AND sterile. It now tests the real connector capsules. Read the ")
TEXT("proved count as a measurement; what is ASSERTED is that none of them is wrong, ")
TEXT("because a false verdict here leaves no geometry and no collision."),
SpanVoxels, (float)SpanVoxels / FMath::Max(P.ShaftSpacing, 1.0f), P.ShaftSpacing,
NumProved, NumMixed, NumBruteSamples, NumUnsound));
if (NumProved == 0)
{
AddWarning(TEXT("No VerticalShafts tile was proved, so the brute force above verified ")
TEXT("nothing. Before hypothesising: the shaft CIRCLE test and the connector ")
TEXT("CAPSULE test are the only two things that can return CarveOnly here, ")
TEXT("and the interval must cover both the shaft and connector capsules before ")
TEXT("either test is relaxed."));
}
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,385 @@
// VoxelForgeOpStackSlabTest.cpp
// PHASE 2, PREMIER PORTAGE — la pile Slab courante, sur LES DEUX archétypes.
// PHASE 2'S FIRST PORT — the current Slab operator stack, on BOTH archetypes.
//
// CE QUE CE TEST DOIT PROUVER / WHAT THIS TEST HAS TO PROVE
// Trois choses, et la troisième est la raison d'être du portage :
//
// 1. CURRENT OWNER — the stack's channels, window invariance and conservative box verdicts
// guard the path used by the game. `GetSlabDensity` is retained as a legacy diagnostic only:
// it still owns the former full passage/support tail, while the operator stack is followed by
// the current common MC post.
// 2. UN OPÉRATEUR, DEUX ARCHÉTYPES — la MÊME pile est vérifiée pour FlatPlain ET
// CrystalChamber. The legacy helper is sampled for migration telemetry, not used as the
// owner oracle; if the current stack becomes non-finite or loses its channel contract, this
// test still fails.
// ⚠️ La fixture ne règle que `GeneratorType`, donc les deux slots portent des params PAR
// DÉFAUT : à eux seuls ils exécutent la même configuration à deux profondeurs. C'est la
// TROISIÈME passe (`CrystalChamber(tuned)`, `CeilingRoughness` 6 → 20) qui fait réellement
// varier ce qui distingue les deux archétypes — et qui sert en même temps de pire cas aux
// bornes d'amplitude de `ClassifyBox`. Voir le bloc en bas de fichier.
// 3. LE VERDICT DE BOÎTE — et c'est ici que §3.1 se paie. `ClassifyTile` prouve ZÉRO tuile pour
// FlatPlain et CrystalChamber aujourd'hui. Depuis que les deux surfaces sont XY-PURES, leurs
// bornes en Z sont connues exactement (contrat [-1,1] de FBM), donc toute tuile entièrement
// sous le sol ou entre les deux bandes se prouve SANS échantillonner.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// The old direct helper contains the removed support-floor/room tail. Room ownership and the
// authored-floor removal deliberately split that structural post from the operator stack, so its
// differences remain visible as diagnostics while the owner assertions below cover the live path.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformMisc.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpStackSlabTest,
"VoxelForge.OpStack.SlabEquivalence",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 NumSlabSamples = 20000;
constexpr int32 NumSlabTiles = 60;
}
bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
//=========================================================================
// LA BATTERIE, PARAMÉTRÉE PAR ARCHÉTYPE
//=========================================================================
// Exécutée à l'identique sur FlatPlain et CrystalChamber. Si les deux passent avec la MÊME
// pile et la MÊME fabrique, la fusion des deux archétypes est démontrée plutôt qu'affirmée.
auto RunBattery = [&](const FSlabGenerationParams& SlabParams,
int32 TopVoxelZ, int32 BottomVoxelZ,
int32 SlotIndex, const TCHAR* SlotName)
{
// `GetSlabDensity` court-circuite sur une strate dégénérée (`return 1.0f`). Cette garde
// appartient à la fonction d'archétype, pas à un opérateur ; la pile suppose une strate
// valide, et `GetDensityAt` retombe sur le `switch` dans ce cas.
if (SlabParams.StrateTopWorldZ - SlabParams.StrateBottomWorldZ <= 0.0f)
{
AddError(FString::Printf(
TEXT("%s has degenerate Z bounds (top %.1f, bottom %.1f), which sends GetSlabDensity ")
TEXT("down its early-out. The op stack has no such early-out by design."),
SlotName, SlabParams.StrateTopWorldZ, SlabParams.StrateBottomWorldZ));
return;
}
FVoxelOpStack Stack;
VoxelDensityOps::BuildSlabStack(Stack, SlabParams, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// La décomposition doit rester une DÉCOMPOSITION : vide + colonnes + 4 structurels.
TestEqual(*FString::Printf(TEXT("%s decomposes into void + columns + 4 structural"), SlotName),
Stack.Num(), 6);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = SlabParams.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = SlabParams.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
TArray<FVector> Points;
Points.Reserve(NumSlabSamples);
{
FRandomStream Rng(31337 + SlotIndex);
for (int32 i = 0; i < NumSlabSamples; ++i)
{
Points.Add(FVector(
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(BottomVoxelZ, TopVoxelZ)));
}
}
//=====================================================================
// 1. ÉQUIVALENCE — géométrie d'abord, bits ensuite.
//=====================================================================
// ─────────────────────────────────────────────────────────────────────
// LE BON MÈTRE — corrigé 2026-07-27 après que la passe `tuned` a crié au loup
// ─────────────────────────────────────────────────────────────────────
// Première version : `16 · max(|Old|, 1) · FLT_EPSILON`, c.-à-d. l'ULP mesuré sur la
// DENSITÉ DE SORTIE. C'est le mauvais mètre, et il se trompe exactement là où le test
// regarde le plus : la densité vaut `min(Z - Sol, Plafond - Z)`, donc PRÈS DE L'ISOSURFACE
// la sortie tend vers 0 pendant que les intermédiaires (surfaces, Z monde, amplitudes de
// bruit) valent des CENTAINES. Un arrondi né à l'échelle 400 était jugé contre un mètre
// à l'échelle 1 — 400× trop serré.
//
// Mesuré : la passe `tuned` (rugosités ×2.25 et ×3.33) a vu ses écarts croître ×4.5, et
// son pire écart valait **0.345 ULP de |Z|**. Sous-ULP à l'échelle où l'erreur naît.
// L'erreur est donc proportionnelle à l'AMPLITUDE, ce qui est la signature d'un arrondi
// ordinaire, pas d'une transcription fausse.
//
// Le mètre correct est la magnitude des quantités D'OÙ VIENT l'erreur. Le test reste
// discriminant : une vraie dérive de portage (offset de bruit faux, `abs()` manquant,
// clamp oublié) déplace la surface de plusieurs VOXELS — 4 ordres de grandeur au-dessus
// de ce seuil, pas 4 fois.
//
// The first yardstick measured ULPs on the OUTPUT density, which tends to 0 near the
// isosurface while the intermediates are in the hundreds. Rounding born at scale ~400 was
// judged against a yardstick of scale 1. Real port drift moves the surface by voxels —
// four orders of magnitude above this bound, so the test stays discriminating.
const float SurfaceScale = FMath::Max(FMath::Abs(SlabParams.StrateTopWorldZ),
FMath::Abs(SlabParams.StrateBottomWorldZ));
int32 NumDiff = 0, WorstIdx = -1, NumBeyondUlpNoise = 0, NumSolidDisagreements = 0;
int32 NumNonFinite = 0;
float WorstDelta = 0.0f, WorstOld = 0.0f, WorstUlpsOfScale = 0.0f;
// Le pire cas PARMI LES DÉPASSEMENTS — c'est lui qui dit si un WARN est du bruit ou une dérive.
float WorstOutlierDelta = 0.0f, WorstOutlierOld = 0.0f, WorstOutlierUlps = 0.0f;
for (int32 i = 0; i < NumSlabSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const float Old = Gen->GetSlabDensity(X, Y, Z, SlabParams); // MC : négatif = solide
const float New = Stack.EvalMC(X, Y, Z);
if (!FMath::IsFinite(New))
{
++NumNonFinite;
}
if (!BitEqual(Old, New))
{
++NumDiff;
const float Delta = FMath::Abs(Old - New);
// L'échelle à laquelle CET échantillon calcule : la sortie, sa propre altitude, et
// les bornes de la strate. C'est le plus grand des trois qui porte l'arrondi.
const float Scale = FMath::Max3(FMath::Abs(Old), FMath::Abs(Z),
FMath::Max(SurfaceScale, 1.0f));
const float Ulps = Delta / (Scale * FLT_EPSILON);
if (Delta > WorstDelta)
{
WorstDelta = Delta; WorstIdx = i; WorstOld = Old; WorstUlpsOfScale = Ulps;
}
if (Delta > 16.0f * Scale * FLT_EPSILON)
{
++NumBeyondUlpNoise;
if (Delta > WorstOutlierDelta)
{
WorstOutlierDelta = Delta; WorstOutlierOld = Old; WorstOutlierUlps = Ulps;
}
}
}
// Le mesher ne lit que le SIGNE. Un désaccord de CÔTÉ bouge la géométrie.
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSolidDisagreements; }
}
AddInfo(FString::Printf(
TEXT("%s legacy slab diagnostic: %d of %d samples differ, %d exceed the old ULP "
"bound, largest |delta| %.9g at (%.0f,%.0f,%.0f), %d side disagreements; "
"current stack non-finite samples=%d."),
SlotName, NumDiff, NumSlabSamples, NumBeyondUlpNoise, WorstDelta,
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
NumSolidDisagreements, NumNonFinite));
TestEqual(*FString::Printf(TEXT("%s: current stack has no non-finite density"), SlotName),
NumNonFinite, 0);
//=====================================================================
// 2. INVARIANCE DE FENÊTRE
//=====================================================================
// Le cache 3×3 des colonnes est `thread_local` et sa clé n'est PAS le chunk mais le jeu de
// params + le seed. Si cette clé est incomplète, la couture apparaît ici.
{
std::atomic<int32> Impure{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
TArray<float> Ref;
Ref.SetNumUninitialized(NumSlabSamples);
for (int32 i = 0; i < NumSlabSamples; ++i)
{
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> LocalOrder;
BuildShuffledOrder(NumSlabSamples, 700 + Block + SlotIndex * 32, LocalOrder);
for (const int32 i : LocalOrder)
{
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
}
});
TestEqual(*FString::Printf(
TEXT("%s: the op stack is window-invariant across order and threads"), SlotName),
Impure.load(), 0);
}
//=====================================================================
// 3. LE VERDICT DE BOÎTE — ce que §3.1 a acheté
//=====================================================================
{
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
FRandomStream Rng(24680 + SlotIndex);
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
const int32 SpanCells = 60;
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
for (int32 t = 0; t < NumSlabTiles; ++t)
{
const int32 Step = 1, Cells = 8;
const int32 Extent = Step * Cells;
const FIntVector Origin(
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
const int32 GridDim = Cells + 1; // le MÊME treillis que le mesher, marge ±1 comprise
const FBox Box(
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
++NumProved;
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
for (int32 gz = -1; gz <= GridDim; ++gz)
for (int32 gy = -1; gy <= GridDim; ++gy)
for (int32 gx = -1; gx <= GridDim; ++gx)
{
const float X = (float)(Origin.X + gx * Step);
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
++NumBruteSamples;
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
{
AddError(FString::Printf(
TEXT("HOLE: %s claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ")
TEXT("ops is not conservative. Suspects, in order: the slab source's ")
TEXT("noise amplitude bounds (the proved FBM supremum is 1.5, not 1.0), the ")
TEXT("ceiling clamp raising CeilSurface above CeilZ, then the column ")
TEXT("mod's reach (MaxRadius + blend)."),
SlotName, bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
}
++NumUnsound;
gz = gy = gx = GridDim + 1;
}
}
}
TestEqual(*FString::Printf(
TEXT("%s: every box verdict survives brute force (a false verdict is a hole)"),
SlotName),
NumUnsound, 0);
AddInfo(FString::Printf(
TEXT("%s box verdicts over %d tiles (XY sampled from +/- %d voxels = %.1f x ")
TEXT("ColumnSpacing %.0f): %d proved uniform, %d Mixed, %d voxels checked, %d violations. Today's ")
TEXT("ClassifyTile proves ZERO of these. This number is the whole point of making ")
TEXT("the slab surfaces XY-pure (OPSTACK-DECOMPOSITION 3.1)."),
SlotName, NumSlabTiles, SpanVoxels,
(float)SpanVoxels / FMath::Max(SlabParams.ColumnSpacing, 1.0f), SlabParams.ColumnSpacing,
NumProved, NumMixed, NumBruteSamples, NumUnsound));
if (NumProved == 0)
{
AddWarning(FString::Printf(
TEXT("%s proved no tile uniform. Not a correctness problem, but the entire perf ")
TEXT("case for dropping the Z term rests on this number being well above zero -- ")
TEXT("a slab is mostly solid rock below the floor. Check that the sampled tile Z ")
TEXT("range actually reaches below FloorZ - FloorAmp."), SlotName));
}
}
};
//=========================================================================
// LES TROIS PASSES
//=========================================================================
auto ResolveSlot = [&](int32 SlotIndex, const TCHAR* SlotName,
FSlabGenerationParams& OutParams, int32& OutTop, int32& OutBottom) -> bool
{
if (!World.GetSlotVoxelZRange(SlotIndex, OutTop, OutBottom))
{
AddError(FString::Printf(
TEXT("The fixture layout has no %s slot. Check FTestWorld::Build's Archetypes[] ")
TEXT("against FTestWorld::Slot%s."), SlotName, SlotName));
return false;
}
const int32 MidChunkZ = ((OutTop + OutBottom) / 2) / CHUNK_SIZE;
OutParams = World.StrateManager->GetSlabParamsForChunk(FIntVector(0, 0, MidChunkZ));
return true;
};
FSlabGenerationParams FlatParams, CrystalParams;
int32 FlatTop = 0, FlatBottom = 0, CrystalTop = 0, CrystalBottom = 0;
if (ResolveSlot(FTestWorld::SlotFlatPlain, TEXT("FlatPlain"), FlatParams, FlatTop, FlatBottom))
{
RunBattery(FlatParams, FlatTop, FlatBottom, FTestWorld::SlotFlatPlain, TEXT("FlatPlain"));
}
if (ResolveSlot(FTestWorld::SlotCrystalChamber, TEXT("CrystalChamber"),
CrystalParams, CrystalTop, CrystalBottom))
{
RunBattery(CrystalParams, CrystalTop, CrystalBottom,
FTestWorld::SlotCrystalChamber, TEXT("CrystalChamber"));
//=====================================================================
// LA PASSE QUI FAIT VRAIMENT LA DÉMONSTRATION
//=====================================================================
// ⚠️ La fixture ne règle QUE `GeneratorType` : FlatPlain et CrystalChamber y reçoivent des
// `FSlabGenerationParams` PAR DÉFAUT, donc identiques. Les deux passes ci-dessus exécutent
// en réalité la même configuration à deux profondeurs — ce qui est un test utile, mais qui
// ne démontre PAS « un opérateur, deux jeux de défauts » : `CeilingRoughness`, la seule
// chose qui distingue réellement CrystalChamber, n'y varie jamais.
//
// Cette passe-ci fait varier ce qui compte, et elle est aussi le PIRE CAS pour les bornes
// d'amplitude de `ClassifyBox` : un `CeilingRoughness` élevé élargit la bande du plafond et
// rend le clamp `Max(CeilZ - bruit, FloorSurface + 2)` beaucoup plus susceptible de mordre.
// Si un verdict de boîte est faux quelque part, c'est ici qu'il apparaît.
//
// The fixture only sets GeneratorType, so both slots get DEFAULT slab params — the two
// passes above are the same configuration at two depths. This pass varies what actually
// distinguishes CrystalChamber, and is simultaneously the worst case for the ClassifyBox
// amplitude bounds: a large CeilingRoughness widens the ceiling band and makes the
// FloorSurface + 2 clamp far more likely to bind.
FSlabGenerationParams Tuned = CrystalParams;
Tuned.CeilingRoughness = 20.0f; // vs 6.0 par défaut — de vraies stalactites
Tuned.CeilingRoughnessFrequency = 0.09f;
Tuned.FloorRoughness = 9.0f;
Tuned.ColumnDensity = 0.25f; // beaucoup plus de colonnes ⇒ FillOnly plus souvent
Tuned.ColumnMaxRadius = 11.0f;
RunBattery(Tuned, CrystalTop, CrystalBottom, 64, TEXT("CrystalChamber(tuned)"));
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,101 @@
// Shared measurement-window policy for player-fit diagnostics.
//
// This is deliberately test/editor-side policy. The density generators do not know which two
// points a measurement is asking about, so the caller that knows the archetype must declare
// whether its topology can require the origin spine.
#pragma once
#if WITH_DEV_AUTOMATION_TESTS
#include "CoreMinimal.h"
#include "VoxelStrateMeasure.h"
#include "VoxelStrateTypes.h"
namespace VoxelForgePlayerFitWindow
{
/**
* The four families below have an origin-rooted connectivity implication:
*
* - Maze: every lattice parent chain terminates at (0,0,0).
* - VerticalShafts: every drainage-tree parent chain terminates at the origin spine.
* - TunnelNetwork/Underwater: their origin-flowing room graph terminates at the origin hub.
*
* Slabs, SurfaceWorld, and FloatingIslands have local/independent interior topology for
* this law. The global structural spine is still generated for them, but it is not the
* topology that their mouth-to-mouth fit law promises to follow.
*/
inline bool NeedsOriginInWindow(ECaveGeneratorType Archetype)
{
switch (Archetype)
{
case ECaveGeneratorType::Maze:
case ECaveGeneratorType::VerticalShafts:
case ECaveGeneratorType::TunnelNetwork:
case ECaveGeneratorType::Underwater:
return true;
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
case ECaveGeneratorType::SurfaceWorld:
case ECaveGeneratorType::FloatingIslands:
default:
return false;
}
}
inline const TCHAR* RouteWindowName(ECaveGeneratorType Archetype)
{
return NeedsOriginInWindow(Archetype)
? TEXT("origin-inclusive mouth-AABB + margin")
: TEXT("mouth-AABB + margin");
}
inline FString DescribeResolvedWindow(
const FVoxelStrateMetrics& Metrics,
int32 SampleStep,
ECaveGeneratorType Archetype)
{
if (!Metrics.bValid)
{
return FString::Printf(
TEXT("%s step=%d REFUSED: %s"),
RouteWindowName(Archetype),
SampleStep,
Metrics.RefusalReason.IsEmpty() ? TEXT("unknown") : *Metrics.RefusalReason);
}
return FString::Printf(
TEXT("%s step=%d X[%.1f,%.1f) Y[%.1f,%.1f) Z[%d,%d) "
"grid=%dx%dx%d cells=%lld"),
RouteWindowName(Archetype),
SampleStep,
Metrics.SampledMinX,
Metrics.SampledMaxX,
Metrics.SampledMinY,
Metrics.SampledMaxY,
Metrics.SampledMinZ,
Metrics.SampledMaxZ,
Metrics.SampledNumX,
Metrics.SampledNumY,
Metrics.SampledNumZ,
static_cast<long long>(Metrics.NumSampled));
}
/** Configure the exact fitted XY window used by the player-fit law. */
inline void ConfigureMouthWindow(
FVoxelStrateMeasureSettings& InOutSettings,
const FVector& ArrivalPoint,
const FVector& DeparturePoint,
ECaveGeneratorType Archetype)
{
InOutSettings.CenterXY = FVector2D(
0.5f * (ArrivalPoint.X + DeparturePoint.X),
0.5f * (ArrivalPoint.Y + DeparturePoint.Y));
InOutSettings.CoverPointA = FVector2D(ArrivalPoint.X, ArrivalPoint.Y);
InOutSettings.CoverPointB = FVector2D(DeparturePoint.X, DeparturePoint.Y);
InOutSettings.CoverMarginVoxels = FMath::CeilToFloat(
InOutSettings.PlayerCapsuleRadiusVoxels) + 2.0f;
InOutSettings.bIncludeOriginInCoverWindow = NeedsOriginInWindow(Archetype);
}
}
#endif // WITH_DEV_AUTOMATION_TESTS
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,179 @@
// Sealed-solid proof soundness and invalidation.
//
// The production shortcut is intentionally one-sided: it may return false too often, but a true
// result skips both meshing and level-0 collision generation. This test therefore brute-forces
// every tile that the fixture presents to the proof and treats any non-solid lattice sample as a
// hard failure. The second half deliberately disables the diff guard; a carve must then make the
// weakened proof look true while the same oracle finds an air sample.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelForgeTestFixture.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeSealedSolidProofTest,
"VoxelForge.Determinism.SealedSolidProofSoundness",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
struct FTileSpec
{
FIntVector Origin = FIntVector::ZeroValue;
int32 Step = 1;
int32 Cells = CHUNK_SIZE;
};
int32 CountNonSolidSamples(const UVoxelGenerator& Generator, const FTileSpec& Tile)
{
const int32 Cells = FMath::Clamp(Tile.Cells, 2, CHUNK_SIZE);
int32 NonSolid = 0;
for (int32 Z = 0; Z <= Cells; ++Z)
for (int32 Y = 0; Y <= Cells; ++Y)
for (int32 X = 0; X <= Cells; ++X)
{
const float Density = Generator.GetDensityAt(
static_cast<float>(Tile.Origin.X + X * Tile.Step),
static_cast<float>(Tile.Origin.Y + Y * Tile.Step),
static_cast<float>(Tile.Origin.Z + Z * Tile.Step));
if (!VoxelMath::IsFinite(Density) || !(Density < 0.0f))
{
++NonSolid;
}
}
return NonSolid;
}
void SetSealThickness(UVoxelStrateDefinition& Definition, float Thickness)
{
Definition.GenerationParams.BoundarySealThickness = Thickness;
Definition.SlabParams.BoundarySealThickness = Thickness;
Definition.MazeParams.BoundarySealThickness = Thickness;
Definition.SurfaceParams.BoundarySealThickness = Thickness;
Definition.VerticalShaftParams.BoundarySealThickness = Thickness;
Definition.FloatingIslandParams.BoundarySealThickness = Thickness;
}
}
bool FVoxelForgeSealedSolidProofTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
// Forty voxels makes a complete 32-cell level-0 tile fit inside a bottom boundary seal. The
// production asset remains untouched; this is a transient proof-only fixture configuration.
FTestWorld World;
World.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false,
/*InStrateHeightInChunks*/8);
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
for (const TStrongObjectPtr<UVoxelStrateDefinition>& Definition : World.Definitions)
{
if (Definition.IsValid())
{
SetSealThickness(*Definition.Get(), 40.0f);
}
}
World.Reinitialize();
const UVoxelGenerator* Generator = World.Generator.Get();
const FIntVector FarXY(4096, 4096, 0);
TArray<FTileSpec> CandidateTiles;
// Enumerate every gap chunk and every strate's bottom-seal tile in this fixture. No sample
// is selected after looking at the proof result: all candidates are offered to it first.
for (int32 ChunkZ = World.BottomChunkZ; ChunkZ <= World.TopChunkZ; ++ChunkZ)
{
if (World.StrateManager->IsGapChunk(FIntVector(0, 0, ChunkZ)))
{
CandidateTiles.Add({FIntVector(FarXY.X, FarXY.Y, ChunkZ * CHUNK_SIZE), 1, CHUNK_SIZE});
}
}
for (const FStrateSlot& Slot : World.StrateManager->GetLayout())
{
CandidateTiles.Add({
FIntVector(FarXY.X, FarXY.Y, Slot.BottomChunkZ * CHUNK_SIZE),
1, CHUNK_SIZE});
}
int32 NumProofSkips = 0;
int32 NumNonSolidSamples = 0;
int32 FirstProofTile = INDEX_NONE;
for (int32 Index = 0; Index < CandidateTiles.Num(); ++Index)
{
const FTileSpec& Tile = CandidateTiles[Index];
if (!Generator->TryProveSealedSolidTile(Tile.Origin, Tile.Step, Tile.Cells))
{
continue;
}
++NumProofSkips;
if (FirstProofTile == INDEX_NONE)
{
FirstProofTile = Index;
}
const int32 TileNonSolid = CountNonSolidSamples(*Generator, Tile);
NumNonSolidSamples += TileNonSolid;
if (TileNonSolid != 0)
{
AddError(FString::Printf(
TEXT("FALSE ALL-SOLID PROOF: candidate tile (%d,%d,%d) returned true but has "
"%d non-solid core-lattice samples."),
Tile.Origin.X, Tile.Origin.Y, Tile.Origin.Z, TileNonSolid));
}
}
AddInfo(FString::Printf(
TEXT("Sealed-solid proof brute force: %d candidate tiles, %d proof skips, %d non-solid "
"core-lattice samples."),
CandidateTiles.Num(), NumProofSkips, NumNonSolidSamples));
TestTrue(TEXT("the fixture exercised at least one sealed-solid proof skip"),
NumProofSkips > 0);
TestEqual(TEXT("every skipped tile has zero non-solid core-lattice samples"),
NumNonSolidSamples, 0);
if (FirstProofTile == INDEX_NONE)
{
return false;
}
// A carve in the proven region is the live-edit case that must invalidate the proof. The
// diff layer stores the modification in the tile's chunk, so this is also the exact guard used
// by the worker before it decides to skip meshing.
const FTileSpec& ProofTile = CandidateTiles[FirstProofTile];
const FVector CarveCenter(
static_cast<float>(ProofTile.Origin.X + 16),
static_cast<float>(ProofTile.Origin.Y + 16),
static_cast<float>(ProofTile.Origin.Z + 16));
FVoxelModification Carve;
Carve.Center = CarveCenter;
Carve.Radius = 6.0f;
Carve.Strength = -64.0f;
Carve.Shape = EVoxelBrushShape::Sphere;
TestTrue(TEXT("the adversarial carve was registered"),
World.DiffLayer->ApplyModification(Carve).Num() > 0);
TestFalse(TEXT("a player carve kills the sealed-solid proof"),
Generator->TryProveSealedSolidTile(
ProofTile.Origin, ProofTile.Step, ProofTile.Cells));
const uint8 DiffGuardDisabled = static_cast<uint8>(
EVoxelSealedSolidProofGuard::DiffLayer);
TestTrue(TEXT("the deliberately weakened proof still exposes the geometric candidate"),
Generator->TryProveSealedSolidTileForTest(
ProofTile.Origin, ProofTile.Step, ProofTile.Cells, DiffGuardDisabled));
const int32 WeakenedNonSolidSamples = CountNonSolidSamples(*Generator, ProofTile);
AddInfo(FString::Printf(
TEXT("Weakened proof (diff guard disabled): %d non-solid core-lattice samples after the "
"carve."),
WeakenedNonSolidSamples));
TestTrue(TEXT("the brute-force oracle fails the deliberately weakened guard"),
WeakenedNonSolidSamples > 0);
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,169 @@
// VoxelForgeStrateParamCoverageTest.cpp
// Le garde-fou de la X-macro : tout champ de FStrateGenerationParams DOIT être listé dans
// VF_STRATE_PARAM_FIELDS, sinon il est silencieusement remis à sa valeur par défaut à chaque
// fusion de frontière de strate.
//
// The X-macro guard: every field of FStrateGenerationParams MUST be listed in
// VF_STRATE_PARAM_FIELDS, or it is silently reset to its default on every strate-boundary blend.
//
// POURQUOI CE TEST EXISTE / WHY THIS TEST EXISTS
// ---------------------------------------------
// `FStrateGenerationParams::Lerp` ne fusionne PAS la structure : il fusionne la LISTE écrite à la
// main dans `VF_STRATE_PARAM_FIELDS`. Ajouter un champ à la structure sans l'ajouter à la liste
// compile parfaitement, se teste vert partout, et produit un bug qui ne ressemble pas à un bug :
// le champ prend sa valeur par défaut dans toute bande de transition Gradient/Interleaved. Le
// terrain change de forme à la frontière et nulle part ailleurs. CODEMAP §3.8 avertit du piège en
// prose depuis toujours ; la prose ne casse pas la CI.
//
// `Lerp` does not blend the STRUCT — it blends the hand-written list in `VF_STRATE_PARAM_FIELDS`.
// Adding a field to the struct without adding it to the list compiles fine, tests green, and
// produces a bug that does not look like one: the field silently takes its default inside every
// Gradient/Interleaved transition band. CODEMAP §3.8 has warned about this in prose forever; prose
// does not fail CI.
//
// L'ENJEU AUGMENTE / THE STAKES ARE RISING
// Le composeur de mondes (voir COMPOSER-NOTES.md) prévoit d'INVENTER des jeux de paramètres en
// mélangeant des vecteurs connus-bons via ce même `Lerp`. Un champ manquant ne casserait alors plus
// seulement les frontières : il serait invariant dans TOUTES les strates inventées, et l'auteur
// n'aurait aucun moyen de le remarquer. Ce test transforme l'avertissement en échec.
//
// The world composer (see COMPOSER-NOTES.md) intends to INVENT parameter sets by blending
// known-good vectors through this same `Lerp`. A missing field would then not merely break
// boundaries — it would be constant across EVERY invented strate, with nothing to notice it by.
// This test turns the warning into a failure.
//
// COMMENT / HOW
// La X-macro est développée une TROISIÈME fois (après LERP et SNAP) : en liste de noms. On compare
// cette liste à la réflexion UObject de la structure. Aucun monde n'est construit — c'est un test
// de forme, pas de comportement, donc il est instantané et ne dépend d'aucune fixture.
//
// The X-macro is expanded a THIRD way (after LERP and SNAP): into a list of names. That list is
// compared against the struct's UObject reflection. No world is built — this is a shape test, not a
// behaviour test, so it is instant and depends on no fixture.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "UObject/Class.h"
#include "UObject/UnrealType.h"
#include "VoxelStrateTypes.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeStrateParamCoverageTest,
"VoxelForge.Determinism.StrateParamBlendCoverage",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
/** Troisième expansion de la X-macro : les noms qu'elle couvre.
* Third expansion of the X-macro: the names it covers. */
#define VF_PARAM_NAME(Name) TEXT(#Name),
const TCHAR* const GBlendedFieldNames[] =
{
VF_STRATE_PARAM_FIELDS(VF_PARAM_NAME, VF_PARAM_NAME)
};
#undef VF_PARAM_NAME
/**
* Champs délibérément NON fusionnés. Le champ de compatibilité bTunnelFloorEnabled reste
* réfléchi pour charger les anciennes données, mais le plancher est désormais toujours
* possédé par la géométrie du tunnel et cette valeur est ignorée.
* Pour en exempter un, l'ajouter ICI avec la raison — l'exemption doit être un choix visible,
* pas un oubli.
*
* Deliberately UN-blended fields. bTunnelFloorEnabled remains reflected so old assets load,
* but tunnel geometry now always owns the floor and the value is ignored. To exempt one, add
* it HERE with the reason — an exemption must be a visible decision, never an omission.
*/
const TCHAR* const GExemptFieldNames[] =
{
TEXT("bTunnelFloorEnabled"), // deprecated compatibility field; tunnel geometry owns the floor
nullptr // sentinelle : garde le tableau non vide / sentinel: keeps the array non-empty
};
}
bool FVoxelForgeStrateParamCoverageTest::RunTest(const FString& Parameters)
{
const UScriptStruct* Struct = FStrateGenerationParams::StaticStruct();
if (!Struct)
{
AddError(TEXT("FStrateGenerationParams::StaticStruct() returned null — reflection is unavailable."));
return false;
}
TSet<FString> Covered;
for (const TCHAR* const Name : GBlendedFieldNames)
{
if (Name)
{
Covered.Add(FString(Name));
}
}
TSet<FString> Exempt;
for (const TCHAR* const Name : GExemptFieldNames)
{
if (Name)
{
Exempt.Add(FString(Name));
}
}
int32 NumChecked = 0;
TArray<FString> Missing;
for (TFieldIterator<FProperty> It(Struct); It; ++It)
{
const FProperty* Prop = *It;
if (!Prop)
{
continue;
}
const FString PropName = Prop->GetName();
++NumChecked;
if (Covered.Contains(PropName) || Exempt.Contains(PropName))
{
continue;
}
// Le type C++ est dans le message : si un jour un champ non scalaire apparaît, le
// correctif ("fusionner comment ?") doit être évident depuis le seul rapport de test.
// The C++ type is in the message: if a non-scalar field ever appears, the fix ("blend it
// how?") must be obvious from the test report alone.
Missing.Add(FString::Printf(TEXT("%s (%s)"), *PropName, *Prop->GetCPPType()));
}
if (Missing.Num() > 0)
{
AddError(FString::Printf(
TEXT("%d field(s) of FStrateGenerationParams are absent from VF_STRATE_PARAM_FIELDS, so ")
TEXT("FStrateGenerationParams::Lerp silently resets them to default in every transition ")
TEXT("band (and in every strate the world composer invents): %s. ")
TEXT("Fix: add each to VF_STRATE_PARAM_FIELDS (LERPF for continuous, SNAPF for discrete), ")
TEXT("or to GExemptFieldNames in this test with a written reason."),
Missing.Num(),
*FString::Join(Missing, TEXT(", "))));
return false;
}
// Un test qui ne regarde rien passe aussi. On vérifie donc qu'il a bien regardé.
// A test that inspects nothing also passes. So check that it actually inspected something.
if (NumChecked == 0)
{
AddError(TEXT("Walked FStrateGenerationParams and found no reflected properties at all — ")
TEXT("the test is not testing anything. Check the struct is still USTRUCT()."));
return false;
}
AddInfo(FString::Printf(
TEXT("%d reflected field(s) checked, %d covered by VF_STRATE_PARAM_FIELDS, %d exempt."),
NumChecked, Covered.Num(), Exempt.Num()));
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,280 @@
// VoxelForgeTestFixture.h
// Fixture partagée par les tests d'automatisation VoxelForge (Phase 0.5 de OPSTACK-PLAN.md).
// Shared fixture for the VoxelForge automation tests (OPSTACK-PLAN.md, Phase 0.5).
//
// WHY THIS EXISTS
// ---------------
// The interesting invariants (density purity across worker threads, ClassifyTile soundness)
// only fire on the REAL path — UVoxelGenerator::GetDensityAt — because that is where the
// thread_local per-chunk caches live (CP_*, GSurfColCache, the diff slots, the SDF cache).
// Calling GetSurfaceDensity / GetMazeDensity directly bypasses every one of them and would
// test almost nothing. GetDensityAt in turn needs a live UVoxelStrateManager, whose only
// entry point is Initialize(UVoxelSettings*, int32) reading TSoftObjectPtr pools.
//
// So the fixture builds a whole synthetic world in memory: transient strate definitions →
// a transient UVoxelSettings pointing at them → a real UVoxelStrateManager::Initialize.
//
// ⚠️ KNOWN RISK, stated rather than hidden: the settings hold TSoftObjectPtr, and we point
// them at TRANSIENT objects (/Engine/Transient.<name>). LoadSynchronous() resolves those via
// FindObject, which works for in-memory objects — but it is the one part of this fixture that
// has never been compiled or run. IsValid() below checks the layout actually materialised, and
// every test hard-FAILS with a clear message when it didn't. A silent skip would be worse than
// a failure: it would look like a pass.
//
// Everything is held by TStrongObjectPtr so the GC cannot eat the world mid-test.
#pragma once
#if WITH_DEV_AUTOMATION_TESTS
#include "CoreMinimal.h"
#include "UObject/StrongObjectPtr.h"
#include "UObject/Package.h"
#include "VoxelTypes.h"
#include "VoxelSettings.h"
#include "VoxelStrateTypes.h"
#include "VoxelStrateDefinition.h"
#include "VoxelStrateManager.h"
#include "VoxelDiffLayer.h"
#include "VoxelGenerator.h"
namespace VoxelForgeTest
{
/**
* FTestWorld — a complete, headless VoxelForge world: settings + strate layout +
* generator + diff layer. No AActor, no UWorld, no PIE.
*
* The default layout stacks one strate of EVERY archetype (in ECaveGeneratorType order),
* so a single fixture exercises all eight density functions and their per-chunk caches,
* plus the gap-bedrock path when InterStrateGapChunks > 0.
*/
struct FTestWorld
{
TStrongObjectPtr<UVoxelSettings> Settings;
TStrongObjectPtr<UVoxelStrateManager> StrateManager;
TStrongObjectPtr<UVoxelDiffLayer> DiffLayer;
TStrongObjectPtr<UVoxelGenerator> Generator;
TArray<TStrongObjectPtr<UVoxelStrateDefinition>> Definitions;
/** World Z (voxel coords) span actually covered by the layout — handy for picking samples. */
int32 TopChunkZ = 0;
int32 BottomChunkZ = 0;
/**
* Build the world.
*
* The default seed stays SMALL, but the reason has changed. It USED to be a workaround:
* AUDIT §C1 (unbounded `SeedF`) meant a large seed collapsed the noise fields to constants,
* which would have made a purity test pass trivially for the wrong reason.
*
* **§C1 is fixed** (`VoxelHash::SeedOffset` — bounded and site-salted). The small default
* now just keeps failure messages comparable across tests. A large seed is no longer
* dangerous — and `VoxelForge.Determinism.LargeSeedSurvives` deliberately passes big ones
* (up to 2e9) to prove it stays that way.
*/
void Build(int32 InSeed = 1337, int32 InGapChunks = 2, bool bUseOperatorStack = false,
int32 InStrateHeightInChunks = 4)
{
Settings = TStrongObjectPtr<UVoxelSettings>(
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
Settings->Seed = InSeed;
Settings->InterStrateGapChunks = InGapChunks;
// WorldRadiusVoxels/EdgeSealThickness intentionally inherit the UVoxelSettings defaults;
// WorldEdgeSeal overrides them explicitly when it needs a small test world or radius 0.
// Une strate par archétype. PINNED via FixedStrates, pas via le pool : Initialize()
// mélange le pool avec le seed, ce qui rendrait la correspondance archétype → Z
// dépendante du seed et un message d'échec impossible à relire.
// One strate per archetype, PINNED through FixedStrates rather than the pool:
// Initialize() shuffles the pool by seed, which would make the archetype → Z mapping
// seed-dependent and a failure message unreadable. Slot i == Archetypes[i].
static const ECaveGeneratorType Archetypes[] = {
ECaveGeneratorType::TunnelNetwork,
ECaveGeneratorType::FlatPlain,
ECaveGeneratorType::CrystalChamber,
ECaveGeneratorType::Maze,
ECaveGeneratorType::SurfaceWorld,
ECaveGeneratorType::VerticalShafts,
ECaveGeneratorType::FloatingIslands,
ECaveGeneratorType::Underwater,
};
const int32 NumArchetypes = (int32)UE_ARRAY_COUNT(Archetypes);
for (int32 i = 0; i < NumArchetypes; ++i)
{
UVoxelStrateDefinition* Def = NewObject<UVoxelStrateDefinition>(
GetTransientPackage(), NAME_None, RF_Transient);
Def->GeneratorType = Archetypes[i];
// Keep the shared density fixture at the historical 4-chunk test volume unless
// a caller explicitly asks for the production/default 8-chunk envelope. The
// refinement and roll tests use the smaller synthetic volume as a bounded memory
// control; the owner-facing showcase opts into 8 chunks below.
Def->StrateHeightInChunks = FMath::Max(1, InStrateHeightInChunks);
// L'OPT-IN de la pile d'opérateurs. Faux par défaut : les treize tests existants
// doivent continuer à exercer le `switch`, qui reste le comportement de référence.
// Seul le test de solidité de ClassifyTie côté pile le passe à vrai.
Def->bUseOperatorStack = bUseOperatorStack;
// Hard transitions: param blending across a boundary would make "which archetype
// owns this chunk" ambiguous, and these tests want an unambiguous mapping.
Def->TransitionType = EVoxelStrateTransition::Hard;
Definitions.Add(TStrongObjectPtr<UVoxelStrateDefinition>(Def));
const TSoftObjectPtr<UVoxelStrateDefinition> SoftDef(Def);
Settings->FixedStrates.Add(i, SoftDef);
Settings->StratePool.Add(SoftDef); // fallback if a fixed entry fails to resolve
}
Settings->TotalStrates = NumArchetypes;
StrateManager = TStrongObjectPtr<UVoxelStrateManager>(
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
//=================================================================
// ⚠️ CHAQUE MONDE DE TEST OBTIENT UNE `LayoutVersion` UNIQUE DANS LE PROCESSUS
//=================================================================
// Ce n'est pas de la cosmétique, c'est une CONTAMINATION CROISÉE réelle entre tests, et
// elle n'était jusqu'ici masquée que par un accident.
//
// `PassagesVersion` est PAR INSTANCE et part de 0, donc deux `FTestWorld` successifs
// rendaient tous les deux **1**. Historiquement, les caches `CP_*` de `GetDensityAt`
// n'avaient que `(ChunkCoord, LayoutVersion)` et le second monde pouvait hériter les
// params — ET `CP_UseOpStack` — du premier. `DensityCacheOwnerId` ferme maintenant CE
// chemin prouvé. Les bumps restent ici comme isolation conservatrice des autres caches
// TLS que cette correction n'a volontairement pas audités ni modifiés.
//
// Un compteur de processus donne à chaque monde une version distincte, donc tout cache
// survivant d'un test à l'autre est forcément invalidé. `Initialize` est déterministe
// (le pool est mélangé par le seed, les fixed strates sont épinglées), donc le rappeler
// ne change pas le layout — seulement le compteur.
//
// Each test world still gets a process-unique LayoutVersion. DensityCacheOwnerId now
// prevents the proved CP_* cross-world reuse directly; the version bumps remain as
// conservative isolation for other TLS caches not audited or changed by that fix.
static int32 GWorldSerial = 0;
const int32 Bumps = ++GWorldSerial;
for (int32 b = 0; b < Bumps; ++b)
{
StrateManager->Initialize(Settings.Get(), Settings->Seed);
}
DiffLayer = TStrongObjectPtr<UVoxelDiffLayer>(
NewObject<UVoxelDiffLayer>(GetTransientPackage(), NAME_None, RF_Transient));
Generator = TStrongObjectPtr<UVoxelGenerator>(
NewObject<UVoxelGenerator>(GetTransientPackage(), NAME_None, RF_Transient));
Generator->InitializeSettings(Settings.Get());
Generator->SetStrateManager(StrateManager.Get());
Generator->SetDiffLayer(DiffLayer.Get());
CacheZBounds();
}
/** Re-run Initialize (bumps LayoutVersion) — the live-edit path AUDIT C2 is about. */
void Reinitialize()
{
StrateManager->Initialize(Settings.Get(), Settings->Seed);
CacheZBounds();
}
/** False when the soft-pointer resolve failed and no strate layout exists. */
bool IsValid() const
{
return StrateManager.IsValid() && StrateManager->GetNumStrates() > 0;
}
FString WhyInvalid() const
{
return TEXT("FTestWorld could not build a strate layout. Most likely the ")
TEXT("TSoftObjectPtr -> transient UVoxelStrateDefinition resolve failed inside ")
TEXT("UVoxelStrateManager::Initialize (LoadSynchronous on /Engine/Transient.*). ")
TEXT("See the header comment in VoxelForgeTestFixture.h. This is a FIXTURE ")
TEXT("failure, not a generator failure — do not read it as a density bug.");
}
/** Voxel-Z of the middle of the layout — a point guaranteed inside a real strate. */
float MidVoxelZ() const
{
return (float)((TopChunkZ + BottomChunkZ) / 2 * CHUNK_SIZE + CHUNK_SIZE / 2);
}
/** Layout slot index of each archetype — the Archetypes[] order in Build(), pinned via
* FixedStrates so it is stable across seeds. SurfaceWorld matters most: it is the only
* archetype ClassifyTile can currently prove anything about (besides bedrock gaps). */
static constexpr int32 SlotTunnelNetwork = 0;
static constexpr int32 SlotFlatPlain = 1;
static constexpr int32 SlotCrystalChamber = 2;
static constexpr int32 SlotMaze = 3;
static constexpr int32 SlotSurfaceWorld = 4;
static constexpr int32 SlotVerticalShafts = 5;
static constexpr int32 SlotFloatingIsland = 6;
static constexpr int32 SlotUnderwater = 7;
/** Voxel-Z span of one layout slot. False if the layout is shorter than expected. */
bool GetSlotVoxelZRange(int32 SlotIndex, int32& OutTopVoxelZ, int32& OutBottomVoxelZ) const
{
const TArray<FStrateSlot>& Layout = StrateManager->GetLayout();
if (!Layout.IsValidIndex(SlotIndex)) { return false; }
OutTopVoxelZ = Layout[SlotIndex].TopChunkZ * CHUNK_SIZE + CHUNK_SIZE - 1;
OutBottomVoxelZ = Layout[SlotIndex].BottomChunkZ * CHUNK_SIZE;
return true;
}
private:
void CacheZBounds()
{
TopChunkZ = 0;
BottomChunkZ = 0;
for (const FStrateSlot& Slot : StrateManager->GetLayout())
{
TopChunkZ = FMath::Max(TopChunkZ, Slot.TopChunkZ);
BottomChunkZ = FMath::Min(BottomChunkZ, Slot.BottomChunkZ);
}
}
};
/**
* A spread of world sample points that deliberately crosses chunk boundaries, strate
* boundaries and bedrock gaps — the exact conditions under which a per-chunk cache with a
* missing key input produces a wrong answer. Integer XY on purpose: that is the branch
* GetDensityAt's T1.a column cache actually takes (fractional XY bypasses the cache).
*/
inline void BuildSamplePoints(const FTestWorld& World, int32 Count, int32 Seed,
TArray<FVector>& OutPoints)
{
OutPoints.Reset(Count);
FRandomStream Rng(Seed);
const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE + CHUNK_SIZE - 1;
const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE;
for (int32 i = 0; i < Count; ++i)
{
// XY range spans several chunks either side of the origin so the (0,0) spine, the
// passages and plain interior rock all appear in the sample set.
const int32 X = Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE);
const int32 Y = Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE);
const int32 Z = Rng.RandRange(BottomVoxelZ, TopVoxelZ);
OutPoints.Add(FVector((float)X, (float)Y, (float)Z));
}
}
/** Deterministic shuffle of an index array — the "different query order" half of purity. */
inline void BuildShuffledOrder(int32 Count, int32 Seed, TArray<int32>& OutOrder)
{
OutOrder.Reset(Count);
for (int32 i = 0; i < Count; ++i) { OutOrder.Add(i); }
FRandomStream Rng(Seed);
for (int32 i = Count - 1; i > 0; --i)
{
OutOrder.Swap(i, Rng.RandRange(0, i));
}
}
/** Bit-exact float compare — NOT FMath::IsNearlyEqual. Window invariance is a bit property
* (ARCHITECTURE §8.4): a 1-ULP difference between two chunk windows is a visible seam. */
inline bool BitEqual(float A, float B)
{
return FMath::IsNaN(A) == FMath::IsNaN(B)
&& (FMath::IsNaN(A) || *reinterpret_cast<const uint32*>(&A) == *reinterpret_cast<const uint32*>(&B));
}
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,226 @@
// The tile-reach proof must fail loudly when its envelope is deliberately made too small.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "HAL/IConsoleManager.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelMarchingCubesMesher.h"
#include "../../Public/VoxelTilePostReach.h"
namespace
{
using namespace VoxelForgeTest;
struct FScopedReachTestCVars
{
IConsoleVariable* Debug = nullptr;
IConsoleVariable* Scale = nullptr;
int32 OldDebug = 0;
float OldScale = 1.0f;
bool bValid = false;
FScopedReachTestCVars()
{
IConsoleManager& Console = IConsoleManager::Get();
Debug = Console.FindConsoleVariable(TEXT("voxel.TilePostReachDebug"));
Scale = Console.FindConsoleVariable(TEXT("voxel.TilePostReachScale"));
if (Debug == nullptr || Scale == nullptr)
{
return;
}
OldDebug = Debug->GetInt();
OldScale = Scale->GetFloat();
bValid = true;
Debug->Set(1, ECVF_SetByCode);
}
~FScopedReachTestCVars()
{
if (!bValid)
{
return;
}
Debug->Set(OldDebug, ECVF_SetByCode);
Scale->Set(OldScale, ECVF_SetByCode);
}
void SetScale(float InScale)
{
if (Scale != nullptr)
{
Scale->Set(InScale, ECVF_SetByCode);
}
}
};
struct FReachSnapshot
{
uint64 Tiles = 0;
uint64 FinalComparisons = 0;
uint64 FinalDifferences = 0;
uint64 AllComparisons = 0;
uint64 AllDifferences = 0;
int64 DensitySamples = 0;
int32 MeshedTiles = 0;
};
FReachSnapshot RunReachTiles(
UVoxelMarchingCubesMesher& Mesher,
const TArray<FIntVector>& Origins)
{
FReachSnapshot Out;
const uint64 TileCountBefore =
VoxelGenLOD::GTilePostReachTileCount.load(std::memory_order_relaxed);
for (const FIntVector& Origin : Origins)
{
int64 Samples = 0;
Mesher.GenerateMesh(
Origin, 8, 16, nullptr, INT32_MIN, INT32_MAX, &Samples);
Out.DensitySamples += Samples;
Out.MeshedTiles += Samples > 0 ? 1 : 0;
}
const uint64 TileCountAfter =
VoxelGenLOD::GTilePostReachTileCount.load(std::memory_order_relaxed);
Out.Tiles = TileCountAfter - TileCountBefore;
Out.FinalComparisons = VoxelGenLOD::GSkippedPostComparisonsByKind[
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::FinalField)]
.load(std::memory_order_relaxed);
Out.FinalDifferences = VoxelGenLOD::GSkippedPostDifferencesByKind[
static_cast<uint8>(VoxelGenLOD::ETilePostComparisonKind::FinalField)]
.load(std::memory_order_relaxed);
Out.AllComparisons = VoxelGenLOD::GSkippedPostComparisons.load(
std::memory_order_relaxed);
Out.AllDifferences = VoxelGenLOD::GSkippedPostDifferences.load(
std::memory_order_relaxed);
return Out;
}
int32 FloorAlign(int32 Value, int32 Alignment)
{
return FMath::FloorToInt(static_cast<float>(Value) / static_cast<float>(Alignment))
* Alignment;
}
void BuildReachTileOrigins(
const FTestWorld& World,
int32 Slot,
TArray<FIntVector>& OutOrigins)
{
int32 SlotTop = 0;
int32 SlotBottom = 0;
if (!World.GetSlotVoxelZRange(Slot, SlotTop, SlotBottom))
{
return;
}
const int32 TileExtent = 16 * 8;
const int32 TileZ = FloorAlign(SlotBottom, TileExtent);
// Nine XY tiles around the origin cover the guaranteed spine, winding tunnel mouths,
// and feature-free graph regions. Include one distant tile to prove the skip side is
// exercised rather than relying only on near-tunnel tiles.
for (int32 Y = -TileExtent; Y <= TileExtent; Y += TileExtent)
{
for (int32 X = -TileExtent; X <= TileExtent; X += TileExtent)
{
OutOrigins.Add(FIntVector(X, Y, TileZ));
}
}
OutOrigins.Add(FIntVector(4 * TileExtent, -3 * TileExtent, TileZ));
}
void AppendWorldReachTiles(
const FTestWorld& World,
TArray<FIntVector>& OutOrigins)
{
BuildReachTileOrigins(World, FTestWorld::SlotTunnelNetwork, OutOrigins);
BuildReachTileOrigins(World, FTestWorld::SlotUnderwater, OutOrigins);
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeTilePostReachProofTest,
"VoxelForge.Correctness.TilePostReachProof",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeTilePostReachProofTest::RunTest(const FString& Parameters)
{
FScopedReachTestCVars CVarGuard;
if (!CVarGuard.bValid)
{
AddError(TEXT("Tile-post reach test could not find its two console variables."));
return false;
}
FTestWorld NativeWorld;
NativeWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false);
FTestWorld FusedWorld;
FusedWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
if (!NativeWorld.IsValid() || !FusedWorld.IsValid())
{
AddError(NativeWorld.IsValid() && FusedWorld.IsValid()
? TEXT("Unexpected invalid reach-proof fixture")
: (!NativeWorld.IsValid() ? NativeWorld.WhyInvalid() : FusedWorld.WhyInvalid()));
return false;
}
TArray<FIntVector> NativeOrigins;
TArray<FIntVector> FusedOrigins;
AppendWorldReachTiles(NativeWorld, NativeOrigins);
AppendWorldReachTiles(FusedWorld, FusedOrigins);
TestTrue(TEXT("the representative native tile set is non-empty"), NativeOrigins.Num() > 0);
TestTrue(TEXT("the representative fused tile set is non-empty"), FusedOrigins.Num() > 0);
TStrongObjectPtr<UVoxelMarchingCubesMesher> NativeMesher(
NewObject<UVoxelMarchingCubesMesher>(GetTransientPackage(), NAME_None, RF_Transient));
TStrongObjectPtr<UVoxelMarchingCubesMesher> FusedMesher(
NewObject<UVoxelMarchingCubesMesher>(GetTransientPackage(), NAME_None, RF_Transient));
NativeMesher->SetGenerator(NativeWorld.Generator.Get());
FusedMesher->SetGenerator(FusedWorld.Generator.Get());
VoxelGenLOD::ResetTilePostReachDiagnostics();
CVarGuard.SetScale(1.0f);
const FReachSnapshot NativeFull = RunReachTiles(*NativeMesher, NativeOrigins);
const FReachSnapshot FusedFull = RunReachTiles(*FusedMesher, FusedOrigins);
const uint64 FullFinalComparisons = NativeFull.FinalComparisons + FusedFull.FinalComparisons;
const uint64 FullFinalDifferences = NativeFull.FinalDifferences + FusedFull.FinalDifferences;
TestTrue(TEXT("normal reach compares final fields on the representative tiles"),
FullFinalComparisons > 0);
TestEqual(TEXT("normal reach has no final-field differences"), FullFinalDifferences, 0ull);
TestEqual(TEXT("normal reach has no skipped-post differences"),
NativeFull.AllDifferences + FusedFull.AllDifferences, 0ull);
VoxelGenLOD::ResetTilePostReachDiagnostics();
// Use a deliberately tiny but valid scale so at least one envelope is rejected while the same
// final-field samples remain exercised; this is the negative control, not a supported setting.
CVarGuard.SetScale(0.01f);
const FReachSnapshot NativeShrunk = RunReachTiles(*NativeMesher, NativeOrigins);
const FReachSnapshot FusedShrunk = RunReachTiles(*FusedMesher, FusedOrigins);
const uint64 ShrunkFinalComparisons =
NativeShrunk.FinalComparisons + FusedShrunk.FinalComparisons;
const uint64 ShrunkFinalDifferences =
NativeShrunk.FinalDifferences + FusedShrunk.FinalDifferences;
TestTrue(TEXT("shrunk reach still executes final-field comparisons"),
ShrunkFinalComparisons > 0);
TestTrue(TEXT("shrunk reach demonstrably fails the final-field proof"),
ShrunkFinalDifferences > 0);
AddInfo(FString::Printf(
TEXT("tile_post_reach_proof: native_tiles=%llu fused_tiles=%llu full_final=%llu/%llu "
"shrunk_final=%llu/%llu native_samples=%lld/%lld fused_samples=%lld/%lld."),
static_cast<unsigned long long>(NativeFull.Tiles),
static_cast<unsigned long long>(FusedFull.Tiles),
static_cast<unsigned long long>(FullFinalComparisons),
static_cast<unsigned long long>(FullFinalDifferences),
static_cast<unsigned long long>(ShrunkFinalComparisons),
static_cast<unsigned long long>(ShrunkFinalDifferences),
static_cast<long long>(NativeFull.DensitySamples),
static_cast<long long>(NativeShrunk.DensitySamples),
static_cast<long long>(FusedFull.DensitySamples),
static_cast<long long>(FusedShrunk.DensitySamples)));
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,89 @@
// Surface-strate streaming must keep the ground visible when the pawn is high in the same strate.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelClipmapDesiredTiles.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeVerticalStrateReachTest,
"VoxelForge.Streaming.VerticalStrateReach",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeVerticalStrateReachTest::RunTest(const FString& Parameters)
{
constexpr int32 StrateBottomZ = -63;
constexpr int32 StrateTopZ = 0;
constexpr int32 PlayerChunkZ = 0; // high above this surface strate's ground
int32 MinChunkZ = MIN_int32;
int32 MaxChunkZ = MAX_int32;
VoxelClipmapDesiredTiles::ResolveVerticalBounds(
PlayerChunkZ, 5, 5, 3, true, true,
StrateTopZ, StrateBottomZ, MinChunkZ, MaxChunkZ);
TestEqual(TEXT("vertical reach follows the full surface strate plus margin"), MinChunkZ, -66);
TestEqual(TEXT("open-strate reach ends at the surface cap margin"), MaxChunkZ, 3);
VoxelClipmapDesiredTiles::FParameters Clipmap;
Clipmap.ClipRadius = 3;
Clipmap.MaxClipLevel = 3;
Clipmap.RenderDistanceChunks = 768;
Clipmap.bFarSheetRing = true;
Clipmap.FarSheetSpanLevels = 2;
TArray<FVoxelTileKey> Desired;
VoxelClipmapDesiredTiles::Build(
FIntVector(0, 0, PlayerChunkZ), MinChunkZ, MaxChunkZ, Clipmap, Desired);
TArray<int32> CountsByLevel;
CountsByLevel.Init(0, 6);
for (const FVoxelTileKey& Tile : Desired)
{
if (CountsByLevel.IsValidIndex(Tile.Level))
{
++CountsByLevel[Tile.Level];
}
}
const int32 ExpectedCounts[] = {343, 218, 178, 178, 178, 9586};
for (int32 Level = 0; Level < static_cast<int32>(UE_ARRAY_COUNT(ExpectedCounts)); ++Level)
{
TestEqual(FString::Printf(TEXT("desired tile count at level %d"), Level),
CountsByLevel[Level], ExpectedCounts[Level]);
}
const auto HasTile = [&Desired](int32 Level, const FIntVector& Coord)
{
return Desired.ContainsByPredicate([Level, &Coord](const FVoxelTileKey& Tile)
{
return Tile.Level == Level && Tile.Coord == Coord;
});
};
TestTrue(TEXT("level 0 tile around the high pawn is present"),
HasTile(0, FIntVector(0, 0, 0)));
TestTrue(TEXT("level 4 bridge tile covering the ground below the pawn is present"),
HasTile(4, FIntVector(0, 0, -3)));
TestTrue(TEXT("adjacent level 4 ground tile is present"),
HasTile(4, FIntVector(1, 0, -3)));
TestTrue(TEXT("outer level 5 sheet reaches the 768-chunk horizon"),
HasTile(5, FIntVector(24, 0, 0)));
TestFalse(TEXT("clamping does not request a full hidden LOD0 column"),
HasTile(0, FIntVector(0, 0, -47)));
for (int32 Level = 0; Level <= 5; ++Level)
{
TestTrue(FString::Printf(TEXT("clip level %d is non-empty"), Level),
CountsByLevel[Level] > 0);
}
AddInfo(FString::Printf(
TEXT("center_z=%d strate=[%d..%d] view=[%d..%d] levels=[%d,%d,%d,%d,%d,%d] total=%d"),
PlayerChunkZ, StrateBottomZ, StrateTopZ, MinChunkZ, MaxChunkZ,
CountsByLevel[0], CountsByLevel[1], CountsByLevel[2], CountsByLevel[3],
CountsByLevel[4], CountsByLevel[5], Desired.Num()));
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,339 @@
// VoxelForgeWorldEdgeSealTest.cpp
// Falsifiable coverage for the bounded-world XY edge seal.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include "VoxelDensityPrimitives.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeWorldEdgeSealTest,
"VoxelForge.Generation.WorldEdgeSeal",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr float TestWorldRadius = 256.0f;
constexpr float TestEdgeThickness = 64.0f;
constexpr float RimJumpThreshold = 1.0f;
void SetEdgeSettings(VoxelForgeTest::FTestWorld& World, float Radius)
{
World.Settings->WorldRadiusVoxels = Radius;
World.Settings->EdgeSealThickness = TestEdgeThickness;
World.Generator->InitializeSettings(World.Settings.Get());
}
void ConfigureNearRimPassages(VoxelForgeTest::FTestWorld& World)
{
for (TStrongObjectPtr<UVoxelStrateDefinition>& DefPtr : World.Definitions)
{
if (!DefPtr.IsValid()) continue;
FStratePassageConfig& Cfg = DefPtr->PassageConfig;
Cfg.Connections = 1;
Cfg.Style = EVoxelPassageStyle::Straight;
Cfg.MouthRadius = 6.0f;
Cfg.MidRadius = 6.0f;
Cfg.ReachMin = 40.0f;
Cfg.ReachMax = 40.0f;
Cfg.DistanceMin = 240.0f;
Cfg.DistanceMax = 240.0f;
Cfg.Wander = 0.0f;
Cfg.Segments = 4;
Cfg.VerticalWobble = 0.0f;
}
World.Reinitialize();
}
bool IsSolidMC(const UVoxelGenerator& Generator, const FVector& Point)
{
return Generator.GetDensityAt(Point.X, Point.Y, Point.Z) < 0.0f;
}
}
bool FVoxelForgeWorldEdgeSealTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
int32 NumFailures = 0;
FString FailureKinds;
auto Fail = [&](const TCHAR* Kind)
{
++NumFailures;
if (!FailureKinds.IsEmpty()) FailureKinds += TEXT(", ");
FailureKinds += Kind;
};
// The ordinary fixture deliberately uses the requested default, so existing op-stack and
// legacy tests exercise the setting without having to know about the new post.
FTestWorld Legacy;
Legacy.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false);
FTestWorld StackWorld;
StackWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
FTestWorld Unbounded;
Unbounded.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false);
if (!Legacy.IsValid() || !StackWorld.IsValid() || !Unbounded.IsValid())
{
AddError(Legacy.IsValid() && StackWorld.IsValid() && Unbounded.IsValid()
? TEXT("Unexpected edge-seal fixture state")
: (!Legacy.IsValid() ? Legacy.WhyInvalid()
: (!StackWorld.IsValid() ? StackWorld.WhyInvalid() : Unbounded.WhyInvalid())));
return false;
}
SetEdgeSettings(Legacy, TestWorldRadius);
SetEdgeSettings(StackWorld, TestWorldRadius);
SetEdgeSettings(Unbounded, 0.0f);
//==========================================================================
// POSITIVE CONTROL — far outside the radius, across several Z strata.
//==========================================================================
const FVector FarXY[] = {
FVector(384.0f, 0.0f, 0.0f),
FVector(-384.0f, 0.0f, 0.0f),
FVector(0.0f, 384.0f, 0.0f),
FVector(0.0f, -384.0f, 0.0f),
FVector(320.0f, 320.0f, 0.0f),
};
TArray<float> StrateZ;
for (int32 Slot = 0; Slot < 8; ++Slot)
{
int32 TopZ = 0, BottomZ = 0;
if (Legacy.GetSlotVoxelZRange(Slot, TopZ, BottomZ))
{
StrateZ.Add((float)((TopZ + BottomZ) / 2));
}
}
StrateZ.Add(Legacy.MidVoxelZ());
int32 NumPositiveSamples = 0;
for (const FVector& XY : FarXY)
{
for (const float Z : StrateZ)
{
const FVector P(XY.X, XY.Y, Z);
++NumPositiveSamples;
if (!IsSolidMC(*Legacy.Generator, P)) Fail(TEXT("positive-legacy"));
if (!IsSolidMC(*StackWorld.Generator, P)) Fail(TEXT("positive-opstack"));
}
}
//==========================================================================
// NEGATIVE CONTROL — the interior must be bit-identical to radius == 0.
//==========================================================================
const FVector InteriorXY[] = {
FVector(0.0f, 0.0f, 0.0f),
FVector(64.0f, -80.0f, 0.0f),
FVector(128.0f, 0.0f, 0.0f),
FVector(0.0f, -128.0f, 0.0f),
FVector(100.0f, 100.0f, 0.0f),
};
int32 NumInteriorComparisons = 0;
for (const FVector& XY : InteriorXY)
{
for (const float Z : StrateZ)
{
const FVector P(XY.X, XY.Y, Z);
const float BoundedD = Legacy.Generator->GetDensityAt(P.X, P.Y, P.Z);
const float UnboundedD = Unbounded.Generator->GetDensityAt(P.X, P.Y, P.Z);
++NumInteriorComparisons;
if (!BitEqual(BoundedD, UnboundedD)) Fail(TEXT("interior-not-no-op"));
}
}
//==========================================================================
// RIM SMOOTHNESS — measure solidness (the negative MC density magnitude), not its sign.
//==========================================================================
float MaxAdjacentSolidnessJump = 0.0f;
int32 NumRimDirections = 0;
const FVector RimDirections[] = {
FVector(1.0f, 0.0f, 0.0f),
FVector(0.0f, 1.0f, 0.0f),
FVector(-1.0f, 0.0f, 0.0f),
FVector(0.0f, -1.0f, 0.0f),
FVector(0.70710678f, 0.70710678f, 0.0f),
};
for (const FVector& Dir : RimDirections)
{
++NumRimDirections;
float PreviousSolidness = 0.0f;
bool bHasPrevious = false;
for (int32 R = (int32)(TestWorldRadius - TestEdgeThickness - 4.0f);
R <= (int32)(TestWorldRadius + 4.0f); ++R)
{
float InternalDensity = 0.0f;
VF_ApplyXYEdgeSeal(InternalDensity, Dir.X * (float)R, Dir.Y * (float)R,
TestWorldRadius, TestEdgeThickness, 8.0f);
const float Solidness = InternalDensity;
if (bHasPrevious)
{
MaxAdjacentSolidnessJump = FMath::Max(
MaxAdjacentSolidnessJump, FMath::Abs(Solidness - PreviousSolidness));
if (Solidness + KINDA_SMALL_NUMBER < PreviousSolidness)
Fail(TEXT("rim-not-monotonic"));
}
PreviousSolidness = Solidness;
bHasPrevious = true;
}
}
if (MaxAdjacentSolidnessJump > RimJumpThreshold) Fail(TEXT("rim-discontinuity"));
// Explicitly verify the true no-op contract, including the MC-facing wrapper.
float NoOpDensity = 3.25f;
const float NoOpBefore = NoOpDensity;
VF_ApplyXYEdgeSealMC(NoOpDensity, 100000.0f, -100000.0f,
0.0f, TestEdgeThickness, 8.0f);
if (!BitEqual(NoOpDensity, NoOpBefore)) Fail(TEXT("radius-zero-not-no-op"));
//==========================================================================
// CLASSIFYBOX — force boxes in the ramp and beyond the radius, then brute-force every voxel.
//==========================================================================
int32 TopShaftZ = 0, BottomShaftZ = 0;
if (!StackWorld.GetSlotVoxelZRange(FTestWorld::SlotVerticalShafts, TopShaftZ, BottomShaftZ))
{
Fail(TEXT("missing-shaft-slot"));
}
int32 NumBoxesProved = 0;
int64 NumVoxelsChecked = 0;
if (TopShaftZ > BottomShaftZ)
{
const float BoxZ0 = (float)(BottomShaftZ + 8);
const float BoxZ1 = (float)FMath::Min(BottomShaftZ + 10, TopShaftZ - 8);
const FBox ProvedBoxes[] = {
// Ramp boxes: closest radius is 240, safely past the 1-voxel proof margin.
FBox(FVector(240.0f, -2.0f, BoxZ0), FVector(244.0f, 2.0f, BoxZ1)),
FBox(FVector(-244.0f, -2.0f, BoxZ0), FVector(-240.0f, 2.0f, BoxZ1)),
FBox(FVector(-2.0f, 240.0f, BoxZ0), FVector(2.0f, 244.0f, BoxZ1)),
FBox(FVector(170.0f, 170.0f, BoxZ0), FVector(174.0f, 174.0f, BoxZ1)),
// Forced-solid boxes beyond the world radius.
FBox(FVector(320.0f, -2.0f, BoxZ0), FVector(324.0f, 2.0f, BoxZ1)),
FBox(FVector(-324.0f, -2.0f, BoxZ0), FVector(-320.0f, 2.0f, BoxZ1)),
FBox(FVector(-2.0f, 320.0f, BoxZ0), FVector(2.0f, 324.0f, BoxZ1)),
FBox(FVector(226.0f, 226.0f, BoxZ0), FVector(230.0f, 230.0f, BoxZ1)),
};
const int32 MidChunkZ = FMath::FloorToInt((BoxZ0 + BoxZ1) * 0.5f / CHUNK_SIZE);
const FVerticalShaftParams ShaftParams = StackWorld.StrateManager->GetVerticalShaftParamsForChunk(
FIntVector(0, 0, MidChunkZ));
FVoxelOpStack Stack;
VoxelDensityOps::BuildVerticalShaftStack(
Stack, ShaftParams, StackWorld.Settings->Seed,
StackWorld.Generator->OriginSpineRadius, StackWorld.StrateManager.Get());
FVoxelOpContext Ctx;
Ctx.ChunkCoord = FIntVector(0, 0, MidChunkZ);
Ctx.Seed = (uint32)StackWorld.Settings->Seed;
Ctx.LayoutVersion = StackWorld.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = ShaftParams.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = ShaftParams.StrateBottomWorldZ;
Ctx.WorldRadiusVoxels = TestWorldRadius;
Ctx.EdgeSealThickness = TestEdgeThickness;
Stack.PrepareChunk(Ctx);
for (const FBox& Box : ProvedBoxes)
{
if (Stack.ClassifyBox(Box, Ctx) != EVoxelTileClass::AllSolid)
{
Fail(TEXT("classifybox-missed-forced-band"));
continue;
}
++NumBoxesProved;
const int32 X0 = FMath::CeilToInt(Box.Min.X);
const int32 X1 = FMath::FloorToInt(Box.Max.X);
const int32 Y0 = FMath::CeilToInt(Box.Min.Y);
const int32 Y1 = FMath::FloorToInt(Box.Max.Y);
const int32 Z0 = FMath::CeilToInt(Box.Min.Z);
const int32 Z1 = FMath::FloorToInt(Box.Max.Z);
for (int32 Z = Z0; Z <= Z1; ++Z)
for (int32 Y = Y0; Y <= Y1; ++Y)
for (int32 X = X0; X <= X1; ++X)
{
++NumVoxelsChecked;
if (!IsSolidMC(*StackWorld.Generator, FVector((float)X, (float)Y, (float)Z)))
Fail(TEXT("classifybox-false-allsolid"));
}
}
}
// The T1.d path must be able to skip an edge tile without knowing which Z category it hits.
const EVoxelTileClass EdgeTile = StackWorld.Generator->ClassifyTile(
FIntVector(320, 0, BottomShaftZ + 8), 1, 8);
int32 NumTilesProved = (EdgeTile == EVoxelTileClass::AllSolid) ? 1 : 0;
if (EdgeTile != EVoxelTileClass::AllSolid) Fail(TEXT("classifytile-missed-edge"));
//==========================================================================
// PASSAGE ORDER — a near-rim passage carve is followed by the edge force.
//==========================================================================
float NearRimInternalDensity = 8.0f;
VF_ApplyPassageCarving(NearRimInternalDensity, 0.0f, 8.0f, TestEdgeThickness);
const float PassageOnlyDensity = NearRimInternalDensity;
VF_ApplyXYEdgeSeal(NearRimInternalDensity, TestWorldRadius - 16.0f, 0.0f,
TestWorldRadius, TestEdgeThickness, 8.0f);
int32 NumNearRimPassageChecks = 1;
if (!(PassageOnlyDensity < 0.0f && NearRimInternalDensity > 0.0f))
Fail(TEXT("passage-breaches-rim"));
// Try the same construction through the real deterministic passage generator. It is diagnostic
// coverage only: the synthetic helper case above is the hard assertion even if landing-site
// adjustment moves every generated mouth out of this small test rim.
FTestWorld GeneratedPassageWorld;
GeneratedPassageWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false);
FTestWorld GeneratedPassageStackWorld;
GeneratedPassageStackWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
auto CheckGeneratedPassages = [&](FTestWorld& World, const TCHAR* FailureKind)
{
if (!World.IsValid())
{
Fail(FailureKind);
return;
}
ConfigureNearRimPassages(World);
SetEdgeSettings(World, TestWorldRadius);
for (const FVoxelPassage& Passage : World.StrateManager->GetPassages())
{
TArray<FVector> Candidates = Passage.ControlPoints;
Candidates.Add(Passage.UpperPoint);
Candidates.Add(Passage.LowerPoint);
for (const FVector& P : Candidates)
{
if (P.X * P.X + P.Y * P.Y <= FMath::Square(TestWorldRadius - TestEdgeThickness)) continue;
++NumNearRimPassageChecks;
if (!IsSolidMC(*World.Generator, P)) Fail(FailureKind);
break;
}
}
};
CheckGeneratedPassages(GeneratedPassageWorld, TEXT("generated-passage-breaches-rim"));
CheckGeneratedPassages(GeneratedPassageStackWorld, TEXT("generated-opstack-passage-breaches-rim"));
// One sorted summary block; individual samples intentionally never log.
AddInfo(FString::Printf(
TEXT("WORLD_EDGE_SEAL SUMMARY\n")
TEXT(" classify_box_proved=%d\n")
TEXT(" classify_tile_proved=%d\n")
TEXT(" interior_comparisons=%d\n")
TEXT(" near_rim_passage_checks=%d\n")
TEXT(" positive_sample_points=%d (checked in both paths)\n")
TEXT(" rim_directions=%d\n")
TEXT(" rim_max_adjacent_solidness_jump=%.6f (threshold=%.6f)\n")
TEXT(" voxels_checked=%lld\n")
TEXT(" failures=%d (%s)"),
NumBoxesProved, NumTilesProved, NumInteriorComparisons, NumNearRimPassageChecks,
NumPositiveSamples, NumRimDirections, MaxAdjacentSolidnessJump, RimJumpThreshold,
(long long)NumVoxelsChecked, NumFailures, *FailureKinds));
if (NumFailures > 0)
{
AddError(FString::Printf(TEXT("WorldEdgeSeal aggregate checks failed: %d (%s)"),
NumFailures, *FailureKinds));
}
return NumFailures == 0;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,350 @@
// Soundness and empirical coverage for the worker-local worm N1 block proof.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelCaveMorphology.h"
#include "VoxelNoise.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeWormBlockSkipTest,
"VoxelForge.Determinism.WormBlockSkipSoundness",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr float WormFrequency = 0.015f;
constexpr float WormHorizontalBias = 3.0f;
constexpr float WormThreshold = 0.06f;
constexpr float WormVerticalScale = 1.0f;
constexpr int32 TestCellsPerAxis = 32;
constexpr int32 TestTileCount = 192;
constexpr int32 TestRandomPairCount = 500000;
FORCEINLINE uint32 NextTestBits(uint32& State)
{
State += 0x9E3779B9u;
uint32 Z = State;
Z = (Z ^ (Z >> 16)) * 0x7FEB352Du;
Z = (Z ^ (Z >> 15)) * 0x846CA68Bu;
return Z ^ (Z >> 16);
}
FORCEINLINE float NextTestUnit(uint32& State)
{
return static_cast<float>(NextTestBits(State)) / 4294967296.0f;
}
FORCEINLINE float ScaledN1(const FVector3f& Position)
{
return FMath::Abs(VoxelNoise::Perlin3D(Position) * VOXEL_NOISE_SCALE);
}
FORCEINLINE FVector3f MakeWormNoisePosition(
int32 WorldX, int32 WorldY, int32 WorldZ, uint32 Seed)
{
float EffectiveZ = static_cast<float>(WorldZ);
if (WormVerticalScale != 1.0f && WormVerticalScale > 0.0f)
{
EffectiveZ /= WormVerticalScale;
}
const float WormZFreq = WormFrequency * WormHorizontalBias;
return FVector3f(
static_cast<float>(WorldX) * WormFrequency
+ VoxelHash::SeedOffset(Seed, 1.0f),
static_cast<float>(WorldY) * WormFrequency
+ VoxelHash::SeedOffset(Seed, 1.7f),
EffectiveZ * WormZFreq + VoxelHash::SeedOffset(Seed, 2.3f));
}
FORCEINLINE double FadePrime(double T)
{
return 30.0 * T * T * (1.0 - T) * (1.0 - T);
}
FORCEINLINE double GradientSupport(double X, double Y, double Z)
{
const double AX = FMath::Abs(X);
const double AY = FMath::Abs(Y);
const double AZ = FMath::Abs(Z);
return FMath::Max(FMath::Max(AX + AY, AX + AZ), AY + AZ);
}
// Supremum of the exact finite support relaxation used in the header derivation. This is
// intentionally independent of the hash: all eight corners may choose any member of the
// actual GradDot set, so this is the field-wide proof envelope rather than an average sample.
double SupportRelaxedXDerivative(double X, double Y, double Z)
{
const double D = FadePrime(X);
const double SY = Y * Y * Y * (Y * (Y * 6.0 - 15.0) + 10.0);
const double SZ = Z * Z * Z * (Z * (Z * 6.0 - 15.0) + 10.0);
double Result = 0.0;
for (int32 J = 0; J < 2; ++J)
{
for (int32 K = 0; K < 2; ++K)
{
const double Weight = (J != 0 ? SY : 1.0 - SY)
* (K != 0 ? SZ : 1.0 - SZ);
const double YLocal = Y - static_cast<double>(J);
const double ZLocal = Z - static_cast<double>(K);
Result += Weight * (
GradientSupport(1.0 - X - D * X, -D * YLocal, -D * ZLocal)
+ GradientSupport(X - D * (1.0 - X), D * YLocal, D * ZLocal));
}
}
return Result;
}
}
bool FVoxelForgeWormBlockSkipTest::RunTest(const FString& Parameters)
{
(void)Parameters;
const double FullL = VoxelNoise::WormN1NoiseSpaceLipschitzBound;
const double HalfL = FullL * 0.5;
TestTrue(TEXT("the production N1 Lipschitz bound is finite and positive"),
FMath::IsFinite(FullL) && FullL > 0.0);
TestTrue(TEXT("the actual 3D partial bound is 15/4"),
FMath::IsNearlyEqual(VoxelNoise::Perlin3DPartialAbsBound, 3.75, 1.0e-12));
TestTrue(TEXT("VOXEL_NOISE_SCALE is included in the N1 bound"),
FMath::IsNearlyEqual(
FullL,
static_cast<double>(VOXEL_NOISE_SCALE)
* VoxelNoise::Perlin3DPartialAbsBound
* static_cast<double>(1.732050807568877293527446341505872L),
1.0e-12));
// Check the finite support maximization densely, including corners and every cell face. The
// closed-form case split is recorded beside the production constant; this grid is a regression
// tripwire for changing GradDot/Fade/Lerp without updating that proof.
double MaxRelaxedSupport = 0.0;
FVector3f MaxRelaxedPoint = FVector3f::ZeroVector;
constexpr int32 SupportGrid = 128;
for (int32 IX = 0; IX <= SupportGrid; ++IX)
{
const double X = static_cast<double>(IX) / SupportGrid;
for (int32 IY = 0; IY <= SupportGrid; ++IY)
{
const double Y = static_cast<double>(IY) / SupportGrid;
for (int32 IZ = 0; IZ <= SupportGrid; ++IZ)
{
const double Z = static_cast<double>(IZ) / SupportGrid;
const double Value = SupportRelaxedXDerivative(X, Y, Z);
if (Value > MaxRelaxedSupport)
{
MaxRelaxedSupport = Value;
MaxRelaxedPoint = FVector3f(
static_cast<float>(X), static_cast<float>(Y), static_cast<float>(Z));
}
}
}
}
AddInfo(FString::Printf(
TEXT("3D support relaxation max=%.12f at (%.6f,%.6f,%.6f), bound=%.12f; "
"the exact case split gives 15/4 at (0.5,0.5,0.5)."),
MaxRelaxedSupport, MaxRelaxedPoint.X, MaxRelaxedPoint.Y, MaxRelaxedPoint.Z,
VoxelNoise::Perlin3DPartialAbsBound));
TestTrue(TEXT("the dense support relaxation stays below the proven partial bound"),
MaxRelaxedSupport <= VoxelNoise::Perlin3DPartialAbsBound + 1.0e-10);
TestTrue(TEXT("the support relaxation reaches the stated supremum"),
MaxRelaxedSupport >= VoxelNoise::Perlin3DPartialAbsBound - 1.0e-10);
// Empirical attack: samples straddle lattice faces/corners at several scales, then a large
// deterministic random-pair population probes ordinary cells and arbitrary directions.
double MaxEmpiricalRatio = 0.0;
FVector3f WorstA = FVector3f::ZeroVector;
FVector3f WorstB = FVector3f::ZeroVector;
uint64 PairCount = 0;
auto ObservePair = [&](const FVector3f& A, const FVector3f& B)
{
const double DX = static_cast<double>(A.X) - static_cast<double>(B.X);
const double DY = static_cast<double>(A.Y) - static_cast<double>(B.Y);
const double DZ = static_cast<double>(A.Z) - static_cast<double>(B.Z);
const double Distance = FMath::Sqrt(DX * DX + DY * DY + DZ * DZ);
if (!(Distance > 0.0))
{
return;
}
++PairCount;
const double Ratio = FMath::Abs(
static_cast<double>(ScaledN1(A)) - static_cast<double>(ScaledN1(B))) / Distance;
if (Ratio > MaxEmpiricalRatio)
{
MaxEmpiricalRatio = Ratio;
WorstA = A;
WorstB = B;
}
};
constexpr float FaceEpsilons[] = { 1.0e-5f, 1.0e-4f, 1.0e-3f, 1.0e-2f, 5.0e-2f };
constexpr float FaceCoordinates[] = {
1.0e-3f, 1.0e-2f, 0.1f, 0.25f, 0.5f, 0.75f, 0.9f, 0.99f, 0.999f };
for (int32 X = -12; X < 12; ++X)
{
for (int32 Y = -12; Y < 12; ++Y)
{
for (int32 Z = -12; Z < 12; ++Z)
{
for (const float Epsilon : FaceEpsilons)
{
for (const float V : FaceCoordinates)
{
for (const float W : FaceCoordinates)
{
ObservePair(
FVector3f(X + 1.0f - Epsilon, Y + V, Z + W),
FVector3f(X + 1.0f + Epsilon, Y + V, Z + W));
ObservePair(
FVector3f(X + V, Y + 1.0f - Epsilon, Z + W),
FVector3f(X + V, Y + 1.0f + Epsilon, Z + W));
ObservePair(
FVector3f(X + V, Y + W, Z + 1.0f - Epsilon),
FVector3f(X + V, Y + W, Z + 1.0f + Epsilon));
}
}
ObservePair(
FVector3f(X + 1.0f - Epsilon, Y + 1.0f - Epsilon, Z + 1.0f - Epsilon),
FVector3f(X + 1.0f + Epsilon, Y + 1.0f + Epsilon, Z + 1.0f + Epsilon));
}
}
}
}
uint32 RandomState = 0xA5B3571Du;
for (int32 Index = 0; Index < TestRandomPairCount; ++Index)
{
const FVector3f A(
NextTestUnit(RandomState) * 128.0f - 64.0f,
NextTestUnit(RandomState) * 128.0f - 64.0f,
NextTestUnit(RandomState) * 128.0f - 64.0f);
const FVector3f B(
A.X + NextTestUnit(RandomState) * 0.5f - 0.25f,
A.Y + NextTestUnit(RandomState) * 0.5f - 0.25f,
A.Z + NextTestUnit(RandomState) * 0.5f - 0.25f);
ObservePair(A, B);
}
AddInfo(FString::Printf(
TEXT("Empirical scaled-N1 Lipschitz attack: pairs=%llu worst=%.9f, worst/L=%.9f, "
"L=%.9f; worst A=(%.6f,%.6f,%.6f) B=(%.6f,%.6f,%.6f)."),
static_cast<unsigned long long>(PairCount), MaxEmpiricalRatio,
MaxEmpiricalRatio / FullL, FullL,
WorstA.X, WorstA.Y, WorstA.Z, WorstB.X, WorstB.Y, WorstB.Z));
TestTrue(TEXT("empirical near-face/corner/random ratios stay below L"),
MaxEmpiricalRatio <= FullL + 0.01);
// Brute-force the exact mesher lattice for a large deterministic set of tile contexts. Every
// block admitted by the production proof is checked at every lattice point it covers.
uint64 BlocksScanned = 0;
uint64 ProductionProofs = 0;
uint64 ProductionLatticeSamples = 0;
uint64 ProductionViolations = 0;
for (int32 TileIndex = 0; TileIndex < TestTileCount; ++TileIndex)
{
const FIntVector TileOrigin(
(TileIndex % 17 - 8) * 64,
((TileIndex / 17) % 13 - 6) * 64,
((TileIndex / 221) % 9 - 4) * 64);
const uint32 Seed = 1337u + static_cast<uint32>(TileIndex) * 0x9E3779B9u;
for (int32 BlockZ = 0; BlockZ < 9; ++BlockZ)
{
for (int32 BlockY = 0; BlockY < 9; ++BlockY)
{
for (int32 BlockX = 0; BlockX < 9; ++BlockX)
{
++BlocksScanned;
const int32 MinX = BlockX * VoxelNoise::WormBlockSampleSide - 1;
const int32 MinY = BlockY * VoxelNoise::WormBlockSampleSide - 1;
const int32 MinZ = BlockZ * VoxelNoise::WormBlockSampleSide - 1;
const int32 MaxX = FMath::Min(
TestCellsPerAxis + 1,
MinX + VoxelNoise::WormBlockSampleSide - 1);
const int32 MaxY = FMath::Min(
TestCellsPerAxis + 1,
MinY + VoxelNoise::WormBlockSampleSide - 1);
const int32 MaxZ = FMath::Min(
TestCellsPerAxis + 1,
MinZ + VoxelNoise::WormBlockSampleSide - 1);
const FVector3f Q0 = MakeWormNoisePosition(
TileOrigin.X + MinX, TileOrigin.Y + MinY, TileOrigin.Z + MinZ, Seed);
const FVector3f Q1 = MakeWormNoisePosition(
TileOrigin.X + MaxX, TileOrigin.Y + MaxY, TileOrigin.Z + MaxZ, Seed);
const FVector3f QMin(
FMath::Min(Q0.X, Q1.X), FMath::Min(Q0.Y, Q1.Y), FMath::Min(Q0.Z, Q1.Z));
const FVector3f QMax(
FMath::Max(Q0.X, Q1.X), FMath::Max(Q0.Y, Q1.Y), FMath::Max(Q0.Z, Q1.Z));
const FVector3f QCenter(
static_cast<float>((static_cast<double>(QMin.X) + QMax.X) * 0.5),
static_cast<float>((static_cast<double>(QMin.Y) + QMax.Y) * 0.5),
static_cast<float>((static_cast<double>(QMin.Z) + QMax.Z) * 0.5));
if (!VoxelNoise::ProvesScaledAbsPerlin3DAbove(
QCenter, QMin, QMax, WormThreshold, FullL))
{
continue;
}
++ProductionProofs;
for (int32 Z = MinZ; Z <= MaxZ; ++Z)
{
for (int32 Y = MinY; Y <= MaxY; ++Y)
{
for (int32 X = MinX; X <= MaxX; ++X)
{
++ProductionLatticeSamples;
const float ExactN1 = ScaledN1(MakeWormNoisePosition(
TileOrigin.X + X, TileOrigin.Y + Y, TileOrigin.Z + Z, Seed));
if (ExactN1 < WormThreshold)
{
++ProductionViolations;
}
}
}
}
}
}
}
}
AddInfo(FString::Printf(
TEXT("Production block audit: tiles=%d blocks=%llu proofs=%llu lattice_samples=%llu "
"violations=%llu, side=%d, threshold=%.6f."),
TestTileCount, static_cast<unsigned long long>(BlocksScanned),
static_cast<unsigned long long>(ProductionProofs),
static_cast<unsigned long long>(ProductionLatticeSamples),
static_cast<unsigned long long>(ProductionViolations),
VoxelNoise::WormBlockSampleSide, WormThreshold));
TestTrue(TEXT("the production-bound block audit found at least one proof"),
ProductionProofs > 0);
TestEqual(TEXT("every production-bound proof is sound on its exact lattice"),
ProductionViolations, static_cast<uint64>(0));
// Fail-capable negative control. This pair is from a real low-coordinate hash cell. Its exact
// scaled-N1 slope is above L/2; the threshold is selected between the two exact values, so a
// deliberately halved bound admits a proof that the second lattice point violates.
const FVector3f WitnessCenter(1510.5478515625f, 2191.43701171875f, 7279.404296875f);
const FVector3f WitnessPoint(1510.54931640625f, 2191.437255859375f, 7279.50390625f);
const FVector3f WitnessMin(
2.0f * WitnessCenter.X - WitnessPoint.X,
2.0f * WitnessCenter.Y - WitnessPoint.Y,
2.0f * WitnessCenter.Z - WitnessPoint.Z);
const FVector3f WitnessMax = WitnessPoint;
constexpr float WitnessThreshold = 0.015f;
const float WitnessCenterN1 = ScaledN1(WitnessCenter);
const float WitnessPointN1 = ScaledN1(WitnessPoint);
const bool bHalfProof = VoxelNoise::ProvesScaledAbsPerlin3DAbove(
WitnessCenter, WitnessMin, WitnessMax, WitnessThreshold, HalfL);
const bool bFullProof = VoxelNoise::ProvesScaledAbsPerlin3DAbove(
WitnessCenter, WitnessMin, WitnessMax, WitnessThreshold, FullL);
AddInfo(FString::Printf(
TEXT("Halved-L negative control: center_N1=%.9f point_N1=%.9f threshold=%.6f "
"half_proof=%d full_proof=%d."),
WitnessCenterN1, WitnessPointN1, WitnessThreshold,
bHalfProof ? 1 : 0, bFullProof ? 1 : 0));
TestTrue(TEXT("the deliberately halved L admits the negative-control block"), bHalfProof);
TestFalse(TEXT("the full L rejects the negative-control block"), bFullProof);
TestTrue(TEXT("the negative-control lattice point really violates N1 >= threshold"),
WitnessPointN1 < WitnessThreshold);
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -42,17 +42,27 @@ void UVoxelAtmosphereManager::UpdateForPlayer(const FVector& PlayerWorldPos)
AActor* O = Owner.Get();
if (!O) return;
const int32 Idx = StrateManager->GetStrateIndex(PlayerWorldPos.Z);
const UVoxelStrateDefinition* Def = StrateManager->GetStrateAt(PlayerWorldPos.Z);
// Le champ de densite est en espace ACTEUR : les requetes de strate et de biome veulent des
// coordonnees LOCALES. Le placement d acteurs, lui, veut du MONDE. Les deux cohabitent ici, ce
// qui est exactement pourquoi ce fichier supposait "acteur a l origine" avant le 2026-08-17.
// The density field is in ACTOR space: strate and biome queries want LOCAL coordinates, while
// actor placement wants WORLD. Both live in this function - which is exactly why it used to
// assume "actor at origin / identity".
const FTransform Xf = O->GetActorTransform();
const FVector LocalPos = Xf.InverseTransformPosition(PlayerWorldPos);
const int32 Idx = StrateManager->GetStrateIndex(LocalPos.Z);
const UVoxelStrateDefinition* Def = StrateManager->GetStrateAt(LocalPos.Z);
// Player's dominant biome (XY field) — atmosphere can vary by biome within a strate.
// Convention matches GetStrateAt: world cm = voxel * VOXEL_SIZE (actor at origin/identity).
// Requete en espace ACTEUR (voir LocalPos ci-dessus) : cm locaux -> voxels.
// Queried in ACTOR space (see LocalPos above): local cm -> voxels.
const UVoxelBiomeDefinition* Biome = nullptr;
if (Generator && Def)
{
const int32 ChunkZ = FMath::FloorToInt((PlayerWorldPos.Z / VOXEL_SIZE) / CHUNK_SIZE);
Biome = Generator->GetDominantBiomeAt(PlayerWorldPos.X / VOXEL_SIZE,
PlayerWorldPos.Y / VOXEL_SIZE, ChunkZ);
const int32 ChunkZ = FMath::FloorToInt((LocalPos.Z / VOXEL_SIZE) / CHUNK_SIZE);
Biome = Generator->GetDominantBiomeAt(LocalPos.X / VOXEL_SIZE,
LocalPos.Y / VOXEL_SIZE, ChunkZ);
}
// React on strate change (full apply) OR biome change within a strate (fog/sky only —
@@ -81,18 +91,18 @@ void UVoxelAtmosphereManager::UpdateForPlayer(const FVector& PlayerWorldPos)
if (Def)
{
float TopZ, BottomZ;
if (StrateManager->GetStrateUnrealZRange(PlayerWorldPos.Z, TopZ, BottomZ))
if (StrateManager->GetStrateUnrealZRange(LocalPos.Z, TopZ, BottomZ))
{
if (CeilingActor)
{
CeilingActor->SetActorLocationAndRotation(
FVector(PlayerWorldPos.X, PlayerWorldPos.Y, TopZ + Def->CeilingLayerZOffset),
Xf.TransformPosition(FVector(LocalPos.X, LocalPos.Y, TopZ + Def->CeilingLayerZOffset)),
Def->CeilingLayerRotation);
}
if (FloorActor)
{
FloorActor->SetActorLocationAndRotation(
FVector(PlayerWorldPos.X, PlayerWorldPos.Y, BottomZ + Def->FloorLayerZOffset),
Xf.TransformPosition(FVector(LocalPos.X, LocalPos.Y, BottomZ + Def->FloorLayerZOffset)),
Def->FloorLayerRotation);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,219 @@
#pragma once
#include "VoxelTypes.h"
namespace VoxelClipmapDesiredTiles
{
struct FParameters
{
int32 ClipRadius = 3;
int32 MaxClipLevel = 4;
int32 RenderDistanceChunks = 0;
bool bFarSheetRing = true;
int32 FarSheetSpanLevels = 2;
};
FORCEINLINE int32 FloorDiv(int32 Value, int32 Divisor)
{
return Value >= 0 ? (Value / Divisor) : -(((-Value) + Divisor - 1) / Divisor);
}
FORCEINLINE FIntVector FloorDiv(const FIntVector& Value, int32 Divisor)
{
return FIntVector(FloorDiv(Value.X, Divisor), FloorDiv(Value.Y, Divisor),
FloorDiv(Value.Z, Divisor));
}
FORCEINLINE int32 CeilDiv(int32 Value, int32 Divisor)
{
return -FloorDiv(-Value, Divisor);
}
FORCEINLINE void AlignedRingBounds(const FIntVector& Center, int32 Radius, int32 Alignment,
FIntVector& OutMin, FIntVector& OutMax)
{
const auto AlignAxis = [Radius, Alignment](int32 CenterAxis,
int32& OutAxisMin, int32& OutAxisMax)
{
const int32 BaseMin = CenterAxis - Radius;
const int32 BaseEnd = CenterAxis + Radius + 1;
OutAxisMin = FloorDiv(BaseMin, Alignment) * Alignment;
OutAxisMax = CeilDiv(BaseEnd, Alignment) * Alignment - 1;
};
AlignAxis(Center.X, OutMin.X, OutMax.X);
AlignAxis(Center.Y, OutMin.Y, OutMax.Y);
AlignAxis(Center.Z, OutMin.Z, OutMax.Z);
}
FORCEINLINE int32 OuterShellRadius(const FParameters& Parameters, int32 Radius, int32 MaxLevel)
{
if (Parameters.RenderDistanceChunks <= 0)
{
return Radius;
}
return FMath::Max(Radius,
(Parameters.RenderDistanceChunks + (1 << MaxLevel) - 1) >> MaxLevel);
}
FORCEINLINE void OuterShell(const FParameters& Parameters, int32 Radius, int32 MaxLevel,
int32& OutLevel, int32& OutRadius)
{
OutLevel = MaxLevel;
OutRadius = OuterShellRadius(Parameters, Radius, MaxLevel);
if (Parameters.bFarSheetRing && OutRadius > Radius)
{
OutLevel = MaxLevel + FMath::Clamp(Parameters.FarSheetSpanLevels, 1, 4);
OutRadius = FMath::Max(1,
(Parameters.RenderDistanceChunks + (1 << OutLevel) - 1) >> OutLevel);
}
}
FORCEINLINE void ResolveVerticalBounds(
int32 CenterChunkZ, int32 ViewDistanceUp, int32 ViewDistanceDown,
int32 StrateViewMarginChunks, bool bClampToStrate, bool bHasStrate,
int32 StrateTopChunkZ, int32 StrateBottomChunkZ,
int32& OutMinChunkZ, int32& OutMaxChunkZ)
{
OutMinChunkZ = MIN_int32;
OutMaxChunkZ = MAX_int32;
if (!bClampToStrate)
{
return;
}
OutMinChunkZ = CenterChunkZ - ViewDistanceDown;
OutMaxChunkZ = CenterChunkZ + ViewDistanceUp;
if (bHasStrate)
{
OutMinChunkZ = StrateBottomChunkZ - StrateViewMarginChunks;
OutMaxChunkZ = StrateTopChunkZ + StrateViewMarginChunks;
}
}
FORCEINLINE void Build(const FIntVector& Center, int32 MinChunkZ, int32 MaxChunkZ,
const FParameters& Parameters, TArray<FVoxelTileKey>& OutTiles)
{
OutTiles.Reset();
const int32 Radius = FMath::Max(1, Parameters.ClipRadius);
const int32 MaxLevel = FMath::Clamp(Parameters.MaxClipLevel, 0, 8);
const int32 OuterRadius = OuterShellRadius(Parameters, Radius, MaxLevel);
int32 SheetLevel = MaxLevel;
int32 SheetRadius = OuterRadius;
OuterShell(Parameters, Radius, MaxLevel, SheetLevel, SheetRadius);
const bool bSheetRing = SheetLevel > MaxLevel;
const int32 MaxMCRingLevel = bSheetRing ? SheetLevel - 1 : MaxLevel;
FIntVector PreviousRingMin = FIntVector::ZeroValue;
FIntVector PreviousRingMax = FIntVector::ZeroValue;
FIntVector MCRingMin = FIntVector::ZeroValue;
FIntVector MCRingMax = FIntVector::ZeroValue;
for (int32 Level = 0; Level <= MaxMCRingLevel; ++Level)
{
const int32 Pow = 1 << Level;
const FIntVector CenterLevel = FloorDiv(Center, Pow);
const int32 LevelRadius = (Level == MaxLevel && !bSheetRing)
? OuterRadius : Radius;
// Every finer shell must end on a complete child-pair boundary so a coarser tile is
// either wholly inside it or wholly outside it. The final MC ring uses the sheet's
// grouping width when a sheet ring follows it; otherwise the ordinary LOD boundary
// only needs pair alignment.
const int32 RingAlignment = Level < MaxMCRingLevel
? 2
: (bSheetRing ? (1 << (SheetLevel - MaxMCRingLevel)) : 1);
FIntVector RingMin, RingMax;
AlignedRingBounds(CenterLevel, LevelRadius, RingAlignment, RingMin, RingMax);
if (Level == MaxMCRingLevel)
{
MCRingMin = RingMin;
MCRingMax = RingMax;
}
int32 DeltaZMin = RingMin.Z - CenterLevel.Z;
int32 DeltaZMax = RingMax.Z - CenterLevel.Z;
if (LevelRadius > Radius && MinChunkZ != MIN_int32)
{
DeltaZMin = FMath::Max(DeltaZMin, FloorDiv(MinChunkZ, Pow) - CenterLevel.Z);
DeltaZMax = FMath::Min(DeltaZMax, FloorDiv(MaxChunkZ, Pow) - CenterLevel.Z);
}
for (int32 DeltaZ = DeltaZMin; DeltaZ <= DeltaZMax; ++DeltaZ)
for (int32 DeltaY = RingMin.Y - CenterLevel.Y;
DeltaY <= RingMax.Y - CenterLevel.Y; ++DeltaY)
for (int32 DeltaX = RingMin.X - CenterLevel.X;
DeltaX <= RingMax.X - CenterLevel.X; ++DeltaX)
{
const FIntVector Tile = CenterLevel + FIntVector(DeltaX, DeltaY, DeltaZ);
const int32 TileMinZ = Tile.Z << Level;
const int32 TileMaxZ = ((Tile.Z + 1) << Level) - 1;
if (TileMaxZ < MinChunkZ || TileMinZ > MaxChunkZ)
{
continue;
}
if (Level > 0)
{
const bool bCovered =
(2 * Tile.X >= PreviousRingMin.X)
&& (2 * Tile.X + 1 <= PreviousRingMax.X)
&& (2 * Tile.Y >= PreviousRingMin.Y)
&& (2 * Tile.Y + 1 <= PreviousRingMax.Y)
&& (2 * Tile.Z >= PreviousRingMin.Z)
&& (2 * Tile.Z + 1 <= PreviousRingMax.Z);
if (bCovered)
{
continue;
}
}
OutTiles.Emplace(Tile, Level);
}
PreviousRingMin = RingMin;
PreviousRingMax = RingMax;
}
if (bSheetRing)
{
const int32 SheetPow = 1 << SheetLevel;
const FIntVector CenterSheet = FloorDiv(Center, SheetPow);
const int32 LevelDelta = SheetLevel - MaxMCRingLevel;
const int32 MCPerSheet = 1 << LevelDelta;
int32 DeltaZMin = -SheetRadius;
int32 DeltaZMax = SheetRadius;
if (MinChunkZ != MIN_int32)
{
DeltaZMin = FMath::Max(DeltaZMin,
FloorDiv(MinChunkZ, SheetPow) - CenterSheet.Z);
DeltaZMax = FMath::Min(DeltaZMax,
FloorDiv(MaxChunkZ, SheetPow) - CenterSheet.Z);
}
for (int32 DeltaZ = DeltaZMin; DeltaZ <= DeltaZMax; ++DeltaZ)
for (int32 DeltaY = -SheetRadius; DeltaY <= SheetRadius; ++DeltaY)
for (int32 DeltaX = -SheetRadius; DeltaX <= SheetRadius; ++DeltaX)
{
const FIntVector Tile = CenterSheet + FIntVector(DeltaX, DeltaY, DeltaZ);
const int32 TileMinZ = Tile.Z << SheetLevel;
const int32 TileMaxZ = ((Tile.Z + 1) << SheetLevel) - 1;
if (TileMaxZ < MinChunkZ || TileMinZ > MaxChunkZ)
{
continue;
}
const bool bCovered =
(Tile.X * MCPerSheet >= MCRingMin.X)
&& ((Tile.X + 1) * MCPerSheet - 1 <= MCRingMax.X)
&& (Tile.Y * MCPerSheet >= MCRingMin.Y)
&& ((Tile.Y + 1) * MCPerSheet - 1 <= MCRingMax.Y)
&& (Tile.Z * MCPerSheet >= MCRingMin.Z)
&& ((Tile.Z + 1) * MCPerSheet - 1 <= MCRingMax.Z);
if (!bCovered)
{
OutTiles.Emplace(Tile, SheetLevel);
}
}
}
}
}
+158 -34
View File
@@ -31,15 +31,31 @@ static constexpr int32 GMaxCompanionsPerParent = 256;
// decoration's per-cell MaxPerChunk keeps its "per chunk" meaning).
static constexpr int32 DECO_CELL_VOXELS = CHUNK_SIZE;
// Count of in-flight decoration march tasks (for the EndPlay drain). File-scope so the worker lambda's
// RAII guard can touch it without keeping the UObject 'this' alive for that alone.
static std::atomic<int32> GActiveDecoTasks{0};
static FORCEINLINE int32 CellChebyshev(const FIntPoint& A, const FIntPoint& B)
{
return FMath::Max(FMath::Abs(A.X - B.X), FMath::Abs(A.Y - B.Y));
}
static bool VF_DecoTransformLess(const FTransform& A, const FTransform& B)
{
// A transform is the complete deterministic identity of a placement. Comparing the matrix
// avoids the q/-q representation ambiguity of raw quaternions and gives the final region order
// the same result regardless of which worker cell completed first.
const FMatrix AM = A.ToMatrixWithScale();
const FMatrix BM = B.ToMatrixWithScale();
for (int32 Row = 0; Row < 4; ++Row)
{
for (int32 Col = 0; Col < 4; ++Col)
{
if (AM.M[Row][Col] != BM.M[Row][Col])
{
return AM.M[Row][Col] < BM.M[Row][Col];
}
}
}
return false;
}
void UVoxelContentManager::Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager,
UVoxelGenerator* InGenerator, UVoxelSettings* InSettings, int32 InSeed)
{
@@ -59,6 +75,7 @@ void UVoxelContentManager::BeginDestroy()
{
// Backstop: stop any worker from touching us. EndPlay → NotifyShutdown should have already drained.
bShuttingDown.store(true, std::memory_order_release);
JoinDecorationTasks();
Super::BeginDestroy();
}
@@ -66,19 +83,33 @@ void UVoxelContentManager::NotifyShutdown()
{
bShuttingDown.store(true, std::memory_order_release);
// Wait for in-flight march tasks to finish (they check the flag and bail). Timeout to avoid hangs.
const double Deadline = FPlatformTime::Seconds() + 3.0;
while (GActiveDecoTasks.load(std::memory_order_relaxed) > 0)
{
if (FPlatformTime::Seconds() > Deadline) break;
FPlatformProcess::Yield();
}
// A timeout is safe for the editor's pre-mutation probe, but not for UObject destruction:
// join this manager's tasks fully before dropping its worker-captured `this`.
JoinDecorationTasks();
DrainDecoResults();
ResetGridBuildState(NearGrid);
ResetGridBuildState(FarGrid);
}
bool UVoxelContentManager::WaitForDecorationTasks(double Deadline)
{
while (ActiveDecorationTasks.load(std::memory_order_relaxed) > 0)
{
if (FPlatformTime::Seconds() > Deadline) return false;
FPlatformProcess::Yield();
}
return true;
}
void UVoxelContentManager::JoinDecorationTasks()
{
while (ActiveDecorationTasks.load(std::memory_order_acquire) > 0)
{
FPlatformProcess::Yield();
}
}
void UVoxelContentManager::DrainDecoResults()
{
FDecoCellResult Discard;
@@ -192,12 +223,24 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos)
// Refresh each grid's (tier, radius, spacing) from settings for this update. Radius/spacing are read
// every frame so live edits to the data asset take effect; the grids themselves persist across updates.
// A stationary player still needs a rebuild when one of these values changes: otherwise already
// loaded regions remain outside the new radius (or retain the old sampling density) forever.
const int32 NewNearRadius = FMath::Max(1, Settings->DecorationNearRadiusChunks);
const int32 NewFarRadius = FMath::Max(1, Settings->DecorationRadiusChunks);
const int32 NewNearSpacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE);
const int32 NewFarSpacing = FMath::Clamp(Settings->DecorationFarSpacingVoxels, 1, CHUNK_SIZE);
const int32 NewRegionSize = RegionSize();
const bool bDecorationGridConfigChanged =
NearGrid.Radius != NewNearRadius || FarGrid.Radius != NewFarRadius
|| NearGrid.Spacing != NewNearSpacing || FarGrid.Spacing != NewFarSpacing
|| LastRegionSize != NewRegionSize;
NearGrid.Tier = EDecoStreamTier::Near;
NearGrid.Radius = FMath::Max(1, Settings->DecorationNearRadiusChunks);
NearGrid.Spacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE);
NearGrid.Radius = NewNearRadius;
NearGrid.Spacing = NewNearSpacing;
FarGrid.Tier = EDecoStreamTier::Far;
FarGrid.Radius = FMath::Max(1, Settings->DecorationRadiusChunks);
FarGrid.Spacing = FMath::Clamp(Settings->DecorationFarSpacingVoxels, 1, CHUNK_SIZE);
FarGrid.Radius = NewFarRadius;
FarGrid.Spacing = NewFarSpacing;
// Build the decoration palette ONCE for this update, PARTITIONED by tier. With biomes, concatenate every
// biome's deco list and tag each entry with its context-biome index; the worker resolves a column's biome
@@ -234,13 +277,19 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos)
}
}
// Strate change → wipe + force a full rebuild (bumps epoch so old in-flight tasks are discarded).
if (StrateIndex != LastStrateIndex)
// Strate or grid configuration change → wipe + force a full rebuild. This also invalidates
// every old region build before the next stationary-player update can reuse it.
if (StrateIndex != LastStrateIndex || bDecorationGridConfigChanged)
{
ClearAllDecorations();
LastStrateIndex = StrateIndex;
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
}
LastNearRadius = NewNearRadius;
LastFarRadius = NewFarRadius;
LastNearSpacing = NewNearSpacing;
LastFarSpacing = NewFarSpacing;
LastRegionSize = NewRegionSize;
if (PlayerCell != LastDecoCell)
{
@@ -289,11 +338,22 @@ void UVoxelContentManager::RebuildDesiredCells(FDecoGrid& G, const FIntPoint& Pl
}
}
TArray<FIntPoint> DesiredRegionList;
DesiredRegionList.Reserve(DesiredRegions.Num());
for (const FIntPoint& Region : DesiredRegions)
{
DesiredRegionList.Add(Region);
}
DesiredRegionList.Sort([](const FIntPoint& A, const FIntPoint& B)
{
return (A.Y != B.Y) ? (A.Y < B.Y) : (A.X < B.X);
});
// Start a build for each desired region that isn't already loaded or building. In-progress builds are
// LEFT to finish even if they fell out of range (their cell tasks are already off-thread); the apply
// step discards a completed build that is no longer desired (see ProcessDecoResults). Each new build
// enqueues all RxR of its cells once — a building region is never re-queued (no duplicate launches).
for (const FIntPoint& Region : DesiredRegions)
for (const FIntPoint& Region : DesiredRegionList)
{
if (G.Regions.Contains(Region)) continue; // already applied → leave it (no re-stream)
if (G.Builds.Contains(Region)) continue; // already marching its cells
@@ -314,7 +374,10 @@ void UVoxelContentManager::RebuildDesiredCells(FDecoGrid& G, const FIntPoint& Pl
// build was already discarded) are cheaply skipped at launch, so PendingLaunch self-cleans as it drains.
G.PendingLaunch.Sort([PlayerCell](const FIntPoint& A, const FIntPoint& B)
{
return CellChebyshev(A, PlayerCell) < CellChebyshev(B, PlayerCell);
const int32 AD = CellChebyshev(A, PlayerCell);
const int32 BD = CellChebyshev(B, PlayerCell);
if (AD != BD) return AD < BD;
return (A.Y != B.Y) ? (A.Y < B.Y) : (A.X < B.X);
});
}
@@ -379,13 +442,17 @@ void UVoxelContentManager::LaunchDecoTasks(FDecoGrid& G, const FIntPoint& Player
UVoxelGenerator* Gen = Generator;
G.InFlightCells.Add(Cell);
GActiveDecoTasks.fetch_add(1, std::memory_order_relaxed);
ActiveDecorationTasks.fetch_add(1, std::memory_order_relaxed);
UE::Tasks::Launch(TEXT("DecoMarch"),
[this, Gen, OwnerXf, Cell, Ctx, LocalSeed, Spacing, Step, MaxCross, ColDepth, BuildId, GridTier,
Entries = MoveTemp(EntriesCopy), EntryBiome = MoveTemp(EntryBiomeCopy)]() mutable
{
struct FGuard { ~FGuard() { GActiveDecoTasks.fetch_sub(1, std::memory_order_relaxed); } } Guard;
struct FGuard
{
UVoxelContentManager* Owner;
~FGuard() { Owner->ActiveDecorationTasks.fetch_sub(1, std::memory_order_release); }
} Guard{this};
if (bShuttingDown.load(std::memory_order_relaxed)) return;
@@ -434,9 +501,9 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
for (int32 e = 0; e < Entries.Num(); ++e)
{
CosMaxSlope[e] = (Entries[e].Profile.MaxSlopeAngle < 89.99f)
? FMath::Cos(FMath::DegreesToRadians(Entries[e].Profile.MaxSlopeAngle)) : -1.0f;
? VoxelMath::DetCos(FMath::DegreesToRadians(Entries[e].Profile.MaxSlopeAngle)) : -1.0f;
CosMinSlope[e] = (Entries[e].Profile.MinSlopeAngle > 0.01f)
? FMath::Cos(FMath::DegreesToRadians(Entries[e].Profile.MinSlopeAngle)) : -1.0f;
? VoxelMath::DetCos(FMath::DegreesToRadians(Entries[e].Profile.MinSlopeAngle)) : -1.0f;
}
// Per-COLUMN biome cache: ResolveBiomeSampleAt's noise-heavy cell classification is box-validated
@@ -580,8 +647,10 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
const float Ang = VoxelHash::ToFloat01(IH) * 2.0f * PI;
const float RadVox = FMath::Lerp(Comp.RadiusMinVox, Comp.RadiusMaxVox,
VoxelHash::ToFloat01(VoxelHash::Mix(IH ^ 0x77u)));
const float SatVX = VX + FMath::Cos(Ang) * RadVox; // voxel, actor-local
const float SatVY = VY + FMath::Sin(Ang) * RadVox;
float SinAng = 0.0f, CosAng = 0.0f;
VoxelMath::DetSinCos(SinAng, CosAng, Ang);
const float SatVX = VX + CosAng * RadVox; // voxel, actor-local
const float SatVY = VY + SinAng * RadVox;
// Optional per-satellite gating (relief/moisture/biome-border at ITS own XY).
if (Comp.Profile.Conditions.Num() > 0 &&
@@ -626,8 +695,10 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
const float SAng = VoxelHash::ToFloat01(JH) * 2.0f * PI;
const float SRadVox = FMath::Lerp(Sub.RadiusMinVox, Sub.RadiusMaxVox,
VoxelHash::ToFloat01(VoxelHash::Mix(JH ^ 0x77u)));
const float SubVX = SatVX + FMath::Cos(SAng) * SRadVox;
const float SubVY = SatVY + FMath::Sin(SAng) * SRadVox;
float SinSAng = 0.0f, CosSAng = 0.0f;
VoxelMath::DetSinCos(SinSAng, CosSAng, SAng);
const float SubVX = SatVX + CosSAng * SRadVox;
const float SubVY = SatVY + SinSAng * SRadVox;
if (Sub.Profile.Conditions.Num() > 0 &&
!Gen->EvaluateTerrainConditions(Sub.Profile.Conditions, SubVX, SubVY, Ctx.BiomeCtx)) continue;
@@ -782,6 +853,10 @@ void UVoxelContentManager::ProcessDecoResults(const FIntPoint& PlayerCell)
for (FDecoGrid* GP : { &NearGrid, &FarGrid })
{
FDecoGrid& G = *GP;
G.Completed.Sort([](const FIntPoint& A, const FIntPoint& B)
{
return (A.Y != B.Y) ? (A.Y < B.Y) : (A.X < B.X);
});
while (G.Completed.Num() > 0 && Applied < Budget)
{
const FIntPoint Region = G.Completed[0];
@@ -840,9 +915,21 @@ void UVoxelContentManager::MergeCellResult(FDecoGrid& G, const FDecoCellResult&
{
if (!Prof.InstancedMesh) continue;
// Bucket by MESH so cells (and biomes) sharing a mesh collapse into one region HISM. The first
// contributor sets the render tuning (cull/shadow/scale) for the whole region's instances.
// contributor in deterministic source order sets the render tuning (cull/shadow/scale) for the
// whole region's instances. Worker completion order must not choose that representative.
FRegionMeshBucket& Bucket = Build->MeshBuckets.FindOrAdd(Prof.InstancedMesh);
if (Bucket.Xforms.Num() == 0) { Bucket.Profile = Prof; }
const bool bEarlierSource =
S.EntryIdx < Bucket.SourceEntryIdx
|| (S.EntryIdx == Bucket.SourceEntryIdx && S.CompanionIdx < Bucket.SourceCompanionIdx)
|| (S.EntryIdx == Bucket.SourceEntryIdx && S.CompanionIdx == Bucket.SourceCompanionIdx
&& S.SubIdx < Bucket.SourceSubIdx);
if (Bucket.Xforms.Num() == 0 || bEarlierSource)
{
Bucket.Profile = Prof;
Bucket.SourceEntryIdx = S.EntryIdx;
Bucket.SourceCompanionIdx = S.CompanionIdx;
Bucket.SourceSubIdx = S.SubIdx;
}
Bucket.Xforms.Add(S.Xf);
}
else if (Prof.ActorClass)
@@ -887,6 +974,15 @@ void UVoxelContentManager::ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FD
FDecoRegionContent& Content = G.Regions.Add(Region);
Build.ActorSpawns.Sort([](const FRegionActorSpawn& A, const FRegionActorSpawn& B)
{
const FString AClass = A.ActorClass ? A.ActorClass->GetPathName() : FString();
const FString BClass = B.ActorClass ? B.ActorClass->GetPathName() : FString();
const int32 ClassCompare = FCString::Strcmp(*AClass, *BClass);
if (ClassCompare != 0) return ClassCompare < 0;
return VF_DecoTransformLess(A.Xf, B.Xf);
});
// Non-instanced actors — spawn each (no batch path). Decorations live only in the player's strate
// (the march is strate-bounded), so their lights are always legitimately visible — no extra culling.
for (const FRegionActorSpawn& A : Build.ActorSpawns)
@@ -903,13 +999,27 @@ void UVoxelContentManager::ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FD
// One HISM per mesh for the ENTIRE region — the render-thread win: hundreds of per-cell components
// collapse to a handful per region, so InitViews walks far fewer primitives every frame.
for (TPair<TWeakObjectPtr<UStaticMesh>, FRegionMeshBucket>& Pair : Build.MeshBuckets)
TArray<TWeakObjectPtr<UStaticMesh>> MeshKeys;
Build.MeshBuckets.GetKeys(MeshKeys);
MeshKeys.Sort([](const TWeakObjectPtr<UStaticMesh>& A, const TWeakObjectPtr<UStaticMesh>& B)
{
FRegionMeshBucket& Bucket = Pair.Value;
UStaticMesh* Mesh = Pair.Key.Get();
const UStaticMesh* AMesh = A.Get();
const UStaticMesh* BMesh = B.Get();
const FString APath = AMesh ? AMesh->GetPathName() : FString();
const FString BPath = BMesh ? BMesh->GetPathName() : FString();
return FCString::Strcmp(*APath, *BPath) < 0;
});
for (const TWeakObjectPtr<UStaticMesh>& MeshKey : MeshKeys)
{
FRegionMeshBucket* BucketPtr = Build.MeshBuckets.Find(MeshKey);
if (!BucketPtr) continue;
FRegionMeshBucket& Bucket = *BucketPtr;
UStaticMesh* Mesh = MeshKey.Get();
if (!Mesh || Bucket.Xforms.Num() == 0) continue;
const FPlacementProfile& Prof = Bucket.Profile;
Bucket.Xforms.Sort(VF_DecoTransformLess);
UHierarchicalInstancedStaticMeshComponent* HISM =
NewObject<UHierarchicalInstancedStaticMeshComponent>(OwnerActor);
HISM->SetStaticMesh(Mesh);
@@ -992,6 +1102,16 @@ void UVoxelContentManager::ClearAllDecorations()
// Drain any results already enqueued by in-flight tasks. No epoch bump needed: their BuildIds are now
// gone from the grids' Builds, so any straggler result is discarded on merge; new builds get fresh BuildIds.
DrainDecoResults();
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
}
void UVoxelContentManager::InvalidateDecorationBuilds()
{
// A terrain edit changes the worker's surface oracle. Clear both the applied regions and the
// in-progress build maps as one operation; any late result then fails the BuildId lookup and cannot
// resurrect pre-edit content. The next UpdateDecorations rebuilds the desired set even if the player
// has not moved.
ClearAllDecorations();
}
//=============================================================================
@@ -1114,9 +1234,9 @@ bool UVoxelContentManager::SpawnFromProfile(const FPlacementProfile& P, uint32 H
// Surface-tilt gates (acos(|N.Z|); guarded so defaults cost no trig).
if (P.MaxSlopeAngle < 89.99f &&
FMath::Abs(N.Z) < FMath::Cos(FMath::DegreesToRadians(P.MaxSlopeAngle))) return false;
FMath::Abs(N.Z) < VoxelMath::DetCos(FMath::DegreesToRadians(P.MaxSlopeAngle))) return false;
if (P.MinSlopeAngle > 0.01f &&
FMath::Abs(N.Z) > FMath::Cos(FMath::DegreesToRadians(P.MinSlopeAngle))) return false;
FMath::Abs(N.Z) > VoxelMath::DetCos(FMath::DegreesToRadians(P.MinSlopeAngle))) return false;
const FVector LocalPos(LocalX, LocalY, ZC * VOXEL_SIZE);
if (P.bRequireWaterRelative && Ctx.bHasWater)
@@ -1301,6 +1421,10 @@ void UVoxelContentManager::UpdateLandmarks(const FVector& PlayerWorldPos)
Ctx.bHasWater = (Wv != -FLT_MAX);
Ctx.WaterLocalZ = Ctx.bHasWater ? Wv * VOXEL_SIZE : -FLT_MAX;
}
// Landmarks use the same per-column biome gates as decorations. Keep the complete POD biome
// context in the worker/game-thread placement context; a null/default context would silently
// bypass the authored RequiredBiome/Conditions decisions for landmarks.
Ctx.BiomeCtx = StrateManager->GetBiomeContextForChunk(RepChunk);
const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
@@ -0,0 +1,131 @@
// VoxelDensityAblation.cpp
// Development-only TunnelNetwork ablation controls.
#include "VoxelDensityAblation.h"
#include "HAL/IConsoleManager.h"
#include "Misc/CommandLine.h"
#include "Misc/Parse.h"
#include <atomic>
namespace
{
constexpr uint32 UnresolvedMask = 0xFFFFFFFFu;
std::atomic<uint32> GResolvedMask(UnresolvedMask);
#if !UE_BUILD_SHIPPING
int32 GCaveWarp = 0;
int32 GDetailOps = 0;
int32 GRoomSDF = 0;
int32 GTunnelSDF = 0;
int32 GTunnelCore = 0;
int32 GPassageCarving = 0;
int32 GPassageStructuralPosts = 0;
int32 GNativeFloor = 0;
int32 GDisturbances = 0;
int32 GOriginSpine = 0;
int32 GBoundarySeal = 0;
int32 GLandingPosts = 0;
int32 GXYEdgeSeal = 0;
int32 GPitChimneySDF = 0;
#define VF_ABLATION_CVAR(Name, Storage) \
FAutoConsoleVariableRef CVar##Storage( \
Name, Storage, \
TEXT("WORLD-CHANGING DEVELOPMENT-ONLY measurement override. Forces this " \
"TunnelNetwork density stage off; never use as a gameplay switch. " \
"All peers and regenerated worlds must use the same value. Resolved once " \
"per process. 0=on, 1=off."), ECVF_Default)
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateCaveWarp"), GCaveWarp);
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateDetailOps"), GDetailOps);
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateRoomSDF"), GRoomSDF);
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateTunnelSDF"), GTunnelSDF);
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateTunnelCore"), GTunnelCore);
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblatePassageCarving"), GPassageCarving);
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblatePassageStructuralPosts"), GPassageStructuralPosts);
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateNativeFloor"), GNativeFloor);
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateDisturbances"), GDisturbances);
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateOriginSpine"), GOriginSpine);
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateBoundarySeal"), GBoundarySeal);
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateLandingPosts"), GLandingPosts);
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateXYEdgeSeal"), GXYEdgeSeal);
VF_ABLATION_CVAR(TEXT("voxel.TunnelAblatePitChimneySDF"), GPitChimneySDF);
#undef VF_ABLATION_CVAR
uint32 ResolveValue(const TCHAR* Name, int32& Storage)
{
int32 Value = Storage;
FParse::Value(FCommandLine::Get(), Name, Value);
Storage = Value != 0 ? 1 : 0;
return Storage != 0 ? 1u : 0u;
}
uint32 ResolveMask()
{
uint32 Mask = 0;
Mask |= ResolveValue(TEXT("voxel.TunnelAblateCaveWarp="), GCaveWarp)
* static_cast<uint32>(VoxelDensityAblation::EStage::CaveWarp);
Mask |= ResolveValue(TEXT("voxel.TunnelAblateDetailOps="), GDetailOps)
* static_cast<uint32>(VoxelDensityAblation::EStage::DetailOps);
Mask |= ResolveValue(TEXT("voxel.TunnelAblateRoomSDF="), GRoomSDF)
* static_cast<uint32>(VoxelDensityAblation::EStage::RoomSDF);
Mask |= ResolveValue(TEXT("voxel.TunnelAblateTunnelSDF="), GTunnelSDF)
* static_cast<uint32>(VoxelDensityAblation::EStage::TunnelSDF);
Mask |= ResolveValue(TEXT("voxel.TunnelAblateTunnelCore="), GTunnelCore)
* static_cast<uint32>(VoxelDensityAblation::EStage::TunnelCore);
Mask |= ResolveValue(TEXT("voxel.TunnelAblatePassageCarving="), GPassageCarving)
* static_cast<uint32>(VoxelDensityAblation::EStage::PassageCarving);
Mask |= ResolveValue(
TEXT("voxel.TunnelAblatePassageStructuralPosts="), GPassageStructuralPosts)
* static_cast<uint32>(VoxelDensityAblation::EStage::PassageStructuralPosts);
Mask |= ResolveValue(TEXT("voxel.TunnelAblateNativeFloor="), GNativeFloor)
* static_cast<uint32>(VoxelDensityAblation::EStage::NativeFloor);
Mask |= ResolveValue(TEXT("voxel.TunnelAblateDisturbances="), GDisturbances)
* static_cast<uint32>(VoxelDensityAblation::EStage::Disturbances);
Mask |= ResolveValue(TEXT("voxel.TunnelAblateOriginSpine="), GOriginSpine)
* static_cast<uint32>(VoxelDensityAblation::EStage::OriginSpine);
Mask |= ResolveValue(TEXT("voxel.TunnelAblateBoundarySeal="), GBoundarySeal)
* static_cast<uint32>(VoxelDensityAblation::EStage::BoundarySeal);
Mask |= ResolveValue(TEXT("voxel.TunnelAblateLandingPosts="), GLandingPosts)
* static_cast<uint32>(VoxelDensityAblation::EStage::LandingPosts);
Mask |= ResolveValue(TEXT("voxel.TunnelAblateXYEdgeSeal="), GXYEdgeSeal)
* static_cast<uint32>(VoxelDensityAblation::EStage::XYEdgeSeal);
Mask |= ResolveValue(TEXT("voxel.TunnelAblatePitChimneySDF="), GPitChimneySDF)
* static_cast<uint32>(VoxelDensityAblation::EStage::PitChimneySDF);
return Mask;
}
#endif
}
namespace VoxelDensityAblation
{
uint32 GetResolvedMask()
{
uint32 Existing = GResolvedMask.load(std::memory_order_acquire);
if (Existing != UnresolvedMask)
{
return Existing;
}
#if UE_BUILD_SHIPPING
constexpr uint32 Resolved = 0;
#else
const uint32 Resolved = ResolveMask();
#endif
uint32 Expected = UnresolvedMask;
if (!GResolvedMask.compare_exchange_strong(
Expected, Resolved, std::memory_order_release, std::memory_order_acquire))
{
return Expected;
}
UE_LOG(LogTemp, Display,
TEXT("[VoxelForgeTunnelAblation] resolved mask=0x%08x "
"(world-changing development measurement overrides; 0=all on)"),
Resolved);
return Resolved;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,982 @@
#include "VoxelDensityProfile.h"
namespace VoxelDensityProfile
{
std::atomic<bool> GEnabled(false);
std::atomic<EMode> GMode(EMode::Disabled);
namespace
{
std::atomic<uint32> GSampleInterval(DefaultSampleInterval);
std::atomic<uint64> GTimerPairCycles(0);
// The old implementation performed a contended atomic fetch_add for every timer and
// counter. A TunnelNetwork sample can close twenty-plus ranges and add several
// candidate counters, so profiling changed the workload it was supposed to measure.
// Keep one accumulator per worker and only aggregate at Snapshot(). Commandlet resets
// happen between completed jobs, so the deliberately light-weight list is sufficient.
struct FThreadState
{
uint64 Calls[BucketCount]{};
uint64 Cycles[BucketCount]{};
uint64 Samples[BucketCount]{};
uint64 Counters[CounterCount]{};
uint32 SampleSequence[BucketCount]{};
uint32 BlockCallCount[BucketCount]{};
uint64 BlockStartCycles[BucketCount]{};
bool BlockActive[BucketCount]{};
FSnapshot::FClassifierStats Classifier;
uint64 TunnelCacheCapacityEntries = 0;
uint64 TunnelCacheValidEntries = 0;
uint64 TunnelCacheStaticBytes = 0;
uint64 TunnelCacheDynamicBytes = 0;
uint64 TunnelCacheEntryBytes = 0;
uint64 TunnelCacheLargestEntryBytes = 0;
uint64 TunnelCacheLargestWorkerValidEntries = 0;
FCacheMemoryBreakdown TunnelCacheBreakdown;
uint64 RoomGraphCacheCapacityEntries = 0;
uint64 RoomGraphCacheValidEntries = 0;
uint64 RoomGraphCacheStaticBytes = 0;
uint64 RoomGraphCacheDynamicBytes = 0;
uint64 RoomGraphCacheEntryBytes = 0;
uint64 RoomGraphCacheLargestEntryBytes = 0;
uint64 RoomGraphCacheLargestWorkerValidEntries = 0;
FCacheMemoryBreakdown RoomGraphCacheBreakdown;
std::atomic<FThreadState*> Next{nullptr};
};
std::atomic<FThreadState*> GThreadStates{nullptr};
thread_local FThreadState* GThreadState = nullptr;
thread_local uint32 GScopeDepth = 0;
thread_local bool GScopeSampled = false;
FThreadState& GetThreadState()
{
if (GThreadState == nullptr)
{
GThreadState = new FThreadState();
FThreadState* Head = GThreadStates.load(std::memory_order_relaxed);
do
{
GThreadState->Next.store(Head, std::memory_order_relaxed);
}
while (!GThreadStates.compare_exchange_weak(
Head, GThreadState,
std::memory_order_release,
std::memory_order_relaxed));
}
return *GThreadState;
}
int32 ToIndex(EBucket Bucket)
{
const int32 Index = static_cast<int32>(Bucket);
return (Index >= 0 && Index < BucketCount)
? Index
: static_cast<int32>(EBucket::OtherOp);
}
bool IsSampledScopeEnabled(EBucket Bucket)
{
// Sampled mode pays for the trustworthy parent and mesh containers only. Attribution
// additionally exposes the component scopes, but still samples them; Full remains the
// deliberately invasive mode for per-call scope timing.
switch (Bucket)
{
case EBucket::GetDensityAt:
case EBucket::MesherGenerateMesh:
case EBucket::MesherDensityGrid:
case EBucket::MesherOther:
return true;
default:
return false;
}
}
bool IsAttributionScope(EBucket Bucket)
{
switch (Bucket)
{
case EBucket::GetDensityAt:
case EBucket::ApplyDisturbances:
case EBucket::PassageModifier:
case EBucket::PassageLandingAir:
case EBucket::PassageLandingFloor:
case EBucket::PassageLandingRoomFloor:
case EBucket::PassageTunnelAir:
case EBucket::PassageStructuralPosts:
case EBucket::TunnelCoreSupport:
case EBucket::TunnelCoreSdf:
case EBucket::TunnelCorePosts:
case EBucket::StructuralTail:
case EBucket::RoomGraphSource:
case EBucket::SdfCarve:
case EBucket::FusedEvaluator:
case EBucket::InterpretedOpStack:
case EBucket::OperatorBlock:
case EBucket::RoomGraphBuild:
case EBucket::RoomGraphSdf:
case EBucket::TunnelCoreWorld:
case EBucket::FusedDetail:
case EBucket::FusedWorm:
case EBucket::FusedStructural:
case EBucket::ClassifierTotal:
case EBucket::ClassifierIntervalProof:
case EBucket::ClassifierExactCore:
case EBucket::ClassifierExactFinal:
case EBucket::ClassifierRoomTail:
case EBucket::RuntimeStreamBuilding:
case EBucket::DensityPrologue:
case EBucket::DensityCore:
case EBucket::DensityDisturbances:
case EBucket::DensityStructuralPosts:
case EBucket::DensityBoundarySeal:
case EBucket::DensityDiffLayer:
case EBucket::DensityTail:
case EBucket::MesherGenerateMesh:
case EBucket::MesherDensityGrid:
case EBucket::MesherCellClassification:
case EBucket::MesherGradientNormals:
case EBucket::MesherVertexInterpolation:
case EBucket::MesherStreamBuilding:
case EBucket::MesherOther:
return true;
default:
return false;
}
}
bool IsAttributionAlwaysTimed(EBucket Bucket)
{
switch (Bucket)
{
case EBucket::MesherGenerateMesh:
case EBucket::MesherDensityGrid:
case EBucket::MesherOther:
case EBucket::RoomGraphBuild:
return true;
default:
return false;
}
}
uint64 ScaleSampledValue(uint64 Value, uint64 Calls, uint64 Samples)
{
if (Samples == 0 || Calls <= Samples)
{
return Value;
}
const double Scaled = static_cast<double>(Value)
* static_cast<double>(Calls) / static_cast<double>(Samples);
return Scaled > static_cast<double>(MAX_uint64)
? MAX_uint64
: static_cast<uint64>(Scaled + 0.5);
}
void AddClassifierStatsScaled(
FSnapshot::FClassifierStats& Destination,
const FSnapshot::FClassifierStats& Source,
uint64 Calls,
uint64 Samples)
{
Destination.RefineNodes += ScaleSampledValue(Source.RefineNodes, Calls, Samples);
Destination.StackBoxCalls += ScaleSampledValue(Source.StackBoxCalls, Calls, Samples);
Destination.WholeMixedNodes += ScaleSampledValue(Source.WholeMixedNodes, Calls, Samples);
Destination.WholeSolidNodes += ScaleSampledValue(Source.WholeSolidNodes, Calls, Samples);
Destination.WholeAirNodes += ScaleSampledValue(Source.WholeAirNodes, Calls, Samples);
Destination.NeedsFinalFieldNodes += ScaleSampledValue(Source.NeedsFinalFieldNodes, Calls, Samples);
Destination.SplitNodes += ScaleSampledValue(Source.SplitNodes, Calls, Samples);
Destination.MaxRefinementDepth = FMath::Max(
Destination.MaxRefinementDepth, Source.MaxRefinementDepth);
Destination.ExactCoreSamples += ScaleSampledValue(Source.ExactCoreSamples, Calls, Samples);
Destination.ExactFinalSamples += ScaleSampledValue(Source.ExactFinalSamples, Calls, Samples);
Destination.ExactCoreCacheHits += ScaleSampledValue(Source.ExactCoreCacheHits, Calls, Samples);
Destination.ExactFinalCacheHits += ScaleSampledValue(Source.ExactFinalCacheHits, Calls, Samples);
Destination.ExactCoreLeaves += ScaleSampledValue(Source.ExactCoreLeaves, Calls, Samples);
Destination.ExactFinalLeaves += ScaleSampledValue(Source.ExactFinalLeaves, Calls, Samples);
Destination.StackBoxCycles += ScaleSampledValue(Source.StackBoxCycles, Calls, Samples);
Destination.ExactCoreCycles += ScaleSampledValue(Source.ExactCoreCycles, Calls, Samples);
Destination.ExactFinalCycles += ScaleSampledValue(Source.ExactFinalCycles, Calls, Samples);
Destination.RoomTailQueries += ScaleSampledValue(Source.RoomTailQueries, Calls, Samples);
Destination.RoomTailEvaluated += ScaleSampledValue(Source.RoomTailEvaluated, Calls, Samples);
Destination.RoomTailCycles += ScaleSampledValue(Source.RoomTailCycles, Calls, Samples);
Destination.RoomPropagateCycles += ScaleSampledValue(Source.RoomPropagateCycles, Calls, Samples);
Destination.RoomExactPrimitiveCycles += ScaleSampledValue(Source.RoomExactPrimitiveCycles, Calls, Samples);
Destination.RoomCacheWindowCycles += ScaleSampledValue(Source.RoomCacheWindowCycles, Calls, Samples);
// These are topology descriptors, not per-call work counts. Keep the largest observed
// descriptor instead of multiplying one sampled graph description by the classifier
// call expansion factor.
Destination.RoomNumRooms = FMath::Max(Destination.RoomNumRooms, Source.RoomNumRooms);
Destination.RoomNumTunnels = FMath::Max(Destination.RoomNumTunnels, Source.RoomNumTunnels);
Destination.RoomNumRoomFloorJoins = FMath::Max(
Destination.RoomNumRoomFloorJoins, Source.RoomNumRoomFloorJoins);
Destination.RoomNumPits = FMath::Max(Destination.RoomNumPits, Source.RoomNumPits);
Destination.RoomNumChimneys = FMath::Max(Destination.RoomNumChimneys, Source.RoomNumChimneys);
}
}
void SetEnabled(bool bEnabled)
{
SetMode(bEnabled ? EMode::Sampled : EMode::Disabled, DefaultSampleInterval);
}
void SetMode(EMode Mode, uint32 SampleInterval)
{
if (SampleInterval == 0)
{
SampleInterval = DefaultSampleInterval;
}
GSampleInterval.store(SampleInterval, std::memory_order_relaxed);
GMode.store(Mode, std::memory_order_relaxed);
GEnabled.store(Mode != EMode::Disabled, std::memory_order_relaxed);
if (Mode != EMode::Disabled && GTimerPairCycles.load(std::memory_order_relaxed) == 0)
{
CalibrateTimer();
}
}
bool IsEnabled()
{
return GEnabled.load(std::memory_order_relaxed);
}
bool IsCycleTimingEnabled()
{
return GMode.load(std::memory_order_relaxed) != EMode::Disabled;
}
EMode GetMode()
{
return GMode.load(std::memory_order_relaxed);
}
uint32 GetSampleInterval()
{
return GSampleInterval.load(std::memory_order_relaxed);
}
uint64 GetTimerPairCycles()
{
return GTimerPairCycles.load(std::memory_order_relaxed);
}
void CalibrateTimer()
{
constexpr uint32 Iterations = 100000;
volatile uint64 Sink = 0;
const uint64 Start = FPlatformTime::Cycles64();
for (uint32 Index = 0; Index < Iterations; ++Index)
{
const uint64 A = FPlatformTime::Cycles64();
const uint64 B = FPlatformTime::Cycles64();
Sink ^= (B - A);
}
const uint64 End = FPlatformTime::Cycles64();
const uint64 Total = End - Start;
const uint64 PairCycles = Total > 0
? FMath::Max<uint64>(1, Total / Iterations)
: 1;
GTimerPairCycles.store(PairCycles, std::memory_order_relaxed);
(void)Sink;
}
void Reset()
{
for (FThreadState* State = GThreadStates.load(std::memory_order_acquire);
State != nullptr;
State = State->Next.load(std::memory_order_acquire))
{
for (int32 Index = 0; Index < BucketCount; ++Index)
{
State->Calls[Index] = 0;
State->Cycles[Index] = 0;
State->Samples[Index] = 0;
}
for (int32 Index = 0; Index < CounterCount; ++Index)
{
State->Counters[Index] = 0;
}
for (int32 Index = 0; Index < BucketCount; ++Index)
{
State->SampleSequence[Index] = 0;
State->BlockCallCount[Index] = 0;
State->BlockStartCycles[Index] = 0;
State->BlockActive[Index] = false;
}
State->Classifier = FSnapshot::FClassifierStats();
State->TunnelCacheCapacityEntries = 0;
State->TunnelCacheValidEntries = 0;
State->TunnelCacheStaticBytes = 0;
State->TunnelCacheDynamicBytes = 0;
State->TunnelCacheEntryBytes = 0;
State->TunnelCacheLargestEntryBytes = 0;
State->TunnelCacheBreakdown = FCacheMemoryBreakdown();
State->RoomGraphCacheCapacityEntries = 0;
State->RoomGraphCacheValidEntries = 0;
State->RoomGraphCacheStaticBytes = 0;
State->RoomGraphCacheDynamicBytes = 0;
State->RoomGraphCacheEntryBytes = 0;
State->RoomGraphCacheLargestEntryBytes = 0;
State->RoomGraphCacheBreakdown = FCacheMemoryBreakdown();
}
GScopeDepth = 0;
GScopeSampled = false;
GSampledScopeActive = false;
}
FSnapshot Snapshot()
{
FSnapshot Result;
for (FThreadState* State = GThreadStates.load(std::memory_order_acquire);
State != nullptr;
State = State->Next.load(std::memory_order_acquire))
{
for (int32 Index = 0; Index < BucketCount; ++Index)
{
Result.Calls[Index] += State->Calls[Index];
Result.Samples[Index] += State->Samples[Index];
if (State->Samples[Index] == 0)
{
Result.Cycles[Index] += State->Cycles[Index];
}
else
{
// Store raw sampled cycles in the worker-local state. Estimate the bucket
// from its observed call/sample ratio at aggregation time. This matters for
// small buckets: multiplying every sample by 64 would overstate a bucket with
// only eight calls where all eight were sampled.
const double Scale = static_cast<double>(State->Calls[Index])
/ static_cast<double>(State->Samples[Index]);
const double EstimatedCycles = static_cast<double>(State->Cycles[Index]) * Scale;
Result.Cycles[Index] += EstimatedCycles > static_cast<double>(MAX_uint64)
? MAX_uint64
: static_cast<uint64>(EstimatedCycles + 0.5);
}
if (State->Calls[Index] > 0)
{
++Result.ActiveWorkers[Index];
const double EstimatedWorkerCycles = State->Samples[Index] == 0
? static_cast<double>(State->Cycles[Index])
: static_cast<double>(State->Cycles[Index])
* static_cast<double>(State->Calls[Index])
/ static_cast<double>(State->Samples[Index]);
Result.WallCycles[Index] = FMath::Max(
Result.WallCycles[Index],
EstimatedWorkerCycles > static_cast<double>(MAX_uint64)
? MAX_uint64
: static_cast<uint64>(EstimatedWorkerCycles + 0.5));
}
}
for (int32 Index = 0; Index < CounterCount; ++Index)
{
Result.Counters[Index] += State->Counters[Index];
}
Result.Classifier.Calls += State->Classifier.Calls;
Result.Classifier.SampledCalls += State->Classifier.SampledCalls;
AddClassifierStatsScaled(
Result.Classifier,
State->Classifier,
State->Classifier.Calls,
State->Classifier.SampledCalls);
const uint64 TunnelWorkerBytes = State->TunnelCacheStaticBytes
+ State->TunnelCacheDynamicBytes;
if (State->TunnelCacheCapacityEntries > 0)
{
++Result.TunnelCacheWorkers;
Result.TunnelCacheCapacityEntries += State->TunnelCacheCapacityEntries;
Result.TunnelCacheValidEntries += State->TunnelCacheValidEntries;
Result.TunnelCacheStaticBytes += State->TunnelCacheStaticBytes;
Result.TunnelCacheDynamicBytes += State->TunnelCacheDynamicBytes;
Result.TunnelCacheEntryBytes += State->TunnelCacheEntryBytes;
Result.TunnelCacheLargestEntryBytes = FMath::Max(
Result.TunnelCacheLargestEntryBytes,
State->TunnelCacheLargestEntryBytes);
Result.TunnelCacheLargestWorkerValidEntries = FMath::Max(
Result.TunnelCacheLargestWorkerValidEntries,
State->TunnelCacheValidEntries);
Result.TunnelCacheLargestWorkerBytes = FMath::Max(
Result.TunnelCacheLargestWorkerBytes, TunnelWorkerBytes);
Result.TunnelCacheBreakdown += State->TunnelCacheBreakdown;
}
const uint64 RoomGraphWorkerBytes = State->RoomGraphCacheStaticBytes
+ State->RoomGraphCacheDynamicBytes;
if (State->RoomGraphCacheCapacityEntries > 0)
{
++Result.RoomGraphCacheWorkers;
Result.RoomGraphCacheCapacityEntries += State->RoomGraphCacheCapacityEntries;
Result.RoomGraphCacheValidEntries += State->RoomGraphCacheValidEntries;
Result.RoomGraphCacheStaticBytes += State->RoomGraphCacheStaticBytes;
Result.RoomGraphCacheDynamicBytes += State->RoomGraphCacheDynamicBytes;
Result.RoomGraphCacheEntryBytes += State->RoomGraphCacheEntryBytes;
Result.RoomGraphCacheLargestEntryBytes = FMath::Max(
Result.RoomGraphCacheLargestEntryBytes,
State->RoomGraphCacheLargestEntryBytes);
Result.RoomGraphCacheLargestWorkerValidEntries = FMath::Max(
Result.RoomGraphCacheLargestWorkerValidEntries,
State->RoomGraphCacheValidEntries);
Result.RoomGraphCacheLargestWorkerBytes = FMath::Max(
Result.RoomGraphCacheLargestWorkerBytes, RoomGraphWorkerBytes);
Result.RoomGraphCacheBreakdown += State->RoomGraphCacheBreakdown;
}
}
Result.TimerPairCycles = GetTimerPairCycles();
Result.SampleInterval = GetSampleInterval();
Result.Mode = GetMode();
return Result;
}
FSnapshot SnapshotCurrentThread()
{
FSnapshot Result;
FThreadState* State = GThreadState;
if (State == nullptr)
{
Result.TimerPairCycles = GetTimerPairCycles();
Result.SampleInterval = GetSampleInterval();
Result.Mode = GetMode();
return Result;
}
for (int32 Index = 0; Index < BucketCount; ++Index)
{
Result.Calls[Index] = State->Calls[Index];
Result.Samples[Index] = State->Samples[Index];
if (State->Samples[Index] == 0)
{
Result.Cycles[Index] = State->Cycles[Index];
}
else
{
const double Scale = static_cast<double>(State->Calls[Index])
/ static_cast<double>(State->Samples[Index]);
const double EstimatedCycles = static_cast<double>(State->Cycles[Index]) * Scale;
Result.Cycles[Index] = EstimatedCycles > static_cast<double>(MAX_uint64)
? MAX_uint64
: static_cast<uint64>(EstimatedCycles + 0.5);
}
Result.WallCycles[Index] = Result.Cycles[Index];
Result.ActiveWorkers[Index] = State->Calls[Index] > 0 ? 1 : 0;
}
for (int32 Index = 0; Index < CounterCount; ++Index)
{
Result.Counters[Index] = State->Counters[Index];
}
Result.Classifier = State->Classifier;
if (State->Classifier.SampledCalls > 0)
{
FSnapshot::FClassifierStats Raw = State->Classifier;
Result.Classifier = FSnapshot::FClassifierStats();
Result.Classifier.Calls = State->Classifier.Calls;
Result.Classifier.SampledCalls = State->Classifier.SampledCalls;
AddClassifierStatsScaled(
Result.Classifier,
Raw,
State->Classifier.Calls,
State->Classifier.SampledCalls);
}
Result.TimerPairCycles = GetTimerPairCycles();
Result.SampleInterval = GetSampleInterval();
Result.Mode = GetMode();
return Result;
}
void AddCounter(ECounter Counter, uint64 Amount)
{
if (!IsEnabledFast())
{
return;
}
const int32 Index = static_cast<int32>(Counter);
if (Index >= 0 && Index < CounterCount)
{
GetThreadState().Counters[Index] += Amount;
}
}
void AddMeasurement(EBucket Bucket, uint64 Cycles, uint64 Calls)
{
const int32 Index = ToIndex(Bucket);
FThreadState& State = GetThreadState();
State.Calls[Index] += Calls;
State.Cycles[Index] += Cycles;
}
void RecordCall(EBucket Bucket)
{
const int32 Index = ToIndex(Bucket);
GetThreadState().Calls[Index] += 1;
}
void RecordClassifierCall(bool bSampled)
{
FThreadState& State = GetThreadState();
++State.Classifier.Calls;
if (bSampled)
{
++State.Classifier.SampledCalls;
}
++State.Calls[ToIndex(EBucket::ClassifierTotal)];
}
void AddClassifierStats(const FSnapshot::FClassifierStats& Stats)
{
FThreadState& State = GetThreadState();
State.Classifier.RefineNodes += Stats.RefineNodes;
State.Classifier.StackBoxCalls += Stats.StackBoxCalls;
State.Classifier.WholeMixedNodes += Stats.WholeMixedNodes;
State.Classifier.WholeSolidNodes += Stats.WholeSolidNodes;
State.Classifier.WholeAirNodes += Stats.WholeAirNodes;
State.Classifier.NeedsFinalFieldNodes += Stats.NeedsFinalFieldNodes;
State.Classifier.SplitNodes += Stats.SplitNodes;
State.Classifier.MaxRefinementDepth = FMath::Max(
State.Classifier.MaxRefinementDepth, Stats.MaxRefinementDepth);
State.Classifier.ExactCoreSamples += Stats.ExactCoreSamples;
State.Classifier.ExactFinalSamples += Stats.ExactFinalSamples;
State.Classifier.ExactCoreCacheHits += Stats.ExactCoreCacheHits;
State.Classifier.ExactFinalCacheHits += Stats.ExactFinalCacheHits;
State.Classifier.ExactCoreLeaves += Stats.ExactCoreLeaves;
State.Classifier.ExactFinalLeaves += Stats.ExactFinalLeaves;
State.Classifier.StackBoxCycles += Stats.StackBoxCycles;
State.Classifier.ExactCoreCycles += Stats.ExactCoreCycles;
State.Classifier.ExactFinalCycles += Stats.ExactFinalCycles;
State.Classifier.RoomTailQueries += Stats.RoomTailQueries;
State.Classifier.RoomTailEvaluated += Stats.RoomTailEvaluated;
State.Classifier.RoomTailCycles += Stats.RoomTailCycles;
State.Classifier.RoomPropagateCycles += Stats.RoomPropagateCycles;
State.Classifier.RoomExactPrimitiveCycles += Stats.RoomExactPrimitiveCycles;
State.Classifier.RoomCacheWindowCycles += Stats.RoomCacheWindowCycles;
State.Classifier.RoomNumRooms = FMath::Max(
State.Classifier.RoomNumRooms, Stats.RoomNumRooms);
State.Classifier.RoomNumTunnels = FMath::Max(
State.Classifier.RoomNumTunnels, Stats.RoomNumTunnels);
State.Classifier.RoomNumRoomFloorJoins = FMath::Max(
State.Classifier.RoomNumRoomFloorJoins, Stats.RoomNumRoomFloorJoins);
State.Classifier.RoomNumPits = FMath::Max(
State.Classifier.RoomNumPits, Stats.RoomNumPits);
State.Classifier.RoomNumChimneys = FMath::Max(
State.Classifier.RoomNumChimneys, Stats.RoomNumChimneys);
}
bool ShouldSample(EBucket Bucket)
{
if (!IsCycleTimingEnabled())
{
return false;
}
if (GetMode() == EMode::Full)
{
return true;
}
FThreadState& State = GetThreadState();
const int32 Index = ToIndex(Bucket);
const uint32 Sequence = State.SampleSequence[Index]++;
const uint32 Interval = GetSampleInterval();
if ((Interval & (Interval - 1u)) == 0u)
{
return (Sequence & (Interval - 1u)) == 0u;
}
return (Sequence % Interval) == 0u;
}
void AddSampledMeasurement(EBucket Bucket, uint64 RawCycles)
{
const int32 Index = ToIndex(Bucket);
FThreadState& State = GetThreadState();
const uint64 TimerCost = GetTimerPairCycles();
const uint64 AdjustedCycles = RawCycles > TimerCost
? RawCycles - TimerCost
: 0;
State.Cycles[Index] += AdjustedCycles;
State.Samples[Index] += 1;
}
void SetWorkerTunnelCacheFootprint(
uint64 CapacityEntries, uint64 ValidEntries,
uint64 StaticBytes, uint64 DynamicBytes,
uint64 EntryBytes, uint64 LargestEntryBytes,
uint64 LargestWorkerValidEntries,
const FCacheMemoryBreakdown& Breakdown)
{
if (!IsEnabled()) { return; }
FThreadState& State = GetThreadState();
State.TunnelCacheCapacityEntries = CapacityEntries;
State.TunnelCacheValidEntries = ValidEntries;
State.TunnelCacheStaticBytes = StaticBytes;
State.TunnelCacheDynamicBytes = DynamicBytes;
State.TunnelCacheEntryBytes = EntryBytes;
State.TunnelCacheLargestEntryBytes = LargestEntryBytes;
State.TunnelCacheLargestWorkerValidEntries = LargestWorkerValidEntries;
State.TunnelCacheBreakdown = Breakdown;
}
void SetWorkerRoomGraphCacheFootprint(
uint64 CapacityEntries, uint64 ValidEntries,
uint64 StaticBytes, uint64 DynamicBytes,
uint64 EntryBytes, uint64 LargestEntryBytes,
uint64 LargestWorkerValidEntries,
const FCacheMemoryBreakdown& Breakdown)
{
if (!IsEnabled()) { return; }
FThreadState& State = GetThreadState();
State.RoomGraphCacheCapacityEntries = CapacityEntries;
State.RoomGraphCacheValidEntries = ValidEntries;
State.RoomGraphCacheStaticBytes = StaticBytes;
State.RoomGraphCacheDynamicBytes = DynamicBytes;
State.RoomGraphCacheEntryBytes = EntryBytes;
State.RoomGraphCacheLargestEntryBytes = LargestEntryBytes;
State.RoomGraphCacheLargestWorkerValidEntries = LargestWorkerValidEntries;
State.RoomGraphCacheBreakdown = Breakdown;
}
const TCHAR* CounterName(ECounter Counter)
{
switch (Counter)
{
case ECounter::TunnelCacheLookup: return TEXT("TunnelCacheLookup");
case ECounter::TunnelCacheHit: return TEXT("TunnelCacheHit");
case ECounter::TunnelCacheMiss: return TEXT("TunnelCacheMiss");
case ECounter::SdfCacheBuild: return TEXT("SdfCacheBuild");
case ECounter::RoomGraphBuildGeneratorTile: return TEXT("RoomGraphBuildGeneratorTile");
case ECounter::RoomGraphBuildGeneratorTunnelCore: return TEXT("RoomGraphBuildGeneratorTunnelCore");
case ECounter::RoomGraphBuildOpShared: return TEXT("RoomGraphBuildOpShared");
case ECounter::RoomGraphBuildOpLocal: return TEXT("RoomGraphBuildOpLocal");
case ECounter::RoomGraphBuildClassifierShared: return TEXT("RoomGraphBuildClassifierShared");
case ECounter::RoomGraphBuildClassifierLocal: return TEXT("RoomGraphBuildClassifierLocal");
case ECounter::RoomGraphBuildUnknown: return TEXT("RoomGraphBuildUnknown");
case ECounter::CaveRoomCandidates:return TEXT("CaveRoomCandidates");
case ECounter::CaveRoomEvaluated: return TEXT("CaveRoomEvaluated");
case ECounter::CaveTunnelCandidates:return TEXT("CaveTunnelCandidates");
case ECounter::CaveTunnelEvaluated:return TEXT("CaveTunnelEvaluated");
case ECounter::TunnelCoreCandidates:return TEXT("TunnelCoreCandidates");
case ECounter::TunnelCoreEvaluated:return TEXT("TunnelCoreEvaluated");
case ECounter::TunnelSupportColumnBuilds:return TEXT("TunnelSupportColumnBuilds");
case ECounter::TunnelSupportColumnCandidates:return TEXT("TunnelSupportColumnCandidates");
case ECounter::TunnelSupportFloorQueries:return TEXT("TunnelSupportFloorQueries");
case ECounter::TunnelSupportFloorChecks:return TEXT("TunnelSupportFloorChecks");
case ECounter::TunnelSupportFloorBackstopFires:return TEXT("TunnelSupportFloorBackstopFires");
case ECounter::TunnelRoomFloorBackstopFires:return TEXT("TunnelRoomFloorBackstopFires");
case ECounter::TunnelAuthoredFloorSamples:return TEXT("TunnelAuthoredFloorSamples");
case ECounter::TunnelAuthoredFloorProtectionClamps:return TEXT("TunnelAuthoredFloorProtectionClamps");
case ECounter::TunnelAuthoredFloorDisturbanceSkips:return TEXT("TunnelAuthoredFloorDisturbanceSkips");
case ECounter::TunnelFloorProfileFallbacks:return TEXT("TunnelFloorProfileFallbacks");
case ECounter::TunnelFloorProfileBuilds:return TEXT("TunnelFloorProfileBuilds");
case ECounter::TunnelFloorProfileSegments:return TEXT("TunnelFloorProfileSegments");
case ECounter::TunnelFloorProfileLedges:return TEXT("TunnelFloorProfileLedges");
case ECounter::TunnelFloorProfileLedgesBelow2Voxels:return TEXT("TunnelFloorProfileLedgesBelow2Voxels");
case ECounter::TunnelFloorProfileLedges2To4Voxels:return TEXT("TunnelFloorProfileLedges2To4Voxels");
case ECounter::TunnelFloorProfileLedges4To8Voxels:return TEXT("TunnelFloorProfileLedges4To8Voxels");
case ECounter::TunnelFloorProfileLedges8PlusVoxels:return TEXT("TunnelFloorProfileLedges8PlusVoxels");
case ECounter::TunnelFloorSteepEdges:return TEXT("TunnelFloorSteepEdges");
case ECounter::TunnelFloorWindingEdges:return TEXT("TunnelFloorWindingEdges");
case ECounter::TunnelFloorDropEdges:return TEXT("TunnelFloorDropEdges");
case ECounter::TunnelFloorMultiStepEdges:return TEXT("TunnelFloorMultiStepEdges");
case ECounter::PassageSupportFloorBackstopFires:return TEXT("PassageSupportFloorBackstopFires");
case ECounter::PassageNativeFloorCompositions:return TEXT("PassageNativeFloorCompositions");
case ECounter::PassageCandidates: return TEXT("PassageCandidates");
case ECounter::PassageEvaluated: return TEXT("PassageEvaluated");
case ECounter::MesherBlockTests: return TEXT("MesherBlockTests");
case ECounter::MesherBlockAllSolid:return TEXT("MesherBlockAllSolid");
case ECounter::MesherBlockAllAir: return TEXT("MesherBlockAllAir");
case ECounter::TileClassifyCalls: return TEXT("TileClassifyCalls");
case ECounter::TileClassifyAllSolid: return TEXT("TileClassifyAllSolid");
case ECounter::TileClassifyAllAir: return TEXT("TileClassifyAllAir");
case ECounter::TileClassifyMixed: return TEXT("TileClassifyMixed");
case ECounter::FusedTunnelSamples: return TEXT("FusedTunnelSamples");
case ECounter::FusedTunnelDetailSamples: return TEXT("FusedTunnelDetailSamples");
case ECounter::FusedTunnelDetailSkipped: return TEXT("FusedTunnelDetailSkipped");
case ECounter::WormEligibleSamples: return TEXT("WormEligibleSamples");
case ECounter::WormBlockProofs: return TEXT("WormBlockProofs");
case ECounter::WormBlockSkippedSamples: return TEXT("WormBlockSkippedSamples");
case ECounter::OpBlockBuilds: return TEXT("OpBlockBuilds");
case ECounter::OpBlockSamples: return TEXT("OpBlockSamples");
case ECounter::OpBlockOperators: return TEXT("OpBlockOperators");
case ECounter::OpBlockActiveOperators: return TEXT("OpBlockActiveOperators");
case ECounter::OpBlockPrunedOperators: return TEXT("OpBlockPrunedOperators");
case ECounter::MesherDensityGridBytes: return TEXT("MesherDensityGridBytes");
case ECounter::MesherSharedGridReadBytes: return TEXT("MesherSharedGridReadBytes");
case ECounter::MesherOutputArrayBytes: return TEXT("MesherOutputArrayBytes");
case ECounter::MesherOutputAllocatedBytes: return TEXT("MesherOutputAllocatedBytes");
case ECounter::OpBlockScratchBytes: return TEXT("OpBlockScratchBytes");
case ECounter::OpBlockCopyBytes: return TEXT("OpBlockCopyBytes");
case ECounter::Count: break;
}
return TEXT("Unknown");
}
EBucket BucketFromName(const TCHAR* Name)
{
if (Name == nullptr) { return EBucket::OtherOp; }
struct FNameBucket
{
const TCHAR* Name;
EBucket Bucket;
};
static const FNameBucket Names[] =
{
{ TEXT("ApplyDisturbances"), EBucket::ApplyDisturbances },
{ TEXT("PassageModifier"), EBucket::PassageModifier },
{ TEXT("PassageLandingAir"), EBucket::PassageLandingAir },
{ TEXT("PassageLandingFloor"), EBucket::PassageLandingFloor },
{ TEXT("PassageLandingRoomFloor"), EBucket::PassageLandingRoomFloor },
{ TEXT("PassageTunnelAir"), EBucket::PassageTunnelAir },
{ TEXT("PassageStructuralPosts"), EBucket::PassageStructuralPosts },
{ TEXT("TunnelCoreSupport"), EBucket::TunnelCoreSupport },
{ TEXT("TunnelCoreSdf"), EBucket::TunnelCoreSdf },
{ TEXT("TunnelCorePosts"), EBucket::TunnelCorePosts },
{ TEXT("StructuralTail"), EBucket::StructuralTail },
{ TEXT("RoomGraphSource"), EBucket::RoomGraphSource },
{ TEXT("SdfCarve"), EBucket::SdfCarve },
{ TEXT("SdfConvertOp"), EBucket::SdfCarve },
{ TEXT("CaveRoughnessMod"), EBucket::CaveRoughnessMod },
{ TEXT("CaveTerraceMod"), EBucket::CaveTerraceMod },
{ TEXT("LayerLineMod"), EBucket::LayerLineMod },
{ TEXT("RibbingMod"), EBucket::RibbingMod },
{ TEXT("CaveOverhangMod"), EBucket::CaveOverhangMod },
{ TEXT("CaveCliffMod"), EBucket::CaveCliffMod },
{ TEXT("ScallopMod"), EBucket::ScallopMod },
{ TEXT("CaveArchMod"), EBucket::CaveArchMod },
{ TEXT("RoomColumnMod"), EBucket::RoomColumnMod },
{ TEXT("DomeMod"), EBucket::DomeMod },
{ TEXT("PinchMod"), EBucket::PinchMod },
{ TEXT("FloorBiasMod"), EBucket::FloorBiasMod },
{ TEXT("WormFieldSource"), EBucket::WormFieldSource },
{ TEXT("OriginSpineOp"), EBucket::OriginSpineOp },
{ TEXT("BoundarySealOp"), EBucket::BoundarySealOp },
{ TEXT("PassageCarveOp"), EBucket::PassageCarveOp },
{ TEXT("XYEdgeSealOp"), EBucket::XYEdgeSealOp },
{ TEXT("FusedEvaluator"), EBucket::FusedEvaluator },
{ TEXT("InterpretedOpStack"),EBucket::InterpretedOpStack },
{ TEXT("OperatorBlock"), EBucket::OperatorBlock },
{ TEXT("RoomGraphBuild"), EBucket::RoomGraphBuild },
{ TEXT("RoomGraphSdf"), EBucket::RoomGraphSdf },
{ TEXT("TunnelCoreWorld"), EBucket::TunnelCoreWorld },
{ TEXT("FusedDetail"), EBucket::FusedDetail },
{ TEXT("FusedWorm"), EBucket::FusedWorm },
{ TEXT("FusedStructural"), EBucket::FusedStructural },
{ TEXT("ClassifierTotal"), EBucket::ClassifierTotal },
{ TEXT("ClassifierIntervalProof"), EBucket::ClassifierIntervalProof },
{ TEXT("ClassifierExactCore"), EBucket::ClassifierExactCore },
{ TEXT("ClassifierExactFinal"), EBucket::ClassifierExactFinal },
{ TEXT("ClassifierRoomTail"), EBucket::ClassifierRoomTail },
{ TEXT("RuntimeStreamBuilding"), EBucket::RuntimeStreamBuilding },
{ TEXT("DensityPrologue"), EBucket::DensityPrologue },
{ TEXT("DensityCore"), EBucket::DensityCore },
{ TEXT("DensityDisturbances"), EBucket::DensityDisturbances },
{ TEXT("DensityStructuralPosts"), EBucket::DensityStructuralPosts },
{ TEXT("DensityBoundarySeal"), EBucket::DensityBoundarySeal },
{ TEXT("DensityDiffLayer"), EBucket::DensityDiffLayer },
{ TEXT("DensityTail"), EBucket::DensityTail },
{ TEXT("MesherGenerateMesh"), EBucket::MesherGenerateMesh },
{ TEXT("MesherDensityGrid"), EBucket::MesherDensityGrid },
{ TEXT("MesherCellClassification"), EBucket::MesherCellClassification },
{ TEXT("MesherGradientNormals"), EBucket::MesherGradientNormals },
{ TEXT("MesherVertexInterpolation"), EBucket::MesherVertexInterpolation },
{ TEXT("MesherStreamBuilding"), EBucket::MesherStreamBuilding },
{ TEXT("MesherOther"), EBucket::MesherOther },
};
for (const FNameBucket& Entry : Names)
{
if (FCString::Strcmp(Name, Entry.Name) == 0)
{
return Entry.Bucket;
}
}
return EBucket::OtherOp;
}
const TCHAR* BucketName(EBucket Bucket)
{
switch (Bucket)
{
case EBucket::GetDensityAt: return TEXT("GetDensityAt");
case EBucket::ApplyDisturbances:return TEXT("ApplyDisturbances");
case EBucket::PassageModifier: return TEXT("PassageModifier");
case EBucket::PassageLandingAir:return TEXT("PassageLandingAir");
case EBucket::PassageLandingFloor:return TEXT("PassageLandingFloor");
case EBucket::PassageLandingRoomFloor:return TEXT("PassageLandingRoomFloor");
case EBucket::PassageTunnelAir:return TEXT("PassageTunnelAir");
case EBucket::PassageStructuralPosts:return TEXT("PassageStructuralPosts");
case EBucket::TunnelCoreSupport:return TEXT("TunnelCoreSupport");
case EBucket::TunnelCoreSdf: return TEXT("TunnelCoreSdf");
case EBucket::TunnelCorePosts: return TEXT("TunnelCorePosts");
case EBucket::StructuralTail: return TEXT("StructuralTail");
case EBucket::RoomGraphSource: return TEXT("RoomGraphSource");
case EBucket::SdfCarve: return TEXT("SdfCarve");
case EBucket::CaveRoughnessMod: return TEXT("CaveRoughnessMod");
case EBucket::CaveTerraceMod: return TEXT("CaveTerraceMod");
case EBucket::LayerLineMod: return TEXT("LayerLineMod");
case EBucket::RibbingMod: return TEXT("RibbingMod");
case EBucket::CaveOverhangMod: return TEXT("CaveOverhangMod");
case EBucket::CaveCliffMod: return TEXT("CaveCliffMod");
case EBucket::ScallopMod: return TEXT("ScallopMod");
case EBucket::CaveArchMod: return TEXT("CaveArchMod");
case EBucket::RoomColumnMod: return TEXT("RoomColumnMod");
case EBucket::DomeMod: return TEXT("DomeMod");
case EBucket::PinchMod: return TEXT("PinchMod");
case EBucket::FloorBiasMod: return TEXT("FloorBiasMod");
case EBucket::WormFieldSource: return TEXT("WormFieldSource");
case EBucket::OriginSpineOp: return TEXT("OriginSpineOp");
case EBucket::BoundarySealOp: return TEXT("BoundarySealOp");
case EBucket::PassageCarveOp: return TEXT("PassageCarveOp");
case EBucket::XYEdgeSealOp: return TEXT("XYEdgeSealOp");
case EBucket::OtherOp: return TEXT("OtherOp");
case EBucket::FusedEvaluator: return TEXT("FusedEvaluator");
case EBucket::InterpretedOpStack:return TEXT("InterpretedOpStack");
case EBucket::OperatorBlock: return TEXT("OperatorBlock");
case EBucket::RoomGraphBuild: return TEXT("RoomGraphBuild");
case EBucket::RoomGraphSdf: return TEXT("RoomGraphSdf");
case EBucket::TunnelCoreWorld: return TEXT("TunnelCoreWorld");
case EBucket::FusedDetail: return TEXT("FusedDetail");
case EBucket::FusedWorm: return TEXT("FusedWorm");
case EBucket::FusedStructural: return TEXT("FusedStructural");
case EBucket::ClassifierTotal: return TEXT("ClassifierTotal");
case EBucket::ClassifierIntervalProof:return TEXT("ClassifierIntervalProof");
case EBucket::ClassifierExactCore:return TEXT("ClassifierExactCore");
case EBucket::ClassifierExactFinal:return TEXT("ClassifierExactFinal");
case EBucket::ClassifierRoomTail:return TEXT("ClassifierRoomTail");
case EBucket::RuntimeStreamBuilding:return TEXT("RuntimeStreamBuilding");
case EBucket::DensityPrologue: return TEXT("DensityPrologue");
case EBucket::DensityCore: return TEXT("DensityCore");
case EBucket::DensityDisturbances:return TEXT("DensityDisturbances");
case EBucket::DensityStructuralPosts:return TEXT("DensityStructuralPosts");
case EBucket::DensityBoundarySeal:return TEXT("DensityBoundarySeal");
case EBucket::DensityDiffLayer: return TEXT("DensityDiffLayer");
case EBucket::DensityTail: return TEXT("DensityTail");
case EBucket::MesherGenerateMesh:return TEXT("MesherGenerateMesh");
case EBucket::MesherDensityGrid:return TEXT("MesherDensityGrid");
case EBucket::MesherCellClassification:return TEXT("MesherCellClassification");
case EBucket::MesherGradientNormals:return TEXT("MesherGradientNormals");
case EBucket::MesherVertexInterpolation:return TEXT("MesherVertexInterpolation");
case EBucket::MesherStreamBuilding:return TEXT("MesherStreamBuilding");
case EBucket::MesherOther: return TEXT("MesherOther");
case EBucket::Count: break;
}
return TEXT("Unknown");
}
FScopeToken BeginScope(EBucket Bucket)
{
FScopeToken Token{};
if (!IsEnabledFast())
{
return Token;
}
const EMode Mode = GetMode();
const bool bSampledMode = Mode == EMode::Sampled || Mode == EMode::Attribution;
if ((Mode == EMode::Sampled && !IsSampledScopeEnabled(Bucket))
|| (Mode == EMode::Attribution && !IsAttributionScope(Bucket)))
{
return Token;
}
Token.bEntered = true;
Token.bPreviousSampled = GScopeSampled;
Token.bPreviousSampledScopeActive = GSampledScopeActive;
++GScopeDepth;
// For the hot parent, amortise the timer over a deterministic block of calls. A timer
// pair around one ~100 ns operation is mostly the timer; a pair around 64 calls is cheap
// and estimates the parent from real elapsed work rather than instrumentation latency.
if (bSampledMode && Bucket == EBucket::GetDensityAt)
{
FThreadState& State = GetThreadState();
const int32 Index = ToIndex(Bucket);
const uint32 Sequence = State.SampleSequence[Index]++;
Token.bBlockTiming = true;
const uint32 Interval = GetSampleInterval();
const bool bBlockBoundary = (Interval & (Interval - 1u)) == 0u
? (Sequence & (Interval - 1u)) == 0u
: (Sequence % Interval) == 0u;
if (bBlockBoundary && !State.BlockActive[Index])
{
State.BlockActive[Index] = true;
State.BlockCallCount[Index] = 0;
State.BlockStartCycles[Index] = FPlatformTime::Cycles64();
}
// The selected call admits all nested component scopes. Every other call exits the
// inline FScopedTimer fast path before crossing into this translation unit.
GScopeSampled = bBlockBoundary;
GSampledScopeActive = bBlockBoundary;
Token.bSampled = bBlockBoundary;
return Token;
}
// Every bucket samples independently, but only one nested non-container scope owns a
// timer at a time. Otherwise a sampled GetDensityAt would also start timers for every
// phase and operation inside that same call, measuring instrumentation instead of work.
// Mesher container ranges deliberately remain transparent so their grid can still expose
// density samples underneath them.
const bool bContainerScope = Bucket == EBucket::MesherGenerateMesh
|| Bucket == EBucket::MesherDensityGrid;
const bool bInheritedAttributionSample = Mode == EMode::Attribution
&& GSampledScopeActive;
Token.bSampled = Mode == EMode::Full
? true
: ((Mode == EMode::Attribution && IsAttributionAlwaysTimed(Bucket))
? true
: (bInheritedAttributionSample
? true
: (Mode == EMode::Attribution && IsAttributionScope(Bucket)
? ShouldSample(Bucket)
: (!GSampledScopeActive && ShouldSample(Bucket)))));
GScopeSampled = Token.bSampled;
if (Token.bSampled)
{
if ((Mode == EMode::Sampled || Mode == EMode::Attribution) && !bContainerScope)
{
GSampledScopeActive = true;
}
Token.StartCycles = FPlatformTime::Cycles64();
}
return Token;
}
void EndScope(EBucket Bucket, const FScopeToken& Token)
{
if (!Token.bEntered)
{
return;
}
RecordCall(Bucket);
if (Token.bBlockTiming)
{
FThreadState& State = GetThreadState();
const int32 Index = ToIndex(Bucket);
++State.BlockCallCount[Index];
const uint32 Interval = GetSampleInterval();
if (State.BlockActive[Index] && State.BlockCallCount[Index] >= Interval)
{
const uint64 RawCycles = FPlatformTime::Cycles64() - State.BlockStartCycles[Index];
const uint64 TimerCost = GetTimerPairCycles();
const uint64 AdjustedCycles = RawCycles > TimerCost
? RawCycles - TimerCost
: 0;
State.Cycles[Index] += AdjustedCycles / FMath::Max<uint32>(1, Interval);
State.Samples[Index] += 1;
State.BlockActive[Index] = false;
State.BlockCallCount[Index] = 0;
State.BlockStartCycles[Index] = 0;
}
}
else if (Token.bSampled)
{
AddSampledMeasurement(Bucket, FPlatformTime::Cycles64() - Token.StartCycles);
}
check(GScopeDepth > 0);
--GScopeDepth;
GScopeSampled = Token.bPreviousSampled;
GSampledScopeActive = Token.bPreviousSampledScopeActive;
}
}
@@ -71,6 +71,7 @@ void UVoxelDensityVolume::Initialize(AActor* InOwner, UVoxelGenerator* InGenerat
Generator = InGenerator;
Settings = InSettings;
bShuttingDown.store(false, std::memory_order_relaxed);
bGenerationPaused.store(false, std::memory_order_relaxed);
bInitialized = true;
// Arrays are allocated lazily on the first Update (EnsureAllocated) so a settings change
// (resolution / level count) before play picks up cleanly.
@@ -80,6 +81,7 @@ void UVoxelDensityVolume::BeginDestroy()
{
// Backstop — EndPlay → NotifyShutdown should already have stopped the fill thread.
bShuttingDown.store(true, std::memory_order_release);
bGenerationPaused.store(true, std::memory_order_release);
StopFillThread();
Super::BeginDestroy();
}
@@ -87,6 +89,7 @@ void UVoxelDensityVolume::BeginDestroy()
void UVoxelDensityVolume::NotifyShutdown()
{
bShuttingDown.store(true, std::memory_order_release);
bGenerationPaused.store(true, std::memory_order_release);
// Stop the dedicated fill thread — Kill(true) blocks until Run() returns, so it can't read the
// Generator after this (the owner tears UObjects down next). Then drop any queued/finished work.
@@ -100,6 +103,10 @@ void UVoxelDensityVolume::NotifyShutdown()
void UVoxelDensityVolume::Reset()
{
// Reset can be called by a live-edit/regeneration path, not only after EndPlay. Join first so
// no fill thread can still read Generator while the old epoch and queued work are discarded.
StopFillThread();
// Bump the epoch so any in-flight fill lands stale and is dropped in DrainResults.
++VolumeEpoch;
PendingFills.Reset();
@@ -118,6 +125,20 @@ void UVoxelDensityVolume::Reset()
LastPlayerVoxel = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
}
void UVoxelDensityVolume::PauseForGenerationChange()
{
bGenerationPaused.store(true, std::memory_order_release);
StopFillThread();
}
void UVoxelDensityVolume::ResumeAfterGenerationChange()
{
if (!bShuttingDown.load(std::memory_order_acquire))
{
bGenerationPaused.store(false, std::memory_order_release);
}
}
//=============================================================================
// Helpers
//=============================================================================
@@ -284,7 +305,8 @@ bool UVoxelDensityVolume::GetLevelShaderParams(int32 Level, FIntVector& OutOrigi
void UVoxelDensityVolume::Update(const FVector& PlayerWorldPos)
{
if (!bInitialized || !Settings || !Settings->bEnableDensityVolume || !Generator) return;
if (bGenerationPaused.load(std::memory_order_acquire)
|| !bInitialized || !Settings || !Settings->bEnableDensityVolume || !Generator) return;
AActor* O = Owner.Get();
if (!O) return;
@@ -557,6 +579,8 @@ void UVoxelDensityVolume::IngestTileCapture(const FIntVector& L0TileCoord, TArra
void UVoxelDensityVolume::EnsureFillThread()
{
if (FillThread) return;
if (bGenerationPaused.load(std::memory_order_acquire)
|| bShuttingDown.load(std::memory_order_acquire)) return;
if (!Settings || !Settings->bEnableDensityVolume) return;
bFillThreadStop.store(false, std::memory_order_release);
if (!FillWakeEvent) { FillWakeEvent = FPlatformProcess::GetSynchEventFromPool(false); } // auto-reset
@@ -588,6 +612,8 @@ void UVoxelDensityVolume::StopFillThread()
void UVoxelDensityVolume::LaunchPendingFills()
{
if (PendingFills.Num() == 0) return;
if (bGenerationPaused.load(std::memory_order_acquire)
|| bShuttingDown.load(std::memory_order_acquire)) return;
EnsureFillThread();
for (FPendingFill& F : PendingFills)
{
+205 -35
View File
@@ -3,6 +3,98 @@
#include "VoxelDiffLayer.h"
namespace
{
std::atomic<uint64> GNextDiffLayerLifetimeId{0};
bool IsFiniteVector(const FVector& Value)
{
return VoxelMath::IsFinite(Value.X)
&& VoxelMath::IsFinite(Value.Y)
&& VoxelMath::IsFinite(Value.Z);
}
bool IsValidModificationGeometry(const FVoxelModification& Mod)
{
if (!IsFiniteVector(Mod.Center) || !IsFiniteVector(Mod.BoxExtent)
|| !IsFiniteVector(Mod.CapsuleEnd)
|| !VoxelMath::IsFinite(Mod.Radius)
|| !VoxelMath::IsFinite(Mod.Strength)
|| !VoxelMath::IsFinite(Mod.Falloff)
|| Mod.Radius <= 0.0f || Mod.Falloff < 0.0f)
{
return false;
}
switch (Mod.Shape)
{
case EVoxelBrushShape::Sphere:
return true;
case EVoxelBrushShape::Box:
return Mod.BoxExtent.X > 0.0f
&& Mod.BoxExtent.Y > 0.0f
&& Mod.BoxExtent.Z > 0.0f;
case EVoxelBrushShape::Capsule:
return true; // A coincident pair of endpoints is a valid spherical capsule.
default:
return false;
}
}
double GetModificationBudgetVolume(const FVoxelModification& Mod)
{
switch (Mod.Shape)
{
case EVoxelBrushShape::Box:
{
// The SDF's falloff is a rounded outer band, so the expanded axis-aligned box is a
// conservative charge and remains cheap to compute.
const double Ex = static_cast<double>(Mod.BoxExtent.X) + static_cast<double>(Mod.Falloff);
const double Ey = static_cast<double>(Mod.BoxExtent.Y) + static_cast<double>(Mod.Falloff);
const double Ez = static_cast<double>(Mod.BoxExtent.Z) + static_cast<double>(Mod.Falloff);
return 8.0 * Ex * Ey * Ez;
}
case EVoxelBrushShape::Capsule:
{
// Charge the full capsule including its falloff shell: cylinder + two hemispheres.
const double OuterRadius = static_cast<double>(Mod.Radius) + static_cast<double>(Mod.Falloff);
const double SegmentLength = static_cast<double>(FVector::Dist(Mod.Center, Mod.CapsuleEnd));
return PI * OuterRadius * OuterRadius * SegmentLength
+ (4.0 / 3.0) * PI * OuterRadius * OuterRadius * OuterRadius;
}
case EVoxelBrushShape::Sphere:
default:
{
const double Radius = static_cast<double>(Mod.Radius);
return (4.0 / 3.0) * PI * Radius * Radius * Radius;
}
}
}
}
uint64 UVoxelDiffLayer::GetCacheLifetimeId() const
{
uint64 LifetimeId = CacheLifetimeId.load(std::memory_order_acquire);
if (LifetimeId != 0)
{
return LifetimeId;
}
const uint64 NewLifetimeId = GNextDiffLayerLifetimeId.fetch_add(1, std::memory_order_relaxed) + 1;
if (CacheLifetimeId.compare_exchange_strong(
LifetimeId,
NewLifetimeId,
std::memory_order_release,
std::memory_order_acquire))
{
return NewLifetimeId;
}
// Another worker initialized the identity first. Its value is written into LifetimeId by
// compare_exchange_strong on failure.
return LifetimeId;
}
//=============================================================================
// BUDGET CONFIGURATION
//=============================================================================
@@ -19,8 +111,13 @@ void UVoxelDiffLayer::SetBudget(int32 InMaxMods, float InMaxRadius, float InMaxV
bool UVoxelDiffLayer::CanModify(float Radius) const
{
if (!VoxelMath::IsFinite(Radius) || Radius <= 0.0f)
{
return false;
}
// Check radius limit (always enforced, even if "unlimited" count/volume)
if (Radius > BudgetMaxRadius)
if (BudgetMaxRadius > 0.0f && Radius > BudgetMaxRadius)
{
return false;
}
@@ -34,8 +131,12 @@ bool UVoxelDiffLayer::CanModify(float Radius) const
// Check volume limit: would this brush push us over?
if (BudgetMaxVolume > 0.0f)
{
const float BrushVolume = (4.0f / 3.0f) * PI * Radius * Radius * Radius;
if (AccumulatedVolume + BrushVolume > BudgetMaxVolume)
const double BrushVolume = (4.0 / 3.0) * PI
* static_cast<double>(Radius) * static_cast<double>(Radius) * static_cast<double>(Radius);
const double NewTotal = static_cast<double>(AccumulatedVolume) + BrushVolume;
if (!VoxelMath::IsFinite(BrushVolume) || BrushVolume > static_cast<double>(FLT_MAX)
|| !VoxelMath::IsFinite(NewTotal)
|| NewTotal > static_cast<double>(BudgetMaxVolume))
{
return false;
}
@@ -44,6 +145,27 @@ bool UVoxelDiffLayer::CanModify(float Radius) const
return true;
}
bool UVoxelDiffLayer::CanModify(const FVoxelModification& Mod) const
{
if (!IsValidModificationGeometry(Mod)) return false;
// Radius is the radial cap for spheres/capsules. A box's largest half-extent is its analogous
// brush radius; checking it separately prevents a direct API caller from hiding a huge box behind
// an unrelated small Radius field.
const float GeometryRadius = (Mod.Shape == EVoxelBrushShape::Box)
? FMath::Max3(Mod.BoxExtent.X, Mod.BoxExtent.Y, Mod.BoxExtent.Z)
: Mod.Radius;
if (BudgetMaxRadius > 0.0f && GeometryRadius > BudgetMaxRadius) return false;
if (BudgetMaxMods > 0 && ModificationCount >= BudgetMaxMods) return false;
const double BrushVolume = GetModificationBudgetVolume(Mod);
const double NewTotal = static_cast<double>(AccumulatedVolume) + BrushVolume;
if (!VoxelMath::IsFinite(BrushVolume) || BrushVolume > static_cast<double>(FLT_MAX)
|| !VoxelMath::IsFinite(NewTotal)) return false;
if (BudgetMaxVolume > 0.0f && NewTotal > static_cast<double>(BudgetMaxVolume)) return false;
return true;
}
int32 UVoxelDiffLayer::GetRemainingModifications() const
{
if (BudgetMaxMods <= 0) return -1; // Unlimited
@@ -64,11 +186,25 @@ TArray<FIntVector> UVoxelDiffLayer::ApplyModification(const FVoxelModification&
{
TArray<FIntVector> AffectedChunks;
// --- Budget enforcement ---
// Clamp radius to max allowed
float ClampedRadius = FMath::Min(Mod.Radius, BudgetMaxRadius);
// Validate the complete geometry before radius clamping, budget accounting, or AABB math.
// FMath::Min(NaN, limit) and the shape switch's default sphere path would otherwise turn
// malformed API input into either a counter update or an invalid chunk range.
if (!IsValidModificationGeometry(Mod))
{
UE_LOG(LogTemp, Warning,
TEXT("[DiffLayer] Modification REJECTED — non-finite or invalid brush geometry"));
return AffectedChunks;
}
if (!CanModify(ClampedRadius))
// --- Budget enforcement ---
// Preserve the historical radius clamp for spheres/capsules when a finite per-brush cap is set;
// the shape-aware check below still charges the resulting geometry and rejects oversized boxes.
const float ClampedRadius = (BudgetMaxRadius > 0.0f)
? FMath::Min(Mod.Radius, BudgetMaxRadius) : Mod.Radius;
FVoxelModification ClampedMod = Mod;
ClampedMod.Radius = ClampedRadius;
if (!CanModify(ClampedMod))
{
UE_LOG(LogTemp, Warning,
TEXT("[DiffLayer] Modification REJECTED — budget exceeded (count=%d/%d, volume=%.0f/%.0f)"),
@@ -76,34 +212,53 @@ TArray<FIntVector> UVoxelDiffLayer::ApplyModification(const FVoxelModification&
return AffectedChunks; // Empty = rejected
}
// Use clamped radius for the actual modification
FVoxelModification ClampedMod = Mod;
ClampedMod.Radius = ClampedRadius;
// Complete the world-AABB calculation before mutating the budget counters. Finite input can
// still overflow during Center +/- extent or address a chunk outside FIntVector's domain;
// neither case is a modification that this layer can store safely.
FVector BoundsMin, BoundsMax;
ClampedMod.GetWorldBounds(BoundsMin, BoundsMax);
auto WorldToChunk = [](float WorldCoord, int32& OutChunk) -> bool
{
if (!VoxelMath::IsFinite(WorldCoord))
{
return false;
}
const double ChunkCoord = FMath::FloorToDouble(
static_cast<double>(WorldCoord) / static_cast<double>(CHUNK_SIZE));
if (!VoxelMath::IsFinite(ChunkCoord)
|| ChunkCoord < static_cast<double>(MIN_int32)
|| ChunkCoord > static_cast<double>(MAX_int32))
{
return false;
}
OutChunk = static_cast<int32>(ChunkCoord);
return true;
};
int32 MinCX = 0, MaxCX = 0, MinCY = 0, MaxCY = 0, MinCZ = 0, MaxCZ = 0;
if (!WorldToChunk(BoundsMin.X, MinCX) || !WorldToChunk(BoundsMax.X, MaxCX)
|| !WorldToChunk(BoundsMin.Y, MinCY) || !WorldToChunk(BoundsMax.Y, MaxCY)
|| !WorldToChunk(BoundsMin.Z, MinCZ) || !WorldToChunk(BoundsMax.Z, MaxCZ))
{
UE_LOG(LogTemp, Warning,
TEXT("[DiffLayer] Modification REJECTED — brush bounds are not representable"));
return TArray<FIntVector>();
}
// Update budget counters
ModificationCount++;
const float BrushVolume = (4.0f / 3.0f) * PI * ClampedRadius * ClampedRadius * ClampedRadius;
AccumulatedVolume += BrushVolume;
// Find every chunk the brush can touch, from its shape-aware world AABB.
FVector BoundsMin, BoundsMax;
ClampedMod.GetWorldBounds(BoundsMin, BoundsMax);
const int32 MinCX = FMath::FloorToInt(BoundsMin.X / CHUNK_SIZE);
const int32 MaxCX = FMath::FloorToInt(BoundsMax.X / CHUNK_SIZE);
const int32 MinCY = FMath::FloorToInt(BoundsMin.Y / CHUNK_SIZE);
const int32 MaxCY = FMath::FloorToInt(BoundsMax.Y / CHUNK_SIZE);
const int32 MinCZ = FMath::FloorToInt(BoundsMin.Z / CHUNK_SIZE);
const int32 MaxCZ = FMath::FloorToInt(BoundsMax.Z / CHUNK_SIZE);
const double BrushVolume = GetModificationBudgetVolume(ClampedMod);
AccumulatedVolume += static_cast<float>(BrushVolume);
{
// Write lock: blocks worker-thread readers (HasModifications / GetDensityOffset) while the map
// is mutated/rehashed. AffectedChunks is local; populate it in the same pass.
FWriteScopeLock Lock(ModsLock);
for (int32 CZ = MinCZ; CZ <= MaxCZ; CZ++)
for (int32 CZ = MinCZ;; ++CZ)
{
for (int32 CY = MinCY; CY <= MaxCY; CY++)
for (int32 CY = MinCY;; ++CY)
{
for (int32 CX = MinCX; CX <= MaxCX; CX++)
for (int32 CX = MinCX;; ++CX)
{
FIntVector ChunkCoord(CX, CY, CZ);
@@ -112,8 +267,12 @@ TArray<FIntVector> UVoxelDiffLayer::ApplyModification(const FVoxelModification&
// Track which chunks need re-meshing
AffectedChunks.Add(ChunkCoord);
if (CX == MaxCX) break;
}
if (CY == MaxCY) break;
}
if (CZ == MaxCZ) break;
}
// Publish: subsequent readers must now take the lock instead of fast-rejecting, and
// worker-side snapshots keyed on the version re-copy their chunk's list.
@@ -171,19 +330,30 @@ bool UVoxelDiffLayer::HasAnyModInChunkRange(const FIntVector& MinChunk, const FI
{
if (!bHasAnyMods.load(std::memory_order_acquire)) return false;
// Walk the modified-chunk KEYS under one read lock. Conservative: a mod is registered in every
// chunk its radius overlaps (ApplyModification), so key-in-range ⟺ the mod can reach the range.
FReadScopeLock Lock(ModsLock);
for (const auto& Pair : ChunkMods)
if (MinChunk.X > MaxChunk.X || MinChunk.Y > MaxChunk.Y || MinChunk.Z > MaxChunk.Z)
{
const FIntVector& C = Pair.Key;
if (C.X >= MinChunk.X && C.X <= MaxChunk.X &&
C.Y >= MinChunk.Y && C.Y <= MaxChunk.Y &&
C.Z >= MinChunk.Z && C.Z <= MaxChunk.Z &&
Pair.Value.Num() > 0)
return false;
}
// ApplyModification registers a mod in every chunk its AABB overlaps, so looking up the keys in
// the queried range is conservative and proportional to that range. Do not walk ChunkMods:
// the edit history can grow without bound while a sealed-solid candidate is being proved.
FReadScopeLock Lock(ModsLock);
for (int32 CZ = MinChunk.Z;; ++CZ)
{
for (int32 CY = MinChunk.Y;; ++CY)
{
return true;
for (int32 CX = MinChunk.X;; ++CX)
{
if (const TArray<FVoxelModification>* Mods = ChunkMods.Find(FIntVector(CX, CY, CZ)))
{
if (Mods->Num() > 0) return true;
}
if (CX == MaxChunk.X) break;
}
if (CY == MaxChunk.Y) break;
}
if (CZ == MaxChunk.Z) break;
}
return false;
}
+34 -2
View File
@@ -2,6 +2,15 @@
// Module implementation - boilerplate
#include "VoxelForgeModule.h"
#include "VoxelStackSampler.h"
#include "VoxelGenerator.h"
#include "VoxelMarchingCubesMesher.h"
#include "Misc/CommandLine.h"
#include "Misc/Parse.h"
#if PLATFORM_WINDOWS
#include <math.h>
#endif
// This macro registers our module with Unreal
// "VoxelForge" must match the module name in VoxelForge.Build.cs and .uplugin
@@ -10,13 +19,36 @@ IMPLEMENT_MODULE(FVoxelForgeModule, VoxelForge)
void FVoxelForgeModule::StartupModule()
{
// Called when plugin loads
// We don't need to do anything here for now
// Keep this diagnostic before any plugin-owned generation can begin. The UCRT may select
// its FMA3 math implementations during process startup; this switch lets the canonical export
// compare that path with the non-FMA3 implementation on the same machine.
#if PLATFORM_WINDOWS
const int32 Fma3Before = _get_FMA3_enable();
int32 RequestedFma3 = 0;
const bool bHasFma3Override = FParse::Value(
FCommandLine::Get(), TEXT("voxel.CrtFma3="), RequestedFma3);
if (bHasFma3Override)
{
_set_FMA3_enable(RequestedFma3 != 0 ? 1 : 0);
}
const int32 Fma3After = _get_FMA3_enable();
UE_LOG(LogTemp, Log,
TEXT("VoxelForge CRT FMA3 diagnostic: before=%d override=%s requested=%d after=%d"),
Fma3Before,
bHasFma3Override ? TEXT("yes") : TEXT("no"),
RequestedFma3,
Fma3After);
#endif
// These parsers used to be entered lazily by worker-side density/mesher calls. Resolve the
// command line once while the module is starting, before any generation task is submitted.
VoxelGenLOD::InitializeConsoleSwitches();
UVoxelMarchingCubesMesher::InitializeConsoleSwitches();
UE_LOG(LogTemp, Log, TEXT("VoxelForge module started!"));
}
void FVoxelForgeModule::ShutdownModule()
{
// Called when plugin unloads
// Cleanup would go here if we had any global resources
FVoxelStackSampler::Get().Shutdown();
UE_LOG(LogTemp, Log, TEXT("VoxelForge module shutdown."));
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,497 @@
// VoxelHeightOpStack.cpp
// Les cinq opérateurs d'espace-hauteur de SurfaceWorld.
// The five height-space operators of SurfaceWorld.
//
// FIDÉLITÉ / FIDELITY
// Chaque corps est une transcription LITTÉRALE du bloc correspondant de
// `SampleSurfaceStructuralZ` / `ComputeSurfaceTerrainZ` — mêmes offsets, mêmes octaves, même ordre
// d'opérations flottantes. Depuis que `FPSemantics = Precise` est posé (AUDIT §C9), l'égalité
// BIT À BIT est atteignable et atteinte pour Maze et Slab : c'est donc la barre ici aussi, et
// `VoxelForge.OpStack.SurfaceHeightEquivalence` la vérifie.
//
// ⚠️ LE DÉTOUR PAR `FVector` EST DÉLIBÉRÉ, comme ailleurs dans ce refactor : `FractalNoise3D` prend
// un `FVector` (donc des DOUBLES en UE5) et re-descend en float. Passer directement des floats
// saute un arrondi. Reproduire le détour, c'est reproduire l'arrondi.
// The FVector round-trip is deliberate: FVector is double in UE5, so the original rounds through a
// double. Going straight through floats skips a rounding step.
#include "VoxelHeightOp.h"
#include "VoxelCaveMorphology.h" // VoxelHash::SeedOffset — AUDIT §C1 (bounded, site-salted)
#include "VoxelNoise.h" // VoxelNoise::FBM / Ridged / Perlin3D
#include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE
namespace
{
//=========================================================================
// HELPERS — les mêmes enveloppes que VoxelGenerator.cpp, transcrites
//=========================================================================
// `FractalNoise3D` et `RidgedNoise3D` sont `static` dans VoxelGenerator.cpp, donc invisibles
// ici. Elles sont recopiées à l'identique plutôt qu'exportées : les exporter changerait leur
// contexte d'inlining, et sous /fp:precise comme sous /fp:fast la règle est la même — on ne
// touche à rien de ce qui entoure une expression flottante qu'on veut reproduire.
FORCEINLINE float HFractalNoise3D(const FVector& Position, int32 Octaves = 4,
float Lacunarity = 2.0f, float Persistence = 0.5f)
{
return VoxelNoise::FBM((float)Position.X, (float)Position.Y, (float)Position.Z,
Octaves, Lacunarity, Persistence);
}
FORCEINLINE float HRidgedNoise3D(const FVector& Position, int32 Octaves = 4,
float Lacunarity = 2.0f, float Persistence = 0.5f)
{
return VoxelNoise::Ridged((float)Position.X, (float)Position.Y, (float)Position.Z,
Octaves, Lacunarity, Persistence);
}
/** Transcription de `UVoxelGenerator::SampleRelief`. Champ [0,1] partagé avec la carte de
* biomes, pour que la géographie et le terrain qu'elle module restent d'accord. */
FORCEINLINE float HSampleRelief(float WorldX, float WorldY, uint32 SeedU,
float Frequency, float Contrast)
{
float R = HFractalNoise3D(FVector(
WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 7.3f),
WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 2.1f),
VoxelHash::SeedOffset(SeedU, 0.5f)), 2) * 0.5f + 0.5f; // [0,1]
R = FMath::Clamp((R - 0.5f) * Contrast + 0.5f, 0.0f, 1.0f);
return SmoothStep01(R);
}
//=========================================================================
// SOURCE — CHAMP STRUCTUREL / STRUCTURAL HEIGHT FIELD
//=========================================================================
// Continents + montagnes + détail, sous une frame de domain-warp. Produit les DEUX canaux.
class FStructuralHeightSource final : public IVoxelHeightOp
{
public:
FStructuralHeightSource(const FSurfaceGenerationParams& InP, int32 InSeed)
: P(InP), SeedU((uint32)InSeed) {}
void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override
{
float M = 1.0f;
InOut.Height = SampleZ(WorldX, WorldY, M); // Replace : racine de pile
InOut.Relief = M;
}
/**
* Le champ nu, exposé parce que `FCliffHeightMod` doit le RÉ-ÉCHANTILLONNER en différences
* centrées. C'est une dépendance réelle du code d'origine (`ComputeSurfaceTerrainZ` appelle
* `SampleSurfaceStructuralZ` quatre fois de plus), pas un raccourci : la pente doit venir du
* champ STRUCTUREL, sans rétroaction des ops, sinon le cliff se nourrirait de lui-même.
*/
float SampleZ(float WorldX, float WorldY, float& OutM) const
{
const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ;
const float BottomZ = P.StrateBottomWorldZ;
const float GroundBase = BottomZ + H * P.BaseGroundRelative;
// Domain-warp des coords STRUCTURELLES (continents + montagnes). Le bruit de détail
// reste sur le vrai XY pour que les bosses fines restent nettes et décorrélées.
float QX = WorldX, QY = WorldY;
if (P.HeightWarpStrength > 0.0f)
{
const float WF = P.HeightWarpFrequency;
const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.31f), WorldY * WF + 4.2f, VoxelHash::SeedOffset(SeedU, 1.7f)));
const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 8.6f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.53f), VoxelHash::SeedOffset(SeedU, 2.9f)));
QX += wx * VOXEL_NOISE_SCALE * P.HeightWarpStrength;
QY += wy * VOXEL_NOISE_SCALE * P.HeightWarpStrength;
}
const float Relief = HSampleRelief(WorldX, WorldY, SeedU, P.ReliefFrequency, P.ReliefContrast);
const float M = FMath::Lerp(1.0f, Relief, P.ReliefStrength);
float Cont = HFractalNoise3D(FVector(
QX * P.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 3.1f),
QY * P.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 5.7f),
VoxelHash::SeedOffset(SeedU, 0.7f)), 4); // [-1,1]
float Detail = HFractalNoise3D(FVector(
WorldX * P.DetailFrequency + 11.0f,
WorldY * P.DetailFrequency + 22.0f,
VoxelHash::SeedOffset(SeedU, 1.3f)), 3); // [-1,1]
float Mountain = 0.0f;
if (P.MountainStrength > 0.0f)
{
float Ridge = HRidgedNoise3D(FVector(
QX * P.MountainFrequency + 99.0f,
QY * P.MountainFrequency + 77.0f,
VoxelHash::SeedOffset(SeedU, 0.9f)), 4); // [-1,1]
Ridge = Ridge * 0.5f + 0.5f; // [0,1] sommets
Mountain = Ridge * P.MountainStrength * M; // les montagnes ne montent qu'en haut relief
}
// Les plaines gardent une fraction du gonflement continental ; les hautes terres tout.
const float ContScale = FMath::Lerp(0.45f, 1.0f, M);
float Terrain = GroundBase
+ Cont * P.ElevationRange * 0.5f * ContScale
+ Mountain * P.ElevationRange
+ Detail * P.SurfaceRoughness;
OutM = M;
return Terrain;
}
// Une SOURCE pose l'altitude, elle ne la déplace pas : la notion de « déplacement max » ne
// s'applique pas. La borne d'une colonne se calcule à partir de la source elle-même
// (GroundBase ± ElevationRange ± SurfaceRoughness), pas ici — d'où FLT_MAX, honnête.
float MaxDisplacement() const override { return FLT_MAX; }
private:
FSurfaceGenerationParams P;
uint32 SeedU;
};
//=========================================================================
// MOD — FALAISE / CLIFF (raidissement conditionné par la pente)
//=========================================================================
class FCliffHeightMod final : public IVoxelHeightOp
{
public:
FCliffHeightMod(const FSurfaceGenerationParams& InP, const FStructuralHeightSource* InSrc)
: P(InP), Src(InSrc) {}
void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override
{
if (P.CliffStrength <= 0.0f || Src == nullptr) { return; }
const float D = FMath::Max(P.CliffSampleDist, 0.5f);
float Ms; // relief scratch — on ne veut que les hauteurs
const float Zxp = Src->SampleZ(WorldX + D, WorldY, Ms);
const float Zxm = Src->SampleZ(WorldX - D, WorldY, Ms);
const float Zyp = Src->SampleZ(WorldX, WorldY + D, Ms);
const float Zym = Src->SampleZ(WorldX, WorldY - D, Ms);
const float dZdX = (Zxp - Zxm) / (2.0f * D);
const float dZdY = (Zyp - Zym) / (2.0f * D);
const float Slope = FMath::Sqrt(dZdX * dZdX + dZdY * dZdY);
const float Thr = FMath::Max(P.CliffSlopeThreshold, 0.05f);
const float SlopeGate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f);
if (SlopeGate > 0.0f)
{
const float Ref = 0.25f * (Zxp + Zxm + Zyp + Zym);
const float Gain = P.CliffStrength * SlopeGate * P.CliffSharpness;
InOut.Height += (InOut.Height - Ref) * Gain;
}
}
private:
FSurfaceGenerationParams P;
const FStructuralHeightSource* Src;
};
//=========================================================================
// MOD — TERRASSES / TERRACE (gaté par le relief : le canal Relief sert ICI)
//=========================================================================
class FTerraceHeightMod final : public IVoxelHeightOp
{
public:
explicit FTerraceHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {}
void Eval(float, float, FVoxelHeightSample& InOut) const override
{
if (P.TerraceStrength <= 0.0f || P.TerraceHeight <= 0.0f) { return; }
const float StepH = P.TerraceHeight;
const float T = InOut.Height / StepH;
const float K = FMath::FloorToFloat(T);
const float Frac = T - K;
const float W = FMath::Lerp(0.5f, 0.03f, FMath::Clamp(P.TerraceHardness, 0.0f, 1.0f));
const float Fs = SmoothStep01(FMath::Clamp((Frac - (0.5f - W)) / (2.0f * W), 0.0f, 1.0f));
const float Stepped = (K + Fs) * StepH;
// `* InOut.Relief` : c'est le `* M` de l'original — la raison d'être du second canal.
InOut.Height = FMath::Lerp(InOut.Height, Stepped, P.TerraceStrength * InOut.Relief);
}
// Le terrace interpole VERS une hauteur quantifiée : l'écart ne dépasse jamais un palier.
float MaxDisplacement() const override
{
return (P.TerraceStrength > 0.0f) ? FMath::Max(P.TerraceHeight, 0.0f) : 0.0f;
}
private:
FSurfaceGenerationParams P;
};
//=========================================================================
// MOD — LIGNES DE STRATES / LAYER LINES
//=========================================================================
class FLayerLineHeightMod final : public IVoxelHeightOp
{
public:
explicit FLayerLineHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {}
void Eval(float, float, FVoxelHeightSample& InOut) const override
{
if (P.LayerLineDepth <= 0.0f || P.LayerLineSpacing <= 0.0f) { return; }
const float Phase = InOut.Height * (2.0f * PI / P.LayerLineSpacing);
InOut.Height -= VoxelMath::DetSin(Phase) * P.LayerLineDepth;
}
// `sin` ∈ [-1,1] ⇒ borne exacte.
float MaxDisplacement() const override
{
return (P.LayerLineSpacing > 0.0f) ? FMath::Max(P.LayerLineDepth, 0.0f) : 0.0f;
}
private:
FSurfaceGenerationParams P;
};
//=========================================================================
// MOD — PLAGE / BEACH (aplatissement vers la ligne d'eau)
//=========================================================================
class FBeachHeightMod final : public IVoxelHeightOp
{
public:
explicit FBeachHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {}
void Eval(float, float, FVoxelHeightSample& InOut) const override
{
// Le niveau d'eau est GLOBAL à la strate (forcé depuis la strate) pour que le plan
// d'eau reste continu — d'où le calcul depuis les bornes de strate, pas depuis un param
// par biome.
const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ;
const float WaterZ = P.StrateBottomWorldZ + H * P.WaterLevelRelative;
if (P.WaterLevelRelative <= 0.0f || P.BeachWidth <= 0.0f) { return; }
const float DAbs = FMath::Abs(InOut.Height - WaterZ);
if (DAbs < P.BeachWidth)
{
float T = SmoothStep01(DAbs / P.BeachWidth);
InOut.Height = FMath::Lerp(WaterZ, InOut.Height, T);
}
}
// N'agit que dans `BeachWidth` de l'eau, et ne fait qu'y RAPPROCHER.
float MaxDisplacement() const override
{
return (P.WaterLevelRelative > 0.0f) ? FMath::Max(P.BeachWidth, 0.0f) : 0.0f;
}
private:
FSurfaceGenerationParams P;
};
//=========================================================================
// SOURCE — LE CIEL / SKY CAP (c'est une ALTITUDE, donc c'est un op de hauteur)
//=========================================================================
// `ComputeSurfaceCeiling` rend un Z, exactement comme le terrain. `OPSTACK-DECOMPOSITION §5` le
// range en `FSkyCapSource` côté DENSITÉ (« Subtract »), mais c'est le même glissement que pour
// les ops de terrain : ce que la fonction produit est une hauteur, et la soustraction n'arrive
// qu'après, dans le combine. Le mettre ici lui donne gratuitement l'invariance de fenêtre
// testée, la pureté XY garantie par le type, et le cache de colonne.
// The sky cap returns a Z, so it belongs in height space; the subtraction happens later, in the
// density-side combine.
class FSkyCapHeightSource final : public IVoxelHeightOp
{
public:
FSkyCapHeightSource(const FSurfaceGenerationParams& InP, int32 InSeed)
: P(InP), SeedU((uint32)InSeed) {}
void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override
{
const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ;
float CeilZ = P.StrateBottomWorldZ + H * P.CeilingRelative;
// Domain-warp des coords larges/ridge (miroir du HeightWarp du sol). Les bosses fines
// restent sur le vrai XY pour rester nettes et décorrélées. 0 ⇒ pas de warp.
float QX = WorldX, QY = WorldY;
if (P.CeilingWarpStrength > 0.0f)
{
const float WF = P.CeilingWarpFrequency;
const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.71f), WorldY * WF + 2.3f, VoxelHash::SeedOffset(SeedU, 3.3f)));
const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 6.1f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.19f), VoxelHash::SeedOffset(SeedU, 4.7f)));
QX += wx * VOXEL_NOISE_SCALE * P.CeilingWarpStrength;
QY += wy * VOXEL_NOISE_SCALE * P.CeilingWarpStrength;
}
// Gonflement large SIGNÉ : monte/descend toute la voûte.
if (P.CeilingUndulation > 0.0f)
{
const float Swell = HFractalNoise3D(FVector(
QX * P.CeilingUndulationFrequency + VoxelHash::SeedOffset(SeedU, 1.9f),
QY * P.CeilingUndulationFrequency + 13.0f,
VoxelHash::SeedOffset(SeedU, 0.5f)), 3); // [-1,1]
CeilZ += Swell * VOXEL_NOISE_SCALE * P.CeilingUndulation;
}
// Pendage vers le BAS uniquement : tout est >= 0, donc rien ne perce vers le haut dans
// le seal. Bosses fines + lames ridgées s'additionnent.
float Hang = 0.0f;
if (P.CeilingRoughness > 0.0f)
{
Hang += FMath::Abs(HFractalNoise3D(FVector(
WorldX * P.CeilingRoughnessFrequency + 5.0f,
WorldY * P.CeilingRoughnessFrequency + 6.0f,
VoxelHash::SeedOffset(SeedU, 2.1f)), 3)) * VOXEL_NOISE_SCALE * P.CeilingRoughness;
}
if (P.CeilingRidgeStrength > 0.0f)
{
float Ridge = HRidgedNoise3D(FVector(
QX * P.CeilingRidgeFrequency + 31.0f,
QY * P.CeilingRidgeFrequency + 47.0f,
VoxelHash::SeedOffset(SeedU, 1.1f)), 4); // [-1,1]
Ridge = Ridge * 0.5f + 0.5f; // [0,1] lignes de crête pendantes
Hang += Ridge * P.CeilingRidgeStrength;
}
InOut.Height = CeilZ - Hang; // Replace : racine de sa propre pile
// Relief laissé intact : le ciel n'en produit pas et personne ne le lui demande.
}
float MaxDisplacement() const override { return FLT_MAX; } // source, pas modificateur
private:
FSurfaceGenerationParams P;
uint32 SeedU;
};
//=========================================================================
// COMBINER `Mask` — MÉLANGE DE BIOMES / BIOME BLEND
//=========================================================================
// Une pile complète par biome ; le champ dit lequel domine ; on interpole les HAUTEURS.
//
// ⚠️ Chaque pile calcule SON PROPRE relief `M` en interne et l'utilise pour son propre gate de
// terrace — exactement comme l'original, où `ComputeSurfaceTerrainZ(X, Y, *PD)` et
// `(…, *PN)` sont deux appels complets et indépendants dont seules les SORTIES sont mêlées.
// Le canal `Relief` qui ressort ici est celui du DOMINANT : il est informatif, personne en aval
// ne s'en sert pour re-gater quoi que ce soit.
//
// Each biome stack computes its own relief internally and gates its own terrace with it, exactly
// as the original makes two independent full calls and blends only the OUTPUTS.
class FBiomeBlendHeightSource final : public IVoxelHeightOp
{
public:
FBiomeBlendHeightSource(const TArray<FSurfaceGenerationParams>& PerBiome, int32 Seed,
const IVoxelBiomeField* InField, bool bCeilingOnly)
: Field(InField)
{
Stacks.Reserve(PerBiome.Num());
for (const FSurfaceGenerationParams& BP : PerBiome)
{
FVoxelHeightStack S;
if (bCeilingOnly) { VoxelHeightOps::BuildSurfaceCeilingStack(S, BP, Seed); }
else { VoxelHeightOps::BuildSurfaceHeightStack(S, BP, Seed); }
Stacks.Add(MoveTemp(S));
}
bBlend = !bCeilingOnly; // le plafond SÉLECTIONNE, il ne mélange pas
}
void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override
{
if (Stacks.Num() == 0) { return; }
FVoxelBiomeWeights W;
if (Field) { W = Field->SampleAt(WorldX, WorldY); }
const int32 D = Stacks.IsValidIndex(W.Dominant) ? W.Dominant : 0;
InOut = Stacks[D].EvalSample(WorldX, WorldY);
// Le plafond ne se mélange pas (voir la fabrique) ; le sol si, et seulement dans la
// bande de frontière où le poids est non nul.
if (bBlend && W.NeighborWeight > 0.0f && Stacks.IsValidIndex(W.Neighbor))
{
const float HN = Stacks[W.Neighbor].EvalHeight(WorldX, WorldY);
InOut.Height = FMath::Lerp(InOut.Height, HN, W.NeighborWeight);
}
}
float MaxDisplacement() const override { return FLT_MAX; } // source composite
private:
TArray<FVoxelHeightStack> Stacks;
const IVoxelBiomeField* Field;
bool bBlend = true;
};
} // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE.
// En dessous commence `namespace VoxelHeightOps` (les fabriques). Y insérer une classe la sort
// de la liaison interne, et l'accolade qu'on ajoute avec elle ne ferme rien → C2059. Erreur
// commise DEUX fois (7cd2bed, puis à nouveau ici) en s'ancrant sur la bannière « FABRIQUES »,
// qui est de l'autre côté de cette accolade.
// END OF THE ANONYMOUS NAMESPACE — new operators go ABOVE this line. Anchoring on the FACTORIES
// banner below puts them outside it, and the brace added with them closes nothing.
//=============================================================================
// FABRIQUES / FACTORIES
//=============================================================================
namespace VoxelHeightOps
{
TUniquePtr<IVoxelHeightOp> MakeBiomeBlendHeightSource(
const TArray<FSurfaceGenerationParams>& PerBiomeParams, int32 Seed,
const IVoxelBiomeField* Field)
{
return MakeUnique<FBiomeBlendHeightSource>(PerBiomeParams, Seed, Field, /*bCeilingOnly*/false);
}
TUniquePtr<IVoxelHeightOp> MakeBiomeSelectCeilingSource(
const TArray<FSurfaceGenerationParams>& PerBiomeParams, int32 Seed,
const IVoxelBiomeField* Field)
{
return MakeUnique<FBiomeBlendHeightSource>(PerBiomeParams, Seed, Field, /*bCeilingOnly*/true);
}
TUniquePtr<IVoxelHeightOp> MakeSkyCapHeightSource(const FSurfaceGenerationParams& P, int32 Seed)
{
return MakeUnique<FSkyCapHeightSource>(P, Seed);
}
void BuildSurfaceCeilingStack(FVoxelHeightStack& OutStack, const FSurfaceGenerationParams& P, int32 Seed)
{
// Un seul op aujourd'hui — et c'est une information, pas un manque : le plafond n'a pas
// d'équivalent des quatre modificateurs du sol. Le jour où on veut des terrasses au
// plafond, on ajoute la ligne ; c'est exactement le genre de composition que le refactor
// existe pour rendre possible.
OutStack.Add(MakeSkyCapHeightSource(P, Seed));
}
TUniquePtr<IVoxelHeightOp> MakeStructuralHeightSource(const FSurfaceGenerationParams& P, int32 Seed,
const IVoxelHeightOp** OutSource)
{
TUniquePtr<FStructuralHeightSource> Src = MakeUnique<FStructuralHeightSource>(P, Seed);
if (OutSource) { *OutSource = Src.Get(); }
return Src;
}
TUniquePtr<IVoxelHeightOp> MakeCliffHeightMod(const FSurfaceGenerationParams& P,
const IVoxelHeightOp* StructuralSource)
{
// `static_cast` plutôt que `Cast<>` : ce ne sont pas des UObject, et le contrat de la
// fabrique est qu'on lui rend exactement le pointeur sorti de MakeStructuralHeightSource.
return MakeUnique<FCliffHeightMod>(
P, static_cast<const FStructuralHeightSource*>(StructuralSource));
}
TUniquePtr<IVoxelHeightOp> MakeTerraceHeightMod(const FSurfaceGenerationParams& P)
{
return MakeUnique<FTerraceHeightMod>(P);
}
TUniquePtr<IVoxelHeightOp> MakeLayerLineHeightMod(const FSurfaceGenerationParams& P)
{
return MakeUnique<FLayerLineHeightMod>(P);
}
TUniquePtr<IVoxelHeightOp> MakeBeachHeightMod(const FSurfaceGenerationParams& P)
{
return MakeUnique<FBeachHeightMod>(P);
}
void BuildSurfaceHeightStack(FVoxelHeightStack& OutStack, const FSurfaceGenerationParams& P, int32 Seed)
{
// L'ORDRE EST CELUI DE `ComputeSurfaceTerrainZ`, et il porte du sens :
// le cliff raidit le champ brut, le terrace quantifie le résultat raidi, les lignes de
// strates se posent dessus, et la plage écrase tout près de l'eau.
const IVoxelHeightOp* Structural = nullptr;
OutStack.Add(MakeStructuralHeightSource(P, Seed, &Structural));
OutStack.Add(MakeCliffHeightMod(P, Structural));
OutStack.Add(MakeTerraceHeightMod(P));
OutStack.Add(MakeLayerLineHeightMod(P));
OutStack.Add(MakeBeachHeightMod(P));
}
}
@@ -3,6 +3,90 @@
#include "VoxelMarchingCubesMesher.h"
#include "MarchingCubesTables.h"
#include "VoxelDensityProfile.h"
#include "VoxelStrateManager.h"
#include "VoxelTilePostReach.h"
#include "ProfilingDebugging/CpuProfilerTrace.h"
#include "HAL/IConsoleManager.h"
#include "Misc/CommandLine.h"
namespace
{
int32 GVoxelForgeUseOperatorBlock = 1;
FAutoConsoleVariableRef CVarVoxelForgeUseOperatorBlock(
TEXT("voxel.UseOperatorBlock"),
GVoxelForgeUseOperatorBlock,
TEXT("Use the operator-major density block path for non-shared mesher grids."));
bool GVoxelForgeBlockSwitchParsed = false;
int32 GVoxelForgeTilePostReachDebug = 0;
FAutoConsoleVariableRef CVarVoxelForgeTilePostReachDebug(
TEXT("voxel.TilePostReachDebug"),
GVoxelForgeTilePostReachDebug,
TEXT("Run skipped landing/structural posts in a bypass and compare their exact result; "
"a nonzero difference fails the debug run."));
float GVoxelForgeTilePostReachScale = 1.0f;
FAutoConsoleVariableRef CVarVoxelForgeTilePostReachScale(
TEXT("voxel.TilePostReachScale"),
GVoxelForgeTilePostReachScale,
TEXT("Scale the per-tile post reach for deliberate soundness tests. Default 1.0 is exact; "
"values below 1 intentionally shrink the guard."));
int32 GVoxelForgeTileReachCostDiagnostics = 0;
FAutoConsoleVariableRef CVarVoxelForgeTileReachCostDiagnostics(
TEXT("voxel.TileReachCostDiagnostics"),
GVoxelForgeTileReachCostDiagnostics,
TEXT("Measure core/passage density cost split by tile reach; no field gate."));
bool GVoxelForgeTilePostReachSwitchesParsed = false;
void VF_ParseTilePostReachSwitches()
{
if (GVoxelForgeTilePostReachSwitchesParsed)
{
return;
}
GVoxelForgeTilePostReachSwitchesParsed = true;
int32 DebugValue = GVoxelForgeTilePostReachDebug;
float ScaleValue = GVoxelForgeTilePostReachScale;
int32 CostDiagnosticsValue = GVoxelForgeTileReachCostDiagnostics;
FParse::Value(
FCommandLine::Get(), TEXT("voxel.TilePostReachDebug="), DebugValue);
FParse::Value(
FCommandLine::Get(), TEXT("voxel.TilePostReachScale="), ScaleValue);
FParse::Value(
FCommandLine::Get(), TEXT("voxel.TileReachCostDiagnostics="), CostDiagnosticsValue);
GVoxelForgeTilePostReachDebug = DebugValue != 0 ? 1 : 0;
if (!VoxelMath::IsFinite(ScaleValue) || ScaleValue <= 0.0f)
{
ScaleValue = 1.0f;
}
GVoxelForgeTilePostReachScale = FMath::Min(ScaleValue, 1.0f);
GVoxelForgeTileReachCostDiagnostics = CostDiagnosticsValue != 0 ? 1 : 0;
VoxelGenLOD::GTilePostReachDebugEnabled.store(
GVoxelForgeTilePostReachDebug != 0, std::memory_order_relaxed);
VoxelGenLOD::GTileReachCostDiagnosticsEnabled.store(
GVoxelForgeTileReachCostDiagnostics != 0, std::memory_order_relaxed);
}
void VF_ParseOperatorBlockSwitch()
{
if (GVoxelForgeBlockSwitchParsed) { return; }
GVoxelForgeBlockSwitchParsed = true;
int32 CommandLineValue = GVoxelForgeUseOperatorBlock;
if (FParse::Value(FCommandLine::Get(), TEXT("voxel.UseOperatorBlock="), CommandLineValue))
{
GVoxelForgeUseOperatorBlock = CommandLineValue;
}
}
}
void UVoxelMarchingCubesMesher::InitializeConsoleSwitches()
{
VF_ParseOperatorBlockSwitch();
VF_ParseTilePostReachSwitches();
}
//=============================================================================
// MAIN ALGORITHM
@@ -12,11 +96,18 @@
FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, int32 Step, int32 InCellsPerAxis,
TArray<uint8>* OutCaptureGrid,
int32 BandZMinVox, int32 BandZMaxVox)
int32 BandZMinVox, int32 BandZMaxVox,
int64* OutDensitySampleCount)
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_MesherGenerateMesh);
FVoxelMeshData MeshData;
if (OutCaptureGrid) { OutCaptureGrid->Reset(); }
if (OutDensitySampleCount) { *OutDensitySampleCount = 0; }
if (!Generator) return MeshData;
VoxelDensityProfile::FScopedTimer MesherProfileTimer(
VoxelDensityProfile::EBucket::MesherGenerateMesh);
VoxelDensityProfile::FScopedTimer MesherSetupTimer(
VoxelDensityProfile::EBucket::MesherOther);
// Cell size in voxels. No upper clamp: coarse clipmap levels use bigger steps (the EXTENT
// grows). Coarse tiles also use FEWER cells (InCellsPerAxis) for cheaper gen.
@@ -29,6 +120,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
const int32 OctaveBias = (LODOctaveDrop > 0 && Step > 1)
? LODOctaveDrop * (int32)FMath::FloorLog2((uint32)Step) : 0;
TGuardValue<int32> OctaveBiasGuard(VoxelGenLOD::OctaveBias, OctaveBias);
TGuardValue<int32> SampleStepGuard(VoxelGenLOD::SampleStep, Step);
// World-cm origin of the tile's min corner (positions are built relative to this).
const FVector ChunkWorldPos = FVector(OriginVoxels) * VOXEL_SIZE;
@@ -195,7 +287,100 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
const int32 CellsPerAxis = FMath::Clamp(InCellsPerAxis, 2, CHUNK_SIZE); // coarse tiles use fewer
const int32 GridDim = CellsPerAxis + 1;
const int32 MDim = GridDim + 2; // +1 marge de chaque côté
TGuardValue<FIntVector> TileOriginGuard(
VoxelGenLOD::TileOriginVoxels, OriginVoxels);
TGuardValue<int32> TileCellsGuard(
VoxelGenLOD::TileCellsPerAxis, CellsPerAxis);
// Every density sample in this tile sees the same post-reach decision. The one-point halo
// matches the actual grid fill below (gx/gy/gz = -1..GridDim), so a false bit proves that no
// landing or passage structural writer can reach any sample used by meshing or gradients.
TGuardValue<bool> TilePostReachDebugGuard(
VoxelGenLOD::bTilePostReachDebug,
GVoxelForgeTilePostReachDebug != 0);
TGuardValue<bool> TilePostReachBypassGuard(
VoxelGenLOD::bTilePostReachBypass, false);
TGuardValue<bool> TileReachDiagnosticGuard(
VoxelGenLOD::bTileReachDiagnosticTile, true);
TGuardValue<bool> TileCoreReachProofGuard(
VoxelGenLOD::bTileCoreReachProofEnabled, false);
TGuardValue<bool> TileCoreReachDecisionGuard(
VoxelGenLOD::bTileCoreReachDecisionRecorded, false);
TGuardValue<float> TileReachScaleGuard(
VoxelGenLOD::TileReachScale, GVoxelForgeTilePostReachScale);
const float ReachScale = GVoxelForgeTilePostReachScale;
const int32 ReachBlocksPerAxis = FMath::DivideAndRoundUp(
CellsPerAxis, VoxelGenLOD::TileReachBlockCells);
TGuardValue<int32> TileReachBlocksGuard(
VoxelGenLOD::TileReachBlocksPerAxis, ReachBlocksPerAxis);
TGuardValue<int32> TileReachBlockIndexGuard(
VoxelGenLOD::TileReachBlockIndex, INDEX_NONE);
TGuardValue<bool> TileBlockReachValidGuard(
VoxelGenLOD::bTileBlockReachValid, false);
uint8 TilePostReachFlags = VoxelGenLOD::AllTilePostReach;
if (Generator->StrateManager != nullptr)
{
const int64 Extent = static_cast<int64>(CellsPerAxis + 1) * Step;
const FBox LatticeWithHalo(
FVector(
static_cast<float>(static_cast<int64>(OriginVoxels.X) - Step),
static_cast<float>(static_cast<int64>(OriginVoxels.Y) - Step),
static_cast<float>(static_cast<int64>(OriginVoxels.Z) - Step)),
FVector(
static_cast<float>(static_cast<int64>(OriginVoxels.X) + Extent),
static_cast<float>(static_cast<int64>(OriginVoxels.Y) + Extent),
static_cast<float>(static_cast<int64>(OriginVoxels.Z) + Extent)));
// Core stays conservative until the tile-window tunnel cache is built. Passage carving
// is manager-owned and can be decided immediately from the same lattice+halo box.
TilePostReachFlags = VoxelGenLOD::TunnelCoreReachable;
if (Generator->StrateManager->AnyOriginLandingNearLattice(
LatticeWithHalo, OriginVoxels, Step, ReachScale))
{
TilePostReachFlags |= VoxelGenLOD::OriginLandingReachable;
}
if (Generator->StrateManager->AnyPassageLandingNearLattice(
LatticeWithHalo, OriginVoxels, Step, ReachScale))
{
TilePostReachFlags |= VoxelGenLOD::PassageLandingReachable;
}
if (Generator->StrateManager->AnyPassageCarvingNearLattice(
LatticeWithHalo, OriginVoxels, Step, ReachScale))
{
TilePostReachFlags |= VoxelGenLOD::PassageCarvingReachable;
}
if (Generator->StrateManager->AnyPassageStructuralPostNearLattice(
LatticeWithHalo, OriginVoxels, Step, ReachScale))
{
TilePostReachFlags |= VoxelGenLOD::PassageStructuralReachable;
}
}
TGuardValue<uint8> TilePostReachFlagsGuard(
VoxelGenLOD::TilePostReachFlags, TilePostReachFlags);
for (int32 BlockZ = 0; BlockZ < ReachBlocksPerAxis; ++BlockZ)
{
for (int32 BlockY = 0; BlockY < ReachBlocksPerAxis; ++BlockY)
{
for (int32 BlockX = 0; BlockX < ReachBlocksPerAxis; ++BlockX)
{
const int32 BlockIndex =
((BlockZ * ReachBlocksPerAxis) + BlockY) * ReachBlocksPerAxis + BlockX;
uint8 BlockFlags = TilePostReachFlags;
if (Generator->StrateManager != nullptr
&& (TilePostReachFlags & VoxelGenLOD::PassageCarvingReachable) != 0
&& !Generator->StrateManager->AnyPassageCarvingNearLattice(
VoxelGenLOD::MakeTileReachBlockBox(
OriginVoxels, Step, CellsPerAxis,
BlockX, BlockY, BlockZ),
OriginVoxels, Step, ReachScale))
{
BlockFlags &= ~VoxelGenLOD::PassageCarvingReachable;
}
VoxelGenLOD::TileBlockReachFlags[BlockIndex] = BlockFlags;
}
}
}
VoxelGenLOD::bTileBlockReachValid = true;
VoxelGenLOD::RecordTileReachMask(TilePostReachFlags);
// COUPE DE CONTENU PAR STRATE — restreint le maillage (et l'échantillonnage) aux cellules
// dont l'intervalle Z chevauche la bande [BandZMinVox, BandZMaxVox] (voxels inclusifs).
// Les tuiles à capture ne sont jamais bandées (niveau 0 — garde-fou ci-dessous).
@@ -217,24 +402,428 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
const int32 GzLo = CzLo - 1; // coins CzLo..CzHi+1, gradients ±1
const int32 GzHi = CzHi + 2; // (== -1..GridDim sans bande)
MesherSetupTimer.End();
// Optional 8x8x8-cell block proof. The tile classifier has already returned Mixed, but
// that is a conservative answer over the whole tile. A tile can still be uniformly solid
// (or uniformly air) when its smaller blocks are each provable. Do this before allocating
// or filling the density grid: the grid is only needed when at least one block can contain
// a surface. A whole-tile early return is allowed only when every relevant block has the
// SAME verdict; adjacent uniform blocks with opposite signs can contain a surface between
// them. This is a proof-only optimization: ClassifyTile is conservative and no samples or
// resolution are removed from a tile that reaches the mesher.
TArray<uint8> BlockSkip;
int32 BlocksPerAxis = 0;
// LOD2's 16-cell tile is the remaining false-Mixed empty case in the streamed game path.
// Its step-8 lattice can use the same proof-only 8-cell block partition as LOD0. Keep the
// gate off for steps 2/16/32: their meshing work is already smaller than repeating a full
// tile classifier per block, and this preserves the established coarse-LOD latency profile.
const bool bUseProofBlocks = bUseBlockEarlyOut && !OutCaptureGrid
&& (Step == 1 || Step == 8);
if (bUseProofBlocks)
{
constexpr int32 BlockCells = 8;
BlocksPerAxis = (CellsPerAxis + BlockCells - 1) / BlockCells;
BlockSkip.Init(0, BlocksPerAxis * BlocksPerAxis * BlocksPerAxis);
bool bHaveUniformBlock = false;
EVoxelTileClass FirstUniformVerdict = EVoxelTileClass::Mixed;
bool bAllRelevantBlocksUniform = true;
bool bAllRelevantBlocksSame = true;
TArray<int32> MixedBlockIndices;
for (int32 BlockZ = 0; BlockZ < BlocksPerAxis; ++BlockZ)
{
const int32 BlockMinZ = BlockZ * BlockCells;
const int32 BlockMaxZ = FMath::Min(CellsPerAxis - 1,
BlockMinZ + BlockCells - 1);
if (BlockMaxZ < CzLo || BlockMinZ > CzHi)
{
continue;
}
for (int32 BlockY = 0; BlockY < BlocksPerAxis; ++BlockY)
{
for (int32 BlockX = 0; BlockX < BlocksPerAxis; ++BlockX)
{
const FIntVector BlockOrigin = OriginVoxels + FIntVector(
BlockX * BlockCells * Step,
BlockY * BlockCells * Step,
BlockMinZ * Step);
if (VoxelDensityProfile::AreCountersEnabled())
{
VoxelDensityProfile::AddCounter(
VoxelDensityProfile::ECounter::MesherBlockTests);
}
const bool bAttributeClassifier =
VoxelDensityProfile::GetMode()
== VoxelDensityProfile::EMode::Attribution;
const bool bSampleClassifier = bAttributeClassifier
&& VoxelDensityProfile::ShouldSample(
VoxelDensityProfile::EBucket::ClassifierTotal);
const uint64 ClassifierStartCycles = bSampleClassifier
? FPlatformTime::Cycles64() : 0;
FVoxelTileClassificationStats ClassifierStats;
if (bAttributeClassifier)
{
VoxelDensityProfile::RecordClassifierCall(bSampleClassifier);
}
const EVoxelTileClass Verdict = Generator->ClassifyTile(
BlockOrigin, Step, BlockCells,
bSampleClassifier ? &ClassifierStats : nullptr);
if (bSampleClassifier)
{
VoxelDensityProfile::AddSampledMeasurement(
VoxelDensityProfile::EBucket::ClassifierTotal,
FPlatformTime::Cycles64() - ClassifierStartCycles);
VoxelDensityProfile::FSnapshot::FClassifierStats ProfileStats;
ProfileStats.RefineNodes = ClassifierStats.RefineNodes;
ProfileStats.StackBoxCalls = ClassifierStats.StackBoxCalls;
ProfileStats.WholeMixedNodes = ClassifierStats.WholeMixedNodes;
ProfileStats.WholeSolidNodes = ClassifierStats.WholeSolidNodes;
ProfileStats.WholeAirNodes = ClassifierStats.WholeAirNodes;
ProfileStats.NeedsFinalFieldNodes = ClassifierStats.NeedsFinalFieldNodes;
ProfileStats.SplitNodes = ClassifierStats.SplitNodes;
ProfileStats.MaxRefinementDepth = ClassifierStats.MaxRefinementDepth;
ProfileStats.ExactCoreSamples = ClassifierStats.ExactCoreSamples;
ProfileStats.ExactFinalSamples = ClassifierStats.ExactFinalSamples;
ProfileStats.ExactCoreCacheHits = ClassifierStats.ExactCoreCacheHits;
ProfileStats.ExactFinalCacheHits = ClassifierStats.ExactFinalCacheHits;
ProfileStats.ExactCoreLeaves = ClassifierStats.ExactCoreLeaves;
ProfileStats.ExactFinalLeaves = ClassifierStats.ExactFinalLeaves;
ProfileStats.StackBoxCycles = ClassifierStats.StackBoxCycles;
ProfileStats.ExactCoreCycles = ClassifierStats.ExactCoreCycles;
ProfileStats.ExactFinalCycles = ClassifierStats.ExactFinalCycles;
ProfileStats.RoomTailQueries = ClassifierStats.RoomTailQueries;
ProfileStats.RoomTailEvaluated = ClassifierStats.RoomTailEvaluated;
ProfileStats.RoomTailCycles = ClassifierStats.RoomTailCycles;
ProfileStats.RoomPropagateCycles = ClassifierStats.RoomPropagateCycles;
ProfileStats.RoomExactPrimitiveCycles = ClassifierStats.RoomExactPrimitiveCycles;
ProfileStats.RoomCacheWindowCycles = ClassifierStats.RoomCacheWindowCycles;
ProfileStats.RoomNumRooms = FMath::Max(ClassifierStats.RoomNumRooms, 0);
ProfileStats.RoomNumTunnels = FMath::Max(ClassifierStats.RoomNumTunnels, 0);
ProfileStats.RoomNumRoomFloorJoins = FMath::Max(
ClassifierStats.RoomNumRoomFloorJoins, 0);
ProfileStats.RoomNumPits = FMath::Max(ClassifierStats.RoomNumPits, 0);
ProfileStats.RoomNumChimneys = FMath::Max(ClassifierStats.RoomNumChimneys, 0);
VoxelDensityProfile::AddClassifierStats(ProfileStats);
if (ClassifierStats.StackBoxCalls > 0)
{
VoxelDensityProfile::AddMeasurement(
VoxelDensityProfile::EBucket::ClassifierIntervalProof,
0, ClassifierStats.StackBoxCalls);
VoxelDensityProfile::AddSampledMeasurement(
VoxelDensityProfile::EBucket::ClassifierIntervalProof,
ClassifierStats.StackBoxCycles);
}
if (ClassifierStats.ExactCoreLeaves > 0)
{
VoxelDensityProfile::AddMeasurement(
VoxelDensityProfile::EBucket::ClassifierExactCore,
0, ClassifierStats.ExactCoreLeaves);
VoxelDensityProfile::AddSampledMeasurement(
VoxelDensityProfile::EBucket::ClassifierExactCore,
ClassifierStats.ExactCoreCycles);
}
if (ClassifierStats.ExactFinalLeaves > 0)
{
VoxelDensityProfile::AddMeasurement(
VoxelDensityProfile::EBucket::ClassifierExactFinal,
0, ClassifierStats.ExactFinalLeaves);
VoxelDensityProfile::AddSampledMeasurement(
VoxelDensityProfile::EBucket::ClassifierExactFinal,
ClassifierStats.ExactFinalCycles);
}
if (ClassifierStats.RoomTailQueries > 0)
{
VoxelDensityProfile::AddMeasurement(
VoxelDensityProfile::EBucket::ClassifierRoomTail,
0, ClassifierStats.RoomTailQueries);
VoxelDensityProfile::AddSampledMeasurement(
VoxelDensityProfile::EBucket::ClassifierRoomTail,
ClassifierStats.RoomTailCycles);
}
}
if (Verdict == EVoxelTileClass::Mixed)
{
MixedBlockIndices.Add(
((BlockZ * BlocksPerAxis) + BlockY) * BlocksPerAxis + BlockX);
}
if (Verdict == EVoxelTileClass::AllSolid
|| Verdict == EVoxelTileClass::AllAir)
{
// Keep the classifier result as a candidate until the exact density grid
// used by this mesher has been filled. ClassifyTile is conservative by
// contract, but its proof and the mesher share worker-local caches; a
// cache replacement must never turn that contract into a cell skip by
// itself. The post-grid sign check below is the final proof consumed by
// the cell loop and clears any candidate whose actual MC vertices disagree.
BlockSkip[((BlockZ * BlocksPerAxis) + BlockY) * BlocksPerAxis + BlockX] =
Verdict == EVoxelTileClass::AllSolid ? 1 : 2;
if (!bHaveUniformBlock)
{
FirstUniformVerdict = Verdict;
bHaveUniformBlock = true;
}
else if (FirstUniformVerdict != Verdict)
{
bAllRelevantBlocksSame = false;
}
}
else
{
bAllRelevantBlocksUniform = false;
}
}
}
}
// A conservative whole-tile proof can leave a small block unresolved even when every
// other block has the same verdict. Resolve that tail on the exact MC lattice. This uses
// the final GetDensityAt field, checks every cell corner, treats zero/non-finite values
// as unknown, and never changes the grid resolution or skips an uncertified block.
if (bHaveUniformBlock && bAllRelevantBlocksSame && !bAllRelevantBlocksUniform
&& MixedBlockIndices.Num() <= 4)
{
int32 RemainingMixedBlocks = MixedBlockIndices.Num();
for (const int32 MixedBlockIndex : MixedBlockIndices)
{
const int32 BlockX = MixedBlockIndex % BlocksPerAxis;
const int32 BlockY = (MixedBlockIndex / BlocksPerAxis) % BlocksPerAxis;
const int32 BlockZ = MixedBlockIndex / (BlocksPerAxis * BlocksPerAxis);
const FIntVector BlockOrigin = OriginVoxels + FIntVector(
BlockX * BlockCells * Step,
BlockY * BlockCells * Step,
BlockZ * BlockCells * Step);
bool bAllSolid = true;
bool bAllAir = true;
for (int32 Z = 0; Z <= BlockCells && (bAllSolid || bAllAir); ++Z)
{
for (int32 Y = 0; Y <= BlockCells && (bAllSolid || bAllAir); ++Y)
{
for (int32 X = 0; X <= BlockCells; ++X)
{
const float Density = Generator->GetDensityAt(
BlockOrigin.X + X * Step,
BlockOrigin.Y + Y * Step,
BlockOrigin.Z + Z * Step);
if (!VoxelMath::IsFinite(Density) || Density == 0.0f)
{
bAllSolid = false;
bAllAir = false;
break;
}
bAllSolid &= Density < 0.0f;
bAllAir &= Density > 0.0f;
if (!bAllSolid && !bAllAir)
{
break;
}
}
}
}
const EVoxelTileClass ExactVerdict = bAllSolid
? EVoxelTileClass::AllSolid
: (bAllAir ? EVoxelTileClass::AllAir : EVoxelTileClass::Mixed);
if (ExactVerdict == EVoxelTileClass::AllSolid
|| ExactVerdict == EVoxelTileClass::AllAir)
{
BlockSkip[MixedBlockIndex] =
ExactVerdict == EVoxelTileClass::AllSolid ? 1 : 2;
--RemainingMixedBlocks;
if (ExactVerdict != FirstUniformVerdict)
{
bAllRelevantBlocksSame = false;
}
}
}
bAllRelevantBlocksUniform = RemainingMixedBlocks == 0;
}
// Every relevant block has independently proved the same sign. This is a complete
// certificate for the tile's MC lattice: the block boxes share their boundary vertices,
// so equal signs on both sides cannot hide a surface between blocks. The whole-tile
// ClassifyTile path already relies on this same non-Mixed proof contract. Return before
// allocating the density grid; no samples are skipped for a tile that can contain
// geometry, and the final geometry remains byte-identical because this path emits none.
if (bHaveUniformBlock && bAllRelevantBlocksSame && bAllRelevantBlocksUniform)
{
return MeshData;
}
// A mixed or differently signed block set still needs the exact mesher grid. Classifier
// results remain candidates only in that case; the grid validation below keeps the cell
// skip independent of worker-local cache state and preserves geometry byte-for-byte.
}
// Réutilise le tampon entre tuiles (thread_local) : SetNumUninitialized garde la
// capacité, donc plus de malloc/free de ~170 Ko (35³ floats) par tuile.
static thread_local TArray<float> DensityGrid;
DensityGrid.SetNumUninitialized(MDim * MDim * MDim);
const FVoxelSharedDensityGrid* ReuseGrid = SharedDensityGrid;
const int32 SharedDeltaX = ReuseGrid
? (OriginVoxels.X - ReuseGrid->OriginVoxels.X) / Step : 0;
const int32 SharedDeltaY = ReuseGrid
? (OriginVoxels.Y - ReuseGrid->OriginVoxels.Y) / Step : 0;
const int32 SharedDeltaZ = ReuseGrid
? (OriginVoxels.Z - ReuseGrid->OriginVoxels.Z) / Step : 0;
const bool bUseSharedDensityGrid = ReuseGrid
&& ReuseGrid->Step == Step
&& ReuseGrid->Dim > 0
&& ReuseGrid->Samples.Num() == ReuseGrid->Dim * ReuseGrid->Dim * ReuseGrid->Dim
&& (OriginVoxels.X - ReuseGrid->OriginVoxels.X) % Step == 0
&& (OriginVoxels.Y - ReuseGrid->OriginVoxels.Y) % Step == 0
&& (OriginVoxels.Z - ReuseGrid->OriginVoxels.Z) % Step == 0
&& SharedDeltaX - 1 >= 0
&& SharedDeltaY - 1 >= 0
&& SharedDeltaZ - 1 >= 0
&& SharedDeltaX + GridDim < ReuseGrid->Dim
&& SharedDeltaY + GridDim < ReuseGrid->Dim
&& SharedDeltaZ + GridDim < ReuseGrid->Dim;
// Bande de strate : seules les rangées Z réellement lues (cellules CzLo..CzHi + marges de
// gradient) sont échantillonnées — le reste du tampon reste non initialisé et non lu.
VoxelDensityProfile::FScopedTimer MesherDensityGridTimer(
VoxelDensityProfile::EBucket::MesherDensityGrid);
const bool bUseOperatorBlock = !bUseSharedDensityGrid
&& GVoxelForgeUseOperatorBlock != 0;
if (bUseOperatorBlock)
{
// The grid includes one sample of halo on every side. The generator groups these exact
// lattice points by chunk key and lets the prepared op stack fill each group once.
Generator->BeginDensityBlock(
OriginVoxels + FIntVector(-Step, -Step, GzLo * Step),
Step, MDim, MDim, GzHi - GzLo + 1);
}
for (int32 gz = GzLo; gz <= GzHi; gz++)
{
if (ShouldAbortWork())
{
if (bUseOperatorBlock) { Generator->EndDensityBlock(); }
return FVoxelMeshData();
}
for (int32 gy = -1; gy <= GridDim; gy++)
{
if (ShouldAbortWork())
{
if (bUseOperatorBlock) { Generator->EndDensityBlock(); }
return FVoxelMeshData();
}
for (int32 gx = -1; gx <= GridDim; gx++)
{
// World voxel = tile origin + grid offset scaled by the cell size (Step).
DensityGrid[((gz + 1) * MDim + (gy + 1)) * MDim + (gx + 1)] =
Generator->GetDensityAt(
const int32 LocalIndex = ((gz + 1) * MDim + (gy + 1)) * MDim + (gx + 1);
if (bUseSharedDensityGrid)
{
const int32 SharedX = SharedDeltaX + gx;
const int32 SharedY = SharedDeltaY + gy;
const int32 SharedZ = SharedDeltaZ + gz;
DensityGrid[LocalIndex] = ReuseGrid->Samples[
(SharedZ * ReuseGrid->Dim + SharedY) * ReuseGrid->Dim + SharedX];
}
else
{
// World voxel = tile origin + grid offset scaled by the cell size (Step).
const int32 ReachBlockX = FMath::Clamp(gx, 0, CellsPerAxis - 1)
/ VoxelGenLOD::TileReachBlockCells;
const int32 ReachBlockY = FMath::Clamp(gy, 0, CellsPerAxis - 1)
/ VoxelGenLOD::TileReachBlockCells;
const int32 ReachBlockZ = FMath::Clamp(gz, 0, CellsPerAxis - 1)
/ VoxelGenLOD::TileReachBlockCells;
VoxelGenLOD::TileReachBlockIndex =
((ReachBlockZ * ReachBlocksPerAxis) + ReachBlockY)
* ReachBlocksPerAxis + ReachBlockX;
DensityGrid[LocalIndex] = Generator->GetDensityAt(
OriginVoxels.X + gx * Step,
OriginVoxels.Y + gy * Step,
OriginVoxels.Z + gz * Step);
if (OutDensitySampleCount)
{
++(*OutDensitySampleCount);
}
}
}
}
}
if (VoxelDensityProfile::AreCountersEnabled())
{
const uint64 GridSampleCount = static_cast<uint64>(GzHi - GzLo + 1)
* static_cast<uint64>(MDim)
* static_cast<uint64>(MDim);
VoxelDensityProfile::AddCounter(
VoxelDensityProfile::ECounter::MesherDensityGridBytes,
GridSampleCount * sizeof(float));
if (bUseSharedDensityGrid)
{
VoxelDensityProfile::AddCounter(
VoxelDensityProfile::ECounter::MesherSharedGridReadBytes,
GridSampleCount * sizeof(float));
}
}
if (bUseOperatorBlock)
{
Generator->EndDensityBlock();
}
MesherDensityGridTimer.End();
if (bUseProofBlocks)
{
// Finalize every block candidate against the exact density samples that the marching-
// cubes loop will consume. This is intentionally redundant with ClassifyTile: it is a
// cheap soundness fence around the proof/cache hand-off. Unknown, zero, or opposite-sign
// vertices clear the candidate, so an unproven block pays the ordinary cell path.
constexpr int32 BlockCells = 8;
for (int32 BlockZ = 0; BlockZ < BlocksPerAxis; ++BlockZ)
{
const int32 BlockMinZ = BlockZ * BlockCells;
const int32 BlockMaxZ = FMath::Min(CellsPerAxis - 1,
BlockMinZ + BlockCells - 1);
if (BlockMaxZ < CzLo || BlockMinZ > CzHi)
{
continue;
}
for (int32 BlockY = 0; BlockY < BlocksPerAxis; ++BlockY)
for (int32 BlockX = 0; BlockX < BlocksPerAxis; ++BlockX)
{
const int32 BlockIndex =
((BlockZ * BlocksPerAxis) + BlockY) * BlocksPerAxis + BlockX;
const uint8 Candidate = BlockSkip[BlockIndex];
if (Candidate == 0)
{
continue;
}
const int32 BlockMaxX = FMath::Min(CellsPerAxis - 1,
BlockX * BlockCells + BlockCells - 1);
const int32 BlockMaxY = FMath::Min(CellsPerAxis - 1,
BlockY * BlockCells + BlockCells - 1);
bool bUniform = true;
for (int32 Z = BlockMinZ; Z <= BlockMaxZ + 1 && bUniform; ++Z)
for (int32 Y = BlockY * BlockCells; Y <= BlockMaxY + 1 && bUniform; ++Y)
for (int32 X = BlockX * BlockCells; X <= BlockMaxX + 1; ++X)
{
const float Density = DensityGrid[
((Z + 1) * MDim + (Y + 1)) * MDim + (X + 1)];
if (!VoxelMath::IsFinite(Density)
|| Density == 0.0f
|| (Candidate == 1 ? Density >= 0.0f : Density <= 0.0f))
{
bUniform = false;
break;
}
}
if (!bUniform)
{
BlockSkip[BlockIndex] = 0;
continue;
}
if (VoxelDensityProfile::AreCountersEnabled())
{
VoxelDensityProfile::AddCounter(
Candidate == 1
? VoxelDensityProfile::ECounter::MesherBlockAllSolid
: VoxelDensityProfile::ECounter::MesherBlockAllAir);
}
}
}
}
@@ -287,12 +876,27 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
static thread_local TArray<int32> CapTris;
GroundTris.Reset();
CapTris.Reset();
const bool bProfileMesher = VoxelDensityProfile::IsCycleTimingEnabled();
uint64 CellClassificationCalls = 0;
uint64 SurfaceCellCalls = 0;
for (int32 cz = CzLo; cz <= CzHi; cz++) // bande de strate : cf. CzLo/CzHi plus haut
{
if (ShouldAbortWork()) return FVoxelMeshData();
for (int32 cy = 0; cy < CellsPerAxis; cy++)
{
if (ShouldAbortWork()) return FVoxelMeshData();
for (int32 cx = 0; cx < CellsPerAxis; cx++)
{
if (bUseProofBlocks
&& BlockSkip[((cz / 8) * BlocksPerAxis + (cy / 8)) * BlocksPerAxis + (cx / 8)] != 0)
{
continue;
}
const bool bSampleCell = bProfileMesher
&& VoxelDensityProfile::ShouldSample(
VoxelDensityProfile::EBucket::MesherCellClassification);
const uint64 ClassificationStart = bSampleCell
? FPlatformTime::Cycles64() : 0;
// PASSE 1 : densités aux 8 coins + index de cas MC SEULEMENT.
// ~70% des cellules d'un chunk sont tout-roc ou tout-air (aucune surface) ;
// on les rejette ICI, AVANT de payer les 8 positions + 8 gradients (48 lectures
@@ -309,9 +913,20 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
if (D >= IsoLevel) CaseIndex |= (1 << i);
}
if (MCEdgeTable[CaseIndex] == 0) continue; // Pas de surface ici → skip
const bool bHasSurface = MCEdgeTable[CaseIndex] != 0;
if (bSampleCell)
{
VoxelDensityProfile::AddSampledMeasurement(
VoxelDensityProfile::EBucket::MesherCellClassification,
FPlatformTime::Cycles64() - ClassificationStart);
}
++CellClassificationCalls;
if (!bHasSurface) continue; // Pas de surface ici → skip
++SurfaceCellCalls;
// PASSE 2 : positions + gradients aux 8 coins (uniquement si surface présente).
const uint64 GradientStart = bSampleCell
? FPlatformTime::Cycles64() : 0;
FVector Positions[8];
FVector Gradients[8];
for (int32 i = 0; i < 8; i++)
@@ -323,10 +938,18 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
+ FVector(GX * Step, GY * Step, GZ * Step) * VOXEL_SIZE;
Gradients[i] = GradAt(GX, GY, GZ);
}
if (bSampleCell)
{
VoxelDensityProfile::AddSampledMeasurement(
VoxelDensityProfile::EBucket::MesherGradientNormals,
FPlatformTime::Cycles64() - GradientStart);
}
// Interpolation des positions + normales sur les arêtes traversées. t = point de
// traversée de l'iso entre les deux coins (clampé, milieu si densités quasi-égales) ;
// la normale interpole les gradients de coin par le même t.
const uint64 VertexInterpolationStart = bSampleCell
? FPlatformTime::Cycles64() : 0;
FVector EdgeVertices[12];
FVector EdgeNormals[12];
for (int32 i = 0; i < 12; i++)
@@ -343,9 +966,17 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
EdgeNormals[i] = Gradients[A] + T * (Gradients[B] - Gradients[A]);
}
}
if (bSampleCell)
{
VoxelDensityProfile::AddSampledMeasurement(
VoxelDensityProfile::EBucket::MesherVertexInterpolation,
FPlatformTime::Cycles64() - VertexInterpolationStart);
}
// Génère les triangles avec vertices dédupliqués.
// Ordre 0, 2, 1 (pas 0, 1, 2) pour le winding attendu par RealtimeMesh.
const uint64 StreamBuildingStart = bSampleCell
? FPlatformTime::Cycles64() : 0;
for (int32 i = 0; MCTriTable[CaseIndex][i] != -1; i += 3)
{
const int32 E0 = MCTriTable[CaseIndex][i];
@@ -364,10 +995,35 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
Dst.Add(Idx2);
Dst.Add(Idx1);
}
if (bSampleCell)
{
VoxelDensityProfile::AddSampledMeasurement(
VoxelDensityProfile::EBucket::MesherStreamBuilding,
FPlatformTime::Cycles64() - StreamBuildingStart);
}
}
}
}
if (bProfileMesher)
{
VoxelDensityProfile::AddMeasurement(
VoxelDensityProfile::EBucket::MesherCellClassification,
0, CellClassificationCalls);
VoxelDensityProfile::AddMeasurement(
VoxelDensityProfile::EBucket::MesherGradientNormals,
0, SurfaceCellCalls);
VoxelDensityProfile::AddMeasurement(
VoxelDensityProfile::EBucket::MesherVertexInterpolation,
0, SurfaceCellCalls);
VoxelDensityProfile::AddMeasurement(
VoxelDensityProfile::EBucket::MesherStreamBuilding,
0, SurfaceCellCalls);
}
VoxelDensityProfile::FScopedTimer MesherFinalisationTimer(
VoxelDensityProfile::EBucket::MesherOther);
//=========================================================================
// SKIRTS — boucheurs de fissures aux coutures de LOD (clipmap)
//=========================================================================
@@ -448,6 +1104,26 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
MeshData.Triangles.Append(CapTris);
MeshData.NumCeilingTriangles = CapTris.Num() / 3;
if (VoxelDensityProfile::AreCountersEnabled())
{
const uint64 OutputArrayBytes = static_cast<uint64>(MeshData.Vertices.Num()) * sizeof(FVector)
+ static_cast<uint64>(MeshData.Normals.Num()) * sizeof(FVector)
+ static_cast<uint64>(MeshData.UVs.Num()) * sizeof(FVector2D)
+ static_cast<uint64>(MeshData.Colors.Num()) * sizeof(FColor)
+ static_cast<uint64>(MeshData.Triangles.Num()) * sizeof(int32);
const uint64 OutputAllocatedBytes = static_cast<uint64>(MeshData.Vertices.GetAllocatedSize())
+ static_cast<uint64>(MeshData.Normals.GetAllocatedSize())
+ static_cast<uint64>(MeshData.UVs.GetAllocatedSize())
+ static_cast<uint64>(MeshData.Colors.GetAllocatedSize())
+ static_cast<uint64>(MeshData.Triangles.GetAllocatedSize());
VoxelDensityProfile::AddCounter(
VoxelDensityProfile::ECounter::MesherOutputArrayBytes,
OutputArrayBytes);
VoxelDensityProfile::AddCounter(
VoxelDensityProfile::ECounter::MesherOutputAllocatedBytes,
OutputAllocatedBytes);
}
return MeshData;
}
@@ -498,6 +1174,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateSheetMesh(FIntVector OriginVox
SheetCols.SetNumUninitialized(MDim * MDim);
for (int32 gy = -1; gy <= GridDim; ++gy)
{
if (ShouldAbortWork()) return FVoxelMeshData();
for (int32 gx = -1; gx <= GridDim; ++gx)
{
float Tz = 0.0f, Cz = 0.0f;
@@ -572,6 +1249,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateSheetMesh(FIntVector OriginVox
for (int32 cy = 0; cy < CellsXY; ++cy)
{
if (ShouldAbortWork()) return FVoxelMeshData();
for (int32 cx = 0; cx < CellsXY; ++cx)
{
if (CellInHole(cx, cy)) continue; // couverte par les coquilles MC proches
@@ -638,6 +1316,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateSheetMesh(FIntVector OriginVox
// (pas dans le trou XY) — sinon mur flottant sans surface.
for (int32 c = 0; c < CellsXY; ++c)
{
if (ShouldAbortWork()) return FVoxelMeshData();
for (int32 Pass = 0; Pass < 2; ++Pass)
{
const bool bCap = (Pass == 1);
@@ -0,0 +1,135 @@
#include "VoxelSeasonAsset.h"
#include "VoxelSeasonManifest.h"
#include "VoxelDensityOpStack.h"
#if WITH_EDITOR
#include "Misc/FileHelper.h"
#endif
namespace
{
bool ValidateRuntimeRecipes(const FVoxelSeasonManifest& Manifest, FString& OutReport)
{
for (const FVoxelSeasonStrate& Strate : Manifest.Strates)
{
if (!Strate.bUsesRecipe) continue;
FVoxelOpStack ValidationStack;
FVoxelOpContext ValidationContext;
FString BuildError;
if (!VF_BuildSeasonStrateStack(
Strate, Manifest.OriginSpineRadius, nullptr,
ValidationStack, ValidationContext, &BuildError))
{
OutReport = FString::Printf(
TEXT("season slot %d recipe is not materialisable: %s"),
Strate.DepthIndex, *BuildError);
return false;
}
}
return true;
}
}
FPrimaryAssetId UVoxelSeasonAsset::GetPrimaryAssetId() const
{
return FPrimaryAssetId(TEXT("VoxelSeason"), GetFName());
}
bool UVoxelSeasonAsset::LoadManifest(FVoxelSeasonManifest& OutManifest, FString& OutReport) const
{
if (ManifestJson.IsEmpty())
{
OutReport = TEXT("season asset contains no imported manifest JSON");
OutManifest = FVoxelSeasonManifest();
return false;
}
if (!VF_DeserializeVoxelSeasonManifest(ManifestJson, OutManifest, OutReport))
{
return false;
}
if (!OutManifest.ContentHash.Equals(ManifestContentHash, ESearchCase::IgnoreCase)
|| OutManifest.SchemaVersion != SchemaVersion
|| OutManifest.Season != SeasonNumber
|| OutManifest.Seed != SeasonSeed
|| FMemory::Memcmp(&OutManifest.OriginSpineRadius,
&OriginSpineRadius, sizeof(float)) != 0
|| FMemory::Memcmp(&OutManifest.WorldRadiusVoxels,
&WorldRadiusVoxels, sizeof(float)) != 0)
{
OutReport = TEXT("season asset metadata does not match its embedded manifest; re-import the JSON");
OutManifest.bValid = false;
OutManifest.Error = OutReport;
return false;
}
if (!ValidateRuntimeRecipes(OutManifest, OutReport))
{
OutManifest.bValid = false;
OutManifest.Error = OutReport;
return false;
}
return true;
}
bool UVoxelSeasonAsset::SetManifestJson(const FString& InManifestJson, FString& OutReport)
{
FVoxelSeasonManifest Parsed;
if (!VF_DeserializeVoxelSeasonManifest(InManifestJson, Parsed, OutReport))
{
return false;
}
if (!ValidateRuntimeRecipes(Parsed, OutReport))
{
return false;
}
Modify();
ManifestJson = InManifestJson;
SchemaVersion = Parsed.SchemaVersion;
SeasonNumber = Parsed.Season;
SeasonSeed = Parsed.Seed;
OriginSpineRadius = Parsed.OriginSpineRadius;
WorldRadiusVoxels = Parsed.WorldRadiusVoxels;
ManifestContentHash = Parsed.ContentHash;
ReferencedDefinitions.Reset();
TSet<FString> SeenDefinitionPaths;
for (const FVoxelSeasonStrate& Strate : Parsed.Strates)
{
if (!Strate.SourceDefinitionPath.IsEmpty()
&& !SeenDefinitionPaths.Contains(Strate.SourceDefinitionPath))
{
SeenDefinitionPaths.Add(Strate.SourceDefinitionPath);
ReferencedDefinitions.Add(TSoftObjectPtr<UVoxelStrateDefinition>(
FSoftObjectPath(Strate.SourceDefinitionPath)));
}
}
MarkPackageDirty();
OutReport = FString::Printf(
TEXT("imported season %d seed %d (%d strates, hash %s)"),
SeasonNumber, SeasonSeed, Parsed.Strates.Num(), *ManifestContentHash);
return true;
}
void UVoxelSeasonAsset::ImportSeasonManifestJson()
{
#if WITH_EDITOR
FString JsonText;
FString Report;
if (SourceManifestJson.FilePath.IsEmpty()
|| !FFileHelper::LoadFileToString(JsonText, *SourceManifestJson.FilePath))
{
UE_LOG(LogTemp, Error, TEXT("[VoxelSeasonAsset] Could not read manifest JSON '%s'."),
*SourceManifestJson.FilePath);
return;
}
if (!SetManifestJson(JsonText, Report))
{
UE_LOG(LogTemp, Error, TEXT("[VoxelSeasonAsset] Import failed: %s"), *Report);
return;
}
UE_LOG(LogTemp, Log, TEXT("[VoxelSeasonAsset] %s"), *Report);
#else
UE_LOG(LogTemp, Error, TEXT("[VoxelSeasonAsset] JSON import is editor-only."));
#endif
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,51 @@
#include "VoxelSettings.h"
#include "VoxelSeasonAsset.h"
int32 UVoxelSettings::GetEffectiveWorldSeed() const
{
if (!Season.IsNull())
{
if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous())
{
return Asset->GetSeasonSeed();
}
}
return Seed;
}
float UVoxelSettings::GetEffectiveOriginSpineRadius() const
{
if (!Season.IsNull())
{
if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous())
{
return Asset->GetOriginSpineRadius();
}
}
return OriginSpineRadius;
}
float UVoxelSettings::GetEffectiveWorldRadiusVoxels() const
{
if (!Season.IsNull())
{
if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous())
{
return Asset->GetWorldRadiusVoxels();
}
}
return WorldRadiusVoxels;
}
int32 UVoxelSettings::GetEffectiveSeasonNumber() const
{
if (!Season.IsNull())
{
if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous())
{
return Asset->GetSeasonNumber();
}
}
return CurrentSeason;
}
@@ -0,0 +1,919 @@
// VoxelStackSampler.cpp
// Fixed-storage raw stack sampling for VoxelForge generation workers.
#include "VoxelStackSampler.h"
#include "Containers/Array.h"
#include "HAL/FileManager.h"
#include "HAL/PlatformProcess.h"
#include "HAL/PlatformStackWalk.h"
#include "HAL/PlatformTime.h"
#include "HAL/PlatformTLS.h"
#include "HAL/Runnable.h"
#include "HAL/RunnableThread.h"
#include "HAL/UnrealMemory.h"
#include "Math/UnrealMathUtility.h"
#include "Containers/StringConv.h"
#include "Misc/FileHelper.h"
#include "Misc/Guid.h"
#include "Misc/Paths.h"
#include "Misc/ScopeLock.h"
#include "Serialization/Archive.h"
#include "Templates/UniquePtr.h"
#if PLATFORM_WINDOWS
#include "Windows/WindowsPlatformStackWalk.h"
#include "Windows/AllowWindowsPlatformTypes.h"
THIRD_PARTY_INCLUDES_START
#include <Psapi.h>
THIRD_PARTY_INCLUDES_END
#include "Windows/HideWindowsPlatformTypes.h"
#endif
#include <atomic>
#include <utility>
namespace
{
constexpr uint64 RegistrationReservedBit = 1ull << 63;
constexpr uint32 MaxRegisteredThreads = 128;
std::atomic<bool> GVoxelStackSamplerEnabled(false);
// The registry is deliberately fixed-size and lock-free from the worker's point of view. A
// reserved state prevents two registering workers from racing on ThreadId before a slot becomes
// visible to the sampler.
struct FRegisteredThreadSlot
{
std::atomic<uint64> State{0};
std::atomic<uint64> ThreadId{0};
std::atomic<int32> LODLevel{INDEX_NONE};
};
thread_local int32 GTlsRegisteredSlot = INDEX_NONE;
thread_local uint64 GTlsRegistrationGeneration = 0;
thread_local int32 GTlsRegistrationDepth = 0;
thread_local int32 GTlsRegisteredLODLevel = INDEX_NONE;
thread_local void* GTlsRegisteredImpl = nullptr;
thread_local uint64 GTlsSamplerSessionGeneration = 0;
std::atomic<uint64> GNextSamplerSessionGeneration(1);
void ResetRegistrationTls()
{
GTlsRegisteredSlot = INDEX_NONE;
GTlsRegistrationGeneration = 0;
GTlsRegistrationDepth = 0;
GTlsRegisteredLODLevel = INDEX_NONE;
GTlsRegisteredImpl = nullptr;
GTlsSamplerSessionGeneration = 0;
}
FString SanitizeLabel(const FString& InLabel)
{
FString Result;
for (const TCHAR Character : InLabel)
{
const bool bAlphaNumeric =
(Character >= TEXT('a') && Character <= TEXT('z'))
|| (Character >= TEXT('A') && Character <= TEXT('Z'))
|| (Character >= TEXT('0') && Character <= TEXT('9'));
Result.AppendChar(bAlphaNumeric || Character == TEXT('_') || Character == TEXT('-')
? Character : TEXT('_'));
}
return Result.IsEmpty() ? TEXT("run") : Result;
}
void WriteUtf8(FArchive& Archive, const FString& Text)
{
FTCHARToUTF8 Utf8(*Text);
if (Utf8.Length() > 0)
{
Archive.Serialize(const_cast<ANSICHAR*>(Utf8.Get()), Utf8.Length());
}
}
#if PLATFORM_WINDOWS
struct FModuleMapRecord
{
FString Path;
uint64 LoadBase = 0;
uint32 ImageSize = 0;
uint64 FileSize = 0;
uint64 LastWriteFileTime = 0;
FString PdbGuid;
uint32 PdbAge = 0;
FString PdbPath;
};
FString ModuleMapField(const FString& InValue)
{
FString Result = InValue;
Result.ReplaceInline(TEXT("\t"), TEXT(" "));
Result.ReplaceInline(TEXT("\r"), TEXT(" "));
Result.ReplaceInline(TEXT("\n"), TEXT(" "));
return Result;
}
bool IsModuleImageRangeValid(uint32 Offset, uint64 Size, uint32 ImageSize)
{
return Offset <= ImageSize && Size <= static_cast<uint64>(ImageSize - Offset);
}
void ReadModuleCodeViewIdentity(
FModuleMapRecord& Record, const uint8* ModuleBase, uint32 ImageSize)
{
if (ModuleBase == nullptr || ImageSize < sizeof(IMAGE_DOS_HEADER))
{
return;
}
const IMAGE_DOS_HEADER* DosHeader = reinterpret_cast<const IMAGE_DOS_HEADER*>(ModuleBase);
if (DosHeader->e_magic != IMAGE_DOS_SIGNATURE || DosHeader->e_lfanew < 0)
{
return;
}
const uint32 NtOffset = static_cast<uint32>(DosHeader->e_lfanew);
if (!IsModuleImageRangeValid(NtOffset, sizeof(IMAGE_NT_HEADERS), ImageSize))
{
return;
}
const IMAGE_NT_HEADERS* NtHeaders = reinterpret_cast<const IMAGE_NT_HEADERS*>(
ModuleBase + NtOffset);
if (NtHeaders->Signature != IMAGE_NT_SIGNATURE
|| NtHeaders->OptionalHeader.NumberOfRvaAndSizes <= IMAGE_DIRECTORY_ENTRY_DEBUG)
{
return;
}
const IMAGE_DATA_DIRECTORY& DebugDirectory =
NtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG];
if (DebugDirectory.VirtualAddress == 0
|| DebugDirectory.Size < sizeof(IMAGE_DEBUG_DIRECTORY)
|| !IsModuleImageRangeValid(
DebugDirectory.VirtualAddress, DebugDirectory.Size, ImageSize))
{
return;
}
const IMAGE_DEBUG_DIRECTORY* Entries = reinterpret_cast<const IMAGE_DEBUG_DIRECTORY*>(
ModuleBase + DebugDirectory.VirtualAddress);
const uint32 EntryCount = DebugDirectory.Size / sizeof(IMAGE_DEBUG_DIRECTORY);
for (uint32 Index = 0; Index < EntryCount; ++Index)
{
const IMAGE_DEBUG_DIRECTORY& Entry = Entries[Index];
if (Entry.Type != IMAGE_DEBUG_TYPE_CODEVIEW || Entry.SizeOfData < 24
|| Entry.AddressOfRawData == 0
|| !IsModuleImageRangeValid(
Entry.AddressOfRawData, Entry.SizeOfData, ImageSize))
{
continue;
}
const uint8* CodeView = ModuleBase + Entry.AddressOfRawData;
if (CodeView[0] != 'R' || CodeView[1] != 'S'
|| CodeView[2] != 'D' || CodeView[3] != 'S')
{
continue;
}
FGuid Guid;
FMemory::Memcpy(&Guid, CodeView + 4, sizeof(Guid));
Record.PdbGuid = Guid.ToString();
FMemory::Memcpy(&Record.PdbAge, CodeView + 20, sizeof(Record.PdbAge));
const uint32 PdbPathBytes = Entry.SizeOfData - 24;
if (PdbPathBytes > 0)
{
const ANSICHAR* PdbPathAnsi = reinterpret_cast<const ANSICHAR*>(CodeView + 24);
int32 Length = 0;
while (Length < static_cast<int32>(PdbPathBytes) && PdbPathAnsi[Length] != '\0')
{
++Length;
}
if (Length > 0)
{
FUTF8ToTCHAR PdbPathUtf8(PdbPathAnsi, Length);
Record.PdbPath = FString(PdbPathUtf8.Get());
}
}
return;
}
}
bool CollectProcessModuleMap(TArray<FModuleMapRecord>& OutRecords, FString& OutError)
{
OutRecords.Reset();
OutError.Reset();
// Keep PSAPI dynamic, like the engine stack-walk implementation. This records module state
// without touching DbgHelp or the process-global symbol engine.
void* PsapiHandle = FPlatformProcess::GetDllHandle(TEXT("PSAPI.DLL"));
if (PsapiHandle == nullptr)
{
OutError = TEXT("PSAPI.DLL could not be loaded");
return false;
}
using FEnumProcessModules = BOOL (WINAPI*)(HANDLE, HMODULE*, DWORD, LPDWORD);
using FGetModuleFileNameExW = DWORD (WINAPI*)(HANDLE, HMODULE, LPWSTR, DWORD);
using FGetModuleInformation = BOOL (WINAPI*)(HANDLE, HMODULE, LPMODULEINFO, DWORD);
const FEnumProcessModules EnumModules = reinterpret_cast<FEnumProcessModules>(
FPlatformProcess::GetDllExport(PsapiHandle, TEXT("EnumProcessModules")));
const FGetModuleFileNameExW GetModuleFileNameEx = reinterpret_cast<FGetModuleFileNameExW>(
FPlatformProcess::GetDllExport(PsapiHandle, TEXT("GetModuleFileNameExW")));
const FGetModuleInformation GetModuleInformation = reinterpret_cast<FGetModuleInformation>(
FPlatformProcess::GetDllExport(PsapiHandle, TEXT("GetModuleInformation")));
if (EnumModules == nullptr || GetModuleFileNameEx == nullptr || GetModuleInformation == nullptr)
{
OutError = TEXT("PSAPI exports needed for module enumeration are unavailable");
FPlatformProcess::FreeDllHandle(PsapiHandle);
return false;
}
DWORD BytesRequired = 0;
EnumModules(GetCurrentProcess(), nullptr, 0, &BytesRequired);
if (BytesRequired == 0)
{
OutError = TEXT("EnumProcessModules returned no module bytes");
FPlatformProcess::FreeDllHandle(PsapiHandle);
return false;
}
TArray<HMODULE> Modules;
Modules.SetNumUninitialized(
FMath::Max<int32>(1, static_cast<int32>(BytesRequired / sizeof(HMODULE) + 1)));
DWORD BytesWritten = 0;
if (!EnumModules(
GetCurrentProcess(), Modules.GetData(),
static_cast<DWORD>(Modules.Num() * sizeof(HMODULE)), &BytesWritten))
{
OutError = TEXT("EnumProcessModules failed");
FPlatformProcess::FreeDllHandle(PsapiHandle);
return false;
}
const int32 ModuleCount = FMath::Min<int32>(
Modules.Num(), static_cast<int32>(BytesWritten / sizeof(HMODULE)));
for (int32 Index = 0; Index < ModuleCount; ++Index)
{
MODULEINFO ModuleInfo{};
if (!GetModuleInformation(
GetCurrentProcess(), Modules[Index], &ModuleInfo, sizeof(ModuleInfo)))
{
continue;
}
WCHAR ImagePath[32768] = {};
const DWORD PathLength = GetModuleFileNameEx(
GetCurrentProcess(), Modules[Index], ImagePath, UE_ARRAY_COUNT(ImagePath));
if (PathLength == 0)
{
continue;
}
FModuleMapRecord Record;
Record.Path = FString(ImagePath, static_cast<int32>(PathLength));
Record.LoadBase = reinterpret_cast<uint64>(ModuleInfo.lpBaseOfDll);
Record.ImageSize = static_cast<uint32>(ModuleInfo.SizeOfImage);
WIN32_FILE_ATTRIBUTE_DATA FileData{};
if (GetFileAttributesExW(*Record.Path, GetFileExInfoStandard, &FileData))
{
ULARGE_INTEGER FileSize;
FileSize.HighPart = FileData.nFileSizeHigh;
FileSize.LowPart = FileData.nFileSizeLow;
Record.FileSize = FileSize.QuadPart;
ULARGE_INTEGER LastWrite;
LastWrite.HighPart = FileData.ftLastWriteTime.dwHighDateTime;
LastWrite.LowPart = FileData.ftLastWriteTime.dwLowDateTime;
Record.LastWriteFileTime = LastWrite.QuadPart;
}
ReadModuleCodeViewIdentity(
Record, reinterpret_cast<const uint8*>(ModuleInfo.lpBaseOfDll), Record.ImageSize);
OutRecords.Add(MoveTemp(Record));
}
OutRecords.Sort([](const FModuleMapRecord& A, const FModuleMapRecord& B)
{
return A.LoadBase < B.LoadBase;
});
FPlatformProcess::FreeDllHandle(PsapiHandle);
return OutRecords.Num() > 0;
}
#endif
}
struct FVoxelStackSampler::FImpl final : public FRunnable
{
struct FStoredSample
{
uint64 ElapsedUs = 0;
uint64 ThreadId = 0;
int32 LODLevel = INDEX_NONE;
uint32 Depth = 0;
uint64 ProgramCounters[MaxStackDepth]{};
};
FVoxelStackSampler& Owner;
uint32 IntervalUs = DefaultIntervalUs;
FString RunLabel;
FString OutputDirectory;
FString RawSamplesPath;
FString ModuleMapPath;
FString SummaryPath;
double StartSeconds = 0.0;
std::atomic<bool> bStop{false};
uint64 SamplerSessionGeneration = 0;
std::atomic<uint64> NextRegistrationGeneration{1};
FRegisteredThreadSlot Slots[MaxRegisteredThreads];
TUniquePtr<FStoredSample[]> Samples;
uint32 StoredSampleCount = 0;
uint64 SamplerTicks = 0;
uint64 CaptureAttempts = 0;
uint64 RegisteredThreadSamples = 0;
uint64 RegistrationRaceDrops = 0;
uint64 CaptureFailures = 0;
uint64 DroppedSamples = 0;
uint64 TotalFrames = 0;
uint64 ReservoirState = 0x9E3779B97F4A7C15ull;
uint64 MaxActiveRegisteredThreads = 0;
bool bStackWalkingInitialized = false;
FRunnableThread* Thread = nullptr;
FImpl(
FVoxelStackSampler& InOwner,
uint32 InIntervalUs,
const FString& InOutputDirectory,
const FString& InRunLabel)
: Owner(InOwner)
, IntervalUs(FMath::Clamp(InIntervalUs, MinIntervalUs, MaxIntervalUs))
, RunLabel(InRunLabel)
, OutputDirectory(InOutputDirectory)
{
SamplerSessionGeneration = GNextSamplerSessionGeneration.fetch_add(
1, std::memory_order_relaxed);
const FString SafeLabel = SanitizeLabel(RunLabel);
const uint32 ProcessId = FPlatformProcess::GetCurrentProcessId();
RawSamplesPath = FPaths::Combine(
OutputDirectory,
FString::Printf(TEXT("VoxelStackSamples_%s_%u.csv"), *SafeLabel, ProcessId));
ModuleMapPath = FPaths::Combine(
OutputDirectory,
FString::Printf(TEXT("VoxelStackModules_%s_%u.tsv"), *SafeLabel, ProcessId));
SummaryPath = FPaths::Combine(
OutputDirectory,
FString::Printf(TEXT("VoxelStackSummary_%s_%u.txt"), *SafeLabel, ProcessId));
Samples = TUniquePtr<FStoredSample[]>(new FStoredSample[MaxStoredSamples]);
}
virtual uint32 Run() override
{
#if PLATFORM_WINDOWS
StartSeconds = FPlatformTime::Seconds();
double NextSampleSeconds = StartSeconds;
while (!bStop.load(std::memory_order_relaxed))
{
const double Now = FPlatformTime::Seconds();
if (Now < NextSampleSeconds)
{
const double SleepSeconds = NextSampleSeconds - Now;
FPlatformProcess::SleepNoStats(static_cast<float>(FMath::Min(SleepSeconds, 0.001)));
continue;
}
CaptureRegisteredThreads(Now);
++SamplerTicks;
NextSampleSeconds += static_cast<double>(IntervalUs) * 1.0e-6;
if (NextSampleSeconds < Now)
{
NextSampleSeconds = Now + static_cast<double>(IntervalUs) * 1.0e-6;
}
}
#else
// The requested implementation is Windows-specific. Keep the runtime module buildable
// on other targets, but never pretend that a no-op is a profile.
while (!bStop.load(std::memory_order_relaxed))
{
FPlatformProcess::SleepNoStats(0.01f);
}
#endif
return 0;
}
virtual void Stop() override
{
bStop.store(true, std::memory_order_release);
}
bool StartThread()
{
#if PLATFORM_WINDOWS
// CaptureThreadStackBackTrace uses the same-process context path and deliberately does
// not require DbgHelp. Initializing DbgHelp here made the measured process depend on
// global symbol-engine state before shutdown; symbolization is an offline concern.
StartSeconds = FPlatformTime::Seconds();
#endif
Thread = FRunnableThread::Create(this, TEXT("VoxelStackSampler"), 0, TPri_Lowest);
return Thread != nullptr;
}
void StopThread()
{
if (Thread == nullptr)
{
return;
}
bStop.store(true, std::memory_order_release);
Thread->WaitForCompletion();
delete Thread;
Thread = nullptr;
}
bool RegisterThread(int32 LODLevel)
{
const uint32 CurrentThreadId = FPlatformTLS::GetCurrentThreadId();
if (GTlsRegistrationDepth > 0)
{
if (GTlsRegisteredImpl == this
&& GTlsSamplerSessionGeneration == SamplerSessionGeneration)
{
++GTlsRegistrationDepth;
return true;
}
// A worker can outlive a session's owner-side shutdown. Never let stale TLS from
// that session masquerade as a nested registration in a later session.
ResetRegistrationTls();
}
const uint64 Generation = NextRegistrationGeneration.fetch_add(
1, std::memory_order_relaxed);
const uint64 ReservedState = Generation | RegistrationReservedBit;
for (uint32 Index = 0; Index < MaxRegisteredThreads; ++Index)
{
uint64 Expected = 0;
if (!Slots[Index].State.compare_exchange_strong(
Expected, ReservedState,
std::memory_order_acq_rel,
std::memory_order_relaxed))
{
continue;
}
Slots[Index].ThreadId.store(CurrentThreadId, std::memory_order_relaxed);
Slots[Index].LODLevel.store(LODLevel, std::memory_order_relaxed);
Slots[Index].State.store(Generation, std::memory_order_release);
GTlsRegisteredSlot = static_cast<int32>(Index);
GTlsRegistrationGeneration = Generation;
GTlsRegistrationDepth = 1;
GTlsRegisteredLODLevel = LODLevel;
GTlsRegisteredImpl = this;
GTlsSamplerSessionGeneration = SamplerSessionGeneration;
return true;
}
return false;
}
void DeregisterThread()
{
if (GTlsRegistrationDepth <= 0)
{
return;
}
--GTlsRegistrationDepth;
if (GTlsRegistrationDepth > 0)
{
return;
}
if (GTlsRegisteredSlot >= 0 && GTlsRegisteredSlot < static_cast<int32>(MaxRegisteredThreads))
{
FRegisteredThreadSlot& Slot = Slots[GTlsRegisteredSlot];
const uint64 State = Slot.State.load(std::memory_order_acquire);
if (State == GTlsRegistrationGeneration)
{
Slot.State.store(0, std::memory_order_release);
Slot.ThreadId.store(0, std::memory_order_relaxed);
Slot.LODLevel.store(INDEX_NONE, std::memory_order_relaxed);
}
}
ResetRegistrationTls();
}
void CaptureRegisteredThreads(double NowSeconds)
{
#if PLATFORM_WINDOWS
uint64 BackTrace[MaxStackDepth]{};
uint64 ActiveCount = 0;
for (uint32 Index = 0; Index < MaxRegisteredThreads; ++Index)
{
FRegisteredThreadSlot& Slot = Slots[Index];
const uint64 Generation = Slot.State.load(std::memory_order_acquire);
if (Generation == 0 || (Generation & RegistrationReservedBit) != 0)
{
continue;
}
const uint64 ThreadId = Slot.ThreadId.load(std::memory_order_acquire);
if (ThreadId == 0)
{
continue;
}
const int32 LODLevel = Slot.LODLevel.load(std::memory_order_acquire);
++ActiveCount;
++CaptureAttempts;
const uint32 CapturedDepth = FPlatformStackWalk::CaptureThreadStackBackTrace(
ThreadId, BackTrace, MaxStackDepth);
const uint64 EndGeneration = Slot.State.load(std::memory_order_acquire);
const uint64 EndThreadId = Slot.ThreadId.load(std::memory_order_acquire);
const int32 EndLODLevel = Slot.LODLevel.load(std::memory_order_acquire);
if (EndGeneration != Generation || EndThreadId != ThreadId || EndLODLevel != LODLevel)
{
++RegistrationRaceDrops;
continue;
}
++RegisteredThreadSamples;
const uint32 Depth = FMath::Min(CapturedDepth, MaxStackDepth);
if (Depth == 0)
{
++CaptureFailures;
}
StoreSample(
NowSeconds,
ThreadId,
LODLevel,
Depth,
BackTrace);
}
MaxActiveRegisteredThreads = FMath::Max(MaxActiveRegisteredThreads, ActiveCount);
#else
(void)NowSeconds;
#endif
}
uint64 NextReservoirValue()
{
ReservoirState ^= ReservoirState << 7;
ReservoirState ^= ReservoirState >> 9;
ReservoirState ^= ReservoirState << 8;
return ReservoirState;
}
void StoreSample(
double NowSeconds,
uint64 ThreadId,
int32 LODLevel,
uint32 Depth,
const uint64* BackTrace)
{
FStoredSample* Sample = nullptr;
if (StoredSampleCount < MaxStoredSamples)
{
Sample = &Samples[StoredSampleCount++];
}
else
{
++DroppedSamples;
// RegisteredThreadSamples is the one-based ordinal of this stable capture. Select a
// uniform-ish prior slot; the raw store therefore represents the full run after the
// bound is reached without allocating or synchronizing with workers.
const uint64 Candidate = NextReservoirValue() % RegisteredThreadSamples;
if (Candidate >= MaxStoredSamples)
{
return;
}
Sample = &Samples[Candidate];
TotalFrames -= Sample->Depth;
}
Sample->ElapsedUs = NowSeconds >= StartSeconds
? static_cast<uint64>((NowSeconds - StartSeconds) * 1.0e6)
: 0;
Sample->ThreadId = ThreadId;
Sample->LODLevel = LODLevel;
Sample->Depth = Depth;
TotalFrames += Depth;
if (Depth > 0)
{
FMemory::Memcpy(
Sample->ProgramCounters,
BackTrace,
static_cast<SIZE_T>(Depth) * sizeof(uint64));
}
}
FSummary Finalize()
{
FSummary Result;
Result.bSupported = PLATFORM_WINDOWS != 0;
Result.bStarted = true;
Result.IntervalUs = IntervalUs;
Result.MaxDepth = MaxStackDepth;
Result.MaxStoredSampleCount = MaxStoredSamples;
Result.bStackWalkingInitialized = bStackWalkingInitialized;
Result.SamplerTicks = SamplerTicks;
Result.CaptureAttempts = CaptureAttempts;
Result.RegisteredThreadSamples = RegisteredThreadSamples;
Result.RegistrationRaceDrops = RegistrationRaceDrops;
Result.CaptureFailures = CaptureFailures;
Result.RetainedSamples = StoredSampleCount;
Result.DroppedSamples = DroppedSamples;
Result.TotalFrames = TotalFrames;
Result.MaxActiveRegisteredThreads = MaxActiveRegisteredThreads;
Result.RunLabel = RunLabel;
Result.RawSamplesPath = RawSamplesPath;
Result.ModuleMapPath = ModuleMapPath;
Result.SummaryPath = SummaryPath;
Result.RunSeconds = FPlatformTime::Seconds() - StartSeconds;
WriteRawSamples(Result);
WriteModuleMap(Result);
// Do not call the DbgHelp-backed symbolizer from the measured process. DbgHelp is a
// process-global state machine and can fault during shutdown even after the sampler
// thread has joined. The raw PCs remain available for a separate offline pass.
WriteRawOnlySummary(Result);
return Result;
}
void WriteRawSamples(FSummary& Result)
{
FArchive* Archive = IFileManager::Get().CreateFileWriter(*RawSamplesPath);
if (Archive == nullptr)
{
return;
}
FString Header = TEXT("sample_index,elapsed_us,thread_id,lod,depth");
for (uint32 Index = 0; Index < MaxStackDepth; ++Index)
{
Header += FString::Printf(TEXT(",pc%u"), Index);
}
Header += TEXT("\n");
WriteUtf8(*Archive, Header);
for (uint32 SampleIndex = 0; SampleIndex < StoredSampleCount; ++SampleIndex)
{
const FStoredSample& Sample = Samples[SampleIndex];
FString Line = FString::Printf(
TEXT("%u,%llu,%llu,%d,%u"),
SampleIndex,
static_cast<unsigned long long>(Sample.ElapsedUs),
static_cast<unsigned long long>(Sample.ThreadId),
Sample.LODLevel,
Sample.Depth);
for (uint32 FrameIndex = 0; FrameIndex < Sample.Depth; ++FrameIndex)
{
Line += FString::Printf(
TEXT(",0x%016llX"),
static_cast<unsigned long long>(Sample.ProgramCounters[FrameIndex]));
}
Line += TEXT("\n");
WriteUtf8(*Archive, Line);
}
delete Archive;
Result.bOutputWritten = true;
}
void WriteModuleMap(FSummary& Result)
{
#if PLATFORM_WINDOWS
TArray<FModuleMapRecord> Records;
FString CollectionError;
const bool bCollected = CollectProcessModuleMap(Records, CollectionError);
FArchive* Archive = IFileManager::Get().CreateFileWriter(*ModuleMapPath);
if (Archive == nullptr)
{
return;
}
WriteUtf8(*Archive, TEXT("# VoxelForge module map v1\n"));
WriteUtf8(*Archive, TEXT("# DbgHelp is not initialized by this artifact writer.\n"));
WriteUtf8(*Archive, TEXT("module_path\tload_base_hex\timage_size\tfile_size\tfile_last_write_filetime\tpdb_guid\tpdb_age\tpdb_path\n"));
if (!bCollected)
{
WriteUtf8(*Archive, FString::Printf(TEXT("# collection_error\t%s\n"), *CollectionError));
}
for (const FModuleMapRecord& Record : Records)
{
const FString Line = FString::Printf(
TEXT("%s\t0x%016llX\t%u\t%llu\t%llu\t%s\t%u\t%s\n"),
*ModuleMapField(Record.Path),
static_cast<unsigned long long>(Record.LoadBase),
Record.ImageSize,
static_cast<unsigned long long>(Record.FileSize),
static_cast<unsigned long long>(Record.LastWriteFileTime),
*ModuleMapField(Record.PdbGuid),
Record.PdbAge,
*ModuleMapField(Record.PdbPath));
WriteUtf8(*Archive, Line);
}
delete Archive;
Result.ModuleCount = static_cast<uint64>(Records.Num());
Result.bModuleMapWritten = true;
#else
(void)Result;
#endif
}
void WriteRawOnlySummary(FSummary& Result)
{
uint64 SamplesWithStack = 0;
for (uint32 SampleIndex = 0; SampleIndex < StoredSampleCount; ++SampleIndex)
{
if (Samples[SampleIndex].Depth > 0)
{
++SamplesWithStack;
}
}
FString Summary;
Summary += TEXT("VoxelForge raw stack sampler\n");
Summary += TEXT("============================\n");
Summary.Appendf(TEXT("run_label: %s\n"), *RunLabel);
Summary.Appendf(TEXT("supported: %s\n"), Result.bSupported ? TEXT("yes") : TEXT("no"));
Summary += TEXT("raw_only: yes\n");
Summary += TEXT("symbolization: deferred\n");
Summary += TEXT("dbghelp_in_measured_process: no\n");
Summary.Appendf(TEXT("module_map_written: %s\n"),
Result.bModuleMapWritten ? TEXT("yes") : TEXT("no"));
Summary.Appendf(TEXT("module_count: %llu\n"),
static_cast<unsigned long long>(Result.ModuleCount));
Summary.Appendf(TEXT("interval_us: %u\n"), IntervalUs);
Summary.Appendf(TEXT("max_stack_depth: %u\n"), MaxStackDepth);
Summary.Appendf(TEXT("max_stored_samples: %u\n"), MaxStoredSamples);
Summary.Appendf(TEXT("sampler_ticks: %llu\n"),
static_cast<unsigned long long>(SamplerTicks));
Summary.Appendf(TEXT("capture_attempts: %llu\n"),
static_cast<unsigned long long>(CaptureAttempts));
Summary.Appendf(TEXT("registered_thread_samples: %llu\n"),
static_cast<unsigned long long>(RegisteredThreadSamples));
Summary.Appendf(TEXT("registration_race_drops: %llu\n"),
static_cast<unsigned long long>(RegistrationRaceDrops));
Summary.Appendf(TEXT("capture_failures: %llu\n"),
static_cast<unsigned long long>(CaptureFailures));
Summary.Appendf(TEXT("retained_samples: %llu\n"),
static_cast<unsigned long long>(StoredSampleCount));
Summary.Appendf(TEXT("dropped_samples_after_bound: %llu\n"),
static_cast<unsigned long long>(DroppedSamples));
Summary.Appendf(TEXT("samples_with_stack: %llu\n"),
static_cast<unsigned long long>(SamplesWithStack));
Summary.Appendf(TEXT("total_frames: %llu\n"),
static_cast<unsigned long long>(TotalFrames));
Summary.Appendf(TEXT("max_active_registered_threads: %llu\n"),
static_cast<unsigned long long>(MaxActiveRegisteredThreads));
Summary.Appendf(TEXT("run_seconds: %.6f\n"), Result.RunSeconds);
Summary.Appendf(TEXT("raw_samples_path: %s\n"), *RawSamplesPath);
Summary.Appendf(TEXT("module_map_path: %s\n"), *ModuleMapPath);
Summary += TEXT("\nThe target process records bounded raw PCs only; resolve symbols after the process exits.\n");
if (FFileHelper::SaveStringToFile(
Summary,
*SummaryPath,
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
{
Result.bOutputWritten = true;
}
}
};
FVoxelStackSampler& FVoxelStackSampler::Get()
{
static FVoxelStackSampler Instance;
return Instance;
}
bool FVoxelStackSampler::Start(
uint32 IntervalUs,
const FString& OutputDirectory,
const FString& RunLabel)
{
FScopeLock LifecycleLock(&LifecycleMutex);
if (Impl != nullptr)
{
return false;
}
#if !PLATFORM_WINDOWS
UE_LOG(LogTemp, Error,
TEXT("[VoxelStackSampler] requested on a non-Windows target; no profile was started."));
return false;
#else
if (!IFileManager::Get().MakeDirectory(*OutputDirectory, true))
{
UE_LOG(LogTemp, Error, TEXT("[VoxelStackSampler] could not create '%s'."), *OutputDirectory);
return false;
}
FImpl* NewImpl = new FImpl(*this, IntervalUs, OutputDirectory, RunLabel);
if (!NewImpl->StartThread())
{
delete NewImpl;
return false;
}
Impl = NewImpl;
GVoxelStackSamplerEnabled.store(true, std::memory_order_release);
UE_LOG(LogTemp, Display,
TEXT("[VoxelStackSampler] started label=%s interval_us=%u raw=%s modules=%s summary=%s"),
*RunLabel, NewImpl->IntervalUs, *NewImpl->RawSamplesPath,
*NewImpl->ModuleMapPath, *NewImpl->SummaryPath);
return true;
#endif
}
FVoxelStackSampler::FSummary FVoxelStackSampler::StopAndWrite()
{
GVoxelStackSamplerEnabled.store(false, std::memory_order_release);
FScopeLock LifecycleLock(&LifecycleMutex);
FSummary Result;
if (Impl == nullptr)
{
return Result;
}
FImpl* StoppedImpl = Impl;
StoppedImpl->StopThread();
Result = StoppedImpl->Finalize();
UE_LOG(LogTemp, Display,
TEXT("[VoxelStackSampler] stopped label=%s ticks=%llu registered_samples=%llu "
"retained=%llu dropped=%llu raw=%s module_map=%s summary=%s"),
*Result.RunLabel,
static_cast<unsigned long long>(Result.SamplerTicks),
static_cast<unsigned long long>(Result.RegisteredThreadSamples),
static_cast<unsigned long long>(Result.RetainedSamples),
static_cast<unsigned long long>(Result.DroppedSamples),
*Result.RawSamplesPath,
*Result.ModuleMapPath,
*Result.SummaryPath);
delete StoppedImpl;
Impl = nullptr;
return Result;
}
void FVoxelStackSampler::Shutdown()
{
StopAndWrite();
}
bool FVoxelStackSampler::IsRunning() const
{
FScopeLock LifecycleLock(&LifecycleMutex);
return Impl != nullptr;
}
bool FVoxelStackSampler::RegisterCurrentThread(int32 LODLevel)
{
// Keep this as the first and only operation in the disabled fast path. In particular, do not
// touch the singleton or TLS until this relaxed load says a session is active.
if (!GVoxelStackSamplerEnabled.load(std::memory_order_relaxed))
{
return false;
}
return Get().RegisterThreadInternal(LODLevel);
}
void FVoxelStackSampler::DeregisterCurrentThread()
{
Get().DeregisterThreadInternal();
}
bool FVoxelStackSampler::IsEnabled()
{
return GVoxelStackSamplerEnabled.load(std::memory_order_relaxed);
}
bool FVoxelStackSampler::RegisterThreadInternal(int32 LODLevel)
{
FScopeLock LifecycleLock(&LifecycleMutex);
return Impl != nullptr && Impl->RegisterThread(LODLevel);
}
void FVoxelStackSampler::DeregisterThreadInternal()
{
FScopeLock LifecycleLock(&LifecycleMutex);
if (GTlsRegistrationDepth <= 0)
{
ResetRegistrationTls();
return;
}
if (Impl != nullptr
&& GTlsRegisteredImpl == Impl
&& GTlsSamplerSessionGeneration == Impl->SamplerSessionGeneration)
{
Impl->DeregisterThread();
return;
}
// StopAndWrite may have detached and destroyed the session before a worker's scope guard
// runs. Clear only this thread's stale registration; never dereference the old FImpl.
ResetRegistrationTls();
}
@@ -0,0 +1,324 @@
#include "VoxelStartupTrace.h"
#include "HAL/FileManager.h"
#include "HAL/PlatformTime.h"
#include "Misc/CommandLine.h"
#include "Misc/Parse.h"
namespace VoxelForgeStartupTrace
{
namespace
{
struct FState
{
FArchive* Archive = nullptr;
FString Path;
double StartSeconds = 0.0;
int32 TileRecordCap = 4096;
int32 TileRecordsSeen = 0;
int32 TileRecordsWritten = 0;
int32 TileRecordsSuppressed = 0;
TArray<FTileSample> Samples;
TMap<FString, double> StageStarts;
TMap<FString, double> StageTotals;
TArray<FString> StageOrder;
bool bActive = false;
};
FCriticalSection GTraceLock;
TUniquePtr<FState> GState;
FString JsonEscape(const FString& In)
{
FString Out = In;
Out.ReplaceInline(TEXT("\\"), TEXT("\\\\"));
Out.ReplaceInline(TEXT("\""), TEXT("\\\""));
Out.ReplaceInline(TEXT("\r"), TEXT("\\r"));
Out.ReplaceInline(TEXT("\n"), TEXT("\\n"));
return Out;
}
FString Number(double Value)
{
return FString::SanitizeFloat(Value);
}
void WriteLineLocked(FState& State, const FString& Line)
{
if (State.Archive == nullptr) return;
const FString WithNewline = Line + TEXT("\n");
FTCHARToUTF8 Utf8(*WithNewline);
State.Archive->Serialize(const_cast<ANSICHAR*>(Utf8.Get()), Utf8.Length());
// The trace is intended to remain readable if a harness terminates the editor after
// the steady-state record. This is bounded I/O (startup only), not the shared UE log.
State.Archive->Flush();
}
void WriteEventLocked(FState& State, const TCHAR* Type, const FString& FieldsJson)
{
FString Line = FString::Printf(
TEXT("{\"type\":\"%s\",\"t_s\":%s"),
Type,
*Number(FPlatformTime::Seconds() - State.StartSeconds));
if (!FieldsJson.IsEmpty())
{
Line += TEXT(",");
Line += FieldsJson;
}
Line += TEXT("}");
WriteLineLocked(State, Line);
}
double Percentile(TArray<double> Values, double Fraction)
{
if (Values.Num() == 0) return 0.0;
Values.Sort();
const int32 Index = FMath::Clamp(
FMath::CeilToInt(Fraction * static_cast<double>(Values.Num() - 1)),
0,
Values.Num() - 1);
return Values[Index];
}
double Maximum(const TArray<double>& Values)
{
double Result = 0.0;
for (const double Value : Values)
{
Result = FMath::Max(Result, Value);
}
return Result;
}
FString BuildTileGroupJson(const TArray<FTileSample>& Samples, int32 Level)
{
TArray<double> Request, Queue, Generation, Classify, Mesh, Apply;
int32 Count = 0;
int32 Empty = 0;
int32 AllAir = 0;
int32 AllSolid = 0;
int32 Mixed = 0;
int64 Vertices = 0;
int64 Triangles = 0;
uint64 GeometryBytes = 0;
for (const FTileSample& Sample : Samples)
{
if (Level >= 0 && Sample.Level != Level) continue;
++Count;
if (Sample.bEmpty) ++Empty;
if (Sample.Verdict == 0) ++Mixed;
else if (Sample.Verdict == 1) ++AllSolid;
else if (Sample.Verdict == 2) ++AllAir;
Vertices += Sample.Vertices;
Triangles += Sample.Triangles;
GeometryBytes += Sample.GeometryBytes;
Request.Add(Sample.RequestToApplySeconds);
Queue.Add(Sample.QueueWaitSeconds);
Generation.Add(Sample.GenerationSeconds);
Classify.Add(Sample.ClassifySeconds);
Mesh.Add(Sample.MeshSeconds);
Apply.Add(Sample.ApplySeconds);
}
return FString::Printf(
TEXT("{\"count\":%d,\"empty\":%d,\"all_air\":%d,\"all_solid\":%d,\"mixed\":%d,\"vertices\":%lld,\"triangles\":%lld,\"geometry_bytes\":%llu,"
"\"request_apply_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
"\"queue_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
"\"generation_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
"\"classify_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
"\"mesh_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
"\"apply_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}}"),
Count, Empty, AllAir, AllSolid, Mixed,
static_cast<long long>(Vertices), static_cast<long long>(Triangles),
static_cast<unsigned long long>(GeometryBytes),
*Number(Percentile(Request, 0.50)), *Number(Percentile(Request, 0.95)), *Number(Maximum(Request)),
*Number(Percentile(Queue, 0.50)), *Number(Percentile(Queue, 0.95)), *Number(Maximum(Queue)),
*Number(Percentile(Generation, 0.50)), *Number(Percentile(Generation, 0.95)), *Number(Maximum(Generation)),
*Number(Percentile(Classify, 0.50)), *Number(Percentile(Classify, 0.95)), *Number(Maximum(Classify)),
*Number(Percentile(Mesh, 0.50)), *Number(Percentile(Mesh, 0.95)), *Number(Maximum(Mesh)),
*Number(Percentile(Apply, 0.50)), *Number(Percentile(Apply, 0.95)), *Number(Maximum(Apply)));
}
FString BuildSummaryFieldsLocked(const FState& State, const TCHAR* Reason)
{
FString Stages = TEXT("{");
for (int32 Index = 0; Index < State.StageOrder.Num(); ++Index)
{
if (Index > 0) Stages += TEXT(",");
const FString& Name = State.StageOrder[Index];
const double* Total = State.StageTotals.Find(Name);
Stages += FString::Printf(
TEXT("\"%s\":%s"), *JsonEscape(Name), *Number(Total ? *Total : 0.0));
}
Stages += TEXT("}");
TArray<int32> Levels;
for (const FTileSample& Sample : State.Samples)
{
if (Sample.Level >= 0 && !Levels.Contains(Sample.Level)) Levels.Add(Sample.Level);
}
Levels.Sort();
FString Groups = FString::Printf(
TEXT("\"all\":%s"), *BuildTileGroupJson(State.Samples, -1));
for (const int32 Level : Levels)
{
Groups += FString::Printf(
TEXT(",\"lod%d\":%s"), Level, *BuildTileGroupJson(State.Samples, Level));
}
return FString::Printf(
TEXT("\"reason\":\"%s\",\"wall_s\":%s,\"tile_records_seen\":%d,"
"\"tile_records_written\":%d,\"tile_records_suppressed\":%d,"
"\"tile_record_cap\":%d,\"stage_seconds\":%s,\"tile_percentiles\":{%s}"),
*JsonEscape(Reason ? FString(Reason) : FString(TEXT("unknown"))),
*Number(FPlatformTime::Seconds() - State.StartSeconds),
State.TileRecordsSeen, State.TileRecordsWritten, State.TileRecordsSuppressed,
State.TileRecordCap, *Stages, *Groups);
}
}
void BeginFromCommandLine()
{
FScopeLock Lock(&GTraceLock);
if (GState.IsValid()) return;
FString Path;
if (!FParse::Value(FCommandLine::Get(), TEXT("voxel.StartupTraceFile="), Path)) return;
Path.TrimQuotesInline();
if (Path.IsEmpty()) return;
TUniquePtr<FState> NewState = MakeUnique<FState>();
NewState->Path = Path;
FParse::Value(FCommandLine::Get(), TEXT("voxel.StartupTraceMaxTiles="), NewState->TileRecordCap);
NewState->TileRecordCap = FMath::Clamp(NewState->TileRecordCap, 64, 16384);
NewState->Samples.Reserve(NewState->TileRecordCap);
NewState->Archive = IFileManager::Get().CreateFileWriter(*Path);
if (NewState->Archive == nullptr)
{
return;
}
NewState->StartSeconds = FPlatformTime::Seconds();
NewState->bActive = true;
GState = MoveTemp(NewState);
WriteEventLocked(*GState, TEXT("header"), FString::Printf(
TEXT("\"schema\":1,\"path\":\"%s\",\"tile_record_cap\":%d"),
*JsonEscape(GState->Path), GState->TileRecordCap));
}
bool IsActive()
{
FScopeLock Lock(&GTraceLock);
return GState.IsValid() && GState->bActive;
}
void RecordEvent(const TCHAR* Type, const FString& FieldsJson)
{
FScopeLock Lock(&GTraceLock);
if (!GState.IsValid() || !GState->bActive) return;
WriteEventLocked(*GState, Type, FieldsJson);
}
void StageBegin(const TCHAR* Name)
{
if (Name == nullptr) return;
FScopeLock Lock(&GTraceLock);
if (!GState.IsValid() || !GState->bActive) return;
const FString StageName(Name);
if (!GState->StageStarts.Contains(StageName))
{
GState->StageOrder.Add(StageName);
}
GState->StageStarts.Add(StageName, FPlatformTime::Seconds());
WriteEventLocked(*GState, TEXT("stage_begin"), FString::Printf(
TEXT("\"name\":\"%s\""), *JsonEscape(StageName)));
}
void StageEnd(const TCHAR* Name)
{
if (Name == nullptr) return;
FScopeLock Lock(&GTraceLock);
if (!GState.IsValid() || !GState->bActive) return;
const FString StageName(Name);
const double Now = FPlatformTime::Seconds();
const double* Start = GState->StageStarts.Find(StageName);
const double Duration = Start ? FMath::Max(0.0, Now - *Start) : 0.0;
GState->StageTotals.FindOrAdd(StageName) += Duration;
GState->StageStarts.Remove(StageName);
WriteEventLocked(*GState, TEXT("stage_end"), FString::Printf(
TEXT("\"name\":\"%s\",\"duration_s\":%s"),
*JsonEscape(StageName), *Number(Duration)));
}
void RecordTile(const FTileSample& Sample)
{
FScopeLock Lock(&GTraceLock);
if (!GState.IsValid() || !GState->bActive) return;
++GState->TileRecordsSeen;
if (GState->TileRecordsWritten >= GState->TileRecordCap)
{
++GState->TileRecordsSuppressed;
return;
}
GState->Samples.Add(Sample);
++GState->TileRecordsWritten;
WriteEventLocked(*GState, TEXT("tile"), FString::Printf(
TEXT("\"tile\":[%d,%d,%d],\"level\":%d,\"verdict\":%d,\"proof\":%d,\"empty\":%d,\"vertices\":%d,\"triangles\":%d,\"geometry_bytes\":%llu,"
"\"request_to_apply_s\":%s,\"queue_wait_s\":%s,\"worker_queue_s\":%s,\"result_queue_s\":%s,"
"\"generation_s\":%s,\"classify_s\":%s,\"mesh_s\":%s,\"stream_s\":%s,\"apply_s\":%s"),
Sample.TileX, Sample.TileY, Sample.TileZ,
Sample.Level, Sample.Verdict, Sample.bSealedSolidProof ? 1 : 0,
Sample.bEmpty ? 1 : 0, Sample.Vertices, Sample.Triangles,
static_cast<unsigned long long>(Sample.GeometryBytes),
*Number(Sample.RequestToApplySeconds), *Number(Sample.QueueWaitSeconds),
*Number(Sample.WorkerQueueSeconds), *Number(Sample.ResultQueueSeconds),
*Number(Sample.GenerationSeconds), *Number(Sample.ClassifySeconds),
*Number(Sample.MeshSeconds), *Number(Sample.StreamSeconds), *Number(Sample.ApplySeconds)));
}
void Finish(const TCHAR* Reason)
{
FScopeLock Lock(&GTraceLock);
if (!GState.IsValid() || !GState->bActive) return;
// Close any stage left open by an abnormal early exit so the summary remains useful.
const double Now = FPlatformTime::Seconds();
for (const TPair<FString, double>& Pair : GState->StageStarts)
{
GState->StageTotals.FindOrAdd(Pair.Key) += FMath::Max(0.0, Now - Pair.Value);
}
GState->StageStarts.Reset();
WriteEventLocked(*GState, TEXT("summary"), BuildSummaryFieldsLocked(*GState, Reason));
GState->Archive->Flush();
delete GState->Archive;
GState->Archive = nullptr;
GState->bActive = false;
// The harness polls this tiny sentinel instead of opening the live JSONL. Unreal's file
// writer does not allow a concurrent reader on Windows; create it only after the trace is
// closed so the summary is safe to consume.
const FString DonePath = GState->Path + TEXT(".done");
if (FArchive* DoneArchive = IFileManager::Get().CreateFileWriter(*DonePath))
{
const ANSICHAR DoneText[] = "summary_complete\n";
DoneArchive->Serialize(const_cast<ANSICHAR*>(DoneText), UE_ARRAY_COUNT(DoneText) - 1);
DoneArchive->Flush();
delete DoneArchive;
}
}
FStageScope::FStageScope(const TCHAR* InName)
: Name(InName ? InName : TEXT("unnamed"))
, bActive(IsActive())
{
if (bActive) StageBegin(*Name);
}
FStageScope::~FStageScope()
{
if (bActive) StageEnd(*Name);
}
}
+27
View File
@@ -0,0 +1,27 @@
// VoxelStats.cpp
// Definitions for the VoxelForge runtime statistics.
// Définitions des statistiques runtime de VoxelForge.
#include "VoxelStats.h"
DEFINE_STAT(STAT_VoxelForgeTilesClassified);
DEFINE_STAT(STAT_VoxelForgeTilesSkippedAllSolid);
DEFINE_STAT(STAT_VoxelForgeTilesSkippedAllAir);
DEFINE_STAT(STAT_VoxelForgeTilesMeshed);
DEFINE_STAT(STAT_VoxelForgeTilesOpStackSolid);
DEFINE_STAT(STAT_VoxelForgeTilesOpStackAir);
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackSoleSlot);
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackBoundaryTile);
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackNoLayout);
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackRecheck);
DEFINE_STAT(STAT_VoxelForgeCaveMixOutOfLayout);
DEFINE_STAT(STAT_VoxelForgeCaveMixGap);
DEFINE_STAT(STAT_VoxelForgeCaveMixSurfaceWorld);
DEFINE_STAT(STAT_VoxelForgeCaveBailTwoCaveSlots);
DEFINE_STAT(STAT_VoxelForgeCaveBailParams);
DEFINE_STAT(STAT_VoxelForgeCaveBailStackVerdict);
DEFINE_STAT(STAT_VoxelForgeCaveBailFoldConflict);
DEFINE_STAT(STAT_VoxelForgeCaveBailDisturbance);
DEFINE_STAT(STAT_VoxelForgeCaveBailNoStack);
DEFINE_STAT(STAT_VoxelForgeColumnMemoHit);
DEFINE_STAT(STAT_VoxelForgeColumnMemoMiss);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,79 @@
// Serialized strate-definition migrations.
#include "VoxelStrateDefinition.h"
#include "Serialization/CustomVersion.h"
#include "VoxelPassageGeometry.h"
namespace
{
// The field name is retained for package compatibility, but version 1 changes its meaning
// from a raw tangent/gradient to authored degrees. This GUID is local to the plugin.
const FGuid GVoxelForgeStrateSerializationVersion(
0xA76D5E31, 0x4B2C4F9A, 0xB8C1D204, 0x6E9F7738);
constexpr int32 GVoxelForgeStrateGentleSlopeDegreesVersion = 1;
FCustomVersionRegistration GVoxelForgeStrateSerializationRegistration(
GVoxelForgeStrateSerializationVersion,
GVoxelForgeStrateGentleSlopeDegreesVersion,
TEXT("VoxelForgeStrateSerialization"));
float LegacyGradientToDegrees(float LegacyGradient)
{
if (!VoxelMath::IsFinite(LegacyGradient) || LegacyGradient <= 0.0f)
{
return 0.0f;
}
if (LegacyGradient >= 4096.0f)
{
return VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees;
}
// Invert the old gradient without atan/tan. The same deterministic sine/cosine helper
// used by generation makes this migration independent of the host CRT.
float LowDegrees = 0.0f;
float HighDegrees = VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees;
for (int32 Iteration = 0; Iteration < 32; ++Iteration)
{
const float MidDegrees = (LowDegrees + HighDegrees) * 0.5f;
float SinValue = 0.0f;
float CosValue = 0.0f;
VoxelMath::DetSinCos(
SinValue, CosValue, MidDegrees * (PI / 180.0f));
const float MidGradient = FMath::Min(
FMath::Abs(SinValue) / FMath::Max(FMath::Abs(CosValue), 1.0e-3f),
4096.0f);
if (MidGradient < LegacyGradient)
{
LowDegrees = MidDegrees;
}
else
{
HighDegrees = MidDegrees;
}
}
return FMath::Clamp(
(LowDegrees + HighDegrees) * 0.5f,
0.0f,
VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees);
}
}
void UVoxelStrateDefinition::PostLoad()
{
Super::PostLoad();
// The field remains reflected solely so old packages deserialize without shifting the
// following members. Tunnel geometry owns the floor now, so every serialized legacy value
// is normalized to the ignored compatibility default before any runtime copy is made.
GenerationParams.bTunnelFloorEnabled = true;
if (GetLinkerCustomVersion(GVoxelForgeStrateSerializationVersion)
>= GVoxelForgeStrateGentleSlopeDegreesVersion)
{
return;
}
const float LegacyGradient = GenerationParams.TunnelFloorGentleSlopeThreshold;
GenerationParams.TunnelFloorGentleSlopeThreshold =
LegacyGradientToDegrees(LegacyGradient);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,38 @@
#include "VoxelStrateComposer.h"
bool FVoxelStrateMeasuredMetrics::IsUsable() const
{
return bValid
&& NumSampled > 0
&& NumAir > 0
&& NumSolid > 0
&& NumSampled == NumAir + NumSolid
&& NumAirComponents > 0
&& LargestComponentCells > 0
&& AirComponentCells.Num() == NumAirComponents
&& FMath::IsFinite(AirFraction)
&& FMath::IsFinite(LargestComponentShare)
&& FMath::IsFinite(WalkableFraction)
&& FMath::IsFinite(WalkableFloorAreaFraction)
&& FMath::IsFinite(LargestWalkableSurfaceShare)
&& FMath::IsFinite(MedianFeatureScale)
&& FMath::IsFinite(LargestComponentPoint.X)
&& FMath::IsFinite(LargestComponentPoint.Y)
&& FMath::IsFinite(LargestComponentPoint.Z);
}
const TCHAR* VF_GetStrateArchetypeName(ECaveGeneratorType Archetype)
{
switch (Archetype)
{
case ECaveGeneratorType::TunnelNetwork: return TEXT("TunnelNetwork");
case ECaveGeneratorType::FlatPlain: return TEXT("FlatPlain");
case ECaveGeneratorType::CrystalChamber: return TEXT("CrystalChamber");
case ECaveGeneratorType::Maze: return TEXT("Maze");
case ECaveGeneratorType::SurfaceWorld: return TEXT("SurfaceWorld");
case ECaveGeneratorType::VerticalShafts: return TEXT("VerticalShafts");
case ECaveGeneratorType::FloatingIslands: return TEXT("FloatingIslands");
case ECaveGeneratorType::Underwater: return TEXT("Underwater");
}
return TEXT("Unknown");
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,98 @@
// VoxelWormField.cpp
// Shared deterministic exact worm-field evaluation.
#include "VoxelWormField.h"
#include "VoxelCaveMorphology.h" // VoxelHash::SeedOffset
#include "VoxelNoise.h"
#include "VoxelTypes.h" // VOXEL_NOISE_SCALE
namespace
{
FORCEINLINE FVector3f MakeWormNoiseBase(
float WorldX,
float WorldY,
float WorldZ,
const VoxelWormField::FParameters& Parameters)
{
const float EffectiveZ = (Parameters.VerticalScale != 1.0f
&& Parameters.VerticalScale > 0.0f)
? WorldZ / Parameters.VerticalScale : WorldZ;
const float WormZFrequency = Parameters.Frequency * Parameters.HorizontalBias;
return FVector3f(
WorldX * Parameters.Frequency
+ VoxelHash::SeedOffset(Parameters.Seed, 1.0f),
WorldY * Parameters.Frequency
+ VoxelHash::SeedOffset(Parameters.Seed, 1.7f),
EffectiveZ * WormZFrequency
+ VoxelHash::SeedOffset(Parameters.Seed, 2.3f));
}
FORCEINLINE float EvaluateN1Field(
float WorldX,
float WorldY,
float WorldZ,
const VoxelWormField::FParameters& Parameters,
float NoiseScale)
{
const FVector3f NoiseBase = MakeWormNoiseBase(
WorldX, WorldY, WorldZ, Parameters);
return FMath::Abs(VoxelNoise::Perlin3D(NoiseBase) * NoiseScale);
}
FORCEINLINE float EvaluateN2Field(
float WorldX,
float WorldY,
float WorldZ,
const VoxelWormField::FParameters& Parameters,
float NoiseScale)
{
const FVector3f NoiseBase = MakeWormNoiseBase(
WorldX, WorldY, WorldZ, Parameters);
const FVector3f N2Position = NoiseBase + FVector3f(137.0f, 259.0f, 431.0f);
return FMath::Abs(VoxelNoise::Perlin3D(N2Position) * NoiseScale);
}
FORCEINLINE float EvaluateExactField(
float WorldX,
float WorldY,
float WorldZ,
float Threshold,
const VoxelWormField::FParameters& Parameters)
{
const float NoiseScale = VOXEL_NOISE_SCALE;
const float N1 = EvaluateN1Field(
WorldX, WorldY, WorldZ, Parameters, NoiseScale);
// N2 is non-negative, so this preserves the exact evaluator's short-circuit.
if (N1 >= Threshold)
{
return N1;
}
return N1 + EvaluateN2Field(
WorldX, WorldY, WorldZ, Parameters, NoiseScale);
}
}
namespace VoxelWormField
{
float EvaluateExact(
float WorldX,
float WorldY,
float WorldZ,
float Threshold,
const FParameters& Parameters)
{
return EvaluateExactField(WorldX, WorldY, WorldZ, Threshold, Parameters);
}
float Evaluate(
float WorldX,
float WorldY,
float WorldZ,
float Threshold,
const FParameters& Parameters)
{
return EvaluateExactField(WorldX, WorldY, WorldZ, Threshold, Parameters);
}
}
@@ -12,6 +12,7 @@
#include "CoreMinimal.h"
#include "VoxelAtmosphereManager.generated.h"
class AActor; // IWYU : pointeur / TWeakObjectPtr seulement / pointer-only
class UVoxelStrateManager;
class UVoxelStrateDefinition;
class UVoxelBiomeDefinition;
@@ -22,6 +22,11 @@
#include "VoxelStrateTypes.h" // FStrateDecoration / FStrateAmbientActor
#include "VoxelBiomeDefinition.generated.h"
// IWYU : utilisé en pointeur seulement ⇒ déclaration avant. Fournie gratuitement par le PCH
// partagé jusqu'ici ; `FPSemantics = Precise` nous en sort. / Pointer-only use, so a forward
// declaration is enough. The shared PCH used to provide this for free.
class UMaterialInterface;
/**
* UVoxelBiomeDefinition — one biome's identity, placement, terrain modulation and content.
*/
+20 -2
View File
@@ -217,7 +217,7 @@ struct FBiomeSample
* done ONCE here, into a small grid covering the chunk footprint + margin; per voxel
* the resolver then only warps + does a cheap 3x3 lookup + blend.
*
* VALIDITY IS A WORLD-XY BOX (+ ChunkZ + Seed), NOT a chunk key (CODEMAP §8.10). The
* VALIDITY IS A WORLD-XY BOX (+ ChunkZ + Seed + owner identities), NOT a chunk key (CODEMAP §8.10). The
* grid covers a halo beyond the chunk, so gradient-normal samples and the +X/+Y chunk
* boundary corners stay inside the valid box and DO NOT thrash the noise-heavy rebuild.
* The box logic is identical in spirit to the SDF cache in GetDensityWithParams.
@@ -229,6 +229,8 @@ struct FChunkBiomeCache
float ValidMinY = 0.0f, ValidMaxY = 0.0f;
int32 ChunkZ = MIN_int32; // which strate slice this was built for
int32 Seed = MIN_int32;
uint64 OwnerId = 0; // generator/world identity
uint64 ManagerLifetimeId = 0; // strate-manager lifetime identity
bool bActive = false; // does this strate have biomes?
FBiomeContext Ctx; // resolved biomes + map (for blending)
@@ -237,10 +239,26 @@ struct FChunkBiomeCache
int32 BaseCellX = 0, BaseCellY = 0, CellsX = 0, CellsY = 0;
TArray<int32> CellBiome;
bool Contains(float X, float Y, int32 InChunkZ, int32 InSeed) const
bool Contains(float X, float Y, int32 InChunkZ, int32 InSeed,
uint64 InOwnerId, uint64 InManagerLifetimeId) const
{
return InSeed == Seed && InChunkZ == ChunkZ
&& InOwnerId == OwnerId && InManagerLifetimeId == ManagerLifetimeId
&& X >= ValidMinX && X <= ValidMaxX
&& Y >= ValidMinY && Y <= ValidMaxY;
}
/** AUDIT C2 — force a rebuild on the next query. The validity box says nothing about the
* FBiomeContext the cells were classified AGAINST, so when the strate layout is rebuilt
* (RebuildStrates / an editor live edit) the grid is stale even though the box still
* covers the query. Callers that key on GetLayoutVersion() call this on a version change.
* Le box de validité ne dit rien du contexte de biome ayant servi à classer les cellules :
* après un rebuild de layout, la grille est périmée alors que la boîte couvre encore. */
void Invalidate()
{
ValidMinX = 1.0f; ValidMaxX = -1.0f; // min > max ⇒ Contains() is false everywhere
ChunkZ = MIN_int32;
OwnerId = 0;
ManagerLifetimeId = 0;
}
};
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more