feat(composer): F1 -- the candidates are VISIBLE, and detail is invisible

PART B: the preview. Jahni, asked what "good" means for ranking a season:
"good is hard to describe, i believe it'll be easier once i'm able to see
the strates candidates." So the ranking policy was blocked on this, not on
more metrics. COMPOSER-NOTES §3.4 promised the preview comes nearly free
once the measurement exists; collected.

Per candidate, rendered from the grid the measurement ALREADY built (no
second sampling of the world): a vertical XZ section and a plan XY slice,
solid dark, air light, walkable cells highlighted, with a chunk scale bar
so a corridor is distinguishable from a cathedral. Plus a self-contained
index.html contact sheet of all 64 -- survivors first, outliers first
within them, recipes, metrics, and every rejected candidate labelled with
WHY (vacuous 3, fragmented 2, law failed 19).

  Saved/ComposerPreview/<runid>/index.html   64 candidates, 128 PNGs

Two honesty rules are on the page itself, not buried in a comment: every
number states the window that produced it (margin/step/Z span), and a
2D slice CANNOT show connectivity -- two regions that look joined in one
slice may not be.

Plan-Z is chosen by highest solid/air boundary-transition count, not
hardcoded mid-height; a slab strate would otherwise render as a blank
sheet. Codex flagged the ambiguity in my spec and picked correctly.

PART A: bootstrap jitter for zero-spread fields. ±25% of a field's own
magnitude when corpus spread is ~0, ±15% of measured spread otherwise,
clamps last. Marked in code as a BOOTSTRAP for a starved corpus that
promotion (Tier 5) should retire. 1,422 applications over 168 fields in
the parameter roll; 6,848 over 107 in the structure roll.

⚠️ AND IT DID NOT BROADEN THE DISTRIBUTIONS. Feature scale 0-376 either
way; walkable actually NARROWED (.389 -> .287). Codex's conclusion, and I
agree: the measurement is INSENSITIVE to these fields, not merely
corpus-starved.

The reason is now obvious and worth writing down: the grid samples every
4 voxels, and cave DETAIL -- terraces, scallops, ribbing -- lives at 1-3
voxels. It is below the sampling resolution. A step-4 grid structurally
cannot see feel; it sees size. So "vary the detail" and "score the detail"
need different instruments, and the preview images inherit the same limit.

Box proofs stayed sound under wider jitter: parameter roll 1,204 proved /
1,602,524 voxels / 0 violations; structure roll 659 proved / 877,129 /
0 violations. §6.2 did not reopen.

fable-idea.md F1 ticked (measurement + preview).

Build clean, 25 succeeded / 0 failed, verified by me.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-04 17:35:07 +02:00
1 parent f088c19f88
commit 993512f0bc
11 files changed
+1294 -38

No files matched your search

