Add packaged XR validation and package helper hardening

This commit is contained in:
axiomlogicnexus 2026-07-02 03:41:18 +00:00
parent 3cdfa8b1e0
commit 8cd099c69b
18 changed files with 2215 additions and 14 deletions

View file

@ -0,0 +1,256 @@
#include "HyperTwistXR/HyperTwistXrPackageValidationLibrary.h"
#include "HAL/FileManager.h"
#include "HyperTwistXR/HyperTwistXrRuntimeLibrary.h"
#include "HyperTwistXR/HyperTwistXrTrainingPawn.h"
#include "JsonObjectConverter.h"
#include "Misc/DateTime.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
namespace HyperTwistXrPackageValidationLibraryInternal
{
const TCHAR* DefaultCookMapPath =
TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining");
const TCHAR* ValidationLaunchSurfaceId =
TEXT("xr/openxr-desktop-training-packaged-validation-launch-surface");
const TCHAR* ValidationGameModeClassPath =
TEXT("/Script/UnrealHyperTwist.HyperTwistXrTrainingGameMode");
const TCHAR* PendingHmdSessionObservationStatus =
TEXT("pending-hmd-session-observation");
const TCHAR* PendingControllerInputObservationStatus =
TEXT("pending-controller-input-observation");
const TCHAR* ObservedLiveControllerInputStatus =
TEXT("observed-live-controller-input");
const TCHAR* RemainingHmdSessionGateId =
TEXT("xr/windows-packaged-headset-session-observation");
const TCHAR* RemainingControllerInputGateId =
TEXT("xr/windows-packaged-controller-input-observation");
FString ResolveProofStatus(
const bool bObservedHeadMountedDisplaySession,
const bool bObservedControllerInputActivity
)
{
if (!bObservedHeadMountedDisplaySession)
{
return PendingHmdSessionObservationStatus;
}
if (!bObservedControllerInputActivity)
{
return PendingControllerInputObservationStatus;
}
return ObservedLiveControllerInputStatus;
}
void PopulateRemainingGateIds(
TArray<FString>& OutRemainingGateIds,
const bool bObservedHeadMountedDisplaySession,
const bool bObservedControllerInputActivity
)
{
OutRemainingGateIds.Reset();
if (!bObservedHeadMountedDisplaySession)
{
OutRemainingGateIds.Add(RemainingHmdSessionGateId);
}
if (!bObservedControllerInputActivity)
{
OutRemainingGateIds.Add(RemainingControllerInputGateId);
}
}
}
bool FHyperTwistXrPackagedValidationReport::IsStructurallyValid() const
{
return !ReportVersion.IsEmpty()
&& !GeneratedAtUtc.IsEmpty()
&& !LaunchSurfaceId.IsEmpty()
&& !GameModeClassPath.IsEmpty()
&& !CookMapAssetPath.IsEmpty()
&& !LaunchMapUrl.IsEmpty()
&& !Result.IsEmpty()
&& !PackagedProofStatus.IsEmpty()
&& !RuntimeOwnerId.IsEmpty()
&& !PreferenceProfileId.IsEmpty()
&& !ControllerBindingProfileId.IsEmpty()
&& !ControllerSettingsOwnerId.IsEmpty()
&& ObservationSeconds >= 0.0;
}
FString UHyperTwistXrPackageValidationLibrary::GetDefaultPackagedValidationCookMapPath()
{
return HyperTwistXrPackageValidationLibraryInternal::DefaultCookMapPath;
}
FString UHyperTwistXrPackageValidationLibrary::GetValidationLaunchSurfaceId()
{
return HyperTwistXrPackageValidationLibraryInternal::ValidationLaunchSurfaceId;
}
FString UHyperTwistXrPackageValidationLibrary::GetValidationGameModeClassPath()
{
return HyperTwistXrPackageValidationLibraryInternal::ValidationGameModeClassPath;
}
FString UHyperTwistXrPackageValidationLibrary::BuildPackagedValidationMapUrl(
const FString& CookMapAssetPath
)
{
const FString ResolvedCookMapAssetPath = CookMapAssetPath.IsEmpty()
? GetDefaultPackagedValidationCookMapPath()
: CookMapAssetPath;
return FString::Printf(
TEXT("%s?game=%s"),
*ResolvedCookMapAssetPath,
*GetValidationGameModeClassPath()
);
}
FHyperTwistXrPackagedValidationReport
UHyperTwistXrPackageValidationLibrary::BuildPackagedValidationReport(
const AHyperTwistXrTrainingPawn* TrainingPawn,
const FString& CookMapAssetPath,
const FString& ReportPath,
const double ObservationSeconds,
const bool bObservedHeadMountedDisplaySession,
const bool bObservedControllerInputActivity,
const float ObservedControllerInputMagnitude
)
{
const FString ResolvedCookMapAssetPath = CookMapAssetPath.IsEmpty()
? GetDefaultPackagedValidationCookMapPath()
: CookMapAssetPath;
const FHyperTwistXrRuntimeOwnershipSurface RuntimeOwnershipSurface =
TrainingPawn != nullptr
? TrainingPawn->GetBoundedRuntimeOwnershipSurface()
: UHyperTwistXrRuntimeLibrary::BuildBundledXrRuntimeOwnershipSurface();
const FHyperTwistXrPreferenceProfile PreferenceProfile =
TrainingPawn != nullptr
? TrainingPawn->ActivePreferenceProfile
: UHyperTwistXrRuntimeLibrary::GetDefaultXrPreferenceProfile();
const FHyperTwistXrControllerBindingProfile ControllerBindingProfile =
TrainingPawn != nullptr
? TrainingPawn->ActiveControllerBindingProfile
: UHyperTwistXrRuntimeLibrary::GetDefaultXrControllerBindingProfile();
const FHyperTwistXrControllerSettingsOwnershipSurface ControllerSettingsSurface =
UHyperTwistXrRuntimeLibrary::BuildBundledXrControllerSettingsOwnershipSurface();
FHyperTwistXrPackagedValidationReport Report;
Report.GeneratedAtUtc = FDateTime::UtcNow().ToIso8601();
Report.LaunchSurfaceId = GetValidationLaunchSurfaceId();
Report.GameModeClassPath = GetValidationGameModeClassPath();
Report.CookMapAssetPath = ResolvedCookMapAssetPath;
Report.LaunchMapUrl = BuildPackagedValidationMapUrl(ResolvedCookMapAssetPath);
Report.ReportPath = ReportPath;
Report.ObservationSeconds = FMath::Max(ObservationSeconds, 0.0);
Report.RuntimeOwnerId = RuntimeOwnershipSurface.RuntimeOwnerId;
Report.PreferenceProfileId = PreferenceProfile.ProfileId;
Report.ControllerBindingProfileId = ControllerBindingProfile.ProfileId;
Report.ControllerSettingsOwnerId = ControllerSettingsSurface.SettingsOwnerId;
Report.RuntimeStatusLine = TrainingPawn != nullptr
? TrainingPawn->BuildRuntimeStatusLine()
: TEXT("xr training pawn unavailable during packaged validation capture");
Report.bRuntimeOwnerStructurallyValid = RuntimeOwnershipSurface.IsStructurallyValid();
Report.bControllerSettingsOwnerStructurallyValid =
ControllerSettingsSurface.IsStructurallyValid();
Report.bHeadMountedDisplayConnected =
TrainingPawn != nullptr && TrainingPawn->bHeadMountedDisplayConnected;
Report.bHeadMountedDisplayEnabled =
TrainingPawn != nullptr && TrainingPawn->bHeadMountedDisplayEnabled;
Report.bObservedHeadMountedDisplaySession = bObservedHeadMountedDisplaySession;
Report.bObservedControllerInputActivity = bObservedControllerInputActivity;
Report.ObservedControllerInputMagnitude = FMath::Max(ObservedControllerInputMagnitude, 0.0f);
Report.PackagedProofStatus =
HyperTwistXrPackageValidationLibraryInternal::ResolveProofStatus(
bObservedHeadMountedDisplaySession,
bObservedControllerInputActivity
);
HyperTwistXrPackageValidationLibraryInternal::PopulateRemainingGateIds(
Report.RemainingGateIds,
bObservedHeadMountedDisplaySession,
bObservedControllerInputActivity
);
if (TrainingPawn == nullptr)
{
Report.Error = TEXT("XR training pawn was unavailable during packaged validation capture.");
}
else if (!Report.bRuntimeOwnerStructurallyValid)
{
Report.Error = TEXT("The XR runtime-owner surface was not structurally valid during packaged validation capture.");
}
else if (!PreferenceProfile.IsStructurallyValid())
{
Report.Error = TEXT("The XR preference profile was not structurally valid during packaged validation capture.");
}
else if (!ControllerBindingProfile.IsStructurallyValid())
{
Report.Error = TEXT("The XR controller binding profile was not structurally valid during packaged validation capture.");
}
else if (!Report.bControllerSettingsOwnerStructurallyValid)
{
Report.Error = TEXT("The XR controller settings ownership surface was not structurally valid during packaged validation capture.");
}
else
{
Report.Result = TEXT("passed");
}
return Report;
}
bool UHyperTwistXrPackageValidationLibrary::WritePackagedValidationReportToFile(
const FHyperTwistXrPackagedValidationReport& Report,
const FString& ReportPath,
FString& OutError
)
{
OutError.Reset();
if (ReportPath.IsEmpty())
{
OutError = TEXT("Packaged validation report path was empty.");
return false;
}
const FString ParentDirectory = FPaths::GetPath(ReportPath);
if (!ParentDirectory.IsEmpty()
&& !IFileManager::Get().MakeDirectory(*ParentDirectory, true))
{
OutError = FString::Printf(
TEXT("Failed to create packaged validation report directory '%s'."),
*ParentDirectory
);
return false;
}
FString ReportJson;
if (!FJsonObjectConverter::UStructToJsonObjectString(
FHyperTwistXrPackagedValidationReport::StaticStruct(),
&Report,
ReportJson,
0,
0))
{
OutError = TEXT("Failed to serialize the XR packaged validation report to JSON.");
return false;
}
if (!FFileHelper::SaveStringToFile(
ReportJson,
*ReportPath,
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
{
OutError = FString::Printf(
TEXT("Failed to write the XR packaged validation report to '%s'."),
*ReportPath
);
return false;
}
return true;
}

View file

@ -0,0 +1,193 @@
#include "HyperTwistXR/HyperTwistXrTrainingGameMode.h"
#include "EngineUtils.h"
#include "GameFramework/PlayerController.h"
#include "GenericPlatform/GenericPlatformMisc.h"
#include "HyperTwistXR/HyperTwistXrPackageValidationLibrary.h"
#include "HyperTwistXR/HyperTwistXrTrainingPawn.h"
#include "Misc/CommandLine.h"
#include "Misc/Parse.h"
AHyperTwistXrTrainingGameMode::AHyperTwistXrTrainingGameMode()
{
PrimaryActorTick.bCanEverTick = true;
PlayerControllerClass = APlayerController::StaticClass();
DefaultPawnClass = AHyperTwistXrTrainingPawn::StaticClass();
ConfiguredValidationObservationSeconds = DefaultValidationObservationSeconds;
}
void AHyperTwistXrTrainingGameMode::BeginPlay()
{
Super::BeginPlay();
ApplyValidationOverridesFromCommandLine();
RefreshObservedValidationState();
}
void AHyperTwistXrTrainingGameMode::Tick(const float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
if (!bPackagedValidationRequested || bPackagedValidationFinalized)
{
return;
}
RefreshObservedValidationState();
RemainingValidationObservationSeconds -= FMath::Max(static_cast<double>(DeltaSeconds), 0.0);
if (ShouldFinalizePackagedValidationCapture())
{
FinalizePackagedValidationCapture();
}
}
void AHyperTwistXrTrainingGameMode::ApplyValidationOverridesFromCommandLine()
{
bPackagedValidationRequested = false;
bPackagedValidationFinalized = false;
ValidationReportPath.Reset();
ConfiguredValidationObservationSeconds = DefaultValidationObservationSeconds;
RemainingValidationObservationSeconds = -1.0;
if (!bEnableCommandLinePackagedValidation)
{
return;
}
const TCHAR* CommandLine = FCommandLine::Get();
FString ParsedReportPath;
if (!FParse::Value(
CommandLine,
TEXT("HyperTwistXrValidationReportPath="),
ParsedReportPath))
{
return;
}
ParsedReportPath.TrimQuotesInline();
if (ParsedReportPath.IsEmpty())
{
return;
}
bPackagedValidationRequested = true;
ValidationReportPath = ParsedReportPath;
double ParsedObservationSeconds = DefaultValidationObservationSeconds;
if (FParse::Value(
CommandLine,
TEXT("HyperTwistXrValidationObservationSeconds="),
ParsedObservationSeconds))
{
ParsedObservationSeconds = FMath::Max(ParsedObservationSeconds, 0.0);
}
ConfiguredValidationObservationSeconds = ParsedObservationSeconds;
RemainingValidationObservationSeconds = ParsedObservationSeconds;
}
AHyperTwistXrTrainingPawn* AHyperTwistXrTrainingGameMode::ResolveTrainingPawn() const
{
if (GetWorld() == nullptr)
{
return nullptr;
}
if (APlayerController* PlayerController = GetWorld()->GetFirstPlayerController())
{
if (AHyperTwistXrTrainingPawn* TrainingPawn =
Cast<AHyperTwistXrTrainingPawn>(PlayerController->GetPawn()))
{
return TrainingPawn;
}
}
for (TActorIterator<AHyperTwistXrTrainingPawn> ActorIt(GetWorld()); ActorIt; ++ActorIt)
{
return *ActorIt;
}
return nullptr;
}
void AHyperTwistXrTrainingGameMode::RefreshObservedValidationState()
{
if (AHyperTwistXrTrainingPawn* TrainingPawn = ResolveTrainingPawn())
{
bObservedHeadMountedDisplaySessionDuringValidation |=
TrainingPawn->HasObservedHeadMountedDisplaySession();
const float ObservationMagnitude =
TrainingPawn->GetControllerInputObservationMagnitude();
MaxObservedControllerInputMagnitude = FMath::Max(
MaxObservedControllerInputMagnitude,
ObservationMagnitude
);
bObservedControllerInputActivityDuringValidation |=
TrainingPawn->HasObservedControllerInputActivity();
}
}
bool AHyperTwistXrTrainingGameMode::ShouldFinalizePackagedValidationCapture() const
{
return RemainingValidationObservationSeconds <= 0.0
|| (bObservedHeadMountedDisplaySessionDuringValidation
&& bObservedControllerInputActivityDuringValidation);
}
void AHyperTwistXrTrainingGameMode::FinalizePackagedValidationCapture()
{
if (bPackagedValidationFinalized)
{
return;
}
const AHyperTwistXrTrainingPawn* TrainingPawn = ResolveTrainingPawn();
FHyperTwistXrPackagedValidationReport Report =
UHyperTwistXrPackageValidationLibrary::BuildPackagedValidationReport(
TrainingPawn,
ResolveActiveCookMapAssetPath(),
ValidationReportPath,
ConfiguredValidationObservationSeconds,
bObservedHeadMountedDisplaySessionDuringValidation,
bObservedControllerInputActivityDuringValidation,
MaxObservedControllerInputMagnitude
);
FString WriteError;
if (!UHyperTwistXrPackageValidationLibrary::WritePackagedValidationReportToFile(
Report,
ValidationReportPath,
WriteError))
{
UE_LOG(
LogTemp,
Error,
TEXT("%s"),
WriteError.IsEmpty()
? TEXT("Failed to write the XR packaged validation report.")
: *WriteError
);
}
bPackagedValidationFinalized = true;
FGenericPlatformMisc::RequestExit(false);
}
FString AHyperTwistXrTrainingGameMode::ResolveActiveCookMapAssetPath() const
{
if (GetWorld() == nullptr)
{
return UHyperTwistXrPackageValidationLibrary::GetDefaultPackagedValidationCookMapPath();
}
if (const UPackage* WorldPackage = GetWorld()->GetPackage())
{
const FString PackageName = WorldPackage->GetName();
if (PackageName.StartsWith(TEXT("/Game/")))
{
return PackageName;
}
}
return UHyperTwistXrPackageValidationLibrary::GetDefaultPackagedValidationCookMapPath();
}

View file

@ -360,6 +360,31 @@ FString AHyperTwistXrTrainingPawn::BuildRuntimeStatusLine() const
);
}
bool AHyperTwistXrTrainingPawn::HasObservedHeadMountedDisplaySession() const
{
return bHeadMountedDisplayConnected && bHeadMountedDisplayEnabled;
}
float AHyperTwistXrTrainingPawn::GetControllerInputObservationMagnitude() const
{
return FMath::Max(
FMath::Abs(LeftGripValue),
FMath::Max(
FMath::Abs(RightGripValue),
FMath::Max(FMath::Abs(LeftTriggerValue), FMath::Abs(RightTriggerValue))
)
);
}
bool AHyperTwistXrTrainingPawn::HasObservedControllerInputActivity() const
{
const float ObservationThreshold = FMath::Max(
0.05f,
ActivePreferenceProfile.ControllerDeadZone
);
return GetControllerInputObservationMagnitude() >= ObservationThreshold;
}
void AHyperTwistXrTrainingPawn::ApplyPostureOffset()
{
if (VROrigin == nullptr)

View file

@ -0,0 +1,124 @@
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "HyperTwistXrPackageValidationLibrary.generated.h"
class AHyperTwistXrTrainingPawn;
USTRUCT(BlueprintType)
struct UNREALHYPERTWIST_API FHyperTwistXrPackagedValidationReport
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
FString ReportVersion = TEXT("ht-xr-packaged-validation/v1");
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
FString GeneratedAtUtc;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
FString LaunchSurfaceId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
FString GameModeClassPath;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
FString CookMapAssetPath;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
FString LaunchMapUrl;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
FString Result = TEXT("failed");
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
FString PackagedProofStatus;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
FString RuntimeOwnerId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
FString PreferenceProfileId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
FString ControllerBindingProfileId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
FString ControllerSettingsOwnerId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
FString RuntimeStatusLine;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
FString ReportPath;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
FString Error;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
double ObservationSeconds = 0.0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
bool bRuntimeOwnerStructurallyValid = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
bool bControllerSettingsOwnerStructurallyValid = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
bool bHeadMountedDisplayConnected = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
bool bHeadMountedDisplayEnabled = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
bool bObservedHeadMountedDisplaySession = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
bool bObservedControllerInputActivity = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
float ObservedControllerInputMagnitude = 0.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
TArray<FString> RemainingGateIds;
bool IsStructurallyValid() const;
};
UCLASS()
class UNREALHYPERTWIST_API UHyperTwistXrPackageValidationLibrary
: public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintPure, Category = "HyperTwist|XR|Validation")
static FString GetDefaultPackagedValidationCookMapPath();
UFUNCTION(BlueprintPure, Category = "HyperTwist|XR|Validation")
static FString GetValidationLaunchSurfaceId();
UFUNCTION(BlueprintPure, Category = "HyperTwist|XR|Validation")
static FString GetValidationGameModeClassPath();
UFUNCTION(BlueprintPure, Category = "HyperTwist|XR|Validation")
static FString BuildPackagedValidationMapUrl(const FString& CookMapAssetPath);
static FHyperTwistXrPackagedValidationReport BuildPackagedValidationReport(
const AHyperTwistXrTrainingPawn* TrainingPawn,
const FString& CookMapAssetPath,
const FString& ReportPath,
double ObservationSeconds,
bool bObservedHeadMountedDisplaySession,
bool bObservedControllerInputActivity,
float ObservedControllerInputMagnitude
);
static bool WritePackagedValidationReportToFile(
const FHyperTwistXrPackagedValidationReport& Report,
const FString& ReportPath,
FString& OutError
);
};

