diff --git a/.gitignore b/.gitignore index b86a9aa..782bb75 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,11 @@ !/Source/ !/Source/** +# The guarded single-entry test harness is source, not generated output. +!/Tools/ +!/Tools/** + !VoxelForge.uplugin # Keep every design / doc markdown at any depth (AUDIT P1 — these were untracked) -!*.md \ No newline at end of file +!*.md diff --git a/CODEMAP.md b/CODEMAP.md index aaa39ea..5cbb2f1 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -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.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). | | `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. | ### 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=`, 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). | | `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=` 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. | | `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. | @@ -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 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 -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` Header is rich with inline docs. Two namespaces + a per-chunk cache system. diff --git a/PERF-SAMPLED.md b/PERF-SAMPLED.md index a09ba15..f579966 100644 --- a/PERF-SAMPLED.md +++ b/PERF-SAMPLED.md @@ -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. +## 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. ## Instrument and build diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 828dbcb..5479f83 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -14,6 +14,7 @@ #include "VoxelDensityVolume.h" #include "VoxelDensityOpStack.h" #include "VoxelDensityProfile.h" +#include "VoxelGeometryHash.h" #include "VoxelStartupTrace.h" #include "VoxelStackSampler.h" #include "VoxelStats.h" @@ -45,8 +46,11 @@ #include "Misc/FileHelper.h" #include "Misc/CommandLine.h" #include "Misc/Paths.h" +#include "Misc/ScopeLock.h" #include "HAL/IConsoleManager.h" #include "HAL/PlatformMisc.h" +#include "Policies/CondensedJsonPrintPolicy.h" +#include "Serialization/JsonWriter.h" #include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0) #if WITH_EDITOR @@ -1133,6 +1137,7 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason) } VoxelCaveMorphology::LogPlayerFitMemoStats(); LogStreamingLatencySummary(); + WriteTileHashDump(); const uint64 TilePostReachDifferences = VoxelGenLOD::GSkippedPostDifferences.load(std::memory_order_relaxed); UE_LOG(LogTemp, Display, @@ -1693,6 +1698,9 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest() HeadlessStreamingTestLastActualPosition = FVector::ZeroVector; HeadlessStreamingTestBeginSeconds = 0.0; HeadlessStreamingTestLastElapsedSeconds = 0.0; + bTileHashDumpEnabled = false; + TileHashDumpPath.Reset(); + TileHashDumpRecords.Reset(); bHeadlessStrateCrossingTest = false; bHeadlessStrateCrossingTestStartPlaced = false; @@ -1720,6 +1728,25 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest() HeadlessStrateCrossingTestRoute.Reset(); 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; const bool bMoveRequested = (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 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>> Writer = + TJsonWriterFactory>::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(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() { if (!bHeadlessStrateCrossingTest) @@ -5308,6 +5438,11 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector 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.MeshSeconds = MeshSeconds; Result.StreamSeconds = StreamSeconds; diff --git a/Source/VoxelForge/Public/VoxelGeometryHash.h b/Source/VoxelForge/Public/VoxelGeometryHash.h new file mode 100644 index 0000000..18bff31 --- /dev/null +++ b/Source/VoxelForge/Public/VoxelGeometryHash.h @@ -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); + } +} diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index 7239df9..2b0aba7 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -5,6 +5,7 @@ #include "CoreMinimal.h" #include "GameFramework/Actor.h" +#include "HAL/CriticalSection.h" #include #include "VoxelTypes.h" #include "VoxelGenerator.h" @@ -137,6 +138,21 @@ struct FChunkResult TArray 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() class VOXELFORGE_API AVoxelWorld : public AActor { @@ -512,6 +528,10 @@ private: void ObserveHeadlessStrateCrossingTest(const FVector& PlayerPosition, APawn* PlayerPawn); void MaybeFinishHeadlessStrateCrossingTest(); 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 // 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 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 TileHashDumpRecords; + // 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 ordinary pawn movement component. The initial placement onto the upper landing is test diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index df5b9b6..11223a0 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -11,7 +11,6 @@ #include "IImageWrapper.h" #include "IImageWrapperModule.h" #include "Misc/CommandLine.h" -#include "Misc/Crc.h" #include "Misc/FileHelper.h" #include "Misc/Parse.h" #include "Misc/Paths.h" @@ -32,6 +31,7 @@ #include "VoxelDensityAblation.h" #include "VoxelDensityProfile.h" #include "VoxelGenerator.h" +#include "VoxelGeometryHash.h" #include "VoxelMarchingCubesMesher.h" #include "VoxelNoise.h" #include "VoxelPassageGeometry.h" @@ -339,6 +339,9 @@ struct FExploreArguments // density_grid_reuse=false to produce a matched wall-clock control. bool bReuseDensityGrid = true; 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 MaxWalkCells = DefaultMaxWalkCells; float BudgetMinutes = DefaultBudgetMinutes; @@ -470,6 +473,10 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr int32 BlockEarlyOut = OutArguments.bBlockEarlyOut ? 1 : 0; FParse::Value(*Params, TEXT("blockearlyout="), BlockEarlyOut); 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); OutArguments.MeshMinBatchSize = FMath::Clamp(OutArguments.MeshMinBatchSize, 1, 64); int32 Lod = 0; @@ -686,11 +693,27 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr 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( - TEXT("slot must be an interior slot in [1,%d] so arrival and departure mouths exist."), - MaxSyntheticStrates - 2); + if (OutArguments.bGameRuntimeConfig) + { + 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; } if (OutArguments.RenderWidth < 16 || OutArguments.RenderHeight < 16 @@ -1127,7 +1150,9 @@ struct FExploreWorld } 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; if (NumStrates > MaxSyntheticStrates) { @@ -1397,6 +1422,12 @@ struct FExploreWorld NewObject(GetTransientPackage(), NAME_None, RF_Transient)); Mesher->SetGenerator(Generator.Get()); Mesher->bUseBlockEarlyOut = Arguments.bBlockEarlyOut; + if (Arguments.bGameRuntimeConfig) + { + Mesher->bGenerateSkirts = Settings->bGenerateSkirts; + Mesher->SkirtCells = Settings->SkirtCells; + Mesher->LODOctaveDrop = Settings->LODOctaveDrop; + } if (Settings->WorldRadiusVoxels != 0.0f || Generator->WorldRadiusVoxels != 0.0f @@ -2048,47 +2079,7 @@ bool AppendCanonicalMesh( FString ComputeGeometryHash(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); + return VoxelForgeGeometryHash::Compute(MeshData); } bool EnsureExploreMesh( @@ -2153,9 +2144,14 @@ bool EnsureExploreMesh( } // 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 - // bounded explorer region, so the shared mesh uses the export setting. - World.Mesher->bGenerateSkirts = false; + // generation. Historical export deliberately omitted presentation skirts; parity opts into + // the authored runtime settings so the requested comparison has the same mesher inputs. + 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(); if (Budget.ShouldStop(TEXT("mesh"))) @@ -6320,6 +6316,7 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("export_step"), Arguments.ExportStep); Writer->WriteValue(TEXT("density_grid_reuse"), Arguments.bReuseDensityGrid); 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("profile_density"), Arguments.bProfileDensity); Writer->WriteValue(TEXT("profile_density_full"), Arguments.bProfileDensityFull); @@ -6532,6 +6529,18 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("x"), TileX); Writer->WriteValue(TEXT("y"), TileY); 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("lod"), TileLod); Writer->WriteValue(TEXT("triangle_count"), World.ExploreTileTriangleCounts[TileIndex]); diff --git a/Tools/RunVoxelForgeAutomationR2.ps1 b/Tools/RunVoxelForgeAutomationR2.ps1 new file mode 100644 index 0000000..3182246 --- /dev/null +++ b/Tools/RunVoxelForgeAutomationR2.ps1 @@ -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 diff --git a/Tools/VoxelForgeTest.ps1 b/Tools/VoxelForgeTest.ps1 new file mode 100644 index 0000000..666c667 --- /dev/null +++ b/Tools/VoxelForgeTest.ps1 @@ -0,0 +1,1028 @@ +[CmdletBinding()] +param( + [ValidateSet('canonical', 'owner', 'probe', 'perf', 'tests', 'parity')] + [string]$Scenario = 'canonical', + [switch]$Build, + [ValidateSet('owner', 'default')] + [string]$Assets = 'owner', + [hashtable]$Cvars = @{}, + [string]$Out, + [string]$Label +) + +# One guarded entry point for all VoxelForge measurements. It intentionally keeps the Unreal +# command lines in this file: callers choose a scenario and data, never an arbitrary process. +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$ScriptFile = [IO.Path]::GetFullPath($PSCommandPath) +$ToolsRoot = Split-Path -Parent $ScriptFile +$PluginRoot = Split-Path -Parent $ToolsRoot +$ProjectRoot = Split-Path -Parent (Split-Path -Parent $PluginRoot) +$HostRoot = Join-Path $PluginRoot 'Saved\BuildHost\HostProject' +$HostProject = Join-Path $HostRoot 'HostProject.uproject' +$StagePlugin = Join-Path $HostRoot 'Plugins\VoxelForge' +$ProjectFile = Join-Path $ProjectRoot 'VoxelM.uproject' +$EngineRoot = 'E:\Program Files\Epic Games\UE_5.7' +$EditorExe = Join-Path $EngineRoot 'Engine\Binaries\Win64\UnrealEditor-Cmd.exe' +$UbtDll = Join-Path $EngineRoot 'Engine\Binaries\DotNET\UnrealBuildTool\UnrealBuildTool.dll' +$LaunchTimeoutSeconds = 1800 +# Fresh branch-tip baseline at 1c642fd1824a7e0e4f88a8fc1f42b9e23fdb1821 after a clean staged +# sync (synthetic canonical, walk+export+probe). This is intentionally asserted so any +# field-affecting edit makes canonical fail loudly. +$CanonicalBaselineCommit = '1c642fd1824a7e0e4f88a8fc1f42b9e23fdb1821' +$CanonicalObjSha256 = '1280524041AB4932E6CFE74D57C349F84C6557C02CB7958C694DE734EF347E2B' + +$Failures = [System.Collections.Generic.List[string]]::new() +$LaunchLedger = [System.Collections.Generic.List[object]]::new() +$BuildRecord = [ordered]@{ requested = [bool]$Build; status = 'not_requested' } +$AssetEvidence = @() +$CommandletMetrics = [ordered]@{} +$GameMetrics = [ordered]@{} +$TestMetrics = @() +$ParityEvidence = [ordered]@{ requested = $Scenario -eq 'parity'; status = 'not_run' } +$DllEvidence = [ordered]@{ + runtime = [ordered]@{ path = Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForge.dll'; sha256 = $null } + editor = [ordered]@{ path = Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForgeEditor.dll'; sha256 = $null } +} +$LocalAppDataWrites = @() +$LocalAppDataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnrealEngine' +$RunRoot = $null +$ResultPath = $null +$SummaryPath = $null + +function Add-Failure([string]$Reason) { + if ([string]::IsNullOrWhiteSpace($Reason)) { return } + if (-not $Failures.Contains($Reason)) { [void]$Failures.Add($Reason) } +} + +function Ensure-Directory([string]$Path) { + if ([string]::IsNullOrWhiteSpace($Path)) { throw 'Cannot create an empty directory path.' } + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { + [IO.Directory]::CreateDirectory($Path) | Out-Null + } +} + +function Get-FullPath([string]$Path) { + return [IO.Path]::GetFullPath($Path) +} + +function Test-UnderPath([string]$Candidate, [string]$Root) { + $candidateFull = (Get-FullPath $Candidate).TrimEnd('\', '/') + $rootFull = (Get-FullPath $Root).TrimEnd('\', '/') + return $candidateFull.Equals($rootFull, [StringComparison]::OrdinalIgnoreCase) -or + $candidateFull.StartsWith($rootFull + '\', [StringComparison]::OrdinalIgnoreCase) -or + $candidateFull.StartsWith($rootFull + '/', [StringComparison]::OrdinalIgnoreCase) +} + +function Get-Sha256([string]$Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToUpperInvariant() +} + +function Get-SourceSnapshot([string]$Root) { + $snapshot = [ordered]@{} + $sourceRoot = Join-Path $Root 'Source' + if (Test-Path -LiteralPath $sourceRoot -PathType Container) { + foreach ($file in @(Get-ChildItem -LiteralPath $sourceRoot -File -Recurse -Force)) { + $relative = $file.FullName.Substring($Root.Length).TrimStart('\', '/') + $snapshot[$relative.ToLowerInvariant()] = Get-Sha256 $file.FullName + } + } + $manifest = Join-Path $Root 'VoxelForge.uplugin' + if (Test-Path -LiteralPath $manifest -PathType Leaf) { + $snapshot['voxelforge.uplugin'] = Get-Sha256 $manifest + } + return $snapshot +} + +function Get-ChangedSnapshotKeys($Before, $After) { + $keys = @($Before.Keys) + @($After.Keys) | Sort-Object -Unique + $changed = [System.Collections.Generic.List[string]]::new() + foreach ($key in $keys) { + if (-not $Before.Contains($key) -or -not $After.Contains($key) -or + $Before[$key] -ne $After[$key]) { + [void]$changed.Add([string]$key) + } + } + return @($changed) +} + +function Get-StateSnapshot([string]$Root) { + $snapshot = [ordered]@{} + if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return $snapshot } + foreach ($item in @(Get-ChildItem -LiteralPath $Root -Recurse -Force -ErrorAction SilentlyContinue)) { + $relative = $item.FullName.Substring($Root.Length).TrimStart('\', '/').ToLowerInvariant() + $kind = if ($item.PSIsContainer) { 'D' } else { 'F' } + $time = $item.LastWriteTimeUtc.Ticks + $length = if ($item.PSIsContainer) { 0 } else { $item.Length } + $snapshot["$kind|$relative"] = "$time|$length" + } + return $snapshot +} + +function Get-StateChanges($Before, $After) { + $keys = @($Before.Keys) + @($After.Keys) | Sort-Object -Unique + $changed = [System.Collections.Generic.List[string]]::new() + foreach ($key in $keys) { + if (-not $Before.Contains($key) -or -not $After.Contains($key) -or $Before[$key] -ne $After[$key]) { + [void]$changed.Add([string]$key) + } + } + return @($changed) +} + +function Get-JsonValue($Object, [string]$Name, $Default = $null) { + if ($null -eq $Object) { return $Default } + if ($Object -is [System.Collections.IDictionary]) { + if ($Object.Contains($Name)) { return $Object[$Name] } + return $Default + } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property) { return $Default } + return $property.Value +} + +function ConvertTo-CompactJson($Object) { + if ($null -eq $Object) { return 'null' } + return ($Object | ConvertTo-Json -Compress -Depth 30) +} + +function Get-UnrealProcesses { + # Get-Process is deliberately the primary guard. Some managed shells deny Win32_Process + # command-line inspection, but they still expose the PID/name needed to protect an owner + # editor before a launch. + $rows = @(Get-Process -ErrorAction SilentlyContinue | + Where-Object { $_.ProcessName -like 'UnrealEditor*' } | + ForEach-Object { + [pscustomobject]@{ + ProcessId = [int]$_.Id + Name = "$($_.ProcessName).exe" + ExecutablePath = $null + CommandLine = $null + } + }) + return @($rows | ForEach-Object { + [pscustomobject]@{ + pid = [int]$_.ProcessId + name = [string]$_.Name + executable = [string]$_.ExecutablePath + command_line = [string]$_.CommandLine + } + }) +} + +function Assert-NoForeignUnreal([string]$When) { + $processes = @(Get-UnrealProcesses) + if ($processes.Count -gt 0) { + $detail = ($processes | ForEach-Object { + "PID=$($_.pid) Name=$($_.name) Exe=$($_.executable)" + }) -join '; ' + throw "UnrealEditor process already exists $When; refusing to touch it: $detail" + } +} + +function Get-CrashSnapshot { + $snapshot = [ordered]@{} + $roots = @( + (Join-Path $HostRoot 'Saved\Crashes'), + (Join-Path $StagePlugin 'Saved\Crashes'), + (Join-Path $RunRoot 'Crashes') + ) + foreach ($root in $roots) { + if (-not (Test-Path -LiteralPath $root -PathType Container)) { continue } + foreach ($dir in @(Get-ChildItem -LiteralPath $root -Directory -Filter 'UECC-*' -Recurse -Force -ErrorAction SilentlyContinue)) { + $snapshot[$dir.FullName.ToLowerInvariant()] = $dir.LastWriteTimeUtc.Ticks + } + } + return $snapshot +} + +function Get-CrashReporterProcesses { + $rows = @() + try { + $rows = @(Get-CimInstance -ClassName Win32_Process -ErrorAction Stop 2>$null | + Where-Object { $_.Name -like '*CrashReportClient*' }) + } catch { + # Without a parent/command line the process cannot be proven to be ours, so leave it + # untouched. The ledger records the absence of a safely attributable reporter. + $Error.Clear() + return @() + } + return @($rows | ForEach-Object { + [pscustomobject]@{ + pid = [int]$_.ProcessId + name = [string]$_.Name + command_line = [string]$_.CommandLine + } + }) +} + +function Reap-OwnCrashReporters($Before, [datetime]$StartedUtc, [string]$LogPath) { + $beforePids = @($Before | ForEach-Object { [int]$_.pid }) + $reaped = [System.Collections.Generic.List[int]]::new() + foreach ($reporter in @(Get-CrashReporterProcesses)) { + if ($beforePids -contains [int]$reporter.pid) { continue } + $commandLine = [string]$reporter.command_line + $belongsToRun = $commandLine.IndexOf($RunRoot, [StringComparison]::OrdinalIgnoreCase) -ge 0 -or + $commandLine.IndexOf($HostProject, [StringComparison]::OrdinalIgnoreCase) -ge 0 -or + $commandLine.IndexOf($LogPath, [StringComparison]::OrdinalIgnoreCase) -ge 0 + if (-not $belongsToRun) { continue } + try { + Stop-Process -Id ([int]$reporter.pid) -Force -ErrorAction Stop + [void]$reaped.Add([int]$reporter.pid) + } catch { + Add-Failure "Could not reap own crash reporter PID $($reporter.pid): $($_.Exception.Message)" + } + } + return @($reaped) +} + +function Get-NewCrashes($Before, [datetime]$StartedUtc) { + $after = Get-CrashSnapshot + $new = [System.Collections.Generic.List[string]]::new() + foreach ($path in $after.Keys) { + if (-not $Before.Contains($path) -or [int64]$after[$path] -ge $StartedUtc.ToUniversalTime().Ticks) { + [void]$new.Add($path) + } + } + return @($new) +} + +function Wait-OwnedProcess($Process, [int]$TimeoutSeconds) { + $finished = $Process.WaitForExit($TimeoutSeconds * 1000) + if ($finished) { + $Process.WaitForExit() + return [ordered]@{ timed_out = $false; exit_code = [int]$Process.ExitCode } + } + # This PID came from our Start-Process call. Never use a name-based kill or a broad process + # query here: an owner's editor may be unrelated and must remain untouched. + try { Stop-Process -Id ([int]$Process.Id) -Force -ErrorAction Stop } catch { } + try { $Process.WaitForExit(5000) } catch { } + return [ordered]@{ timed_out = $true; exit_code = $null } +} + +function Invoke-UnrealLaunch([string]$Kind, [string[]]$Arguments, [string]$LogPath, + [string]$StdoutPath, [string]$StderrPath) { + Assert-NoForeignUnreal "before $Kind launch" + Ensure-Directory (Split-Path -Parent $LogPath) + Ensure-Directory (Split-Path -Parent $StdoutPath) + Ensure-Directory (Split-Path -Parent $StderrPath) + $startedUtc = [datetime]::UtcNow + $crashesBefore = Get-CrashSnapshot + $reportersBefore = @(Get-CrashReporterProcesses) + $ledger = [ordered]@{ + kind = $Kind + pid = $null + started_utc = $startedUtc.ToString('o') + ended_utc = $null + exit_code = $null + timed_out = $false + crash_reporters_reaped = @() + new_crashes = @() + log = $LogPath + stdout = $StdoutPath + stderr = $StderrPath + arguments = @($Arguments) + status = 'not_started' + } + try { + $process = Start-Process -FilePath $EditorExe -ArgumentList $Arguments -WorkingDirectory $HostRoot ` + -RedirectStandardOutput $StdoutPath -RedirectStandardError $StderrPath -WindowStyle Hidden -PassThru + $ledger.pid = [int]$process.Id + $wait = Wait-OwnedProcess $process $LaunchTimeoutSeconds + $ledger.timed_out = [bool]$wait.timed_out + $ledger.exit_code = $wait.exit_code + $ledger.status = if ($ledger.timed_out) { 'timeout' } elseif ($ledger.exit_code -eq 0) { 'passed' } else { 'failed' } + } catch { + $ledger.status = 'launch_error' + $ledger.error = $_.Exception.Message + } + $ledger.ended_utc = [datetime]::UtcNow.ToString('o') + $ledger.crash_reporters_reaped = @(Reap-OwnCrashReporters $reportersBefore $startedUtc $LogPath) + $ledger.new_crashes = @(Get-NewCrashes $crashesBefore $startedUtc) + [void]$LaunchLedger.Add($ledger) + if ($ledger.status -ne 'passed') { + Add-Failure "$Kind launch $($ledger.status) (PID=$($ledger.pid), exit=$($ledger.exit_code))" + } + if ($ledger.new_crashes.Count -gt 0) { + Add-Failure "$Kind produced new UECC crash directory: $($ledger.new_crashes[0])" + } + return $ledger +} + +function Invoke-BuildProcess([string[]]$Arguments, [string]$LogPath, [string]$ErrorPath, + [string]$StdoutPath = $LogPath) { + Ensure-Directory (Split-Path -Parent $LogPath) + $dotnetHome = Join-Path $RunRoot 'DotnetHome' + Ensure-Directory $dotnetHome + $dotnet = (Get-Command dotnet.exe -ErrorAction Stop).Source + $started = [datetime]::UtcNow + $oldDotnetCliHome = $env:DOTNET_CLI_HOME + $oldDotnetFirstUse = $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE + $oldDotnetNoLogo = $env:DOTNET_NOLOGO + try { + # Keep the build tool's first-use sentinel and package metadata inside this run. This is + # separate from Unreal's LocalAppData guard and avoids touching the owner's profile. + $env:DOTNET_CLI_HOME = $dotnetHome + $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1' + $env:DOTNET_NOLOGO = '1' + $process = Start-Process -FilePath $dotnet -ArgumentList $Arguments -WorkingDirectory $PluginRoot ` + -RedirectStandardOutput $StdoutPath -RedirectStandardError $ErrorPath -WindowStyle Hidden -PassThru + $wait = Wait-OwnedProcess $process $LaunchTimeoutSeconds + } finally { + if ($null -eq $oldDotnetCliHome) { Remove-Item Env:DOTNET_CLI_HOME -ErrorAction SilentlyContinue } else { $env:DOTNET_CLI_HOME = $oldDotnetCliHome } + if ($null -eq $oldDotnetFirstUse) { Remove-Item Env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE -ErrorAction SilentlyContinue } else { $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = $oldDotnetFirstUse } + if ($null -eq $oldDotnetNoLogo) { Remove-Item Env:DOTNET_NOLOGO -ErrorAction SilentlyContinue } else { $env:DOTNET_NOLOGO = $oldDotnetNoLogo } + } + return [ordered]@{ + tool = $dotnet + pid = [int]$process.Id + started_utc = $started.ToString('o') + ended_utc = [datetime]::UtcNow.ToString('o') + exit_code = $wait.exit_code + timed_out = [bool]$wait.timed_out + log = $LogPath + stdout = $StdoutPath + error_log = $ErrorPath + arguments = @($Arguments) + status = if ($wait.timed_out) { 'timeout' } elseif ($wait.exit_code -eq 0) { 'passed' } else { 'failed' } + } +} + +function Copy-DirectoryContents([string]$Source, [string]$Destination) { + Ensure-Directory $Destination + foreach ($item in @(Get-ChildItem -LiteralPath $Source -Force)) { + $target = Join-Path $Destination $item.Name + $null = Copy-Item -LiteralPath $item.FullName -Destination $target -Recurse -Force -PassThru + } +} + +function Sync-StagedHost { + if (-not (Test-Path -LiteralPath $HostProject -PathType Leaf)) { + throw "Staged host project is missing: $HostProject" + } + if (-not (Test-Path -LiteralPath $StagePlugin -PathType Container)) { + throw "Staged VoxelForge plugin is missing: $StagePlugin" + } + $stageSource = Join-Path $StagePlugin 'Source' + if (Test-Path -LiteralPath $stageSource -PathType Container) { + if (-not (Test-UnderPath $stageSource $StagePlugin)) { + throw "Refusing to clean a staged source path outside the staged plugin: $stageSource" + } + Remove-Item -LiteralPath $stageSource -Recurse -Force + } + Copy-DirectoryContents (Join-Path $PluginRoot 'Source') $stageSource + $null = Copy-Item -LiteralPath (Join-Path $PluginRoot 'VoxelForge.uplugin') ` + -Destination (Join-Path $StagePlugin 'VoxelForge.uplugin') -Force -PassThru + + # The staged map is a host copy; these copies never write the owner's project. + $stageContent = Join-Path $HostRoot 'Content' + $stageVoxelContent = Join-Path $stageContent 'VoxelForge' + Ensure-Directory $stageVoxelContent + foreach ($relative in @('Untitled.umap', 'VoxelForge\BP_VoxelWorld.uasset')) { + $source = Join-Path (Join-Path $ProjectRoot 'Content') $relative + if (Test-Path -LiteralPath $source -PathType Leaf) { + $target = Join-Path $stageContent $relative + Ensure-Directory (Split-Path -Parent $target) + $null = Copy-Item -LiteralPath $source -Destination $target -Force -PassThru + } + } +} + +function Sync-OwnerAssets { + $rows = [System.Collections.Generic.List[object]]::new() + foreach ($assetName in @('DA_Strate3', 'DA_Settings')) { + $source = Join-Path $ProjectRoot "Content\VoxelForge\$assetName.uasset" + $target = Join-Path $HostRoot "Content\VoxelForge\$assetName.uasset" + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "Owner asset is missing: $source" + } + Ensure-Directory (Split-Path -Parent $target) + $null = Copy-Item -LiteralPath $source -Destination $target -Force -PassThru + [void]$rows.Add([ordered]@{ + name = $assetName + used_by_scenario = $Scenario -in @('owner', 'probe', 'parity') + source_path = $source + source_sha256 = Get-Sha256 $source + staged_path = $target + staged_sha256 = Get-Sha256 $target + }) + } + return @($rows) +} + +function Get-AssetEvidence { + $rows = [System.Collections.Generic.List[object]]::new() + foreach ($assetName in @('DA_Strate3', 'DA_Settings')) { + $source = Join-Path $ProjectRoot "Content\VoxelForge\$assetName.uasset" + $target = Join-Path $HostRoot "Content\VoxelForge\$assetName.uasset" + [void]$rows.Add([ordered]@{ + name = $assetName + used_by_scenario = $Scenario -in @('owner', 'probe', 'parity') + source_path = $source + source_sha256 = Get-Sha256 $source + staged_path = $target + staged_sha256 = Get-Sha256 $target + }) + } + return @($rows) +} + +function Get-ValueText($Value) { + if ($Value -is [bool]) { return $(if ($Value) { '1' } else { '0' }) } + if ($Value -is [System.Array]) { return (($Value | ForEach-Object { Get-ValueText $_ }) -join ',') } + return [string]$Value +} + +function Merge-Cvars([System.Collections.IDictionary]$Base) { + $merged = [ordered]@{} + foreach ($key in $Base.Keys) { $merged[[string]$key] = $Base[$key] } + if ($null -ne $Cvars) { + foreach ($key in $Cvars.Keys) { $merged[[string]$key] = $Cvars[$key] } + } + return $merged +} + +function New-CommonArguments([string]$Kind, [string]$LogPath, $CvarMap) { + $safeLabel = ($Label -replace '[^A-Za-z0-9_.-]', '_') + $args = [System.Collections.Generic.List[string]]::new() + # Quoting the whole -project= token is intentional: this is the failure mode that used to + # swallow every argument after "Projet Unreal". + [void]$args.Add(('-project="{0}"' -f $HostProject)) + [void]$args.Add('-nullrhi') + [void]$args.Add('-DDC-ForceMemoryCache') + [void]$args.Add('-unattended') + [void]$args.Add('-nop4') + [void]$args.Add('-nosplash') + [void]$args.Add('-NoSound') + [void]$args.Add('-stdout') + [void]$args.Add('-FullStdOutLogOutput') + [void]$args.Add('-forcelogflush') + [void]$args.Add('-NoZenAutoLaunch') + [void]$args.Add('-nocrashreports') + [void]$args.Add('-WindowsPlatformCrashContext.ForceCrashReportDialogOff=1') + [void]$args.Add("-enginesaveddirsuffix=VoxelForgeTest_$safeLabel") + [void]$args.Add(('-userdir="{0}"' -f (Join-Path $RunRoot 'User'))) + [void]$args.Add(('-ZenDataPath="{0}"' -f (Join-Path $RunRoot 'ZenData'))) + [void]$args.Add('-ddc=NoZenLocalFallback') + [void]$args.Add(('-LocalDataCachePath="{0}"' -f (Join-Path $RunRoot 'DDC'))) + [void]$args.Add(('-abslog="{0}"' -f $LogPath)) + if ($Kind -eq 'commandlet') { + [void]$args.Add('-run=VoxelForgeExplore') + } elseif ($Kind -eq 'game') { + [void]$args.Add('-game') + [void]$args.Add('-map=/Game/Untitled') + } + foreach ($key in @($CvarMap.Keys | Sort-Object)) { + $switch = [string]$key + if (-not $switch.StartsWith('-')) { $switch = "-$switch" } + $value = Get-ValueText $CvarMap[$key] + if ($value -match '\s') { + [void]$args.Add(('{0}="{1}"' -f $switch, $value)) + } else { + [void]$args.Add("$switch=$value") + } + } + return @($args) +} + +function New-CommandletArguments([string]$CommandletOut, [string]$Modes, [bool]$UseOwnerAssets, + [bool]$GameRuntimeConfig, [int]$ExportSize = 128, + [int]$Slot = 1) { + $args = [System.Collections.Generic.List[string]]::new() + [void]$args.Add('-seed=0') + [void]$args.Add('-archetype=TunnelNetwork') + [void]$args.Add("-slot=$Slot") + [void]$args.Add('-opstack=1') + [void]$args.Add(('-modes="{0}"' -f $Modes)) + [void]$args.Add('-blockearlyout=0') + [void]$args.Add('-densitygridreuse=1') + [void]$args.Add("-exportsize=$ExportSize") + if ($GameRuntimeConfig) { [void]$args.Add('-gameconfig=1') } + if ($UseOwnerAssets) { + [void]$args.Add('-strateref=/Game/VoxelForge/DA_Strate3.DA_Strate3') + [void]$args.Add('-settingsref=/Game/VoxelForge/DA_Settings.DA_Settings') + } + [void]$args.Add(('-out="{0}"' -f $CommandletOut)) + return @($args) +} + +function Read-JsonFile([string]$Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + return (Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json) +} + +function Get-CommandletMetrics([string]$OutputDirectory, [string]$Use) { + $jsonPath = Join-Path $OutputDirectory 'explore.json' + $json = Read-JsonFile $jsonPath + if ($null -eq $json) { + return [ordered]@{ status = 'missing'; output = $OutputDirectory } + } + $summary = Get-JsonValue $json 'summary' + $export = Get-JsonValue $json 'export' + $walk = Get-JsonValue $json 'walk' + $ledger = Get-JsonValue $summary 'tile_ledger' + $objPath = Join-Path $OutputDirectory 'geometry.obj' + $capabilities = [ordered]@{} + foreach ($name in @('player_fit_volume_cells', 'reachable_player_fit_cells', + 'walk_only_reachable_player_fit_cells', 'climb_reachable_player_fit_cells', + 'arrival_passage_count', 'departure_passage_count', 'triangle_count', + 'tile_count', 'completed_tiles')) { + $value = Get-JsonValue $walk $name $null + if ($null -eq $value) { $value = Get-JsonValue $summary $name $null } + if ($null -eq $value) { $value = Get-JsonValue $export $name $null } + if ($null -ne $value) { $capabilities[$name] = $value } + } + $probe = Get-JsonValue $json 'tunnel_core_probe' + $probeAmplitudes = [ordered]@{} + foreach ($sectionName in @('floor', 'wall', 'ceiling', 'mouth_probe')) { + $section = Get-JsonValue $probe $sectionName $null + if ($null -eq $section) { continue } + foreach ($property in @($section.PSObject.Properties)) { + if ($property.Name -match 'amplitude|period') { + $probeAmplitudes["$sectionName.$($property.Name)"] = $property.Value + } + } + } + foreach ($name in @('periodic_amplitude_voxels', 'periodic_amplitude_m', + 'amplitude_voxels', 'amplitude', 'periodic_period_voxels')) { + $value = Get-JsonValue $probe $name $null + if ($null -ne $value) { $probeAmplitudes[$name] = $value } + } + $meshSeconds = Get-JsonValue $summary 'mesh_seconds' $null + $workerSeconds = Get-JsonValue (Get-JsonValue $summary 'mesher_tasks') 'job_sum_seconds' $meshSeconds + $connectivity = [ordered]@{ + result = Get-JsonValue $walk 'connectivity_result' $null + can_reach_departure = Get-JsonValue $walk 'can_reach_departure' $null + walk_only_can_reach_departure = Get-JsonValue $walk 'walk_only_can_reach_departure' $null + } + return [ordered]@{ + status = Get-JsonValue $json 'status' 'ok' + use = $Use + output = $OutputDirectory + json = $jsonPath + capability_counts = $capabilities + connectivity = $connectivity + geometry_hash = Get-JsonValue $summary 'geometry_hash' (Get-JsonValue $export 'geometry_hash' $null) + obj_sha256 = Get-Sha256 $objPath + obj_path = $objPath + probe_amplitudes = $probeAmplitudes + worker_seconds = $workerSeconds + mesh_seconds = $meshSeconds + tile_ledger = $ledger + input = Get-JsonValue $json 'input' $null + game_runtime_config = Get-JsonValue (Get-JsonValue $json 'input') 'game_runtime_config' $false + } +} + +function Get-StreamingMetrics([string]$LogPath) { + $lineMatch = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeStreamingLatency\]' -SimpleMatch:$false -ErrorAction SilentlyContinue | + Select-Object -Last 1) + if ($lineMatch.Count -eq 0) { return [ordered]@{ status = 'missing'; log = $LogPath } } + $line = [string]$lineMatch[0].Line + $metrics = [ordered]@{ status = 'ok'; log = $LogPath; raw = $line } + $patterns = [ordered]@{ + lod0_ready_samples = 'lod0_ready_samples=(?[-+0-9.eE]+)' + request_to_ready_p50 = 'request_to_ready_s\[p50=(?[-+0-9.eE]+)' + request_to_ready_p95 = 'request_to_ready_s\[p50=[^]]*p95=(?[-+0-9.eE]+)' + queue_wait_p50 = 'queue_wait_s\[p50=(?[-+0-9.eE]+)' + queue_wait_p95 = 'queue_wait_s\[p50=[^]]*p95=(?[-+0-9.eE]+)' + generation_p50 = 'generation_s\[p50=(?[-+0-9.eE]+)' + generation_p95 = 'generation_s\[p50=[^]]*p95=(?[-+0-9.eE]+)' + worker_generation_sum_s = 'worker_generation_sum_s=(?[-+0-9.eE]+)' + applied_tiles = 'applied_tiles=(?[-+0-9.eE]+)' + applied_triangles = 'applied_triangles=(?[-+0-9.eE]+)' + } + foreach ($key in $patterns.Keys) { + $match = [regex]::Match($line, $patterns[$key]) + if ($match.Success) { $metrics[$key] = $match.Groups['v'].Value } + } + $metrics['p50_p95'] = [ordered]@{ + request_to_ready = [ordered]@{ p50 = $metrics['request_to_ready_p50']; p95 = $metrics['request_to_ready_p95'] } + queue_wait = [ordered]@{ p50 = $metrics['queue_wait_p50']; p95 = $metrics['queue_wait_p95'] } + generation = [ordered]@{ p50 = $metrics['generation_p50']; p95 = $metrics['generation_p95'] } + } + return $metrics +} + +function Get-TestMetrics($Ledger) { + if ($null -eq $Ledger) { return @() } + $logPaths = @($Ledger.log, $Ledger.stdout, $Ledger.stderr) | Where-Object { + $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) + } + $rows = [System.Collections.Generic.List[object]]::new() + $seen = @{} + foreach ($path in $logPaths) { + foreach ($matchLine in @(Select-String -LiteralPath $path -Pattern 'Test Completed\. Result=' -ErrorAction SilentlyContinue)) { + $match = [regex]::Match([string]$matchLine.Line, + 'Test Completed\. Result=\{(?[^}]+)\} Name=\{(?[^}]+)\} Path=\{(?[^}]+)\}') + if (-not $match.Success) { continue } + $testPath = $match.Groups['path'].Value + $key = if ([string]::IsNullOrWhiteSpace($testPath)) { $match.Groups['name'].Value } else { $testPath } + if ($seen.ContainsKey($key)) { continue } + $seen[$key] = $true + $rawResult = $match.Groups['result'].Value + $status = if ($rawResult -match '^(Success|Passed|Pass)$') { 'passed' } else { 'failed' } + [void]$rows.Add([ordered]@{ + name = $match.Groups['name'].Value + path = $testPath + status = $status + raw_result = $rawResult + }) + } + } + return @($rows | Sort-Object path, name) +} + +function Get-ParityKey($Tile) { + return "$($Tile.absolute_tile_x):$($Tile.absolute_tile_y):$($Tile.absolute_tile_z):$($Tile.lod)" +} + +function Get-ParityConfigEvidence([string]$CommandletLogPath, [string]$GameLogPath) { + $pattern = '\[StrateManager\] (Strate |Initialized|Operator-stack)|\[VoxelForgeExplore\]\[OwnerAssets\]' + $commandletLines = @() + $gameLines = @() + if (Test-Path -LiteralPath $CommandletLogPath -PathType Leaf) { + $commandletLines = @(Select-String -LiteralPath $CommandletLogPath -Pattern $pattern -ErrorAction SilentlyContinue | + ForEach-Object { $_.Line }) + } + if (Test-Path -LiteralPath $GameLogPath -PathType Leaf) { + $gameLines = @(Select-String -LiteralPath $GameLogPath -Pattern $pattern -ErrorAction SilentlyContinue | + ForEach-Object { $_.Line }) + } + $getCount = { + param([object[]]$Lines) + foreach ($line in $Lines) { + $match = [regex]::Match([string]$line, 'Initialized (?\d+) strates') + if ($match.Success) { return [int]$match.Groups['count'].Value } + } + return $null + } + $commandletCount = & $getCount $commandletLines + $gameCount = & $getCount $gameLines + return [ordered]@{ + commandlet = [ordered]@{ + initialized_strates = $commandletCount + relevant_log_lines = @($commandletLines | Select-Object -Last 30) + } + game = [ordered]@{ + initialized_strates = $gameCount + relevant_log_lines = @($gameLines | Select-Object -Last 30) + } + layout_matches = ($null -ne $commandletCount -and $null -ne $gameCount -and $commandletCount -eq $gameCount) + interpretation = 'StrateManager lines expose the authored settings/strate layout used by each path; tile step/cells and content-band fields remain in the per-tile records.' + } +} + +function Compare-Parity($CommandletJson, [string]$GameDumpPath, + [string]$CommandletLogPath, [string]$GameLogPath) { + $expected = @((Get-JsonValue (Get-JsonValue $CommandletJson 'summary') 'tile_ledger').tiles) + $gameJson = Read-JsonFile $GameDumpPath + $configEvidence = Get-ParityConfigEvidence $CommandletLogPath $GameLogPath + if ($null -eq $gameJson) { + return [ordered]@{ + status = 'failed' + reason = "game tile hash dump missing: $GameDumpPath" + configuration = $configEvidence + } + } + $gameByKey = @{} + foreach ($tile in @((Get-JsonValue $gameJson 'records'))) { + $key = "$($tile.tile_x):$($tile.tile_y):$($tile.tile_z):$($tile.level)" + $gameByKey[$key] = $tile + } + $firstDifference = $null + foreach ($tile in $expected) { + $key = Get-ParityKey $tile + if (-not $gameByKey.ContainsKey($key)) { + $firstDifference = [ordered]@{ tile = $key; reason = 'missing_from_game'; commandlet = $tile } + break + } + $gameTile = $gameByKey[$key] + if ([string]$tile.geometry_hash -ne [string]$gameTile.geometry_hash) { + $diagnosis = [System.Collections.Generic.List[string]]::new() + if ([int]$gameTile.step -ne 1 -or [int]$gameTile.cells -ne 32) { + [void]$diagnosis.Add('LOD/step/cells differ from the level-0 export') + } + if ([int]$gameTile.band_chunk_lo -ne -2147483648 -or [int]$gameTile.band_chunk_hi -ne 2147483647) { + [void]$diagnosis.Add('runtime content-band cut is active') + } + if ($configEvidence.layout_matches -eq $false) { + [void]$diagnosis.Add(('settings/strate layout differs: commandlet initialized {0}, game initialized {1}' -f + $configEvidence.commandlet.initialized_strates, $configEvidence.game.initialized_strates)) + } + [void]$diagnosis.Add('cvar defaults and authored settings are recorded in both artifacts') + [void]$diagnosis.Add('if those inputs match, the remaining candidate is the fused evaluator/runtime route') + $firstDifference = [ordered]@{ + tile = $key + reason = 'geometry_hash_mismatch' + commandlet_hash = [string]$tile.geometry_hash + game_hash = [string]$gameTile.geometry_hash + commandlet_tile = $tile + game_tile = $gameTile + diagnosis = @($diagnosis) + } + break + } + } + if ($null -ne $firstDifference) { + return [ordered]@{ + status = 'failed' + first_difference = $firstDifference + commandlet_tile_count = $expected.Count + game_record_count = @((Get-JsonValue $gameJson 'records')).Count + game_dump = $GameDumpPath + configuration = $configEvidence + } + } + return [ordered]@{ + status = 'passed' + compared_tile_count = $expected.Count + commandlet_tile_count = $expected.Count + game_record_count = @((Get-JsonValue $gameJson 'records')).Count + game_dump = $GameDumpPath + configuration = $configEvidence + } +} + +try { + if ([string]::IsNullOrWhiteSpace($Label)) { $Label = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss') } + $Label = ($Label -replace '[^A-Za-z0-9_.-]', '_') + if ([string]::IsNullOrWhiteSpace($Out)) { + $Out = Join-Path $PluginRoot "Saved\TestHarness\${Label}_${Scenario}" + } + elseif (-not [IO.Path]::IsPathRooted($Out)) { + throw "-Out must be an absolute path: $Out" + } + $RunRoot = Get-FullPath $Out + if (-not (Test-UnderPath $RunRoot (Join-Path $PluginRoot 'Saved'))) { + throw "-Out must stay under the plugin Saved directory: $RunRoot" + } + Ensure-Directory $RunRoot + $ResultPath = Join-Path $RunRoot 'result.json' + $SummaryPath = Join-Path $RunRoot 'summary.txt' + Ensure-Directory (Join-Path $RunRoot 'Logs') + Ensure-Directory (Join-Path $RunRoot 'User') + Ensure-Directory (Join-Path $RunRoot 'ZenData') + Ensure-Directory (Join-Path $RunRoot 'DDC') + $localBefore = Get-StateSnapshot $LocalAppDataRoot + + foreach ($required in @($EditorExe, $UbtDll, $HostProject, $ProjectFile)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Required file is missing: $required" } + } + if ($Build) { + $sourceBefore = Get-SourceSnapshot $PluginRoot + $stageBefore = Get-SourceSnapshot $StagePlugin + $sourceChangedKeys = @(Get-ChangedSnapshotKeys $stageBefore $sourceBefore) + $runtimeBefore = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForge.dll') + $editorBefore = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForgeEditor.dll') + Assert-NoForeignUnreal 'before staged sync/build' + Sync-StagedHost + $buildLog = Join-Path $RunRoot 'Logs\ubt.log' + $buildStdout = Join-Path $RunRoot 'Logs\build.stdout.log' + $buildError = Join-Path $RunRoot 'Logs\build.err.log' + $buildArgs = @(('"{0}"' -f $UbtDll), 'UnrealEditor', 'Win64', 'Development', ('-Project="{0}"' -f $HostProject), + '-WaitMutex', '-FromMsBuild', '-architecture=x64', '-NoUBA', ('-Log="{0}"' -f $buildLog)) + $BuildRecord = Invoke-BuildProcess $buildArgs $buildLog $buildError $buildStdout + if ($BuildRecord.status -ne 'passed') { Add-Failure "staged build $($BuildRecord.status)" } + $runtimeAfter = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForge.dll') + $editorAfter = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForgeEditor.dll') + $BuildRecord.source_changed_keys = @($sourceChangedKeys) + $BuildRecord.runtime_dll_before = $runtimeBefore + $BuildRecord.runtime_dll_after = $runtimeAfter + $BuildRecord.editor_dll_before = $editorBefore + $BuildRecord.editor_dll_after = $editorAfter + $runtimeChanged = @($sourceChangedKeys | Where-Object { $_ -like 'source\voxelforge\*' }).Count -gt 0 + $editorChanged = @($sourceChangedKeys | Where-Object { $_ -like 'source\voxelforgeeditor\*' }).Count -gt 0 + if ($runtimeChanged -and $runtimeBefore -eq $runtimeAfter) { + throw 'Source changed under Source/VoxelForge but the staged runtime DLL SHA-256 did not change.' + } + if ($editorChanged -and $editorBefore -eq $editorAfter) { + throw 'Source changed under Source/VoxelForgeEditor but the staged editor DLL SHA-256 did not change.' + } + } + if ($Assets -eq 'owner') { + $AssetEvidence = @(Sync-OwnerAssets) + } else { + $AssetEvidence = @(Get-AssetEvidence) + } + $DllEvidence.runtime = [ordered]@{ + path = Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForge.dll' + sha256 = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForge.dll') + } + $DllEvidence.editor = [ordered]@{ + path = Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForgeEditor.dll' + sha256 = Get-Sha256 (Join-Path $StagePlugin 'Binaries\Win64\UnrealEditor-VoxelForgeEditor.dll') + } + if (-not $DllEvidence.runtime.sha256 -or -not $DllEvidence.editor.sha256) { + throw "Staged runtime/editor DLL is missing under $StagePlugin; use -Build." + } + + # VoxelForgeExplore deliberately rejects output outside its staged plugin Saved root. The + # harness result stays in $RunRoot; this is the commandlet's internal artifact directory. + $commandletOut = Join-Path $StagePlugin "Saved\TestHarness\${Label}_${Scenario}" + $baseCvars = [ordered]@{} + $commandletLedger = $null + $gameLedger = $null + switch ($Scenario) { + 'canonical' { + $baseCvars['voxel.OuterClassifierMode'] = 0 + $args = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\canonical.log') (Merge-Cvars $baseCvars) + $args += New-CommandletArguments $commandletOut 'walk,export,tunnelcoreprobe' $false $false 128 + $commandletLedger = Invoke-UnrealLaunch 'canonical-commandlet' $args ` + (Join-Path $RunRoot 'Logs\canonical.log') (Join-Path $RunRoot 'Logs\canonical.stdout.log') (Join-Path $RunRoot 'Logs\canonical.stderr.log') + $CommandletMetrics = Get-CommandletMetrics $commandletOut 'synthetic canonical' + $canonicalObjSha = Get-JsonValue $CommandletMetrics 'obj_sha256' + if ($canonicalObjSha -and $canonicalObjSha -ne $CanonicalObjSha256) { + Add-Failure "canonical OBJ SHA-256 changed: expected $CanonicalObjSha256, got $canonicalObjSha" + } + } + 'owner' { + $args = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\owner.log') (Merge-Cvars $baseCvars) + $args += New-CommandletArguments $commandletOut 'walk,export' ($Assets -eq 'owner') $false 128 + $commandletLedger = Invoke-UnrealLaunch 'owner-commandlet' $args ` + (Join-Path $RunRoot 'Logs\owner.log') (Join-Path $RunRoot 'Logs\owner.stdout.log') (Join-Path $RunRoot 'Logs\owner.stderr.log') + $CommandletMetrics = Get-CommandletMetrics $commandletOut 'owner assets' + } + 'probe' { + $args = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\probe.log') (Merge-Cvars $baseCvars) + $args += New-CommandletArguments $commandletOut 'tunnelcoreprobe' ($Assets -eq 'owner') $false 128 + $commandletLedger = Invoke-UnrealLaunch 'probe-commandlet' $args ` + (Join-Path $RunRoot 'Logs\probe.log') (Join-Path $RunRoot 'Logs\probe.stdout.log') (Join-Path $RunRoot 'Logs\probe.stderr.log') + $CommandletMetrics = Get-CommandletMetrics $commandletOut 'owner probe' + } + 'perf' { + $baseCvars['voxel.TestExitSeconds'] = 15 + $baseCvars['voxel.ProfileTileGeneration'] = 1 + $baseCvars['voxel.UseBlockEarlyOut'] = 0 + $baseCvars['voxel.OuterClassifierMode'] = 0 + $baseCvars['voxel.SealedSolidProof'] = 0 + $args = New-CommonArguments 'game' (Join-Path $RunRoot 'Logs\perf.log') (Merge-Cvars $baseCvars) + $gameLedger = Invoke-UnrealLaunch 'perf-game' $args ` + (Join-Path $RunRoot 'Logs\perf.log') (Join-Path $RunRoot 'Logs\perf.stdout.log') (Join-Path $RunRoot 'Logs\perf.stderr.log') + $GameMetrics = Get-StreamingMetrics (Join-Path $RunRoot 'Logs\perf.log') + } + 'tests' { + $baseCvars['ExecCmds'] = 'Automation RunTests VoxelForge; Quit' + $baseCvars['ReportExportPath'] = (Join-Path $RunRoot 'AutomationReport') + $args = New-CommonArguments 'tests' (Join-Path $RunRoot 'Logs\tests.log') (Merge-Cvars $baseCvars) + # ExecCmds is a command-line value, not a cvar; replace the generated -ExecCmds token + # with the quoted form accepted by Unreal's parser. + $args = @($args | Where-Object { $_ -notlike '-ExecCmds=*' -and $_ -notlike '-ReportExportPath=*' }) + $args += '-ExecCmds="Automation RunTests VoxelForge; Quit"' + $args += ('-ReportExportPath="{0}"' -f (Join-Path $RunRoot 'AutomationReport')) + $gameLedger = Invoke-UnrealLaunch 'automation-tests' $args ` + (Join-Path $RunRoot 'Logs\tests.log') (Join-Path $RunRoot 'Logs\tests.stdout.log') (Join-Path $RunRoot 'Logs\tests.stderr.log') + $TestMetrics = @(Get-TestMetrics $gameLedger) + if ($TestMetrics.Count -eq 0 -and $gameLedger.status -eq 'passed') { + Add-Failure 'automation suite produced no per-test completion records' + } + $failedTests = @($TestMetrics | Where-Object { $_.status -eq 'failed' }) + if ($failedTests.Count -gt 0) { + $failedNames = ($failedTests | ForEach-Object { $_.path }) -join ', ' + Add-Failure "automation tests failed: $failedNames" + } + } + 'parity' { + $parityArgs = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\parity-commandlet.log') (Merge-Cvars $baseCvars) + # Use the same 128-voxel / 64-tile bounded region as the canonical exporter. The game + # harness streams a larger clipmap around its center; comparison is restricted to these + # exact level-0 tiles, including non-empty geometry rather than only an air tile. + $parityArgs += New-CommandletArguments $commandletOut 'export' $true $true 128 0 + $commandletLedger = Invoke-UnrealLaunch 'parity-commandlet' $parityArgs ` + (Join-Path $RunRoot 'Logs\parity-commandlet.log') (Join-Path $RunRoot 'Logs\parity-commandlet.stdout.log') (Join-Path $RunRoot 'Logs\parity-commandlet.stderr.log') + $CommandletMetrics = Get-CommandletMetrics $commandletOut 'owner assets; game_runtime_config=1' + $parityJson = Read-JsonFile (Join-Path $commandletOut 'explore.json') + if ($null -eq $parityJson) { throw 'Parity commandlet did not produce explore.json.' } + $parityExport = Get-JsonValue $parityJson 'export' + $region = Get-JsonValue $parityExport 'region_origin_voxels' + if ($null -eq $region) { throw 'Parity commandlet did not report export.region_origin_voxels.' } + $paritySize = [int](Get-JsonValue $parityExport 'region_size_cells_per_axis' 128) + $centerOffset = [int]($paritySize / 2) + $centerX = [int]$region.x + $centerOffset + $centerY = [int]$region.y + $centerOffset + $centerZ = [int]$region.z + $centerOffset + $gameDump = Join-Path $RunRoot 'game_tile_hashes.json' + $baseCvars['voxel.TestExitSeconds'] = 15 + $baseCvars['voxel.StreamingTestCenterX'] = $centerX + $baseCvars['voxel.StreamingTestCenterY'] = $centerY + $baseCvars['voxel.StreamingTestCenterZ'] = $centerZ + $baseCvars['voxel.TileHashDump'] = $gameDump + $baseCvars['voxel.UseBlockEarlyOut'] = 0 + $baseCvars['voxel.OuterClassifierMode'] = 0 + $baseCvars['voxel.SealedSolidProof'] = 0 + $gameArgs = New-CommonArguments 'game' (Join-Path $RunRoot 'Logs\parity-game.log') (Merge-Cvars $baseCvars) + $gameLedger = Invoke-UnrealLaunch 'parity-game' $gameArgs ` + (Join-Path $RunRoot 'Logs\parity-game.log') (Join-Path $RunRoot 'Logs\parity-game.stdout.log') (Join-Path $RunRoot 'Logs\parity-game.stderr.log') + $GameMetrics = Get-StreamingMetrics (Join-Path $RunRoot 'Logs\parity-game.log') + $ParityEvidence = Compare-Parity $parityJson $gameDump ` + (Join-Path $RunRoot 'Logs\parity-commandlet.log') (Join-Path $RunRoot 'Logs\parity-game.log') + if ($ParityEvidence.status -ne 'passed') { + $difference = Get-JsonValue $ParityEvidence 'first_difference' $null + if ($null -ne $difference) { + Add-Failure "parity verdict failed: first differing tile $($difference.tile) ($($difference.reason))" + } else { + Add-Failure "parity verdict failed: $(Get-JsonValue $ParityEvidence 'reason' 'unknown parity failure')" + } + } + } + } + $localAfter = Get-StateSnapshot $LocalAppDataRoot + $LocalAppDataWrites = @(Get-StateChanges $localBefore $localAfter) + if ($LocalAppDataWrites.Count -gt 0) { + Add-Failure "writes detected under ${LocalAppDataRoot}: $($LocalAppDataWrites[0])" + } +} catch { + Add-Failure $_.Exception.Message +} + +if ($null -eq $RunRoot) { + $RunRoot = Join-Path $PluginRoot 'Saved\TestHarness\failed-before-output' + Ensure-Directory $RunRoot +} +if ($null -eq $ResultPath) { $ResultPath = Join-Path $RunRoot 'result.json' } +if ($null -eq $SummaryPath) { $SummaryPath = Join-Path $RunRoot 'summary.txt' } + +$pass = $Failures.Count -eq 0 +$reason = if ($pass) { 'all requested checks passed' } else { [string]$Failures[0] } +$result = [ordered]@{ + schema_version = 1 + pass = $pass + status = if ($pass) { 'PASS' } else { 'FAIL' } + reason = $reason + scenario = $Scenario + label = $Label + run_directory = $RunRoot + project = $ProjectFile + staged_host = $HostProject + assets_mode = $Assets + requested_cvars = $Cvars + canonical_baseline = [ordered]@{ + commit = $CanonicalBaselineCommit + expected_obj_sha256 = $CanonicalObjSha256 + checked = $Scenario -eq 'canonical' + } + assets = @($AssetEvidence) + build = $BuildRecord + staged_dlls = $DllEvidence + capability_counts = if ($CommandletMetrics.Contains('capability_counts')) { $CommandletMetrics.capability_counts } else { [ordered]@{} } + connectivity = if ($CommandletMetrics.Contains('connectivity')) { $CommandletMetrics.connectivity } else { [ordered]@{} } + geometry_hash = if ($CommandletMetrics.Contains('geometry_hash')) { $CommandletMetrics.geometry_hash } else { $null } + obj_sha256 = if ($CommandletMetrics.Contains('obj_sha256')) { $CommandletMetrics.obj_sha256 } else { $null } + probe_amplitudes = if ($CommandletMetrics.Contains('probe_amplitudes')) { $CommandletMetrics.probe_amplitudes } else { [ordered]@{} } + commandlet = $CommandletMetrics + game = $GameMetrics + worker_seconds = [ordered]@{ + commandlet = if ($CommandletMetrics.Contains('worker_seconds')) { $CommandletMetrics.worker_seconds } else { $null } + game = if ($GameMetrics.Contains('worker_generation_sum_s')) { $GameMetrics.worker_generation_sum_s } else { $null } + } + p50_p95 = if ($GameMetrics.Contains('p50_p95')) { $GameMetrics.p50_p95 } else { [ordered]@{} } + tests = @($TestMetrics) + parity = $ParityEvidence + localappdata_unrealengine = [ordered]@{ + path = $LocalAppDataRoot + writes_detected = @($LocalAppDataWrites) + verified_clean = $LocalAppDataWrites.Count -eq 0 + } + failures = @($Failures) + launch_ledger = @($LaunchLedger) +} + +try { + Ensure-Directory (Split-Path -Parent $ResultPath) + ($result | ConvertTo-Json -Compress -Depth 30) | Set-Content -LiteralPath $ResultPath -Encoding UTF8 + $summaryLines = [System.Collections.Generic.List[string]]::new() + [void]$summaryLines.Add("$(if ($pass) { 'PASS' } else { 'FAIL' }) $reason") + [void]$summaryLines.Add("scenario=$Scenario label=$Label") + [void]$summaryLines.Add("run_directory=$RunRoot") + [void]$summaryLines.Add("assets=$Assets") + [void]$summaryLines.Add("canonical_baseline=$(ConvertTo-CompactJson $result.canonical_baseline)") + [void]$summaryLines.Add("capability_counts=$(ConvertTo-CompactJson $result.capability_counts)") + [void]$summaryLines.Add("connectivity=$(ConvertTo-CompactJson $result.connectivity)") + [void]$summaryLines.Add("geometry_hash=$($result.geometry_hash)") + [void]$summaryLines.Add("obj_sha256=$($result.obj_sha256)") + [void]$summaryLines.Add("probe_amplitudes=$(ConvertTo-CompactJson $result.probe_amplitudes)") + [void]$summaryLines.Add("worker_seconds=$(ConvertTo-CompactJson $result.worker_seconds)") + [void]$summaryLines.Add("p50_p95=$(ConvertTo-CompactJson $result.p50_p95)") + [void]$summaryLines.Add("tests=$(ConvertTo-CompactJson $result.tests)") + [void]$summaryLines.Add("parity=$(ConvertTo-CompactJson $result.parity)") + [void]$summaryLines.Add("localappdata_unrealengine_clean=$($result.localappdata_unrealengine.verified_clean)") + [void]$summaryLines.Add("runtime_dll=$($DllEvidence.runtime.sha256)") + [void]$summaryLines.Add("editor_dll=$($DllEvidence.editor.sha256)") + [void]$summaryLines.Add("unreal_launch_count=$($LaunchLedger.Count)") + foreach ($entry in @($LaunchLedger)) { + [void]$summaryLines.Add("launch kind=$($entry.kind) pid=$($entry.pid) status=$($entry.status) exit=$($entry.exit_code) timeout=$($entry.timed_out) crashes=$(@($entry.new_crashes).Count)") + } + foreach ($failure in @($Failures | Select-Object -Skip 1)) { [void]$summaryLines.Add("failure=$failure") } + $summaryLines | Select-Object -First 40 | Set-Content -LiteralPath $SummaryPath -Encoding UTF8 +} catch { + # The primary result path is already in scope. A failed summary write must still make the + # process non-zero; do not hide the original failure. + Add-Failure "could not write result files: $($_.Exception.Message)" +} + +if ($Failures.Count -gt 0) { + exit 1 +} +exit 0 diff --git a/Tools/__pycache__/AnalyzeVoxelForgePerfProbes.cpython-312.pyc b/Tools/__pycache__/AnalyzeVoxelForgePerfProbes.cpython-312.pyc new file mode 100644 index 0000000..b967c3c Binary files /dev/null and b/Tools/__pycache__/AnalyzeVoxelForgePerfProbes.cpython-312.pyc differ