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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Jahni: "I do not need your work to be identical or near identical to what I had
before, only having it 99.99% at worst reproducible if two people share the same
seed, since everyone rebuilds it on multiplayer."
Recorded as OPSTACK-PLAN 2.6.1, superseding 2.6's "recognisably the same place".
Consequences, each recorded where it will be found:
- C10 closed permanently rather than parked: it measures old-path vs new-path
agreement and the two never coexist in a shipped world.
- The equivalence tests keep their value as PORT-CORRECTNESS checks, not fidelity
checks. Isosurface hard-fail stays; ULP grading is diagnostic only.
- C9 promoted to top open risk. "Two people share a seed" is exactly what /fp:fast
weakens across toolchains, and a Linux dedicated server against Windows clients
compiles the density path under opposite float models. FPSemantics = Precise is
the fix and the IWYU debt now blocks something that matters.
- C1 unblocked: it was deferred only because it re-rolls the world's noise.
Then, doing C1's arithmetic before applying its documented one-liner: THE FIX IS
WRONG. It bounds SeedF but keeps the * 97.7 multiplier, so the coordinate term
still reaches 1.6e6 where the ULP is 0.19 — 9.5x the ~0.02/voxel step. It would
have left the bug live for mid-range seeds while closing the ticket.
The real fix deletes the multipliers: they only decorrelate the ~40 noise sites,
which is a hashing job. VoxelHash::SeedOffset(Seed, Site) gives a site-salted,
bounded, final-units offset. Bounding SeedF alone would also funnel every seed
through 16384 offsets shared by all sites; per-site salting requires a collision at
all ~40 sites instead.
The op stack has already inherited the bug via FSlabVoidSource, so it must land in
both paths at once — and every further port copies it again.
Docs only; the C1 fix is not written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
Handoff: a pasteable resume prompt at the plugin root -- read order, exact state
(Phase 0.5 green, Phase 1 done and measured, step 3 written but not compiled),
the immediate next action, hard rules, open items, and the method lesson from
this session.
Skills: Jahni's UE library was at .claude/skills/core/<name>/SKILL.md, two levels
deep, where Claude Code discovers skills one level deep -- so none of the 39 were
loading. Flattened; 124 reference files intact, all frontmatter valid, folder
names already matched their name: field. core/category.md left as documentation.
Confirmed loading.
They are untracked and cannot be tracked without un-ignoring .claude/ itself
(git cannot re-include a file whose parent directory is excluded). Same shape as
AUDIT P1; flagged, not actioned.
Note for the next session: module-and-build-system documents PCHUsage, shared
PCHs and IWYU -- the exact mechanism that blocked C10's settling experiment. That
skill was in the repo, undiscovered, while it was worked out the slow way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
CODEMAP gains 3.2c (VoxelDensityPrimitives), 3.2d (the operator stack and its
factories), FVoxelOpSample under 3.2b, and the two new tests under 3.12.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "starting Phase 0.5" entry said the ClassifyTile test self-skips when the
fixture fails to build. It does not -- all four tests fail loudly with a message
identifying it as a fixture failure. Corrected by appending, since the log is
append-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 0.5 and the Phase 1 skeleton marked WRITTEN / NOT COMPILED (not "done" --
the gates are not met until the tests actually run). Section 8 independent fixes
2, 5 and 6 ticked. Section 9 "Resume here" now says BUILD.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AUDIT P1: every markdown design doc except CODEMAP.md was untracked, so
ARCHITECTURE / AUDIT / OPSTACK-PLAN / fable-idea / REVIEW_FINDINGS lived
only on disk. Replaces the single !CODEMAP.md exception with !*.md.
Also makes OPSTACK-PROGRESS.md commits actually record something, which
the unattended crash-safety discipline depends on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>