View file

@ -0,0 +1,44 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "HyperTwistXrTrainingGameMode.generated.h"
class AHyperTwistXrTrainingPawn;
UCLASS(BlueprintType, Blueprintable)
class UNREALHYPERTWIST_API AHyperTwistXrTrainingGameMode : public AGameModeBase
{
GENERATED_BODY()
public:
AHyperTwistXrTrainingGameMode();
virtual void BeginPlay() override;
virtual void Tick(float DeltaSeconds) override;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation")
bool bEnableCommandLinePackagedValidation = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|XR|Validation", meta = (ClampMin = "0.0"))
double DefaultValidationObservationSeconds = 8.0;
protected:
void ApplyValidationOverridesFromCommandLine();
AHyperTwistXrTrainingPawn* ResolveTrainingPawn() const;
void RefreshObservedValidationState();
bool ShouldFinalizePackagedValidationCapture() const;
void FinalizePackagedValidationCapture();
FString ResolveActiveCookMapAssetPath() const;
private:
FString ValidationReportPath;
double ConfiguredValidationObservationSeconds = 0.0;
double RemainingValidationObservationSeconds = -1.0;
bool bPackagedValidationRequested = false;
bool bPackagedValidationFinalized = false;
bool bObservedHeadMountedDisplaySessionDuringValidation = false;
bool bObservedControllerInputActivityDuringValidation = false;
float MaxObservedControllerInputMagnitude = 0.0f;
};

View file

@ -90,6 +90,15 @@ public:
UFUNCTION(BlueprintPure, Category = "HyperTwist|XR|Runtime")
FString BuildRuntimeStatusLine() const;
UFUNCTION(BlueprintPure, Category = "HyperTwist|XR|Runtime")
bool HasObservedHeadMountedDisplaySession() const;
UFUNCTION(BlueprintPure, Category = "HyperTwist|XR|Runtime")
float GetControllerInputObservationMagnitude() const;
UFUNCTION(BlueprintPure, Category = "HyperTwist|XR|Runtime")
bool HasObservedControllerInputActivity() const;
protected:
void ApplyPostureOffset();
void ApplyPointerAnchors();

