CLAUDE.md says each doc has one job and they must not duplicate. After GDD.md
landed, COMPOSER-NOTES.md and it both held the death loop, the city tiers and
the economy -- and duplicated documents drift until neither is authoritative.
So the notes are now the engineering document and nothing else: the vision and
its correction, what the game *requires* of the generator (constraints only,
pointing at the GDD for why), the composer itself, the manifest, naming, every
code finding and hazard, the build order, and the corrections nobody should
re-derive. 1561 lines down to 527, with the game design replaced by pointers.
Two sections earn their place on their own. Section 6 is the verified code
findings, including the one that blocks free composition (EffectOverBox answers
for the source+carve pair, so an op's box verdict is only correct in the stack
it shipped in) and the primordial law's live hole (GeneratePassages never
consults the destination strate's layout, so a passage can open into a sealed
pocket). Section 8 is the list of corrections that cost real time: Phase 3 is
reopened, Jahni does not want to author strates either, peer determinism never
required matching the old system.
The full 1561-line transcript remains in git at b781e3b for the reasoning
behind anything here that reads as bald assertion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
COMPOSER-NOTES.md is a conversation: chronological, with my wrong turns kept
on purpose so nobody re-derives them. That is the right shape for us reading it
back and the wrong shape for anyone else, including Codex.
GDD.md is the other half -- the decided state without the journey. Seventeen
sections covering the premise, the core loop, the city, the depths, a strate,
descending, the economy, death, housing, the verbs, the season, the community
layer and the opening. Every section separates what Jahni decided from what is
still open, and design that is mine rather than his says so, so nothing reads
as settled that isn't.
Written deliberately plainer than the notes: prose rather than dense tables,
because a design document that only its authors can read has failed at the one
thing it exists for.
Also records why the file is in the wrong folder. This is a document about the
game, and most of it -- the city, combat, the economy, seasons -- is nothing to
do with VoxelForge. It lives in the plugin because the plugin is the only part
of the project under version control. It should move to the project root once
that becomes a repository.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
Second design session. Started on the composer, ended up with the whole game's
economic loop, and one terminology fix that was overdue.
The loop, closed: the surface is a sky island or an unclimbable mountain, so
resources must come from below; the depths supply them; the resources build the
settlement; the settlement enables deeper descent. The settlement is communal,
identical for everyone, and permanent -- a season advances it one tier
(camp, village, town, city). So the wipe means something beyond freshness: the
world is impermanent, the civilization is not. We lost our maps, but we built a
town.
I had that arc backwards and it is marked as such in the file, along with the
vocabulary that caused it: "the world" was being used for two different things.
The depths are what VoxelForge generates and what regenerates each season; the
city is a plain Unreal level, permanent, and entirely outside this plugin. That
distinction is now pinned at the top of the notes.
Also settled: no bottom, boss strates every ~5 as intermediary gates, gear stats
capped around depth 30 with deeper loot buying better rolls and unique materials
rather than bigger numbers, materials tagged by difficulty tier rather than
depth so their location is rediscovered every season, and death leaving a
possessed corpse that copies your weapons without taking them -- a mirror match
that self-balances at every tier forever, provided the copied gear is never
lootable.
Two design records worth keeping: the manifest is two things (a tiny stored
spine and an infinite hash-addressable field, where the ID encodes the position
so resolving it needs no table), and the opening cinematic -- civilization
emerged from the depths, humans crystallized in the first artificial strate --
justifies the authored/generated seam in fiction. A player who senses strate 1
feels different from strate 2 is reading the story correctly, not spotting the
join.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
The city's hole is not at world (0,0), so the AVoxelWorld actor has to move.
It couldn't: the plugin was only HALF actor-relative, and had been for a long
time without symptoms because the actor always sat at the origin.
Already correct: GetBiomeAtWorldLocation, GetVoxelSurfaceHeightAt,
UpdateDecorations, UpdateLandmarks, UpdateWater, UVoxelDensityVolume::Update,
the passage debug draw, the clipmap material.
Not correct: the streaming centre, the streaming anchors, all six carve/fill
entry points, GetStrateAtPosition, and UVoxelAtmosphereManager::UpdateForPlayer
-- whose own comment documented the assumption out loud ("actor at
origin/identity"). Carving would have landed offset; the strate lookup would
have been quietly wrong by the actor's Z.
The trap worth recording: because the content managers already convert
internally, converting PlayerLastPos once at the source would have DOUBLE-
converted four call sites. The fix has to be per-boundary. That was only
visible after reading each consumer -- the estimate before that read was wrong
in both directions.
WorldToLocalCm / WorldToLocalVoxel / LocalVoxelToWorld are now declared as the
only sanctioned boundary between Unreal coordinates and voxel coordinates, and
the real deliverable is the invariant next to them: no `/ VOXEL_SIZE` applied
to a parameter named `World*` outside those three functions. A grep can check
that. The risk was never a missed site among the ones found -- it is the next
one nobody notices.
Also adds a translation-only guard in BeginPlay. Tiles, ClassifyBox verdicts,
the clipmap window, culling and streaming distance all assume world-axis-
aligned boxes; a rotation or non-unit scale breaks that structurally rather
than gracefully, so it logs an Error naming what was set.
With the actor at the origin every conversion is identity, so this is a no-op
until the actor actually moves. Built and confirmed working.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
FStrateGenerationParams::Lerp blends the hand-written VF_STRATE_PARAM_FIELDS
list, not the struct. Add a field to one and not the other and it compiles,
tests green, and silently takes its default inside every Gradient/Interleaved
transition band -- terrain that changes shape at the boundary and nowhere else.
CODEMAP has warned about this in prose for a month.
The test expands the X-macro a third way (after LERP and SNAP), into a list of
names, and diffs it against the struct's UObject reflection. No fixture, no
world -- a shape test, instant. GExemptFieldNames is empty on purpose: every
reflected field is covered today, so an exemption has to be written down as a
decision rather than happening by omission.
The stakes rise with the world composer, which intends to invent parameter sets
through this same Lerp: a missing field would then be constant across every
invented strate, with nothing to notice it by.
(Its CODEMAP §3.12 row lands in the following commit, which also touches that
file for an unrelated reason.)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
Second half of the design session. The realisation that resets the whole cost
budget: everyone shares one seed per season, so there is exactly one world per
season and the composer never runs on a player's machine. It runs once, on a
build box. Not "fast enough for a loading screen" -- "runs overnight". It can
generate a thousand candidates, keep eight, and let Jahni veto the season before
it publishes. What ships is the seed plus the chosen parameter vectors.
The measurement pass itself is one coarse grid and one flood fill, producing one
feature vector that serves validation, danger and rarity at once. Noted the one
place coarse sampling can lie -- a wall thinner than the sample spacing makes
sealed spaces look connected, which would pass the primordial-law check on an
impassable strate. Coarse to find the route, full resolution to confirm it.
And the answer to "how do we ever judge what's good": the judge is the corpus,
not a model. Good never gets defined, only collected -- strate-level thumbs
up/down, player behaviour, and best of all where players choose to snapshot
their home, which is an ungameable "I love this place" that costs nothing to
collect. Known risk written down: promote only what you already liked and the
system converges on a comfortable middle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
A long design conversation, written down as it happened. The headline: the
op stack's "Phase 3" is REOPENED. Asked what the world was missing, Jahni
described exactly it -- the system assembling worlds from data assets and a
seed -- so the 2026-08-16 handoff's "do NOT start Phase 3" is superseded.
Then a correction that moved the whole thing a level deeper: he does not want
to author strates either. The system must INVENT them. He authors raw content
(operators, meshes, materials, sounds, creatures) and tags it; everything above
that is composed and measured.
Decided along the way: worlds are wiped every 6-12 months, which dissolves the
"generation is frozen forever once public" problem -- generation is frozen only
within a season. Shared player builds are blueprint structures, vote-ranked.
Housing is an instanced level built from a snapshot of land the player chose.
Danger is depth x a per-strate multiplier, not a sort. Every strate has a
findable entrance and exit -- built first, generated around.
The range problem turned out cheap: don't roll 73 parameters independently
(that is what makes mush), blend known-good ones with FStrateGenerationParams::Lerp,
which already exists and already blends the whole field set. Ranges come from
the spread of the existing corpus, not from authored numbers. Verified against
the code: the X-macro is in sync with the struct, only Alpha differs.
Recurring mechanism, arrived at three separate times: measure, don't declare --
and promote what measures well back into the corpus, so the system's vocabulary
grows from its own history.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
Jahni built the last two commits and the game looks fine. Closing the thread.
OPSTACK-HANDOFF.md fully rewritten. The old one had become a private dialect --
"you've started using very unique terminology, i have NO idea what you've been
doing" -- which is a failure of the document, not the reader. The new version
leads with what the op stack was for, says plainly that Phase 3 was never built
and is not being built, carries the honest three-week ledger (120 commits, every
feat: a port, the world unchanged by design), and translates the jargon.
Final state: 8/8 archetypes live and bit-identical, 14/14 tests green through
871ca19, violations 0, and 39% of tiles skipped in the running game against 0%
that morning.
Flagged in the handoff: 4d33321 and 91585ea are built but not re-verified -- the
suite has not been re-run since. Two named checks, and revert 4d33321 if either
fails; the 39% does not depend on it.
Direction agreed: no Phase 3, no further refactoring, old switch stays as the
oracle, next work is visible content from fable-idea.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Jahni asked whether a "warning stage" he had been seeing for several sessions
meant something was broken. It did not: the only Warning in that run is the
tunnel test's "[dense fixture] No tile was proved", whose own text explains that
0 proved is the EXPECTED and correct result there -- cull spheres cover the
dense fixture ~3.6x, so no box can be outside all of them. Test passes, density
bit-identical, all coverage non-zero, violations 0, and production defaults
reports 11 proved / 14641 voxels / 0 violations right below it.
But a warning that fires every run and always means "fine" trains the reader to
ignore warnings, and this one quietly worried him across several sessions.
RunTileScan now takes bZeroProvedIsExpected: AddInfo on the dense fixture where
zero is the only possible answer, AddWarning on production defaults where zero
would be a real regression from 11. Same message, severity now carries meaning.
Same defect as the lumped bail counter one layer out: a signal that always says
the same thing measures nothing.
Test-only. Not built.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Classified 1.88 / Meshed 1.15 / SkippedAllAir 0.73 (1.15 + 0.73 = 1.88, exact).
Was Classified 1.90 / Meshed 1.90 / nothing skipped. Cave Bail No Layout went
1.58 -> gone.
39% of classified tiles now skipped against 0% this morning, each avoiding
~43000 density evaluations plus marching cubes. First observation of
tile-skipping in the running game rather than an automation harness.
Not yet firing, stated plainly: TilesSkippedAllSolid and the two op-stack
counters remain absent, so the cave branch still produces no verdicts in game --
its attempts land in Cave Bail Mixed Content (0.77) and Stack Verdict (0.32).
The win here is the open-air class proved by the classifier's own Z categories,
not by ClassifyBox. That is the next thread and the counters now name it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VoxelGenerator.cpp(2796): C2248 -- FindSlotIndexForChunkZ is protected.
I claimed it was public "verified" in the previous commit. The grep printed the
declaration line, not the access specifier above it. Same shape as the partial
read that produced a false accusation earlier today.
GetStrateChunkZBounds is public (VoxelStrateManager.h:177), returns false for
exactly the no-slot case, and ClassifyTile already calls it three times for the
same question. Wrapped in a VF_ChunkZHasSlot lambda so the condition reads as
the predicate it is. No accessor promoted, no API widened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured in game: Cave Bail Not Op Stack No Layout = 1.58 of 1.90 classified
tiles (83%), with SoleSlot and BoundaryTile never firing. The assets were
correctly ticked all along.
Cause: GetGeneratorTypeForChunk returns TunnelNetwork for any chunk outside the
strate stack, commenting "the fallback density path will produce solid rock
anyway". That comment is FALSE. GetGenerationParams for SlotIdx < 0 returns
BaseDensity = -1, RoomDensity = 0, WormStrength = 0 -- a CONSTANT AIR field.
IsGapChunk's "open air, NOT a gap" was the correct description.
So every tile touching the open air above the world looked like a cave
archetype, entered the cave branch, found no layout slot, and bailed. T1.d was
never failing -- it was unreachable, behind a routing mistake in the archetype
lookup that had nothing to do with the operator stack, the box verdicts, or the
flags.
Fix: ClassifyTile gains a fourth Z category for no-slot chunks. It sets
bAnyNonCave (never enters the cave branch) and bCanSolid = false (AllAir
survives), and bails to Mixed if a disturbance could add rock there -- chasms
only carve, so they cannot threaten an air verdict.
A tile entirely above the stack now resolves AllAir and is skipped: the
majority of a surface flight, a class T1.d has never been able to prove.
Classifier only -- no density value changes. Equivalences must stay
bit-identical and violations must stay 0.
Not built -- Jahni builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
In-game with Sol's four-way split: NotOpStackNoLayout = 1.58 of 1.90 classified
(83%), while SoleSlot, BoundaryTile and MixedContent never fire. So the assets
ARE ticked -- Jahni was right -- and the neighbour theory is dead. The split
earned itself on its first flight; the old lumped counter would have sent us at
the assets again.
Mechanism: GetGeneratorTypeForChunk returns TunnelNetwork for any chunk outside
the strate stack ("fallback produces solid rock anyway"), while IsGapChunk
returns false for above-stack chunks ("open air, NOT a gap"). So every chunk
above TopChunkZ = 0 -- all the open air over the world -- looks to ClassifyTile
like a cave archetype, enters the cave branch, finds no layout slot, and bails.
T1.d was never failing. It was unreachable, behind a routing mistake in the
archetype lookup that has nothing to do with the operator stack, the box
verdicts, or the flags -- all of which are correct and tested.
Not fixed: the two comments disagree about whether out-of-layout is solid rock
or open air, and the right verdict depends on which. Read GetDensityAt's
no-slot path before choosing; guessing there writes a hole.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The verbosity fix stopped the diagnostic failing the suite, but it still printed
at Log on every Initialize -- which the fixture calls dozens of times per run,
burying the actual test output under hundreds of identical lines.
Under GIsAutomationTesting it now logs at Verbose (off by default, still
available with -LogCmds). Editor and game keep Warning, where it is actionable
and fires once per rebuild.
The information was worthless in tests anyway: the fixture's opt-in state is
known by construction, and the one test that needs the opted-in variant
(OpStack.ClassifyTileSoundness) already reports "all 7 cave layout slots have
Use Operator Stack enabled" on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14/14 pass with no StrateManager warnings. Every reported number is identical
to the pre-change run, including the CrossPlatform FIELD digest
(0xF62F3D355B1C0BDB) -- a bit-for-bit hash over 115,000 samples of the whole
field.
That digest is the strongest evidence available: the six-box LRU, the
DensityCacheOwnerId key, the bail re-attribution and the scope fix did not move
a single voxel between them. Four changes to caching, keying and diagnostics,
and the world is byte-for-byte identical -- exactly the contract each claimed,
now tested rather than asserted.
Still unmeasured: the performance benefit of those caching changes. "Quite a
fraction of what it used to be" is a real observation, not a same-seed/
same-route number.
Still open: read `Operator-stack opt-in` in the GAME log. That line is the
ground truth for T1.d and supersedes both the bail-counter inference and the
recorded belief that the assets have the switch on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sol's opt-in warning logs at Warning. UE's automation framework counts a
Warning as a failure, and the Determinism.* tests deliberately build a
NON-opted-in world -- that world is their comparison oracle, the baseline every
OpStack.* number is measured against. The diagnostic was failing the suite by
correctly reporting an intentional configuration.
Verbosity is now Log under GIsAutomationTesting and Warning otherwise. The
message is written once and only the verbosity branches (Printf then log %s):
four copies of a long format string across two sites would have been exactly
the "one definition, not two kept in sync" failure this project has rules
about. CoreGlobals.h included explicitly for GIsAutomationTesting -- IWYU, no
shared PCH here.
Note for the record: the tests must NOT be switched to the op stack. The
non-opted-in fixture is the oracle, not an oversight.
The diagnostic also did its job: 7/7 cave slots report disabled in the fixture
(slot 4 absent = the SurfaceWorld narrowing working). The same line read from
the GAME log is now the ground truth for whether T1.d has ever been enabled in
production.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VoxelDensityOpStack.cpp(832): C2065 'Box' undeclared.
The six-box LRU refactor moved acquisition to `FColumnBox& Box = Cache.Acquire(...)`
inside `if (bIntegerXY)`, but the Computed flag is set after the column is
computed, outside that block. The previous single-box version had Box at
function scope so the write-back compiled.
Hoists `FColumnBox* AcquiredBox` beside MemoColumn and writes back through it.
The guard becomes `if (AcquiredBox)` instead of `if (bIntegerXY)`: non-null
implies the integer path, so the pointer proves its own safety rather than
relying on two conditions staying in agreement.
No caching or logic change. Other Box. uses (761-763) are in scope, verified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Append the exact commits, Approach A memory decision, cave-only warning caveat, diagnostic semantics, CP owner scope, compile watchpoints, deliberately open measurements, and recoverable worktree cleanup. No build or runtime result is claimed.
Function-static thread_local CP state previously trusted only chunk coordinates and each manager's locally-reused layout version, allowing a worker to carry params, biome context, CP_UseOpStack, and the built stack into another world. Allocate each generator a monotonic owner ID and add one uint64 comparison to the hot key. This intentionally fixes only the proved CP_* path; other TLS caches remain outside this change.
The old Not Op Stack counter fired before slot attribution, so interior disabled strates and boundary encounters produced the same number. Split the instrumentation into sole-slot, boundary-tile, unresolved-layout, and late-recheck counters while preserving every ClassifyTile condition, return point, and verdict.
The single direct-indexed box discarded all 6,561 computed columns whenever spatial or column identity moved. Port the reference six-box LRU so interleaved regions keep five warm working sets, accepting the documented ~0.79 MiB TLS cost per worker to preserve the recommended and measurable A/B path. Also report disabled cave opt-ins at layout initialization; SurfaceWorld is excluded because its exact-lattice tile proof does not use that flag.
Sol High investigated with Luna xHigh sub-agents; two approaches built in
isolated worktrees, main tree untouched.
Two corrections to my own assertions:
1. "One un-ticked neighbouring strate kills boundary tiles" is WRONG.
UsesOperatorStackForChunk depends only on ChunkCoord.Z, and ClassifyTile's
earlier Z loop already required the predicate for every sampled cave Z within
one slot -- so the later XYZ sweep is redundant, not over-strict. Removing it
unlocks nothing; boundary tiles still fail the one-slot/type/params guards,
correctly.
2. I falsely wrote that VF-03's fixture citation was fabricated. It exists
(VoxelForgeTestFixture.h ~134/146, explicit about CP_UseOpStack
contamination). I had read only the 30-line header and asserted a negative
from a partial read. Header corrected. VF-03's core claim is now CONFIRMED:
GetDensityAt keys thread_local CP_* by (ChunkCoord, LayoutVersion) with no
world identity, and every manager's version starts equal.
Also: the Cave Bail Not Op Stack counter I specced is ambiguous -- it fires
before the different-slot attribution, so it cannot distinguish "flown slot
un-ticked" from "boundary tile met an un-ticked slot first". My 80% reading was
over-confident. Same defect I fixed for TilesSkippedAllSolid, reintroduced one
layer down.
Approach A recommended (explicit opt-in + six-box LRU) over B (code-enforced
cutover): the code does not justify weakening ClassifyTile, and B would delete
the flag, which is the only same-route A/B lever available to prove the
refactor. Narrow A's warning to cave archetypes before adopting.
Nothing built. Worktrees left in place for review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Part B paid for itself on its first flight. Over the caves, Tiles Classified
== Tiles Meshed (nothing skipped at all) and CaveBailNotOpStack accounts for
~80% of cave-branch attempts. That counter has exactly one cause:
UsesOperatorStackForChunk returned false -- so either the strate flown over has
not ticked bUseOperatorStack, or a chunk in the tile's box belongs to an
adjacent strate that has not, since the per-chunk sweep requires every chunk to
agree. CaveBailStackVerdict being non-zero (0.32) corroborates: where the flag
did hold for the whole box, the stack built fine and merely returned Mixed.
The machinery works; it is mostly not being reached.
RETRACTION: I called the column-memo port a regression after comparing 14.7%
from one flight against 23.3% from another. Invalid -- the miss rate is
strongly location-dependent (~10-30%) and was in that band before the port.
Comparing single-flight averages from different routes measures the route. A
real before/after needs the same seed and route; parked until then, and not
urgent.
Kept while diagnosing: GSurfColCache is a 6-box LRU, not one box. My spec said
"a direct-indexed box" and Codex implemented one faithfully, so any recentre
now wipes all 6561 cells where the old table lost one entry. Plausible thrash
source, unmeasured. Port the LRU if the memo is revisited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BUILD RESULTS FIRST: 14/14 tests green, 0 violations. VerticalShafts box
verdicts went 0 -> 30 of 60 (zero was the number for the project's whole life).
TunnelNetwork production held at 11 proved / 14641 voxels, byte-identical --
that line was the isolated diagnostic for cab8e8f, so its stillness proves
Jahni's room/tunnel Min <= Max are correctly ordered. All eight equivalence
tests bit-identical, which TESTS the "bounds only, cannot reach density" claim
made on 7dbdf51 / eaa44bf / cab8e8f rather than asserting it.
PART A -- the column memo thrash is confirmed quantitatively. Measured 14.74%
miss rate against a prediction of 14.0% if thrashing and 1.4% if not; and 8,300
recomputes per tile against a healthy 1,225 (6.8x) from an independent
statistic. Replaced the 4096-slot direct-mapped hashed table with the
direct-indexed box scheme GSurfColCache has always used: no hash, no
collisions, every column computed exactly once. ParamsFingerprint is retained
in the box key -- its absence was the shipped bug that silently deleted the
overhang, and GSurfColCache's own omission of it was deliberately not copied.
PART B -- T1.d never fires in game: the two op-stack counters never appeared,
and since every displayed row has Min 1.00 rather than 0.00, rows only render
when a counter fires. The cave branch has 13 return-Mixed paths; guessing which
is the mistake this project keeps paying for. Six attribution counters now name
the bail category outright. Only edits there are brace-expansions so a counter
fits before each existing return -- every condition and returned value is
byte-identical.
Open falsifiable hypothesis: Tiles Meshed averages 2.13/frame, plausibly the
reason LOD rings update slowly. If misses drop ~10x and LODs speed up, the memo
was the ceiling; if not, they are separate problems, cleanly separated.
Not built -- Jahni builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Initialize does StrateLayout.Empty() and Passages.Empty()/Add() -- freeing and
reallocating both -- with no guard, while mesher workers read them through
AnyPassageNearBox, EvaluateModifierSDF and FindSlotIndexForChunkZ. The epoch is
bumped AFTER, so previous-epoch workers are live during the mutation; the epoch
rejects a finished result, it cannot make a read of a freed allocation safe.
Same class as the DiffLayer.ChunkMods carve-vs-stream AV that ModsLock fixed.
Chose the drain over the two alternatives:
- FRWLock on the arrays: correct, exact in-repo precedent, but a read lock on
the per-voxel hot path (~43k/tile) would contaminate the perf measurement
CODEX-TASK-001/002 are queued to take. Worst possible timing.
- Immutable generation snapshot (Sol's proposal): right long-term, refactors
the whole StrateManager API surface. A design conversation, not an agent's
unprompted call.
- Drain: zero hot-path cost, reuses the machinery EndPlay already proved, and
its stall lands only on human-initiated editor actions, never during play.
FScopedGenerationPause raises a new bGenerationPaused (deliberately NOT
bShuttingDown, which means teardown) and drains both reader populations: chunk
tasks via ActiveTaskCount and decoration tasks via a new
WaitForDecorationTasks, whose refactor also removed NotifyShutdown's duplicate
spin loop. On timeout it does NOT mutate -- logs an Error and leaves the world
consistent. A timeout that proceeds is what the bug already does.
RebuildStrates, OnObjectModifiedInEditor and ChangeSeed cannot reach Initialize
without the pause. BeginPlay untouched (no tasks yet). EndPlay untouched --
VF-02's 3s timeout is a separate deliberate decision.
Verified: four files, no density/mesher file, so terrain cannot move.
ShouldAbortWork replaced three existing checks, adding none. No worker path
waits on the game thread, so the drain is bounded.
Not built -- Jahni builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VF-01 CONFIRMED and it is the most serious find of the day. Initialize does
StrateLayout.Empty() and Passages.Empty()/Add() -- freeing and reallocating both
-- with no lock, barrier or drain anywhere in that file, while worker threads
read the same arrays through AnyPassageNearBox (:460), EvaluateModifierSDF and
FindSlotIndexForChunkZ, reached from GetDensityAt/ClassifyTile. RegenerateAllChunks
bumps the epoch AFTER the mutation, so previous-epoch workers are live during it;
the epoch rejects a finished result, it cannot make a read of a freed allocation
safe. Same class already fixed once here: DiffLayer.ChunkMods got ModsLock after
a carve-vs-stream AV. The dangerous call site is OnObjectModifiedInEditor, which
fires automatically on a strate asset edit while streaming -- routine here.
VF-10 confirmed real (74 fields, per near-surface sample) but Sol missed the
conclusion: the copy is INHERITED from GetDensityWithParams, so both paths pay it
and it does NOT explain the op-stack regression. The op stack improved it by
memoising across eleven detail ops. Future optimisation, not the answer.
Deliberately NOT fixing VF-01 now. Four code changes are stacked unbuilt and
three have "nothing should move" as their acceptance signal; a fifth change to
streaming lifecycle would make an odd build result unattributable. The three
fix options (drain / snapshot / RWLock) trade a possible crash for an editor
hitch or hot-path lock traffic -- a product decision, not an agent's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third instance of the same class, and the worst one -- found by the Sol-High
read-only audit (VF-05) and verified against the code before acting.
MaxInfluence, RoomZBuffer and EvaluateSDF's Margin all derive from
MaxRoomRadius / TunnelMaxRadius, while the radii are Lerp(Min, Max, hash), which
yields up to max(Min, Max). A room able to reach a chunk can therefore sit in a
cell the collect region never visited.
Worse than the two fixed earlier today because:
- it is TunnelNetwork, the largest archetype;
- BuildChunkCache is called by BOTH density paths (FRoomGraphSource calls it
rather than transcribing it), so this was never an op-stack bug -- it is in
the shipped original code and always has been;
- the failure mode is a window-invariance break (ARCHITECTURE 8.4): whether a
room exists depends on which chunk you queried from, which in multiplayer
means two peers generate different geometry from the same seed.
Four bound sites now use RoomRadiusEnvelope / TunnelRadiusEnvelope; the two
duplicated copies of the formula still compute the identical expression. No
Lerp, placement, hash or bStore line changed -- with correctly ordered params
max(Min,Max) == Max, so this is bit-identical. A no-op at correct values is the
acceptance signal.
Also adds a reviewer's header to AUDIT-2026-08-CODEX.md marking which findings I
verified (VF-05 confirmed, VF-02 premise confirmed, VF-03 evidence overstated --
its cited fixture corroboration does not exist) and which are unverified leads.
Not built -- Jahni builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The handoff said "No strate asset has the box ticked -- that is my call and I
still haven't made it." Stale, and I reasoned from it all session. Jahni: "the
data assets in game have the switch on."
Everything downstream flips. The operator stack is the PRODUCTION density path,
so the measured perf regression is one players feel, and any unsound
EffectOverBox is a live hole rather than a latent one. The two correctness fixes
committed today (7dbdf51, eaa44bf) both say "nothing in the running game was
affected" in their bodies -- that sentence is wrong. Those commits are pushed
and stay as written; OPSTACK-PROGRESS 2026-08-16 (h) is the correction of record.
Concrete consequence: 7dbdf51 did not fail in practice only because this
Perlin's empirical sup (~1.0-1.1) happened to stay under the margin the wrong
bound left (1.27 for shafts, 1.40 for maze) -- not because the code was right.
And eaa44bf is worth checking against the real assets: if any strate has
ColumnMinRadius > ColumnMaxRadius or ShaftMinRadius > ShaftMaxRadius, it WAS
producing tiles with no geometry and no collision.
Process failure worth keeping: the flag lives in .uasset binary data, which is
not greppable from here, so the docs carried a hand-written claim about it that
nobody re-checked. A fact that lives outside the repo cannot be maintained
inside it -- record what it was and when it was checked, never assert it as
current. Rule added to the handoff.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both said the refactor was mid-flight and both contradicted the code around them.
1. BuildTunnelNetworkStack: "stage A of three, the twelve detail modifiers and
the per-room override are not ported yet, which is why
UsesOperatorStackForChunk returns false for TunnelNetwork." All three stages
are done, the twelve modifiers are added a dozen lines below the comment, and
that function returns true.
2. FIslandBlobSource: "two of the three frame users are not ported yet; revisit
when TunnelNetwork brings the second real use." TunnelNetwork is ported, and
BuildTunnelNetworkStack already records the verdict: CaveWarp wraps exactly
one operator and VerticalScale is a pure scalar function, so ZERO frames out
of three candidates. The question was answered; only this comment still asked
it, and it would have sent the next reader off to build frame infrastructure
that was explicitly decided against.
A comment that contradicts the code beneath it is the "read the code, not the
comment" trap in reverse. This file already carries the forward version of that
lesson, at the cliff modifier whose comment promises a gradient it never samples.
Comments only -- no code changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "a sampler must cover at least one period" bug was found in the tunnel test
(+/-32 vs RoomSpacing 80) and again in the shaft test (+/-48 vs ShaftSpacing 55).
Both were fixed. The class was not: three box-verdict scans still ran the
original RandRange(-6,6)*Extent at Step 1 / Cells 8, i.e. +/-48 voxels.
Island +/-48 vs IslandSpacing 95 = 0.51 periods (worse than either fix)
Slab +/-48 vs ColumnSpacing 60 = 0.80 periods
Maze +/-48 vs CellSize 40 = 1.20 periods
Verified the fixture does not override any of those spacings, so the header
defaults are what these tests really ran against.
This matters now specifically: 7dbdf51 changed the island and maze verdicts and
eaa44bf changed the slab column verdict, and these are the tests that guard
them. Widening before the build is what makes that build's green mean anything.
The rule the fixed tests already encoded, now explicit: Extent = 8, so
SpanCells = the lattice spacing gives exactly 8 periods -- which is where the
shaft test's 55 came from. Island 95, Slab 60, Maze 40, each printing the ratio
computed LIVE from the params struct so a future narrowing cannot be silent.
No assertion, tolerance, AddError, brute-force loop, seed, Step, Cells or tile
count was touched: this changes what the measurement looks at, never what it
demands. Proved counts will move (expected); NumUnsound must stay 0.
Test-only -- git diff lists three files under Private/Tests/. Not built.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second defect from the full 28-op EffectOverBox audit. Three ops roll a radius
as Lerp(MinRadius, MaxRadius, hash01), which lands in [min(A,B), max(A,B)] --
Lerp does not require A <= B. Their EffectOverBox sweeps lattice cells padded by
the largest radius a cell could hold, so a pad below the true maximum means the
cells are never examined and the op reports Identity for a box its own Eval will
fill or carve: no geometry, no collision.
FGridColumnMod ~1086 Max(MaxRadius, 0) exposed
FShaftFieldSource ~1436 Max(ShaftMaxRadius, ConnectorRadius) exposed
FIslandBlobSource ~1803 Max(IslandMinRadius, IslandMaxRadius) already correct
The third one is the argument: the concern was met and guarded once in this same
file, and the other two shipped without it. Fixed with FMath::Max3, a spelling
already used here (~2481) and in VoxelCaveMorphology.cpp.
Shipped defaults are ordered correctly (2/5, 2/7, 5/11), so this is a NO-OP at
defaults -- that is its acceptance signal, and any moved number means the diff
did more than intended. It needs a mis-ordered asset value, which nothing
prevents: ClampMin is a per-property floor and UE cannot express "<= that other
property". ColumnMinRadius is also settable per-room via UVoxelTerrainOpDefinition.
Deliberately NOT fixed by normalising the params: swapping the Lerp endpoints
maps the same hash to a different radius, changing geometry and breaking the
eight equivalence tests. Only the bound becomes conservative; Eval is byte-
identical -- verified, no Eval/Roll/GetCells line in the diff.
Also audited and found sound (recorded so they are not re-chased): FPassageCarveOp
and the passage bounding sphere, FOriginSpineOp, FBoundarySealOp, FShaftLedgeMod,
FCaveArchMod, FDomeMod.
Not built -- Jahni builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CORRECTNESS on a box verdict, not perf. This file derives |Perlin3D| <= 1.5
rigorously, exposes it as PerlinAbsBound, uses it for the tunnel warp, and says
outright that the header's "~[-1,1]" is an observation and that a box verdict
resting on one is a hole. Three ExtraReach formulas in the same file assumed
sup|FBM| <= 1.0 -- and VoxelNoise::FBM normalises (Total / MaxValue), so
sup|FBM| = sup|Perlin3D| exactly. The bound was wrong by 1.5x.
At shipped defaults, soundness needed B <= 1.27 (VerticalShafts, Rough 3.0) and
B <= 1.40 (Maze, Rough 2.0); both are violated at B = 1.5. Break-even roughness
is 1.6. FloatingIslands survives only because its SDFBlendRadius is 5 -- sound
by parameter luck, not construction.
Nothing in the running game was affected (no strate has bUseOperatorStack
ticked) and the empirical Perlin sup ~1.0-1.1 is why nothing surfaced. But the
tile scans SAMPLE, and for shafts they sampled a source that proved zero tiles
until e002bd4 -- which is what makes this reachable rather than latent.
Fix: PerlinAbsBound hoisted to file scope as VF_PerlinAbsBound (one definition,
not a class-static plus three implicit 1.0s) and multiplied into all three
reaches; the three comments now state the real justification instead of the
refuted one.
Cannot change density: ExtraReach is read only inside EffectOverBox, verified
mechanically -- no Eval/GetCells line appears in the diff. Direction is strictly
conservative, so it can only cost CPU. Expect FEWER proved tiles for Maze and
VerticalShafts; that is the correct outcome, not a regression.
Not built -- Jahni builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reading session -- no build available, so: verify things instead of writing code.
- Cross-checked the eight stat declarations against their definitions and use
sites. Identical. The one compile risk I had only eyeballed is now retired.
- REVIEW_FINDINGS: the GetDensityWithParams and BuildChunkCache splits are now
counter-productive rather than optional -- the op stack is replacing the
first, and FRoomGraphSource deliberately CALLS the second so a restructure
forks what was kept unforked. The two passage enums are not duplicates and
are UMETA-serialised into authored assets; merging them rewrites content for
tidiness. Judged: leave, and close the item.
- CODEX-TASK-002's acceptance said "20-30% of lookups", a guess wearing a
number. Replaced with arithmetic from the real grid: 1225 columns/tile over
35 Z planes, load factor 0.299, ~317 colliding columns, ~12000 vs 1225
computations = ~9.8x. Plus the reading rule that matters -- the overhang and
cliff mods re-query the same XY and add HITS only, so compare the ratio of
the two hypotheses, not an absolute percentage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Was about to spec a Codex fix for OC_Chunk / BM_Chunk / FChunkBiomeCache,
which three docs still list as open. Checked the sites first. All of them
already carry a layout-version guard:
CP_Chunk -> CP_Version OC_Chunk -> OC_Version
BM_Chunk -> BM_Version TC_BiomeCache -> TC_SeenVersion
FChunkBiomeCache::Invalidate() exists precisely because the validity box says
nothing about the FBiomeContext its cells were classified against, and all four
thread_local instances call it on a version change. The only other two
instances in the tree (VoxelContentManager ~445, the height-stack test) are
function-local, constructed per task, so staleness is impossible there.
Seventh time in this project a confident premise reversed on reading. It cost
a doc edit instead of a Codex run and a build cycle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plugin had ZERO stat counters, so every claim this refactor makes was
harness-only: "skipped correctly" and "skipped nothing" render identically.
CODEX-TASK-001 and -002, executed by Codex (gpt-5.6-luna xhigh), reviewed
against the source.
Eight DWORD counters in a new stat group:
GenerateTileResult -- TilesClassified / SkippedAllSolid / SkippedAllAir / Meshed
ClassifyTile bAnyCave exit -- TilesOpStackSolid / TilesOpStackAir
FSurfaceColumnSource::GetColumn -- ColumnMemoHit / ColumnMemoMiss
The op-stack counters are separate from the lumped skip counters on purpose:
ClassifyTile also proves AllSolid on its hand-written bedrock-gap path with no
strate opted in, so the lumped number cannot show a before/after. The op-stack
pair is zero BY CONSTRUCTION until a strate ticks the flag.
GetColumn is instrumented and deliberately NOT fixed -- the instrument and the
fix in one build would make each other unreadable.
Verified against UE_5.7 Stats.h rather than assumed (first stats use in this
plugin, no in-repo precedent): all four macro arities, and INC_DWORD_STAT ->
FThreadStats::AddMessage, so the worker-thread safety both sites need holds by
mechanism. Not built -- Jahni builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three orchestration findings on the parked PERF item, none measured yet:
1. The perf A/B needs no new code -- two Insights traces on a deterministic
world are a clean before/after. But tile skipping changes how many times
GenerateMesh runs, so totals conflate "cheaper per tile" with "fewer
tiles". TilesMeshed from task 001 is the denominator. 001 is therefore a
PREREQUISITE for the perf work, not a parallel item as the handoff had it.
2. The suspects don't share an archetype: SurfaceWorld is the column memo,
TunnelNetwork is 19 virtual calls per voxel (16 + 3 structural post).
One lumped number can't be acted on -- measure one strate at a time.
3. GetColumn is a direct-mapped hashed table where GSurfColCache is a
direct-indexed box, and the mesher pre-samples Z-OUTERMOST (verified,
VoxelMarchingCubesMesher.cpp ~226) so every column is revisited ~34x per
tile. The table's sizing comment reasons about fullness; a direct-mapped
table evicts on collision. At load factor 0.28 that is still ~25% of
columns recomputing the full height stack every Z plane -- derived ~9x.
Task 002 measures that and fixes nothing, so the instrument and the fix
cannot land in the same build and make each other unreadable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewing the spec against ClassifyTile reversed its acceptance bar. The function
reaches a non-Mixed verdict two independent ways: the hand-written path (a bedrock
gap sets bCanAir=false, VoxelGenerator.cpp ~2835, and the tile resolves AllSolid)
and the operator-stack path (the bAnyCave block). The first fires with NO strate
opted in, so the spec's baseline -- "TilesSkippedAllSolid stays 0 underground" --
was never going to hold, and the before/after would have been unreadable.
Adds site B at the bAnyCave exit (~3009) with TilesOpStackSolid / TilesOpStackAir.
Those are zero BY CONSTRUCTION without an opted-in strate, since the branch returns
Mixed at the UsesOperatorStackForChunk gate -- a stronger baseline than the one it
replaces. Deliverable is now: tick the box, TilesOpStackSolid goes non-zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Jahni built a world and said "I don't know if it dropped any meshing? but it
looks alright by the eye." That sentence is the honest state of this refactor:
everything proved so far was proved in an automation harness on 40 sampled
tiles, and in the running game tile-skipping is unobservable.
grep INC_DWORD_STAT over Source/ returns nothing -- the plugin has zero stat
counters -- and "skipped correctly" renders identically to "skipped nothing", so
no visual check can separate them. The tests got the "coverage is a number, not
a boolean" discipline this session; the game never did.
CODEX-TASK-001-tile-skip-stats.md specs a stat VoxelForge group with
TilesClassified / TilesSkippedAllSolid / TilesSkippedAllAir / TilesMeshed. Solid
and air are split deliberately: cave archetypes prove AllSolid, so that counter
is the one that says whether the op-stack work did anything real. Its deliverable
is the before/after that constitutes the PRODUCTION proof of T1.d, which does not
exist today.
The spec carries the invariants rather than just the task, which is the point of
a spec here: bTrivialEmpty decides whether a tile has COLLISION, the five-clause
gate is load-bearing, and GenerateTileResult runs on worker threads -- so a plain
static int32++ is a data race while INC_DWORD_STAT is not.
Handoff updated: a "How we work now" section (Codex Model Luna xHigh writes most
code, Claude orchestrates -- hand over the INVARIANT, review against the code and
not the description), first actions split into the Codex task and the pending
e002bd4 build, and the T1.d heading qualified as harness-measured rather than
game-proven. Also records the Insights interim answer and its trap: ~84% of tiles
were already rejected by the hand-written surface/bedrock paths, so surface skips
drown the cave ones unless you are underground in an opted-in strate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rule said "Never push" without qualification, which was true while
experimental existed only locally and became misleading the moment it did not:
a future session would read it and let origin/experimental drift.
Now: push experimental freely (tracked since 2026-07-29), never push main, which
stays pinned at the known-good commit. Both statements of the rule are updated
(OPSTACK-HANDOFF.md and OPSTACK-PROMPT.md's crash-safe discipline).
Also added at both sites, because it is the failure mode this creates: a pushed
commit is NOT a "verified green" marker. This branch carries unbuilt work by
design, so OPSTACK-PROGRESS.md remains the only record of what was actually
built and measured, and the remote records only what was written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old handoff's "first action" (confirm the Underwater coverage number) and its
"one task everything is waiting on" (make FRoomGraphSource::EffectOverBox answer
spatially) are both done, so most of it was actively misleading.
What it says now:
- T1.d is real and measured: 11 of 40 tiles proved AllSolid at production
defaults, 14641 voxels brute-forced, 0 violations; dense fixture correctly 0.
- A boxed invariant near the top, because it is the most dangerous thing in the
current code: Identity from the room source means Sdf >= T, not FLT_MAX, and
any new Sdf consumer with a bigger threshold silently produces tiles with no
geometry and no collision.
- First action is now "build e002bd4 and read ONE line" (VerticalShafts box
verdicts), including what to check first if it is still 0 -- ExtraReach --
rather than re-deriving, which is what cost three rounds on TunnelNetwork.
- Both old debts restated with their real status: the per-room-op bound is
DORMANT (checked, premise reversed), and AUDIT C2 is FIXED on the switch path
with a note on why the audit's own suggested alternative was the wrong fix.
- The warp squeeze is parked WITH its measured ceiling and a recorded negative
result, so the obvious next attempt (local Lipschitz warp bound) is not
repeated: 8.5 * 0.206 = 1.75 exceeds the global range bound of 1.5.
New method lessons, led by the one this session actually paid for: instrument
what you ASSUMED, not just what you changed. Also that a sampler must cover a
period of what it samples, that a fixture tuned for coverage can be antagonistic
to what you are measuring, that diagnostics must report this run rather than
carry narrative, and that you must not assert a number you intend to improve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Jahni's call: bank T1.d at 11/40 and take VerticalShafts rather than squeeze the
Perlin sup. Right call -- a clearly-scoped defect with no hole-risk maths, on an
archetype that was getting nothing.
The defect is stated in its own comment. FShaftFieldSource::EffectOverBox ended
with a "connectors" branch that never looks at a connector: it returns CarveOnly
because a shaft EXISTS within Spacing*1.6 + Pad. At ShaftSpacing 55 and
ShaftDensity 0.6 that box spans ~4x4 cells and ~10 shafts, so the condition is
true essentially everywhere -- exactly the reported 0 proved of 60. Conservative,
never wrong, completely sterile; the same shape as the worm's unconditional
CarveOnly one archetype over.
It now rebuilds the connectors the way GetCells does and tests the real capsule.
Two things had to be right and both were read in the source rather than assumed:
- The enumeration is a superset. Eval reads connectors from the 3x3 of ITS
query's cell, so any pair visible from a point in the box has both shafts in
the 3x3 of some cell the box touches, i.e. in [box cells] +- 1 -- the range
swept here. Pairs no query ever sees may be produced: extra CarveOnly, never a
hole.
- The pair order matches, so the hash matches. VoxelHash::Pair is fed in
insertion order and GetCells inserts over (dy, dx), row-major; this sweeps
(cy, cx), and row-major order restricted to a sub-grid preserves the relative
order of two cells. So the same pair gets the same hash WITHOUT assuming
Pair() is symmetric -- which was never verified and now need not be.
The capsule test splits the axes because a connector is HORIZONTAL at height Zc:
Z is exact, XY is point-to-segment from the box centre minus the XY half
diagonal. Tighter than the 3-D half-diagonal the tunnel version had to settle for.
Also the same sampler trap, caught before the build this time: tile XY was drawn
from +-48 voxels against ShaftSpacing 55 -- under one period of the pattern,
identical in kind to the +-32-vs-80 bug that cost the tunnel test three runs.
Widened to +-440, and the report prints its own extent in units of ShaftSpacing.
The safety net was already there and is untouched: this test brute-forces the
full lattice of every proved tile, both hypotheses, and AddErrors on the first
violation. If the superset or hash-order argument is wrong, the assertion fails
rather than a player falling through the floor.
Unbuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The instrument landed on its first run. At production defaults, rooms reaching
go 0.9 -> 0.4 and tunnels 2.4 -> 1.1 when the warp dilation is set to zero: more
than half of all remaining blocking is the query box rather than cave geometry.
Hypothesis confirmed, and the number is permanent output now.
CORRECTION 1. Last entry I claimed BuildChunkCache's Expansion =
CaveWarpStrength + 2 means "the whole plugin bets on |Perlin3D| <= 0.8", and used
it to argue 2.0 was needlessly conservative. False. Six lines further down,
bNeedRebuild tests WarpedX < CachedSMinX || WarpedX > CachedSMaxX -- the cache
REBUILDS when the warped query leaves the box, so that expansion is a
rebuild-frequency heuristic and nothing bets on 0.8. The 2.0 -> 1.5 change stands
on its own derivation. Sixth premise this refactor that reversed on checking, and
I asserted this one in the same entry where I diagnosed the habit.
CORRECTION 2 / NEGATIVE RESULT. The obvious next move -- evaluate the warp at the
box centre, SHIFT the box, dilate only by the variation across it -- does not pay
here, worked out before writing it. A rigorous per-axis Lipschitz bound for this
Perlin3D is |dV/dfx| <= 4*1.875 + 1 = 8.5 per unit cell (fade derivative times
the spread of GradDot, plus GradDot's own linear term, u and v being distinct
axes). The half-box is 0.206 in noise units, so the local variation bound is 1.75
against a GLOBAL range bound of 1.5. The local bound is worse than the global
one. Recorded so nobody spends a build rediscovering it.
That leaves one route for the warp term: tightening the sup of |Perlin3D| from
the proved 1.5 toward its apparent true value ~1.0-1.1, worth ~27% of the
dilation. Spot-checking a grid is not a proof, and a sup proved wrong is a tile
with no geometry and no collision -- so that is a judgement call about appetite,
not a technical unknown.
State: T1.d delivered and verified. 11 of 40 tiles (27.5%) proved AllSolid at
production defaults, 14641 voxels brute-forced, 0 violations. The dense fixture
correctly proves nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Jahni asked whether I was in a loop. I was, and re-reading the original found it
in one line.
PerlinAbsBound was set to 2.0 in the first commit of the spatial EffectOverBox
and never revisited. The dilation is CaveWarpStrength * VOXEL_NOISE_SCALE *
PerlinAbsBound, and CaveWarpStrength is 8.0 by default:
8 * 1.25 * 2.0 = 20 voxels, applied +/- on every axis
tile 10 voxels -> query box 50 per side -> 125x the tile volume
So the tunnel test I called "an order of magnitude tighter" actually needed
DistToAxis >= 78 against a cull rejecting at ~107. 27% tighter, not 10x -- and
the run said so: tunnels reaching went 78.0 -> 58.5, a 25% cut. The instrument
was right and I credited the tunnels.
Four rounds -- worm, columns, sampler, tunnel disjunction -- each individually
correct, every one of them tightening around a term nobody had measured. I kept
instrumenting what I had just changed and never instrumented what I had assumed.
What re-reading showed: BuildChunkCache is called everywhere with
Expansion = CaveWarpStrength + 2.0f, which is only correct if |Perlin3D| <= 0.8.
The whole plugin has always bet on 0.8. I picked 2.0 -- 2.5x more conservative
than the assumption the cache's own correctness already rests on.
The corrected bound is derived, not guessed. GradDot returns +-u +-v with u and
v two DISTINCT components (checked on all four hash branches). Splitting the
eight corners by i gives, per axis, sum w*|dx| = (1-su)*fx + su*(1-fx) <= 0.5
(max at fx = 0.5). Hence |Perlin3D| <= S_x + S_y + S_z <= 1.5, with no case
analysis on the hashes. Dilation 20 -> 15.
And the instrument that should have existed from the start: EffectOverBox now
also runs every room/tunnel test with the warp dilation set to ZERO and reports
both, so Hit* - Hit*NoWarp is exactly the blocking caused by my box rather than
by geometry. The report says, in the output, that if that gap dominates the next
move is the warp bound and not the primitives.
Separately: the diagnostics had turned into narrative, printing hardcoded numbers
from previous runs next to live ones ("32 of 34 tiles vs 21 for rooms" while the
live figures said 21 of 28). Unreadable, and I wrote all of it. Diagnostics now
report THIS run; the history stays in OPSTACK-PROGRESS.
Unbuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
THE PRIZE LANDED. At production defaults: 6 of 40 tiles proved AllSolid, 7986
voxels brute-forced, 0 violations. 15% of tiles skip GenerateMesh entirely, one
BuildChunkCache traded against 30000+ density evaluations each, and not one
verdict is asserted -- every voxel was re-evaluated and came back on the claimed
side. The dense fixture still reports 0, exactly as the arithmetic said it must,
so this is a measurement of production rather than of a widened test.
The breakdown finally isolated the tunnels: of 34 unproved tiles, 32 were
blocked by a tunnel and 21 by a room, so >=13 were tunnel-only. That is what
justified the deferred disjunction, and why it was deferred rather than guessed.
A primitive no longer matters if it misses its cull OR its own SDF stays >= T+K
over the box. Each branch wins on a different class, by calculation: for rooms
the cull (Rmax+3K) is tighter than the threshold (Rmax+T+K), so rooms keep the
cull alone; for tunnels the cull is a capsule BOUNDING SPHERE, radius ~107 for a
200-long tube of radius 7, against a true segment distance. Order of magnitude.
The bound is exact, not cautious. TaperedCapsule was read, not assumed --
Dist(P, ClosestOnSegment) - Lerp(Ra,Rb,t) -- so SDF >= dist(P,segment) -
max(Ra,Rb); and dist(box,segment) >= dist(centre,segment) - half-diagonal by
triangle inequality. VF_DistPointSegment is written locally rather than taken
from FMath: five lines, and "I think that function does that" is not good enough
under a correctness bound.
Identity CHANGED MEANING, from "Sdf stays FLT_MAX" to "Sdf >= T" with
T = max(3*SDFBlendRadius, WormNetworkRange). Sound only because all three
consumers of the SDF channel were read one by one: FSdfConvertOp's Blend is
MakeSdfCarve(P.SDFBlendRadius, ...) => K; the twelve modifiers gate at 3K;
FWormFieldSource at WormNetworkRange. Plus the one that could have bitten --
FCaveTerraceMod re-probes the SDF at Z+-1, OUTSIDE the box, but its gate is line
13 and the probes are lines 28-29, so a gate false everywhere never emits one.
ANY NEW CONSUMER OF THE Sdf CHANNEL MUST HAVE A THRESHOLD <= T OR JOIN THAT MAX,
or it gets tiles with no geometry and no collision. Written at the site.
Why -K suffices for any N: SmoothMin's penalty is exactly zero once |A-B| >= K,
so the running minimum saturates at K below the smallest term and cannot descend
further. Sdf >= min_i(SDF_i) - K for ANY number of primitives, not - N*K/6 --
without which the slack would scale with the ~88 tunnels and be worthless.
Unbuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The widened sampler worked: 39 of 40 tiles now land clear of the (0,0) spine.
The verdict did not move -- rooms still hit 40 of 40. By my own pre-registered
rule that means the room-lattice model was wrong, and it was, for a reason the
report had been printing at me for three runs: "= 7.6 x RoomSpacing 42".
RoomSpacing is 42 here, not the 80 I did the arithmetic with. I read the
UPROPERTY default out of the header instead of the fixture that overrides it.
Fifth premise this refactor that reversed on being checked, and the plainest:
the number was on screen.
EnableTunnelFeatures densifies on purpose -- RoomSpacing 80 -> 42, RoomDensity
0.35 -> 0.85 -- because at the defaults only 1.1% of samples were in open cave
and the equivalence compared solid rock to solid rock. That densification is what
makes check 1 mean anything, and it is exactly antagonistic to provability:
room cull radius = max(R*1.5, R*HeightRatio) + 3*SDFBlendRadius
= 1.5R + 12 for R in [10,30] => 27..57, mean ~42
lattice spacing = 42 at 85% occupancy
The mean cull radius equals the lattice spacing, so cull spheres cover that world
~3.6x over and NO box can be outside all of them. "6.3 of 8.3 rooms reach" is not
a loose test, it is a saturated world. No sampler change and no tunnel tightening
can move it -- which is why widening the sampler correctly changed nothing.
So 0 proved on this fixture is the RIGHT answer, and informative: the prize
shrinks to nothing as caves saturate. It is simply not an answer about
production.
Check 4 is now a lambda run twice -- parameterised rather than copy-pasted,
because two copies of the criterion would drift and the second would lie:
[dense fixture] as before, expected to stay ~0 and now documented as correct;
[production defaults] the same 40 tiles against RoomSpacing 80 / RoomDensity
0.35, the real UVoxelStrateDefinition defaults. Both brute-forced voxel by voxel.
Not "widen it until it passes": the dense run stays in the report, must stay ~0,
and a false verdict in either still fails. The two stacks deliberately share
FRoomGraphSource's thread_local caches, so this run and check 3 now watch each
other through the params fingerprint in the key.
Unbuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>