tooling(test): one command to test VoxelForge, with a commandlet-vs-game parity check

Tools/VoxelForgeTest.ps1 runs scenarios (canonical, owner, probe, perf, tests, parity) with the known-good flag sets, isolated user/config dirs, serialized launches, an owner-process guard, and a compact result.json + summary.txt instead of 300 MB logs that Codex has to stream. Answers the owner's question 'can the commandlet reflect in-game behaviour': -gameconfig=1 makes the commandlet use the authored settings, and the parity scenario compares per-tile geometry hashes between the commandlet export and the game's streaming path. Parity PASS: 64 commandlet tiles vs 810 game records, no differing tile.

First run of the tests scenario: 44 tests, 28 pass, 16 fail (8 composer, 6 op-stack equivalence, PassageLandsInOpenSpace, StrateParamBlendCoverage). Triage is the next round: stale expectations from the floor removal and the degrees change, versus real defects, especially the legacy-vs-op-stack equivalence ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-18 05:13:16 +02:00
1 parent 1c642fd182
commit a7b8d10de6
10 files changed
+1484 -52

No files matched your search

+5 -1
View File
@@ -9,7 +9,11 @@
!/Source/ !/Source/
!/Source/** !/Source/**
# The guarded single-entry test harness is source, not generated output.
!/Tools/
!/Tools/**
!VoxelForge.uplugin !VoxelForge.uplugin
# Keep every design / doc markdown at any depth (AUDIT P1 — these were untracked) # Keep every design / doc markdown at any depth (AUDIT P1 — these were untracked)
!*.md !*.md
+7 -1
View File
@@ -114,8 +114,10 @@ remains `0`, and lateral regions remain gated off.
|------|------| |------|------|
| `../../VoxelForge.uplugin` | Plugin manifest. Runtime module `VoxelForge` plus the Editor-only `VoxelForgeEditor` commandlet module. Beta. | | `../../VoxelForge.uplugin` | Plugin manifest. Runtime module `VoxelForge` plus the Editor-only `VoxelForgeEditor` commandlet module. Beta. |
| `VoxelForge.Build.cs` | Deps: Core, CoreUObject, Engine, **GameplayTags**, **RealtimeMeshComponent**. | | `VoxelForge.Build.cs` | Deps: Core, CoreUObject, Engine, **GameplayTags**, **RealtimeMeshComponent**. |
| `Tools/VoxelForgeTest.ps1` | The single guarded test entry point. `-Scenario canonical|owner|probe|perf|tests|parity`, optional `-Build`, `-Assets owner|default`, `-Cvars`, absolute `-Out`, and `-Label`; stages the host/assets, isolates user/DDC/config state, serializes Unreal launches, and emits compact `result.json`/`summary.txt` plus full logs. |
| `Public/VoxelForgeModule.h` / `Private/VoxelForgeModule.cpp` | `FVoxelForgeModule` boilerplate (Startup/Shutdown just log). | | `Public/VoxelForgeModule.h` / `Private/VoxelForgeModule.cpp` | `FVoxelForgeModule` boilerplate (Startup/Shutdown just log). |
| `Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs` / `Private/VoxelForgeExploreCommandlet.*` | Editor/commandlet-only `-run=VoxelForgeExplore`; owns bounded render/`VF_` walk/OBJ export orchestration and is not linked by Game/Shipping. | | `Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs` / `Private/VoxelForgeExploreCommandlet.*` | Editor/commandlet-only `-run=VoxelForgeExplore`; owns bounded render/`VF_` walk/OBJ export orchestration and is not linked by Game/Shipping. |
| `Public/VoxelGeometryHash.h` | Header-only geometry digest shared by commandlet export and runtime tile-hash dump; hashes mesh counts and the vertex/normal/UV/color/triangle arrays with the historical CRC order. |
| `Public/VoxelStats.h` / `Private/VoxelStats.cpp` | `stat VoxelForge` DWORD counters for tile classification, skipping, meshing, operator-stack verdicts, and cave-bail diagnosis. The former ambiguous `Cave Bail Not Op Stack` is split into `Sole Slot`, `Boundary Tile`, `No Layout`, and late `Recheck` counters, so each increment names one guard/context. | | `Public/VoxelStats.h` / `Private/VoxelStats.cpp` | `stat VoxelForge` DWORD counters for tile classification, skipping, meshing, operator-stack verdicts, and cave-bail diagnosis. The former ambiguous `Cave Bail Not Op Stack` is split into `Sole Slot`, `Boundary Tile`, `No Layout`, and late `Recheck` counters, so each increment names one guard/context. |
### 3.2 Foundational types — `Public/VoxelTypes.h` (no UClass, everyone includes it) ### 3.2 Foundational types — `Public/VoxelTypes.h` (no UClass, everyone includes it)
@@ -306,6 +308,7 @@ casse cette arithmetique *structurellement* — `BeginPlay` loggue une **Error**
| `FVoxelStackSampler` / `FScopedVoxelStackRegistration` (`VoxelStackSampler.h/.cpp`) | — | **Diagnostic raw stack sampler, off by default.** A sampler thread captures registered generation workers' stacks (`CaptureThreadStackBackTrace`) every N µs and tags each with the tile's LOD. It writes bounded raw PCs to CSV plus a symbolization-free summary under `Saved/`; symbol resolution is deferred until after process exit so the measured process never initializes/calls DbgHelp for this diagnostic. Lifecycle teardown is serialized and registration TLS is session-tagged. Registration is per tile TASK in `LoadTile` (and the export's tasks), never per sample; disabled costs one relaxed atomic load. Game `-voxel.SampleStacks=<us>`, commandlet `-samplestacks[=us]`. | | `FVoxelStackSampler` / `FScopedVoxelStackRegistration` (`VoxelStackSampler.h/.cpp`) | — | **Diagnostic raw stack sampler, off by default.** A sampler thread captures registered generation workers' stacks (`CaptureThreadStackBackTrace`) every N µs and tags each with the tile's LOD. It writes bounded raw PCs to CSV plus a symbolization-free summary under `Saved/`; symbol resolution is deferred until after process exit so the measured process never initializes/calls DbgHelp for this diagnostic. Lifecycle teardown is serialized and registration TLS is session-tagged. Registration is per tile TASK in `LoadTile` (and the export's tasks), never per sample; disabled costs one relaxed atomic load. Game `-voxel.SampleStacks=<us>`, commandlet `-samplestacks[=us]`. |
| Room-cache performance (2026-09-13): `VF_FindPlayerFitPointForRoomMemoized` / `GPlayerFitMemo` (`VoxelCaveMorphology.cpp`), `voxel.TileCacheWindow` + `VoxelGenLOD::TileOriginVoxels/TileCellsPerAxis` (`VoxelGenerator.h`), `FChunkSDFSpatialIndex` | — | **Player-fit memo:** exact 97-word key, process-wide, `voxel.PlayerFitMemo`. **Tile cache window:** the room cache covers the requesting tile's footprint plus a fixed collect margin. It is set via `TGuardValue` in `GenerateMesh`/classify, and is window-invariant by construction (the margin does not grow with the tile). **LOD-gated:** fused path at LOD ≥ `voxel.TileCacheWindowMinLOD` (3), op-stack at LOD ≥ `voxel.TileCacheWindowOpStackMinLOD` (4); lower LODs keep the 4-chunk worker window, because the tile window slowed LOD0 evaluation ~6%. Strates whose authored terrain ops exceed the proven 256-voxel extent (or are malformed/unloaded) fall back to the legacy window (`VF_ComputeTerrainOpEnvelope`). `voxel.SpatialIndex` is -1 auto (off at fused LOD0, on elsewhere), 0 or 1. **Spatial index:** ordered XY buckets over cache primitives, exact rejection. The `static_assert` in `VoxelStrateTypes.h` ties `VF_STRATE_PARAM_FIELDS` to `FStrateGenerationParams` (83 fields + 8 B padding). | | Room-cache performance (2026-09-13): `VF_FindPlayerFitPointForRoomMemoized` / `GPlayerFitMemo` (`VoxelCaveMorphology.cpp`), `voxel.TileCacheWindow` + `VoxelGenLOD::TileOriginVoxels/TileCellsPerAxis` (`VoxelGenerator.h`), `FChunkSDFSpatialIndex` | — | **Player-fit memo:** exact 97-word key, process-wide, `voxel.PlayerFitMemo`. **Tile cache window:** the room cache covers the requesting tile's footprint plus a fixed collect margin. It is set via `TGuardValue` in `GenerateMesh`/classify, and is window-invariant by construction (the margin does not grow with the tile). **LOD-gated:** fused path at LOD ≥ `voxel.TileCacheWindowMinLOD` (3), op-stack at LOD ≥ `voxel.TileCacheWindowOpStackMinLOD` (4); lower LODs keep the 4-chunk worker window, because the tile window slowed LOD0 evaluation ~6%. Strates whose authored terrain ops exceed the proven 256-voxel extent (or are malformed/unloaded) fall back to the legacy window (`VF_ComputeTerrainOpEnvelope`). `voxel.SpatialIndex` is -1 auto (off at fused LOD0, on elsewhere), 0 or 1. **Spatial index:** ordered XY buckets over cache primitives, exact rejection. The `static_assert` in `VoxelStrateTypes.h` ties `VF_STRATE_PARAM_FIELDS` to `FStrateGenerationParams` (83 fields + 8 B padding). |
| `GenerateTileResult` | — | **Shared worker-side gen** for one tile (async `LoadTile` task + sync `SyncRemeshTile`): [ClassifyTile (T1.d) + exact validation, only when `voxel.OuterClassifierMode=1`; **default 0 since 2026-09-13**] → `GenerateMesh`/`GenerateSheetMesh` → `BuildTileStreamSet`. Reads Generator/Mesher only → safe on a worker or the game thread; fills `FChunkResult`, no enqueue. | | `GenerateTileResult` | — | **Shared worker-side gen** for one tile (async `LoadTile` task + sync `SyncRemeshTile`): [ClassifyTile (T1.d) + exact validation, only when `voxel.OuterClassifierMode=1`; **default 0 since 2026-09-13**] → `GenerateMesh`/`GenerateSheetMesh` → `BuildTileStreamSet`. Reads Generator/Mesher only → safe on a worker or the game thread; fills `FChunkResult`, no enqueue. |
| `RecordTileHash` / `WriteTileHashDump` | — | Headless `-voxel.TileHashDump=<absolute path>` parity instrument. Records the exact `FVoxelMeshData` used by the streaming result, including empty/classified tiles, then writes sorted absolute tile keys, LOD step/cells, content-band limits, settings digest context, and the shared `VoxelGeometryHash` at `EndPlay`. |
| `SyncRemeshTile` | — | **INSTANT DIG**: level-0 same-frame re-mesh on the game thread (`GenerateTileResult` + `ApplyTileResult` inline, Cells=CHUNK_SIZE/Step=1 + strate band, no capture). Used for the tile under the brush centre so a carve is visible THIS frame; one full-res gen on the game thread. | | `SyncRemeshTile` | — | **INSTANT DIG**: level-0 same-frame re-mesh on the game thread (`GenerateTileResult` + `ApplyTileResult` inline, Cells=CHUNK_SIZE/Step=1 + strate band, no capture). Used for the tile under the brush centre so a carve is visible THIS frame; one full-res gen on the game thread. |
| `UpdateChunksAroundPosition` | 362 | Builds desired set, cancels pending keys that left it, sorts from the **true pawn position**, submits the occupied/support LOD0 tile and the next tile along the pawn heading as an absolute `BackgroundHigh` prefix, then loads/unloads and handles LOD changes. **Delta cull**: `BuildDesiredTiles` returns the LEAVERS (stamped `DesiredStamped` map, one sweep) — only those + `TransitionHold` are considered per crossing, not every loaded tile. `BuildDesiredTiles` also applies `RenderDistanceChunks`: the outermost shell widens to cover the distance — as level-MaxClipLevel MC tiles, or (F18 `bFarSheetRing`) as a SHEET ring at level MaxClipLevel+span (covered-check vs the MaxClipLevel box; `VF_OuterShell` shared with `IsTileInClipRange` so the cull sees the same horizon), dz pre-clamped to the vertical band. **§9.3 anchors:** also prunes dead `StreamingAnchors` + detects their chunk crossings → rebuilds the desired set when an anchor moves/(un)registers (`bAnchorsMoved`/`bForceDesiredRebuild`), same cadence as player movement. §8.10. | | `UpdateChunksAroundPosition` | 362 | Builds desired set, cancels pending keys that left it, sorts from the **true pawn position**, submits the occupied/support LOD0 tile and the next tile along the pawn heading as an absolute `BackgroundHigh` prefix, then loads/unloads and handles LOD changes. **Delta cull**: `BuildDesiredTiles` returns the LEAVERS (stamped `DesiredStamped` map, one sweep) — only those + `TransitionHold` are considered per crossing, not every loaded tile. `BuildDesiredTiles` also applies `RenderDistanceChunks`: the outermost shell widens to cover the distance — as level-MaxClipLevel MC tiles, or (F18 `bFarSheetRing`) as a SHEET ring at level MaxClipLevel+span (covered-check vs the MaxClipLevel box; `VF_OuterShell` shared with `IsTileInClipRange` so the cull sees the same horizon), dz pre-clamped to the vertical band. **§9.3 anchors:** also prunes dead `StreamingAnchors` + detects their chunk crossings → rebuilds the desired set when an anchor moves/(un)registers (`bAnchorsMoved`/`bForceDesiredRebuild`), same cadence as player movement. §8.10. |
| `BuildDesiredTiles` | ~722 | Builds `DesiredSorted`+`DesiredStamped` (player clipmap shells + F18 sheet ring) then `AddAnchorDesiredTiles()` before the leaver sweep; it orders from the true local pawn position and builds `CriticalDesiredTiles` for the occupied/support and heading tiles. | | `BuildDesiredTiles` | ~722 | Builds `DesiredSorted`+`DesiredStamped` (player clipmap shells + F18 sheet ring) then `AddAnchorDesiredTiles()` before the leaver sweep; it orders from the true local pawn position and builds `CriticalDesiredTiles` for the occupied/support and heading tiles. |
@@ -423,7 +426,10 @@ uses that same canonical aggregate for metre-space OBJ plus `manifest.json` (can
voxel-space). Output JSON/manifest are emitted by fixed-order writers and compared with a second voxel-space). Output JSON/manifest are emitted by fixed-order writers and compared with a second
serialization before they are written. The commandlet keeps `WorldRadiusVoxels=0`, does not touch an serialization before they are written. The commandlet keeps `WorldRadiusVoxels=0`, does not touch an
authored asset or diff layer, and enforces the default 25-minute / maximum 30-minute wall-clock authored asset or diff layer, and enforces the default 25-minute / maximum 30-minute wall-clock
budget, reporting partial artifacts and completed modes on truncation. budget, reporting partial artifacts and completed modes on truncation. `-gameconfig=1` opts into the
authored settings' skirts/LOD octave drop and permits export-only parity of a single-strate asset;
the historical interior-slot requirement remains in force for walk/probe runs. `VoxelGeometryHash.h`
is the shared digest used by `Tools/VoxelForgeTest.ps1 -Scenario parity`.
### 3.7 Cave morphology (SDF rooms/tunnels) — `Public/VoxelCaveMorphology.h` + `.cpp` ### 3.7 Cave morphology (SDF rooms/tunnels) — `Public/VoxelCaveMorphology.h` + `.cpp`
Header is rich with inline docs. Two namespaces + a per-chunk cache system. Header is rich with inline docs. Two namespaces + a per-chunk cache system.
+17
View File
@@ -6,6 +6,23 @@ Measurement-only report for the in-process stack sampler, 2026-09-13. No generat
The sampler is useful: it samples registered generation workers without adding scopes to the density hot path, tags every capture with LOD, finds the planted cost, and exposes the game path's dominant work. It is not yet an acceptance-clean low-overhead instrument at the requested default interval. Static timing and worker CPU move beyond the two-run off baseline; moving worker CPU is within noise, but moving request/generation timing is not. The sampler is useful: it samples registered generation workers without adding scopes to the density hot path, tags every capture with LOD, finds the planted cost, and exposes the game path's dominant work. It is not yet an acceptance-clean low-overhead instrument at the requested default interval. Static timing and worker CPU move beyond the two-run off baseline; moving worker CPU is within noise, but moving request/generation timing is not.
## Current command entry point
Use the guarded harness at `E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Tools\VoxelForgeTest.ps1` for new measurements. It owns the staged host under `Saved\BuildHost`, serializes Unreal launches, uses an isolated `-userdir`, Zen/DDC directory, and log directory under the requested run directory, waits by PID, records crash/`UECC-*` evidence, and writes compact `result.json` plus a ≤40-line `summary.txt`; full logs remain beside them.
All paths passed to `-Out` must be absolute and under the plugin's `Saved` directory. The normal entry forms are:
```powershell
pwsh -NoLogo -NoProfile -File "E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Tools\VoxelForgeTest.ps1" -Scenario canonical -Build -Assets default -Label canonical
pwsh -NoLogo -NoProfile -File "E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Tools\VoxelForgeTest.ps1" -Scenario owner -Assets owner -Label owner
pwsh -NoLogo -NoProfile -File "E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Tools\VoxelForgeTest.ps1" -Scenario probe -Assets owner -Label probe
pwsh -NoLogo -NoProfile -File "E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Tools\VoxelForgeTest.ps1" -Scenario perf -Label perf
pwsh -NoLogo -NoProfile -File "E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Tools\VoxelForgeTest.ps1" -Scenario tests -Label tests
pwsh -NoLogo -NoProfile -File "E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Tools\VoxelForgeTest.ps1" -Scenario parity -Build -Assets owner -Label parity
```
Optional controls are `-Cvars @{...}`, `-Out "E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Saved\TestHarness\named"`, and `-Label name`. `-Assets owner` stages `DA_Strate3` and `DA_Settings` and records both SHA-256 values. `parity` exports 64 level-0 tiles through `VoxelForgeExplore -gameconfig=1`, runs the headless game's streaming path with the same owner settings, dumps per-tile hashes, and fails on the first missing or differing tile. Do not invent a direct `UnrealEditor` command line: the harness's launch ledger is the reproducible command record.
The headline game result is that LOD0 is not dominated by candidate AABB rejection. It is dominated by the evaluator and its downstream tunnel/floor/core and structural work. Coarse LOD1+ samples are dominated by room-cache/player-fit/room-landing work. The profile supports the suspected swept-tunnel/core duplication, but does not isolate the parameter-copy cost. The headline game result is that LOD0 is not dominated by candidate AABB rejection. It is dominated by the evaluator and its downstream tunnel/floor/core and structural work. Coarse LOD1+ samples are dominated by room-cache/player-fit/room-landing work. The profile supports the suspected swept-tunnel/core duplication, but does not isolate the parameter-copy cost.
## Instrument and build ## Instrument and build
+135
View File
@@ -14,6 +14,7 @@
#include "VoxelDensityVolume.h" #include "VoxelDensityVolume.h"
#include "VoxelDensityOpStack.h" #include "VoxelDensityOpStack.h"
#include "VoxelDensityProfile.h" #include "VoxelDensityProfile.h"
#include "VoxelGeometryHash.h"
#include "VoxelStartupTrace.h" #include "VoxelStartupTrace.h"
#include "VoxelStackSampler.h" #include "VoxelStackSampler.h"
#include "VoxelStats.h" #include "VoxelStats.h"
@@ -45,8 +46,11 @@
#include "Misc/FileHelper.h" #include "Misc/FileHelper.h"
#include "Misc/CommandLine.h" #include "Misc/CommandLine.h"
#include "Misc/Paths.h" #include "Misc/Paths.h"
#include "Misc/ScopeLock.h"
#include "HAL/IConsoleManager.h" #include "HAL/IConsoleManager.h"
#include "HAL/PlatformMisc.h" #include "HAL/PlatformMisc.h"
#include "Policies/CondensedJsonPrintPolicy.h"
#include "Serialization/JsonWriter.h"
#include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0) #include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0)
#if WITH_EDITOR #if WITH_EDITOR
@@ -1133,6 +1137,7 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason)
} }
VoxelCaveMorphology::LogPlayerFitMemoStats(); VoxelCaveMorphology::LogPlayerFitMemoStats();
LogStreamingLatencySummary(); LogStreamingLatencySummary();
WriteTileHashDump();
const uint64 TilePostReachDifferences = const uint64 TilePostReachDifferences =
VoxelGenLOD::GSkippedPostDifferences.load(std::memory_order_relaxed); VoxelGenLOD::GSkippedPostDifferences.load(std::memory_order_relaxed);
UE_LOG(LogTemp, Display, UE_LOG(LogTemp, Display,
@@ -1693,6 +1698,9 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest()
HeadlessStreamingTestLastActualPosition = FVector::ZeroVector; HeadlessStreamingTestLastActualPosition = FVector::ZeroVector;
HeadlessStreamingTestBeginSeconds = 0.0; HeadlessStreamingTestBeginSeconds = 0.0;
HeadlessStreamingTestLastElapsedSeconds = 0.0; HeadlessStreamingTestLastElapsedSeconds = 0.0;
bTileHashDumpEnabled = false;
TileHashDumpPath.Reset();
TileHashDumpRecords.Reset();
bHeadlessStrateCrossingTest = false; bHeadlessStrateCrossingTest = false;
bHeadlessStrateCrossingTestStartPlaced = false; bHeadlessStrateCrossingTestStartPlaced = false;
@@ -1720,6 +1728,25 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest()
HeadlessStrateCrossingTestRoute.Reset(); HeadlessStrateCrossingTestRoute.Reset();
const TCHAR* CommandLine = FCommandLine::Get(); const TCHAR* CommandLine = FCommandLine::Get();
FString RequestedTileHashDumpPath;
if (FParse::Value(CommandLine, TEXT("voxel.TileHashDump="), RequestedTileHashDumpPath))
{
RequestedTileHashDumpPath.TrimQuotesInline();
if (FPaths::IsRelative(RequestedTileHashDumpPath))
{
UE_LOG(LogTemp, Error,
TEXT("[VoxelForgeParity] voxel.TileHashDump must be an absolute path: '%s'."),
*RequestedTileHashDumpPath);
}
else
{
TileHashDumpPath = FPaths::ConvertRelativePathToFull(RequestedTileHashDumpPath);
FPaths::NormalizeFilename(TileHashDumpPath);
bTileHashDumpEnabled = true;
UE_LOG(LogTemp, Display, TEXT("[VoxelForgeParity] tile_hash_dump_enabled=1 path=%s"),
*TileHashDumpPath);
}
}
int32 MoveValue = 0; int32 MoveValue = 0;
const bool bMoveRequested = const bool bMoveRequested =
(FParse::Value(CommandLine, TEXT("voxel.TestMove="), MoveValue) && MoveValue != 0) (FParse::Value(CommandLine, TEXT("voxel.TestMove="), MoveValue) && MoveValue != 0)
@@ -1849,6 +1876,109 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest()
} }
} }
void AVoxelWorld::RecordTileHash(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels,
int32 Step, int32 Cells, int32 BandChunkLo, int32 BandChunkHi,
bool bSheetTile, const FVoxelMeshData& MeshData)
{
if (!bTileHashDumpEnabled)
{
return;
}
FVoxelTileHashDumpRecord Record;
Record.Tile = Tile;
Record.OriginVoxels = OriginVoxels;
Record.Step = Step;
Record.Cells = Cells;
Record.BandChunkLo = BandChunkLo;
Record.BandChunkHi = BandChunkHi;
Record.bSheetTile = bSheetTile;
Record.bEmpty = MeshData.IsEmpty();
Record.NumTriangles = MeshData.Triangles.Num() / 3;
Record.GeometryHash = VoxelForgeGeometryHash::Compute(MeshData);
FScopeLock Lock(&TileHashDumpMutex);
TileHashDumpRecords.Add(MoveTemp(Record));
}
void AVoxelWorld::WriteTileHashDump()
{
if (!bTileHashDumpEnabled || TileHashDumpPath.IsEmpty())
{
return;
}
TArray<FVoxelTileHashDumpRecord> Records;
{
FScopeLock Lock(&TileHashDumpMutex);
Records = TileHashDumpRecords;
}
Records.Sort([](const FVoxelTileHashDumpRecord& A, const FVoxelTileHashDumpRecord& B)
{
if (A.Tile.Level != B.Tile.Level) return A.Tile.Level < B.Tile.Level;
if (A.Tile.Coord.Z != B.Tile.Coord.Z) return A.Tile.Coord.Z < B.Tile.Coord.Z;
if (A.Tile.Coord.Y != B.Tile.Coord.Y) return A.Tile.Coord.Y < B.Tile.Coord.Y;
if (A.Tile.Coord.X != B.Tile.Coord.X) return A.Tile.Coord.X < B.Tile.Coord.X;
return A.GeometryHash < B.GeometryHash;
});
IFileManager::Get().MakeDirectory(*FPaths::GetPath(TileHashDumpPath), true);
FString Json;
TSharedRef<TJsonWriter<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>> Writer =
TJsonWriterFactory<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>::Create(&Json);
Writer->WriteObjectStart();
Writer->WriteValue(TEXT("schema_version"), 1);
Writer->WriteValue(TEXT("source"), TEXT("game_streaming_path"));
Writer->WriteValue(TEXT("settings_asset"), Settings ? Settings->GetPathName() : FString());
if (Settings != nullptr)
{
Writer->WriteObjectStart(TEXT("runtime_config"));
Writer->WriteValue(TEXT("generate_skirts"), Settings->bGenerateSkirts);
Writer->WriteValue(TEXT("skirt_cells"), static_cast<double>(Settings->SkirtCells));
Writer->WriteValue(TEXT("lod_octave_drop"), Settings->LODOctaveDrop);
Writer->WriteValue(TEXT("full_res_clip_levels"), Settings->FullResClipLevels);
Writer->WriteValue(TEXT("coarse_tile_cells"), Settings->CoarseTileCells);
Writer->WriteValue(TEXT("strate_content_cut_min_level"),
Settings->GetEffectiveStrateContentCutMinLevel());
Writer->WriteObjectEnd();
}
Writer->WriteValue(TEXT("record_count"), Records.Num());
Writer->WriteArrayStart(TEXT("records"));
for (const FVoxelTileHashDumpRecord& Record : Records)
{
Writer->WriteObjectStart();
Writer->WriteValue(TEXT("tile_x"), Record.Tile.Coord.X);
Writer->WriteValue(TEXT("tile_y"), Record.Tile.Coord.Y);
Writer->WriteValue(TEXT("tile_z"), Record.Tile.Coord.Z);
Writer->WriteValue(TEXT("level"), Record.Tile.Level);
Writer->WriteValue(TEXT("origin_x_voxels"), Record.OriginVoxels.X);
Writer->WriteValue(TEXT("origin_y_voxels"), Record.OriginVoxels.Y);
Writer->WriteValue(TEXT("origin_z_voxels"), Record.OriginVoxels.Z);
Writer->WriteValue(TEXT("step"), Record.Step);
Writer->WriteValue(TEXT("cells"), Record.Cells);
Writer->WriteValue(TEXT("band_chunk_lo"), Record.BandChunkLo);
Writer->WriteValue(TEXT("band_chunk_hi"), Record.BandChunkHi);
Writer->WriteValue(TEXT("sheet_tile"), Record.bSheetTile);
Writer->WriteValue(TEXT("empty"), Record.bEmpty);
Writer->WriteValue(TEXT("triangle_count"), Record.NumTriangles);
Writer->WriteValue(TEXT("geometry_hash"), Record.GeometryHash);
Writer->WriteObjectEnd();
}
Writer->WriteArrayEnd();
Writer->WriteObjectEnd();
if (!Writer->Close() || !FFileHelper::SaveStringToFile(Json, *TileHashDumpPath))
{
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeParity] Could not write tile hash dump '%s'."),
*TileHashDumpPath);
}
else
{
UE_LOG(LogTemp, Display,
TEXT("[VoxelForgeParity] tile_hash_dump=%s records=%d"),
*TileHashDumpPath, Records.Num());
}
}
void AVoxelWorld::InitializeHeadlessStrateCrossingTest() void AVoxelWorld::InitializeHeadlessStrateCrossingTest()
{ {
if (!bHeadlessStrateCrossingTest) if (!bHeadlessStrateCrossingTest)
@@ -5308,6 +5438,11 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
Result.bHasGroundTris = NumTris > MeshData.NumCeilingTriangles; Result.bHasGroundTris = NumTris > MeshData.NumCeilingTriangles;
} }
// Parity instrumentation observes the same FVoxelMeshData that the runtime stream builder
// consumes. It is intentionally after the abort fence, so a canceled worker cannot publish a
// partial identity, and it records empty/proof tiles as well as visible meshes.
RecordTileHash(Tile, OriginVoxels, Step, Cells, BandChunkLo, BandChunkHi, bSheetTile, MeshData);
Result.ClassifySeconds = ClassifySeconds; Result.ClassifySeconds = ClassifySeconds;
Result.MeshSeconds = MeshSeconds; Result.MeshSeconds = MeshSeconds;
Result.StreamSeconds = StreamSeconds; Result.StreamSeconds = StreamSeconds;
@@ -0,0 +1,59 @@
// VoxelGeometryHash.h
// The compact geometry identity shared by offline export and runtime streaming diagnostics.
#pragma once
#include "CoreMinimal.h"
#include "Misc/Crc.h"
#include "VoxelTypes.h"
namespace VoxelForgeGeometryHash
{
// Keep this deliberately identical to the historical explorer hash. It is a cheap,
// deterministic identity for a tile's complete mesh payload; changing it would invalidate
// the commandlet's existing canonical evidence.
inline FString Compute(const FVoxelMeshData& MeshData)
{
uint32 Crc = 0;
const int32 Counts[] = {
MeshData.Vertices.Num(),
MeshData.Normals.Num(),
MeshData.UVs.Num(),
MeshData.Colors.Num(),
MeshData.Triangles.Num(),
MeshData.NumCeilingTriangles,
};
Crc = FCrc::MemCrc32(Counts, sizeof(Counts), Crc);
if (MeshData.Vertices.Num() > 0)
{
Crc = FCrc::MemCrc32(
MeshData.Vertices.GetData(),
MeshData.Vertices.Num() * sizeof(FVector), Crc);
}
if (MeshData.Normals.Num() > 0)
{
Crc = FCrc::MemCrc32(
MeshData.Normals.GetData(),
MeshData.Normals.Num() * sizeof(FVector), Crc);
}
if (MeshData.UVs.Num() > 0)
{
Crc = FCrc::MemCrc32(
MeshData.UVs.GetData(),
MeshData.UVs.Num() * sizeof(FVector2D), Crc);
}
if (MeshData.Colors.Num() > 0)
{
Crc = FCrc::MemCrc32(
MeshData.Colors.GetData(),
MeshData.Colors.Num() * sizeof(FColor), Crc);
}
if (MeshData.Triangles.Num() > 0)
{
Crc = FCrc::MemCrc32(
MeshData.Triangles.GetData(),
MeshData.Triangles.Num() * sizeof(int32), Crc);
}
return FString::Printf(TEXT("%08X"), Crc);
}
}
+27
View File
@@ -5,6 +5,7 @@
#include "CoreMinimal.h" #include "CoreMinimal.h"
#include "GameFramework/Actor.h" #include "GameFramework/Actor.h"
#include "HAL/CriticalSection.h"
#include <atomic> #include <atomic>
#include "VoxelTypes.h" #include "VoxelTypes.h"
#include "VoxelGenerator.h" #include "VoxelGenerator.h"
@@ -137,6 +138,21 @@ struct FChunkResult
TArray<uint8> CaptureGrid; TArray<uint8> CaptureGrid;
}; };
/** One worker-produced mesh identity emitted only by the explicit parity harness. */
struct FVoxelTileHashDumpRecord
{
FVoxelTileKey Tile;
FIntVector OriginVoxels = FIntVector::ZeroValue;
int32 Step = 1;
int32 Cells = 0;
int32 BandChunkLo = MIN_int32;
int32 BandChunkHi = MAX_int32;
bool bSheetTile = false;
bool bEmpty = true;
int32 NumTriangles = 0;
FString GeometryHash;
};
UCLASS() UCLASS()
class VOXELFORGE_API AVoxelWorld : public AActor class VOXELFORGE_API AVoxelWorld : public AActor
{ {
@@ -512,6 +528,10 @@ private:
void ObserveHeadlessStrateCrossingTest(const FVector& PlayerPosition, APawn* PlayerPawn); void ObserveHeadlessStrateCrossingTest(const FVector& PlayerPosition, APawn* PlayerPawn);
void MaybeFinishHeadlessStrateCrossingTest(); void MaybeFinishHeadlessStrateCrossingTest();
void MaybeFinishHeadlessStreamingTest(); void MaybeFinishHeadlessStreamingTest();
void RecordTileHash(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels,
int32 Step, int32 Cells, int32 BandChunkLo, int32 BandChunkHi,
bool bSheetTile, const FVoxelMeshData& MeshData);
void WriteTileHashDump();
// Packed shader params, recomputed each Tick. ALL meaningful data is in .xyz — a material Vector // Packed shader params, recomputed each Tick. ALL meaningful data is in .xyz — a material Vector
// Parameter only delivers float3 (RGB) into a Custom node (the alpha is dropped), so we never use .w. // Parameter only delivers float3 (RGB) into a Custom node (the alpha is dropped), so we never use .w.
@@ -977,6 +997,13 @@ public:
double HeadlessStreamingTestBeginSeconds = 0.0; double HeadlessStreamingTestBeginSeconds = 0.0;
double HeadlessStreamingTestLastElapsedSeconds = 0.0; double HeadlessStreamingTestLastElapsedSeconds = 0.0;
// Explicit parity evidence. Workers append under the lock; EndPlay writes the compact JSON
// after ActiveTaskCount has reached zero, so the dump is a complete worker-side snapshot.
bool bTileHashDumpEnabled = false;
FString TileHashDumpPath;
FCriticalSection TileHashDumpMutex;
TArray<FVoxelTileHashDumpRecord> TileHashDumpRecords;
// Real inter-strate crossing validation. This is enabled only by -voxel.TestStrateCrossing=1; // Real inter-strate crossing validation. This is enabled only by -voxel.TestStrateCrossing=1;
// the route is copied from UVoxelStrateManager's generated passage and movement is supplied to // the route is copied from UVoxelStrateManager's generated passage and movement is supplied to
// the ordinary pawn movement component. The initial placement onto the upper landing is test // the ordinary pawn movement component. The initial placement onto the upper landing is test
@@ -11,7 +11,6 @@
#include "IImageWrapper.h" #include "IImageWrapper.h"
#include "IImageWrapperModule.h" #include "IImageWrapperModule.h"
#include "Misc/CommandLine.h" #include "Misc/CommandLine.h"
#include "Misc/Crc.h"
#include "Misc/FileHelper.h" #include "Misc/FileHelper.h"
#include "Misc/Parse.h" #include "Misc/Parse.h"
#include "Misc/Paths.h" #include "Misc/Paths.h"
@@ -32,6 +31,7 @@
#include "VoxelDensityAblation.h" #include "VoxelDensityAblation.h"
#include "VoxelDensityProfile.h" #include "VoxelDensityProfile.h"
#include "VoxelGenerator.h" #include "VoxelGenerator.h"
#include "VoxelGeometryHash.h"
#include "VoxelMarchingCubesMesher.h" #include "VoxelMarchingCubesMesher.h"
#include "VoxelNoise.h" #include "VoxelNoise.h"
#include "VoxelPassageGeometry.h" #include "VoxelPassageGeometry.h"
@@ -339,6 +339,9 @@ struct FExploreArguments
// density_grid_reuse=false to produce a matched wall-clock control. // density_grid_reuse=false to produce a matched wall-clock control.
bool bReuseDensityGrid = true; bool bReuseDensityGrid = true;
bool bBlockEarlyOut = false; bool bBlockEarlyOut = false;
// Opt-in parity mode: copy the runtime mesher presentation/LOD settings from the authored
// settings object. The default remains the historical bounded export configuration.
bool bGameRuntimeConfig = false;
int32 MeshMinBatchSize = 1; int32 MeshMinBatchSize = 1;
int32 MaxWalkCells = DefaultMaxWalkCells; int32 MaxWalkCells = DefaultMaxWalkCells;
float BudgetMinutes = DefaultBudgetMinutes; float BudgetMinutes = DefaultBudgetMinutes;
@@ -470,6 +473,10 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr
int32 BlockEarlyOut = OutArguments.bBlockEarlyOut ? 1 : 0; int32 BlockEarlyOut = OutArguments.bBlockEarlyOut ? 1 : 0;
FParse::Value(*Params, TEXT("blockearlyout="), BlockEarlyOut); FParse::Value(*Params, TEXT("blockearlyout="), BlockEarlyOut);
OutArguments.bBlockEarlyOut = BlockEarlyOut != 0; OutArguments.bBlockEarlyOut = BlockEarlyOut != 0;
int32 GameRuntimeConfig = OutArguments.bGameRuntimeConfig ? 1 : 0;
FParse::Value(*Params, TEXT("gameconfig="), GameRuntimeConfig);
FParse::Value(*Params, TEXT("paritygame="), GameRuntimeConfig);
OutArguments.bGameRuntimeConfig = GameRuntimeConfig != 0;
FParse::Value(*Params, TEXT("meshminbatch="), OutArguments.MeshMinBatchSize); FParse::Value(*Params, TEXT("meshminbatch="), OutArguments.MeshMinBatchSize);
OutArguments.MeshMinBatchSize = FMath::Clamp(OutArguments.MeshMinBatchSize, 1, 64); OutArguments.MeshMinBatchSize = FMath::Clamp(OutArguments.MeshMinBatchSize, 1, 64);
int32 Lod = 0; int32 Lod = 0;
@@ -686,11 +693,27 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr
return false; return false;
} }
if (OutArguments.Slot < 1 || OutArguments.Slot > MaxSyntheticStrates - 2) // The historical explorer contract needs two adjacent mouths around an interior slot. A
// parity export can instead target the game's authored single-strate layout, which has no
// such mouths and is valid for export-only mode. Build() still validates the slot against
// the loaded settings layout.
const int32 MinimumSlot = OutArguments.bGameRuntimeConfig ? 0 : 1;
const int32 MaximumSlot = OutArguments.bGameRuntimeConfig
? MaxSyntheticStrates - 1 : MaxSyntheticStrates - 2;
if (OutArguments.Slot < MinimumSlot || OutArguments.Slot > MaximumSlot)
{ {
OutError = FString::Printf( if (OutArguments.bGameRuntimeConfig)
TEXT("slot must be an interior slot in [1,%d] so arrival and departure mouths exist."), {
MaxSyntheticStrates - 2); OutError = FString::Printf(
TEXT("slot must be in [0,%d] for game-runtime configuration parity."),
MaxSyntheticStrates - 1);
}
else
{
OutError = FString::Printf(
TEXT("slot must be an interior slot in [1,%d] so arrival and departure mouths exist."),
MaxSyntheticStrates - 2);
}
return false; return false;
} }
if (OutArguments.RenderWidth < 16 || OutArguments.RenderHeight < 16 if (OutArguments.RenderWidth < 16 || OutArguments.RenderHeight < 16
@@ -1127,7 +1150,9 @@ struct FExploreWorld
} }
const int32 NumStrates = bUseAuthoredSettings const int32 NumStrates = bUseAuthoredSettings
? FMath::Max(Settings->TotalStrates, Arguments.Slot + 2) ? (Arguments.bGameRuntimeConfig
? FMath::Max(Settings->TotalStrates, 1)
: FMath::Max(Settings->TotalStrates, Arguments.Slot + 2))
: Arguments.Slot + 2; : Arguments.Slot + 2;
if (NumStrates > MaxSyntheticStrates) if (NumStrates > MaxSyntheticStrates)
{ {
@@ -1397,6 +1422,12 @@ struct FExploreWorld
NewObject<UVoxelMarchingCubesMesher>(GetTransientPackage(), NAME_None, RF_Transient)); NewObject<UVoxelMarchingCubesMesher>(GetTransientPackage(), NAME_None, RF_Transient));
Mesher->SetGenerator(Generator.Get()); Mesher->SetGenerator(Generator.Get());
Mesher->bUseBlockEarlyOut = Arguments.bBlockEarlyOut; Mesher->bUseBlockEarlyOut = Arguments.bBlockEarlyOut;
if (Arguments.bGameRuntimeConfig)
{
Mesher->bGenerateSkirts = Settings->bGenerateSkirts;
Mesher->SkirtCells = Settings->SkirtCells;
Mesher->LODOctaveDrop = Settings->LODOctaveDrop;
}
if (Settings->WorldRadiusVoxels != 0.0f if (Settings->WorldRadiusVoxels != 0.0f
|| Generator->WorldRadiusVoxels != 0.0f || Generator->WorldRadiusVoxels != 0.0f
@@ -2048,47 +2079,7 @@ bool AppendCanonicalMesh(
FString ComputeGeometryHash(const FVoxelMeshData& MeshData) FString ComputeGeometryHash(const FVoxelMeshData& MeshData)
{ {
uint32 Crc = 0; return VoxelForgeGeometryHash::Compute(MeshData);
const int32 Counts[] = {
MeshData.Vertices.Num(),
MeshData.Normals.Num(),
MeshData.UVs.Num(),
MeshData.Colors.Num(),
MeshData.Triangles.Num(),
MeshData.NumCeilingTriangles,
};
Crc = FCrc::MemCrc32(Counts, sizeof(Counts), Crc);
if (MeshData.Vertices.Num() > 0)
{
Crc = FCrc::MemCrc32(
MeshData.Vertices.GetData(),
MeshData.Vertices.Num() * sizeof(FVector), Crc);
}
if (MeshData.Normals.Num() > 0)
{
Crc = FCrc::MemCrc32(
MeshData.Normals.GetData(),
MeshData.Normals.Num() * sizeof(FVector), Crc);
}
if (MeshData.UVs.Num() > 0)
{
Crc = FCrc::MemCrc32(
MeshData.UVs.GetData(),
MeshData.UVs.Num() * sizeof(FVector2D), Crc);
}
if (MeshData.Colors.Num() > 0)
{
Crc = FCrc::MemCrc32(
MeshData.Colors.GetData(),
MeshData.Colors.Num() * sizeof(FColor), Crc);
}
if (MeshData.Triangles.Num() > 0)
{
Crc = FCrc::MemCrc32(
MeshData.Triangles.GetData(),
MeshData.Triangles.Num() * sizeof(int32), Crc);
}
return FString::Printf(TEXT("%08X"), Crc);
} }
bool EnsureExploreMesh( bool EnsureExploreMesh(
@@ -2153,9 +2144,14 @@ bool EnsureExploreMesh(
} }
// This is the same canonical UVoxelMarchingCubesMesher used by export and runtime chunk // This is the same canonical UVoxelMarchingCubesMesher used by export and runtime chunk
// generation. Skirts are presentation geometry for LOD seams and would extend outside the // generation. Historical export deliberately omitted presentation skirts; parity opts into
// bounded explorer region, so the shared mesh uses the export setting. // the authored runtime settings so the requested comparison has the same mesher inputs.
World.Mesher->bGenerateSkirts = false; World.Mesher->bGenerateSkirts = Arguments.bGameRuntimeConfig
? World.Settings->bGenerateSkirts : false;
World.Mesher->SkirtCells = Arguments.bGameRuntimeConfig
? World.Settings->SkirtCells : 2.0f;
World.Mesher->LODOctaveDrop = Arguments.bGameRuntimeConfig
? World.Settings->LODOctaveDrop : 0;
const double MeshStartSeconds = FPlatformTime::Seconds(); const double MeshStartSeconds = FPlatformTime::Seconds();
if (Budget.ShouldStop(TEXT("mesh"))) if (Budget.ShouldStop(TEXT("mesh")))
@@ -6320,6 +6316,7 @@ FString BuildExploreJson(
Writer->WriteValue(TEXT("export_step"), Arguments.ExportStep); Writer->WriteValue(TEXT("export_step"), Arguments.ExportStep);
Writer->WriteValue(TEXT("density_grid_reuse"), Arguments.bReuseDensityGrid); Writer->WriteValue(TEXT("density_grid_reuse"), Arguments.bReuseDensityGrid);
Writer->WriteValue(TEXT("block_early_out"), Arguments.bBlockEarlyOut); Writer->WriteValue(TEXT("block_early_out"), Arguments.bBlockEarlyOut);
Writer->WriteValue(TEXT("game_runtime_config"), Arguments.bGameRuntimeConfig);
Writer->WriteValue(TEXT("mesh_min_batch_size"), Arguments.MeshMinBatchSize); Writer->WriteValue(TEXT("mesh_min_batch_size"), Arguments.MeshMinBatchSize);
Writer->WriteValue(TEXT("profile_density"), Arguments.bProfileDensity); Writer->WriteValue(TEXT("profile_density"), Arguments.bProfileDensity);
Writer->WriteValue(TEXT("profile_density_full"), Arguments.bProfileDensityFull); Writer->WriteValue(TEXT("profile_density_full"), Arguments.bProfileDensityFull);
@@ -6532,6 +6529,18 @@ FString BuildExploreJson(
Writer->WriteValue(TEXT("x"), TileX); Writer->WriteValue(TEXT("x"), TileX);
Writer->WriteValue(TEXT("y"), TileY); Writer->WriteValue(TEXT("y"), TileY);
Writer->WriteValue(TEXT("z"), TileZ); Writer->WriteValue(TEXT("z"), TileZ);
Writer->WriteValue(TEXT("absolute_tile_x"),
World.ExploreMeshOrigin.X / CHUNK_SIZE + TileX);
Writer->WriteValue(TEXT("absolute_tile_y"),
World.ExploreMeshOrigin.Y / CHUNK_SIZE + TileY);
Writer->WriteValue(TEXT("absolute_tile_z"),
World.ExploreMeshOrigin.Z / CHUNK_SIZE + TileZ);
Writer->WriteValue(TEXT("origin_x_voxels"),
World.ExploreMeshOrigin.X + TileX * CHUNK_SIZE);
Writer->WriteValue(TEXT("origin_y_voxels"),
World.ExploreMeshOrigin.Y + TileY * CHUNK_SIZE);
Writer->WriteValue(TEXT("origin_z_voxels"),
World.ExploreMeshOrigin.Z + TileZ * CHUNK_SIZE);
Writer->WriteValue(TEXT("sample_step"), Arguments.ExportStep); Writer->WriteValue(TEXT("sample_step"), Arguments.ExportStep);
Writer->WriteValue(TEXT("lod"), TileLod); Writer->WriteValue(TEXT("lod"), TileLod);
Writer->WriteValue(TEXT("triangle_count"), World.ExploreTileTriangleCounts[TileIndex]); Writer->WriteValue(TEXT("triangle_count"), World.ExploreTileTriangleCounts[TileIndex]);
+147
View File
@@ -0,0 +1,147 @@
param(
[Parameter(Mandatory=$true)] [string] $RunName,
[string] $TestName = 'VoxelForge.Determinism.DensityReuseRejectsMismatchedKey',
[int] $HardLimitSeconds = 120
)
$ErrorActionPreference = 'Stop'
$pluginRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$hostRoot = Join-Path $pluginRoot 'Saved\BuildHost\HostProject'
$hostSaved = Join-Path $hostRoot 'Saved'
$uproject = Join-Path $hostRoot 'HostProject.uproject'
$editor = 'E:\Program Files\Epic Games\UE_5.7\Engine\Binaries\Win64\UnrealEditor.exe'
$stageBin = Join-Path $hostRoot 'Plugins\VoxelForge\Binaries\Win64'
$runtime = Join-Path $stageBin 'UnrealEditor-VoxelForge.dll'
$editorModule = Join-Path $stageBin 'UnrealEditor-VoxelForgeEditor.dll'
$crashRoot = Join-Path $hostRoot 'Saved\Crashes'
$ddcPath = Join-Path $pluginRoot 'Saved\DDC'
$zenPath = Join-Path $pluginRoot 'Saved\ZenData'
$runDir = Join-Path $pluginRoot ("Saved\PerfProbeR2_20260916\Automation\{0}" -f $RunName)
$logPath = Join-Path $runDir "$RunName.log"
$stdoutPath = Join-Path $runDir "$RunName.stdout.log"
$stderrPath = Join-Path $runDir "$RunName.stderr.log"
$modulePath = Join-Path $runDir "$RunName.LoadedModule.txt"
foreach ($path in @($uproject, $editor, $runtime, $editorModule)) {
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing required file: $path" }
}
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
foreach ($path in @($logPath, $stdoutPath, $stderrPath, $modulePath)) {
if (Test-Path -LiteralPath $path) { throw "Refusing to overwrite launch artifact: $path" }
}
. (Join-Path $pluginRoot 'Saved\VoxelForgeHarnessCommon.ps1')
$expected = @{}
foreach ($path in @($runtime, $editorModule)) {
$item = Get-Item -LiteralPath $path
$expected[$item.Name] = [pscustomobject]@{
Path = $item.FullName; Length = [int64]$item.Length
SHA256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash
}
}
Set-Content -LiteralPath $modulePath -Encoding utf8 -Value @(
"ExpectedRuntimePath=$($expected['UnrealEditor-VoxelForge.dll'].Path)"
"ExpectedRuntimeLength=$($expected['UnrealEditor-VoxelForge.dll'].Length)"
"ExpectedRuntimeSHA256=$($expected['UnrealEditor-VoxelForge.dll'].SHA256)"
"ExpectedEditorPath=$($expected['UnrealEditor-VoxelForgeEditor.dll'].Path)"
"ExpectedEditorLength=$($expected['UnrealEditor-VoxelForgeEditor.dll'].Length)"
"ExpectedEditorSHA256=$($expected['UnrealEditor-VoxelForgeEditor.dll'].SHA256)"
'LoadedModules='
)
$owners = @(Get-Process | Where-Object { $_.ProcessName -like 'UnrealEditor*' })
Write-Host "OWNER_EDITOR_CHECK launch=$RunName count=$($owners.Count)"
if ($owners.Count -ne 0) { throw "Refusing to launch while UnrealEditor* is running: $($owners.Id -join ',')" }
$startedUtc = (Get-Date).ToUniversalTime()
$crashBefore = Get-VoxelForgeCrashFolderSnapshot $crashRoot
$arguments = @(
"`"$uproject`"", '-unattended', '-nop4', '-nosplash', '-nullrhi', '-NoZenAutoLaunch',
"-ZenDataPath=`"$zenPath`"", '-ddc=NoZenLocalFallback', "-LocalDataCachePath=`"$ddcPath`"",
'-nocrashreports', '-WindowsPlatformCrashContext.ForceCrashReportDialogOff=1',
"-ExecCmds=`"Automation RunTests $TestName; Quit`"", "-abslog=`"$logPath`""
)
Write-Host "LAUNCH_START name=$RunName editor=$editor"
Write-Host "LAUNCH_COMMAND name=$RunName $editor $($arguments -join ' ')"
$process = Start-Process -FilePath $editor -ArgumentList $arguments -WorkingDirectory $hostRoot `
-RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -WindowStyle Hidden -PassThru
Write-Host "LAUNCH_PID name=$RunName pid=$($process.Id)"
$loadedNames = New-Object 'System.Collections.Generic.HashSet[string]'
$reporterPids = New-Object 'System.Collections.Generic.HashSet[int]'
$harnessError = ''
$watch = [System.Diagnostics.Stopwatch]::StartNew()
function Try-RecordLoadedModules(
[System.Diagnostics.Process] $Process,
[hashtable] $Expected,
[string] $ModulePath,
[System.Collections.Generic.HashSet[string]] $LoadedNames)
{
try {
$Process.Refresh()
foreach ($module in $Process.Modules) {
if (-not $Expected.ContainsKey($module.ModuleName) -or $LoadedNames.Contains($module.ModuleName)) { continue }
$loadedPath = [System.IO.Path]::GetFullPath($module.FileName)
$loadedItem = Get-Item -LiteralPath $loadedPath
$loadedHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $loadedPath).Hash
Add-Content -LiteralPath $ModulePath -Encoding utf8 -Value @(
"LoadedName=$($module.ModuleName)"
"LoadedPath=$loadedPath"
"LoadedLength=$($loadedItem.Length)"
"LoadedSHA256=$loadedHash"
)
$expectedModule = $Expected[$module.ModuleName]
if ($loadedPath -ine $expectedModule.Path -or [int64]$loadedItem.Length -ne $expectedModule.Length -or $loadedHash -ine $expectedModule.SHA256) {
throw "Loaded module mismatch: $($module.ModuleName)"
}
[void]$LoadedNames.Add($module.ModuleName)
}
}
catch [System.ComponentModel.Win32Exception] {
# Process.Modules can be temporarily unavailable while the editor is initializing. The
# log-side plugin marker below remains the required fallback for this editor-only launch.
}
}
try {
while (-not $process.HasExited) {
[void](Stop-VoxelForgeCrashReporters -MonitoredPid $process.Id -SeenPids $reporterPids)
Try-RecordLoadedModules -Process $process -Expected $expected -ModulePath $modulePath -LoadedNames $loadedNames
if ($watch.Elapsed.TotalSeconds -ge $HardLimitSeconds) { throw "Automation run exceeded $HardLimitSeconds seconds." }
Wait-Process -Id $process.Id -Timeout 1 -ErrorAction SilentlyContinue | Out-Null
}
$process.Refresh()
}
catch { $harnessError = $_.Exception.Message }
finally {
if (-not $process.HasExited) { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue }
}
for ($index = 0; $index -lt 8; ++$index) {
[void](Stop-VoxelForgeCrashReporters -MonitoredPid $process.Id -SeenPids $reporterPids)
Start-Sleep -Milliseconds 125
}
$process.Refresh()
$exitCode = [int]$process.ExitCode
$crashFolders = @(Get-VoxelForgeChangedCrashFolders -CrashRoot $crashRoot -Before $crashBefore -StartedUtc $startedUtc)
$testLines = @(Select-String -LiteralPath $logPath -Pattern ([regex]::Escape($TestName)) -ErrorAction SilentlyContinue)
$completionLines = @(Select-String -LiteralPath $logPath -Pattern 'TEST COMPLETE\. EXIT CODE:\s*0' -ErrorAction SilentlyContinue)
$failureLines = @(Select-String -LiteralPath $logPath -Pattern 'No automation tests matched|TEST COMPLETE\. EXIT CODE:\s*-[1-9]' -ErrorAction SilentlyContinue)
$pluginStartLines = @(Select-String -LiteralPath $logPath -Pattern 'VoxelForge module started' -ErrorAction SilentlyContinue)
$remainingReporters = @(Get-VoxelForgeCrashReporterProcesses -MonitoredPid $process.Id)
$ownerAfter = @(Get-Process | Where-Object { $_.ProcessName -like 'UnrealEditor*' })
$valid = $exitCode -eq 0 -and [string]::IsNullOrWhiteSpace($harnessError) `
-and $testLines.Count -gt 0 -and $completionLines.Count -gt 0 -and $failureLines.Count -eq 0 `
-and $pluginStartLines.Count -gt 0 -and $crashFolders.Count -eq 0 `
-and $remainingReporters.Count -eq 0 -and $ownerAfter.Count -eq 0
Write-Host "OWNER_EDITOR_AFTER name=$RunName count=$($ownerAfter.Count)"
if ($crashFolders.Count -gt 0) { Write-Host "CRASH_FOLDERS name=$RunName $($crashFolders -join ';')" } else { Write-Host "CRASH_FOLDERS name=$RunName none" }
Write-Host "LAUNCH_RESULT name=$RunName outcome=$(if ($valid) { 'PASS' } else { 'FAIL' }) exit_code=$exitCode elapsed_seconds=$($watch.Elapsed.TotalSeconds.ToString('F6',[Globalization.CultureInfo]::InvariantCulture)) loaded_modules=$($loadedNames.Count)/2 plugin_marker=$($pluginStartLines.Count) crash_reporters_observed=$($reporterPids.Count)"
if ($harnessError) { Write-Host "LAUNCH_ERROR name=$RunName $harnessError" }
if ($testLines.Count -eq 0) { Write-Host "LAUNCH_FAILURE_REASON name=$RunName requested automation test was not found in log" }
if ($completionLines.Count -eq 0) { Write-Host "LAUNCH_FAILURE_REASON name=$RunName no successful automation completion marker was found" }
if ($failureLines.Count -gt 0) { Write-Host "LAUNCH_FAILURE_REASON name=$RunName automation reported a failure or no matching test" }
if ($pluginStartLines.Count -eq 0) { Write-Host "LAUNCH_FAILURE_REASON name=$RunName VoxelForge module startup marker was not observed" }
if ($crashFolders.Count -gt 0) { Write-Host "LAUNCH_FAILURE_REASON name=$RunName new crash folder observed" }
if ($ownerAfter.Count -ne 0) { Write-Host "LAUNCH_FAILURE_REASON name=$RunName UnrealEditor* remained after launch" }
Write-Host "LAUNCH_ARTIFACTS name=$RunName log=$logPath stdout=$stdoutPath stderr=$stderrPath loaded_modules=$modulePath"
if (-not $valid) { exit 1 }
exit 0
File diff suppressed because it is too large. Load diff