View file

@ -0,0 +1,171 @@
// Copyright HyperTwist, Inc. All Rights Reserved.
#include "Misc/AutomationTest.h"
#include "GameFramework/PlayerController.h"
#include "HyperTwistXR/HyperTwistXrPackageValidationLibrary.h"
#include "HyperTwistXR/HyperTwistXrTrainingGameMode.h"
#include "HyperTwistXR/HyperTwistXrTrainingPawn.h"
#if WITH_AUTOMATION_TESTS
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistXrPackagedValidationDefaultsTest,
"HyperTwist.FirstParty.XR.PackagedValidationDefaults",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)
bool FHyperTwistXrPackagedValidationDefaultsTest::RunTest(const FString& Parameters)
{
const FString DefaultCookMapPath =
UHyperTwistXrPackageValidationLibrary::GetDefaultPackagedValidationCookMapPath();
TestEqual(
TEXT("The XR packaged-validation helper must keep the default cook map stable."),
DefaultCookMapPath,
FString(TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining"))
);
TestEqual(
TEXT("The XR packaged-validation launch surface id must stay stable."),
UHyperTwistXrPackageValidationLibrary::GetValidationLaunchSurfaceId(),
FString(TEXT("xr/openxr-desktop-training-packaged-validation-launch-surface"))
);
TestEqual(
TEXT("The XR packaged-validation game mode class path must stay stable."),
UHyperTwistXrPackageValidationLibrary::GetValidationGameModeClassPath(),
FString(TEXT("/Script/UnrealHyperTwist.HyperTwistXrTrainingGameMode"))
);
TestEqual(
TEXT("The XR packaged-validation map URL must stay game-mode overridden."),
UHyperTwistXrPackageValidationLibrary::BuildPackagedValidationMapUrl(DefaultCookMapPath),
FString(TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining?game=/Script/UnrealHyperTwist.HyperTwistXrTrainingGameMode"))
);
const AHyperTwistXrTrainingGameMode* GameModeDefaults =
GetDefault<AHyperTwistXrTrainingGameMode>();
TestNotNull(TEXT("The XR training game mode defaults must exist."), GameModeDefaults);
if (GameModeDefaults == nullptr)
{
return false;
}
TestTrue(
TEXT("The XR training game mode must keep the bounded XR pawn as its default pawn."),
GameModeDefaults->DefaultPawnClass.Get() == AHyperTwistXrTrainingPawn::StaticClass()
);
TestTrue(
TEXT("The XR training game mode must keep the plain player controller by default."),
GameModeDefaults->PlayerControllerClass.Get() == APlayerController::StaticClass()
);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistXrPackagedValidationReportProofStatesTest,
"HyperTwist.FirstParty.XR.PackagedValidationReportProofStates",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)
bool FHyperTwistXrPackagedValidationReportProofStatesTest::RunTest(
const FString& Parameters
)
{
AHyperTwistXrTrainingPawn* Pawn =
NewObject<AHyperTwistXrTrainingPawn>(GetTransientPackage());
TestNotNull(
TEXT("The XR packaged-validation report builder must construct a transient XR training pawn for proof-state checks."),
Pawn
);
if (Pawn == nullptr)
{
return false;
}
const FString DefaultCookMapPath =
UHyperTwistXrPackageValidationLibrary::GetDefaultPackagedValidationCookMapPath();
const FString ReportPath = TEXT("C:/HyperTwist/validation/xr-proof.json");
const FHyperTwistXrPackagedValidationReport PendingHmdReport =
UHyperTwistXrPackageValidationLibrary::BuildPackagedValidationReport(
Pawn,
DefaultCookMapPath,
ReportPath,
8.0,
false,
false,
0.0f
);
TestTrue(
TEXT("The packaged XR validation report must stay structurally valid when no headset session has been observed yet."),
PendingHmdReport.IsStructurallyValid()
);
TestEqual(
TEXT("The packaged XR validation report must stay in the pending-HMD proof state until a headset session is observed."),
PendingHmdReport.PackagedProofStatus,
FString(TEXT("pending-hmd-session-observation"))
);
TestTrue(
TEXT("The packaged XR validation report must retain both remaining packaged gates before any hardware observation exists."),
PendingHmdReport.RemainingGateIds.Contains(
TEXT("xr/windows-packaged-headset-session-observation"))
&& PendingHmdReport.RemainingGateIds.Contains(
TEXT("xr/windows-packaged-controller-input-observation"))
);
Pawn->bHeadMountedDisplayConnected = true;
Pawn->bHeadMountedDisplayEnabled = true;
const FHyperTwistXrPackagedValidationReport PendingControllerReport =
UHyperTwistXrPackageValidationLibrary::BuildPackagedValidationReport(
Pawn,
DefaultCookMapPath,
ReportPath,
8.0,
true,
false,
0.0f
);
TestEqual(
TEXT("The packaged XR validation report must move to pending-controller proof once a headset session is observed without live controller input."),
PendingControllerReport.PackagedProofStatus,
FString(TEXT("pending-controller-input-observation"))
);
TestTrue(
TEXT("The packaged XR validation report must retain only the controller-input gate once the headset session is observed."),
PendingControllerReport.RemainingGateIds.Num() == 1
&& PendingControllerReport.RemainingGateIds.Contains(
TEXT("xr/windows-packaged-controller-input-observation"))
);
Pawn->LeftGripValue = 0.7f;
TestTrue(
TEXT("The XR training pawn helper must treat non-zero controller grip input as live controller activity."),
Pawn->HasObservedControllerInputActivity()
&& Pawn->GetControllerInputObservationMagnitude() >= 0.7f
);
const FHyperTwistXrPackagedValidationReport ObservedControllerReport =
UHyperTwistXrPackageValidationLibrary::BuildPackagedValidationReport(
Pawn,
DefaultCookMapPath,
ReportPath,
8.0,
true,
true,
Pawn->GetControllerInputObservationMagnitude()
);
TestEqual(
TEXT("The packaged XR validation report must surface observed live controller input once both the headset session and controller activity were observed."),
ObservedControllerReport.PackagedProofStatus,
FString(TEXT("observed-live-controller-input"))
);
TestTrue(
TEXT("The packaged XR validation report must clear remaining packaged hardware gates once live controller activity has been observed."),
ObservedControllerReport.RemainingGateIds.Num() == 0
);
TestEqual(
TEXT("The packaged XR validation report must preserve the stable controller settings ownership id."),
ObservedControllerReport.ControllerSettingsOwnerId,
FString(TEXT("xr-openxr-controller-settings-owner/v1"))
);
return true;
}
#endif

View file

@ -0,0 +1,142 @@
{
"reportVersion": "ht-xr-package-validation/v1",
"generatedAtUtc": "2026-07-02T03:29:40.2865192Z",
"projectRoot": "C:\\HyperTwist_worktrees\\phase10validate",
"archiveDirectory": "C:\\HyperTwist_worktrees\\phase10validate_packaged_xr_20260702_rebuild",
"configuration": "Development",
"cookMaps": [
"/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining",
"/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining"
],
"smokeMaps": [
"/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining",
"/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining"
],
"observationSeconds": 8,
"timeoutSeconds": 30,
"runtimeLaunchMode": "nullrhi",
"validationLaunchSurfaceId": "xr/openxr-desktop-training-packaged-validation-launch-surface",
"validationGameModeClassPath": "/Script/UnrealHyperTwist.HyperTwistXrTrainingGameMode",
"additionalCookerOptions": [
"-DisablePlugins=MovieRenderPipeline",
"-SkipCookingEditorContent"
],
"cleanArchive": true,
"skipBuild": true,
"prePackageEditorBuild": {
"lane": "Windows Unreal editor build",
"performed": true,
"result": "passed"
},
"skipLaunch": false,
"result": "passed",
"packagedExecutablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_xr_20260702_rebuild\\Windows\\UnrealHyperTwist.exe",
"smokeReports": [
{
"reportVersion": "ht-xr-package-smoke/v1",
"generatedAtUtc": "2026-07-02T03:31:09.1508874Z",
"packageRoot": "C:\\HyperTwist_worktrees\\phase10validate_packaged_xr_20260702_rebuild",
"executablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_xr_20260702_rebuild\\Windows\\UnrealHyperTwist.exe",
"mapAssetPath": "/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining",
"mapUrl": "/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining?game=/Script/UnrealHyperTwist.HyperTwistXrTrainingGameMode",
"runtimeLaunchMode": "nullrhi",
"observationSeconds": 8,
"timeoutSeconds": 30,
"resolution": null,
"keepRunning": false,
"runtimeReportPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_xr_20260702_rebuild\\validation\\smoke\\_Game_HyperTwistTraining_Maps_L_HyperTwist_FollowAlongTraining.runtime.json",
"result": "passed",
"processId": 30804,
"processStopped": false,
"exitCode": 0,
"runtimeReport": {
"reportVersion": "ht-xr-packaged-validation/v1",
"generatedAtUtc": "2026-07-02T03:31:22.493Z",
"launchSurfaceId": "xr/openxr-desktop-training-packaged-validation-launch-surface",
"gameModeClassPath": "/Script/UnrealHyperTwist.HyperTwistXrTrainingGameMode",
"cookMapAssetPath": "/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining",
"launchMapUrl": "/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining?game=/Script/UnrealHyperTwist.HyperTwistXrTrainingGameMode",
"result": "passed",
"packagedProofStatus": "pending-hmd-session-observation",
"runtimeOwnerId": "xr/openxr-desktop-training-runtime-owner",
"preferenceProfileId": "xr-openxr-training-preferences/v1",
"controllerBindingProfileId": "xr-openxr-motion-controllers/v1",
"controllerSettingsOwnerId": "xr-openxr-controller-settings-owner/v1",
"runtimeStatusLine": "OpenXR runtime owner xr/openxr-desktop-training-runtime-owner | preferences xr-openxr-training-preferences/v1 | bindings xr-openxr-motion-controllers/v1 | HMD connected no | HMD enabled no | posture standing | dominant hand right | snap turn on | smooth locomotion on | vertical offset 0 | desktop pitch 0 | grip 0.00/0.00 | trigger 0.00/0.00",
"reportPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_xr_20260702_rebuild\\validation\\smoke\\_Game_HyperTwistTraining_Maps_L_HyperTwist_FollowAlongTraining.runtime.json",
"error": "",
"observationSeconds": 8,
"bRuntimeOwnerStructurallyValid": true,
"bControllerSettingsOwnerStructurallyValid": true,
"bHeadMountedDisplayConnected": false,
"bHeadMountedDisplayEnabled": false,
"bObservedHeadMountedDisplaySession": false,
"bObservedControllerInputActivity": false,
"observedControllerInputMagnitude": 0,
"remainingGateIds": [
"xr/windows-packaged-headset-session-observation",
"xr/windows-packaged-controller-input-observation"
]
},
"error": null
},
{
"reportVersion": "ht-xr-package-smoke/v1",
"generatedAtUtc": "2026-07-02T03:31:24.2215641Z",
"packageRoot": "C:\\HyperTwist_worktrees\\phase10validate_packaged_xr_20260702_rebuild",
"executablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_xr_20260702_rebuild\\Windows\\UnrealHyperTwist.exe",
"mapAssetPath": "/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining",
"mapUrl": "/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining?game=/Script/UnrealHyperTwist.HyperTwistXrTrainingGameMode",
"runtimeLaunchMode": "nullrhi",
"observationSeconds": 8,
"timeoutSeconds": 30,
"resolution": null,
"keepRunning": false,
"runtimeReportPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_xr_20260702_rebuild\\validation\\smoke\\_Game_HyperTwistTraining_Maps_L_HyperTwist_ClassicTraining.runtime.json",
"result": "passed",
"processId": 33736,
"processStopped": false,
"exitCode": 0,
"runtimeReport": {
"reportVersion": "ht-xr-packaged-validation/v1",
"generatedAtUtc": "2026-07-02T03:31:34.666Z",
"launchSurfaceId": "xr/openxr-desktop-training-packaged-validation-launch-surface",
"gameModeClassPath": "/Script/UnrealHyperTwist.HyperTwistXrTrainingGameMode",
"cookMapAssetPath": "/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining",
"launchMapUrl": "/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining?game=/Script/UnrealHyperTwist.HyperTwistXrTrainingGameMode",
"result": "passed",
"packagedProofStatus": "pending-hmd-session-observation",
"runtimeOwnerId": "xr/openxr-desktop-training-runtime-owner",
"preferenceProfileId": "xr-openxr-training-preferences/v1",
"controllerBindingProfileId": "xr-openxr-motion-controllers/v1",
"controllerSettingsOwnerId": "xr-openxr-controller-settings-owner/v1",
"runtimeStatusLine": "OpenXR runtime owner xr/openxr-desktop-training-runtime-owner | preferences xr-openxr-training-preferences/v1 | bindings xr-openxr-motion-controllers/v1 | HMD connected no | HMD enabled no | posture standing | dominant hand right | snap turn on | smooth locomotion on | vertical offset 0 | desktop pitch 0 | grip 0.00/0.00 | trigger 0.00/0.00",
"reportPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_xr_20260702_rebuild\\validation\\smoke\\_Game_HyperTwistTraining_Maps_L_HyperTwist_ClassicTraining.runtime.json",
"error": "",
"observationSeconds": 8,
"bRuntimeOwnerStructurallyValid": true,
"bControllerSettingsOwnerStructurallyValid": true,
"bHeadMountedDisplayConnected": false,
"bHeadMountedDisplayEnabled": false,
"bObservedHeadMountedDisplaySession": false,
"bObservedControllerInputActivity": false,
"observedControllerInputMagnitude": 0,
"remainingGateIds": [
"xr/windows-packaged-headset-session-observation",
"xr/windows-packaged-controller-input-observation"
]
},
"error": null
}
],
"packagedHeadMountedDisplaySessionObserved": false,
"packagedControllerInputObserved": false,
"packagedProofStatusSet": [
"pending-hmd-session-observation"
],
"remainingGateIds": [
"xr/windows-packaged-headset-session-observation",
"xr/windows-packaged-controller-input-observation"
],
"error": null
}

View file

@ -25,7 +25,7 @@ Current authoritative host posture:
- that bounded runtime-owner substrate must still be treated as truthful
desktop-simulator ownership, not as proof of a finished launch-tier
XR/controller product
- polished user-facing rebinding/settings ownership and packaged
- polished user-facing rebinding/settings ownership and live packaged
headset/controller proof remain unfinished and continue to hold the wider
lane closed
@ -136,7 +136,7 @@ HyperTwist already owns:
HyperTwist does **not** yet own:
- polished controller rebinding/preferences ownership
- packaged headset/controller proof
- live packaged headset/controller proof
- broader launch-tier cross-device XR completion
Therefore the current truthful host decision is:
@ -173,3 +173,59 @@ widening.
- that means the broader native XR/controller widening gate no longer waits on
a hypothetical settings/rebinding layer; it now waits on Windows packaged
controller validation with controller truth on the shipping lane
## Update - 2026-07-02 - packaged validation proof now exists, live observation gate remains
- the next same-family continuation now adds a first-party packaged-validation
seam for the bounded desktop-hosted XR lane instead of leaving packaged proof
entirely hypothetical:
- `UHyperTwistXrPackageValidationLibrary`
- `AHyperTwistXrTrainingGameMode`
- `scripts\Invoke-HyperTwistXrPackage.ps1`
- `scripts\Launch-HyperTwistXrPackage.ps1`
- checked-in aggregate proof:
`docs/generated/xr/xr_packaged_validation_report_2026-07-02.json`
- the maintained reverse-SSH Windows lane on
`C:\HyperTwist_worktrees\phase10validate` first exposed a real stale-
packaged-artifact hazard: the old helper would allow `-SkipBuild` from an
editor-green posture even when the packaged-game receipt and binary were no
longer fresh against current source/config/plugin inputs
- that helper posture is now repaired: `-SkipBuild` is accepted only when the
packaged-game receipt and binary are both present and fresh against the
current `.uproject`, `Source`, `Config`, and `Plugins` inputs
- after that correction, a truthful full packaged-game rebuild on the same
maintained root completed with:
- `Result: Succeeded`
- UnrealBuildTool `Total execution time: 2499.70 seconds`
- `BuildCookRun time: 2593.82 s`
- the first packaged smoke after that rebuild then exposed a second real host-
context fact: the reverse-SSH headless lane cannot truthfully claim windowed
packaged XR proof when swapchain creation fails under that unattended
session
- the owned XR package smoke helpers are therefore now explicitly bifurcated:
- `RuntimeLaunchMode=windowed` remains the lane for any future live
headset/controller observation proof
- `RuntimeLaunchMode=nullrhi` is the bounded current reverse-SSH packaged
structural launch and ownership proof path
- the final exact-source rerun on that same maintained root then stayed green
in headless `nullrhi` mode with:
- `BuildCookRun time: 85.47 s`
- both packaged smoke maps passed:
- `/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining`
- `/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining`
- aggregate report `result: passed`
- stable owned ids preserved:
- `xr/openxr-desktop-training-runtime-owner`
- `xr-openxr-training-preferences/v1`
- `xr-openxr-motion-controllers/v1`
- `xr-openxr-controller-settings-owner/v1`
- explicit remaining gate ids:
- `xr/windows-packaged-headset-session-observation`
- `xr/windows-packaged-controller-input-observation`
- current truthful reading after this packaged continuation:
- HyperTwist now owns first-party Windows packaged structural launch and XR
ownership proof on the shipping lane
- HyperTwist still does not own live packaged headset-session observation or
live packaged controller-input observation
- the broader native XR/controller widening branch therefore remains closed
until those two remaining live packaged gates are actually proven

View file

@ -672,11 +672,69 @@ Operational rules reinforced by this proof:
- when validating package-helper archive/report behavior and the packaged-game
receipt already exists, prefer a fresh `-SkipBuild` package proof over an
unrelated full game rebuild
- when using `-SkipBuild` for package-helper proof, do not treat receipt
existence alone as sufficient: the packaged-game receipt and binary must
also be fresh against current `.uproject`, `Source`, `Config`, and `Plugins`
inputs or the helper should force a truthful rebuild instead
- when one `UnrealEditor-Cmd` `ExecCmds` string attempts to queue multiple
disjoint `Automation RunTests` filters, inspect the exported report JSON or
rerun the filters explicitly; the first filter may be the only one reflected
in the report
## Addendum - 2026-07-02 (XR packaged-validation lane posture)
Live follow-up on `2026-07-02` established these additional facts:
- the primary `localhost:22022` lane remained healthy for the current bounded
XR packaged-validation continuation on maintained Windows worktree
`C:\HyperTwist_worktrees\phase10validate`
- the repo now also carries first-party bounded XR packaged-validation helpers:
- `scripts\Invoke-HyperTwistXrPackage.ps1`
- `scripts\Launch-HyperTwistXrPackage.ps1`
- the first exact-source packaged rerun exposed a real stale-artifact truth:
the older helper posture could allow `-SkipBuild` from a merely editor-green
state even when the packaged-game target receipt and Win64 game binary were
stale relative to current source/config/plugin inputs
- that helper posture is now repaired: `-SkipBuild` is only valid when those
packaged-game artifacts are both present and fresh against the current build
inputs
- after that repair, the same maintained root then completed a truthful full
packaged-game rebuild with:
- `Result: Succeeded`
- UnrealBuildTool `Total execution time: 2499.70 seconds`
- `BuildCookRun time: 2593.82 s`
- the first packaged smoke after that rebuild then exposed a second host-state
truth on this lane: under the unattended reverse-SSH session, windowed XR
packaged proof failed swapchain creation with `DXGI_ERROR_NOT_CURRENTLY_AVAILABLE`
- the current owned recovery path is therefore explicit:
- use `RuntimeLaunchMode=windowed` only for future live headset/controller
observation proof on a suitable non-headless host context
- use `RuntimeLaunchMode=nullrhi` for the current reverse-SSH packaged
structural launch/ownership proof
- the final exact-source rerun on that same maintained root then stayed green
in `nullrhi` mode with:
- `BuildCookRun time: 85.47 s`
- aggregate report `result: passed`
- both XR smoke maps green:
- `/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining`
- `/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining`
- checked-in aggregate proof:
`docs/generated/xr/xr_packaged_validation_report_2026-07-02.json`
- explicit remaining gate ids:
- `xr/windows-packaged-headset-session-observation`
- `xr/windows-packaged-controller-input-observation`
Operational rules reinforced by this proof:
- for headless reverse-SSH packaged XR proof, `nullrhi` is the truthful
current structural launch path and should not be mislabeled as live
headset/controller proof
- for live packaged headset/controller observation, reopen a non-headless
`windowed` proof context instead of overloading the current unattended lane
- when packaged proof is the goal, judge `-SkipBuild` freshness from the
packaged-game receipt and binary timestamps against current build inputs, not
from artifact existence alone
## Addendum - 2026-06-13 (Phase 9B Unreal-native media-export proof)
Live follow-up on `2026-06-13` established these additional facts:

View file

@ -269,6 +269,26 @@ must present both truths instead of flattening the packet back down to one
both the package-helper `skipBuild` state and the surrounding
`prePackageEditorBuild` result for the same proof packet.
Further XR packaged-validation proof on `2026-07-02` then tightened the same
doctrine again for the native XR lane: the newly added
`scripts\Invoke-HyperTwistXrPackage.ps1` helper first exposed that packaged
artifact existence alone is not sufficient `-SkipBuild` truth, because the
maintained root could still be editor-green while the packaged-game receipt and
Win64 game binary were stale relative to current `.uproject`, `Source`,
`Config`, and `Plugins` inputs. The helper posture was then repaired to require
freshness, after which the same maintained root
`C:\HyperTwist_worktrees\phase10validate` completed a truthful full packaged
rebuild with `Result: Succeeded`, UnrealBuildTool `Total execution time:
2499.70 seconds`, and `BuildCookRun time: 2593.82 s`. The first packaged
windowed smoke then exposed a second real host-context truth by failing
swapchain creation under the unattended reverse-SSH session, so the owned XR
package smoke lane now uses `RuntimeLaunchMode=nullrhi` for structural packaged
launch/ownership proof and reserves `RuntimeLaunchMode=windowed` for future
live headset/controller observation proof. The final exact-source rerun then
stayed green with `BuildCookRun time: 85.47 s`, both XR smoke maps passed, and
checked-in aggregate proof now lives at
`docs/generated/xr/xr_packaged_validation_report_2026-07-02.json`.
Further `2026-06-13` continuation proof on that same primary lane recovered
the classic-cube replay/leaderboard/package slice after a corrupted reverse
sync incident: a prior aborted broad tar sync left remote
@ -378,7 +398,13 @@ scripts/run-hypertwist-remote-unreal-automation-sequence.sh \
files before classifying later compiler failures as a real source regression
- when validating package-helper archive/report behavior and the packaged-game
receipt already exists, `-SkipBuild` is the preferred proof path over an
unrelated full game rebuild
unrelated full game rebuild, but only when the packaged-game receipt and
binary are both still fresh against current `.uproject`, `Source`, `Config`,
and `Plugins` inputs rather than merely present
- for reverse-SSH/headless packaged XR proof, a bounded `nullrhi` smoke launch
is an acceptable structural launch/ownership validation mode, but it does
not satisfy the separate live headset-session or controller-input observation
gate
- for reverse-SSH/headless classic-cube replay-export validation, use
`-NullRHI` only for the asset-authoring leg and `-RenderOffScreen` for the
actual `MovieRenderQueue` render leg; they solve different failure modes

View file

@ -391,8 +391,26 @@ truthfully claim:
- the later `2026-07-01` same-family continuation now also adds first-party
user-facing controller settings, layout presets, and rebinding ownership for
the exact shipped XR input ids
- the later `2026-07-02` packaged-validation continuation then adds a real
first-party packaged XR ownership seam on the Windows reverse-SSH lane:
- `UHyperTwistXrPackageValidationLibrary`
- `AHyperTwistXrTrainingGameMode`
- `scripts\Invoke-HyperTwistXrPackage.ps1`
- `scripts\Launch-HyperTwistXrPackage.ps1`
- checked-in aggregate proof:
`docs/generated/xr/xr_packaged_validation_report_2026-07-02.json`
- that packaged follow-up truthfully proves:
- packaged launch-surface ownership
- packaged game-mode ownership
- packaged runtime-owner / preference / controller-settings id continuity
- packaged smoke success for both current training maps on the Windows lane
- that same packaged follow-up does **not** yet prove live headset/controller
behavior, because the maintained reverse-SSH session required headless
`nullrhi` runtime launch after windowed swapchain creation failed in the
unattended host context
- the current remaining unproven pieces therefore are:
- packaged headset/controller behavior on the Windows lane
- live packaged headset-session observation on the Windows lane
- live packaged controller-input observation on the Windows lane
- broader launch-tier cross-device XR completion
- `HyperTwistTrainingImmersiveEnvironmentLibrary.cpp` clearly establishes a
first-party immersive/scenic environment direction, but it also explicitly
@ -415,6 +433,10 @@ Current truthful product wording should say:
- native operator/training diagnostics now also keep the current project
`OpenXR` plugin posture explicit rather than leaving that fact implied by
surrounding XR groundwork wording
- packaged Windows XR structural launch proof now also exists through the new
first-party packaged-validation seam, but current proof remains headless
`nullrhi` structural ownership proof rather than live headset/controller
observation
- the currently shipped selectable native roster is visible inside Unreal:
classic keyboard, scenic immersive presets, and dedicated 120-cell/5D view
selectors
@ -463,7 +485,8 @@ That packet leaves native XR widening closed by default.
If the project later reopens this lane from the current `No-Go` posture, the
first clean remaining implementation packet should be:
1. packaged validation on the Windows Unreal lane with headset/controller truth
kept explicit above the now-landed bounded runtime-owner plus controller
settings/rebinding substrate
2. public/product copy update only after that packaged proof exists
1. live packaged headset-session plus controller-input observation on the
Windows Unreal lane, kept explicit above the now-landed bounded runtime-
owner plus controller settings/rebinding substrate and above the already-
shipped headless `nullrhi` packaged structural proof
2. public/product copy update only after that live packaged proof exists

View file

@ -1160,7 +1160,8 @@ Current audit note:
- HyperTwist now owns a real bounded desktop-training OpenXR runtime-owner
seam
- the broader desktop-hosted XR/controller widening branch still remains
closed until packaged headset/controller proof exists on the shipping lane
closed until live packaged headset/controller proof exists on the shipping
lane
- this is the right intermediate state to document publicly because it is
stronger than the older negative-only wording and still honest about the
remaining gap
@ -2381,6 +2382,86 @@ Latest same-family responsive public-route expansion follow-up on `2026-06-29`:
desktop control ownership without overclaiming reopened VR/controller
product status
## XR packaged-validation continuation (`2026-07-02`)
- the next same-family continuation then moved the remaining XR gate from
“packaged proof is still theoretical” into a real first-party maintained
package-validation seam without reopening the broader desktop-hosted `No-Go`
branch:
- new runtime/report ownership now lives in:
- `UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistXR/HyperTwistXrPackageValidationLibrary.h`
- `UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistXR/HyperTwistXrPackageValidationLibrary.cpp`
- `UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistXR/HyperTwistXrTrainingGameMode.h`
- `UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistXR/HyperTwistXrTrainingGameMode.cpp`
- the bounded training pawn now also exposes packaged-proof observation
helpers for HMD-session detection and controller-input activity
- focused coverage now also includes
`UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistXrPackageValidationContractTest.cpp`
- the owned packaged wrappers now live in:
- `scripts/Invoke-HyperTwistXrPackage.ps1`
- `scripts/Launch-HyperTwistXrPackage.ps1`
- the first same-family remote proof step on maintained Windows root
`C:\HyperTwist_worktrees\phase10validate` then exposed a real stale-proof
hazard instead of being silently waved through:
- the earlier helper posture would still allow `-SkipBuild` if the packaged
target receipt merely existed
- that is not truthful packaged proof when current `.uproject`, `Source`,
`Config`, or `Plugins` inputs are newer than the packaged-game receipt or
local Win64 game binary
- the helper doctrine is now repaired across the maintained package wrappers:
- `scripts/Invoke-HyperTwistXrPackage.ps1`
- `scripts/Invoke-HyperTwistHigherDimensionalPackage.ps1`
- `scripts/Invoke-HyperTwistClassicCubePackage.ps1`
- `-SkipBuild` now requires both packaged-game artifact presence and
freshness against current build-relevant inputs
- a passed editor build alone no longer masquerades as sufficient packaged
runtime proof
- after that correction, the same maintained Windows root then ran the
truthful full packaged-game rebuild:
- `Result: Succeeded`
- UnrealBuildTool `Total execution time: 2499.70 seconds`
- `BuildCookRun time: 2593.82 s`
- archive root:
`C:\HyperTwist_worktrees\phase10validate_packaged_xr_20260702_rebuild`
- the first packaged smoke after that rebuild then exposed the second real
lane truth:
- under the unattended reverse-SSH session, windowed packaged XR could not
create the swapchain and failed with
`DXGI_ERROR_NOT_CURRENTLY_AVAILABLE`
- that means the current lane must not overclaim windowed packaged XR proof
when the real host context is still headless
- the owned package-smoke wrapper is therefore now explicit about launch mode:
- `RuntimeLaunchMode=windowed` remains the future live
headset/controller-observation lane
- `RuntimeLaunchMode=nullrhi` is the current reverse-SSH packaged
structural launch/ownership lane
- the launcher now also sets the packaged executable working directory
explicitly instead of assuming inherited shell state
- the final exact-source rerun on that same maintained root then stayed green
in headless `nullrhi` mode:
- `BuildCookRun time: 85.47 s`
- aggregate report `result: passed`
- both packaged smoke maps passed:
- `/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining`
- `/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining`
- stable ids remained intact through packaged runtime proof:
- `xr/openxr-desktop-training-runtime-owner`
- `xr-openxr-training-preferences/v1`
- `xr-openxr-motion-controllers/v1`
- `xr-openxr-controller-settings-owner/v1`
- the checked-in aggregate proof now lives at:
`docs/generated/xr/xr_packaged_validation_report_2026-07-02.json`
- the remaining live reopen gates stayed explicit as:
- `xr/windows-packaged-headset-session-observation`
- `xr/windows-packaged-controller-input-observation`
- current truthful reading after this continuation:
- HyperTwist now owns first-party Windows packaged XR structural launch and
ownership proof on the shipping lane
- HyperTwist still does not own live packaged headset-session observation or
live packaged controller-input observation
- the broader native XR/controller widening branch therefore remains closed
until those two remaining live packaged gates are actually proven
## Latest protected launch-status responsive recovery plus tooling rerun (`2026-06-29`)
- the next same-family browser pass fixed a real small-screen regression on the

File diff suppressed because one or more lines are too long

View file

@ -131,6 +131,105 @@ function Assert-CookMapExists {
}
}
function Resolve-ExistingPath {
param(
[string[]]$CandidatePaths
)
return $CandidatePaths | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
}
function Get-MostRecentFileItem {
param(
[string[]]$Paths
)
$MostRecentItem = $null
foreach ($Path in $Paths)
{
if (-not (Test-Path -LiteralPath $Path))
{
continue
}
$PathItem = Get-Item -LiteralPath $Path
$CandidateItem = $null
if ($PathItem.PSIsContainer)
{
$CandidateItem = Get-ChildItem -LiteralPath $Path -Recurse -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
}
else
{
$CandidateItem = $PathItem
}
if ($null -eq $CandidateItem)
{
continue
}
if ($null -eq $MostRecentItem -or $CandidateItem.LastWriteTimeUtc -gt $MostRecentItem.LastWriteTimeUtc)
{
$MostRecentItem = $CandidateItem
}
}
return $MostRecentItem
}
function Assert-SkipBuildInputFreshness {
param(
[string]$RootPath,
[string]$Configuration,
[string]$GameTargetReceiptPath
)
if (-not (Test-Path -LiteralPath $GameTargetReceiptPath))
{
throw "SkipBuild was requested, but the packaged-game target receipt was not found at '$GameTargetReceiptPath'. Re-run without -SkipBuild so RunUAT can build the Win64 game target."
}
$GameBinaryPath = Resolve-ExistingPath -CandidatePaths @(
(Join-Path $RootPath 'UnrealHyperTwist\Binaries\Win64\UnrealHyperTwist.exe'),
(Join-Path $RootPath ("UnrealHyperTwist\Binaries\Win64\UnrealHyperTwist-Win64-{0}.exe" -f $Configuration))
)
if ($null -eq $GameBinaryPath)
{
throw "SkipBuild was requested, but no local packaged-game binary was found beneath '$(Join-Path $RootPath 'UnrealHyperTwist\Binaries\Win64')'. Re-run without -SkipBuild so RunUAT can build the Win64 game target."
}
$NewestBuildInputItem = Get-MostRecentFileItem -Paths @(
(Join-Path $RootPath 'UnrealHyperTwist\UnrealHyperTwist.uproject'),
(Join-Path $RootPath 'UnrealHyperTwist\Source'),
(Join-Path $RootPath 'UnrealHyperTwist\Config'),
(Join-Path $RootPath 'UnrealHyperTwist\Plugins')
)
if ($null -eq $NewestBuildInputItem)
{
return
}
$ReceiptItem = Get-Item -LiteralPath $GameTargetReceiptPath
$GameBinaryItem = Get-Item -LiteralPath $GameBinaryPath
if ($NewestBuildInputItem.LastWriteTimeUtc -gt $ReceiptItem.LastWriteTimeUtc `
-or $NewestBuildInputItem.LastWriteTimeUtc -gt $GameBinaryItem.LastWriteTimeUtc)
{
throw (
"SkipBuild was requested, but the packaged-game build artifacts are stale. " +
"Newest build input '{0}' ({1:o}) is newer than receipt '{2}' ({3:o}) or game binary '{4}' ({5:o}). " +
"Re-run without -SkipBuild so RunUAT can rebuild the Win64 game target; a passed editor build alone is not sufficient packaged-runtime proof."
) -f `
$NewestBuildInputItem.FullName,
$NewestBuildInputItem.LastWriteTimeUtc,
$ReceiptItem.FullName,
$ReceiptItem.LastWriteTimeUtc,
$GameBinaryItem.FullName,
$GameBinaryItem.LastWriteTimeUtc
}
}
if (-not (Test-Path $RunUatPath))
{
throw "RunUAT was not found at '$RunUatPath'."
@ -159,9 +258,12 @@ foreach ($ExpectedMaterialPath in $ExpectedClassicCubeMaterialPaths)
}
}
if ($SkipBuild -and -not (Test-Path $GameTargetReceiptPath))
if ($SkipBuild)
{
throw "SkipBuild was requested, but the packaged-game target receipt was not found at '$GameTargetReceiptPath'. Re-run without -SkipBuild so RunUAT can build the Win64 game target."
Assert-SkipBuildInputFreshness `
-RootPath $ProjectRoot `
-Configuration $Configuration `
-GameTargetReceiptPath $GameTargetReceiptPath
}
if ($CleanArchive -and (Test-Path $ArchiveDirectory))

View file

@ -125,6 +125,105 @@ function Assert-CookMapExists {
}
}
function Resolve-ExistingPath {
param(
[string[]]$CandidatePaths
)
return $CandidatePaths | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
}
function Get-MostRecentFileItem {
param(
[string[]]$Paths
)
$MostRecentItem = $null
foreach ($Path in $Paths)
{
if (-not (Test-Path -LiteralPath $Path))
{
continue
}
$PathItem = Get-Item -LiteralPath $Path
$CandidateItem = $null
if ($PathItem.PSIsContainer)
{
$CandidateItem = Get-ChildItem -LiteralPath $Path -Recurse -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
}
else
{
$CandidateItem = $PathItem
}
if ($null -eq $CandidateItem)
{
continue
}
if ($null -eq $MostRecentItem -or $CandidateItem.LastWriteTimeUtc -gt $MostRecentItem.LastWriteTimeUtc)
{
$MostRecentItem = $CandidateItem
}
}
return $MostRecentItem
}
function Assert-SkipBuildInputFreshness {
param(
[string]$RootPath,
[string]$Configuration,
[string]$GameTargetReceiptPath
)
if (-not (Test-Path -LiteralPath $GameTargetReceiptPath))
{
throw "SkipBuild was requested, but the packaged-game target receipt was not found at '$GameTargetReceiptPath'. Re-run without -SkipBuild so RunUAT can build the Win64 game target."
}
$GameBinaryPath = Resolve-ExistingPath -CandidatePaths @(
(Join-Path $RootPath 'UnrealHyperTwist\Binaries\Win64\UnrealHyperTwist.exe'),
(Join-Path $RootPath ("UnrealHyperTwist\Binaries\Win64\UnrealHyperTwist-Win64-{0}.exe" -f $Configuration))
)
if ($null -eq $GameBinaryPath)
{
throw "SkipBuild was requested, but no local packaged-game binary was found beneath '$(Join-Path $RootPath 'UnrealHyperTwist\Binaries\Win64')'. Re-run without -SkipBuild so RunUAT can build the Win64 game target."
}
$NewestBuildInputItem = Get-MostRecentFileItem -Paths @(
(Join-Path $RootPath 'UnrealHyperTwist\UnrealHyperTwist.uproject'),
(Join-Path $RootPath 'UnrealHyperTwist\Source'),
(Join-Path $RootPath 'UnrealHyperTwist\Config'),
(Join-Path $RootPath 'UnrealHyperTwist\Plugins')
)
if ($null -eq $NewestBuildInputItem)
{
return
}
$ReceiptItem = Get-Item -LiteralPath $GameTargetReceiptPath
$GameBinaryItem = Get-Item -LiteralPath $GameBinaryPath
if ($NewestBuildInputItem.LastWriteTimeUtc -gt $ReceiptItem.LastWriteTimeUtc `
-or $NewestBuildInputItem.LastWriteTimeUtc -gt $GameBinaryItem.LastWriteTimeUtc)
{
throw (
"SkipBuild was requested, but the packaged-game build artifacts are stale. " +
"Newest build input '{0}' ({1:o}) is newer than receipt '{2}' ({3:o}) or game binary '{4}' ({5:o}). " +
"Re-run without -SkipBuild so RunUAT can rebuild the Win64 game target; a passed editor build alone is not sufficient packaged-runtime proof."
) -f `
$NewestBuildInputItem.FullName,
$NewestBuildInputItem.LastWriteTimeUtc,
$ReceiptItem.FullName,
$ReceiptItem.LastWriteTimeUtc,
$GameBinaryItem.FullName,
$GameBinaryItem.LastWriteTimeUtc
}
}
if ([string]::IsNullOrWhiteSpace($AuthoringManifestPath))
{
$AuthoringManifestPath = Join-Path $ProjectRoot 'docs\generated\higher_dimensional_training_maps\phase6c_dedicated_family_map_manifest.json'
@ -215,9 +314,12 @@ foreach ($TargetMap in ($CookMaps + $ResolvedSmokeMaps | Select-Object -Unique))
}
}
if ($SkipBuild -and -not (Test-Path $GameTargetReceiptPath))
if ($SkipBuild)
{
throw "SkipBuild was requested, but the packaged-game target receipt was not found at '$GameTargetReceiptPath'. Re-run without -SkipBuild so RunUAT can build the Win64 game target."
Assert-SkipBuildInputFreshness `
-RootPath $ProjectRoot `
-Configuration $Configuration `
-GameTargetReceiptPath $GameTargetReceiptPath
}
if ($PrePackageEditorBuildPerformed -and $PrePackageEditorBuildResult -ne 'passed')

