From e4e159f0cd8e8130091faaf0d6976ce5cd657239 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Sun, 6 Sep 2026 12:58:56 +0200 Subject: [PATCH] test(measure): the window was NOT the problem -- my sixth hypothesis dies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I thought the fitted mouth-AABB window was cropping an origin-routed path and producing a false NotConnected. Codex tested it three ways at step 1 on the Maze candidate Jahni walked: mouth-AABB + margin 842,996 cells NotConnected origin-inclusive 3,251,556 cells NotConnected WHOLE STRATE 31,881,528 cells NotConnected 115,578 fit cells, 2,903 components, largest 11,418 -- and the mouths sit in components of 172 and FOUR cells, 274 voxels apart. The window was never the problem. Sixth measurement hypothesis, first one that was wrong in the other direction: this time the instrument was fine and I doubted it. The window rule still improved: bIncludeOriginInCoverWindow now covers the route an origin-rooted topology actually implies (Maze, VerticalShafts, TunnelNetwork, Underwater), expansion happens BEFORE MaxCells, and an over-cap window is REFUSED honestly instead of silently cropping back to a mouth-only box. VerticalShafts refused at the 4M production cap and said so. Corrected law across eight: 0/7 measured Connected, 1 unknown (CoarseLiedBudgetExhausted), 1 refused. ⇒ FIVE CAUSES NOW ELIMINATED: size (Jahni walked it), the fit stencil (fixed, 309 pockets -> 96), placement (mouths land on legal poses), corridor connectivity (spanning tree, 0 disconnected cells over 64 seeds), and the measurement window (whole-strate says the same thing). ⭐ AND THE REMAINING CAUSE IS NAMED BY ITS OWN NUMBER: the departure mouth sits in a FOUR-CELL player-fit component. A passage tube is a bore through rock -- sloped or vertical, no floor within the walkable angle -- so the mouth point is not a standing position and nothing around it is either. You arrive and there is nowhere to stand. That is precisely COMPOSER-NOTES §6.5 / Tier 1's unbuilt item: "the passage mouth becomes a room plus a distinctive landmark". It was never started. The law cannot pass while a mouth opens into a bore instead of a landing. Build clean, 32/32 successful, 0 failed, same two known warnings. WorldRadiusVoxels=0, lateral gating, determinism, equivalence all intact. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K8V9my1PL68pmQb2gutZZV --- CODEMAP.md | 4 +- COMPOSER-NOTES.md | 72 ++++++ .../Tests/VoxelForgeComposerShowcaseTest.cpp | 59 +++-- .../Private/Tests/VoxelForgePlayerFitWindow.h | 101 ++++++++ .../Tests/VoxelForgeScaleDiagnosisTest.cpp | 229 +++++++++++++++++- .../VoxelForge/Private/VoxelStrateMeasure.cpp | 13 + Source/VoxelForge/Public/VoxelStrateMeasure.h | 5 + 7 files changed, 444 insertions(+), 39 deletions(-) create mode 100644 Source/VoxelForge/Private/Tests/VoxelForgePlayerFitWindow.h diff --git a/CODEMAP.md b/CODEMAP.md index 4324c3f..9dcfa94 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -364,7 +364,9 @@ Headless, read-only Tier 2 measurement pass. `VF_MeasureStrate` samples one stra boundary-seal-derived (or explicitly overridden, including explicit zero) interior margin into a bounded grid. When both `CoverPointA` and `CoverPointB` are set, the XY window is their AABB expanded by `CoverMarginVoxels`; otherwise it is the legacy square centered on `CenterXY` with -`RadiusInVoxels`. +`RadiusInVoxels`. Origin-rooted callers set `bIncludeOriginInCoverWindow` to expand that fitted +AABB to include `(0,0)` before the `MaxCells` check; an over-cap route window is refused rather +than cropped back to a mouth-only box. It performs one deterministic 6-connected air flood fill, and derives fractions, components, walkability, feature scale, and clearance from that grid. It then performs one bounded 4-neighbour flood fill over the projected XY columns that contain a walkable surface; this is a floor-continuity diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index 08f425a..4a2e755 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -1946,3 +1946,75 @@ the world. Fix the measurement before touching generation again. - **Acceptance must be the measured property, never "the code was written."** Twice a task returned "built, and it does not meet the criterion — here is the number." Both times that honesty pointed straight at the real cause. + +--- + +## 16. Player-fit route-window audit — the origin is not the missing flip (2026-09-06) + +This pass tested the proposed sixth measurement artifact directly on the fixed Maze identity: +composer seed `0`, candidate index `50`, fourth one-based fixture slot (`SlotMaze == 3`). The +player-fit stencil remained `SampleStep=1`; every row below names its exact XY/Z window and cell +budget. `NotConnectedAtThisResolution` is a result of that stated grid, not a claim that the +infinite Maze graph is disconnected. + +### 16.1 Part A — three Maze windows + +| window | exact window / grid | MaxCells | fit cells | components | largest | arrival / departure | gap (voxels) | law | +|---|---|---:|---:|---:|---:|---:|---:|---| +| current mouth-AABB + margin | `X[-84.1,168.7) Y[39.5,53.3) Z[-1175,-937)`, `253×14×238`, `842,996` cells, step 1 | 4,000,000 | 1,352 | 50 | 237 | 128 / 1 | 254.492 | `NotConnectedAtThisResolution` | +| origin-inclusive mouth-AABB + margin | `X[-84.1,168.7) Y[0.0,53.3) Z[-1175,-937)`, `253×54×238`, `3,251,556` cells, step 1 | 4,000,000 | 9,598 | 309 | 845 | 120 / 3 | 274.811 | `NotConnectedAtThisResolution` | +| whole-strate centered origin cap window | `X[-183,183) Y[-183,183) Z[-1175,-937)`, `366×366×238`, `31,881,528` cells, step 1 | 32,000,000 | 115,578 | 2,903 | 11,418 | 172 / 4 | 274.811 | `NotConnectedAtThisResolution` | + +The origin-inclusive box did **not** flip to `Connected`, and the larger centered window did not +either. The hypothesis is therefore **not confirmed** for this candidate. The whole-strate probe +used an explicit 32M cap so it could remain a real step-1 player-fit measurement; the production +fine-law cap remains 4M. A step-2 player-fit probe is not equivalent here: the player-fit API +intentionally refuses every `SampleStep != 1` because the capsule stencil cannot be resolved at +that resolution. + +### 16.2 Part B — route-aware window policy + +The measurement now has an explicit `bIncludeOriginInCoverWindow` flag. It expands the fitted mouth +AABB to include `(0,0)` **before** the `MaxCells` check. If that route window is over the cap, the +measurement refuses with `The requested sample grid exceeds MaxCells; measurement refused.` It does +not shrink back to a mouth-only box. + +The policy used by the eight-row law is: + +- origin-inclusive mouth-AABB: `Maze`, `VerticalShafts`, `TunnelNetwork`, and `Underwater`; +- mouth-AABB plus capsule margin: `CrystalChamber`, `FlatPlain`, `SurfaceWorld`, and + `FloatingIslands`. + +The first group is origin-rooted by its actual topology: Maze parent chains, VerticalShafts +drainage-tree chains, and the origin-flowing room graph shared by TunnelNetwork/Underwater. The +global structural spine exists for every stack, but it is not treated as the promised route for +the four local/independent families. + +### 16.3 Part C — corrected eight-row law + +These are the fixed seed-0 identities in the scale diagnosis. All valid rows are step 1. The +window column is part of the result, not an omitted implementation detail. + +| archetype | player-fit window (`X/Y/Z`, grid, cells) | fit cells | components | largest | arrival / departure | gap (voxels) | law | +|---|---|---:|---:|---:|---:|---:|---| +| CrystalChamber | mouth-AABB + margin; `X[-178.9,-152.8) Y[-38.2,29.1) Z[-537,-295)`, `27×68×242`, `444,312` | 992 | 102 | 165 | 40 / 6 | 50.070 | `NotConnectedAtThisResolution` | +| FlatPlain | mouth-AABB + margin; `X[-178.9,-152.8) Y[-38.2,29.1) Z[-534,-298)`, `27×68×236`, `433,296` | 2,346 | 9 | 2,337 | 1 / 2,337 | 138.105 | `NotConnectedAtThisResolution` | +| FloatingIslands | mouth-AABB + margin; `X[-143.5,-135.5) Y[121.8,129.8) Z[-537,-295)`, `8×8×242`, `15,488` | 0 | 0 | 0 | 0 / 0 | -1.000 | `StartCellNotPlayerFit` | +| Maze | origin-inclusive mouth-AABB + margin; `X[-84.1,168.7) Y[0.0,53.3) Z[-1175,-937)`, `253×54×238`, `3,251,556` | 9,598 | 309 | 845 | 120 / 3 | 274.811 | `NotConnectedAtThisResolution` | +| SurfaceWorld | mouth-AABB + margin; `X[-133.7,-76.1) Y[-68.8,47.5) Z[-1495,-1257)`, `58×117×238`, `1,615,068` | 1,720 | 248 | 801 | 1 / 3 | 101.454 | `NotConnectedAtThisResolution` | +| TunnelNetwork | origin-inclusive mouth-AABB + margin; `X[-178.9,0.0) Y[-38.2,29.1) Z[-536,-296)`, `179×68×240`, `2,921,280` | 3,684 | 213 | 1,176 | 108 / 82 | 83.361 | `NotConnectedAtThisResolution` | +| Underwater | origin-inclusive mouth-AABB + margin; `X[-178.9,0.0) Y[-38.2,29.1) Z[-535,-297)`, `179×68×238`, `2,896,936` | 1,669 | 79 | 618 | 109 / 109 | 0.000 | `CoarseLiedBudgetExhausted` (**unknown**, not a pass) | +| VerticalShafts | origin-inclusive mouth-AABB + margin; **refused before allocation** because the required grid exceeds `MaxCells=4,000,000` | — | — | — | — | — | `REFUSED(MaxCells)` | + +The valid-row tally is **0/7 Connected**, with **1/8 refused** and one `CoarseLiedBudgetExhausted` +unknown. The old one-cell/one-pocket success is not present in this corrected table. + +### 16.4 Plain verdict + +The remaining failure is not the cropped measurement window. Maze remains disconnected in player-fit +volume even when the origin is covered and when the full 32M-cell step-1 centered window is measured. +The proven spanning-tree graph and the player-fit volume are different claims: the graph has a route, +but the generated player-fitting corridor/mouth volume does not currently provide a continuous route +from these two passage mouths. The smallest next generation task is therefore at the mouths or their +player-fit join to the corridor network (with the possibility of a fine-floor/stencil break along +the route), not another window, size, density, or topology tuning pass. diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp index cace7bf..7561d98 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp @@ -13,6 +13,7 @@ #include "VoxelStrateComposer.h" #include "VoxelStrateMeasure.h" #include "VoxelStratePreview.h" +#include "VoxelForgePlayerFitWindow.h" #include "VoxelTypes.h" namespace @@ -113,6 +114,7 @@ namespace FVoxelStrateMetrics FineMetrics; FVoxelConnectivityDiagnostics LegacyLaw; FVoxelConnectivityDiagnostics PlayerFitLaw; + FString FineWindowSummary; double SelectionScore = -1.0; FString LawText; int32 TargetStrateIndex = INDEX_NONE; @@ -247,22 +249,15 @@ namespace const FVoxelStrateMeasureSettings& BaseSettings, const FVoxelStrateFinePreviewSettings& FinePreviewSettings, const FVector& ArrivalPoint, - const FVector& DeparturePoint) + const FVector& DeparturePoint, + ECaveGeneratorType Archetype) { 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; + // The fine preview follows the measured route contract. Local topologies use the two + // mouths plus a capsule margin; origin-rooted topologies also cover the (0,0) spine. + VoxelForgePlayerFitWindow::ConfigureMouthWindow( + Settings, ArrivalPoint, DeparturePoint, Archetype); return Settings; } @@ -366,7 +361,8 @@ namespace FinePreviewSettings.MaxCells = FinePreviewMaxCells; FVoxelStrateMeasureSettings FineMeasureSettings = VF_MakeFinePlayerFitSettings( - MeasureSettings, FinePreviewSettings, ArrivalPoint, DeparturePoint); + MeasureSettings, FinePreviewSettings, ArrivalPoint, DeparturePoint, + Candidate.Archetype); FVoxelStrateMetrics FineMetrics; const FVoxelConnectivityDiagnostics PlayerFitLaw = VF_DiagnosePlayerFitConnectivityWithSampler( @@ -374,6 +370,8 @@ namespace ArrivalPoint, DeparturePoint, FineMeasureSettings, &FineMetrics); OutSample.FineMetrics = FineMetrics; OutSample.PlayerFitLaw = PlayerFitLaw; + OutSample.FineWindowSummary = VoxelForgePlayerFitWindow::DescribeResolvedWindow( + FineMetrics, FineMeasureSettings.SampleStep, Candidate.Archetype); OutSample.LawText = FString::Printf( TEXT("legacy=%s%s; player_fit=%s%s"), VF_ShowcaseConnectivityName(Law.Result), @@ -948,7 +946,8 @@ namespace FString Report = FString::Printf( TEXT("%s (player-fit fields are fitted SampleStep=1; walkable floor area and " "median vertical clearance are coarse SampleStep=4 proxies)\n" - "archetype | player_fit_fraction_step1 | traversable_share_step1 | " + "archetype | player_fit_window | player_fit_fraction_step1 | " + "traversable_share_step1 | " "fit_cells_step1 | components_step1 | largest_component_cells_step1 | " "arrival_component_cells_step1 | departure_component_cells_step1 | " "mouth_component_gap_voxels_step1 | restricted_law_step1 | " @@ -965,6 +964,7 @@ namespace { Report += FString::Printf( TEXT("%s | no measured sample | no measured sample | no measured sample | " + "no measured sample | " "no measured sample | no measured sample | no measured sample | " "no measured sample | no measured sample | no measured sample | " "n/a | n/a | n/a\n"), @@ -979,9 +979,10 @@ namespace const float FloorAreaM2 = Found->Metrics.WalkableFloorAreaFraction * 128.0f * 128.0f; Report += FString::Printf( - TEXT("%s | %.6f | %.6f | %lld | %d | %lld | %lld | %lld | %.3f | %s | " + TEXT("%s | %s | %.6f | %.6f | %lld | %d | %lld | %lld | %lld | %.3f | %s | " "%.1f (%.6f footprint) | %.2f | %.3f\n"), VF_GetStrateArchetypeName(Archetype), + *Found->FineWindowSummary, M.bPlayerFitResolved ? M.PlayerFitFraction : 0.0f, M.bPlayerFitResolved ? M.TraversableComponentShare : 0.0f, static_cast(M.NumPlayerFitCells), @@ -1042,7 +1043,8 @@ namespace Stack.PrepareChunk(Context); FShowcaseStackSampler Sampler(Stack); const FVoxelStrateMeasureSettings FineSettings = VF_MakeFinePlayerFitSettings( - BaseSettings, FinePreviewSettings, ArrivalPoint, DeparturePoint); + BaseSettings, FinePreviewSettings, ArrivalPoint, DeparturePoint, + MazeSample.Candidate.Archetype); FVoxelStrateMetrics ZeroMetrics; const FVoxelConnectivityDiagnostics ZeroLaw = VF_DiagnosePlayerFitConnectivityWithSampler( @@ -1096,7 +1098,8 @@ namespace } const FVoxelStrateMeasureSettings FineSettings = VF_MakeFinePlayerFitSettings( - BaseSettings, FinePreviewSettings, ArrivalPoint, DeparturePoint); + BaseSettings, FinePreviewSettings, ArrivalPoint, DeparturePoint, + MazeSample.Candidate.Archetype); const float RadiusSweep[] = { 6.0f, 8.0f, 10.0f, 12.0f, 12.5f }; OutReport = TEXT( "Part A Maze corridor-radius sweep (same seed/index, fitted step-1 ROI; " @@ -1889,12 +1892,13 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters) } const FString FineSummary = Sample.FineMetrics.bPlayerFitResolved ? FString::Printf( - TEXT("fit_step1=%.6f traversable_step1=%.6f fit_cells_step1=%lld " + TEXT("window=%s; fit_step1=%.6f traversable_step1=%.6f fit_cells_step1=%lld " "components_step1=%d largest_component_cells_step1=%lld " "arrival_component_cells_step1=%lld departure_component_cells_step1=%lld " "mouth_component_gap_step1=%.3f min_player_clearance_step1=%.2f " "restricted_law_step1=%s; coarse_step4_walkable=%.6f " "coarse_step4_clearance=%d voxels"), + *Sample.FineWindowSummary, Sample.FineMetrics.PlayerFitFraction, Sample.FineMetrics.TraversableComponentShare, static_cast(Sample.FineMetrics.NumPlayerFitCells), @@ -1908,8 +1912,9 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters) Sample.Metrics.WalkableFraction, Sample.Metrics.MedianVerticalClearance) : FString::Printf( - TEXT("fit_step1=UNRESOLVED reason=%s; coarse_step4_walkable=%.6f " + TEXT("window=%s; fit_step1=UNRESOLVED reason=%s; coarse_step4_walkable=%.6f " "coarse_step4_clearance=%d voxels"), + *Sample.FineWindowSummary, Sample.FineMetrics.PlayerFitRefusalReason.IsEmpty() ? TEXT("not measured") : *Sample.FineMetrics.PlayerFitRefusalReason, Sample.Metrics.WalkableFraction, @@ -2114,7 +2119,8 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters) World, Selected.TargetStrateIndex, FineArrivalPoint, FineDeparturePoint)); const FVoxelStrateMeasureSettings FineMeasureSettings = VF_MakeFinePlayerFitSettings( - MeasureSettings, FinePreviewSettings, FineArrivalPoint, FineDeparturePoint); + MeasureSettings, FinePreviewSettings, FineArrivalPoint, FineDeparturePoint, + Candidate.Archetype); FVoxelStrateSampleGrid FineGrid; const FVoxelStrateMetrics FineMetrics = VF_MeasureStrateWithSampler( Sampler, Selected.BottomVoxelZ, Selected.TopVoxelZ + 1, Context.EdgeSealThickness, @@ -2224,8 +2230,10 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters) { const FShowcaseSample& Selected = PreviewSelections[ArchetypeIndex]; Summaries[ArchetypeIndex].Window = PreviewCandidates[ArchetypeIndex].Window; - Summaries[ArchetypeIndex].WindowSummary = VF_FormatShowcaseSummaryWindow( - Stats[ArchetypeIndex]); + Summaries[ArchetypeIndex].WindowSummary = FString::Printf( + TEXT("coarse_window=%s; player_fit_window=%s"), + *VF_FormatShowcaseSummaryWindow(Stats[ArchetypeIndex]), + *Selected.FineWindowSummary); Summaries[ArchetypeIndex].PlayerFitFractionSummary = FString::Printf( TEXT("%.6f (step=1)"), Selected.FineMetrics.PlayerFitFraction); Summaries[ArchetypeIndex].TraversableComponentShareSummary = FString::Printf( @@ -2239,7 +2247,7 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters) "components_step1=%d, largest_component_cells_step1=%lld, " "arrival_component_cells_step1=%lld, departure_component_cells_step1=%lld, " "mouth_component_gap_voxels_step1=%.3f, player_clearance_step1=%.2f voxels, " - "restricted_law_step1=%s, window=%s"), + "restricted_law_step1=%s, coarse_window=%s, player_fit_window=%s"), VF_GetStrateArchetypeName(GShowcaseArchetypes[ArchetypeIndex]), Stats[ArchetypeIndex].Survivors.Num(), Selected.Candidate.Seed, Selected.Candidate.Index, @@ -2258,7 +2266,8 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters) Selected.PlayerFitLaw.StartToGoalComponentDistanceCells, Selected.FineMetrics.MinimumPlayerClearanceVoxels, VF_ShowcaseConnectivityName(Selected.PlayerFitLaw.Result), - *PreviewCandidates[ArchetypeIndex].Window.Describe())); + *PreviewCandidates[ArchetypeIndex].Window.Describe(), + *Selected.FineWindowSummary)); } FString IndexPath; diff --git a/Source/VoxelForge/Private/Tests/VoxelForgePlayerFitWindow.h b/Source/VoxelForge/Private/Tests/VoxelForgePlayerFitWindow.h new file mode 100644 index 0000000..8d39e77 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgePlayerFitWindow.h @@ -0,0 +1,101 @@ +// Shared measurement-window policy for player-fit diagnostics. +// +// This is deliberately test/editor-side policy. The density generators do not know which two +// points a measurement is asking about, so the caller that knows the archetype must declare +// whether its topology can require the origin spine. + +#pragma once + +#if WITH_DEV_AUTOMATION_TESTS + +#include "CoreMinimal.h" +#include "VoxelStrateMeasure.h" +#include "VoxelStrateTypes.h" + +namespace VoxelForgePlayerFitWindow +{ + /** + * The four families below have an origin-rooted connectivity implication: + * + * - Maze: every lattice parent chain terminates at (0,0,0). + * - VerticalShafts: every drainage-tree parent chain terminates at the origin spine. + * - TunnelNetwork/Underwater: their origin-flowing room graph terminates at the origin hub. + * + * Slabs, SurfaceWorld, and FloatingIslands have local/independent interior topology for + * this law. The global structural spine is still generated for them, but it is not the + * topology that their mouth-to-mouth fit law promises to follow. + */ + inline bool NeedsOriginInWindow(ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::Maze: + case ECaveGeneratorType::VerticalShafts: + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return true; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + case ECaveGeneratorType::SurfaceWorld: + case ECaveGeneratorType::FloatingIslands: + default: + return false; + } + } + + inline const TCHAR* RouteWindowName(ECaveGeneratorType Archetype) + { + return NeedsOriginInWindow(Archetype) + ? TEXT("origin-inclusive mouth-AABB + margin") + : TEXT("mouth-AABB + margin"); + } + + inline FString DescribeResolvedWindow( + const FVoxelStrateMetrics& Metrics, + int32 SampleStep, + ECaveGeneratorType Archetype) + { + if (!Metrics.bValid) + { + return FString::Printf( + TEXT("%s step=%d REFUSED: %s"), + RouteWindowName(Archetype), + SampleStep, + Metrics.RefusalReason.IsEmpty() ? TEXT("unknown") : *Metrics.RefusalReason); + } + return FString::Printf( + TEXT("%s step=%d X[%.1f,%.1f) Y[%.1f,%.1f) Z[%d,%d) " + "grid=%dx%dx%d cells=%lld"), + RouteWindowName(Archetype), + SampleStep, + Metrics.SampledMinX, + Metrics.SampledMaxX, + Metrics.SampledMinY, + Metrics.SampledMaxY, + Metrics.SampledMinZ, + Metrics.SampledMaxZ, + Metrics.SampledNumX, + Metrics.SampledNumY, + Metrics.SampledNumZ, + static_cast(Metrics.NumSampled)); + } + + /** Configure the exact fitted XY window used by the player-fit law. */ + inline void ConfigureMouthWindow( + FVoxelStrateMeasureSettings& InOutSettings, + const FVector& ArrivalPoint, + const FVector& DeparturePoint, + ECaveGeneratorType Archetype) + { + InOutSettings.CenterXY = FVector2D( + 0.5f * (ArrivalPoint.X + DeparturePoint.X), + 0.5f * (ArrivalPoint.Y + DeparturePoint.Y)); + InOutSettings.CoverPointA = FVector2D(ArrivalPoint.X, ArrivalPoint.Y); + InOutSettings.CoverPointB = FVector2D(DeparturePoint.X, DeparturePoint.Y); + InOutSettings.CoverMarginVoxels = FMath::CeilToFloat( + InOutSettings.PlayerCapsuleRadiusVoxels) + 2.0f; + InOutSettings.bIncludeOriginInCoverWindow = NeedsOriginInWindow(Archetype); + } +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp index 97e77f3..0326fd2 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp @@ -13,6 +13,7 @@ #include "VoxelForgeTestFixture.h" #include "VoxelStrateComposer.h" #include "VoxelStrateMeasure.h" +#include "VoxelForgePlayerFitWindow.h" #include "VoxelTypes.h" namespace @@ -703,17 +704,99 @@ namespace } } + int32 VF_MaxCenteredRadiusForCap( + int32 TopVoxelZ, + int32 BottomVoxelZ, + float BoundarySealThickness, + const FVoxelStrateMeasureSettings& Settings) + { + const int64 StrateHeight = static_cast(TopVoxelZ) + 1 + - static_cast(BottomVoxelZ); + const int64 MaxMargin64 = FMath::Max(1, StrateHeight / 4); + const int32 MaxMargin = static_cast(FMath::Min( + MaxMargin64, static_cast(INT32_MAX))); + int32 RequestedMargin = Settings.InteriorMarginVoxels; + if (RequestedMargin < 0) + { + RequestedMargin = FMath::CeilToInt(2.0f * BoundarySealThickness); + } + const int32 ResolvedMargin = FMath::Clamp(RequestedMargin, 0, MaxMargin); + const int64 InteriorHeight = StrateHeight - 2 * static_cast(ResolvedMargin); + if (InteriorHeight <= 0 || Settings.SampleStep <= 0 || Settings.MaxCells <= 0) + { + return 0; + } + + const int64 NumZ = (InteriorHeight + Settings.SampleStep - 1) + / static_cast(Settings.SampleStep); + const int64 MaxAxisCells = FMath::Min( + static_cast(INT32_MAX), + static_cast(FMath::Sqrt( + static_cast(Settings.MaxCells / FMath::Max(NumZ, 1))))); + int32 Radius = static_cast(FMath::Max( + 1, (MaxAxisCells * static_cast(Settings.SampleStep)) / 2)); + auto CellCountForRadius = [Settings, NumZ](int32 CandidateRadius) -> int64 + { + const int64 NumXY = (2 * static_cast(CandidateRadius) + + Settings.SampleStep - 1) + / static_cast(Settings.SampleStep); + if (NumXY <= 0 || NumXY > INT64_MAX / NumXY + || NumXY * NumXY > INT64_MAX / FMath::Max(NumZ, 1)) + { + return INT64_MAX; + } + return NumXY * NumXY * NumZ; + }; + while (Radius > 1 && CellCountForRadius(Radius) > Settings.MaxCells) + { + --Radius; + } + while (Radius < INT32_MAX + && CellCountForRadius(Radius + 1) <= Settings.MaxCells) + { + ++Radius; + } + return Radius; + } + void VF_ReportPlayerFitRow( FAutomationTestBase& Test, const TCHAR* Label, + const FString& WindowLabel, + int32 SampleStep, const FVoxelStrateMetrics& Metrics, const FVoxelConnectivityDiagnostics& Diagnostics) { + if (!Metrics.bValid || !Metrics.bPlayerFitResolved) + { + const FString& Refusal = !Metrics.RefusalReason.IsEmpty() + ? Metrics.RefusalReason : Metrics.PlayerFitRefusalReason; + Test.AddInfo(FString::Printf( + TEXT("PLAYER_FIT step=%d window=%s %s: REFUSED reason=%s"), + SampleStep, + *WindowLabel, + Label, + Refusal.IsEmpty() ? TEXT("player-fit metrics unresolved") : *Refusal)); + return; + } Test.AddInfo(FString::Printf( - TEXT("PLAYER_FIT step=1 fitted-window %s: fit_cells=%lld components=%d " + TEXT("PLAYER_FIT step=%d window=%s bounds=X[%.1f,%.1f) Y[%.1f,%.1f) " + "Z[%d,%d) grid=%dx%dx%d sampled=%lld %s: fit_cells=%lld components=%d " "largest_component_cells=%lld arrival_component_cells=%lld " "departure_component_cells=%lld mouth_component_gap_voxels=%.3f " "fit_fraction=%.9f traversable_share=%.9f law=%s"), + SampleStep, + *WindowLabel, + Metrics.SampledMinX, + Metrics.SampledMaxX, + Metrics.SampledMinY, + Metrics.SampledMaxY, + Metrics.SampledMinZ, + Metrics.SampledMaxZ, + Metrics.SampledNumX, + Metrics.SampledNumY, + Metrics.SampledNumZ, + static_cast(Metrics.NumSampled), Label, static_cast(Metrics.NumPlayerFitCells), Metrics.NumTraversableComponents, @@ -1016,7 +1099,115 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) ControlSettings.PlayerWalkableFloorAngleDegrees, ControlSettings.HeadroomCells)); - // H3 — the eight fixed showcase identities, measured at step 1 on a mouth-fitted window. + // PART A — direct route-window experiment on the requested Maze identity. The first row is + // intentionally the old mouth-only box; the second turns on the topology-aware origin + // coverage; the third uses the largest centered origin window that the same MaxCells cap can + // hold while retaining enough resolution to contain both mouths. + { + const int32 PartAMazeSlot = FTestWorld::SlotMaze; + VF_SetCandidateOnSlot(World, MazeCandidate, PartAMazeSlot); + FVector ArrivalPoint = FVector::ZeroVector; + FVector DeparturePoint = FVector::ZeroVector; + int32 ArrivalCount = 0; + int32 DepartureCount = 0; + for (const FVoxelPassage& Passage : World.StrateManager->GetPassages()) + { + if (Passage.LowerStrateIndex == PartAMazeSlot + && Passage.UpperStrateIndex + 1 == PartAMazeSlot) + { + ArrivalPoint = Passage.LowerPoint; + ++ArrivalCount; + } + if (Passage.UpperStrateIndex == PartAMazeSlot + && Passage.LowerStrateIndex == PartAMazeSlot + 1) + { + DeparturePoint = Passage.UpperPoint; + ++DepartureCount; + } + } + + TestEqual(TEXT("PART A Maze has one arrival mouth"), ArrivalCount, 1); + TestEqual(TEXT("PART A Maze has one departure mouth"), DepartureCount, 1); + int32 MazeTopVoxelZ = 0; + int32 MazeBottomVoxelZ = 0; + TestTrue(TEXT("PART A Maze has a vertical measurement range"), + World.GetSlotVoxelZRange(PartAMazeSlot, MazeTopVoxelZ, MazeBottomVoxelZ)); + if (ArrivalCount == 1 && DepartureCount == 1 + && World.GetSlotVoxelZRange(PartAMazeSlot, MazeTopVoxelZ, MazeBottomVoxelZ)) + { + FVoxelStrateMeasureSettings PartABase = CharacterFitSettings; + PartABase.SampleStep = 1; + PartABase.RadiusInVoxels = 64; + PartABase.MaxCells = FineDiagnosisMaxCells; + PartABase.MaxRouteRetries = 16; + PartABase.HeadroomCells = 2; + PartABase.InteriorMarginVoxels = -1; + + FGeneratorSampler GeneratorSampler(*World.Generator); + auto RunPartAWindow = + [&](const FString& WindowLabel, const FVoxelStrateMeasureSettings& Settings) + { + FVoxelStrateMetrics Metrics; + const FVoxelConnectivityDiagnostics Diagnostics = + VF_DiagnosePlayerFitConnectivityWithSampler( + GeneratorSampler, MazeBottomVoxelZ, MazeTopVoxelZ + 1, + VF_BoundarySealForCandidate(MazeCandidate), + ArrivalPoint, DeparturePoint, Settings, &Metrics); + VF_ReportPlayerFitRow( + *this, TEXT("Maze seed=0 index=50 slot=4"), WindowLabel, + Settings.SampleStep, Metrics, Diagnostics); + return TTuple( + MoveTemp(Metrics), Diagnostics); + }; + + FVoxelStrateMeasureSettings CurrentSettings = PartABase; + VoxelForgePlayerFitWindow::ConfigureMouthWindow( + CurrentSettings, ArrivalPoint, DeparturePoint, ECaveGeneratorType::Maze); + CurrentSettings.bIncludeOriginInCoverWindow = false; + const auto CurrentResult = RunPartAWindow( + TEXT("current mouth-AABB + margin"), CurrentSettings); + + FVoxelStrateMeasureSettings OriginSettings = PartABase; + VoxelForgePlayerFitWindow::ConfigureMouthWindow( + OriginSettings, ArrivalPoint, DeparturePoint, ECaveGeneratorType::Maze); + const auto OriginResult = RunPartAWindow( + TEXT("origin-inclusive mouth-AABB + margin"), OriginSettings); + + FVoxelStrateMeasureSettings WholeSettings = PartABase; + WholeSettings.MaxCells = FineDiagnosisMaxCells * 8; + WholeSettings.CenterXY = FVector2D::ZeroVector; + WholeSettings.CoverPointA.Reset(); + WholeSettings.CoverPointB.Reset(); + WholeSettings.bIncludeOriginInCoverWindow = false; + const float MouthMargin = FMath::CeilToFloat( + WholeSettings.PlayerCapsuleRadiusVoxels) + 2.0f; + const int32 MouthReach = FMath::CeilToInt(FMath::Max( + FMath::Max(FMath::Abs(ArrivalPoint.X), FMath::Abs(DeparturePoint.X)), + FMath::Max(FMath::Abs(ArrivalPoint.Y), FMath::Abs(DeparturePoint.Y))) + + MouthMargin); + WholeSettings.SampleStep = 1; + WholeSettings.RadiusInVoxels = VF_MaxCenteredRadiusForCap( + MazeTopVoxelZ, MazeBottomVoxelZ, + VF_BoundarySealForCandidate(MazeCandidate), WholeSettings); + const FString WholeWindowLabel = FString::Printf( + TEXT("whole-strate centered origin cap window (radius=%d; mouth_reach=%d; " + "MaxCells=%d)"), + WholeSettings.RadiusInVoxels, MouthReach, WholeSettings.MaxCells); + const auto WholeResult = RunPartAWindow(WholeWindowLabel, WholeSettings); + + AddInfo(FString::Printf( + TEXT("PART_A verdict: current=%s; origin-inclusive=%s; whole-strate-cap=%s; " + "origin flip=%s"), + VF_DiagnosisConnectivityName(CurrentResult.Get<1>().Result), + VF_DiagnosisConnectivityName(OriginResult.Get<1>().Result), + VF_DiagnosisConnectivityName(WholeResult.Get<1>().Result), + OriginResult.Get<1>().Result == EVoxelConnectivityResult::Connected + ? TEXT("CONFIRMED") : TEXT("NOT_CONFIRMED"))); + } + } + + // H3 — the eight fixed showcase identities, measured at step 1 on the topology-aware fitted + // window. Origin-rooted families include (0,0) before the MaxCells refusal check. // The Maze identity is intentionally the requested seed 0 / index 50 / fourth one-based slot. struct FShowcaseSelection { @@ -1065,6 +1256,7 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) int32 NumH3Connected = 0; int32 NumH3DegenerateConnected = 0; int32 NumH3Measured = 0; + int32 NumH3Refused = 0; for (const FShowcaseSelection& Selection : Selections) { @@ -1109,13 +1301,8 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) FVoxelStrateMeasureSettings Settings = CharacterFitSettings; Settings.SampleStep = 1; Settings.RadiusInVoxels = 64; - 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; + VoxelForgePlayerFitWindow::ConfigureMouthWindow( + Settings, ArrivalPoint, DeparturePoint, Selection.Archetype); Settings.MaxCells = FineDiagnosisMaxCells; Settings.MaxRouteRetries = 16; Settings.HeadroomCells = 2; @@ -1136,7 +1323,22 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) GeneratorSampler, BottomVoxelZ, TopVoxelZ + 1, VF_BoundarySealForCandidate(Candidate), ArrivalPoint, DeparturePoint, Settings, &Metrics); - VF_ReportPlayerFitRow(*this, Selection.Label, Metrics, Diagnostics); + VF_ReportPlayerFitRow( + *this, Selection.Label, + VoxelForgePlayerFitWindow::RouteWindowName(Selection.Archetype), + Settings.SampleStep, Metrics, Diagnostics); + if (!Metrics.bValid || !Metrics.bPlayerFitResolved) + { + ++NumH3Refused; + const FString& Refusal = !Metrics.RefusalReason.IsEmpty() + ? Metrics.RefusalReason : Metrics.PlayerFitRefusalReason; + AddInfo(FString::Printf( + TEXT("H3 %s law=REFUSED; window=%s; reason=%s"), + Selection.Label, + VoxelForgePlayerFitWindow::RouteWindowName(Selection.Archetype), + Refusal.IsEmpty() ? TEXT("player-fit metrics unresolved") : *Refusal)); + continue; + } ++NumH3Measured; if (Diagnostics.Result == EVoxelConnectivityResult::Connected) { @@ -1182,11 +1384,12 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) AddInfo(FString::Printf( TEXT("LAW_TALLY restricted step1: before_connected=1/8 " "before_connected_through_4_cells=1; after_connected=%d/%d " - "after_connected_through_4_cells=%d measured=%d/8"), + "after_connected_through_4_cells=%d measured=%d/8 refused=%d/8"), NumH3Connected, - UE_ARRAY_COUNT(Selections), + NumH3Measured, NumH3DegenerateConnected, - NumH3Measured)); + NumH3Measured, + NumH3Refused)); return true; } diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp index d2a8309..920bbcb 100644 --- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -202,6 +202,17 @@ namespace VoxelStrateMeasurePrivate MaxX64 = FMath::Max(static_cast(PointA.X), static_cast(PointB.X)) + Margin; MinY64 = FMath::Min(static_cast(PointA.Y), static_cast(PointB.Y)) - Margin; MaxY64 = FMath::Max(static_cast(PointA.Y), static_cast(PointB.Y)) + Margin; + if (Settings.bIncludeOriginInCoverWindow) + { + // The fitted box is a route-coverage contract, not merely an endpoint box. An + // origin-rooted graph may have its only mouth-to-mouth path through this spine. + // Expand the requested bounds before the MaxCells check below; never crop the + // route after discovering that the full box is too large. + MinX64 = FMath::Min(MinX64, 0.0); + MaxX64 = FMath::Max(MaxX64, 0.0); + MinY64 = FMath::Min(MinY64, 0.0); + MaxY64 = FMath::Max(MaxY64, 0.0); + } WindowCenter = FVector2D( static_cast(0.5 * (MinX64 + MaxX64)), static_cast(0.5 * (MinY64 + MaxY64))); @@ -2169,6 +2180,7 @@ namespace VoxelStrateMeasurePrivate } if (OutPlayerMetrics != nullptr) { + OutPlayerMetrics->RefusalReason = Reason; OutPlayerMetrics->PlayerFitRefusalReason = Reason; } }; @@ -2216,6 +2228,7 @@ namespace VoxelStrateMeasurePrivate } if (OutPlayerMetrics != nullptr) { + OutPlayerMetrics->RefusalReason = RefusalReason; OutPlayerMetrics->PlayerFitRefusalReason = RefusalReason; } return EVoxelConnectivityResult::OutOfWindow; diff --git a/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Source/VoxelForge/Public/VoxelStrateMeasure.h index dd1e9d0..d1ea0bb 100644 --- a/Source/VoxelForge/Public/VoxelStrateMeasure.h +++ b/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -68,6 +68,11 @@ struct VOXELFORGE_API FVoxelStrateMeasureSettings TOptional CoverPointA; TOptional CoverPointB; float CoverMarginVoxels = 48.0f; + // Origin-rooted topologies can route from one mouth to the other through the (0,0) spine. + // When true, include (0,0) in the fitted AABB before applying MaxCells. A caller must keep + // this enabled for that topology; an over-cap route window is then refused honestly instead + // of being cropped back to a mouth-only box. + bool bIncludeOriginInCoverWindow = false; int32 MaxCells = 8000000; // Refuse a grid larger than this many cells. int32 MaxRouteRetries = 16; // Alternate coarse routes to full-resolution-check after the first route. int32 HeadroomCells = 2; // Legacy walkable-column metric only.