feat(strate): passages aim at open space -- for half the archetypes, measurably

Tier 1. GeneratePassages placed every passage at a random angle, distance and
reach, never consulting the destination strate. The carved tube is air by
construction, so nothing looked wrong -- but whether the lower mouth joined the
destination's connected cave space was luck. A player could descend and arrive
in a sealed pocket. The primordial law was a hope.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-08-29 17:45:05 +02:00
1 parent 10a8bbc677
commit 1ab8c0cae4
7 files changed
+806 -4

No files matched your search

@@ -266,6 +266,19 @@ void UVoxelStrateManager::GeneratePassages()
constexpr uint32 PassageSaltPhase = 0xA1100009u;
constexpr uint32 PassageSaltBendFreq = 0xA110000Au;
int32 TotalPassages = 0;
int32 NumAimedAtOpenPoint = 0;
TSet<FString> NoQueryArchetypes;
const auto ArchetypeName = [](ECaveGeneratorType Archetype)
{
if (const UEnum* ArchetypeEnum = StaticEnum<ECaveGeneratorType>())
{
return ArchetypeEnum->GetNameStringByValue(static_cast<int64>(Archetype));
}
return FString::Printf(TEXT("Value_%d"), static_cast<int32>(Archetype));
};
//=========================================================================
// INTER-STRATE PASSAGES: tunnels connecting consecutive strates.
// Each passage is randomly assigned one of 5 types, which determines
@@ -317,13 +330,92 @@ void UVoxelStrateManager::GeneratePassages()
const float PX = FMath::Cos(Angle) * Distance;
const float PY = FMath::Sin(Angle) * Distance;
++TotalPassages;
// Ask the destination archetype for a source-level open Z at this XY. This is pure
// and safe during Initialize: it does not construct an operator stack or call back
// into the manager. Cave room graphs use the same strate seed as BuildChunkCache;
// slab fields use the world seed consumed by GetSlabDensity.
float SuggestedLowerZ = 0.0f;
bool bAimedAtOpenPoint = false;
const UVoxelStrateDefinition* LowerDef = Lower.Definition;
if (LowerDef)
{
const bool bUsesRoomSeed =
LowerDef->GeneratorType == ECaveGeneratorType::TunnelNetwork
|| LowerDef->GeneratorType == ECaveGeneratorType::Underwater;
const int32 LowerQuerySeed = bUsesRoomSeed
? static_cast<int32>(VoxelCaveMorphology::MakeStrateSeed(
static_cast<uint32>(CachedSeed), Lower.StrateIndex))
: CachedSeed;
bAimedAtOpenPoint = VF_SuggestOpenPointZ(
LowerDef->GeneratorType,
LowerDef->GenerationParams,
LowerDef->SlabParams,
LowerQuerySeed,
LowerTopZ,
(float)(Lower.BottomChunkZ) * CHUNK_SIZE,
PX,
PY,
SuggestedLowerZ);
if (bAimedAtOpenPoint)
{
++NumAimedAtOpenPoint;
}
else
{
NoQueryArchetypes.Add(ArchetypeName(LowerDef->GeneratorType));
}
}
// LENGTH: reach into each strate, capped to the interior.
const float UpperReach = FMath::Min(
PassageRandomRange(Cfg.ReachMin, Cfg.ReachMax, PassageSaltUpperReach), UpperMax);
const float LowerReach = FMath::Min(
PassageRandomRange(Cfg.ReachMin, Cfg.ReachMax, PassageSaltLowerReach), LowerMax);
const float TopZ = UpperBottomZ + UpperReach;
const float BottomZ = LowerTopZ - LowerReach;
float BottomZ = LowerTopZ - LowerReach;
if (bAimedAtOpenPoint)
{
float LowerSealThickness = 0.0f;
switch (LowerDef->GeneratorType)
{
case ECaveGeneratorType::TunnelNetwork:
case ECaveGeneratorType::Underwater:
LowerSealThickness = LowerDef->GenerationParams.BoundarySealThickness;
break;
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
LowerSealThickness = LowerDef->SlabParams.BoundarySealThickness;
break;
default:
break;
}
const float LowerBottomZ = (float)(Lower.BottomChunkZ) * CHUNK_SIZE;
const float InnerBottomZ = LowerBottomZ + LowerSealThickness;
const float InnerTopZ = LowerTopZ - LowerSealThickness;
if (FMath::IsFinite(SuggestedLowerZ)
&& FMath::IsFinite(InnerBottomZ)
&& FMath::IsFinite(InnerTopZ)
&& InnerBottomZ < InnerTopZ)
{
// VF_SuggestOpenPointZ already guarantees a strict interior answer. Keep a
// tiny margin in the final clamp so a future source query cannot land on a
// seal boundary through rounding.
const float StrictMargin = FMath::Min(
KINDA_SMALL_NUMBER, (InnerTopZ - InnerBottomZ) * 0.25f);
BottomZ = FMath::Clamp(
SuggestedLowerZ,
InnerBottomZ + StrictMargin,
InnerTopZ - StrictMargin);
}
}
const int32 Segments = FMath::Clamp(Cfg.Segments, 1, 48);
Passage.ControlPoints.Reset();
@@ -422,6 +514,25 @@ void UVoxelStrateManager::GeneratePassages()
}
}
TArray<FString> SortedNoQueryArchetypes;
for (const FString& Archetype : NoQueryArchetypes)
{
SortedNoQueryArchetypes.Add(Archetype);
}
SortedNoQueryArchetypes.Sort();
FString NoQueryList = TEXT("none");
if (SortedNoQueryArchetypes.Num() > 0)
{
NoQueryList = FString::Join(SortedNoQueryArchetypes, TEXT(", "));
}
UE_LOG(LogTemp, Log,
TEXT("[StrateManager] Passage landing sites: %d/%d aimed at generator-confirmed open space, %d fell back to random reach (archetypes with no query: %s)."),
NumAimedAtOpenPoint,
TotalPassages,
TotalPassages - NumAimedAtOpenPoint,
*NoQueryList);
//=========================================================================
// SURFACE ENTRY SHAFT — the one auto-opened (0,0) connection.
// A straight vertical shaft at (0,0) piercing the TOP seal of the topmost