fix(collision gate): stop freezing the player on a stale prediction
The owner fell through a surface-only world and hovered in mid-air: log showed 'ready=1 predicted_wait=1' for 50 s. Two causes, both fixed: (1) engaging the gate zeroes velocity, so the predicted-support probe never re-ran and the gate kept waiting on the tile remembered from when the pawn was still moving -- the prediction is now re-derived every tick from the pawn's current pose; (2) a tile the streamer never requests (out of layout, or outside the desired set) could never become ready, so the wait was unbounded -- such tiles are now logged and treated as nonblocking, and any wait is bounded to 2 s with a warning. Repro added to the harness (surface-fall scenario): before, the pawn stops at Z=1315.9 with collision_gate_prediction_deadlock; after, it reaches the ground in 2.25 s at Z=-45.6. Strate crossing still protected: 145.49 s route, max gate wait 0.109 s (limit 10 s). Perf S/M/S/M 24.265/54.262 and 23.947/53.057 s, within repeat noise. Owner and canonical geometry unchanged (44E7C813 / 94B47A1C). Known red, pre-existing and unrelated: VoxelForge.Composer.ParameterRoll still reports false box verdicts in the owner's current single-strate surface layout (5 violations, identical in the baseline). Next round. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
This commit is contained in:
1 parent
7af8d0e870
commit
0dd45ca410
3 files changed
+696
-34
No files matched your search
@@ -1514,6 +1514,52 @@ void AVoxelWorld::BeginPlay()
|
||||
return;
|
||||
}
|
||||
|
||||
// The owner's current DA_Settings is intentionally a one-strate surface layout, so it cannot
|
||||
// exercise the separate inter-strate traversal regression. The explicit headless crossing
|
||||
// switch uses a transient copy of the authored four-definition corpus; no package is modified
|
||||
// or saved, and ordinary gameplay keeps the assigned settings untouched.
|
||||
if (bHeadlessStrateCrossingTest)
|
||||
{
|
||||
UVoxelSettings* CrossingSettings = DuplicateObject<UVoxelSettings>(Settings, this);
|
||||
TArray<UVoxelStrateDefinition*> CrossingDefinitions;
|
||||
for (int32 DefinitionIndex = 1; DefinitionIndex <= 4; ++DefinitionIndex)
|
||||
{
|
||||
const FString AssetPath = FString::Printf(
|
||||
TEXT("/Game/VoxelForge/DA_Strate%d.DA_Strate%d"),
|
||||
DefinitionIndex, DefinitionIndex);
|
||||
if (UVoxelStrateDefinition* Definition =
|
||||
LoadObject<UVoxelStrateDefinition>(nullptr, *AssetPath))
|
||||
{
|
||||
CrossingDefinitions.Add(Definition);
|
||||
}
|
||||
}
|
||||
if (CrossingSettings != nullptr && CrossingDefinitions.Num() == 4)
|
||||
{
|
||||
CrossingSettings->Season.Reset();
|
||||
CrossingSettings->StratePool.Reset();
|
||||
CrossingSettings->FixedStrates.Reset();
|
||||
CrossingSettings->TotalStrates = CrossingDefinitions.Num();
|
||||
for (int32 DefinitionIndex = 0; DefinitionIndex < CrossingDefinitions.Num();
|
||||
++DefinitionIndex)
|
||||
{
|
||||
CrossingSettings->FixedStrates.Add(
|
||||
DefinitionIndex, CrossingDefinitions[DefinitionIndex]);
|
||||
}
|
||||
Settings = CrossingSettings;
|
||||
UE_LOG(LogTemp, Display,
|
||||
TEXT("[VoxelForgeStrateCrossingTest] fixture=transient_authored_corpus "
|
||||
"strates=%d assets_not_saved=1"),
|
||||
CrossingDefinitions.Num());
|
||||
}
|
||||
else
|
||||
{
|
||||
UE_LOG(LogTemp, Warning,
|
||||
TEXT("[VoxelForgeStrateCrossingTest] fixture=transient_authored_corpus "
|
||||
"unavailable definitions=%d; using assigned settings"),
|
||||
CrossingDefinitions.Num());
|
||||
}
|
||||
}
|
||||
|
||||
if (VoxelForgeStartupTrace::IsActive())
|
||||
{
|
||||
FString SettingsPath = Settings->GetPathName();
|
||||
@@ -1698,6 +1744,26 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest()
|
||||
HeadlessStreamingTestLastActualPosition = FVector::ZeroVector;
|
||||
HeadlessStreamingTestBeginSeconds = 0.0;
|
||||
HeadlessStreamingTestLastElapsedSeconds = 0.0;
|
||||
bHeadlessSurfaceFallTest = false;
|
||||
bHeadlessSurfaceFallTestStartPlaced = false;
|
||||
bHeadlessSurfaceFallTestPassed = false;
|
||||
bHeadlessSurfaceFallTestFailed = false;
|
||||
bHeadlessSurfaceFallTestExitRequested = false;
|
||||
bHeadlessSurfaceFallTestPredictionProbeInjected = false;
|
||||
bHeadlessSurfaceFallTestPredictionProbeCleared = false;
|
||||
HeadlessSurfaceFallTestSpawnHeightVoxels = 20.0f;
|
||||
HeadlessSurfaceFallTestGroundToleranceVoxels = 4.0f;
|
||||
HeadlessSurfaceFallTestTimeoutSeconds = 8.0f;
|
||||
HeadlessSurfaceFallTestPlayerHalfHeightVoxels =
|
||||
VoxelPassageGeometry::PlayerHalfHeightVoxels;
|
||||
HeadlessSurfaceFallTestTerrainZ = 0.0f;
|
||||
HeadlessSurfaceFallTestBeginSeconds = 0.0;
|
||||
HeadlessSurfaceFallTestStartSeconds = 0.0;
|
||||
HeadlessSurfaceFallTestDistanceCm = 0.0;
|
||||
HeadlessSurfaceFallTestStartPosition = FVector::ZeroVector;
|
||||
HeadlessSurfaceFallTestLastActualPosition = FVector::ZeroVector;
|
||||
HeadlessSurfaceFallTestPredictionProbePosition = FVector::ZeroVector;
|
||||
HeadlessSurfaceFallTestFailureReason.Reset();
|
||||
bTileHashDumpEnabled = false;
|
||||
TileHashDumpPath.Reset();
|
||||
TileHashDumpRecords.Reset();
|
||||
@@ -1709,6 +1775,7 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest()
|
||||
bHeadlessStrateCrossingTestFailed = false;
|
||||
bHeadlessStrateCrossingTestExitRequested = false;
|
||||
bHeadlessStrateCrossingTestGateWasEngaged = false;
|
||||
bHeadlessStrateCrossingTestInitialSupportReported = false;
|
||||
HeadlessStrateCrossingTestRequestedPassageIndex = INDEX_NONE;
|
||||
HeadlessStrateCrossingTestPassageIndex = INDEX_NONE;
|
||||
HeadlessStrateCrossingTestUpperStrateIndex = INDEX_NONE;
|
||||
@@ -1806,6 +1873,41 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest()
|
||||
HeadlessStrateCrossingTestRequestedPassageIndex);
|
||||
}
|
||||
|
||||
int32 SurfaceFallValue = 0;
|
||||
bHeadlessSurfaceFallTest =
|
||||
(FParse::Value(CommandLine, TEXT("voxel.TestSurfaceFall="), SurfaceFallValue)
|
||||
&& SurfaceFallValue != 0)
|
||||
|| FParse::Param(CommandLine, TEXT("voxel.TestSurfaceFall"));
|
||||
if (bHeadlessSurfaceFallTest)
|
||||
{
|
||||
if (bHeadlessStrateCrossingTest)
|
||||
{
|
||||
UE_LOG(LogTemp, Error,
|
||||
TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=conflicting_headless_tests"));
|
||||
bHeadlessSurfaceFallTestFailed = true;
|
||||
HeadlessSurfaceFallTestFailureReason = TEXT("conflicting_headless_tests");
|
||||
}
|
||||
FParse::Value(CommandLine, TEXT("voxel.TestSurfaceFallSpawnHeightVoxels="),
|
||||
HeadlessSurfaceFallTestSpawnHeightVoxels);
|
||||
FParse::Value(CommandLine, TEXT("voxel.TestSurfaceFallGroundToleranceVoxels="),
|
||||
HeadlessSurfaceFallTestGroundToleranceVoxels);
|
||||
FParse::Value(CommandLine, TEXT("voxel.TestSurfaceFallTimeoutSeconds="),
|
||||
HeadlessSurfaceFallTestTimeoutSeconds);
|
||||
HeadlessSurfaceFallTestSpawnHeightVoxels = FMath::Max(
|
||||
HeadlessSurfaceFallTestSpawnHeightVoxels, 1.0f);
|
||||
HeadlessSurfaceFallTestGroundToleranceVoxels = FMath::Max(
|
||||
HeadlessSurfaceFallTestGroundToleranceVoxels, 0.5f);
|
||||
HeadlessSurfaceFallTestTimeoutSeconds = FMath::Max(
|
||||
HeadlessSurfaceFallTestTimeoutSeconds, 1.0f);
|
||||
HeadlessSurfaceFallTestBeginSeconds = FPlatformTime::Seconds();
|
||||
UE_LOG(LogTemp, Display,
|
||||
TEXT("[VoxelForgeSurfaceFallTest] configured=1 spawn_height_vox=%.2f "
|
||||
"ground_tolerance_vox=%.2f timeout_s=%.3f"),
|
||||
HeadlessSurfaceFallTestSpawnHeightVoxels,
|
||||
HeadlessSurfaceFallTestGroundToleranceVoxels,
|
||||
HeadlessSurfaceFallTestTimeoutSeconds);
|
||||
}
|
||||
|
||||
int32 TraceThroughCrossingValue = 0;
|
||||
if (FParse::Value(
|
||||
CommandLine,
|
||||
@@ -2151,6 +2253,27 @@ void AVoxelWorld::AdvanceHeadlessStrateCrossingTest(
|
||||
StrateManager->GetPassages()[HeadlessStrateCrossingTestPassageIndex];
|
||||
if (!bHeadlessStrateCrossingTestStartPlaced)
|
||||
{
|
||||
FVoxelTileKey InitialSupportTile;
|
||||
const TCHAR* InitialSupportUnavailableReason = nullptr;
|
||||
if (!IsPlayerSupportCollisionReady(
|
||||
PlayerPawn, InOutPlayerPosition, &InitialSupportTile,
|
||||
&InitialSupportUnavailableReason))
|
||||
{
|
||||
if (!bHeadlessStrateCrossingTestInitialSupportReported)
|
||||
{
|
||||
UE_LOG(LogTemp, Display,
|
||||
TEXT("[VoxelForgeStrateCrossingTest] waiting_for_initial_support "
|
||||
"tile=(%d,%d,%d) reason=%s"),
|
||||
InitialSupportTile.Coord.X, InitialSupportTile.Coord.Y,
|
||||
InitialSupportTile.Coord.Z,
|
||||
InitialSupportUnavailableReason != nullptr
|
||||
? InitialSupportUnavailableReason : TEXT("collision_pending"));
|
||||
bHeadlessStrateCrossingTestInitialSupportReported = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
bHeadlessStrateCrossingTestInitialSupportReported = false;
|
||||
|
||||
if (const ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||||
{
|
||||
if (const UCapsuleComponent* Capsule = Character->GetCapsuleComponent())
|
||||
@@ -2529,6 +2652,319 @@ void AVoxelWorld::AdvanceHeadlessStreamingTest(
|
||||
}
|
||||
}
|
||||
|
||||
void AVoxelWorld::AdvanceHeadlessSurfaceFallTest(
|
||||
FVector& InOutPlayerPosition, FVector& InOutPlayerHeading, APawn* PlayerPawn)
|
||||
{
|
||||
if (!bHeadlessSurfaceFallTest || bHeadlessSurfaceFallTestFailed
|
||||
|| bHeadlessSurfaceFallTestPassed || !IsValid(PlayerPawn)
|
||||
|| HeadlessSurfaceFallTestBeginSeconds <= 0.0 || !Generator || !StrateManager)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const double Now = FPlatformTime::Seconds();
|
||||
if (!bHeadlessSurfaceFallTestStartPlaced)
|
||||
{
|
||||
if (StrateManager->GetNumStrates() != 1
|
||||
|| StrateManager->GetGeneratorTypeForChunk(FIntVector(0, 0, 0))
|
||||
!= ECaveGeneratorType::SurfaceWorld)
|
||||
{
|
||||
bHeadlessSurfaceFallTestFailed = true;
|
||||
HeadlessSurfaceFallTestFailureReason = TEXT("layout_is_not_one_surface_strate");
|
||||
UE_LOG(LogTemp, Error,
|
||||
TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=%s strates=%d"),
|
||||
*HeadlessSurfaceFallTestFailureReason, StrateManager->GetNumStrates());
|
||||
return;
|
||||
}
|
||||
|
||||
float HeightfieldTerrainZ = 0.0f;
|
||||
float CeilingZ = 0.0f;
|
||||
if (!Generator->GetSurfaceHeightAt(
|
||||
0.0f, 0.0f, 0, HeightfieldTerrainZ, CeilingZ))
|
||||
{
|
||||
bHeadlessSurfaceFallTestFailed = true;
|
||||
HeadlessSurfaceFallTestFailureReason = TEXT("surface_height_oracle_unavailable");
|
||||
UE_LOG(LogTemp, Error,
|
||||
TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=%s"),
|
||||
*HeadlessSurfaceFallTestFailureReason);
|
||||
return;
|
||||
}
|
||||
|
||||
if (const ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||||
{
|
||||
if (const UCapsuleComponent* Capsule = Character->GetCapsuleComponent())
|
||||
{
|
||||
const float HalfHeightCm = Capsule->GetScaledCapsuleHalfHeight();
|
||||
if (VoxelMath::IsFinite(HalfHeightCm) && HalfHeightCm > 0.0f)
|
||||
{
|
||||
HeadlessSurfaceFallTestPlayerHalfHeightVoxels =
|
||||
HalfHeightCm / VOXEL_SIZE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int32 TopChunkZ = 0;
|
||||
int32 BottomChunkZ = 0;
|
||||
if (!StrateManager->GetStrateChunkZBounds(0, TopChunkZ, BottomChunkZ))
|
||||
{
|
||||
bHeadlessSurfaceFallTestFailed = true;
|
||||
HeadlessSurfaceFallTestFailureReason = TEXT("surface_strate_bounds_unavailable");
|
||||
UE_LOG(LogTemp, Error,
|
||||
TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=%s"),
|
||||
*HeadlessSurfaceFallTestFailureReason);
|
||||
return;
|
||||
}
|
||||
|
||||
// The authored surface entry is at the top of the one-strate band. The procedural
|
||||
// heightfield can be far below that opening, so use the higher of the two as the spawn
|
||||
// floor; this reproduces a character falling through the surface-only world instead of
|
||||
// accidentally starting next to the eventual terrain.
|
||||
const float SurfaceEntryTopZ = static_cast<float>(TopChunkZ + 1) * CHUNK_SIZE;
|
||||
if (!StrateManager->GetLayout().IsValidIndex(0)
|
||||
|| StrateManager->GetLayout()[0].Definition == nullptr
|
||||
|| Settings == nullptr)
|
||||
{
|
||||
bHeadlessSurfaceFallTestFailed = true;
|
||||
HeadlessSurfaceFallTestFailureReason = TEXT("surface_entry_geometry_unavailable");
|
||||
UE_LOG(LogTemp, Error,
|
||||
TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=%s"),
|
||||
*HeadlessSurfaceFallTestFailureReason);
|
||||
return;
|
||||
}
|
||||
|
||||
const UVoxelStrateDefinition* TopDefinition =
|
||||
StrateManager->GetLayout()[0].Definition;
|
||||
const float TopSeal = TopDefinition->SurfaceParams.BoundarySealThickness;
|
||||
const float OriginRadius = Settings->GetEffectiveOriginSpineRadius();
|
||||
const VoxelPassageGeometry::FOriginLandingGeometry OriginLanding =
|
||||
VoxelPassageGeometry::BuildOriginLandingGeometry(
|
||||
SurfaceEntryTopZ,
|
||||
static_cast<float>(BottomChunkZ) * CHUNK_SIZE,
|
||||
TopSeal,
|
||||
OriginRadius);
|
||||
if (!OriginLanding.bValid)
|
||||
{
|
||||
bHeadlessSurfaceFallTestFailed = true;
|
||||
HeadlessSurfaceFallTestFailureReason = TEXT("surface_entry_landing_unavailable");
|
||||
UE_LOG(LogTemp, Error,
|
||||
TEXT("[VoxelForgeSurfaceFallTest] result=FAIL reason=%s"),
|
||||
*HeadlessSurfaceFallTestFailureReason);
|
||||
return;
|
||||
}
|
||||
HeadlessSurfaceFallTestTerrainZ = OriginLanding.FloorZ;
|
||||
const float GroundHeightfieldZ = HeightfieldTerrainZ;
|
||||
const float SpawnBaseZ = FMath::Max(HeightfieldTerrainZ, SurfaceEntryTopZ);
|
||||
const FVector StartVoxel(
|
||||
0.0f,
|
||||
0.0f,
|
||||
SpawnBaseZ
|
||||
+ HeadlessSurfaceFallTestPlayerHalfHeightVoxels
|
||||
+ HeadlessSurfaceFallTestSpawnHeightVoxels);
|
||||
HeadlessSurfaceFallTestStartPosition = LocalVoxelToWorld(StartVoxel);
|
||||
const bool bPlaced = PlayerPawn->SetActorLocation(
|
||||
HeadlessSurfaceFallTestStartPosition,
|
||||
false,
|
||||
nullptr,
|
||||
ETeleportType::TeleportPhysics);
|
||||
const FVector ActualPosition = PlayerPawn->GetActorLocation();
|
||||
InOutPlayerPosition = ActualPosition;
|
||||
InOutPlayerHeading = FVector::ZeroVector;
|
||||
HeadlessSurfaceFallTestStartPosition = ActualPosition;
|
||||
HeadlessSurfaceFallTestLastActualPosition = ActualPosition;
|
||||
HeadlessSurfaceFallTestDistanceCm = 0.0;
|
||||
bHeadlessSurfaceFallTestStartPlaced = true;
|
||||
HeadlessSurfaceFallTestStartSeconds = Now;
|
||||
|
||||
if (ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||||
{
|
||||
if (UCharacterMovementComponent* CharacterMovement = Character->GetCharacterMovement())
|
||||
{
|
||||
CharacterMovement->StopMovementImmediately();
|
||||
CharacterMovement->SetMovementMode(MOVE_Falling);
|
||||
}
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Display,
|
||||
TEXT("[VoxelForgeSurfaceFallTest] start_placed moved=%d ground_vox=%.3f "
|
||||
"heightfield_vox=%.3f entry_top_vox=%.3f "
|
||||
"spawn_vox=(%.2f,%.2f,%.2f) world=(%.1f,%.1f,%.1f)"),
|
||||
bPlaced ? 1 : 0,
|
||||
HeadlessSurfaceFallTestTerrainZ,
|
||||
GroundHeightfieldZ,
|
||||
SurfaceEntryTopZ,
|
||||
StartVoxel.X, StartVoxel.Y, StartVoxel.Z,
|
||||
ActualPosition.X, ActualPosition.Y, ActualPosition.Z);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reproduce the gate's original stale-prediction path while the pawn is still above the
|
||||
// landing: the current support is valid, but a deliberately fast one-frame horizontal probe
|
||||
// points at a tile outside the current desired set. The old gate remembers that probe after
|
||||
// stopping the pawn and never re-derives it. The probe is cleared on the next tick only when
|
||||
// the gate has correctly released, after which ordinary gravity continues the fall.
|
||||
if (!bHeadlessSurfaceFallTestPredictionProbeInjected
|
||||
&& Now - HeadlessSurfaceFallTestStartSeconds >= 0.25)
|
||||
{
|
||||
FVoxelTileKey ProbeSupportTile;
|
||||
const FVector LocalPosition = WorldToLocalVoxel(InOutPlayerPosition);
|
||||
const bool bProbeSupportReady =
|
||||
IsPlayerSupportCollisionReady(PlayerPawn, InOutPlayerPosition, &ProbeSupportTile);
|
||||
int32 ProbeTopChunkZ = 0;
|
||||
int32 ProbeBottomChunkZ = 0;
|
||||
const bool bProbeSupportInLayout = bProbeSupportReady
|
||||
&& StrateManager->GetStrateChunkZBounds(
|
||||
ProbeSupportTile.Coord.Z, ProbeTopChunkZ, ProbeBottomChunkZ);
|
||||
if (bProbeSupportReady
|
||||
&& bProbeSupportInLayout
|
||||
&& LocalPosition.Z - HeadlessSurfaceFallTestPlayerHalfHeightVoxels
|
||||
> HeadlessSurfaceFallTestTerrainZ + 2.0f)
|
||||
{
|
||||
bHeadlessSurfaceFallTestPredictionProbeInjected = true;
|
||||
HeadlessSurfaceFallTestPredictionProbePosition = InOutPlayerPosition;
|
||||
if (ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||||
{
|
||||
if (UCharacterMovementComponent* CharacterMovement =
|
||||
Character->GetCharacterMovement())
|
||||
{
|
||||
CharacterMovement->Velocity = FVector(1000000.0f, 0.0f, 0.0f);
|
||||
CharacterMovement->SetMovementMode(MOVE_Falling);
|
||||
CharacterMovement->UpdateComponentVelocity();
|
||||
}
|
||||
}
|
||||
UE_LOG(LogTemp, Display,
|
||||
TEXT("[VoxelForgeSurfaceFallTest] prediction_probe injected=1 "
|
||||
"current_support=(%d,%d,%d) local=(%.2f,%.2f,%.2f) "
|
||||
"velocity_cm_s=1000000 pawn_velocity=(%.1f,%.1f,%.1f)"),
|
||||
ProbeSupportTile.Coord.X, ProbeSupportTile.Coord.Y, ProbeSupportTile.Coord.Z,
|
||||
LocalPosition.X, LocalPosition.Y, LocalPosition.Z,
|
||||
PlayerPawn->GetVelocity().X, PlayerPawn->GetVelocity().Y,
|
||||
PlayerPawn->GetVelocity().Z);
|
||||
// Use a stable test-frame delta so this command-line probe exercises the same
|
||||
// prediction branch even when an uncapped -nullrhi tick reports a near-zero delta.
|
||||
UpdatePawnCollisionGate(PlayerPawn, InOutPlayerPosition, 1.0f / 60.0f);
|
||||
}
|
||||
}
|
||||
else if (bHeadlessSurfaceFallTestPredictionProbeInjected
|
||||
&& !bHeadlessSurfaceFallTestPredictionProbeCleared
|
||||
&& !bPawnCollisionGateEngaged)
|
||||
{
|
||||
const bool bRestored = PlayerPawn->SetActorLocation(
|
||||
HeadlessSurfaceFallTestPredictionProbePosition,
|
||||
false,
|
||||
nullptr,
|
||||
ETeleportType::TeleportPhysics);
|
||||
InOutPlayerPosition = PlayerPawn->GetActorLocation();
|
||||
InOutPlayerHeading = FVector::ZeroVector;
|
||||
bHeadlessSurfaceFallTestPredictionProbeCleared = true;
|
||||
if (ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||||
{
|
||||
if (UCharacterMovementComponent* CharacterMovement =
|
||||
Character->GetCharacterMovement())
|
||||
{
|
||||
CharacterMovement->StopMovementImmediately();
|
||||
CharacterMovement->SetMovementMode(MOVE_Falling);
|
||||
}
|
||||
}
|
||||
UE_LOG(LogTemp, Display,
|
||||
TEXT("[VoxelForgeSurfaceFallTest] prediction_probe cleared=1 restored=%d"),
|
||||
bRestored ? 1 : 0);
|
||||
}
|
||||
|
||||
HeadlessSurfaceFallTestDistanceCm += FVector::Dist(
|
||||
HeadlessSurfaceFallTestLastActualPosition, InOutPlayerPosition);
|
||||
HeadlessSurfaceFallTestLastActualPosition = InOutPlayerPosition;
|
||||
|
||||
const FVector LocalPosition = WorldToLocalVoxel(InOutPlayerPosition);
|
||||
const float FeetZ = LocalPosition.Z - HeadlessSurfaceFallTestPlayerHalfHeightVoxels;
|
||||
bool bOnGround = false;
|
||||
if (const ACharacter* Character = Cast<ACharacter>(PlayerPawn))
|
||||
{
|
||||
if (const UCharacterMovementComponent* CharacterMovement =
|
||||
Character->GetCharacterMovement())
|
||||
{
|
||||
bOnGround = CharacterMovement->IsMovingOnGround();
|
||||
}
|
||||
}
|
||||
if (bOnGround
|
||||
&& bHeadlessSurfaceFallTestPredictionProbeCleared
|
||||
&& FeetZ <= HeadlessSurfaceFallTestTerrainZ
|
||||
+ HeadlessSurfaceFallTestGroundToleranceVoxels
|
||||
&& FeetZ >= HeadlessSurfaceFallTestTerrainZ
|
||||
- HeadlessSurfaceFallTestGroundToleranceVoxels)
|
||||
{
|
||||
bHeadlessSurfaceFallTestPassed = true;
|
||||
UE_LOG(LogTemp, Display,
|
||||
TEXT("[VoxelForgeSurfaceFallTest] reached_ground elapsed_s=%.6f "
|
||||
"distance_m=%.6f feet_vox=%.3f terrain_vox=%.3f gate=%d probe=1"),
|
||||
Now - HeadlessSurfaceFallTestStartSeconds,
|
||||
HeadlessSurfaceFallTestDistanceCm / 100.0,
|
||||
FeetZ,
|
||||
HeadlessSurfaceFallTestTerrainZ,
|
||||
bPawnCollisionGateEngaged ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
void AVoxelWorld::MaybeFinishHeadlessSurfaceFallTest()
|
||||
{
|
||||
if (!bHeadlessSurfaceFallTest || bHeadlessSurfaceFallTestExitRequested
|
||||
|| HeadlessSurfaceFallTestBeginSeconds <= 0.0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const double Now = FPlatformTime::Seconds();
|
||||
if (!bHeadlessSurfaceFallTestStartPlaced
|
||||
&& Now - HeadlessSurfaceFallTestBeginSeconds
|
||||
>= static_cast<double>(HeadlessSurfaceFallTestTimeoutSeconds))
|
||||
{
|
||||
bHeadlessSurfaceFallTestFailed = true;
|
||||
HeadlessSurfaceFallTestFailureReason = TEXT("pawn_start_not_observed");
|
||||
}
|
||||
else if (!bHeadlessSurfaceFallTestPredictionProbeInjected
|
||||
&& !bHeadlessSurfaceFallTestPassed && !bHeadlessSurfaceFallTestFailed
|
||||
&& Now - HeadlessSurfaceFallTestStartSeconds
|
||||
>= static_cast<double>(HeadlessSurfaceFallTestTimeoutSeconds))
|
||||
{
|
||||
bHeadlessSurfaceFallTestFailed = true;
|
||||
HeadlessSurfaceFallTestFailureReason = TEXT("surface_prediction_probe_not_exercised");
|
||||
}
|
||||
else if (bHeadlessSurfaceFallTestPredictionProbeInjected
|
||||
&& !bHeadlessSurfaceFallTestPredictionProbeCleared
|
||||
&& !bHeadlessSurfaceFallTestPassed && !bHeadlessSurfaceFallTestFailed
|
||||
&& Now - HeadlessSurfaceFallTestStartSeconds
|
||||
>= static_cast<double>(HeadlessSurfaceFallTestTimeoutSeconds))
|
||||
{
|
||||
bHeadlessSurfaceFallTestFailed = true;
|
||||
HeadlessSurfaceFallTestFailureReason = TEXT("collision_gate_prediction_deadlock");
|
||||
}
|
||||
else if (!bHeadlessSurfaceFallTestPassed && !bHeadlessSurfaceFallTestFailed
|
||||
&& Now - HeadlessSurfaceFallTestStartSeconds
|
||||
>= static_cast<double>(HeadlessSurfaceFallTestTimeoutSeconds))
|
||||
{
|
||||
bHeadlessSurfaceFallTestFailed = true;
|
||||
HeadlessSurfaceFallTestFailureReason = TEXT("surface_ground_timeout");
|
||||
}
|
||||
if (!bHeadlessSurfaceFallTestPassed && !bHeadlessSurfaceFallTestFailed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bHeadlessSurfaceFallTestExitRequested = true;
|
||||
const FVector FinalPosition = HeadlessSurfaceFallTestLastActualPosition;
|
||||
const bool bPassed = bHeadlessSurfaceFallTestPassed && !bHeadlessSurfaceFallTestFailed;
|
||||
UE_LOG(LogTemp, Display,
|
||||
TEXT("[VoxelForgeSurfaceFallTest] result=%s reason=%s elapsed_s=%.6f "
|
||||
"distance_m=%.6f final_position=(%.1f,%.1f,%.1f) terrain_vox=%.3f"),
|
||||
bPassed ? TEXT("PASS") : TEXT("FAIL"),
|
||||
bPassed ? TEXT("reached_surface_ground") : *HeadlessSurfaceFallTestFailureReason,
|
||||
Now - HeadlessSurfaceFallTestBeginSeconds,
|
||||
HeadlessSurfaceFallTestDistanceCm / 100.0,
|
||||
FinalPosition.X, FinalPosition.Y, FinalPosition.Z,
|
||||
HeadlessSurfaceFallTestTerrainZ);
|
||||
FPlatformMisc::RequestExitWithStatus(
|
||||
false, bPassed ? 0 : 1, TEXT("VoxelForge surface fall test complete"));
|
||||
}
|
||||
|
||||
void AVoxelWorld::MaybeFinishHeadlessStreamingTest()
|
||||
{
|
||||
if (bHeadlessStrateCrossingTest)
|
||||
@@ -2537,6 +2973,12 @@ void AVoxelWorld::MaybeFinishHeadlessStreamingTest()
|
||||
return;
|
||||
}
|
||||
|
||||
if (bHeadlessSurfaceFallTest)
|
||||
{
|
||||
MaybeFinishHeadlessSurfaceFallTest();
|
||||
return;
|
||||
}
|
||||
|
||||
if (bHeadlessStreamingTestExitRequested || HeadlessStreamingTestExitSeconds <= 0.0f
|
||||
|| HeadlessStreamingTestBeginSeconds <= 0.0)
|
||||
{
|
||||
@@ -2575,6 +3017,7 @@ void AVoxelWorld::Tick(float DeltaTime)
|
||||
FVector PlayerHeading = PlayerPawn ? PlayerPawn->GetVelocity() : FVector::ZeroVector;
|
||||
AdvanceHeadlessStreamingTest(PlayerLastPos, PlayerHeading, PlayerPawn);
|
||||
AdvanceHeadlessStrateCrossingTest(PlayerLastPos, PlayerHeading, PlayerPawn);
|
||||
AdvanceHeadlessSurfaceFallTest(PlayerLastPos, PlayerHeading, PlayerPawn);
|
||||
PeakObservedPawnSpeedCmPerSecond = FMath::Max(
|
||||
PeakObservedPawnSpeedCmPerSecond, PlayerHeading.Size2D());
|
||||
UpdatePawnCollisionGate(PlayerPawn, PlayerLastPos, DeltaTime);
|
||||
@@ -2955,9 +3398,49 @@ bool AVoxelWorld::IsTileCollisionReady(const FVoxelTileKey& Tile) const
|
||||
return Mesh != nullptr && Mesh->GetBodySetup() != nullptr;
|
||||
}
|
||||
|
||||
bool AVoxelWorld::IsCollisionReadyFromSupportTile(
|
||||
const FVoxelTileKey& InitialTile, FVoxelTileKey* OutSupportTile) const
|
||||
bool AVoxelWorld::IsCollisionTileUnavailable(const FVoxelTileKey& Tile,
|
||||
const TCHAR*& OutReason) const
|
||||
{
|
||||
OutReason = nullptr;
|
||||
|
||||
// A tile outside every authored strate is open air. The streamer deliberately does not
|
||||
// request it, so treating its absent collision as pending would make a one-strate world an
|
||||
// impossible gate target. Inter-strate gap chunks are different: they are authored solid
|
||||
// bedrock and must remain blocking until their collision is ready.
|
||||
if (StrateManager != nullptr)
|
||||
{
|
||||
int32 TopChunkZ = 0;
|
||||
int32 BottomChunkZ = 0;
|
||||
const bool bInStrate = StrateManager->GetStrateChunkZBounds(
|
||||
Tile.Coord.Z, TopChunkZ, BottomChunkZ);
|
||||
if (!bInStrate && !StrateManager->IsGapChunk(Tile.Coord))
|
||||
{
|
||||
OutReason = TEXT("out_of_layout");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// DesiredStamp is zero only during the initial possession/setup tick, before the first
|
||||
// desired set has been built. Do not classify that transient state as "outside desired";
|
||||
// otherwise the initial safety gate could release before streaming has any authority.
|
||||
if (DesiredStamp > 0 && !IsDesired(Tile))
|
||||
{
|
||||
OutReason = TEXT("outside_desired_set");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool AVoxelWorld::IsCollisionReadyFromSupportTile(
|
||||
const FVoxelTileKey& InitialTile, FVoxelTileKey* OutSupportTile,
|
||||
const TCHAR** OutUnavailableReason) const
|
||||
{
|
||||
if (OutUnavailableReason != nullptr)
|
||||
{
|
||||
*OutUnavailableReason = nullptr;
|
||||
}
|
||||
|
||||
FVoxelTileKey CandidateTile = InitialTile;
|
||||
const int32 Direction = CollisionSolidTiles.Contains(InitialTile) ? 1 : -1;
|
||||
|
||||
@@ -2975,6 +3458,19 @@ bool AVoxelWorld::IsCollisionReadyFromSupportTile(
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const TCHAR* UnavailableReason = nullptr;
|
||||
if (IsCollisionTileUnavailable(CandidateTile, UnavailableReason))
|
||||
{
|
||||
if (OutSupportTile != nullptr)
|
||||
{
|
||||
*OutSupportTile = CandidateTile;
|
||||
}
|
||||
if (OutUnavailableReason != nullptr)
|
||||
{
|
||||
*OutUnavailableReason = UnavailableReason;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!CollisionNotRequiredTiles.Contains(CandidateTile))
|
||||
{
|
||||
if (OutSupportTile != nullptr)
|
||||
@@ -2993,15 +3489,22 @@ bool AVoxelWorld::IsCollisionReadyFromSupportTile(
|
||||
}
|
||||
|
||||
bool AVoxelWorld::IsPlayerSupportCollisionReady(APawn* Pawn, const FVector& PlayerPosition,
|
||||
FVoxelTileKey* OutSupportTile) const
|
||||
FVoxelTileKey* OutSupportTile,
|
||||
const TCHAR** OutUnavailableReason) const
|
||||
{
|
||||
if (OutUnavailableReason != nullptr)
|
||||
{
|
||||
*OutUnavailableReason = nullptr;
|
||||
}
|
||||
|
||||
FVoxelTileKey CandidateTile;
|
||||
if (!GetPlayerSupportTile(Pawn, PlayerPosition, CandidateTile))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsCollisionReadyFromSupportTile(CandidateTile, OutSupportTile);
|
||||
return IsCollisionReadyFromSupportTile(
|
||||
CandidateTile, OutSupportTile, OutUnavailableReason);
|
||||
}
|
||||
|
||||
void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosition,
|
||||
@@ -3027,6 +3530,7 @@ void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosi
|
||||
PawnTickPrerequisite = Pawn;
|
||||
Pawn->AddTickPrerequisiteActor(this);
|
||||
bPawnGateUnsupportedReported = false;
|
||||
bPawnGateUnavailableReported = false;
|
||||
}
|
||||
|
||||
// The actor prerequisite covers the pawn tick; attach the same prerequisite directly to its
|
||||
@@ -3062,11 +3566,13 @@ void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosi
|
||||
}
|
||||
|
||||
FVoxelTileKey CurrentSupportTile;
|
||||
const TCHAR* CurrentSupportUnavailableReason = nullptr;
|
||||
const bool bCurrentSupportReady = IsPlayerSupportCollisionReady(
|
||||
Pawn, PlayerPosition, &CurrentSupportTile);
|
||||
Pawn, PlayerPosition, &CurrentSupportTile, &CurrentSupportUnavailableReason);
|
||||
bool bCanRelease = bCurrentSupportReady;
|
||||
bool bWaitingForPredictedSupport = false;
|
||||
FVoxelTileKey PredictedSupportTile;
|
||||
const TCHAR* PredictedSupportUnavailableReason = nullptr;
|
||||
|
||||
// Check the movement path ahead while the gate is open. Without this, a pawn could leave a
|
||||
// ready tile on this frame and enter an un-cooked tile before the next world tick observes it.
|
||||
@@ -3085,48 +3591,74 @@ void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosi
|
||||
const float Alpha = static_cast<float>(ProbeIndex)
|
||||
/ static_cast<float>(NumPredictedProbes);
|
||||
const FVector PredictedPosition = PlayerPosition + Velocity * DeltaTime * Alpha;
|
||||
const bool bHavePredictedSupport = GetPlayerSupportTile(
|
||||
Pawn, PredictedPosition, PredictedSupportTile);
|
||||
const bool bPredictedSupportReady = bHavePredictedSupport
|
||||
&& IsPlayerSupportCollisionReady(
|
||||
Pawn, PredictedPosition, &PredictedSupportTile);
|
||||
const bool bPredictedSupportReady = IsPlayerSupportCollisionReady(
|
||||
Pawn, PredictedPosition, &PredictedSupportTile,
|
||||
&PredictedSupportUnavailableReason);
|
||||
if (!bPredictedSupportReady)
|
||||
{
|
||||
bCanRelease = false;
|
||||
bWaitingForPredictedSupport = bHavePredictedSupport;
|
||||
bWaitingForPredictedSupport = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Engage() stops velocity, so the blocked tile must be remembered across subsequent ticks;
|
||||
// otherwise a gate caused by a predicted crossing would release immediately on the next tick.
|
||||
if (bPawnCollisionGateEngaged && bPawnGateWaitingForPredictedSupport)
|
||||
const TCHAR* NonBlockingReason = CurrentSupportUnavailableReason != nullptr
|
||||
? CurrentSupportUnavailableReason : PredictedSupportUnavailableReason;
|
||||
if (NonBlockingReason != nullptr)
|
||||
{
|
||||
if (!IsCollisionReadyFromSupportTile(PawnGatePredictedSupportTile,
|
||||
&PredictedSupportTile))
|
||||
if (!bPawnGateUnavailableReported)
|
||||
{
|
||||
bCanRelease = false;
|
||||
bWaitingForPredictedSupport = true;
|
||||
UE_LOG(LogTemp, Display,
|
||||
TEXT("[VoxelForgeCollisionGate] nonblocking pawn=%s reason=%s "
|
||||
"current_reason=%s predicted_reason=%s current_support=(%d,%d,%d) "
|
||||
"predicted_support=(%d,%d,%d); tile will not be produced, no collision to wait for"),
|
||||
*Pawn->GetName(),
|
||||
NonBlockingReason,
|
||||
CurrentSupportUnavailableReason != nullptr
|
||||
? CurrentSupportUnavailableReason : TEXT("none"),
|
||||
PredictedSupportUnavailableReason != nullptr
|
||||
? PredictedSupportUnavailableReason : TEXT("none"),
|
||||
CurrentSupportTile.Coord.X, CurrentSupportTile.Coord.Y,
|
||||
CurrentSupportTile.Coord.Z,
|
||||
PredictedSupportTile.Coord.X, PredictedSupportTile.Coord.Y,
|
||||
PredictedSupportTile.Coord.Z);
|
||||
}
|
||||
bPawnGateUnavailableReported = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
bPawnGateUnavailableReported = false;
|
||||
}
|
||||
|
||||
if (bCanRelease)
|
||||
{
|
||||
bPawnGateWaitingForPredictedSupport = false;
|
||||
ReleasePawnCollisionGate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (bWaitingForPredictedSupport)
|
||||
constexpr double MaxCollisionGateWaitSeconds = 2.0;
|
||||
if (bPawnCollisionGateEngaged && PawnGateEngagedAtSeconds > 0.0)
|
||||
{
|
||||
bPawnGateWaitingForPredictedSupport = true;
|
||||
PawnGatePredictedSupportTile = PredictedSupportTile;
|
||||
const double WaitSeconds = FPlatformTime::Seconds() - PawnGateEngagedAtSeconds;
|
||||
if (WaitSeconds >= MaxCollisionGateWaitSeconds)
|
||||
{
|
||||
UE_LOG(LogTemp, Warning,
|
||||
TEXT("[VoxelForgeCollisionGate] wait_timeout pawn=%s waited_s=%.3f "
|
||||
"current_ready=%d predicted_wait=%d current_support=(%d,%d,%d) "
|
||||
"predicted_support=(%d,%d,%d); releasing safety gate"),
|
||||
*Pawn->GetName(),
|
||||
WaitSeconds,
|
||||
bCurrentSupportReady ? 1 : 0,
|
||||
bWaitingForPredictedSupport ? 1 : 0,
|
||||
CurrentSupportTile.Coord.X, CurrentSupportTile.Coord.Y,
|
||||
CurrentSupportTile.Coord.Z,
|
||||
PredictedSupportTile.Coord.X, PredictedSupportTile.Coord.Y,
|
||||
PredictedSupportTile.Coord.Z);
|
||||
ReleasePawnCollisionGate();
|
||||
return;
|
||||
}
|
||||
else if (!bCurrentSupportReady)
|
||||
{
|
||||
bPawnGateWaitingForPredictedSupport = false;
|
||||
}
|
||||
|
||||
if (!bPawnCollisionGateEngaged)
|
||||
@@ -3167,6 +3699,7 @@ void AVoxelWorld::EngagePawnCollisionGate(APawn* Pawn)
|
||||
CharacterMovement->StopMovementImmediately();
|
||||
CharacterMovement->SetMovementMode(MOVE_None);
|
||||
bPawnCollisionGateEngaged = true;
|
||||
PawnGateEngagedAtSeconds = FPlatformTime::Seconds();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3178,6 +3711,7 @@ void AVoxelWorld::EngagePawnCollisionGate(APawn* Pawn)
|
||||
PawnMovement->StopMovementImmediately();
|
||||
PawnMovement->Deactivate();
|
||||
bPawnCollisionGateEngaged = true;
|
||||
PawnGateEngagedAtSeconds = FPlatformTime::Seconds();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3200,7 +3734,7 @@ void AVoxelWorld::ReleasePawnCollisionGate()
|
||||
CollisionGatedCharacterMovement.Reset();
|
||||
bSavedCharacterMovementMode = false;
|
||||
bSavedPawnMovementActive = false;
|
||||
bPawnGateWaitingForPredictedSupport = false;
|
||||
PawnGateEngagedAtSeconds = 0.0;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3235,7 +3769,7 @@ void AVoxelWorld::ReleasePawnCollisionGate()
|
||||
bSavedCharacterMovementMode = false;
|
||||
bSavedPawnMovementActive = false;
|
||||
bPawnCollisionGateEngaged = false;
|
||||
bPawnGateWaitingForPredictedSupport = false;
|
||||
PawnGateEngagedAtSeconds = 0.0;
|
||||
}
|
||||
|
||||
void AVoxelWorld::BindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh)
|
||||
|
||||
@@ -522,11 +522,14 @@ private:
|
||||
void ConfigureHeadlessStreamingTest();
|
||||
void AdvanceHeadlessStreamingTest(FVector& InOutPlayerPosition,
|
||||
FVector& InOutPlayerHeading, APawn* PlayerPawn);
|
||||
void AdvanceHeadlessSurfaceFallTest(FVector& InOutPlayerPosition,
|
||||
FVector& InOutPlayerHeading, APawn* PlayerPawn);
|
||||
void InitializeHeadlessStrateCrossingTest();
|
||||
void AdvanceHeadlessStrateCrossingTest(FVector& InOutPlayerPosition,
|
||||
FVector& InOutPlayerHeading, APawn* PlayerPawn);
|
||||
void ObserveHeadlessStrateCrossingTest(const FVector& PlayerPosition, APawn* PlayerPawn);
|
||||
void MaybeFinishHeadlessStrateCrossingTest();
|
||||
void MaybeFinishHeadlessSurfaceFallTest();
|
||||
void MaybeFinishHeadlessStreamingTest();
|
||||
void RecordTileHash(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels,
|
||||
int32 Step, int32 Cells, int32 BandChunkLo, int32 BandChunkHi,
|
||||
@@ -824,10 +827,14 @@ public:
|
||||
FVoxelTileKey& OutSupportTile) const;
|
||||
|
||||
bool IsTileCollisionReady(const FVoxelTileKey& Tile) const;
|
||||
bool IsCollisionTileUnavailable(const FVoxelTileKey& Tile,
|
||||
const TCHAR*& OutReason) const;
|
||||
bool IsCollisionReadyFromSupportTile(const FVoxelTileKey& CandidateTile,
|
||||
FVoxelTileKey* OutSupportTile = nullptr) const;
|
||||
FVoxelTileKey* OutSupportTile = nullptr,
|
||||
const TCHAR** OutUnavailableReason = nullptr) const;
|
||||
bool IsPlayerSupportCollisionReady(APawn* Pawn, const FVector& PlayerPosition,
|
||||
FVoxelTileKey* OutSupportTile = nullptr) const;
|
||||
FVoxelTileKey* OutSupportTile = nullptr,
|
||||
const TCHAR** OutUnavailableReason = nullptr) const;
|
||||
|
||||
/** Gate pawn movement until its current and, when moving, next support tile have a completed
|
||||
* level-0 RMC collision body. The world tick is installed as the pawn prerequisite in the
|
||||
@@ -997,6 +1004,29 @@ public:
|
||||
double HeadlessStreamingTestBeginSeconds = 0.0;
|
||||
double HeadlessStreamingTestLastElapsedSeconds = 0.0;
|
||||
|
||||
// Explicit owner-layout collision-gate repro. This is a command-line-only harness path: it
|
||||
// places the possessed character above the authored single-strate surface and lets ordinary
|
||||
// CharacterMovement gravity carry it to the generated ground.
|
||||
bool bHeadlessSurfaceFallTest = false;
|
||||
bool bHeadlessSurfaceFallTestStartPlaced = false;
|
||||
bool bHeadlessSurfaceFallTestPassed = false;
|
||||
bool bHeadlessSurfaceFallTestFailed = false;
|
||||
bool bHeadlessSurfaceFallTestExitRequested = false;
|
||||
bool bHeadlessSurfaceFallTestPredictionProbeInjected = false;
|
||||
bool bHeadlessSurfaceFallTestPredictionProbeCleared = false;
|
||||
float HeadlessSurfaceFallTestSpawnHeightVoxels = 20.0f;
|
||||
float HeadlessSurfaceFallTestGroundToleranceVoxels = 4.0f;
|
||||
float HeadlessSurfaceFallTestTimeoutSeconds = 8.0f;
|
||||
float HeadlessSurfaceFallTestPlayerHalfHeightVoxels = 3.52f;
|
||||
float HeadlessSurfaceFallTestTerrainZ = 0.0f;
|
||||
double HeadlessSurfaceFallTestBeginSeconds = 0.0;
|
||||
double HeadlessSurfaceFallTestStartSeconds = 0.0;
|
||||
double HeadlessSurfaceFallTestDistanceCm = 0.0;
|
||||
FVector HeadlessSurfaceFallTestStartPosition = FVector::ZeroVector;
|
||||
FVector HeadlessSurfaceFallTestLastActualPosition = FVector::ZeroVector;
|
||||
FVector HeadlessSurfaceFallTestPredictionProbePosition = FVector::ZeroVector;
|
||||
FString HeadlessSurfaceFallTestFailureReason;
|
||||
|
||||
// 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;
|
||||
@@ -1015,6 +1045,7 @@ public:
|
||||
bool bHeadlessStrateCrossingTestFailed = false;
|
||||
bool bHeadlessStrateCrossingTestExitRequested = false;
|
||||
bool bHeadlessStrateCrossingTestGateWasEngaged = false;
|
||||
bool bHeadlessStrateCrossingTestInitialSupportReported = false;
|
||||
int32 HeadlessStrateCrossingTestRequestedPassageIndex = INDEX_NONE;
|
||||
int32 HeadlessStrateCrossingTestPassageIndex = INDEX_NONE;
|
||||
int32 HeadlessStrateCrossingTestUpperStrateIndex = INDEX_NONE;
|
||||
@@ -1050,8 +1081,8 @@ public:
|
||||
bool bSavedCharacterMovementMode = false;
|
||||
bool bSavedPawnMovementActive = false;
|
||||
bool bPawnCollisionGateEngaged = false;
|
||||
bool bPawnGateWaitingForPredictedSupport = false;
|
||||
FVoxelTileKey PawnGatePredictedSupportTile;
|
||||
double PawnGateEngagedAtSeconds = 0.0;
|
||||
bool bPawnGateUnavailableReported = false;
|
||||
bool bPawnGateUnsupportedReported = false;
|
||||
|
||||
// Desired-set membership STAMPÉE : clé → numéro du dernier crossing où la tuile était désirée.
|
||||
|
||||
+100
-3
@@ -1,6 +1,6 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('canonical', 'owner', 'probe', 'perf', 'tests', 'parity')]
|
||||
[ValidateSet('canonical', 'owner', 'probe', 'perf', 'tests', 'surface-fall', 'crossing', 'parity')]
|
||||
[string]$Scenario = 'canonical',
|
||||
[switch]$Build,
|
||||
[ValidateSet('owner', 'default')]
|
||||
@@ -43,6 +43,8 @@ $BuildRecord = [ordered]@{ requested = [bool]$Build; status = 'not_requested' }
|
||||
$AssetEvidence = @()
|
||||
$CommandletMetrics = [ordered]@{}
|
||||
$GameMetrics = [ordered]@{}
|
||||
$SurfaceFallMetrics = [ordered]@{ requested = $Scenario -in @('tests', 'surface-fall'); status = 'not_run' }
|
||||
$StrateCrossingMetrics = [ordered]@{ requested = $Scenario -eq 'crossing'; status = 'not_run' }
|
||||
$TestMetrics = @()
|
||||
$ParityEvidence = [ordered]@{ requested = $Scenario -eq 'parity'; status = 'not_run' }
|
||||
$DllEvidence = [ordered]@{
|
||||
@@ -290,7 +292,13 @@ function Invoke-UnrealLaunch([string]$Kind, [string[]]$Arguments, [string]$LogPa
|
||||
arguments = @($Arguments)
|
||||
status = 'not_started'
|
||||
}
|
||||
$isolatedLocalAppData = Join-Path $RunRoot 'LocalAppData'
|
||||
Ensure-Directory $isolatedLocalAppData
|
||||
$oldLocalAppData = $env:LOCALAPPDATA
|
||||
try {
|
||||
# Unreal resolves several engine-wide stores from LOCALAPPDATA even when -userdir and
|
||||
# -LocalDataCachePath are supplied. Keep this child process inside the run directory.
|
||||
$env:LOCALAPPDATA = $isolatedLocalAppData
|
||||
$process = Start-Process -FilePath $EditorExe -ArgumentList $Arguments -WorkingDirectory $HostRoot `
|
||||
-RedirectStandardOutput $StdoutPath -RedirectStandardError $StderrPath -WindowStyle Hidden -PassThru
|
||||
$ledger.pid = [int]$process.Id
|
||||
@@ -301,7 +309,10 @@ function Invoke-UnrealLaunch([string]$Kind, [string[]]$Arguments, [string]$LogPa
|
||||
} catch {
|
||||
$ledger.status = 'launch_error'
|
||||
$ledger.error = $_.Exception.Message
|
||||
} finally {
|
||||
if ($null -eq $oldLocalAppData) { Remove-Item Env:LOCALAPPDATA -ErrorAction SilentlyContinue } else { $env:LOCALAPPDATA = $oldLocalAppData }
|
||||
}
|
||||
$ledger.isolated_local_app_data = $isolatedLocalAppData
|
||||
$ledger.ended_utc = [datetime]::UtcNow.ToString('o')
|
||||
$ledger.crash_reporters_reaped = @(Reap-OwnCrashReporters $reportersBefore $startedUtc $LogPath)
|
||||
$ledger.new_crashes = @(Get-NewCrashes $crashesBefore $startedUtc)
|
||||
@@ -325,12 +336,16 @@ function Invoke-BuildProcess([string[]]$Arguments, [string]$LogPath, [string]$Er
|
||||
$oldDotnetCliHome = $env:DOTNET_CLI_HOME
|
||||
$oldDotnetFirstUse = $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE
|
||||
$oldDotnetNoLogo = $env:DOTNET_NOLOGO
|
||||
$isolatedLocalAppData = Join-Path $RunRoot 'LocalAppData'
|
||||
Ensure-Directory $isolatedLocalAppData
|
||||
$oldLocalAppData = $env:LOCALAPPDATA
|
||||
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'
|
||||
$env:LOCALAPPDATA = $isolatedLocalAppData
|
||||
$process = Start-Process -FilePath $dotnet -ArgumentList $Arguments -WorkingDirectory $PluginRoot `
|
||||
-RedirectStandardOutput $StdoutPath -RedirectStandardError $ErrorPath -WindowStyle Hidden -PassThru
|
||||
$wait = Wait-OwnedProcess $process $LaunchTimeoutSeconds
|
||||
@@ -338,6 +353,7 @@ function Invoke-BuildProcess([string[]]$Arguments, [string]$LogPath, [string]$Er
|
||||
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 }
|
||||
if ($null -eq $oldLocalAppData) { Remove-Item Env:LOCALAPPDATA -ErrorAction SilentlyContinue } else { $env:LOCALAPPDATA = $oldLocalAppData }
|
||||
}
|
||||
return [ordered]@{
|
||||
tool = $dotnet
|
||||
@@ -350,6 +366,7 @@ function Invoke-BuildProcess([string[]]$Arguments, [string]$LogPath, [string]$Er
|
||||
stdout = $StdoutPath
|
||||
error_log = $ErrorPath
|
||||
arguments = @($Arguments)
|
||||
isolated_local_app_data = $isolatedLocalAppData
|
||||
status = if ($wait.timed_out) { 'timeout' } elseif ($wait.exit_code -eq 0) { 'passed' } else { 'failed' }
|
||||
}
|
||||
}
|
||||
@@ -409,7 +426,7 @@ function Sync-OwnerAssets {
|
||||
$null = Copy-Item -LiteralPath $source -Destination $target -Force -PassThru
|
||||
[void]$rows.Add([ordered]@{
|
||||
name = $assetName
|
||||
used_by_scenario = $Scenario -in @('owner', 'probe', 'parity')
|
||||
used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing')
|
||||
source_path = $source
|
||||
source_sha256 = Get-Sha256 $source
|
||||
staged_path = $target
|
||||
@@ -457,7 +474,7 @@ function Get-AssetEvidence {
|
||||
$target = Join-Path $HostRoot "Content\VoxelForge\$assetName.uasset"
|
||||
[void]$rows.Add([ordered]@{
|
||||
name = $assetName
|
||||
used_by_scenario = $Scenario -in @('owner', 'probe', 'parity')
|
||||
used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing')
|
||||
source_path = $source
|
||||
source_sha256 = Get-Sha256 $source
|
||||
staged_path = $target
|
||||
@@ -644,6 +661,36 @@ function Get-StreamingMetrics([string]$LogPath) {
|
||||
return $metrics
|
||||
}
|
||||
|
||||
function Get-SurfaceFallMetrics([string]$LogPath) {
|
||||
$lineMatch = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeSurfaceFallTest\] result=' -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 = 'failed'; log = $LogPath; raw = $line }
|
||||
$result = [regex]::Match($line, 'result=(?<v>PASS|FAIL)')
|
||||
if ($result.Success -and $result.Groups['v'].Value -eq 'PASS') { $metrics.status = 'passed' }
|
||||
foreach ($key in @('reason', 'elapsed_s', 'distance_m', 'terrain_vox')) {
|
||||
$match = [regex]::Match($line, ('{0}=(?<v>[^ ]+)' -f $key))
|
||||
if ($match.Success) { $metrics[$key] = $match.Groups['v'].Value }
|
||||
}
|
||||
return $metrics
|
||||
}
|
||||
|
||||
function Get-StrateCrossingMetrics([string]$LogPath) {
|
||||
$lineMatch = @(Select-String -LiteralPath $LogPath -Pattern '\[VoxelForgeStrateCrossingTest\] result=' -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 = 'failed'; log = $LogPath; raw = $line }
|
||||
$result = [regex]::Match($line, 'result=(?<v>PASS|FAIL)')
|
||||
if ($result.Success -and $result.Groups['v'].Value -eq 'PASS') { $metrics.status = 'passed' }
|
||||
foreach ($key in @('reason', 'elapsed_s', 'distance_m', 'max_gate_s', 'gate_limit_s', 'gate_total_s', 'min_floor_clearance_vox')) {
|
||||
$match = [regex]::Match($line, ('{0}=(?<v>[^ ]+)' -f $key))
|
||||
if ($match.Success) { $metrics[$key] = $match.Groups['v'].Value }
|
||||
}
|
||||
return $metrics
|
||||
}
|
||||
|
||||
function Get-TestMetrics($Ledger) {
|
||||
if ($null -eq $Ledger) { return @() }
|
||||
$logPaths = @($Ledger.log, $Ledger.stdout, $Ledger.stderr) | Where-Object {
|
||||
@@ -904,6 +951,35 @@ try {
|
||||
(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')
|
||||
}
|
||||
'surface-fall' {
|
||||
$baseCvars['voxel.TestSurfaceFall'] = 1
|
||||
$baseCvars['voxel.TestSurfaceFallSpawnHeightVoxels'] = 20
|
||||
$baseCvars['voxel.TestSurfaceFallGroundToleranceVoxels'] = 4
|
||||
$baseCvars['voxel.TestSurfaceFallTimeoutSeconds'] = 8
|
||||
$args = New-CommonArguments 'game' (Join-Path $RunRoot 'Logs\surface-fall.log') (Merge-Cvars $baseCvars)
|
||||
$gameLedger = Invoke-UnrealLaunch 'surface-fall-game' $args `
|
||||
(Join-Path $RunRoot 'Logs\surface-fall.log') (Join-Path $RunRoot 'Logs\surface-fall.stdout.log') (Join-Path $RunRoot 'Logs\surface-fall.stderr.log')
|
||||
$SurfaceFallMetrics = Get-SurfaceFallMetrics (Join-Path $RunRoot 'Logs\surface-fall.log')
|
||||
if ($SurfaceFallMetrics.status -ne 'passed') {
|
||||
Add-Failure "surface-fall repro did not pass: $($SurfaceFallMetrics.status) $($SurfaceFallMetrics.reason)"
|
||||
}
|
||||
}
|
||||
'crossing' {
|
||||
$baseCvars['voxel.TestStrateCrossing'] = 1
|
||||
# The authored four-strate route is a real switchback whose vertical drop is hundreds
|
||||
# of metres. Use a fixed diagnostic speed and enough wall time for initial collision
|
||||
# cooking; the game code still uses swept character movement and the collision gate.
|
||||
$baseCvars['voxel.TestStrateCrossingSpeedCmPerSecond'] = 2000
|
||||
$baseCvars['voxel.TestStrateCrossingTimeoutSeconds'] = 360
|
||||
$baseCvars['voxel.TestStrateCrossingMaxGateSeconds'] = 10
|
||||
$args = New-CommonArguments 'game' (Join-Path $RunRoot 'Logs\crossing.log') (Merge-Cvars $baseCvars)
|
||||
$gameLedger = Invoke-UnrealLaunch 'strate-crossing-game' $args `
|
||||
(Join-Path $RunRoot 'Logs\crossing.log') (Join-Path $RunRoot 'Logs\crossing.stdout.log') (Join-Path $RunRoot 'Logs\crossing.stderr.log')
|
||||
$StrateCrossingMetrics = Get-StrateCrossingMetrics (Join-Path $RunRoot 'Logs\crossing.log')
|
||||
if ($StrateCrossingMetrics.status -ne 'passed') {
|
||||
Add-Failure "strate-crossing test did not pass: $($StrateCrossingMetrics.status) $($StrateCrossingMetrics.reason)"
|
||||
}
|
||||
}
|
||||
'tests' {
|
||||
$testPath = if ([string]::IsNullOrWhiteSpace($TestFilter)) { 'VoxelForge' } else { $TestFilter }
|
||||
$baseCvars['ExecCmds'] = "Automation RunTests $testPath; Quit"
|
||||
@@ -925,6 +1001,23 @@ try {
|
||||
$failedNames = ($failedTests | ForEach-Object { $_.path }) -join ', '
|
||||
Add-Failure "automation tests failed: $failedNames"
|
||||
}
|
||||
|
||||
# The runtime collision-gate repro is part of the tests scenario as an integration
|
||||
# test alongside the 44 pure automation tests. It uses the same isolated host/assets
|
||||
# and gets its own launch ledger entry and log.
|
||||
$surfaceBaseCvars = [ordered]@{
|
||||
'voxel.TestSurfaceFall' = 1
|
||||
'voxel.TestSurfaceFallSpawnHeightVoxels' = 20
|
||||
'voxel.TestSurfaceFallGroundToleranceVoxels' = 4
|
||||
'voxel.TestSurfaceFallTimeoutSeconds' = 8
|
||||
}
|
||||
$surfaceArgs = New-CommonArguments 'game' (Join-Path $RunRoot 'Logs\surface-fall.log') (Merge-Cvars $surfaceBaseCvars)
|
||||
$surfaceFallLedger = Invoke-UnrealLaunch 'surface-fall-game' $surfaceArgs `
|
||||
(Join-Path $RunRoot 'Logs\surface-fall.log') (Join-Path $RunRoot 'Logs\surface-fall.stdout.log') (Join-Path $RunRoot 'Logs\surface-fall.stderr.log')
|
||||
$SurfaceFallMetrics = Get-SurfaceFallMetrics (Join-Path $RunRoot 'Logs\surface-fall.log')
|
||||
if ($SurfaceFallMetrics.status -ne 'passed') {
|
||||
Add-Failure "surface-fall repro did not pass: $($SurfaceFallMetrics.status) $($SurfaceFallMetrics.reason)"
|
||||
}
|
||||
}
|
||||
'parity' {
|
||||
$parityArgs = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\parity-commandlet.log') (Merge-Cvars $baseCvars)
|
||||
@@ -1015,6 +1108,8 @@ $result = [ordered]@{
|
||||
probe_amplitudes = if ($CommandletMetrics.Contains('probe_amplitudes')) { $CommandletMetrics.probe_amplitudes } else { [ordered]@{} }
|
||||
commandlet = $CommandletMetrics
|
||||
game = $GameMetrics
|
||||
surface_fall = $SurfaceFallMetrics
|
||||
strate_crossing = $StrateCrossingMetrics
|
||||
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 }
|
||||
@@ -1044,6 +1139,8 @@ try {
|
||||
[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("surface_fall=$(ConvertTo-CompactJson $result.surface_fall)")
|
||||
[void]$summaryLines.Add("strate_crossing=$(ConvertTo-CompactJson $result.strate_crossing)")
|
||||
[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)")
|
||||
|
||||
Reference in new issue
Block a user