View file

@ -0,0 +1,466 @@
param(
[string]$ProjectRoot = 'C:\HyperTwist',
[string]$ArchiveDirectory = 'C:\HyperTwist\packaged\xr',
[ValidateSet('Development', 'Shipping')]
[string]$Configuration = 'Development',
[string]$CookMap = '/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining',
[string[]]$AdditionalCookMaps = @('/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining'),
[string[]]$SmokeMaps = @(),
[double]$ObservationSeconds = 8.0,
[double]$TimeoutSeconds = 30.0,
[ValidateSet('nullrhi', 'windowed')]
[string]$RuntimeLaunchMode = 'nullrhi',
[string[]]$AdditionalCookerOptions = @(
'-DisablePlugins=MovieRenderPipeline',
'-SkipCookingEditorContent'
),
[string]$ValidationReportPath = '',
[switch]$CleanArchive,
[switch]$SkipBuild,
[switch]$PrePackageEditorBuildPerformed,
[ValidateSet('not-run', 'passed')]
[string]$PrePackageEditorBuildResult = 'not-run',
[switch]$SkipLaunch
)
$ErrorActionPreference = 'Stop'
$RunUatPath = 'C:\Program Files\Epic Games\UE_5.7\Engine\Build\BatchFiles\RunUAT.bat'
$UProjectPath = Join-Path $ProjectRoot 'UnrealHyperTwist\UnrealHyperTwist.uproject'
$LaunchScriptPath = Join-Path $ProjectRoot 'scripts\Launch-HyperTwistXrPackage.ps1'
$GameTargetReceiptPath = Join-Path $ProjectRoot 'UnrealHyperTwist\Binaries\Win64\UnrealHyperTwist.target'
$UnrealBuildToolSavedPath = Join-Path $ProjectRoot 'UnrealHyperTwist\Saved\UnrealBuildTool'
$CookMaps = @($CookMap) + $AdditionalCookMaps | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
$ResolvedSmokeMaps = @(
if ($SmokeMaps.Count -gt 0)
{
$SmokeMaps
}
else
{
$CookMaps
}
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
$ResolvedAdditionalCookerOptions = @($AdditionalCookerOptions) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
$ValidationRootPath = Join-Path $ArchiveDirectory 'validation'
$SmokeReportDirectory = Join-Path $ValidationRootPath 'smoke'
function Write-Utf8JsonFile {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[object]$Value
)
$ParentPath = Split-Path -Parent $Path
if (-not [string]::IsNullOrWhiteSpace($ParentPath))
{
New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null
}
$Json = $Value | ConvertTo-Json -Depth 12
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
}
function Convert-PathToken {
param(
[string]$Value
)
if ([string]::IsNullOrWhiteSpace($Value))
{
return 'unknown'
}
return ($Value -replace '[\\/:*?"<>| ]', '_')
}
function Read-JsonFile {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Get-JsonPropertyValue {
param(
[Parameter(Mandatory = $true)]
[object]$Object,
[Parameter(Mandatory = $true)]
[string[]]$Names,
[object]$Default = $null
)
foreach ($Name in $Names)
{
$Property = $Object.PSObject.Properties[$Name]
if ($null -ne $Property)
{
return $Property.Value
}
}
return $Default
}
function Resolve-PackagedExecutablePath {
param(
[string]$PackageRoot
)
$CandidateExecutablePaths = @(
(Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'),
(Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'),
(Join-Path $PackageRoot 'UnrealHyperTwist.exe')
)
return $CandidateExecutablePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
}
function Convert-GameMapPathToContentPath {
param(
[string]$RootPath,
[string]$GameMapPath
)
if (-not $GameMapPath.StartsWith('/Game/'))
{
throw "Cook map '$GameMapPath' is not a supported /Game asset path."
}
$RelativeMapPath = $GameMapPath.Substring('/Game/'.Length).Replace('/', '\')
return Join-Path $RootPath ("UnrealHyperTwist\Content\{0}.umap" -f $RelativeMapPath)
}
function Assert-CookMapExists {
param(
[string]$RootPath,
[string]$GameMapPath
)
$ExpectedMapPath = Convert-GameMapPathToContentPath -RootPath $RootPath -GameMapPath $GameMapPath
if (-not (Test-Path $ExpectedMapPath))
{
throw "Expected XR packaged-validation cook map '$GameMapPath' was not found at '$ExpectedMapPath'."
}
}
function Resolve-ExistingPath {
param(
[string[]]$CandidatePaths
)
return $CandidatePaths | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
}
function Get-MostRecentFileItem {
param(
[string[]]$Paths
)
$MostRecentItem = $null
foreach ($Path in $Paths)
{
if (-not (Test-Path -LiteralPath $Path))
{
continue
}
$PathItem = Get-Item -LiteralPath $Path
$CandidateItem = $null
if ($PathItem.PSIsContainer)
{
$CandidateItem = Get-ChildItem -LiteralPath $Path -Recurse -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
}
else
{
$CandidateItem = $PathItem
}
if ($null -eq $CandidateItem)
{
continue
}
if ($null -eq $MostRecentItem -or $CandidateItem.LastWriteTimeUtc -gt $MostRecentItem.LastWriteTimeUtc)
{
$MostRecentItem = $CandidateItem
}
}
return $MostRecentItem
}
function Assert-SkipBuildInputFreshness {
param(
[string]$RootPath,
[string]$Configuration,
[string]$GameTargetReceiptPath
)
if (-not (Test-Path -LiteralPath $GameTargetReceiptPath))
{
throw "SkipBuild was requested, but the packaged-game target receipt was not found at '$GameTargetReceiptPath'. Re-run without -SkipBuild so RunUAT can build the Win64 game target."
}
$GameBinaryPath = Resolve-ExistingPath -CandidatePaths @(
(Join-Path $RootPath 'UnrealHyperTwist\Binaries\Win64\UnrealHyperTwist.exe'),
(Join-Path $RootPath ("UnrealHyperTwist\Binaries\Win64\UnrealHyperTwist-Win64-{0}.exe" -f $Configuration))
)
if ($null -eq $GameBinaryPath)
{
throw "SkipBuild was requested, but no local packaged-game binary was found beneath '$(Join-Path $RootPath 'UnrealHyperTwist\Binaries\Win64')'. Re-run without -SkipBuild so RunUAT can build the Win64 game target."
}
$NewestBuildInputItem = Get-MostRecentFileItem -Paths @(
(Join-Path $RootPath 'UnrealHyperTwist\UnrealHyperTwist.uproject'),
(Join-Path $RootPath 'UnrealHyperTwist\Source'),
(Join-Path $RootPath 'UnrealHyperTwist\Config'),
(Join-Path $RootPath 'UnrealHyperTwist\Plugins')
)
if ($null -eq $NewestBuildInputItem)
{
return
}
$ReceiptItem = Get-Item -LiteralPath $GameTargetReceiptPath
$GameBinaryItem = Get-Item -LiteralPath $GameBinaryPath
if ($NewestBuildInputItem.LastWriteTimeUtc -gt $ReceiptItem.LastWriteTimeUtc `
-or $NewestBuildInputItem.LastWriteTimeUtc -gt $GameBinaryItem.LastWriteTimeUtc)
{
throw (
"SkipBuild was requested, but the packaged-game build artifacts are stale. " +
"Newest build input '{0}' ({1:o}) is newer than receipt '{2}' ({3:o}) or game binary '{4}' ({5:o}). " +
"Re-run without -SkipBuild so RunUAT can rebuild the Win64 game target; a passed editor build alone is not sufficient packaged-runtime proof."
) -f `
$NewestBuildInputItem.FullName,
$NewestBuildInputItem.LastWriteTimeUtc,
$ReceiptItem.FullName,
$ReceiptItem.LastWriteTimeUtc,
$GameBinaryItem.FullName,
$GameBinaryItem.LastWriteTimeUtc
}
}
if (-not (Test-Path $RunUatPath))
{
throw "RunUAT was not found at '$RunUatPath'."
}
if (-not (Test-Path $UProjectPath))
{
throw "UnrealHyperTwist project file was not found at '$UProjectPath'."
}
foreach ($TargetMap in ($CookMaps + $ResolvedSmokeMaps | Select-Object -Unique))
{
Assert-CookMapExists -RootPath $ProjectRoot -GameMapPath $TargetMap
}
if ($SkipBuild)
{
Assert-SkipBuildInputFreshness `
-RootPath $ProjectRoot `
-Configuration $Configuration `
-GameTargetReceiptPath $GameTargetReceiptPath
}
if ($PrePackageEditorBuildPerformed -and $PrePackageEditorBuildResult -ne 'passed')
{
throw "PrePackageEditorBuildPerformed was set, but PrePackageEditorBuildResult was '$PrePackageEditorBuildResult' instead of 'passed'."
}
if (-not $PrePackageEditorBuildPerformed -and $PrePackageEditorBuildResult -eq 'passed')
{
throw "PrePackageEditorBuildResult was 'passed', but PrePackageEditorBuildPerformed was not set."
}
if ($CleanArchive -and (Test-Path $ArchiveDirectory))
{
Remove-Item -LiteralPath $ArchiveDirectory -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $UnrealBuildToolSavedPath | Out-Null
New-Item -ItemType Directory -Force -Path $ArchiveDirectory | Out-Null
New-Item -ItemType Directory -Force -Path $ValidationRootPath | Out-Null
New-Item -ItemType Directory -Force -Path $SmokeReportDirectory | Out-Null
if ([string]::IsNullOrWhiteSpace($ValidationReportPath))
{
$ValidationReportPath = Join-Path $ValidationRootPath 'xr-package-validation-report.json'
}
$RunUatArguments = @(
'BuildCookRun',
"-project=$UProjectPath",
'-noP4',
'-platform=Win64',
"-clientconfig=$Configuration",
'-cook',
'-stage',
'-package',
'-pak',
'-archive',
"-archivedirectory=$ArchiveDirectory",
"-map=$($CookMaps -join '+')",
'-unattended',
'-utf8output'
)
if (-not $SkipBuild)
{
$RunUatArguments += '-build'
}
if ($ResolvedAdditionalCookerOptions.Count -gt 0)
{
$RunUatArguments += "-AdditionalCookerOptions=$($ResolvedAdditionalCookerOptions -join ' ')"
}
$ValidationReport = [ordered]@{
reportVersion = 'ht-xr-package-validation/v1'
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
projectRoot = $ProjectRoot
archiveDirectory = $ArchiveDirectory
configuration = $Configuration
cookMaps = @($CookMaps)
smokeMaps = @($ResolvedSmokeMaps)
observationSeconds = $ObservationSeconds
timeoutSeconds = $TimeoutSeconds
runtimeLaunchMode = $RuntimeLaunchMode
validationLaunchSurfaceId = 'xr/openxr-desktop-training-packaged-validation-launch-surface'
validationGameModeClassPath = '/Script/UnrealHyperTwist.HyperTwistXrTrainingGameMode'
additionalCookerOptions = @($ResolvedAdditionalCookerOptions)
cleanArchive = [bool]$CleanArchive
skipBuild = [bool]$SkipBuild
prePackageEditorBuild = [ordered]@{
lane = 'Windows Unreal editor build'
performed = [bool]$PrePackageEditorBuildPerformed
result = $PrePackageEditorBuildResult
}
skipLaunch = [bool]$SkipLaunch
result = 'failed'
packagedExecutablePath = $null
smokeReports = @()
packagedHeadMountedDisplaySessionObserved = $false
packagedControllerInputObserved = $false
packagedProofStatusSet = @()
remainingGateIds = @()
error = $null
}
try
{
Write-Host "Packaging HyperTwist XR validation lane to '$ArchiveDirectory'..."
& $RunUatPath @RunUatArguments
if ($LASTEXITCODE -ne 0)
{
throw "RunUAT packaging failed with exit code $LASTEXITCODE."
}
$PackagedExecutablePath = Resolve-PackagedExecutablePath -PackageRoot $ArchiveDirectory
if ($null -eq $PackagedExecutablePath)
{
throw "No packaged UnrealHyperTwist executable was found beneath '$ArchiveDirectory' after packaging."
}
$ValidationReport.packagedExecutablePath = $PackagedExecutablePath
if (-not $SkipLaunch)
{
if (-not (Test-Path $LaunchScriptPath))
{
throw "Launch script was not found at '$LaunchScriptPath'."
}
foreach ($SmokeMap in $ResolvedSmokeMaps)
{
$SmokeReportPath = Join-Path $SmokeReportDirectory (
'{0}.json' -f (Convert-PathToken -Value $SmokeMap)
)
Write-Host "Smoke validating packaged XR map '$SmokeMap'..."
& $LaunchScriptPath `
-PackageRoot $ArchiveDirectory `
-MapAssetPath $SmokeMap `
-ObservationSeconds $ObservationSeconds `
-TimeoutSeconds $TimeoutSeconds `
-RuntimeLaunchMode $RuntimeLaunchMode `
-ReportPath $SmokeReportPath
if ($LASTEXITCODE -ne 0)
{
throw "Packaged XR smoke launch failed for '$SmokeMap' with exit code $LASTEXITCODE."
}
if (-not (Test-Path $SmokeReportPath))
{
throw "Packaged XR smoke report '$SmokeReportPath' was not written for '$SmokeMap'."
}
$SmokeReport = Read-JsonFile -Path $SmokeReportPath
if ($null -eq $SmokeReport)
{
throw "Packaged XR smoke report '$SmokeReportPath' could not be parsed."
}
if ($SmokeReport.result -ne 'passed')
{
throw "Packaged XR smoke report '$SmokeReportPath' recorded result '$($SmokeReport.result)' for '$SmokeMap'."
}
if ($SmokeReport.mapAssetPath -ne $SmokeMap)
{
throw "Packaged XR smoke report '$SmokeReportPath' surfaced map '$($SmokeReport.mapAssetPath)' instead of '$SmokeMap'."
}
if ($null -eq $SmokeReport.runtimeReport)
{
throw "Packaged XR smoke report '$SmokeReportPath' did not retain the nested runtime report."
}
$RuntimeReport = $SmokeReport.runtimeReport
$ValidationReport.packagedHeadMountedDisplaySessionObserved =
$ValidationReport.packagedHeadMountedDisplaySessionObserved -or [bool](Get-JsonPropertyValue `
-Object $RuntimeReport `
-Names @('bObservedHeadMountedDisplaySession', 'ObservedHeadMountedDisplaySession') `
-Default $false)
$ValidationReport.packagedControllerInputObserved =
$ValidationReport.packagedControllerInputObserved -or [bool](Get-JsonPropertyValue `
-Object $RuntimeReport `
-Names @('bObservedControllerInputActivity', 'ObservedControllerInputActivity') `
-Default $false)
$ValidationReport.smokeReports += $SmokeReport
$ValidationReport.packagedProofStatusSet += [string]$RuntimeReport.PackagedProofStatus
foreach ($RemainingGateId in @((Get-JsonPropertyValue `
-Object $RuntimeReport `
-Names @('RemainingGateIds', 'remainingGateIds') `
-Default @())))
{
if (-not [string]::IsNullOrWhiteSpace($RemainingGateId))
{
$ValidationReport.remainingGateIds += $RemainingGateId
}
}
}
}
$ValidationReport.packagedProofStatusSet =
@($ValidationReport.packagedProofStatusSet | Select-Object -Unique)
$ValidationReport.remainingGateIds =
@($ValidationReport.remainingGateIds | Select-Object -Unique)
$ValidationReport.result = 'passed'
}
catch
{
$ValidationReport.error = $_.Exception.Message
Write-Utf8JsonFile -Path $ValidationReportPath -Value $ValidationReport
throw
}
Write-Utf8JsonFile -Path $ValidationReportPath -Value $ValidationReport

