8a303cc0853b3c7118673d48b4f2ecb4427292e6
53 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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>
|
||
|
|
b063d437c1 |
feat(opstack B4): port Columns (4d), Domes (4g), Pinch (4h), FloorBias + Column op in the test pool
STAGE B group 4 of 5 -- and with it, ALL TWELVE detail modifiers are ported. 19 ops:
ConstantRock -> RoomGraph -> SdfCarve -> Roughness -> Terrace -> LayerLines -> Ribbing
-> Overhang -> Cliff -> Scallop -> Arch -> RoomColumn -> Dome -> Pinch -> FloorBias
-> Worms -> [structural x3]
Still NOT wired: UsesOperatorStackForChunk returns false for TunnelNetwork. What is missing is C1,
the per-room op override -- which adds NO operator, it changes what eleven of them READ.
TRANSCRIBED
- FRoomColumnMod (4d): walks SDFCache.Columns. Not FGridColumnMod -- that one rolls cylinders on a
world grid for FlatPlain/CrystalChamber; this one iterates a per-room bake.
- FDomeMod (4g): upward half-ellipsoid, up to 2 per room, capped at 0.85 * room radius.
- FPinchMod (4h): perimeter-anchored ellipsoid with the SideFactor term that leaves the passage
axis clear.
- FFloorBiasMod: quadratic fill below room centre, only in definite air. LAST in the chain and not
interchangeable -- it exists to undo what roughness (4b) did to floors. That is why the operator
order in BuildTunnelNetworkStack is the original's order line for line.
THE TRAP IN THIS GROUP, WHICH IS THE PIT LESSON A THIRD TIME
Columns have NO strate parameter. The per-voxel loop has no `if (ColumnDensity > 0)`, and the bake
reads OpParams (a FRESH struct with only the room's op applied), so FStrateGenerationParams::
ColumnDensity is read by nothing. Consequences, all three of which shaped this commit:
1. The `ColumnDensity = 0` line in the old DisableStageBModifiers was inert. It was never what
kept columns off; the absence of a Column op in the pool was.
2. No FeatureProbes entry can cover columns -- there is no param to switch off. And a second
stack built on a pool without the Column op would SHARE the thread_local SDF cache (the pool
is not in its key) and return identical densities, i.e. it would lie. Exactly the trap that
was documented for pits.
3. So the stage-A guard that ERRORED on TotalColumns > 0 is inverted in this same commit: it now
REQUIRES TotalColumns > 0. Asking the bake what it baked is the only sound check here.
DisableStageBModifiers is deleted rather than left empty: its emptiness is the measure of stage B
being complete, and an empty function still being called is how a step gets forgotten.
TEST (same commit): op count 15 -> 19; MakeShaftOpPool renamed MakeRoomOpPool and given a third
entry (Column, Probability 1.0 on all three so every room draws one of the three evenly); three
more param probes (dome/pinch/floor bias); the column guard inverted.
⚠️ THE POOL MUST STAY Pit/Chimney/Column UNTIL C1. Eleven of the twelve modifiers read the per-room
param copy in the original and the strate params here; those agree only while no room carries a
DETAIL-type op, and ApplyTo(Pit/Chimney/Column) writes only pit/chimney/column fields. A Terrace
entry would break the equivalence -- which is precisely the test C1 adds, and the only possible
proof that the override works.
IF THIS GROUP IS WRONG: dome/pinch/arch diffs land inside open cave near room centres and
perimeters; a column error shows up as a ring of diffs at fixed XY through the whole room height.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
6ec60099d6 |
feat(opstack B3): port cave Overhang, Cliff, Scallop, Arch (STEP 4c)
STAGE B group 3 of 5. The stack is now 15 ops: ConstantRock -> RoomGraph -> SdfCarve -> Roughness -> Terrace -> LayerLines -> Ribbing -> Overhang -> Cliff -> Scallop -> Arch -> Worms -> [structural x3] Still NOT wired: UsesOperatorStackForChunk returns false for TunnelNetwork. TRANSCRIBED - FCaveOverhangMod: fBM with Z frequency at 0.15x of XY, positive lobe only (rock extends INTO the cave, never away), quadratic fade. Note this is the THIRD unrelated thing called "overhang" in the plugin -- FOverhangShelfMod (SurfaceWorld) borrows the uphill terrain height and shares nothing with it but the name. - FScallopMod: cellular noise, positive lobe carved out. Second consumer of VoxelNoise::Cellular3D, which is the second reason B1 shared that body instead of copying it. - FCaveArchMod: the first ROOM-RELATIVE modifier -- it needs the room's hash, centre and radii, so it is what makes FRoomGraphSource::GetNearestRoomIdx() necessary. Its gate is NOT the shared one: CaveSDF < SDFBlendRadius (inside the cave), not < SDFBlendRadius*3. PORTED AS-IS THOUGH THE ORIGINAL'S OWN COMMENT DISAGREES WITH ITS CODE FCaveCliffMod's comment promises "sample density at Z+1 and Z-1, compute vertical gradient". The code samples nothing: it draws a Perlin whose Z frequency is 3x its XY frequency and names the result VertGrad. It is a gradient-shaped PROXY, decorrelated from the wall's actual slope; the multiply by CaveSDF still gives it the right SIGN either side of the surface, which is why it produces steeper faces at all. Fixing it would change the world, and saying nothing would leave it to be "fixed" later by someone reading the comment instead of the code. Ported, documented in the op, recorded in OPSTACK-PROGRESS. ONE DELIBERATE ADDITION FCaveArchMod bounds-checks NearestRoomIdx with IsValidIndex before indexing; the original tests only >= 0. The index comes from EvaluateSDFCached so it is valid by construction, which is exactly why this can never change output -- only prevent a crash if that invariant ever breaks. Same class of decision as the params fingerprint in the cache key: err on cost, never on result. Overhang is FillOnly, Scallop is CarveOnly, Arch is FillOnly; only Cliff needs Both. Four of the eight modifiers ported so far keep a usable direction for the fold, which matters for the DECOMPOSITION 0.2 work later. TEST (same commit): op count 11 -> 15, four params groups move into EnableTunnelFeatures, four more per-operator coverage probes. ArchDensity is set to 0.9 rather than its 0.1 default and the reason is written down: at 0.1 a room draws ~0.3 arches and the probe would report zero with nothing wrong. Test coverage settings are not production values. IF THIS GROUP IS WRONG: diffs concentrate inside open cave (arch) or in a band |SDF| < range (the other three), and the roughness/terrace probes stay green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ab1a996cc5 |
feat(opstack B2): port Terrace, LayerLines, Ribbing (STEP 4c) + hoist the room source's per-worker state
STAGE B group 2 of 5. The stack is now 11 ops: ConstantRock -> RoomGraph -> SdfCarve -> CaveRoughness -> Terrace -> LayerLines -> Ribbing -> Worms -> [structural x3] Still NOT wired: UsesOperatorStackForChunk returns false for TunnelNetwork. STRUCTURAL CHANGE THIS GROUP FORCED FRoomGraphSource's thread_local SDF cache moved out of Eval into a `static FState& State()` accessor, because terracing re-queries the cache at Z+-1 and (from B4 on) columns walk it and the room-relative ops need NearestRoomIdx. Kept `static`, i.e. SHARED BETWEEN INSTANCES, on purpose: that is what the original does, and the test's stale-cache check (check 3) rests on two stacks sharing this cache while the params CRC in the key keeps them from serving each other's rooms. Making it a member would have turned that check green for the wrong reason. This is the established pattern in this file, not a new one: FOverhangShelfMod reads FSurfaceColumnSource, FShaftLedgeMod reads FShaftFieldSource, both via a non-owning pointer handed over at build time. Terrace now reads FRoomGraphSource the same way. TRANSCRIBED - FCaveTerraceMod: the Z-only SDF gradient orientation gate, the optional noise displacement of Z before the staircase, the Edge = lerp(0.45, 0.02, Hardness) stair profile, the quadratic fade. - FLayerLineMod: sine along Z, half-wave rectified, CUBED, subtracted. - FRibbingMod: same sine shifted by PI/2, rectified, SQUARED, added. THINGS THAT LOOK WRONG AND WERE PORTED AS-IS - Terrace's two gradient probes use UNWARPED X/Y and RAW Z, while the field they probe was evaluated at WARPED coords and effective Z, and they exclude pits/chimneys. So the probe does not sample exactly the field whose slope it measures. Real, in the original, load-bearing for the look; noted in OPSTACK-PROGRESS rather than fixed. - Terrace/LayerLines/Ribbing use raw WorldZ while roughness uses EffectiveZ. Deliberate in the original (geology stays horizontal whatever the strate's vertical scale). - `&& CaveSDF < FLT_MAX` is redundant under the bNearCaveSurface gate; kept. - NearestRoom is now reset to -1 unconditionally, which the original does not do (its thread_local keeps the previous voxel's value when RoomDensity <= 0). Unobservable there because no consumer runs; here the consumers are separate objects, and a stale value crossing an operator boundary is not something you find later. Ribbing and LayerLines are FillOnly / CarveOnly rather than Both -- two detail modifiers that keep a usable direction for the fold, which is more than the roughness or the terrace can say. TEST (same commit): op count 8 -> 11; the three params move from DisableStageBModifiers into EnableTunnelFeatures with spacings deliberately small against the sample window; three MORE coverage probes, one per operator rather than one per group -- a single "B2" probe would stay green while two of the three were wrong. IF THIS GROUP IS WRONG: diffs cluster where |SDF| < spacing*1.5 near horizontal surfaces (terrace) or in thin Z bands (lines/ribs). If instead the whole equivalence collapses everywhere, suspect the FState hoist, not the three new ops. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6e29cbfe4c |
feat(opstack B1): port TunnelNetwork surface roughness (STEP 4b) + share the cellular noise
STAGE B of TunnelNetwork, group 1 of 5. The stack is now
ConstantRock -> RoomGraph -> SdfCarve -> CaveRoughness(4b) -> Worms -> [structural x3]
8 ops (was 7). Still NOT wired: UsesOperatorStackForChunk returns false for TunnelNetwork.
TRANSCRIBED
- FCaveRoughnessMod (VoxelDensityOpStack.cpp): STEP 4b literally -- two octave sets
(main + fine x3 with the +2000/+2500/+3000 offsets), the optional domain warp, the four-way
noise switch, the min(TotalRough, 0) anti-fill clamp inside definite air, and the quadratic
fade by distance from surface. Reads EffectiveZ, not WorldZ.
- VoxelNoise::Cellular3D moved verbatim from VoxelGenerator.cpp's `static CellularNoise3D` into
VoxelCaveMorphology.h; the generator keeps a one-line forwarder, exactly as FractalNoise3D and
RidgedNoise3D already do since T2.a. It lands in the cave header rather than VoxelNoise.h
because it needs VoxelHash, and VoxelNoise.h must not depend on the cave header. Forking a pure
function is how AUDIT C1 happened.
- HRidged3D added next to HFractal3D (same FVector round-trip, deliberately).
TWO THINGS THAT LOOKED WRONG AND WERE PORTED AS-IS
- The domain warp computes ONE offset and adds it to BOTH noise positions, so the main and fine
octave sets are warped identically. Two independent warps would be tidier and a different world.
- Roughness reads the STRATE params, NOT the per-room override. The original's
`const FStrateGenerationParams& Params = LocalTerrainParams;` shadow is declared INSIDE the
`if (bNearCaveSurface)` block that begins after step 4b. So eleven of the twelve detail
modifiers read the room copy and this one does not -- flagged in the op so C1 does not
"uniformise" it. (The handoff's "all 13 modifiers read the shadowed copy" is off on both counts:
it is twelve modifiers, and one of them is outside the shadow.)
STAGE B5 DECIDED HERE, WITH REASONS (VF_NearCaveSurface)
The gate is a repeated early-out per op, NOT a scoping container: the stack is a flat list that
ClassifyBox folds op by op, a container would have to re-implement VF_FoldOp and would hide its
children from the fold, and an op that only exists inside a container is not a Phase-3 asset.
Cost stated rather than hidden: the original tests once and skips twelve, the stack tests twelve
times. Measured before optimised -- that is the C10 lesson.
TEST (same commit)
- Op count 7 -> 8.
- SurfaceRoughness/DomainWarp moved from DisableStageBModifiers into EnableTunnelFeatures.
- NEW check 1b, group coverage: rebuild the stack with the group OFF and count moved samples.
Zero is an ERROR, not a warning -- a ported group that never executes is exactly how a bad
transcription survives a whole green run (the PitDensity lesson). This diff-of-stacks is sound
here although it lied for the op pool, because these are params and the params CRC IS in the
SDF cache key.
- NEW check 1c: all 4 noise types x {warp off, warp on} compared to the original over 1500 points.
The main equivalence only ever takes the FBM branch; three of four cases were untested.
IF THIS GROUP IS WRONG, what breaks first: the main equivalence reports diffs concentrated in
open cave and within |SDF| < SurfaceRoughness*2; if instead only check 1c fails, the fault is
inside one branch of the noise switch and nothing else is implicated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
ef5bda3d8a |
feat: TunnelNetwork stage A — the SDF spine, wrapping BuildChunkCache
The last archetype is ~1080 lines with 13 detail modifiers, a two-region cache and a per-room op override. Porting it whole before anything can be verified is ~600 unverified lines on top of ~200 — the pattern this refactor has dodged six times. So: three stages. Stage A = vertical scale, base rock, cave warp, room graph (+ pits and chimneys), carve, worms, structural post. 6 ops. It is verifiable NOW because every detail modifier is amplitude-gated and FStrateGenerationParams already defaults them all to zero — zeroing SurfaceRoughness sends the ORIGINAL down exactly the path stage A ported. TunnelNetwork stays OFF in UsesOperatorStackForChunk until stage C. The decision that matters: FRoomGraphSource CALLS BuildChunkCache and EvaluateSDFCached rather than transcribing them. That is where §8.4's two-region window-invariance discipline lives; a transcription would fork it, and the fork would be "validated" by a test comparing it to the original. Only the ~60 lines of glue are transcribed. FRAME ops are retired. All three candidates are now ported and none needed one: CaveWarp's scope is exactly one operator (pits/chimneys read unwarped coords), VerticalScale is a one-line pure function, and the island warp was already local. Not missing infrastructure — one idea seen three times from a distance. Also: check 3 was going to compare two interleaved param sets against the original, which would have FAILED — the original's SDF cache key has no params, so it serves B the rooms it built for A. Comparing there measures its bug, not the port. Rewritten against each stack evaluated alone. The same reasoning suggests a live production staleness across Gradient transitions; filed in AUDIT §C2 as SUSPECTED with the check that would confirm it, since it rests on a premise I have not verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
96e75abe57 |
feat: port FloatingIslands — the stack that runs backwards
6 of 8 archetypes ported. This one starts from VOID and FILLS where the other four start from ROCK and CARVE, which is what it was worth doing: neither end of the pile needed a new operator, only the opposite sign. FConstantRockSource -> FConstantFieldSource(+/-Base) AllSolid <-> AllAir FSdfCarveOp -> FSdfConvertOp(Sign = +/-1) carve <-> fill FSdfRoughnessMod 4th archetype, unchanged Only the island blob source is new. Multiplying by +/-1 is exact in IEEE-754, so the three already-green ports are bit-for-bit untouched. ClassifyBox can return AllAir for the first time in the plugin, and an island strate is by construction mostly empty — the test counts AllSolid and AllAir separately so an aggregate cannot hide whether that fired. Two bounds that would have been holes if assumed rather than derived: the island bound is one-sided (a hairline thread of matter hangs below each island down its axis, so only the TOP may reject), and the domain warp displaces X and Y independently, so the pad needs WarpAmp*sqrt(2). Also: AUDIT C1 was NOT closed. The 2026-07-27 sweep matched `SeedF * K` and this archetype's warp spells it `(float)S * K`, so one site survived — at seed 2e9 the warp flattens and every island snaps back to a perfect circle. Fixed in both paths in one pass so the equivalence test stays a valid oracle. Expect island silhouettes to change at large seeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d0a9ce3018 |
fix: the column key omitted the params — my perf fix broke the overhang
VerticalShaftEquivalence is bit-identical (966 samples inside a shaft), so operator
reuse across archetypes is measured now, not intended.
But SurfaceHeightEquivalence failed: 69/20000 overhang samples differ, 1 crossing the
isosurface. Cause is the ColumnKey from
|
||
|
|
3acb3fbc6b |
feat: port VerticalShafts — three ops reused from Maze unchanged
The port that tests the thesis rather than the fidelity. Previous ports asked whether the decomposition reproduces the original; this one asks whether operators actually get reused across archetypes, which is section 2.5's claim and the only reason to do this refactor instead of tidying the switch. ConstantRock, SdfRoughness and SdfCarve are Maze's, reused without a line changed. In the switch, GetMazeDensity and GetVerticalShaftDensity are two ~100-line functions with nothing visibly in common; as operators they are the same three ops with a different source and different tuning (freq 0.1 vs 0.12, window rough+4 vs R+rough+2). New: FShaftFieldSource (infinite cylinders + hash-gated connectors into the SDF channel) and FShaftLedgeMod (banded shelves on the +X/+Y half so the shaft stays climbable). Deviation from section 6, stated: it suggested splitting the source so the XY-pure cylinder half could get an exact box verdict. Kept as one op because the connectors derive from the same 3x3 roll and the ledge mod needs the shaft list anyway, so splitting means rolling twice or sharing a cache between two ops. Forfeited: the exact verdict on the cylinder half. Kept: a conservative EffectOverBox testing circles and connector reach. FShaftLedgeMod gates on the POST-roughness Sdf as the stack left it; re-deriving it would use the pre-roughness value and shift every ledge. Reading the channel rather than recomputing is what the two-channel sample is for. Compile fix: FCells was declared below the functions returning it. Member bodies are deferred, return types are not. Ported: Maze, FlatPlain, CrystalChamber, SurfaceWorld (biomes included), VerticalShafts — 5 of 8. UNVERIFIED: not compiled past the FCells fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f3faa3b5c2 |
perf: the column memo threw itself away every chunk
Jahni measured what I had only flagged: generation is slower on the op-stack path. Two compounding causes. The memo was keyed on InstanceId, which changes on every stack rebuild — every chunk. GSurfColCache, the cache this path replaced, is keyed on (XY box, StrateKey, Seed, LayoutVersion) with no ChunkZ, deliberately shared down the whole vertical strate stack. So a 4-chunk strate recomputed every column four times, including the cliff's four extra structural samples per column. And the table held 256 entries where a chunk is CHUNK_SIZE^2 = 1024 columns, so it thrashed against itself within a single tile before any cross-chunk question arose. PrepareChunk now derives a shared ColumnKey from (StrateBottomWorldZ, LayoutVersion, Seed) — the same identity GSurfColCache uses — and the table is 4096 entries (~150 KB/worker, in line with GSurfColCache's 6 x 59 KB). The memo is thread_local so it already survived rebuilds; only the key was discarding the contents. Sharing across chunk Z is sound because heights are XY-pure by type and the biome field is documented Z-independent — the same justification GSurfColCache rests on. ColumnKey starts at InstanceId rather than 0: slots initialise to Key = 0, so a zero key would falsely hit the pristine slot at (0,0). Without PrepareChunk you get per-instance caching, which is less sharing but still correct. This may not close the gap entirely and I am not claiming it does. Virtual dispatch and the hashed lookup vs a direct-indexed box both remain; they are smaller than a 4x column recompute, but "smaller" is a guess until measured. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c277931a08 |
feat: SurfaceWorld complete — biome blending wired, guard removed
The two biome checks added last commit printed nothing on success, so a passing run was indistinguishable from a block that never executed — the exact flaw I flagged twice this session and then wrote myself. Both now report their coverage. Step 2c closes SurfaceWorld: - FSurfaceColumnSource takes per-biome params and an OWNED IVoxelBiomeField. Empty params leaves the original path bit-for-bit unchanged. - The field is owned by the stack rather than borrowed: the adapter points at GetDensityAt's thread_local biome context and cache, and the stack is itself thread_local rebuilt in the same refetch block, so all three live and die together. Structural ownership beats a convention the next reader has to infer. - The overhang amp blends across biomes — Lerp(Amp(PD), Amp(PN), W) with slope and threshold from the dominant only, as ComputeSurfaceColumn does. Interpolating the slope would be meaningless; it measures the terrain rather than configuring it. - FGeneratorBiomeField lives in VoxelGenerator.cpp, on the side that knows the generator. The op sees a capability, never an owner — which is what lets it become an asset in Phase 3. - The no-biome guard is removed from UsesOperatorStackForChunk. Also: the two constructors now delegate to one body with one id counter. The first draft had two competing counters, one tagged with a high bit to avoid collision, which is a smell rather than a design. 5 of 8 archetypes ported: Maze, FlatPlain, CrystalChamber, SurfaceWorld. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1a3f6b6a72 |
fix: same stray-brace error as 7cd2bed — and label the boundary so it stops recurring
I anchored on the FACTORIES banner again, which sits after the anonymous namespace's
closing brace, so FBiomeBlendHeightSource landed at file scope and the `}` added with
it closed nothing. Identical to
|
||
|
|
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> |
||
|
|
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> |
||
|
|
cd4cf216f5 |
fix: AUDIT C1 — bounded, site-salted seed offsets across all 85 noise sites
The op stack had already inherited this three times and every remaining port would copy it again, so fixing it now is cheaper than after. The audit's documented fix was wrong: bounding SeedF while keeping the * 97.7f multiplier still reaches 1.6e6, where the ULP is 0.19 — 9.5x the per-voxel step. Less spectacular, still broken, ticket closed. VoxelHash::SeedOffset(Seed, SiteKey) inverts the roles: the multiplier no longer decorrelates by amplifying, it IDENTIFIES the site, and the hash decorrelates. Output is in final units, bounded to [0, 16383], so the ULP is 10% of a voxel step. Site-salted, so two seeds must collide at all ~50 sites rather than sharing one global bucket. Safe to apply without compiling because the transformation is a pure regex and the literal stays visible at the call site, so each line remains eye-checkable against the original. Applied to all three files in one pass so the archetype switch and the ported ops changed identically — had they not, the three equivalence tests would say so. 62 + 7 + 16 sites, none left, plus two bare `+ SeedF` worm sites by hand. New test VoxelForge.Determinism.LargeSeedSurvives (seeds up to 2e9) because the equivalence tests are structurally blind to this: they compare the stack against the switch, both read the same faulty expression, so at a large seed both collapse identically — bit-identical, green, and both flat. An oracle that shares the bug cannot see it. This test asserts a property instead of a comparison. EXPECT EVERY WORLD TO LOOK DIFFERENT: this re-rolls every noise offset in the plugin. Intended, and covered by OPSTACK-PLAN 2.6.1. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f7ed9407bf |
feat: wire SurfaceWorld (biome-less) + fix a column-memo perf trap
All six checks green, 9399 samples inside the overhang window. The single-entry column memo was correct only if the caller walks a Z column before changing XY, which the mesher does not promise. Iterating X first within a Z slice would miss on every voxel and re-run the whole height stack per voxel, cliff resamples included — an order of magnitude on the plugin's most expensive archetype. The tests could not have caught it: they sample random XY, where a one-entry memo and a 256-entry one behave identically. Only reading the access pattern finds this. Replaced with a direct-mapped 256-entry thread_local table hashed on the XY bit patterns, full key compared on hit, so a collision costs a recompute and never returns the wrong column. Wiring: UsesOperatorStackForChunk returns true for SurfaceWorld only when the strate has no biomes. The original blends heights toward the neighbouring biome across the border band; the stack evaluates one param set, so a biome strate would get a hard seam at every border rather than a subtle shift. The guard sits beside the archetype list so "can this strate take the stack?" stays one question in one place, and GetDensityAt keeps a defensive CP_BiomeCtx check that falls back if the two disagree. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
69fa73e07e | tmp | ||
|
|
cb61c8b2e4 | potential perf regression | ||
|
|
6875614002 | Fix Decoration Placement |