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:
1 parent
1c642fd182
commit
a7b8d10de6
10 files changed
+1480
-48
No files matched your search
@@ -9,6 +9,10 @@
|
||||
!/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)
|
||||
|
||||
+7
-1
@@ -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=<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). |
|
||||
| `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. |
|
||||
| `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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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()
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/Actor.h"
|
||||
#include "HAL/CriticalSection.h"
|
||||
#include <atomic>
|
||||
#include "VoxelTypes.h"
|
||||
#include "VoxelGenerator.h"
|
||||
@@ -137,6 +138,21 @@ struct FChunkResult
|
||||
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()
|
||||
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<FVoxelTileHashDumpRecord> 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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
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<UVoxelMarchingCubesMesher>(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]);
|
||||
|
||||
@@ -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
Binary file not shown.
Reference in new issue
Block a user