View file

@ -0,0 +1,323 @@
param(
[string]$PackageRoot = 'C:\HyperTwist\packaged\xr',
[string]$MapAssetPath = '/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining',
[double]$ObservationSeconds = 8.0,
[double]$TimeoutSeconds = 30.0,
[ValidateSet('nullrhi', 'windowed')]
[string]$RuntimeLaunchMode = 'nullrhi',
[int]$ResX = 1600,
[int]$ResY = 900,
[string]$ReportPath = '',
[switch]$KeepRunning
)
$ErrorActionPreference = 'Stop'
function Write-Utf8JsonFile {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[object]$Value
)
$ParentPath = Split-Path -Parent $Path
if (-not [string]::IsNullOrWhiteSpace($ParentPath))
{
New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null
}
$Json = $Value | ConvertTo-Json -Depth 12
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
}
function Read-JsonFile {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Get-JsonPropertyValue {
param(
[Parameter(Mandatory = $true)]
[object]$Object,
[Parameter(Mandatory = $true)]
[string[]]$Names,
[object]$Default = $null
)
foreach ($Name in $Names)
{
$Property = $Object.PSObject.Properties[$Name]
if ($null -ne $Property)
{
return $Property.Value
}
}
return $Default
}
function Convert-PathToken {
param(
[string]$Value
)
if ([string]::IsNullOrWhiteSpace($Value))
{
return 'unknown'
}
return ($Value -replace '[\\/:*?"<>| ?=&]', '_')
}
$CandidateExecutablePaths = @(
(Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'),
(Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'),
(Join-Path $PackageRoot 'UnrealHyperTwist.exe')
)
$ExecutablePath = $CandidateExecutablePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
if ($null -eq $ExecutablePath)
{
throw "No packaged UnrealHyperTwist executable was found beneath '$PackageRoot'."
}
$ValidationGameModeClassPath = '/Script/UnrealHyperTwist.HyperTwistXrTrainingGameMode'
$ResolvedMapUrl = '{0}?game={1}' -f $MapAssetPath, $ValidationGameModeClassPath
$ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path
$GeneratedAtUtc = [DateTime]::UtcNow.ToString('o')
$SanitizedMapToken = Convert-PathToken -Value $MapAssetPath
$ResolvedRuntimeLaunchMode = if ([string]::IsNullOrWhiteSpace($RuntimeLaunchMode))
{
'nullrhi'
}
else
{
$RuntimeLaunchMode
}
$ReportLeafBase = [System.IO.Path]::GetFileNameWithoutExtension($ReportPath)
$RuntimeReportPath = if (-not [string]::IsNullOrWhiteSpace($ReportPath))
{
Join-Path (Split-Path -Parent $ReportPath) ('{0}.runtime.json' -f $ReportLeafBase)
}
else
{
Join-Path ([System.IO.Path]::GetTempPath()) ('hypertwist-xr-runtime-{0}.json' -f $SanitizedMapToken)
}
if (Test-Path -LiteralPath $RuntimeReportPath)
{
Remove-Item -LiteralPath $RuntimeReportPath -Force
}
$ArgumentList = @(
$ResolvedMapUrl,
'-log',
"-HyperTwistXrValidationReportPath=$RuntimeReportPath",
"-HyperTwistXrValidationObservationSeconds=$ObservationSeconds"
)
switch ($ResolvedRuntimeLaunchMode)
{
'nullrhi'
{
$ArgumentList += @(
'-nullrhi',
'-unattended',
'-nosound'
)
break
}
'windowed'
{
$ArgumentList += @(
"-ResX=$ResX",
"-ResY=$ResY",
'-windowed'
)
break
}
}
Write-Host "Launching packaged XR validation lane from '$ExecutablePath'..."
$Process = $null
$Report = [ordered]@{
reportVersion = 'ht-xr-package-smoke/v1'
generatedAtUtc = $GeneratedAtUtc
packageRoot = $ResolvedPackageRoot
executablePath = $ExecutablePath
mapAssetPath = $MapAssetPath
mapUrl = $ResolvedMapUrl
runtimeLaunchMode = $ResolvedRuntimeLaunchMode
observationSeconds = $ObservationSeconds
timeoutSeconds = $TimeoutSeconds
resolution = if ($ResolvedRuntimeLaunchMode -eq 'windowed')
{
[ordered]@{
width = $ResX
height = $ResY
}
}
else
{
$null
}
keepRunning = [bool]$KeepRunning
runtimeReportPath = $RuntimeReportPath
result = 'failed'
processId = $null
processStopped = $false
exitCode = $null
runtimeReport = $null
error = $null
}
try
{
$Process = Start-Process `
-FilePath $ExecutablePath `
-ArgumentList $ArgumentList `
-WorkingDirectory (Split-Path -Parent $ExecutablePath) `
-PassThru
$Report.processId = $Process.Id
$Deadline = [DateTime]::UtcNow.AddSeconds([Math]::Max($TimeoutSeconds, $ObservationSeconds + 5.0))
while ([DateTime]::UtcNow -lt $Deadline)
{
if (Test-Path -LiteralPath $RuntimeReportPath)
{
break
}
Start-Sleep -Milliseconds 500
$Process.Refresh()
if ($Process.HasExited)
{
$Report.exitCode = $Process.ExitCode
}
}
if (-not (Test-Path -LiteralPath $RuntimeReportPath))
{
throw "The packaged XR runtime report was not written to '$RuntimeReportPath' before timeout."
}
$RuntimeReport = Read-JsonFile -Path $RuntimeReportPath
if ($null -eq $RuntimeReport)
{
throw "The packaged XR runtime report '$RuntimeReportPath' could not be parsed."
}
$Report.runtimeReport = $RuntimeReport
$Process.Refresh()
if (-not $Process.HasExited)
{
$RemainingWaitMs = [Math]::Max(0, [int]($Deadline - [DateTime]::UtcNow).TotalMilliseconds)
[void]$Process.WaitForExit($RemainingWaitMs)
$Process.Refresh()
}
if ($Process.HasExited)
{
$Report.exitCode = $Process.ExitCode
}
elseif (-not $KeepRunning)
{
Stop-Process -Id $Process.Id -Force
$Report.processStopped = $true
}
if ($RuntimeReport.ReportVersion -ne 'ht-xr-packaged-validation/v1')
{
throw "The packaged XR runtime report version '$($RuntimeReport.ReportVersion)' was unexpected."
}
if ($RuntimeReport.Result -ne 'passed')
{
throw "The packaged XR runtime report recorded Result='$($RuntimeReport.Result)' instead of 'passed'."
}
if ($RuntimeReport.CookMapAssetPath -ne $MapAssetPath)
{
throw "The packaged XR runtime report cooked '$($RuntimeReport.CookMapAssetPath)' instead of '$MapAssetPath'."
}
if ($RuntimeReport.LaunchMapUrl -ne $ResolvedMapUrl)
{
throw "The packaged XR runtime report launched '$($RuntimeReport.LaunchMapUrl)' instead of '$ResolvedMapUrl'."
}
if ($RuntimeReport.GameModeClassPath -ne $ValidationGameModeClassPath)
{
throw "The packaged XR runtime report used game mode '$($RuntimeReport.GameModeClassPath)' instead of '$ValidationGameModeClassPath'."
}
if ($RuntimeReport.RuntimeOwnerId -ne 'xr/openxr-desktop-training-runtime-owner')
{
throw "The packaged XR runtime report surfaced runtime owner '$($RuntimeReport.RuntimeOwnerId)' instead of 'xr/openxr-desktop-training-runtime-owner'."
}
if ($RuntimeReport.ControllerSettingsOwnerId -ne 'xr-openxr-controller-settings-owner/v1')
{
throw "The packaged XR runtime report surfaced controller settings owner '$($RuntimeReport.ControllerSettingsOwnerId)' instead of 'xr-openxr-controller-settings-owner/v1'."
}
$RuntimeOwnerStructurallyValid = [bool](Get-JsonPropertyValue `
-Object $RuntimeReport `
-Names @('bRuntimeOwnerStructurallyValid', 'RuntimeOwnerStructurallyValid') `
-Default $false)
if (-not $RuntimeOwnerStructurallyValid)
{
throw "The packaged XR runtime report did not keep the runtime-owner surface structurally valid."
}
$ControllerSettingsOwnerStructurallyValid = [bool](Get-JsonPropertyValue `
-Object $RuntimeReport `
-Names @('bControllerSettingsOwnerStructurallyValid', 'ControllerSettingsOwnerStructurallyValid') `
-Default $false)
if (-not $ControllerSettingsOwnerStructurallyValid)
{
throw "The packaged XR runtime report did not keep the controller-settings surface structurally valid."
}
if ($null -ne $Report.exitCode -and $Report.exitCode -ne 0)
{
throw "The packaged XR executable exited with code $($Report.exitCode)."
}
$Report.result = 'passed'
}
catch
{
$Report.error = $_.Exception.Message
if ($null -ne $Process)
{
$Process.Refresh()
if ($Process.HasExited)
{
$Report.exitCode = $Process.ExitCode
}
elseif (-not $KeepRunning)
{
Stop-Process -Id $Process.Id -Force
$Report.processStopped = $true
}
}
if (-not [string]::IsNullOrWhiteSpace($ReportPath))
{
Write-Utf8JsonFile -Path $ReportPath -Value $Report
}
throw
}
if (-not [string]::IsNullOrWhiteSpace($ReportPath))
{
Write-Utf8JsonFile -Path $ReportPath -Value $Report
}