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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>