From eb47efdec9e0edceab45510ff63ce621192668ca Mon Sep 17 00:00:00 2001
From: Fr0zka
Date: Sat, 5 Sep 2026 12:33:58 +0200
Subject: [PATCH] feat(measure): a player does not fit. The law was passing
impossible routes.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Jahni walked composed strates for the first time: "'maze' is way too tight
tunnel radius wise, i can't enter nor anything". The candidates he could not
enter were the ones our metrics ranked BEST -- Maze had the highest walkable
fraction (.285) and highest connected surface (.99) of all eight.
⭐ THE HEADLINE, AND IT IS BAD NEWS WORTH HAVING:
old arrival->departure law passing on 7 of 8 candidates
player-fit-restricted law CONNECTED ON 0 OF 8
**The primordial law has been passing on routes no player fits through.**
Every "the world is traversable" claim in this project measured air, not
passage. Player-fit fraction across the eight showcase candidates ranges
.0000 (FloatingIslands -- literally zero) to .0117. The season composer now
returns 0 survivors for 6 required slots, and the showcase gate rejects
8 of 8.
That is the correct outcome. The gate is not tuned to a threshold; it
requires a resolved player-fit volume and a connected player-fit route.
Capsule confirmed by Jahni as the base ACharacter: 34 cm radius, 88 cm
half-height = 1.36 voxels radius, 7.04 tall. Named constant with the
derivation beside it.
Two traps avoided, both of which would have produced confident nonsense:
- THE COARSE GRID CANNOT RESOLVE A PLAYER. SampleStep 4 makes a cell 4
voxels, ~3x the player's radius, so fit is below the resolution of every
metric built so far -- which is exactly why nothing flagged an impassable
maze. Fit runs on step 1 and REFUSES (bPlayerFitResolved=false + reason)
when the step is too coarse.
- Manhattan distance >= Euclidean, so reusing the existing transform for
clearance would OVERESTIMATE free space and report fits that do not
exist. Clearance uses a separate Chebyshev transform; Manhattan stays
only on the legacy feature-scale metric.
⚠️ MY ROUGHNESS HYPOTHESIS WAS WRONG, and Codex disproved it rather than
confirming it. Forcing SurfaceRoughness=0 on the Maze moved PlayerFit
.0105 -> .0138 and the restricted law stayed NotConnected. Minimum corridor
clearance is 1.00 voxel (25 cm) with or without roughness -- the corridors
are simply too tight for a 34 cm capsule, full stop.
Roughness IS still a real pinch risk where reach exceeds corridor radius:
Underwater 2.21x, FlatPlain 2.31x, CrystalChamber 1.81x, TunnelNetwork
1.44x on corridors, VerticalShafts 1.30x on connectors. The 5.625-voxel
default bound stays dangerous.
Islands measured, not changed (feel is Jahni's call): 0/2 touch either
seal, median clear gap 47.8 above / 9.0 below -- but effective radius
spans 27-258 voxels at H=128 with ThicknessRatio .546, so the worst case
has zero guaranteed separation. Recommendation recorded: radius back to
18-42, StrateHeightInChunks 4 -> 6, ThicknessRatio <= .40.
Build clean, 30 succeeded / 0 failed, verified in the namespace log.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
---
.../VoxelForgeComposerCorpusFreeTest.cpp | 10 +-
.../VoxelForgeComposerParameterRollTest.cpp | 2 +
.../Tests/VoxelForgeComposerPromotionTest.cpp | 2 +
.../Tests/VoxelForgeComposerSeasonTest.cpp | 21 +-
.../Tests/VoxelForgeComposerShowcaseTest.cpp | 695 +++++++++++++++++-
.../VoxelForgeComposerStructureRollTest.cpp | 2 +
.../Tests/VoxelForgeLateralRegionsTest.cpp | 4 +
.../VoxelForgeStrateConnectivityTest.cpp | 15 +-
.../Private/VoxelSeasonManifest.cpp | 102 ++-
.../Private/VoxelStrateComposer.cpp | 52 ++
.../VoxelForge/Private/VoxelStrateMeasure.cpp | 631 +++++++++++++++-
.../VoxelForge/Private/VoxelStratePreview.cpp | 52 +-
.../VoxelForge/Public/VoxelSeasonManifest.h | 10 +
.../VoxelForge/Public/VoxelStrateComposer.h | 9 +
Source/VoxelForge/Public/VoxelStrateMeasure.h | 74 +-
Source/VoxelForge/Public/VoxelStratePreview.h | 10 +
16 files changed, 1600 insertions(+), 91 deletions(-)
diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerCorpusFreeTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerCorpusFreeTest.cpp
index ee36ee7..3df93a9 100644
--- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerCorpusFreeTest.cpp
+++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerCorpusFreeTest.cpp
@@ -34,6 +34,8 @@ namespace
case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid");
case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow");
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudget");
+ case EVoxelConnectivityResult::StartCellNotPlayerFit: return TEXT("StartCellNotPlayerFit");
+ case EVoxelConnectivityResult::GoalCellNotPlayerFit: return TEXT("GoalCellNotPlayerFit");
}
return TEXT("Unknown");
}
@@ -272,7 +274,7 @@ namespace
int32 InvalidStackCount = 0;
int32 ChannelOrderFailures = 0;
int32 ContextViolations = 0;
- int32 LawResults[6] = { 0, 0, 0, 0, 0, 0 };
+ int32 LawResults[8] = { 0, 0, 0, 0, 0, 0, 0, 0 };
FQualityStats AllQuality;
FQualityStats SurvivorQuality;
FBoxVerdictReport Box;
@@ -303,6 +305,8 @@ namespace
case EVoxelConnectivityResult::GoalCellSolid: return 3;
case EVoxelConnectivityResult::OutOfWindow: return 4;
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return 5;
+ case EVoxelConnectivityResult::StartCellNotPlayerFit: return 6;
+ case EVoxelConnectivityResult::GoalCellNotPlayerFit: return 7;
}
return 5;
}
@@ -723,7 +727,8 @@ bool FVoxelForgeComposerCorpusFreeTest::RunTest(const FString& Parameters)
"law skipped=%d, "
"roll failures=%d, build failures=%d, unmeasured=%d, deterministic roll failures=%d; "
"stack-count failures=%d, channel-order failures=%d, context violations=%d; "
- "law results Connected=%d NotConnected=%d StartSolid=%d GoalSolid=%d OutOfWindow=%d Budget=%d; "
+ "law results Connected=%d NotConnected=%d StartSolid=%d GoalSolid=%d OutOfWindow=%d Budget=%d "
+ "StartNotPlayerFit=%d GoalNotPlayerFit=%d; "
"quality all %s; survivor quality %s; first failure=%s."),
Arm->Name, Arm->NonVacuous, LargestComponentSurvivalThreshold, Arm->LargestEnough,
Arm->LawPasses, Arm->LawFailedObserved, Arm->LawSkipped,
@@ -732,6 +737,7 @@ bool FVoxelForgeComposerCorpusFreeTest::RunTest(const FString& Parameters)
Arm->InvalidStackCount, Arm->ChannelOrderFailures, Arm->ContextViolations,
Arm->LawResults[0], Arm->LawResults[1], Arm->LawResults[2],
Arm->LawResults[3], Arm->LawResults[4], Arm->LawResults[5],
+ Arm->LawResults[6], Arm->LawResults[7],
*Arm->AllQuality.SummaryText(), *Arm->SurvivorQuality.SummaryText(),
Arm->FirstFailure.IsEmpty() ? TEXT("none") : *Arm->FirstFailure));
AddInfo(FString::Printf(
diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp
index ffc1c17..c830e13 100644
--- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp
+++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp
@@ -218,6 +218,8 @@ namespace
case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid");
case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow");
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudget");
+ case EVoxelConnectivityResult::StartCellNotPlayerFit: return TEXT("StartCellNotPlayerFit");
+ case EVoxelConnectivityResult::GoalCellNotPlayerFit: return TEXT("GoalCellNotPlayerFit");
}
return TEXT("Unknown");
}
diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerPromotionTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerPromotionTest.cpp
index 0144da4..f0cc45d 100644
--- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerPromotionTest.cpp
+++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerPromotionTest.cpp
@@ -32,6 +32,8 @@ namespace
case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid");
case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow");
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudget");
+ case EVoxelConnectivityResult::StartCellNotPlayerFit: return TEXT("StartCellNotPlayerFit");
+ case EVoxelConnectivityResult::GoalCellNotPlayerFit: return TEXT("GoalCellNotPlayerFit");
}
return TEXT("Unknown");
}
diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp
index 2384724..42a4236 100644
--- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp
+++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp
@@ -61,12 +61,29 @@ bool FVoxelForgeComposerSeasonTest::RunTest(const FString& Parameters)
const int32 SeasonSeed = 0x2468;
const FVoxelSeasonManifest Manifest = VF_ComposeSeason(SeasonSeed, Settings);
- TestTrue(TEXT("composition produces a usable season manifest"), Manifest.IsUsable());
- if (!Manifest.IsUsable())
+ const bool bManifestUsable = Manifest.IsUsable();
+ if (!bManifestUsable)
{
+ // The confirmed player-fit gate is intentionally allowed to expose an empty survivor
+ // pool. This corpus/fixture was authored before capsule occupancy was measured, so an
+ // honest blocked composition is a passing diagnostic outcome, not a reason to relax the
+ // gate or silently fall back to the old air-only law. Keep the full manifest assertions
+ // below for any future corpus that supplies enough physically traversable candidates.
+ const bool bBlockedByPlayerFitGate = Manifest.Error.Contains(
+ TEXT("candidates survived the hard gates"));
+ TestTrue(TEXT("empty composition is explicitly reported as a hard-gate survivor shortage"),
+ bBlockedByPlayerFitGate);
+ if (bBlockedByPlayerFitGate)
+ {
+ AddInfo(FString::Printf(
+ TEXT("Season composition remains blocked by the fine step-1 player-fit gate: %s"),
+ *Manifest.Error));
+ return true;
+ }
AddError(Manifest.Error);
return false;
}
+ TestTrue(TEXT("composition produces a usable season manifest"), bManifestUsable);
TestEqual(TEXT("candidate count is the configured number of attempts"),
Manifest.CandidateCount, Settings.CandidateCount);
TestEqual(TEXT("selected count is the configured spine length"),
diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp
index cb7eae9..aa1c49c 100644
--- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp
+++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp
@@ -7,11 +7,13 @@
#include "Misc/Paths.h"
#include "VoxelForgeTestFixture.h"
+#include "VoxelCaveMorphology.h"
#include "VoxelDensityOpStack.h"
#include "VoxelSettings.h"
#include "VoxelStrateComposer.h"
#include "VoxelStrateMeasure.h"
#include "VoxelStratePreview.h"
+#include "VoxelTypes.h"
namespace
{
@@ -58,6 +60,8 @@ namespace
case EVoxelConnectivityResult::NotConnectedAtThisResolution: return TEXT("NotConnectedAtThisResolution");
case EVoxelConnectivityResult::StartCellSolid: return TEXT("StartCellSolid");
case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid");
+ case EVoxelConnectivityResult::StartCellNotPlayerFit: return TEXT("StartCellNotPlayerFit");
+ case EVoxelConnectivityResult::GoalCellNotPlayerFit: return TEXT("GoalCellNotPlayerFit");
case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow");
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudgetExhausted");
}
@@ -106,6 +110,9 @@ namespace
{
FVoxelStrateComposerCandidate Candidate;
FVoxelStrateMetrics Metrics;
+ FVoxelStrateMetrics FineMetrics;
+ FVoxelConnectivityDiagnostics LegacyLaw;
+ FVoxelConnectivityDiagnostics PlayerFitLaw;
double SelectionScore = -1.0;
FString LawText;
int32 TargetStrateIndex = INDEX_NONE;
@@ -211,14 +218,43 @@ namespace
return ArrivalCount == 1 && DepartureCount == 1;
}
+ FVoxelStrateMeasureSettings VF_MakeFinePlayerFitSettings(
+ const FVoxelStrateMeasureSettings& BaseSettings,
+ const FVoxelStrateFinePreviewSettings& FinePreviewSettings,
+ const FVector& ArrivalPoint,
+ const FVector& DeparturePoint)
+ {
+ FVoxelStrateMeasureSettings Settings =
+ FinePreviewSettings.MakeMeasureSettings(BaseSettings);
+ // The old fine preview was centred on the coarse largest-component representative. That
+ // is useful for a picture, but it can put both mouths outside the ROI and turn a real
+ // player-fit route into a meaningless OutOfWindow result. Fit the fine ROI to the two
+ // existing mouth XY points instead; the small explicit margin leaves room for the
+ // capsule's horizontal stencil at either endpoint without changing placement.
+ Settings.CenterXY = FVector2D(
+ 0.5f * (ArrivalPoint.X + DeparturePoint.X),
+ 0.5f * (ArrivalPoint.Y + DeparturePoint.Y));
+ Settings.CoverPointA = FVector2D(ArrivalPoint.X, ArrivalPoint.Y);
+ Settings.CoverPointB = FVector2D(DeparturePoint.X, DeparturePoint.Y);
+ Settings.CoverMarginVoxels = FMath::CeilToFloat(
+ Settings.PlayerCapsuleRadiusVoxels) + 2.0f;
+ return Settings;
+ }
+
bool VF_EvaluateShowcaseCandidate(
const VoxelForgeTest::FTestWorld& World,
const FVoxelStrateComposerCandidate& Candidate,
int32 InTargetStrateIndex,
const FVoxelStrateMeasureSettings& MeasureSettings,
FShowcaseSample& OutSample,
- FString* OutFailureReason = nullptr)
+ FString* OutFailureReason = nullptr,
+ bool bMeasurePlayerFitRegardlessOfLegacy = false)
{
+ // Keep the evidence that was measured before a hard-gate rejection. The previous
+ // showcase must be auditable even when the new player-fit gate rejects every one of its
+ // eight records; returning only a bool would hide exactly the failure this test exists to
+ // expose.
+ OutSample = FShowcaseSample();
auto Fail = [OutFailureReason](const FString& Reason) -> bool
{
if (OutFailureReason != nullptr)
@@ -273,32 +309,84 @@ namespace
const bool bLawPass = Law.bValid
&& Law.Result == EVoxelConnectivityResult::Connected
&& !Law.bStartSnapped && !Law.bGoalSnapped;
- if (!(bNonVacuous && bLargestEnough && bLawPass))
+ OutSample.Candidate = Candidate;
+ OutSample.Metrics = Metrics;
+ OutSample.LegacyLaw = Law;
+ OutSample.SelectionScore = VF_ShowcaseSelectionScore(Metrics);
+ OutSample.LawText = FString::Printf(
+ TEXT("legacy=%s%s"),
+ VF_ShowcaseConnectivityName(Law.Result),
+ (Law.bStartSnapped || Law.bGoalSnapped) ? TEXT(" (snapped)") : TEXT(""));
+ OutSample.TargetStrateIndex = InTargetStrateIndex;
+ OutSample.TopVoxelZ = TopVoxelZ;
+ OutSample.BottomVoxelZ = BottomVoxelZ;
+ if (!(bNonVacuous && bLargestEnough && bLawPass)
+ && !bMeasurePlayerFitRegardlessOfLegacy)
+ {
+ // The player-fit gate is conjunctive with the established legacy gates. Once a
+ // candidate already fails one of those necessary conditions, skip its expensive
+ // step-1 capsule pass; this cannot change the survivor set or any accepted metric.
+ return Fail(FString::Printf(
+ TEXT("nonv=%d largest=%d legacy_law=%s snapped=%d/%d"),
+ bNonVacuous ? 1 : 0,
+ bLargestEnough ? 1 : 0,
+ VF_ShowcaseConnectivityName(Law.Result),
+ Law.bStartSnapped ? 1 : 0,
+ Law.bGoalSnapped ? 1 : 0));
+ }
+
+ FVoxelStrateFinePreviewSettings FinePreviewSettings;
+ FinePreviewSettings.SampleStep = FinePreviewSampleStep;
+ FinePreviewSettings.RadiusInVoxels = FinePreviewRadiusVoxels;
+ FinePreviewSettings.MaxCells = FinePreviewMaxCells;
+ FVoxelStrateMeasureSettings FineMeasureSettings =
+ VF_MakeFinePlayerFitSettings(
+ MeasureSettings, FinePreviewSettings, ArrivalPoint, DeparturePoint);
+ FVoxelStrateMetrics FineMetrics;
+ const FVoxelConnectivityDiagnostics PlayerFitLaw =
+ VF_DiagnosePlayerFitConnectivityWithSampler(
+ Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness,
+ ArrivalPoint, DeparturePoint, FineMeasureSettings, &FineMetrics);
+ OutSample.FineMetrics = FineMetrics;
+ OutSample.PlayerFitLaw = PlayerFitLaw;
+ OutSample.LawText = FString::Printf(
+ TEXT("legacy=%s%s; player_fit=%s%s"),
+ VF_ShowcaseConnectivityName(Law.Result),
+ (Law.bStartSnapped || Law.bGoalSnapped) ? TEXT(" (snapped)") : TEXT(""),
+ VF_ShowcaseConnectivityName(PlayerFitLaw.Result),
+ (PlayerFitLaw.bStartSnapped || PlayerFitLaw.bGoalSnapped)
+ ? TEXT(" (snapped)") : TEXT(""));
+ const bool bPlayerFitExists = FineMetrics.bValid
+ && FineMetrics.bPlayerFitResolved
+ && FineMetrics.NumPlayerFitCells > 0
+ && FineMetrics.NumTraversableComponents > 0
+ && FineMetrics.TraversableComponentShare > 0.0f;
+ const bool bPlayerFitLawPass = bPlayerFitExists
+ && PlayerFitLaw.bValid
+ && PlayerFitLaw.Result == EVoxelConnectivityResult::Connected;
+ if (!(bNonVacuous && bLargestEnough && bLawPass && bPlayerFitLawPass))
{
return Fail(FString::Printf(
- TEXT("nonv=%d largest=%d law=%s snapped=%d/%d air=%.6f largest_share=%.6f "
- "walk_area=%.6f clearance=%d"),
+ TEXT("nonv=%d largest=%d legacy_law=%s snapped=%d/%d player_law=%s "
+ "player_snapped=%d/%d player_fit=%d fit_fraction=%.6f traversable=%.6f "
+ "air=%.6f largest_share=%.6f walk_area=%.6f clearance=%d"),
bNonVacuous ? 1 : 0,
bLargestEnough ? 1 : 0,
VF_ShowcaseConnectivityName(Law.Result),
Law.bStartSnapped ? 1 : 0,
Law.bGoalSnapped ? 1 : 0,
+ VF_ShowcaseConnectivityName(PlayerFitLaw.Result),
+ PlayerFitLaw.bStartSnapped ? 1 : 0,
+ PlayerFitLaw.bGoalSnapped ? 1 : 0,
+ bPlayerFitExists ? 1 : 0,
+ FineMetrics.PlayerFitFraction,
+ FineMetrics.TraversableComponentShare,
Metrics.AirFraction,
Metrics.LargestComponentShare,
Metrics.WalkableFloorAreaFraction,
Metrics.MedianVerticalClearance));
}
- OutSample = FShowcaseSample();
- OutSample.Candidate = Candidate;
- OutSample.Metrics = Metrics;
- OutSample.SelectionScore = VF_ShowcaseSelectionScore(Metrics);
- OutSample.LawText = FString::Printf(
- TEXT("%s%s"), VF_ShowcaseConnectivityName(Law.Result),
- (Law.bStartSnapped || Law.bGoalSnapped) ? TEXT(" (snapped)") : TEXT(""));
- OutSample.TargetStrateIndex = InTargetStrateIndex;
- OutSample.TopVoxelZ = TopVoxelZ;
- OutSample.BottomVoxelZ = BottomVoxelZ;
return true;
}
@@ -347,6 +435,398 @@ namespace
return Values[(Values.Num() - 1) / 2];
}
+ float VF_RoughnessReach(float SurfaceRoughness)
+ {
+ // The source note's bound is sup|FBM| = 1.5, and VOXEL_NOISE_SCALE is 1.25.
+ return FMath::Max(0.0f, SurfaceRoughness) * VOXEL_NOISE_SCALE * 1.5f;
+ }
+
+ float VF_GetSurfaceRoughness(const FVoxelStrateComposerCandidate& Candidate)
+ {
+ switch (Candidate.Archetype)
+ {
+ case ECaveGeneratorType::TunnelNetwork:
+ case ECaveGeneratorType::Underwater:
+ return Candidate.ArchetypeParams.TunnelNetworkParams.SurfaceRoughness;
+ case ECaveGeneratorType::Maze:
+ return Candidate.ArchetypeParams.MazeParams.SurfaceRoughness;
+ case ECaveGeneratorType::VerticalShafts:
+ return Candidate.ArchetypeParams.VerticalShaftParams.SurfaceRoughness;
+ case ECaveGeneratorType::FloatingIslands:
+ return Candidate.ArchetypeParams.FloatingIslandParams.SurfaceRoughness;
+ case ECaveGeneratorType::SurfaceWorld:
+ return Candidate.ArchetypeParams.SurfaceParams.SurfaceRoughness;
+ case ECaveGeneratorType::FlatPlain:
+ case ECaveGeneratorType::CrystalChamber:
+ default:
+ return 0.0f;
+ }
+ }
+
+ FString VF_FormatRoughnessRadiusReport(const TArray& Samples)
+ {
+ FString Report = TEXT(
+ "Part B roughness reach / characteristic radius (reach = roughness * "
+ "VOXEL_NOISE_SCALE * 1.5; source parameter values):\n");
+ for (const FShowcaseSample& Sample : Samples)
+ {
+ const FVoxelStrateArchetypeParams& Params = Sample.Candidate.ArchetypeParams;
+ const float SurfaceRoughness = VF_GetSurfaceRoughness(Sample.Candidate);
+ const float Reach = VF_RoughnessReach(SurfaceRoughness);
+ FString Detail;
+ switch (Sample.Candidate.Archetype)
+ {
+ case ECaveGeneratorType::TunnelNetwork:
+ case ECaveGeneratorType::Underwater:
+ {
+ const FStrateGenerationParams& P = Params.TunnelNetworkParams;
+ const float RoomRadius = 0.5f * (P.MinRoomRadius + P.MaxRoomRadius);
+ const float TunnelRadius = 0.5f * (P.TunnelMinRadius + P.TunnelMaxRadius);
+ Detail = FString::Printf(
+ TEXT("room r=%.3f (radius %.3f), corridor r=%.3f (radius %.3f)"),
+ Reach / FMath::Max(RoomRadius, KINDA_SMALL_NUMBER), RoomRadius,
+ Reach / FMath::Max(TunnelRadius, KINDA_SMALL_NUMBER), TunnelRadius);
+ break;
+ }
+ case ECaveGeneratorType::Maze:
+ {
+ const FMazeGenerationParams& P = Params.MazeParams;
+ Detail = FString::Printf(
+ TEXT("corridor r=%.3f (radius %.3f)"),
+ Reach / FMath::Max(P.CorridorRadius, KINDA_SMALL_NUMBER),
+ P.CorridorRadius);
+ break;
+ }
+ case ECaveGeneratorType::VerticalShafts:
+ {
+ const FVerticalShaftParams& P = Params.VerticalShaftParams;
+ const float ShaftRadius = 0.5f * (P.ShaftMinRadius + P.ShaftMaxRadius);
+ Detail = FString::Printf(
+ TEXT("shaft r=%.3f (radius %.3f), connector r=%.3f (radius %.3f)"),
+ Reach / FMath::Max(ShaftRadius, KINDA_SMALL_NUMBER), ShaftRadius,
+ Reach / FMath::Max(P.ConnectorRadius, KINDA_SMALL_NUMBER),
+ P.ConnectorRadius);
+ break;
+ }
+ case ECaveGeneratorType::FloatingIslands:
+ {
+ const FFloatingIslandParams& P = Params.FloatingIslandParams;
+ const float IslandRadius = 0.5f * (P.IslandMinRadius + P.IslandMaxRadius);
+ Detail = FString::Printf(
+ TEXT("island r=%.3f (radius %.3f)"),
+ Reach / FMath::Max(IslandRadius, KINDA_SMALL_NUMBER), IslandRadius);
+ break;
+ }
+ case ECaveGeneratorType::FlatPlain:
+ case ECaveGeneratorType::CrystalChamber:
+ {
+ const FSlabGenerationParams& P = Params.SlabParams;
+ const float ColumnRadius = 0.5f * (P.ColumnMinRadius + P.ColumnMaxRadius);
+ const float FloorReach = VF_RoughnessReach(P.FloorRoughness);
+ const float CeilingReach = VF_RoughnessReach(P.CeilingRoughness);
+ if (P.ColumnDensity > 0.0f)
+ {
+ Detail = FString::Printf(
+ TEXT("floor/ceiling reach-to-column-radius=%.3f/%.3f "
+ "(column radius %.3f; slab has no corridor/room radius)"),
+ FloorReach / FMath::Max(ColumnRadius, KINDA_SMALL_NUMBER),
+ CeilingReach / FMath::Max(ColumnRadius, KINDA_SMALL_NUMBER), ColumnRadius);
+ }
+ else
+ {
+ Detail = FString::Printf(
+ TEXT("floor reach %.3f, ceiling reach %.3f; radius=N/A "
+ "(slab has no corridor/room feature)"),
+ FloorReach, CeilingReach);
+ }
+ break;
+ }
+ case ECaveGeneratorType::SurfaceWorld:
+ {
+ const FSurfaceGenerationParams& P = Params.SurfaceParams;
+ const float ReliefReach = VF_RoughnessReach(P.SurfaceRoughness);
+ // SurfaceWorld is a heightfield, so it has no radial cave feature. ElevationRange
+ // is reported as the nearest characteristic vertical relief scale and is labelled
+ // explicitly rather than pretending it is a tunnel/room radius.
+ Detail = FString::Printf(
+ TEXT("terrain relief=%.3f (reach %.3f / ElevationRange %.3f; "
+ "no radial feature)"),
+ ReliefReach / FMath::Max(P.ElevationRange, KINDA_SMALL_NUMBER), ReliefReach,
+ P.ElevationRange);
+ break;
+ }
+ default:
+ Detail = TEXT("unsupported");
+ break;
+ }
+ Report += FString::Printf(
+ TEXT(" %s seed=%d index=%d roughness=%.3f reach=%.3f: %s\n"),
+ VF_GetStrateArchetypeName(Sample.Candidate.Archetype),
+ Sample.Candidate.Seed, Sample.Candidate.Index,
+ SurfaceRoughness, Reach,
+ *Detail);
+ }
+ return Report;
+ }
+
+ bool VF_RunMazeRoughnessExperiment(
+ const VoxelForgeTest::FTestWorld& World,
+ const FShowcaseSample& MazeSample,
+ const FVoxelStrateMeasureSettings& BaseSettings,
+ const FVoxelStrateFinePreviewSettings& FinePreviewSettings,
+ FString& OutReport)
+ {
+ if (MazeSample.Candidate.Archetype != ECaveGeneratorType::Maze)
+ {
+ OutReport = TEXT("Part B maze roughness experiment unavailable: previous Maze roll was not found.");
+ return false;
+ }
+ FVector ArrivalPoint = FVector::ZeroVector;
+ FVector DeparturePoint = FVector::ZeroVector;
+ if (!VF_GetShowcaseMouthPair(
+ World, MazeSample.TargetStrateIndex, ArrivalPoint, DeparturePoint))
+ {
+ OutReport = TEXT("Part B maze roughness experiment unavailable: mouth pair was not found.");
+ return false;
+ }
+
+ int32 TopVoxelZ = 0;
+ int32 BottomVoxelZ = 0;
+ if (!World.GetSlotVoxelZRange(MazeSample.TargetStrateIndex, TopVoxelZ, BottomVoxelZ))
+ {
+ OutReport = TEXT("Part B maze roughness experiment unavailable: Z range was not found.");
+ return false;
+ }
+
+ FVoxelOpStack Stack;
+ FVoxelOpContext Context;
+ FVoxelStrateComposerCandidate ZeroCandidate = MazeSample.Candidate;
+ ZeroCandidate.ArchetypeParams.MazeParams.SurfaceRoughness = 0.0f;
+ FString BuildError;
+ if (!VF_BuildShowcaseStack(
+ World, ZeroCandidate, TopVoxelZ, BottomVoxelZ, Stack, Context, BuildError))
+ {
+ OutReport = FString::Printf(
+ TEXT("Part B maze roughness experiment unavailable: %s"), *BuildError);
+ return false;
+ }
+ Stack.PrepareChunk(Context);
+ FShowcaseStackSampler Sampler(Stack);
+ const FVoxelStrateMeasureSettings FineSettings = VF_MakeFinePlayerFitSettings(
+ BaseSettings, FinePreviewSettings, ArrivalPoint, DeparturePoint);
+ FVoxelStrateMetrics ZeroMetrics;
+ const FVoxelConnectivityDiagnostics ZeroLaw =
+ VF_DiagnosePlayerFitConnectivityWithSampler(
+ Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness,
+ ArrivalPoint, DeparturePoint, FineSettings, &ZeroMetrics);
+ const FVoxelStrateArchetypeParams& OriginalParams = MazeSample.Candidate.ArchetypeParams;
+ const float OriginalReach = VF_RoughnessReach(
+ OriginalParams.MazeParams.SurfaceRoughness);
+ OutReport = FString::Printf(
+ TEXT("Part B Maze roughness=%.3f (reach %.3f voxels; corridor radius %.3f) -> 0: "
+ "PlayerFitFraction %.6f -> %.6f, minimum corridor clearance %.2f -> %.2f "
+ "voxels, restricted law %s -> %s; same target=%d and fitted step-1 ROI."),
+ OriginalParams.MazeParams.SurfaceRoughness, OriginalReach,
+ OriginalParams.MazeParams.CorridorRadius,
+ MazeSample.FineMetrics.PlayerFitFraction, ZeroMetrics.PlayerFitFraction,
+ MazeSample.FineMetrics.MinimumPlayerClearanceVoxels,
+ ZeroMetrics.MinimumPlayerClearanceVoxels,
+ VF_ShowcaseConnectivityName(MazeSample.PlayerFitLaw.Result),
+ VF_ShowcaseConnectivityName(ZeroLaw.Result), MazeSample.TargetStrateIndex);
+ return true;
+ }
+
+ struct FIslandVerticalAudit
+ {
+ int32 NumIslands = 0;
+ int32 NumTopTouching = 0;
+ int32 NumBottomTouching = 0;
+ TArray GapsAbove;
+ TArray GapsBelow;
+ };
+
+ bool VF_AuditFloatingIslandVerticalExtents(
+ const FShowcaseSample& Sample,
+ int32 SourceSeed,
+ FIslandVerticalAudit& OutAudit)
+ {
+ OutAudit = FIslandVerticalAudit();
+ if (Sample.Candidate.Archetype != ECaveGeneratorType::FloatingIslands
+ || !Sample.Metrics.bValid)
+ {
+ return false;
+ }
+
+ const FFloatingIslandParams& Params = Sample.Candidate.ArchetypeParams.FloatingIslandParams;
+ const float TopWorldZ = static_cast(Sample.TopVoxelZ) + 1.0f;
+ const float BottomWorldZ = static_cast(Sample.BottomVoxelZ);
+ const float H = TopWorldZ - BottomWorldZ;
+ const float Spacing = FMath::Max(Params.IslandSpacing, 1.0f);
+ const float Seal = VF_ShowcaseBoundarySeal(
+ Sample.Candidate.ArchetypeParams, Sample.Candidate.Archetype);
+ if (!FMath::IsFinite(H) || H <= 0.0f || !FMath::IsFinite(Spacing)
+ || !FMath::IsFinite(Seal) || Seal < 0.0f)
+ {
+ return false;
+ }
+
+ const float MinX = Sample.Metrics.SampledMinX;
+ const float MaxX = Sample.Metrics.SampledMaxX;
+ const float MinY = Sample.Metrics.SampledMinY;
+ const float MaxY = Sample.Metrics.SampledMaxY;
+ const int32 MinCellX = FMath::FloorToInt(MinX / Spacing) - 1;
+ const int32 MaxCellX = FMath::CeilToInt(MaxX / Spacing) + 1;
+ const int32 MinCellY = FMath::FloorToInt(MinY / Spacing) - 1;
+ const int32 MaxCellY = FMath::CeilToInt(MaxY / Spacing) + 1;
+ const int64 CellCount = static_cast(MaxCellX - MinCellX + 1)
+ * static_cast(MaxCellY - MinCellY + 1);
+ constexpr int64 MaxAuditCells = 65536;
+ if (CellCount <= 0 || CellCount > MaxAuditCells)
+ {
+ return false;
+ }
+
+ const uint32 SeedU = static_cast(SourceSeed) ^ 0x49736C64u; // 'Isld'
+ const float MidZ = 0.5f * (TopWorldZ + BottomWorldZ);
+ for (int32 CellY = MinCellY; CellY <= MaxCellY; ++CellY)
+ {
+ for (int32 CellX = MinCellX; CellX <= MaxCellX; ++CellX)
+ {
+ const uint32 Hh = VoxelHash::Cell(CellX, CellY, SeedU);
+ if (VoxelHash::ToFloat01(Hh) > Params.IslandDensity)
+ {
+ continue;
+ }
+ const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u));
+ const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u));
+ const float IslandX = (CellX + 0.15f + JX * 0.7f) * Spacing;
+ const float IslandY = (CellY + 0.15f + JY * 0.7f) * Spacing;
+ if (IslandX < MinX || IslandX >= MaxX || IslandY < MinY || IslandY >= MaxY)
+ {
+ continue;
+ }
+
+ const float Radius = FMath::Lerp(
+ Params.IslandMinRadius, Params.IslandMaxRadius,
+ VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x5A5Au)));
+ const float TopHalf = Radius * 0.20f;
+ const float UnderDepth = Radius * FMath::Max(Params.ThicknessRatio, 0.25f);
+ const float SpreadZ = FMath::Max(
+ H * 0.5f - FMath::Max(TopHalf, UnderDepth) - Seal, 0.0f)
+ * Params.VerticalJitter;
+ const float CenterZ = MidZ + VoxelHash::ToFloatSigned(
+ VoxelHash::Mix(Hh ^ 0xB17Du)) * SpreadZ;
+ const float IslandTop = CenterZ + TopHalf;
+ const float IslandBottom = CenterZ - UnderDepth;
+ ++OutAudit.NumIslands;
+ if (IslandTop >= TopWorldZ - Seal)
+ {
+ ++OutAudit.NumTopTouching;
+ }
+ if (IslandBottom <= BottomWorldZ + Seal)
+ {
+ ++OutAudit.NumBottomTouching;
+ }
+ OutAudit.GapsAbove.Add(FMath::Max(
+ 0.0f, TopWorldZ - Seal - IslandTop));
+ OutAudit.GapsBelow.Add(FMath::Max(
+ 0.0f, IslandBottom - (BottomWorldZ + Seal)));
+ }
+ }
+ return OutAudit.NumIslands > 0;
+ }
+
+ FString VF_FormatFloatingIslandReport(
+ const TArray& Samples,
+ int32 SourceSeed)
+ {
+ for (const FShowcaseSample& Sample : Samples)
+ {
+ if (Sample.Candidate.Archetype != ECaveGeneratorType::FloatingIslands)
+ {
+ continue;
+ }
+ FIslandVerticalAudit Audit;
+ if (!VF_AuditFloatingIslandVerticalExtents(Sample, SourceSeed, Audit))
+ {
+ return TEXT("Part C FloatingIslands vertical audit unavailable for the previous candidate.");
+ }
+ const float TopFraction = static_cast(Audit.NumTopTouching)
+ / static_cast(Audit.NumIslands);
+ const float BottomFraction = static_cast(Audit.NumBottomTouching)
+ / static_cast(Audit.NumIslands);
+ const FFloatingIslandParams& P = Sample.Candidate.ArchetypeParams.FloatingIslandParams;
+ const float H = static_cast(Sample.TopVoxelZ + 1 - Sample.BottomVoxelZ);
+ const float MaxVerticalHalfExtent = FMath::Max(
+ 0.20f * P.IslandMaxRadius,
+ P.IslandMaxRadius * FMath::Max(P.ThicknessRatio, 0.25f));
+ const float AvailableCenterHalfSpan = FMath::Max(
+ 0.0f, H * 0.5f - MaxVerticalHalfExtent
+ - VF_ShowcaseBoundarySeal(Sample.Candidate.ArchetypeParams,
+ Sample.Candidate.Archetype));
+ const float Jitter = FMath::Clamp(P.VerticalJitter, 0.0f, 1.0f);
+ const float CurrentWorstNominalGap = FMath::Max(
+ 0.0f, AvailableCenterHalfSpan * (1.0f - Jitter));
+ const int32 CurrentHeightChunks = FMath::Max(
+ 1, FMath::RoundToInt(H / static_cast(CHUNK_SIZE)));
+ constexpr float AuthoredMinRadius = 18.0f;
+ constexpr float AuthoredMaxRadius = 42.0f;
+ const float AuthoredMaxVerticalHalfExtent = FMath::Max(
+ 0.20f * AuthoredMaxRadius,
+ AuthoredMaxRadius * FMath::Max(P.ThicknessRatio, 0.25f));
+ const float AuthoredAvailableCenterHalfSpan = FMath::Max(
+ 0.0f, H * 0.5f - AuthoredMaxVerticalHalfExtent
+ - VF_ShowcaseBoundarySeal(Sample.Candidate.ArchetypeParams,
+ Sample.Candidate.Archetype));
+ const int32 RecommendedHeightChunks = FMath::Max(CurrentHeightChunks + 1, 6);
+ const float RecommendedH = RecommendedHeightChunks * static_cast(CHUNK_SIZE);
+ const float AuthoredRecommendedAvailableCenterHalfSpan = FMath::Max(
+ 0.0f, RecommendedH * 0.5f - AuthoredMaxVerticalHalfExtent
+ - VF_ShowcaseBoundarySeal(Sample.Candidate.ArchetypeParams,
+ Sample.Candidate.Archetype));
+ const float AuthoredRecommendedWorstNominalGap = FMath::Max(
+ 0.0f, AuthoredRecommendedAvailableCenterHalfSpan * (1.0f - Jitter));
+ constexpr float RecommendedNominalGap = 16.0f;
+ const float ObservedRequiredHeight = 2.0f * (
+ VF_ShowcaseBoundarySeal(Sample.Candidate.ArchetypeParams,
+ Sample.Candidate.Archetype)
+ + MaxVerticalHalfExtent
+ + RecommendedNominalGap / FMath::Max(1.0f - Jitter, 0.01f));
+ const int32 ObservedRequiredHeightChunks = FMath::Max(
+ CurrentHeightChunks,
+ FMath::CeilToInt(ObservedRequiredHeight / static_cast(CHUNK_SIZE)));
+ return FString::Printf(
+ TEXT("Part C FloatingIslands structural audit (coarse XY window, %d nominal "
+ "islands; extents before surface roughness/merging): top-seal contact "
+ "%.6f (%d/%d), bottom-seal contact %.6f (%d/%d), median clear air gap "
+ "above %.3f voxels, below %.3f voxels. H=%d voxels=%d chunks, "
+ "IslandRadius=%.1f..%.1f, ThicknessRatio=%.3f, VerticalJitter=%.3f, "
+ "seal=%.1f; max-radius available centre half-span=%.3f, jittered "
+ "half-span=%.3f voxels; worst-case nominal seal gap=%.3f. "
+ "Authored radius reference is %.1f..%.1f: at current H its available "
+ "centre half-span is %.3f. Recommendation (not applied): restore/limit "
+ "IslandMinRadius/IslandMaxRadius to %.1f..%.1f, then raise "
+ "StrateHeightInChunks %d -> %d (H %.0f -> %.0f) for %.3f voxels "
+ "worst-case nominal gap; if the observed max radius is retained, height "
+ "needs at least %d chunks for a %.0f-voxel nominal gap. A ThicknessRatio "
+ "of <= 0.40 further separates authored-size plates but makes them thinner."),
+ Audit.NumIslands, TopFraction, Audit.NumTopTouching, Audit.NumIslands,
+ BottomFraction, Audit.NumBottomTouching, Audit.NumIslands,
+ VF_MedianFloat(Audit.GapsAbove), VF_MedianFloat(Audit.GapsBelow),
+ static_cast(H), static_cast(H / static_cast(CHUNK_SIZE)),
+ P.IslandMinRadius, P.IslandMaxRadius, P.ThicknessRatio, P.VerticalJitter,
+ VF_ShowcaseBoundarySeal(Sample.Candidate.ArchetypeParams,
+ Sample.Candidate.Archetype),
+ AvailableCenterHalfSpan, AvailableCenterHalfSpan * P.VerticalJitter,
+ CurrentWorstNominalGap, AuthoredMinRadius, AuthoredMaxRadius,
+ AuthoredAvailableCenterHalfSpan, AuthoredMinRadius, AuthoredMaxRadius,
+ CurrentHeightChunks, RecommendedHeightChunks, H, RecommendedH,
+ AuthoredRecommendedWorstNominalGap, ObservedRequiredHeightChunks,
+ RecommendedNominalGap);
+ }
+ return TEXT("Part C FloatingIslands vertical audit unavailable: previous candidate was not found.");
+ }
+
FString VF_FormatFloatDistribution(const TArray& Samples,
TFunctionRef Getter)
{
@@ -463,6 +943,14 @@ namespace
&& A.LargestWalkableSurfaceShare == B.LargestWalkableSurfaceShare
&& A.MedianFeatureScale == B.MedianFeatureScale
&& A.MedianVerticalClearance == B.MedianVerticalClearance
+ && A.bPlayerFitResolved == B.bPlayerFitResolved
+ && A.PlayerFitRefusalReason == B.PlayerFitRefusalReason
+ && A.NumPlayerFitCells == B.NumPlayerFitCells
+ && A.PlayerFitFraction == B.PlayerFitFraction
+ && A.NumTraversableComponents == B.NumTraversableComponents
+ && A.LargestTraversableComponentCells == B.LargestTraversableComponentCells
+ && A.TraversableComponentShare == B.TraversableComponentShare
+ && A.MinimumPlayerClearanceVoxels == B.MinimumPlayerClearanceVoxels
&& A.ResolvedMarginVoxels == B.ResolvedMarginVoxels
&& A.SampledMinZ == B.SampledMinZ
&& A.SampledMaxZ == B.SampledMaxZ
@@ -789,19 +1277,124 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters)
Stats[ArchetypeIndex].LastFailure.IsEmpty()
? TEXT("none") : *Stats[ArchetypeIndex].LastFailure));
}
+
+ struct FPreviousShowcaseSelection
+ {
+ ECaveGeneratorType Archetype;
+ int32 Seed;
+ int32 CandidateIndex;
+ int32 TargetStrateIndex;
+ };
+ const FPreviousShowcaseSelection PreviousSelections[] = {
+ { ECaveGeneratorType::CrystalChamber, 7331, 34, VoxelForgeTest::FTestWorld::SlotFlatPlain },
+ { ECaveGeneratorType::FlatPlain, 0, 4, VoxelForgeTest::FTestWorld::SlotFlatPlain },
+ { ECaveGeneratorType::FloatingIslands, 0, 34, VoxelForgeTest::FTestWorld::SlotFlatPlain },
+ { ECaveGeneratorType::Maze, 0, 58, VoxelForgeTest::FTestWorld::SlotFlatPlain },
+ { ECaveGeneratorType::SurfaceWorld, 0, 11, VoxelForgeTest::FTestWorld::SlotSurfaceWorld },
+ { ECaveGeneratorType::TunnelNetwork, 0, 61, VoxelForgeTest::FTestWorld::SlotFlatPlain },
+ { ECaveGeneratorType::Underwater, 0, 46, VoxelForgeTest::FTestWorld::SlotFlatPlain },
+ { ECaveGeneratorType::VerticalShafts, 0, 15, VoxelForgeTest::FTestWorld::SlotFlatPlain },
+ };
+ TArray PreviousSamples;
+ PreviousSamples.Reserve(UE_ARRAY_COUNT(PreviousSelections));
+ int32 NumPreviousShowcaseRejected = 0;
+ for (const FPreviousShowcaseSelection& Previous : PreviousSelections)
+ {
+ const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate(
+ Corpus, Previous.Seed, Previous.CandidateIndex, false);
+ FShowcaseSample Sample;
+ FString FailureReason;
+ const bool bPassedNewGate = Candidate.bValid
+ && Candidate.Archetype == Previous.Archetype
+ && VF_EvaluateShowcaseCandidate(
+ World, Candidate, Previous.TargetStrateIndex, MeasureSettings,
+ Sample, &FailureReason, true);
+ if (!bPassedNewGate)
+ {
+ ++NumPreviousShowcaseRejected;
+ }
+ if (Sample.Candidate.bValid && Sample.Metrics.bValid)
+ {
+ PreviousSamples.Add(Sample);
+ }
+ const FString FineSummary = Sample.FineMetrics.bPlayerFitResolved
+ ? FString::Printf(
+ TEXT("fit=%.6f traversable=%.6f fit_cells=%lld min_player_clearance=%.2f "
+ "restricted_law=%s"),
+ Sample.FineMetrics.PlayerFitFraction,
+ Sample.FineMetrics.TraversableComponentShare,
+ static_cast(Sample.FineMetrics.NumPlayerFitCells),
+ Sample.FineMetrics.MinimumPlayerClearanceVoxels,
+ VF_ShowcaseConnectivityName(Sample.PlayerFitLaw.Result))
+ : FString::Printf(
+ TEXT("fit=UNRESOLVED reason=%s"),
+ Sample.FineMetrics.PlayerFitRefusalReason.IsEmpty()
+ ? TEXT("not measured") : *Sample.FineMetrics.PlayerFitRefusalReason);
+ AddInfo(FString::Printf(
+ TEXT("previous showcase %s seed=%d index=%d: %s; old_walkable=%.6f "
+ "old_floor_area=%.6f old_largest_surface=%.6f old_law=%s; %s%s"),
+ VF_GetStrateArchetypeName(Previous.Archetype), Previous.Seed,
+ Previous.CandidateIndex,
+ bPassedNewGate ? TEXT("ACCEPTED by player-fit gate") : TEXT("REJECTED by player-fit gate"),
+ Sample.Metrics.WalkableFraction,
+ Sample.Metrics.WalkableFloorAreaFraction,
+ Sample.Metrics.LargestWalkableSurfaceShare,
+ VF_ShowcaseConnectivityName(Sample.LegacyLaw.Result),
+ *FineSummary,
+ (!bPassedNewGate && !FailureReason.IsEmpty())
+ ? *FString::Printf(TEXT(" failure=(%s)"), *FailureReason) : TEXT("")));
+ }
+ AddInfo(FString::Printf(
+ TEXT("Player capsule: radius %.2f voxels (%.0f cm / %.0f cm per voxel), "
+ "half-height %.2f voxels (%.0f cm / %.0f cm per voxel), full height %.2f voxels; "
+ "player-fit is resolved only at fine SampleStep=1."),
+ FVoxelPlayerCapsuleConstants::RadiusVoxels,
+ FVoxelPlayerCapsuleConstants::RadiusCentimeters,
+ FVoxelPlayerCapsuleConstants::VoxelSizeCentimeters,
+ FVoxelPlayerCapsuleConstants::HalfHeightVoxels,
+ FVoxelPlayerCapsuleConstants::HalfHeightCentimeters,
+ FVoxelPlayerCapsuleConstants::VoxelSizeCentimeters,
+ FVoxelPlayerCapsuleConstants::HeightVoxels));
+ AddInfo(FString::Printf(
+ TEXT("Previous eight showcase candidates rejected by the new player-fit gate: %d/8."),
+ NumPreviousShowcaseRejected));
+ AddInfo(VF_FormatRoughnessRadiusReport(PreviousSamples));
+ FString MazeRoughnessReport;
+ if (const FShowcaseSample* MazeSample = PreviousSamples.FindByPredicate(
+ [](const FShowcaseSample& Sample)
+ {
+ return Sample.Candidate.Archetype == ECaveGeneratorType::Maze;
+ }))
+ {
+ VF_RunMazeRoughnessExperiment(
+ World, *MazeSample, MeasureSettings, FinePreviewSettings, MazeRoughnessReport);
+ }
+ else
+ {
+ MazeRoughnessReport = TEXT(
+ "Part B Maze roughness experiment unavailable: previous Maze metrics were not measurable.");
+ }
+ AddInfo(MazeRoughnessReport);
+ AddInfo(VF_FormatFloatingIslandReport(PreviousSamples, World.Generator->Seed));
+
for (int32 ArchetypeIndex = 0;
ArchetypeIndex < UE_ARRAY_COUNT(GShowcaseArchetypes);
++ArchetypeIndex)
{
const FShowcaseStats& ArchetypeStats = Stats[ArchetypeIndex];
- TestTrue(FString::Printf(TEXT("%s has at least one hard-gate survivor"),
- VF_GetStrateArchetypeName(GShowcaseArchetypes[ArchetypeIndex])),
- ArchetypeStats.Best.IsSet());
+ if (!ArchetypeStats.Best.IsSet())
+ {
+ AddInfo(FString::Printf(
+ TEXT("%s: no candidate survived the unchanged legacy gates plus the new "
+ "player-fit gate in the bounded search."),
+ VF_GetStrateArchetypeName(GShowcaseArchetypes[ArchetypeIndex])));
+ }
}
if (!bHaveAllArchetypes)
{
- AddError(TEXT("The bounded deterministic search did not find one hard-gate survivor for every archetype."));
- return false;
+ AddInfo(TEXT("Showcase cards are not written because the new hard gate found no complete "
+ "eight-archetype survivor set; the diagnostic report above is the result."));
+ return true;
}
const FString CoarseWindowSummary = FString::Printf(
@@ -910,27 +1503,35 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters)
bCommonWindowSet = true;
}
- FVoxelStrateMeasureSettings FineMeasureSettings =
- FinePreviewSettings.MakeMeasureSettings(MeasureSettings);
- FineMeasureSettings.CenterXY = FVector2D(
- Metrics.LargestComponentPoint.X, Metrics.LargestComponentPoint.Y);
+ FVector FineArrivalPoint = FVector::ZeroVector;
+ FVector FineDeparturePoint = FVector::ZeroVector;
+ TestTrue(FString::Printf(TEXT("selected %s has the deterministic mouth pair for fine ROI"),
+ VF_GetStrateArchetypeName(Candidate.Archetype)),
+ VF_GetShowcaseMouthPair(
+ World, Selected.TargetStrateIndex, FineArrivalPoint, FineDeparturePoint));
+ const FVoxelStrateMeasureSettings FineMeasureSettings =
+ VF_MakeFinePlayerFitSettings(
+ MeasureSettings, FinePreviewSettings, FineArrivalPoint, FineDeparturePoint);
FVoxelStrateSampleGrid FineGrid;
const FVoxelStrateMetrics FineMetrics = VF_MeasureStrateWithSampler(
Sampler, Selected.BottomVoxelZ, Selected.TopVoxelZ + 1, Context.EdgeSealThickness,
FineMeasureSettings, &FineGrid);
- const FVector2D FineResolvedCenter(
- 0.5f * (FineGrid.MinX + FineGrid.MaxX),
- 0.5f * (FineGrid.MinY + FineGrid.MaxY));
- TestTrue(FString::Printf(TEXT("selected %s fine ROI is centred on LargestComponentPoint"),
+ const auto IsInsideFineXY = [&FineGrid](const FVector& Point)
+ {
+ return Point.X >= FineGrid.MinX && Point.X < FineGrid.MaxX
+ && Point.Y >= FineGrid.MinY && Point.Y < FineGrid.MaxY;
+ };
+ TestTrue(FString::Printf(TEXT("selected %s fine ROI covers both mouth XY points"),
VF_GetStrateArchetypeName(Candidate.Archetype)),
!FineMetrics.bValid
- || (FMath::IsNearlyEqual(
- FineResolvedCenter.X, Metrics.LargestComponentPoint.X, 0.5f)
- && FMath::IsNearlyEqual(
- FineResolvedCenter.Y, Metrics.LargestComponentPoint.Y, 0.5f)));
+ || (IsInsideFineXY(FineArrivalPoint)
+ && IsInsideFineXY(FineDeparturePoint)));
TestTrue(FString::Printf(TEXT("selected %s fine ROI stays within its cap"),
VF_GetStrateArchetypeName(Candidate.Archetype)),
!FineMetrics.bValid || FineGrid.CellCount <= FinePreviewMaxCells);
+ TestTrue(FString::Printf(TEXT("selected %s fine player-fit metrics rerun identically"),
+ VF_GetStrateArchetypeName(Candidate.Archetype)),
+ VF_ShowcaseMetricsEqual(Selected.FineMetrics, FineMetrics));
const bool bFineWritten = VF_WriteStratePreviewFineCandidate(
ShowcaseDirectory, RenderIndex, FineGrid,
FineMeasureSettings.HeadroomCells,
@@ -945,6 +1546,16 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters)
VF_GetStrateArchetypeName(Candidate.Archetype), *PreviewError));
return false;
}
+ PreviewCandidate.bPlayerFitResolved = FineMetrics.bPlayerFitResolved;
+ PreviewCandidate.PlayerFitRefusalReason = FineMetrics.PlayerFitRefusalReason;
+ PreviewCandidate.NumPlayerFitCells = FineMetrics.NumPlayerFitCells;
+ PreviewCandidate.PlayerFitFraction = FineMetrics.PlayerFitFraction;
+ PreviewCandidate.NumTraversableComponents = FineMetrics.NumTraversableComponents;
+ PreviewCandidate.LargestTraversableComponentCells =
+ FineMetrics.LargestTraversableComponentCells;
+ PreviewCandidate.TraversableComponentShare = FineMetrics.TraversableComponentShare;
+ PreviewCandidate.MinimumPlayerClearanceVoxels =
+ FineMetrics.MinimumPlayerClearanceVoxels;
if (FineMetrics.bValid)
{
TestTrue(FString::Printf(TEXT("selected %s fine preview has filled and contour views"),
@@ -971,11 +1582,13 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters)
PreviewCandidate.ComposerTargetStrateIndex = Selected.TargetStrateIndex;
PreviewCandidate.bComposerRollStructure = false;
PreviewCandidate.SelectionReason = FString::Printf(
- TEXT("Hard gates passed: non-vacuous, largest air component >= %.2f, and exact "
- "unsnapped arrival → departure connectivity. Selected by score "
+ TEXT("Hard gates passed: non-vacuous, largest air component >= %.2f, and "
+ "player-fit arrival → departure connectivity (fine step=1; "
+ "fit cells=%lld). Selected by legacy score "
"floor_area + 0.25*clamp(clearance/%d,0,1) + 0.10*largest_surface = %.6f; "
"floor area is the primary walking signal. Applied to existing target slot %d."),
LargestComponentSurvivalThreshold,
+ static_cast(FineMetrics.NumPlayerFitCells),
static_cast(ClearanceNormalizationVoxels),
Selected.SelectionScore,
Selected.TargetStrateIndex);
@@ -994,10 +1607,16 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters)
Summaries[ArchetypeIndex].Window = PreviewCandidates[ArchetypeIndex].Window;
Summaries[ArchetypeIndex].WindowSummary = VF_FormatShowcaseSummaryWindow(
Stats[ArchetypeIndex]);
+ Summaries[ArchetypeIndex].PlayerFitFractionSummary = FString::Printf(
+ TEXT("%.6f (step=1)"), Selected.FineMetrics.PlayerFitFraction);
+ Summaries[ArchetypeIndex].TraversableComponentShareSummary = FString::Printf(
+ TEXT("%.6f (step=1)"), Selected.FineMetrics.TraversableComponentShare);
AddInfo(FString::Printf(
TEXT("%s: hard-gate survivors=%d, selected seed=%d index=%d, "
- "walkable=%.6f, floor_area=%.6f, clearance=%d voxels, largest_surface=%.6f, "
- "window=%s"),
+ "old_walkable=%.6f, old_floor_area=%.6f, old_clearance=%d voxels, "
+ "old_largest_surface=%.6f, old_law=%s, player_fit_fraction=%.6f, "
+ "traversable_component_share=%.6f, player_fit_cells=%lld, "
+ "player_clearance=%.2f voxels, restricted_law=%s, window=%s"),
VF_GetStrateArchetypeName(GShowcaseArchetypes[ArchetypeIndex]),
Stats[ArchetypeIndex].Survivors.Num(),
Selected.Candidate.Seed, Selected.Candidate.Index,
@@ -1005,6 +1624,12 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters)
Selected.Metrics.WalkableFloorAreaFraction,
Selected.Metrics.MedianVerticalClearance,
Selected.Metrics.LargestWalkableSurfaceShare,
+ VF_ShowcaseConnectivityName(Selected.LegacyLaw.Result),
+ Selected.FineMetrics.PlayerFitFraction,
+ Selected.FineMetrics.TraversableComponentShare,
+ static_cast(Selected.FineMetrics.NumPlayerFitCells),
+ Selected.FineMetrics.MinimumPlayerClearanceVoxels,
+ VF_ShowcaseConnectivityName(Selected.PlayerFitLaw.Result),
*PreviewCandidates[ArchetypeIndex].Window.Describe()));
}
diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp
index 9024469..5ec9544 100644
--- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp
+++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp
@@ -35,6 +35,8 @@ namespace
case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid");
case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow");
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudget");
+ case EVoxelConnectivityResult::StartCellNotPlayerFit: return TEXT("StartCellNotPlayerFit");
+ case EVoxelConnectivityResult::GoalCellNotPlayerFit: return TEXT("GoalCellNotPlayerFit");
}
return TEXT("Unknown");
}
diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeLateralRegionsTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeLateralRegionsTest.cpp
index 3feb2ad..3510b21 100644
--- a/Source/VoxelForge/Private/Tests/VoxelForgeLateralRegionsTest.cpp
+++ b/Source/VoxelForge/Private/Tests/VoxelForgeLateralRegionsTest.cpp
@@ -48,6 +48,10 @@ namespace
return TEXT("StartCellSolid");
case EVoxelConnectivityResult::GoalCellSolid:
return TEXT("GoalCellSolid");
+ case EVoxelConnectivityResult::StartCellNotPlayerFit:
+ return TEXT("StartCellNotPlayerFit");
+ case EVoxelConnectivityResult::GoalCellNotPlayerFit:
+ return TEXT("GoalCellNotPlayerFit");
case EVoxelConnectivityResult::OutOfWindow:
return TEXT("OutOfWindow");
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted:
diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp
index 93b33fa..e0bf4cf 100644
--- a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp
+++ b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp
@@ -102,6 +102,15 @@ namespace
&& SameFloatBits(A.WalkableFraction, B.WalkableFraction)
&& SameFloatBits(A.MedianFeatureScale, B.MedianFeatureScale)
&& A.MedianVerticalClearance == B.MedianVerticalClearance
+ && A.bPlayerFitResolved == B.bPlayerFitResolved
+ && A.PlayerFitRefusalReason == B.PlayerFitRefusalReason
+ && A.NumPlayerFitCells == B.NumPlayerFitCells
+ && SameFloatBits(A.PlayerFitFraction, B.PlayerFitFraction)
+ && A.NumTraversableComponents == B.NumTraversableComponents
+ && A.LargestTraversableComponentCells == B.LargestTraversableComponentCells
+ && SameFloatBits(A.TraversableComponentShare, B.TraversableComponentShare)
+ && SameFloatBits(A.MinimumPlayerClearanceVoxels,
+ B.MinimumPlayerClearanceVoxels)
&& A.ResolvedMarginVoxels == B.ResolvedMarginVoxels
&& A.SampledMinZ == B.SampledMinZ
&& A.SampledMaxZ == B.SampledMaxZ
@@ -124,6 +133,10 @@ namespace
return TEXT("NOT_CONNECTED_AT_THIS_RESOLUTION");
case EVoxelConnectivityResult::StartCellSolid: return TEXT("START_CELL_SOLID");
case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GOAL_CELL_SOLID");
+ case EVoxelConnectivityResult::StartCellNotPlayerFit:
+ return TEXT("START_CELL_NOT_PLAYER_FIT");
+ case EVoxelConnectivityResult::GoalCellNotPlayerFit:
+ return TEXT("GOAL_CELL_NOT_PLAYER_FIT");
case EVoxelConnectivityResult::OutOfWindow: return TEXT("OUT_OF_WINDOW");
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted:
return TEXT("COARSE_LIED_BUDGET_EXHAUSTED");
@@ -132,7 +145,7 @@ namespace
}
constexpr int32 ConnectivityResultCount =
- static_cast(EVoxelConnectivityResult::CoarseLiedBudgetExhausted) + 1;
+ static_cast(EVoxelConnectivityResult::GoalCellNotPlayerFit) + 1;
int32 NumRefutedRoutes(const FConnectivityProbe& Probe)
{
diff --git a/Source/VoxelForge/Private/VoxelSeasonManifest.cpp b/Source/VoxelForge/Private/VoxelSeasonManifest.cpp
index 0b2f37f..94b9b0b 100644
--- a/Source/VoxelForge/Private/VoxelSeasonManifest.cpp
+++ b/Source/VoxelForge/Private/VoxelSeasonManifest.cpp
@@ -886,6 +886,19 @@ namespace VoxelSeasonManifestPrivate
Metrics.LargestWalkableSurfaceShare);
SetMetricFloat(Object, TEXT("median_feature_scale"), Metrics.MedianFeatureScale);
Object->SetNumberField(TEXT("median_vertical_clearance"), Metrics.MedianVerticalClearance);
+ Object->SetBoolField(TEXT("player_fit_resolved"), Metrics.bPlayerFitResolved);
+ Object->SetStringField(TEXT("player_fit_refusal_reason"), Metrics.PlayerFitRefusalReason);
+ Object->SetNumberField(TEXT("num_player_fit_cells"),
+ static_cast(Metrics.NumPlayerFitCells));
+ SetMetricFloat(Object, TEXT("player_fit_fraction"), Metrics.PlayerFitFraction);
+ Object->SetNumberField(TEXT("num_traversable_components"),
+ Metrics.NumTraversableComponents);
+ Object->SetNumberField(TEXT("largest_traversable_component_cells"),
+ static_cast(Metrics.LargestTraversableComponentCells));
+ SetMetricFloat(Object, TEXT("traversable_component_share"),
+ Metrics.TraversableComponentShare);
+ SetMetricFloat(Object, TEXT("minimum_player_clearance_voxels"),
+ Metrics.MinimumPlayerClearanceVoxels);
Object->SetNumberField(TEXT("resolved_margin_voxels"), Metrics.ResolvedMarginVoxels);
Object->SetNumberField(TEXT("sampled_min_z"), Metrics.SampledMinZ);
Object->SetNumberField(TEXT("sampled_max_z"), Metrics.SampledMaxZ);
@@ -940,6 +953,25 @@ namespace VoxelSeasonManifestPrivate
}
bValid = ReadMetricFloat(Object, TEXT("median_feature_scale"), OutMetrics.MedianFeatureScale) && bValid;
bValid = ReadJsonInt32(Object, TEXT("median_vertical_clearance"), OutMetrics.MedianVerticalClearance) && bValid;
+ if (Object->HasField(TEXT("player_fit_resolved")))
+ {
+ bValid = ReadJsonBool(Object, TEXT("player_fit_resolved"),
+ OutMetrics.bPlayerFitResolved) && bValid;
+ bValid = ReadJsonString(Object, TEXT("player_fit_refusal_reason"),
+ OutMetrics.PlayerFitRefusalReason) && bValid;
+ bValid = ReadJsonInt64(Object, TEXT("num_player_fit_cells"),
+ OutMetrics.NumPlayerFitCells) && bValid;
+ bValid = ReadMetricFloat(Object, TEXT("player_fit_fraction"),
+ OutMetrics.PlayerFitFraction) && bValid;
+ bValid = ReadJsonInt32(Object, TEXT("num_traversable_components"),
+ OutMetrics.NumTraversableComponents) && bValid;
+ bValid = ReadJsonInt64(Object, TEXT("largest_traversable_component_cells"),
+ OutMetrics.LargestTraversableComponentCells) && bValid;
+ bValid = ReadMetricFloat(Object, TEXT("traversable_component_share"),
+ OutMetrics.TraversableComponentShare) && bValid;
+ bValid = ReadMetricFloat(Object, TEXT("minimum_player_clearance_voxels"),
+ OutMetrics.MinimumPlayerClearanceVoxels) && bValid;
+ }
bValid = ReadJsonInt32(Object, TEXT("resolved_margin_voxels"), OutMetrics.ResolvedMarginVoxels) && bValid;
bValid = ReadJsonInt32(Object, TEXT("sampled_min_z"), OutMetrics.SampledMinZ) && bValid;
bValid = ReadJsonInt32(Object, TEXT("sampled_max_z"), OutMetrics.SampledMaxZ) && bValid;
@@ -997,6 +1029,8 @@ namespace VoxelSeasonManifestPrivate
case EVoxelConnectivityResult::NotConnectedAtThisResolution: return TEXT("NotConnectedAtThisResolution");
case EVoxelConnectivityResult::StartCellSolid: return TEXT("StartCellSolid");
case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid");
+ case EVoxelConnectivityResult::StartCellNotPlayerFit: return TEXT("StartCellNotPlayerFit");
+ case EVoxelConnectivityResult::GoalCellNotPlayerFit: return TEXT("GoalCellNotPlayerFit");
case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow");
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudgetExhausted");
}
@@ -1010,6 +1044,8 @@ namespace VoxelSeasonManifestPrivate
EVoxelConnectivityResult::NotConnectedAtThisResolution,
EVoxelConnectivityResult::StartCellSolid,
EVoxelConnectivityResult::GoalCellSolid,
+ EVoxelConnectivityResult::StartCellNotPlayerFit,
+ EVoxelConnectivityResult::GoalCellNotPlayerFit,
EVoxelConnectivityResult::OutOfWindow,
EVoxelConnectivityResult::CoarseLiedBudgetExhausted,
};
@@ -1917,7 +1953,11 @@ namespace VoxelSeasonCompositionPrivate
&& FMath::IsFinite(Settings.CenterXY.X)
&& FMath::IsFinite(Settings.CenterXY.Y)
&& FMath::IsFinite(Settings.CoverMarginVoxels)
- && Settings.CoverMarginVoxels >= 0.0f;
+ && Settings.CoverMarginVoxels >= 0.0f
+ && FMath::IsFinite(Settings.PlayerCapsuleRadiusVoxels)
+ && Settings.PlayerCapsuleRadiusVoxels > 0.0f
+ && FMath::IsFinite(Settings.PlayerCapsuleHalfHeightVoxels)
+ && Settings.PlayerCapsuleHalfHeightVoxels > 0.0f;
}
bool IsBossSlot(int32 DepthIndex, int32 TotalStrates, int32 Interval)
@@ -2193,20 +2233,54 @@ namespace VoxelSeasonCompositionPrivate
return false;
}
- const FVoxelConnectivityDiagnostics Law = VF_DiagnoseConnectivityWithSampler(
- Sampler, Strate.BottomWorldZ, Strate.TopWorldZ, Seal,
- FVector(0.0f, 0.0f, InteriorBottom + 0.5f),
- FVector(0.0f, 0.0f, InteriorTop - 0.5f), Settings.MeasureSettings);
- OutEvaluation.PrimordialLawResult = Law.Result;
- OutEvaluation.bPassedPrimordialLaw = Law.bValid
- && Law.Result == EVoxelConnectivityResult::Connected
- && !Law.bStartSnapped && !Law.bGoalSnapped;
+ FVoxelStrateMeasureSettings PlayerFitSettings = Settings.PlayerFitMeasureSettings;
+ // The season primordial law has always used the (0,0) spine as its arrival and departure
+ // line. Keep the fine ROI centred on that same physical route; centring it on the coarse
+ // largest-air representative can exclude the law endpoints and turn a valid route into
+ // an arbitrary OutOfWindow result. The player-fit pass still has its independent fine
+ // resolution and bounded radius.
+ PlayerFitSettings.CenterXY = FVector2D::ZeroVector;
+ PlayerFitSettings.CoverPointA.Reset();
+ PlayerFitSettings.CoverPointB.Reset();
+ FVoxelStrateMetrics PlayerFitMetrics;
+ const FVoxelConnectivityDiagnostics PlayerFitLaw =
+ VF_DiagnosePlayerFitConnectivityWithSampler(
+ Sampler, Strate.BottomWorldZ, Strate.TopWorldZ, Seal,
+ FVector(0.0f, 0.0f, InteriorBottom + 0.5f),
+ FVector(0.0f, 0.0f, InteriorTop - 0.5f),
+ PlayerFitSettings, &PlayerFitMetrics);
+ // The legacy fields remain the bounded coarse measurement. The player fields come from
+ // the separate one-voxel ROI that also backed the restricted route query.
+ RawMetrics.bPlayerFitResolved = PlayerFitMetrics.bPlayerFitResolved;
+ RawMetrics.PlayerFitRefusalReason = PlayerFitMetrics.PlayerFitRefusalReason;
+ RawMetrics.NumPlayerFitCells = PlayerFitMetrics.NumPlayerFitCells;
+ RawMetrics.PlayerFitFraction = PlayerFitMetrics.PlayerFitFraction;
+ RawMetrics.NumTraversableComponents = PlayerFitMetrics.NumTraversableComponents;
+ RawMetrics.LargestTraversableComponentCells =
+ PlayerFitMetrics.LargestTraversableComponentCells;
+ RawMetrics.TraversableComponentShare = PlayerFitMetrics.TraversableComponentShare;
+ RawMetrics.MinimumPlayerClearanceVoxels =
+ PlayerFitMetrics.MinimumPlayerClearanceVoxels;
+ if (OutRawMetrics != nullptr)
+ {
+ *OutRawMetrics = RawMetrics;
+ }
+
+ OutEvaluation.PrimordialLawResult = PlayerFitLaw.Result;
+ const bool bPlayerFitExists = PlayerFitMetrics.bValid
+ && PlayerFitMetrics.bPlayerFitResolved
+ && PlayerFitMetrics.NumPlayerFitCells > 0
+ && PlayerFitMetrics.NumTraversableComponents > 0
+ && PlayerFitMetrics.TraversableComponentShare > 0.0f;
+ OutEvaluation.bPassedPrimordialLaw = bPlayerFitExists
+ && PlayerFitLaw.bValid
+ && PlayerFitLaw.Result == EVoxelConnectivityResult::Connected;
if (!OutEvaluation.bPassedPrimordialLaw)
{
OutEvaluation.FailureReason = FString::Printf(
- TEXT("primordial law failed: %s%s"),
- RejectionReasonForLaw(Law.Result),
- (Law.bStartSnapped || Law.bGoalSnapped) ? TEXT(" (endpoint snapped)") : TEXT(""));
+ TEXT("player-fit primordial law failed: %s%s"),
+ RejectionReasonForLaw(PlayerFitLaw.Result),
+ bPlayerFitExists ? TEXT("") : TEXT(" (no player-fitting floor volume)"));
return false;
}
@@ -2433,6 +2507,10 @@ FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed,
{
return Fail(TEXT("MeasureSettings are invalid for a bounded offline pass."));
}
+ if (!IsValidMeasureSettings(Settings.PlayerFitMeasureSettings))
+ {
+ return Fail(TEXT("PlayerFitMeasureSettings are invalid for a bounded offline pass."));
+ }
TMap FixedStrates;
FString FixedError;
diff --git a/Source/VoxelForge/Private/VoxelStrateComposer.cpp b/Source/VoxelForge/Private/VoxelStrateComposer.cpp
index 50135dc..82fa6b1 100644
--- a/Source/VoxelForge/Private/VoxelStrateComposer.cpp
+++ b/Source/VoxelForge/Private/VoxelStrateComposer.cpp
@@ -2285,6 +2285,14 @@ FVoxelStrateMeasuredMetrics VF_SummarizeStrateMetrics(
Result.LargestWalkableSurfaceShare = Metrics.LargestWalkableSurfaceShare;
Result.MedianFeatureScale = Metrics.MedianFeatureScale;
Result.MedianVerticalClearance = Metrics.MedianVerticalClearance;
+ Result.bPlayerFitResolved = Metrics.bPlayerFitResolved;
+ Result.PlayerFitRefusalReason = Metrics.PlayerFitRefusalReason;
+ Result.NumPlayerFitCells = Metrics.NumPlayerFitCells;
+ Result.PlayerFitFraction = Metrics.PlayerFitFraction;
+ Result.NumTraversableComponents = Metrics.NumTraversableComponents;
+ Result.LargestTraversableComponentCells = Metrics.LargestTraversableComponentCells;
+ Result.TraversableComponentShare = Metrics.TraversableComponentShare;
+ Result.MinimumPlayerClearanceVoxels = Metrics.MinimumPlayerClearanceVoxels;
Result.ResolvedMarginVoxels = Metrics.ResolvedMarginVoxels;
Result.SampledMinZ = Metrics.SampledMinZ;
Result.SampledMaxZ = Metrics.SampledMaxZ;
@@ -2778,6 +2786,19 @@ namespace
Metrics.LargestWalkableSurfaceShare);
Object->SetNumberField(TEXT("median_feature_scale"), Metrics.MedianFeatureScale);
Object->SetNumberField(TEXT("median_vertical_clearance"), Metrics.MedianVerticalClearance);
+ Object->SetBoolField(TEXT("player_fit_resolved"), Metrics.bPlayerFitResolved);
+ Object->SetStringField(TEXT("player_fit_refusal_reason"), Metrics.PlayerFitRefusalReason);
+ Object->SetNumberField(TEXT("num_player_fit_cells"),
+ static_cast(Metrics.NumPlayerFitCells));
+ Object->SetNumberField(TEXT("player_fit_fraction"), Metrics.PlayerFitFraction);
+ Object->SetNumberField(TEXT("num_traversable_components"),
+ Metrics.NumTraversableComponents);
+ Object->SetNumberField(TEXT("largest_traversable_component_cells"),
+ static_cast(Metrics.LargestTraversableComponentCells));
+ Object->SetNumberField(TEXT("traversable_component_share"),
+ Metrics.TraversableComponentShare);
+ Object->SetNumberField(TEXT("minimum_player_clearance_voxels"),
+ Metrics.MinimumPlayerClearanceVoxels);
Object->SetNumberField(TEXT("resolved_margin_voxels"), Metrics.ResolvedMarginVoxels);
Object->SetNumberField(TEXT("sampled_min_z"), Metrics.SampledMinZ);
Object->SetNumberField(TEXT("sampled_max_z"), Metrics.SampledMaxZ);
@@ -2852,6 +2873,25 @@ namespace
OutMetrics.MedianFeatureScale) && bValid;
bValid = VF_ReadJsonInt32(Object, TEXT("median_vertical_clearance"),
OutMetrics.MedianVerticalClearance) && bValid;
+ if (Object->HasField(TEXT("player_fit_resolved")))
+ {
+ bValid = VF_ReadJsonBool(Object, TEXT("player_fit_resolved"),
+ OutMetrics.bPlayerFitResolved) && bValid;
+ bValid = VF_ReadJsonString(Object, TEXT("player_fit_refusal_reason"),
+ OutMetrics.PlayerFitRefusalReason) && bValid;
+ bValid = VF_ReadJsonInt64(Object, TEXT("num_player_fit_cells"),
+ OutMetrics.NumPlayerFitCells) && bValid;
+ bValid = VF_ReadJsonFloat(Object, TEXT("player_fit_fraction"),
+ OutMetrics.PlayerFitFraction) && bValid;
+ bValid = VF_ReadJsonInt32(Object, TEXT("num_traversable_components"),
+ OutMetrics.NumTraversableComponents) && bValid;
+ bValid = VF_ReadJsonInt64(Object, TEXT("largest_traversable_component_cells"),
+ OutMetrics.LargestTraversableComponentCells) && bValid;
+ bValid = VF_ReadJsonFloat(Object, TEXT("traversable_component_share"),
+ OutMetrics.TraversableComponentShare) && bValid;
+ bValid = VF_ReadJsonFloat(Object, TEXT("minimum_player_clearance_voxels"),
+ OutMetrics.MinimumPlayerClearanceVoxels) && bValid;
+ }
bValid = VF_ReadJsonInt32(Object, TEXT("resolved_margin_voxels"),
OutMetrics.ResolvedMarginVoxels) && bValid;
bValid = VF_ReadJsonInt32(Object, TEXT("sampled_min_z"), OutMetrics.SampledMinZ) && bValid;
@@ -2995,6 +3035,14 @@ namespace
&& A.LargestWalkableSurfaceShare == B.LargestWalkableSurfaceShare
&& A.MedianFeatureScale == B.MedianFeatureScale
&& A.MedianVerticalClearance == B.MedianVerticalClearance
+ && A.bPlayerFitResolved == B.bPlayerFitResolved
+ && A.PlayerFitRefusalReason == B.PlayerFitRefusalReason
+ && A.NumPlayerFitCells == B.NumPlayerFitCells
+ && A.PlayerFitFraction == B.PlayerFitFraction
+ && A.NumTraversableComponents == B.NumTraversableComponents
+ && A.LargestTraversableComponentCells == B.LargestTraversableComponentCells
+ && A.TraversableComponentShare == B.TraversableComponentShare
+ && A.MinimumPlayerClearanceVoxels == B.MinimumPlayerClearanceVoxels
&& A.ResolvedMarginVoxels == B.ResolvedMarginVoxels
&& A.SampledMinZ == B.SampledMinZ
&& A.SampledMaxZ == B.SampledMaxZ
@@ -3075,6 +3123,10 @@ namespace
&& FMath::IsFinite(Metrics.WalkableFloorAreaFraction)
&& FMath::IsFinite(Metrics.LargestWalkableSurfaceShare)
&& FMath::IsFinite(Metrics.MedianFeatureScale)
+ && (!Metrics.bPlayerFitResolved
+ || (FMath::IsFinite(Metrics.PlayerFitFraction)
+ && FMath::IsFinite(Metrics.TraversableComponentShare)
+ && FMath::IsFinite(Metrics.MinimumPlayerClearanceVoxels)))
&& FMath::IsFinite(Metrics.LargestComponentPoint.X)
&& FMath::IsFinite(Metrics.LargestComponentPoint.Y)
&& FMath::IsFinite(Metrics.LargestComponentPoint.Z);
diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp
index c80e5f4..38108cc 100644
--- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp
+++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp
@@ -441,7 +441,8 @@ namespace VoxelStrateMeasurePrivate
int64& OutLargestComponentCells,
int32& OutLargestComponentLowestCell,
int32& OutNumComponentsAtLeast1Pct,
- TArray* OutComponentCells)
+ TArray* OutComponentCells,
+ const TArray* EligibilityMask = nullptr)
{
OutComponents.SetNumUninitialized(Grid.CellCount);
for (int32 Index = 0; Index < Grid.CellCount; ++Index)
@@ -462,7 +463,9 @@ namespace VoxelStrateMeasurePrivate
for (int32 Start = 0; Start < Grid.CellCount; ++Start)
{
- if (Grid.Air[Start] == 0u || OutComponents[Start] >= 0)
+ if (Grid.Air[Start] == 0u
+ || (EligibilityMask != nullptr && (*EligibilityMask)[Start] == 0u)
+ || OutComponents[Start] >= 0)
{
continue;
}
@@ -486,7 +489,9 @@ namespace VoxelStrateMeasurePrivate
auto Visit = [&](int32 Neighbor)
{
- if (Grid.Air[Neighbor] != 0u && OutComponents[Neighbor] < 0)
+ if (Grid.Air[Neighbor] != 0u
+ && (EligibilityMask == nullptr || (*EligibilityMask)[Neighbor] != 0u)
+ && OutComponents[Neighbor] < 0)
{
OutComponents[Neighbor] = ComponentId;
Queue[Tail++] = Neighbor;
@@ -576,6 +581,343 @@ namespace VoxelStrateMeasurePrivate
}
}
+ struct FPlayerCapsuleStencilRow
+ {
+ int32 RelativeZ = 0;
+ TArray HorizontalOffsets;
+ };
+
+ bool IsPointInsideCapsule(
+ float RelativeZ,
+ int32 OffsetX,
+ int32 OffsetY,
+ float Radius,
+ float HalfHeight)
+ {
+ // The capsule is upright and its centre is HalfHeight above the top face of the solid
+ // floor cell. This is a direct capsule test in the fine voxel lattice; it deliberately
+ // does not infer occupancy from Manhattan distance.
+ const float AxisHalfLength = FMath::Max(0.0f, HalfHeight - Radius);
+ const float DistanceToAxis = FMath::Max(
+ FMath::Abs(RelativeZ) - AxisHalfLength, 0.0f);
+ const float DistanceSquared = static_cast(OffsetX * OffsetX + OffsetY * OffsetY)
+ + DistanceToAxis * DistanceToAxis;
+ return DistanceSquared <= Radius * Radius + KINDA_SMALL_NUMBER;
+ }
+
+ bool BuildPlayerCapsuleStencil(
+ const FVoxelStrateMeasureSettings& Settings,
+ TArray& OutRows,
+ FString& OutReason)
+ {
+ OutRows.Reset();
+ if (!FMath::IsFinite(Settings.PlayerCapsuleRadiusVoxels)
+ || Settings.PlayerCapsuleRadiusVoxels <= 0.0f
+ || !FMath::IsFinite(Settings.PlayerCapsuleHalfHeightVoxels)
+ || Settings.PlayerCapsuleHalfHeightVoxels <= 0.0f)
+ {
+ return Refuse(OutReason,
+ TEXT("Player capsule radius and half-height must be finite and greater than zero."));
+ }
+
+ const double HeightCellsReal = FMath::CeilToDouble(
+ 2.0 * static_cast(Settings.PlayerCapsuleHalfHeightVoxels));
+ // This is a stencil-size guard, not a gameplay threshold. It prevents a malformed
+ // caller-supplied capsule from allocating an unbounded per-anchor loop.
+ constexpr double MaxStencilHeightCells = 4096.0;
+ if (!FMath::IsFinite(HeightCellsReal) || HeightCellsReal <= 0.0
+ || HeightCellsReal > MaxStencilHeightCells)
+ {
+ return Refuse(OutReason,
+ TEXT("The player capsule height exceeds the bounded fit stencil."));
+ }
+
+ const double MaxHorizontalOffsetReal = FMath::CeilToDouble(
+ static_cast(Settings.PlayerCapsuleRadiusVoxels));
+ constexpr double MaxStencilHorizontalRadiusCells = 4096.0;
+ if (!FMath::IsFinite(MaxHorizontalOffsetReal)
+ || MaxHorizontalOffsetReal < 0.0
+ || MaxHorizontalOffsetReal > MaxStencilHorizontalRadiusCells)
+ {
+ return Refuse(OutReason,
+ TEXT("The player capsule radius exceeds the bounded fit stencil."));
+ }
+ const int32 HeightCells = static_cast(HeightCellsReal);
+ const int32 MaxHorizontalOffset = static_cast(MaxHorizontalOffsetReal);
+ // This bounds both the retained stencil and the per-anchor occupancy work. It is
+ // deliberately far above the confirmed capsule's handful of offsets, but prevents a
+ // malformed caller from turning a valid-looking finite radius into an enormous array.
+ constexpr int64 MaxStencilOffsets = 1ll << 20;
+ int64 NumStencilOffsets = 0;
+
+ OutRows.Reserve(HeightCells);
+ for (int32 RelativeZ = 0; RelativeZ < HeightCells; ++RelativeZ)
+ {
+ FPlayerCapsuleStencilRow& Row = OutRows.AddDefaulted_GetRef();
+ Row.RelativeZ = RelativeZ;
+ const float CapsuleRelativeZ = static_cast(RelativeZ) + 0.5f
+ - Settings.PlayerCapsuleHalfHeightVoxels;
+ for (int32 OffsetY = -MaxHorizontalOffset;
+ OffsetY <= MaxHorizontalOffset;
+ ++OffsetY)
+ {
+ for (int32 OffsetX = -MaxHorizontalOffset;
+ OffsetX <= MaxHorizontalOffset;
+ ++OffsetX)
+ {
+ if (IsPointInsideCapsule(
+ CapsuleRelativeZ, OffsetX, OffsetY,
+ Settings.PlayerCapsuleRadiusVoxels,
+ Settings.PlayerCapsuleHalfHeightVoxels))
+ {
+ if (NumStencilOffsets >= MaxStencilOffsets)
+ {
+ return Refuse(
+ OutReason,
+ TEXT("The player capsule occupancy stencil exceeds its bounded work limit."));
+ }
+ Row.HorizontalOffsets.Add(FIntPoint(OffsetX, OffsetY));
+ ++NumStencilOffsets;
+ }
+ }
+ }
+
+ // The full-height floor rule still requires one clear voxel in rows whose voxel
+ // centre lies outside the rounded cap (normally the top partial voxel of a 7.04
+ // voxel player). This is what makes 176 cm a full-height requirement rather than a
+ // 7-centre-point approximation.
+ if (Row.HorizontalOffsets.IsEmpty())
+ {
+ Row.HorizontalOffsets.Add(FIntPoint::ZeroValue);
+ }
+ }
+ return true;
+ }
+
+ bool BuildPlayerFitMask(
+ const FSampleGrid& Grid,
+ const FVoxelStrateMeasureSettings& Settings,
+ TArray& OutPlayerFit,
+ int64& OutNumPlayerFitCells,
+ FString& OutReason)
+ {
+ OutPlayerFit.Reset();
+ OutNumPlayerFitCells = 0;
+ if (Grid.SampleStep != 1)
+ {
+ return Refuse(
+ OutReason,
+ *FString::Printf(
+ TEXT("Player-fit metrics refused: SampleStep=%d; exact capsule fit requires SampleStep=1."),
+ Grid.SampleStep));
+ }
+
+ TArray StencilRows;
+ if (!BuildPlayerCapsuleStencil(Settings, StencilRows, OutReason))
+ {
+ return false;
+ }
+
+ OutPlayerFit.Init(0u, Grid.CellCount);
+ for (int32 Z = 1; Z < Grid.NumZ; ++Z)
+ {
+ for (int32 Y = 0; Y < Grid.NumY; ++Y)
+ {
+ for (int32 X = 0; X < Grid.NumX; ++X)
+ {
+ const int32 Current = Grid.Index(X, Y, Z);
+ if (Grid.Air[Current] == 0u
+ || Grid.Air[Grid.Index(X, Y, Z - 1)] != 0u)
+ {
+ continue;
+ }
+
+ bool bFits = true;
+ for (const FPlayerCapsuleStencilRow& Row : StencilRows)
+ {
+ const int32 TargetZ = Z + Row.RelativeZ;
+ if (TargetZ < 0 || TargetZ >= Grid.NumZ)
+ {
+ bFits = false;
+ break;
+ }
+ for (const FIntPoint& Offset : Row.HorizontalOffsets)
+ {
+ const int32 TargetX = X + Offset.X;
+ const int32 TargetY = Y + Offset.Y;
+ if (TargetX < 0 || TargetX >= Grid.NumX
+ || TargetY < 0 || TargetY >= Grid.NumY
+ || Grid.Air[Grid.Index(TargetX, TargetY, TargetZ)] == 0u)
+ {
+ bFits = false;
+ break;
+ }
+ }
+ if (!bFits)
+ {
+ break;
+ }
+ }
+
+ if (bFits)
+ {
+ OutPlayerFit[Current] = 1u;
+ ++OutNumPlayerFitCells;
+ }
+ }
+ }
+ }
+ return true;
+ }
+
+ float MinimumPlayerClearanceChebyshev(
+ const FSampleGrid& Grid,
+ const TArray& PlayerFit)
+ {
+ if (PlayerFit.Num() != Grid.CellCount)
+ {
+ return 0.0f;
+ }
+
+ const int32 PlaneCells = Grid.NumX * Grid.NumY;
+ constexpr int32 Infinity = INT32_MAX / 4;
+ TArray Distances;
+ Distances.SetNumUninitialized(PlaneCells);
+ int32 MinimumDistance = Infinity;
+ for (int32 Z = 0; Z < Grid.NumZ; ++Z)
+ {
+ for (int32 Y = 0; Y < Grid.NumY; ++Y)
+ {
+ for (int32 X = 0; X < Grid.NumX; ++X)
+ {
+ const int32 PlaneIndex = X + Grid.NumX * Y;
+ Distances[PlaneIndex] = Grid.Air[Grid.Index(X, Y, Z)] == 0u
+ ? 0 : Infinity;
+ }
+ }
+
+ auto Relax = [&](int32 Current, int32 Neighbor)
+ {
+ if (Distances[Neighbor] < Infinity)
+ {
+ Distances[Current] = FMath::Min(
+ Distances[Current], Distances[Neighbor] + 1);
+ }
+ };
+
+ // Two raster passes with the complete 8-neighbour stencil give a true 2D
+ // Chebyshev distance to solid in this horizontal slice. It is used only for the
+ // reported minimum; capsule occupancy above is the direct stencil check.
+ for (int32 Y = 0; Y < Grid.NumY; ++Y)
+ {
+ for (int32 X = 0; X < Grid.NumX; ++X)
+ {
+ const int32 Current = X + Grid.NumX * Y;
+ if (Grid.Air[Grid.Index(X, Y, Z)] == 0u) continue;
+ if (X > 0) Relax(Current, Current - 1);
+ if (Y > 0)
+ {
+ Relax(Current, Current - Grid.NumX);
+ if (X > 0) Relax(Current, Current - Grid.NumX - 1);
+ if (X + 1 < Grid.NumX) Relax(Current, Current - Grid.NumX + 1);
+ }
+ }
+ }
+ for (int32 Y = Grid.NumY - 1; Y >= 0; --Y)
+ {
+ for (int32 X = Grid.NumX - 1; X >= 0; --X)
+ {
+ const int32 Current = X + Grid.NumX * Y;
+ if (Grid.Air[Grid.Index(X, Y, Z)] == 0u) continue;
+ if (X + 1 < Grid.NumX) Relax(Current, Current + 1);
+ if (Y + 1 < Grid.NumY)
+ {
+ Relax(Current, Current + Grid.NumX);
+ if (X > 0) Relax(Current, Current + Grid.NumX - 1);
+ if (X + 1 < Grid.NumX) Relax(Current, Current + Grid.NumX + 1);
+ }
+ }
+ }
+
+ for (int32 Y = 0; Y < Grid.NumY; ++Y)
+ {
+ for (int32 X = 0; X < Grid.NumX; ++X)
+ {
+ const int32 Cell = Grid.Index(X, Y, Z);
+ if (PlayerFit[Cell] != 0u)
+ {
+ MinimumDistance = FMath::Min(
+ MinimumDistance, Distances[X + Grid.NumX * Y]);
+ }
+ }
+ }
+ }
+
+ return MinimumDistance == Infinity
+ ? 0.0f : static_cast(MinimumDistance);
+ }
+
+ void BuildPlayerFitMetrics(
+ const FSampleGrid& Grid,
+ const FVoxelStrateMeasureSettings& Settings,
+ FVoxelStrateMetrics& InOutMetrics,
+ TArray* OutPlayerFitMask = nullptr)
+ {
+ InOutMetrics.bPlayerFitResolved = false;
+ InOutMetrics.PlayerFitRefusalReason.Reset();
+ InOutMetrics.NumPlayerFitCells = 0;
+ InOutMetrics.PlayerFitFraction = 0.0f;
+ InOutMetrics.NumTraversableComponents = 0;
+ InOutMetrics.LargestTraversableComponentCells = 0;
+ InOutMetrics.TraversableComponentShare = 0.0f;
+ InOutMetrics.MinimumPlayerClearanceVoxels = 0.0f;
+
+ TArray PlayerFit;
+ if (!BuildPlayerFitMask(
+ Grid, Settings, PlayerFit, InOutMetrics.NumPlayerFitCells,
+ InOutMetrics.PlayerFitRefusalReason))
+ {
+ if (OutPlayerFitMask != nullptr)
+ {
+ OutPlayerFitMask->Reset();
+ }
+ return;
+ }
+ InOutMetrics.bPlayerFitResolved = true;
+ InOutMetrics.PlayerFitFraction = InOutMetrics.NumAir > 0
+ ? static_cast(static_cast(InOutMetrics.NumPlayerFitCells)
+ / static_cast(InOutMetrics.NumAir))
+ : 0.0f;
+
+ TArray Components;
+ int32 NumComponents = 0;
+ int64 LargestComponentCells = 0;
+ int32 LargestComponentLowestCell = INDEX_NONE;
+ int32 NumComponentsAtLeast1Pct = 0;
+ FloodFillAir(
+ Grid,
+ Components,
+ NumComponents,
+ InOutMetrics.NumPlayerFitCells,
+ LargestComponentCells,
+ LargestComponentLowestCell,
+ NumComponentsAtLeast1Pct,
+ nullptr,
+ &PlayerFit);
+ InOutMetrics.NumTraversableComponents = NumComponents;
+ InOutMetrics.LargestTraversableComponentCells = LargestComponentCells;
+ InOutMetrics.TraversableComponentShare = InOutMetrics.NumPlayerFitCells > 0
+ ? static_cast(static_cast(LargestComponentCells)
+ / static_cast(InOutMetrics.NumPlayerFitCells))
+ : 0.0f;
+ InOutMetrics.MinimumPlayerClearanceVoxels = MinimumPlayerClearanceChebyshev(
+ Grid, PlayerFit);
+ if (OutPlayerFitMask != nullptr)
+ {
+ *OutPlayerFitMask = MoveTemp(PlayerFit);
+ }
+ }
+
float MedianFloat(TArray& Values)
{
if (Values.Num() == 0) return 0.0f;
@@ -591,7 +933,8 @@ namespace VoxelStrateMeasurePrivate
void BuildMetricsFromGrid(
const FSampleGrid& Grid,
const FVoxelStrateMeasureSettings& Settings,
- FVoxelStrateMetrics& InOutMetrics)
+ FVoxelStrateMetrics& InOutMetrics,
+ TArray* OutPlayerFitMask = nullptr)
{
TArray Components;
int32 NumComponents = 0;
@@ -775,6 +1118,11 @@ namespace VoxelStrateMeasurePrivate
}
InOutMetrics.MedianFeatureScale = MedianFloat(FeatureScales);
}
+
+ // This is intentionally a separate fine-resolution pass. The legacy metrics above may
+ // be useful at SampleStep=4/8, but they cannot answer whether the confirmed player capsule
+ // fits. A coarse grid therefore receives an explicit refusal instead of an estimate.
+ BuildPlayerFitMetrics(Grid, Settings, InOutMetrics, OutPlayerFitMask);
}
bool FindCellForPoint(const FSampleGrid& Grid, const FVector& Point, int32& OutCell)
@@ -793,14 +1141,16 @@ namespace VoxelStrateMeasurePrivate
{
OutOfWindow,
Air,
- Solid
+ Solid,
+ NotPlayerFit
};
EEndpointCellResult ResolveEndpointCell(
const FSampleGrid& Grid,
const FVector& Point,
int32& OutCell,
- bool& bOutSnapped)
+ bool& bOutSnapped,
+ const TArray* EligibilityMask = nullptr)
{
bOutSnapped = false;
int32 OriginalCell = -1;
@@ -808,7 +1158,12 @@ namespace VoxelStrateMeasurePrivate
{
return EEndpointCellResult::OutOfWindow;
}
- if (Grid.Air[OriginalCell] != 0u)
+ const auto IsEligible = [&, EligibilityMask](int32 Cell)
+ {
+ return Grid.Air[Cell] != 0u
+ && (EligibilityMask == nullptr || (*EligibilityMask)[Cell] != 0u);
+ };
+ if (IsEligible(OriginalCell))
{
OutCell = OriginalCell;
return EEndpointCellResult::Air;
@@ -848,7 +1203,7 @@ namespace VoxelStrateMeasurePrivate
}
const int32 Candidate = Grid.Index(X, Y, Z);
- if (Grid.Air[Candidate] == 0u)
+ if (!IsEligible(Candidate))
{
continue;
}
@@ -868,7 +1223,43 @@ namespace VoxelStrateMeasurePrivate
if (BestCell == INDEX_NONE)
{
- return EEndpointCellResult::Solid;
+ if (EligibilityMask != nullptr)
+ {
+ // Passage mouth points are authored on the centreline of a carved connector, not
+ // necessarily on the floor-anchor lattice used by PlayerFit. Resolve them to the
+ // nearest player-fitting floor cell over the bounded fine grid, rather than
+ // declaring an in-air mouth unreachable just because its floor is several cells
+ // below. The endpoint is reported as snapped, and the route re-check below still
+ // validates the endpoint-to-anchor segment against density.
+ for (int32 Candidate = 0; Candidate < Grid.CellCount; ++Candidate)
+ {
+ if ((*EligibilityMask)[Candidate] == 0u)
+ {
+ continue;
+ }
+
+ int32 CandidateX = 0;
+ int32 CandidateY = 0;
+ int32 CandidateZ = 0;
+ DecodeIndex(Grid, Candidate, CandidateX, CandidateY, CandidateZ);
+ const float DistanceSquared = FVector::DistSquared(
+ Point, Grid.CellCenter(CandidateX, CandidateY, CandidateZ));
+ if (DistanceSquared < BestDistanceSquared
+ || (DistanceSquared == BestDistanceSquared
+ && (BestCell == INDEX_NONE || Candidate < BestCell)))
+ {
+ BestDistanceSquared = DistanceSquared;
+ BestCell = Candidate;
+ }
+ }
+ }
+ }
+
+ if (BestCell == INDEX_NONE)
+ {
+ return EligibilityMask != nullptr
+ ? EEndpointCellResult::NotPlayerFit
+ : EEndpointCellResult::Solid;
}
OutCell = BestCell;
@@ -943,10 +1334,49 @@ namespace VoxelStrateMeasurePrivate
const FVector& A,
const FVector& B,
const TArray& Path,
- FCoarseEdge& OutFailedEdge)
+ FCoarseEdge& OutFailedEdge,
+ const TArray* EligibilityMask = nullptr)
{
OutFailedEdge = FCoarseEdge();
+ if (EligibilityMask != nullptr)
+ {
+ // A player-fit route is deliberately a graph over floor anchors, not a second air
+ // route with a coarse-cell illusion. The mask was built at SampleStep=1 and every
+ // edge below is between adjacent player-fitting cells.
+ for (int32 PathIndex = 0; PathIndex < Path.Num(); ++PathIndex)
+ {
+ if (!EligibilityMask->IsValidIndex(Path[PathIndex])
+ || (*EligibilityMask)[Path[PathIndex]] == 0u)
+ {
+ if (PathIndex > 0)
+ {
+ OutFailedEdge = MakeCoarseEdge(
+ Path[PathIndex - 1], Path[PathIndex]);
+ }
+ return false;
+ }
+ }
+ int32 StartX = 0;
+ int32 StartY = 0;
+ int32 StartZ = 0;
+ DecodeIndex(Grid, Path[0], StartX, StartY, StartZ);
+ int32 EndX = 0;
+ int32 EndY = 0;
+ int32 EndZ = 0;
+ DecodeIndex(Grid, Path.Last(), EndX, EndY, EndZ);
+ if (!FullResolutionAirOnSegment(
+ Generator, Sampler, A, Grid.CellCenter(StartX, StartY, StartZ))
+ || !FullResolutionAirOnSegment(
+ Generator, Sampler, Grid.CellCenter(EndX, EndY, EndZ), B))
+ {
+ // Endpoint approaches have no graph edge to exclude. The player-fit route is
+ // therefore unknown if either mouth-to-anchor segment contains solid density.
+ return false;
+ }
+ return true;
+ }
+
int32 StartX = 0;
int32 StartY = 0;
int32 StartZ = 0;
@@ -998,9 +1428,15 @@ namespace VoxelStrateMeasurePrivate
int32 Start,
int32 Goal,
const TArray& BlockedEdges,
- TArray& OutPath)
+ TArray& OutPath,
+ const TArray* EligibilityMask = nullptr)
{
- if (Grid.Air[Start] == 0u || Grid.Air[Goal] == 0u)
+ const auto IsEligible = [&, EligibilityMask](int32 Cell)
+ {
+ return Grid.Air[Cell] != 0u
+ && (EligibilityMask == nullptr || (*EligibilityMask)[Cell] != 0u);
+ };
+ if (!IsEligible(Start) || !IsEligible(Goal))
{
return false;
}
@@ -1029,7 +1465,7 @@ namespace VoxelStrateMeasurePrivate
auto Visit = [&](int32 Neighbor)
{
- if (Grid.Air[Neighbor] != 0u
+ if (IsEligible(Neighbor)
&& Parent[Neighbor] == -2
&& !IsCoarseEdgeBlocked(BlockedEdges, Current, Neighbor))
{
@@ -1084,7 +1520,8 @@ namespace VoxelStrateMeasurePrivate
bool& bOutStartSnapped,
bool& bOutGoalSnapped,
int32& OutNumRouteRetries,
- int32 MaxRouteRetries)
+ int32 MaxRouteRetries,
+ const TArray* EligibilityMask = nullptr)
{
OutStart = -1;
OutGoal = -1;
@@ -1093,9 +1530,9 @@ namespace VoxelStrateMeasurePrivate
OutNumRouteRetries = 0;
const EEndpointCellResult StartCell = ResolveEndpointCell(
- Grid, AVoxel, OutStart, bOutStartSnapped);
+ Grid, AVoxel, OutStart, bOutStartSnapped, EligibilityMask);
const EEndpointCellResult GoalCell = ResolveEndpointCell(
- Grid, BVoxel, OutGoal, bOutGoalSnapped);
+ Grid, BVoxel, OutGoal, bOutGoalSnapped, EligibilityMask);
if (StartCell == EEndpointCellResult::OutOfWindow
|| GoalCell == EEndpointCellResult::OutOfWindow)
{
@@ -1105,16 +1542,25 @@ namespace VoxelStrateMeasurePrivate
{
return EVoxelConnectivityResult::StartCellSolid;
}
+ if (StartCell == EEndpointCellResult::NotPlayerFit)
+ {
+ return EVoxelConnectivityResult::StartCellNotPlayerFit;
+ }
if (GoalCell == EEndpointCellResult::Solid)
{
return EVoxelConnectivityResult::GoalCellSolid;
}
+ if (GoalCell == EEndpointCellResult::NotPlayerFit)
+ {
+ return EVoxelConnectivityResult::GoalCellNotPlayerFit;
+ }
TArray BlockedEdges;
TArray Path;
for (;;)
{
- if (!FindCoarsePath(Grid, OutStart, OutGoal, BlockedEdges, Path))
+ if (!FindCoarsePath(
+ Grid, OutStart, OutGoal, BlockedEdges, Path, EligibilityMask))
{
// This is a negative verdict only after all edges excluded by earlier
// full-resolution failures leave no coarse route at this resolution.
@@ -1125,7 +1571,9 @@ namespace VoxelStrateMeasurePrivate
// spacing in the original voxel field. A single solid sample refutes only this
// route, so exclude its precise coarse edge and search again.
FCoarseEdge FailedEdge;
- if (FullResolutionPathIsAir(Generator, Sampler, Grid, AVoxel, BVoxel, Path, FailedEdge))
+ if (FullResolutionPathIsAir(
+ Generator, Sampler, Grid, AVoxel, BVoxel, Path, FailedEdge,
+ EligibilityMask))
{
return EVoxelConnectivityResult::Connected;
}
@@ -1193,10 +1641,13 @@ namespace VoxelStrateMeasurePrivate
bool bStartSnapped,
bool bGoalSnapped,
int32 NumRouteRetries,
- FVoxelConnectivityDiagnostics& OutDiagnostics)
+ FVoxelConnectivityDiagnostics& OutDiagnostics,
+ const TArray* EligibilityMask = nullptr)
{
OutDiagnostics = FVoxelConnectivityDiagnostics();
OutDiagnostics.Result = ConnectivityResult;
+ OutDiagnostics.bPlayerFitRestricted = EligibilityMask != nullptr;
+ OutDiagnostics.bPlayerFitResolved = EligibilityMask != nullptr;
OutDiagnostics.bStartSnapped = bStartSnapped;
OutDiagnostics.bGoalSnapped = bGoalSnapped;
OutDiagnostics.NumRouteRetries = NumRouteRetries;
@@ -1213,9 +1664,11 @@ namespace VoxelStrateMeasurePrivate
int32 LargestComponentLowestCell = INDEX_NONE;
int32 NumComponentsAtLeast1Pct = 0;
int64 NumAir = 0;
- for (const uint8 bAir : Grid.Air)
+ for (int32 Index = 0; Index < Grid.CellCount; ++Index)
{
- NumAir += bAir != 0u ? 1 : 0;
+ NumAir += Grid.Air[Index] != 0u
+ && (EligibilityMask == nullptr || (*EligibilityMask)[Index] != 0u)
+ ? 1 : 0;
}
FloodFillAir(
Grid,
@@ -1225,7 +1678,8 @@ namespace VoxelStrateMeasurePrivate
LargestComponentCells,
LargestComponentLowestCell,
NumComponentsAtLeast1Pct,
- nullptr);
+ nullptr,
+ EligibilityMask);
const int32 StartComponent = Components[Start];
const int32 GoalComponent = Components[Goal];
@@ -1285,11 +1739,30 @@ namespace VoxelStrateMeasurePrivate
bool& bOutStartSnapped,
bool& bOutGoalSnapped,
int32& OutNumRouteRetries,
- FVoxelConnectivityDiagnostics* OutDiagnostics)
+ FVoxelConnectivityDiagnostics* OutDiagnostics,
+ bool bPlayerFitRestricted = false,
+ FVoxelStrateMetrics* OutPlayerMetrics = nullptr)
{
bOutStartSnapped = false;
bOutGoalSnapped = false;
OutNumRouteRetries = 0;
+ if (OutPlayerMetrics != nullptr)
+ {
+ *OutPlayerMetrics = FVoxelStrateMetrics();
+ }
+ auto RefusePlayerFit = [&](const FString& Reason)
+ {
+ if (OutDiagnostics != nullptr)
+ {
+ *OutDiagnostics = FVoxelConnectivityDiagnostics();
+ OutDiagnostics->bPlayerFitRestricted = bPlayerFitRestricted;
+ OutDiagnostics->RefusalReason = Reason;
+ }
+ if (OutPlayerMetrics != nullptr)
+ {
+ OutPlayerMetrics->PlayerFitRefusalReason = Reason;
+ }
+ };
if (!FMath::IsFinite(AVoxel.X) || !FMath::IsFinite(AVoxel.Y)
|| !FMath::IsFinite(AVoxel.Z) || !FMath::IsFinite(BVoxel.X)
|| !FMath::IsFinite(BVoxel.Y) || !FMath::IsFinite(BVoxel.Z))
@@ -1297,6 +1770,7 @@ namespace VoxelStrateMeasurePrivate
if (OutDiagnostics != nullptr)
{
*OutDiagnostics = FVoxelConnectivityDiagnostics();
+ OutDiagnostics->bPlayerFitRestricted = bPlayerFitRestricted;
}
return EVoxelConnectivityResult::OutOfWindow;
}
@@ -1305,10 +1779,19 @@ namespace VoxelStrateMeasurePrivate
if (OutDiagnostics != nullptr)
{
*OutDiagnostics = FVoxelConnectivityDiagnostics();
+ OutDiagnostics->bPlayerFitRestricted = bPlayerFitRestricted;
}
return EVoxelConnectivityResult::OutOfWindow;
}
+ if (bPlayerFitRestricted && Settings.SampleStep != 1)
+ {
+ RefusePlayerFit(FString::Printf(
+ TEXT("Player-fit connectivity refused: SampleStep=%d; exact capsule fit requires SampleStep=1."),
+ Settings.SampleStep));
+ return EVoxelConnectivityResult::OutOfWindow;
+ }
+
FSampleGrid Grid;
FString RefusalReason;
if (!BuildSampleGrid(Generator, Manager, Sampler, StrateIndex,
@@ -1319,10 +1802,58 @@ namespace VoxelStrateMeasurePrivate
if (OutDiagnostics != nullptr)
{
*OutDiagnostics = FVoxelConnectivityDiagnostics();
+ OutDiagnostics->bPlayerFitRestricted = bPlayerFitRestricted;
+ OutDiagnostics->RefusalReason = RefusalReason;
+ }
+ if (OutPlayerMetrics != nullptr)
+ {
+ OutPlayerMetrics->PlayerFitRefusalReason = RefusalReason;
}
return EVoxelConnectivityResult::OutOfWindow;
}
+ TArray PlayerFitMask;
+ FVoxelStrateMetrics PlayerMetrics;
+ if (bPlayerFitRestricted)
+ {
+ // A player-fit query needs only the fine-grid air count plus the direct capsule
+ // stencil. Do not pay for the legacy all-air flood fill, walkable-column scan, or
+ // Manhattan feature transform here: those values belong to the separate legacy
+ // measurement pass, and recomputing them for every fine candidate made the gate
+ // needlessly expensive without changing its answer.
+ PlayerMetrics.ResolvedMarginVoxels = Grid.ResolvedMarginVoxels;
+ PlayerMetrics.SampledMinZ = Grid.SampledMinZ;
+ PlayerMetrics.SampledMaxZ = Grid.SampledMaxZ;
+ PlayerMetrics.SampledNumX = Grid.NumX;
+ PlayerMetrics.SampledNumY = Grid.NumY;
+ PlayerMetrics.SampledNumZ = Grid.NumZ;
+ PlayerMetrics.SampledMinX = Grid.MinX;
+ PlayerMetrics.SampledMaxX = Grid.MaxX;
+ PlayerMetrics.SampledMinY = Grid.MinY;
+ PlayerMetrics.SampledMaxY = Grid.MaxY;
+ PlayerMetrics.NumSampled = Grid.CellCount;
+ for (const uint8 bAir : Grid.Air)
+ {
+ PlayerMetrics.NumAir += bAir != 0u ? 1 : 0;
+ }
+ PlayerMetrics.NumSolid = static_cast(Grid.CellCount) - PlayerMetrics.NumAir;
+ PlayerMetrics.AirFraction = PlayerMetrics.NumSampled > 0
+ ? static_cast(static_cast(PlayerMetrics.NumAir)
+ / static_cast(PlayerMetrics.NumSampled))
+ : 0.0f;
+ BuildPlayerFitMetrics(Grid, Settings, PlayerMetrics, &PlayerFitMask);
+ PlayerMetrics.bValid = PlayerMetrics.NumSampled > 0;
+ if (OutPlayerMetrics != nullptr)
+ {
+ *OutPlayerMetrics = PlayerMetrics;
+ }
+ if (!PlayerMetrics.bPlayerFitResolved)
+ {
+ RefusePlayerFit(PlayerMetrics.PlayerFitRefusalReason);
+ return EVoxelConnectivityResult::OutOfWindow;
+ }
+ }
+
int32 Start = -1;
int32 Goal = -1;
const EVoxelConnectivityResult Result = EvaluateConnectivityOnGrid(
@@ -1336,7 +1867,8 @@ namespace VoxelStrateMeasurePrivate
bOutStartSnapped,
bOutGoalSnapped,
OutNumRouteRetries,
- Settings.MaxRouteRetries);
+ Settings.MaxRouteRetries,
+ bPlayerFitRestricted ? &PlayerFitMask : nullptr);
if (OutDiagnostics != nullptr)
{
PopulateConnectivityDiagnostics(
@@ -1347,7 +1879,16 @@ namespace VoxelStrateMeasurePrivate
bOutStartSnapped,
bOutGoalSnapped,
OutNumRouteRetries,
- *OutDiagnostics);
+ *OutDiagnostics,
+ bPlayerFitRestricted ? &PlayerFitMask : nullptr);
+ if (bPlayerFitRestricted &&
+ (Result == EVoxelConnectivityResult::StartCellNotPlayerFit
+ || Result == EVoxelConnectivityResult::GoalCellNotPlayerFit))
+ {
+ OutDiagnostics->RefusalReason = Result == EVoxelConnectivityResult::StartCellNotPlayerFit
+ ? TEXT("arrival endpoint is not a player-fitting floor cell")
+ : TEXT("departure endpoint is not a player-fitting floor cell");
+ }
}
return Result;
}
@@ -1582,4 +2123,44 @@ FVoxelConnectivityDiagnostics VF_DiagnoseConnectivityWithSampler(
return Result;
}
+EVoxelConnectivityResult VF_ArePlayerFitConnectedWithSampler(
+ const IVoxelStrateDensitySampler& Sampler,
+ int32 StrateBottomWorldZ,
+ int32 StrateTopWorldZ,
+ float BoundarySealThickness,
+ const FVector& AVoxel,
+ const FVector& BVoxel,
+ const FVoxelStrateMeasureSettings& Settings,
+ bool& bOutStartSnapped,
+ bool& bOutGoalSnapped,
+ FVoxelConnectivityDiagnostics* OutDiagnostics)
+{
+ int32 NumRouteRetries = 0;
+ return VoxelStrateMeasurePrivate::QueryConnectivity(
+ nullptr, nullptr, &Sampler, INDEX_NONE,
+ StrateBottomWorldZ, StrateTopWorldZ, BoundarySealThickness, true,
+ AVoxel, BVoxel, Settings, bOutStartSnapped, bOutGoalSnapped,
+ NumRouteRetries, OutDiagnostics, true, nullptr);
+}
+
+FVoxelConnectivityDiagnostics VF_DiagnosePlayerFitConnectivityWithSampler(
+ const IVoxelStrateDensitySampler& Sampler,
+ int32 StrateBottomWorldZ,
+ int32 StrateTopWorldZ,
+ float BoundarySealThickness,
+ const FVector& AVoxel,
+ const FVector& BVoxel,
+ const FVoxelStrateMeasureSettings& Settings,
+ FVoxelStrateMetrics* OutPlayerMetrics)
+{
+ FVoxelConnectivityDiagnostics Result;
+ int32 NumRouteRetries = 0;
+ VoxelStrateMeasurePrivate::QueryConnectivity(
+ nullptr, nullptr, &Sampler, INDEX_NONE,
+ StrateBottomWorldZ, StrateTopWorldZ, BoundarySealThickness, true,
+ AVoxel, BVoxel, Settings, Result.bStartSnapped, Result.bGoalSnapped,
+ NumRouteRetries, &Result, true, OutPlayerMetrics);
+ return Result;
+}
+
#endif // WITH_EDITOR — measurement never ships
diff --git a/Source/VoxelForge/Private/VoxelStratePreview.cpp b/Source/VoxelForge/Private/VoxelStratePreview.cpp
index 9f5e94c..b55fb35 100644
--- a/Source/VoxelForge/Private/VoxelStratePreview.cpp
+++ b/Source/VoxelForge/Private/VoxelStratePreview.cpp
@@ -716,6 +716,15 @@ bool VF_WriteStratePreviewCandidate(
OutCandidate.LargestWalkableSurfaceShare = Metrics.LargestWalkableSurfaceShare;
OutCandidate.MedianFeatureScale = Metrics.MedianFeatureScale;
OutCandidate.MedianVerticalClearance = Metrics.MedianVerticalClearance;
+ OutCandidate.bPlayerFitResolved = Metrics.bPlayerFitResolved;
+ OutCandidate.PlayerFitRefusalReason = Metrics.PlayerFitRefusalReason;
+ OutCandidate.NumPlayerFitCells = Metrics.NumPlayerFitCells;
+ OutCandidate.PlayerFitFraction = Metrics.PlayerFitFraction;
+ OutCandidate.NumTraversableComponents = Metrics.NumTraversableComponents;
+ OutCandidate.LargestTraversableComponentCells =
+ Metrics.LargestTraversableComponentCells;
+ OutCandidate.TraversableComponentShare = Metrics.TraversableComponentShare;
+ OutCandidate.MinimumPlayerClearanceVoxels = Metrics.MinimumPlayerClearanceVoxels;
OutCandidate.DistanceFromCorpusCentroid = FMath::IsFinite(DistanceFromCorpusCentroid)
? DistanceFromCorpusCentroid : 0.0;
@@ -1085,10 +1094,11 @@ bool VF_WriteStratePreviewIndex(
"walkable cell / sampled XY footprint. Largest connected surface is the largest "
"four-neighbour component of those projected columns / all walkable columns. It is "
"not a 3D traversal proof; the arrival → departure route check is separate.
\n"
- "
Part A measurement settings: step=4 voxels, "
- "HeadroomCells=2, XY radius=256 voxels, MaxCells=8,000,000. Each summary row lists "
- "the exact target, derived interior margin, Z range, XY range, and grid for every "
- "hard-gate survivor; each card repeats its selected window.
\n"
+ "
Part A measurement settings: legacy fields use "
+ "step=4 voxels, HeadroomCells=2, XY radius=256 voxels, MaxCells=8,000,000. The "
+ "player-fit gate uses a separate step-1 fine ROI and refuses to answer at any coarser "
+ "step. Each summary row lists the exact target, derived interior margin, Z range, XY "
+ "range, and grid for every hard-gate survivor; each card repeats its selected window.
\n"
"
Reality check: real caves mix long, narrow passages "
"with rooms, branches, multiple levels, and occasional vertical shafts; there is no "
"universal target floor-to-volume ratio. The quantitative table below is therefore a "
@@ -1103,20 +1113,23 @@ bool VF_WriteStratePreviewIndex(
"
Part A — survivor distributions; every value uses the exact window printed in its row
Fine ROI — largest open space: the separate step-1 pass "
- "is requested only for selected candidates (this run selects coarse survivors). Its XY "
- "window is centred on the deterministic LargestComponentPoint from the coarse "
- "air flood fill, not on the strate midpoint or the world origin. Its exact window is printed "
+ "
Fine ROI — player-fit audit: the separate step-1 pass "
+ "is used by the player-fit gate for every candidate that reaches the legacy screen, and "
+ "is rendered for the selected candidate. The caller supplies its bounded XY window; the "
+ "showcase fits that window to the deterministic arrival/departure mouth pair so the gate "
+ "cannot pass or fail because its endpoints were cropped out. Its exact window is printed "
"on the card. If its cell cap is exceeded, no fine allocation is made and the refusal is "
"shown on that card.
\n"
"
Ordering: survivors first, then each group by descending distance from "
@@ -1271,7 +1285,7 @@ bool VF_WriteStratePreviewIndex(
Html += FString::Printf(TEXT("
No coarse image: %s
\n"),
*VF_HtmlEscape(Candidate.RenderFailureReason));
}
- Html += TEXT("\n
FINE ROI — largest open space; filled cells and density=0 contour
\n");
+ Html += TEXT("
\n
FINE ROI — player-fit audit; filled cells and density=0 contour
\n");
if (!Candidate.bFineRequested)
{
Html += TEXT("
Fine ROI not requested for this candidate; it did not pass the coarse survivor screen.