@@ -22,6 +22,9 @@
namespace
{
constexpr float GJitterFraction = 0.15f;
constexpr float GBootstrapJitterFraction = 0.25f;
constexpr double GNearZeroSpread = 1.0e-6;
constexpr double GBootstrapMagnitudeFloor = 1.0;
static const ECaveGeneratorType GAllArchetypes[] =
{
@@ -861,10 +864,41 @@ namespace
}
}
void VF_JitterMeasuredField(double& Value,
const FVoxelStrateFieldSpread& Spread,
ECaveGeneratorType Archetype,
FRandomStream& Rng,
int32& OutBootstrapFieldCount,
TArray<FString>& OutBootstrapFieldNames)
{
const double CorpusRange = FMath::Max(0.0, Spread.Max - Spread.Min);
const bool bUseBootstrap = CorpusRange <= GNearZeroSpread;
// Spread-derived jitter is the real rule. This bootstrap is only for a starved corpus:
// when season zero has supplied no useful measured range, use a fraction of this field's
// own magnitude, with a one-native-unit floor near zero. Promotion (Tier 5) should retire
// this fallback as the corpus grows.
const double JitterScale = bUseBootstrap
? FMath::Max(FMath::Abs(Value), GBootstrapMagnitudeFloor)
: CorpusRange;
const float JitterFraction = bUseBootstrap
? GBootstrapJitterFraction : GJitterFraction;
Value += static_cast<double>((Rng.FRand() * 2.0f - 1.0f) * JitterFraction)
* JitterScale;
if (bUseBootstrap)
{
++OutBootstrapFieldCount;
OutBootstrapFieldNames.AddUnique(VF_SpreadKey(Archetype, Spread.FieldName));
}
}
void VF_JitterNativeParams(FVoxelStrateArchetypeParams& Params,
ECaveGeneratorType Archetype,
const FVoxelStrateCorpus& Corpus,
FRandomStream& Rng)
FRandomStream& Rng,
int32& OutBootstrapFieldCount,
TArray<FString>& OutBootstrapFieldNames)
{
void* Memory = VF_GetParamMemory(Params, Archetype);
UStruct* Struct = VF_GetParamStruct(Archetype);
@@ -889,17 +923,11 @@ namespace
continue;
}
const double CorpusRange = FMath::Max(0.0, Spread.Max - Spread.Min);
if (!(CorpusRange > 0.0))
{
continue;
}
double Value = 0.0;
if (VF_ReadNamedField(Memory, Archetype, Spread.FieldName, Value))
{
Value += static_cast<double>((Rng.FRand() * 2.0f - 1.0f) * GJitterFraction)
* CorpusRange;
VF_JitterMeasuredField(Value, Spread, Archetype, Rng,
OutBootstrapFieldCount, OutBootstrapFieldNames);
VF_WriteNamedField(Memory, Archetype, Spread.FieldName, Value);
}
}
@@ -920,17 +948,11 @@ namespace
continue;
}
const double CorpusRange = FMath::Max(0.0, Spread->Max - Spread->Min);
if (!(CorpusRange > 0.0))
{
continue;
}
double Value = 0.0;
if (VF_ReadPropertyValue(Pair.Value, Memory, Value))
{
Value += static_cast<double>((Rng.FRand() * 2.0f - 1.0f) * GJitterFraction)
* CorpusRange;
VF_JitterMeasuredField(Value, *Spread, Archetype, Rng,
OutBootstrapFieldCount, OutBootstrapFieldNames);
VF_WritePropertyValue(Pair.Value, Memory, Value);
}
}
@@ -1247,7 +1269,9 @@ namespace
VF_CopyDominantBooleans(Result.ArchetypeParams,
Entries[DominantParent].ArchetypeParams,
Result.Archetype);
VF_JitterNativeParams(Result.ArchetypeParams, Result.Archetype, Corpus, Rng);
VF_JitterNativeParams(Result.ArchetypeParams, Result.Archetype, Corpus, Rng,
Result.BootstrapJitterFieldCount,
Result.BootstrapJitterFieldNames);
VF_ClampNativeParams(Result.ArchetypeParams, Result.Archetype, Corpus);
VF_RepairNativeOrderedPairs(Result.ArchetypeParams, Result.Archetype);
VF_ResetExcludedFields(Result.ArchetypeParams, Result.Archetype);
@@ -2089,6 +2113,47 @@ bool VF_AreStrateArchetypeParamsBitIdentical(
&& FMemory::Memcmp(&ABottom, &BBottom, sizeof(ABottom)) == 0;
}
double VF_DistanceFromStrateCorpusCentroid(
const FVoxelStrateCorpus& Corpus,
const FVoxelStrateArchetypeParams& Params,
ECaveGeneratorType Archetype)
{
const void* Memory = VF_GetParamMemory(Params, Archetype);
if (Memory == nullptr)
{
return 0.0;
}
double DistanceSquared = 0.0;
for (const FVoxelStrateFieldSpread& Spread : Corpus.GetFieldSpreads())
{
if (Spread.Archetype != Archetype || Spread.bExcluded
|| Spread.Kind == EVoxelStrateFieldKind::Boolean
|| Spread.Kind == EVoxelStrateFieldKind::Enum)
{
continue;
}
double Value = 0.0;
if (!VF_ReadNamedField(Memory, Archetype, Spread.FieldName, Value))
{
continue;
}
const double CorpusRange = FMath::Max(0.0, Spread.Max - Spread.Min);
const double Scale = CorpusRange > GNearZeroSpread
? CorpusRange
: FMath::Max(FMath::Abs(Spread.Mean) * GBootstrapJitterFraction,
GBootstrapMagnitudeFloor);
if (FMath::IsFinite(Value) && FMath::IsFinite(Spread.Mean) && Scale > 0.0)
{
const double Normalized = (Value - Spread.Mean) / Scale;
DistanceSquared += Normalized * Normalized;
}
}
return FMath::Sqrt(FMath::Max(0.0, DistanceSquared));
}
FVoxelStrateRollInfo VF_RollStrateParamsDetailed(const FVoxelStrateCorpus& Corpus,
int32 Seed, int32 Index)
{