From 8cd099c69b91e2e8caf70daf9f2fce5d3615ff48 Mon Sep 17 00:00:00 2001 From: axiomlogicnexus Date: Thu, 2 Jul 2026 03:41:18 +0000 Subject: [PATCH] Add packaged XR validation and package helper hardening --- .../HyperTwistXrPackageValidationLibrary.cpp | 256 ++++++++++ .../HyperTwistXrTrainingGameMode.cpp | 193 ++++++++ .../HyperTwistXR/HyperTwistXrTrainingPawn.cpp | 25 + .../HyperTwistXrPackageValidationLibrary.h | 124 +++++ .../HyperTwistXrTrainingGameMode.h | 44 ++ .../HyperTwistXR/HyperTwistXrTrainingPawn.h | 9 + ...erTwistXrPackageValidationContractTest.cpp | 171 +++++++ ...packaged_validation_report_2026-07-02.json | 142 ++++++ ...T_AND_PLUGIN_DECISION_PACKET_2026-06-24.md | 60 ++- ...DOWS_BUILD_LANE_VERIFICATION_2026-06-01.md | 58 +++ ...BUILD_VALIDATION_REQUIREMENT_2026-06-01.md | 28 +- ...UT_AND_XR_COMPLETENESS_AUDIT_2026-06-22.md | 33 +- .../HyperTwist/DEVELOPMENT.md | 83 +++- .../HyperTwist/FEATURE_REGISTRY.md | 2 +- .../Invoke-HyperTwistClassicCubePackage.ps1 | 106 +++- ...oke-HyperTwistHigherDimensionalPackage.ps1 | 106 +++- scripts/Invoke-HyperTwistXrPackage.ps1 | 466 ++++++++++++++++++ scripts/Launch-HyperTwistXrPackage.ps1 | 323 ++++++++++++ 18 files changed, 2215 insertions(+), 14 deletions(-) create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistXR/HyperTwistXrPackageValidationLibrary.cpp create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistXR/HyperTwistXrTrainingGameMode.cpp create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistXR/HyperTwistXrPackageValidationLibrary.h create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistXR/HyperTwistXrTrainingGameMode.h create mode 100644 UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistXrPackageValidationContractTest.cpp create mode 100644 docs/generated/xr/xr_packaged_validation_report_2026-07-02.json create mode 100644 scripts/Invoke-HyperTwistXrPackage.ps1 create mode 100644 scripts/Launch-HyperTwistXrPackage.ps1 diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistXR/HyperTwistXrPackageValidationLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistXR/HyperTwistXrPackageValidationLibrary.cpp new file mode 100644 index 0000000..aa5ef44 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistXR/HyperTwistXrPackageValidationLibrary.cpp @@ -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& 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; +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistXR/HyperTwistXrTrainingGameMode.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistXR/HyperTwistXrTrainingGameMode.cpp new file mode 100644 index 0000000..ce40eef --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistXR/HyperTwistXrTrainingGameMode.cpp @@ -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(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(PlayerController->GetPawn())) + { + return TrainingPawn; + } + } + + for (TActorIterator 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(); +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistXR/HyperTwistXrTrainingPawn.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistXR/HyperTwistXrTrainingPawn.cpp index 77101cc..7d5be1c 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistXR/HyperTwistXrTrainingPawn.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistXR/HyperTwistXrTrainingPawn.cpp @@ -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) diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistXR/HyperTwistXrPackageValidationLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistXR/HyperTwistXrPackageValidationLibrary.h new file mode 100644 index 0000000..8db1dd5 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistXR/HyperTwistXrPackageValidationLibrary.h @@ -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 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 + ); +}; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistXR/HyperTwistXrTrainingGameMode.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistXR/HyperTwistXrTrainingGameMode.h new file mode 100644 index 0000000..4d5bb2f --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistXR/HyperTwistXrTrainingGameMode.h @@ -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; +}; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistXR/HyperTwistXrTrainingPawn.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistXR/HyperTwistXrTrainingPawn.h index 91c6845..717d313 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistXR/HyperTwistXrTrainingPawn.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistXR/HyperTwistXrTrainingPawn.h @@ -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(); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistXrPackageValidationContractTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistXrPackageValidationContractTest.cpp new file mode 100644 index 0000000..9262c64 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistXrPackageValidationContractTest.cpp @@ -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(); + 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(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 diff --git a/docs/generated/xr/xr_packaged_validation_report_2026-07-02.json b/docs/generated/xr/xr_packaged_validation_report_2026-07-02.json new file mode 100644 index 0000000..2fbb7ef --- /dev/null +++ b/docs/generated/xr/xr_packaged_validation_report_2026-07-02.json @@ -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 +} \ No newline at end of file diff --git a/docs/ops/HYPERTWIST_NATIVE_XR_HOST_AND_PLUGIN_DECISION_PACKET_2026-06-24.md b/docs/ops/HYPERTWIST_NATIVE_XR_HOST_AND_PLUGIN_DECISION_PACKET_2026-06-24.md index 7913b61..d4a2552 100644 --- a/docs/ops/HYPERTWIST_NATIVE_XR_HOST_AND_PLUGIN_DECISION_PACKET_2026-06-24.md +++ b/docs/ops/HYPERTWIST_NATIVE_XR_HOST_AND_PLUGIN_DECISION_PACKET_2026-06-24.md @@ -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 diff --git a/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md b/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md index 5741857..fc5031a 100644 --- a/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md +++ b/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md @@ -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: diff --git a/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md b/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md index 4ebd004..96b3dd5 100644 --- a/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md +++ b/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md @@ -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 diff --git a/docs/ops/HYPERTWIST_UNREAL_INPUT_AND_XR_COMPLETENESS_AUDIT_2026-06-22.md b/docs/ops/HYPERTWIST_UNREAL_INPUT_AND_XR_COMPLETENESS_AUDIT_2026-06-22.md index 9f20d0d..769d66d 100644 --- a/docs/ops/HYPERTWIST_UNREAL_INPUT_AND_XR_COMPLETENESS_AUDIT_2026-06-22.md +++ b/docs/ops/HYPERTWIST_UNREAL_INPUT_AND_XR_COMPLETENESS_AUDIT_2026-06-22.md @@ -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 diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/DEVELOPMENT.md b/docs/v6_5_deep_manual_pack/HyperTwist/DEVELOPMENT.md index 2e6e9b3..ad43d02 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/DEVELOPMENT.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/DEVELOPMENT.md @@ -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 diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md index 0791491..3bae65c 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md @@ -145,7 +145,7 @@ Do not collapse those three tiers into one undifferentiated "features" voice. | Analytics/reporting surfaces | Implemented now | landed analytics/reporting packets | Reporting is real, but bounded to accepted retained slices. | | Rewritten training analytics and report reference grounding | Implemented now | `apache/echarts` retained permissive lane + first-party current code | Current live `Training Analytics` reference side includes four rewritten first-party targets grounded in retained `apache/echarts`: session outcome and progress reporting, analytics data-view and export, timing-trend history and overview interaction, and the optional richer explainer or sidecar boundary. This does not displace the landed `Phase 3R-C` first-party analytics/reporting owner or elevate `ecomfe/echarts-gl` and `ecomfe/zrender` beyond support-only sidecars. | | Browser/spatial/media adjunct surfaces | Implemented now | landed `three.js`, `react-three-fiber`, `xr`, `model-viewer`, `remotion` packets | These are implemented bounded families, and the current Phase 1 browser-runtime landing now includes a first-party authoritative `Content/Browser/index.html` shell, bundled-runtime upgrade path, committed clean-checkout plain-JS fallback runtime, embedded `UHyperTwistBrowserWidget` bridge, the `2026-06-19` runtime-ready queue hardening that retains outbound Unreal shell traffic until the browser runtime is ready, a same-day first-party `Browser Runtime Status` surface with shared boot-state ownership across bundled and fallback shells, a follow-on typed Unreal-side `browser-runtime-status` capture seam that retains the last valid runtime snapshot across unrelated later envelope traffic until explicit reset, a second follow-on typed Unreal-side `hypertwist-runtime-ready` capture seam that raises the original handshake payload out of raw JSON-only handling without changing queue flush behavior, a third same-day native/operator-facing status surface that consumes those typed seams inside Unreal while clearing retained runtime ownership on shell-authority change, a fourth same-day native training/operator diagnostics-panel continuation that wires that typed status ownership into `UHyperTwistTrainingPanelWidget` and the coach dashboard without making the dashboard inspect seam depend on stale rendered strings, a fifth same-day diagnostics-fidelity continuation that now carries bootstrap/runtime-ready timestamps, last command/shell-state receipt timestamps, and fallback reason through the same native operator/training seams, and a later `2026-06-23` control/input readiness continuation that projects shipped keyboard/input truth and unfinished XR/preferences truth through the same native training/operator surfaces. This still is not proof of unlimited browser-shell parity. | -| Bounded native OpenXR runtime-owner substrate | Implemented now | landed first-party `2026-07-01` bounded XR continuation | The desktop-hosted branch now enables `OpenXR` plus `XRBase`, ships first-party runtime-owner/profile ownership through `UHyperTwistXrRuntimeLibrary` and `AHyperTwistXrTrainingPawn`, and keeps the stable ids `xr/openxr-desktop-training-runtime-owner`, `xr-openxr-training-preferences/v1`, and `xr-openxr-motion-controllers/v1` visible through both native XR code and the operator/training inspect surfaces. The current same-family hardening pass also clamps invalid preference values back into bounded ranges, wires bounded manual vertical-adjust input into the XR training pawn instead of leaving that preference field inert, and clears stale desktop-fallback rotation when headset runtime becomes active so desktop look-pitch does not leak forward into live HMD posture. The maintained reverse-SSH Windows lane revalidated the exact-source state on `C:\HyperTwist_worktrees\phase10validate` with final editor build `Result: Succeeded` at UnrealBuildTool `3672.35 seconds`, focused XR automation report `XR-BoundedRuntimeOwner-Hardening-20260701` with `4/4` green, and full browser automation report `Browser-XrHardening-20260701` with all `26` `HyperTwist.Browser.*` tests green. The current same-family continuation source now also adds first-party controller-settings/rebinding ownership above that substrate, while broader controller widening and packaged headset/controller proof remain explicitly unfinished. A later exact-source semantics hardening follow-up on `2026-07-02` then rebuilt the same maintained root with `Result: Succeeded` at UnrealBuildTool `3104.63 seconds`, re-exported `XR-ControllerSettingsOwnership-Semantics-20260702` with all `4` `HyperTwist.FirstParty.XR.*` tests green plus `Browser-XrControllerSettingsOwnership-Semantics-20260702` with all `26` `HyperTwist.Browser.*` tests green, and kept first-party controller-settings/rebinding ownership distinct from legacy finished packaged-proof state so the bounded lane now says `owned` without overstating `shipping complete`. | +| Bounded native OpenXR runtime-owner substrate | Implemented now | landed first-party `2026-07-01` bounded XR continuation | The desktop-hosted branch now enables `OpenXR` plus `XRBase`, ships first-party runtime-owner/profile ownership through `UHyperTwistXrRuntimeLibrary` and `AHyperTwistXrTrainingPawn`, and keeps the stable ids `xr/openxr-desktop-training-runtime-owner`, `xr-openxr-training-preferences/v1`, and `xr-openxr-motion-controllers/v1` visible through both native XR code and the operator/training inspect surfaces. The current same-family hardening pass also clamps invalid preference values back into bounded ranges, wires bounded manual vertical-adjust input into the XR training pawn instead of leaving that preference field inert, and clears stale desktop-fallback rotation when headset runtime becomes active so desktop look-pitch does not leak forward into live HMD posture. The maintained reverse-SSH Windows lane revalidated the exact-source state on `C:\HyperTwist_worktrees\phase10validate` with final editor build `Result: Succeeded` at UnrealBuildTool `3672.35 seconds`, focused XR automation report `XR-BoundedRuntimeOwner-Hardening-20260701` with `4/4` green, and full browser automation report `Browser-XrHardening-20260701` with all `26` `HyperTwist.Browser.*` tests green. The current same-family continuation source now also adds first-party controller-settings/rebinding ownership above that substrate, while broader controller widening and live packaged headset/controller proof remain explicitly unfinished. A later exact-source semantics hardening follow-up on `2026-07-02` then rebuilt the same maintained root with `Result: Succeeded` at UnrealBuildTool `3104.63 seconds`, re-exported `XR-ControllerSettingsOwnership-Semantics-20260702` with all `4` `HyperTwist.FirstParty.XR.*` tests green plus `Browser-XrControllerSettingsOwnership-Semantics-20260702` with all `26` `HyperTwist.Browser.*` tests green, and kept first-party controller-settings/rebinding ownership distinct from legacy finished packaged-proof state so the bounded lane now says `owned` without overstating `shipping complete`. The next same-family packaged-validation continuation on that same date then added first-party packaged XR launch-surface/game-mode/report ownership through `UHyperTwistXrPackageValidationLibrary`, `AHyperTwistXrTrainingGameMode`, `scripts\Invoke-HyperTwistXrPackage.ps1`, and `scripts\Launch-HyperTwistXrPackage.ps1`; after repairing stale `-SkipBuild` semantics with a truthful full packaged rebuild (`Result: Succeeded`, UnrealBuildTool `2499.70 seconds`, `BuildCookRun time: 2593.82 s`), the final exact-source rerun stayed green in headless `nullrhi` mode 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`. That package seam proves first-party Windows packaged structural launch and ownership truth, while the remaining live gates stay explicit as `xr/windows-packaged-headset-session-observation` and `xr/windows-packaged-controller-input-observation`. | | Native control/input readiness inspect surface | Implemented now | landed first-party `2026-06-23` browser/native operator continuation | `UHyperTwistTrainingPanelWidget` and `UHyperTwistCoachDashboardWidget` now expose `FHyperTwistTrainingControlInputReadinessInspectSurface`, including the shipped `classic-wca-keyboard/v1` mapping, exact classic-cube pointer, orbit, zoom, and action-shortcut truth, higher-dimensional dedicated-family runtime ownership truth, project-level `EnhancedInput` plus motion-controller groundwork facts, immersive-presence contract presence, and explicit bounded XR/runtime plus controller-settings truth. The rendered detail line now also carries the fixed desktop-hosted `No-Go` decision on native OpenXR/controller widening instead of leaving that truth stranded in a non-rendered follow-up field. A later same-day structured-boundary hardening follow-up then promoted that same XR/controller boundary into stable inspect-surface fields as well, including decision id `desktop-hosted-openxr-controller-widening-no-go` plus explicit reopen requirements for dedicated runtime owners, user-facing settings or rebinding ownership, and Windows packaged controller validation, so native operator automation no longer depends only on rendered prose to verify the boundary. The recovered primary reverse-SSH `localhost:22022` lane rebuilt maintained validation worktree `C:\HyperTwist_worktrees\phase10validate` on `2026-06-23` with `Result: Succeeded`, UnrealBuildTool `Total execution time: 1563.53 seconds`, and exported `Saved\AutomationReports\Browser-ControlInputReadiness-Verify\index.json` with all `16` `HyperTwist.Browser.*` tests passing, including `CoachDashboard.ControlInputReadinessInspectSurface` and `TrainingPanel.ControlInputReadinessInspectSurface`. A later same-day quality follow-up then moved the project-input-groundwork inspection behind that surface from raw text scanning into structured Unreal config reads, rebuilt the same maintained validation worktree again with `Result: Succeeded`, UnrealBuildTool `Total execution time: 158.13 seconds`, and re-exported focused browser proof to `Saved\AutomationReports\Browser-ControlInputReadiness-StructuredConfig-Verify` with all `16` `HyperTwist.Browser.*` tests green again. The latest same-family truth-render follow-up on `2026-06-24` then rebuilt that same maintained validation root with `Result: Succeeded`, UnrealBuildTool `Total execution time: 172.52 seconds`, and exported `Saved\AutomationReports\Browser-XrNoGoRendered-Verify\index.json` with all `20` `HyperTwist.Browser.*` tests green again, explicitly covering both training-panel and coach-dashboard control-input detail-line visibility for the `No-Go` host decision. A later same-day post-build structured-boundary follow-up then rebuilt that same maintained validation root once more with `Result: Succeeded`, UnrealBuildTool `Total execution time: 2323.18 seconds`, and exported `Saved\AutomationReports\Browser-XrBoundaryStructured-PostBuild-Verify\index.json` with all `20` `HyperTwist.Browser.*` tests green again, now explicitly covering the stable decision-id and reopen-requirement fields on both control-input inspect surfaces alongside the sibling control/settings and control/profile seams. A later same-day `2026-06-25` parity follow-up then widened the same surface from general readiness truth into literal roster truth by rendering dedicated classic-cube pointer and action-shortcut lines, rebuilding the maintained validation root with `Result: Succeeded` at `5278.57 seconds` and exact-source rerun `70.76 seconds`, and exporting `Saved\AutomationReports\Browser-ControlRosterParity-Verify\index.json` with all `21` `HyperTwist.Browser.*` tests green, including both training-panel and coach-dashboard control-input surfaces plus the structured dashboard artifact seam. The latest same-family `2026-06-28` ownership-truth follow-up then tightened higher-dimensional readiness again by resolving the shipped `Magic120Cell` and `MagicCube5D` dedicated-family host, view-context, session, and interactive-scene surfaces explicitly by activation profile id instead of treating broad catalog validity as equivalent proof. That exact-source-state follow-up rebuilt maintained validation root `C:\HyperTwist_worktrees\phase10validate` with `Result: Succeeded`, UnrealBuildTool `Total execution time: 109.14 seconds`, then passed all three focused browser filters `TrainingPanel.ControlInputReadinessInspectSurface`, `CoachDashboard.ControlInputReadinessInspectSurface`, and `CoachDashboard.ControlSurfaceStructuredTextArtifacts`, so both the inspect surfaces and the rendered structured dashboard row now keep concrete family ownership ids such as `phase6c/magic120cell/runtime-host-surface` and `phase6c/magiccube5d/interactive-scene-surface` visible to operators. A later same-day bounded preferences-continuity follow-up then tightened the non-`XR` seam again without reopening the controller lane: the preferences line now resolves the first valid viewer camera-export artifact instead of trusting only the first listed id, keeps `artifact/camera-export-json` plus `immersive-training-session-recall-boundary` visible with recall-scope and preference-field counts, and preserves the honest boundary that packaged controller proof and broader XR completion are still not shipped. That exact-source continuity follow-up then rebuilt maintained validation root `C:\HyperTwist_worktrees\phase10validate` with `Result: Succeeded`, UnrealBuildTool `Total execution time: 3372.27 seconds`, and exported `Browser-PreferencesContinuity-20260628-HyperTwist.Browser.TrainingPanel.ControlInputReadinessInspectSurface`, `Browser-PreferencesContinuity-20260628-HyperTwist.Browser.CoachDashboard.ControlInputReadinessInspectSurface`, and `Browser-PreferencesContinuity-20260628-HyperTwist.Browser.CoachDashboard.ControlSurfaceStructuredTextArtifacts`, each with `index.json` `State=Success`, while the accepted unattended `Failed to create the web browser window.` dialog remained a non-blocking Windows editor lane artifact. The current same-family XR hardening follow-up then widened that same inspect truth again without widening the broader `No-Go` decision: the native/operator surface now also proves the dedicated vertical-adjust axis is present in project input config, and the XR preference line now keeps vertical-adjust plus pointer-length ownership explicit instead of burying those bounded controls inside the profile only. The current controller-settings continuation source now also carries `xr-openxr-controller-settings-owner/v1`, `xr-openxr-controller-rebinding-surface/v1`, `xr-openxr-controller-layout-default/v1`, exact field/input/layout counts, and explicit truth that packaged controller validation is the remaining reopen gate. | | Native control/settings ownership inspect surface | Implemented now | landed first-party `2026-06-24` browser/native operator continuation | `UHyperTwistTrainingPanelWidget` and `UHyperTwistCoachDashboardWidget` now expose `FHyperTwistTrainingControlSettingsOwnershipInspectSurface`, including the shipped viewer camera-settings owner `tool/camera-settings`, the immersive-presence control contract, dedicated-family `magic120cell-focus-view-profile` and `magiccube5d-projection-view-profile` view ownership, higher-dimensional selector ownership, persisted generated-mode selector recall when a structurally valid launch request is present, and bounded XR/controller settings plus rebinding ownership truth while packaged proof remains unfinished. A later same-day structured-boundary hardening follow-up then carried the same `desktop-hosted-openxr-controller-widening-no-go` decision id plus the explicit reopen requirements for dedicated runtime owners, polished user-facing settings or rebinding ownership, and Windows packaged controller validation into this surface’s structured state as well, so control-settings diagnostics no longer have to prove the boundary only by substring matching their rendered detail line. The same primary reverse-SSH `localhost:22022` lane rebuilt maintained validation worktree `C:\HyperTwist_worktrees\phase10validate` on `2026-06-24` with `Result: Succeeded`, UnrealBuildTool `Total execution time: 4331.11 seconds`, and then exported `Saved\AutomationReports\Browser-ControlSettingsOwnership-Verify\index.json` with all `18` `HyperTwist.Browser.*` tests passing, including `CoachDashboard.ControlSettingsOwnershipInspectSurface`, `TrainingPanel.ControlSettingsOwnershipInspectSurface`, and the previously landed runtime plus control/input inspect seams. A later same-day selector-recall hardening follow-up then rebuilt that same maintained validation root again with `Result: Succeeded`, UnrealBuildTool `Total execution time: 4084.77 seconds`, and exported `Saved\AutomationReports\Browser-SelectorRecall-Verify\index.json` with all `20` `HyperTwist.Browser.*` tests passing, keeping the current control/settings proof aligned with the latest repository-backed selector-recall behavior. A later same-day post-build structured-boundary follow-up then rebuilt that same maintained validation root once more with `Result: Succeeded`, UnrealBuildTool `Total execution time: 2323.18 seconds`, and exported `Saved\AutomationReports\Browser-XrBoundaryStructured-PostBuild-Verify\index.json` with all `20` `HyperTwist.Browser.*` tests green again, now explicitly covering the stable decision-id and reopen-requirement fields on both control/settings inspect surfaces alongside the sibling control/input and control/profile seams. The latest same-family `2026-06-28` ownership-proof follow-up then tightened this settings seam again by resolving the active higher-dimensional session surface, active interactive-scene surface, dedicated-family session surface, dedicated-family interactive-scene surface, persistence-boundary id, and state-semantics id explicitly for both `Magic120Cell` and `MagicCube5D`, while also carrying concrete projection, symmetry, stereo, visibility, and focus tag counts plus persistence-readiness booleans in structured state instead of leaving that ownership implied by broad view-profile validity. That exact-source proof-hardening follow-up synced the tightened browser test source into the already-current maintained validation root, rebuilt it with `Result: Succeeded` and UnrealBuildTool `Total execution time: 168.35 seconds`, then passed focused browser proof for `HyperTwist.Browser.TrainingPanel.ControlSettingsOwnershipInspectSurface`, `HyperTwist.Browser.CoachDashboard.ControlSettingsOwnershipInspectSurface`, and `HyperTwist.Browser.CoachDashboard.ControlSurfaceStructuredTextArtifacts`, so both the inspect surfaces and the rendered structured dashboard row now keep exact family-owned ids such as `phase6c/magic120cell/dedicated-training-session-surface`, `phase6c/magic120cell/interactive-scene-surface`, `magic120cell-persistence-boundary`, `phase6c/magiccube5d/dedicated-training-session-surface`, and `phase6c/magiccube5d/family-owned-scene-state` visible to operators. A later same-day bounded preferences-continuity follow-up then tightened the same settings seam again without reopening native controller widening: the structured state now carries camera workflow, preview-state, export-artifact, immersive presence-surface, recall-scope, preference-field, and reset-surface counts together with explicit camera-continuity and immersive-session-recall readiness booleans; the rendered status text now distinguishes `missing`, `partial`, and `ready` ownership truth instead of collapsing those states; and the shipped `artifact/camera-export-json` plus `immersive-training-session-recall-boundary` ids now stay visible in both the training-panel and coach-dashboard settings detail lines. That exact-source continuity follow-up then rebuilt maintained validation root `C:\HyperTwist_worktrees\phase10validate` with `Result: Succeeded`, UnrealBuildTool `Total execution time: 3372.27 seconds`, and exported `Browser-PreferencesContinuity-20260628-HyperTwist.Browser.TrainingPanel.ControlSettingsOwnershipInspectSurface`, `Browser-PreferencesContinuity-20260628-HyperTwist.Browser.CoachDashboard.ControlSettingsOwnershipInspectSurface`, and `Browser-PreferencesContinuity-20260628-HyperTwist.Browser.CoachDashboard.ControlSurfaceStructuredTextArtifacts`, each with `index.json` `State=Success`, while the same accepted unattended `Failed to create the web browser window.` dialog remained non-blocking on the Windows editor lane. A later same-family `2026-06-30` degraded-state hardening follow-up then pulled that same readiness doctrine fully into the shared formatter layer itself, so absent higher-dimensional family ownership now renders as `missing` instead of being overstated as generic `partial`, one-sided view-versus-persistence lanes still render as `partial`, and both the dedicated family lines and the selector-ownership line now mask stale ids/counts while preserving the side of the lane that is actually ready. The current controller-settings continuation source now also carries `xr-openxr-controller-settings-owner/v1`, `xr-openxr-controller-rebinding-surface/v1`, `xr-openxr-controller-layout-default/v1`, exact field/input/layout counts, and explicit truth that packaged controller validation is the remaining reopen gate. | | Native control/profile roster inspect surface | Implemented now | landed first-party `2026-06-24` browser/native operator continuation | `UHyperTwistTrainingPanelWidget` and `UHyperTwistCoachDashboardWidget` now expose `FHyperTwistTrainingControlProfileRosterInspectSurface`, keeping the actually shipped selectable native roster visible: classic keyboard profile `classic-wca-keyboard/v1` with `18` bindings plus the exact classic move roster, immersive-presence contract `immersive-training-presence-control-contract` with `3` intensity plus `3` reduced-distraction presets, dedicated-family `Magic120Cell` runtime/view ids `magic120cell-runtime-profile` and `magic120cell-focus-view-profile`, dedicated-family `MagicCube5D` runtime/view ids `magiccube5d-runtime-profile` and `magiccube5d-projection-view-profile`, the current `4` selectors in each dedicated-family roster, persisted generated-mode selector recall when a structurally valid launch request is present, and bounded XR controller settings/rebinding ownership truth while packaged proof remains unfinished. The rendered roster boundary now also states the fixed desktop-hosted `No-Go` decision on native OpenXR/controller widening instead of sounding like a merely pending generic settings packet. A later same-day structured-boundary hardening follow-up then carried the same `desktop-hosted-openxr-controller-widening-no-go` decision id plus its explicit reopen requirements into the roster surface’s structured state too, so native operator automation can prove the shipped roster boundary without depending only on rendered text. After hash-syncing the touched type/training/dashboard/test files into maintained validation worktree `C:\HyperTwist_worktrees\phase10validate`, the same primary reverse-SSH `localhost:22022` lane rebuilt the widened slice on `2026-06-24` with `Result: Succeeded`, UnrealBuildTool `Total execution time: 3606.23 seconds`, then exported `Saved\AutomationReports\Browser-ControlProfileRoster-Verify\index.json` with all `20` `HyperTwist.Browser.*` tests passing, including `CoachDashboard.ControlProfileRosterInspectSurface` and `TrainingPanel.ControlProfileRosterInspectSurface`. A later same-day selector-recall hardening follow-up then rebuilt that same maintained validation root again with `Result: Succeeded`, UnrealBuildTool `Total execution time: 4084.77 seconds`, and exported `Saved\AutomationReports\Browser-SelectorRecall-Verify\index.json` with all `20` `HyperTwist.Browser.*` tests passing again, now explicitly covering imported-request fallback plus active-deck precedence for repository-backed selector recall inside the training-panel and coach-dashboard roster surface. The latest same-family truth-render follow-up on `2026-06-24` then rebuilt that same maintained validation root with `Result: Succeeded`, UnrealBuildTool `Total execution time: 172.52 seconds`, and exported `Saved\AutomationReports\Browser-XrNoGoRendered-Verify\index.json` with all `20` `HyperTwist.Browser.*` tests green again, explicitly covering the roster detail-line visibility of the same fixed `No-Go` boundary. A later same-day post-build structured-boundary follow-up then rebuilt that same maintained validation root once more with `Result: Succeeded`, UnrealBuildTool `Total execution time: 2323.18 seconds`, and exported `Saved\AutomationReports\Browser-XrBoundaryStructured-PostBuild-Verify\index.json` with all `20` `HyperTwist.Browser.*` tests green again, now explicitly covering the stable decision-id and reopen-requirement fields on both control/profile inspect surfaces alongside the sibling control/input and control/settings seams. A later same-day `2026-06-25` parity follow-up then added the literal shipped classic move-roster line to that surface, mirrored it into dedicated coach-dashboard structured rows, rebuilt the maintained validation root with `Result: Succeeded` at `5278.57 seconds` and exact-source rerun `70.76 seconds`, and exported `Saved\AutomationReports\Browser-ControlRosterParity-Verify\index.json` with all `21` `HyperTwist.Browser.*` tests green, including both training-panel and coach-dashboard control/profile surfaces plus the structured dashboard artifact seam. The later same-family `2026-06-28` continuity follow-up then widened that roster seam again by carrying the active higher-dimensional scene id alongside the active activation, view-context, and session ids, while also resolving the first valid viewer camera-export artifact and keeping `artifact/camera-export-json` plus `immersive-training-session-recall-boundary` visible with recall-scope and preference-field counts through both the native detail line and a dedicated coach-dashboard structured row. The authoritative short-root recovery lane on `C:\HTpp` then re-synced the exact touched Unreal files, rebuilt the exact-source state with `Result: Succeeded` and UnrealBuildTool `Total execution time: 2374.44 seconds`, and exported both `Browser-ControlProfileContinuityParity-20260628-HyperTwist.Browser.TrainingPanel.ControlProfileContinuityParity` and `Browser-ControlProfileContinuityParity-20260628-HyperTwist.Browser.CoachDashboard.ControlProfileContinuityArtifacts` with `Result={Success}`, while the repeated `Failed to create the web browser window.` dialog remained a non-blocking unattended editor artifact. A later exact-source short-root recovery proof on `2026-06-29` then rebuilt the same touched state again with `Result: Succeeded` and UnrealBuildTool `Total execution time: 78.51 seconds`, re-exported `Saved\AutomationReports\ControlProfileContinuity-Rerun` with `HyperTwist.Browser.CoachDashboard.ControlProfileContinuityArtifacts`, `HyperTwist.Browser.TrainingPanel.ControlProfileContinuityParity`, and `HyperTwist.Browser.ControlProfileContinuityStateFormatting` all `Result={Success}`, and also re-exported `Saved\AutomationReports\ContinuityStateFormatting-Rerun` with the wider shared continuity-formatting trio green, so the roster-parity and shared continuity-helper seams are both grounded in current exact-source Windows proof rather than only the earlier broader maintained-root evidence. A final maintained-root exact-source hardening follow-up later that same day then tightened the shared continuity formatter so absent readiness masks stale ids and counts instead of leaking carried-over values, rebuilt `C:\HyperTwist_worktrees\phase10validate` with `Result: Succeeded` and UnrealBuildTool `Total execution time: 3325.07 seconds`, and exported `Continuity-CountMasking-20260629-*` with `HyperTwist.Browser.ControlProfileContinuityStateFormatting`, `HyperTwist.Browser.TrainingPanel.ControlProfileContinuityParity`, and `HyperTwist.Browser.CoachDashboard.ControlProfileContinuityArtifacts` all `Result={Success}`, keeping the shipped roster-continuity truth grounded in current maintained-root degraded-state proof as well. The current controller-settings continuation source now also carries `xr-openxr-controller-settings-owner/v1`, `xr-openxr-controller-rebinding-surface/v1`, `xr-openxr-controller-layout-default/v1`, exact field/input/layout counts, and explicit truth that packaged controller validation is the remaining reopen gate. | diff --git a/scripts/Invoke-HyperTwistClassicCubePackage.ps1 b/scripts/Invoke-HyperTwistClassicCubePackage.ps1 index 5b62fa1..628cb4b 100644 --- a/scripts/Invoke-HyperTwistClassicCubePackage.ps1 +++ b/scripts/Invoke-HyperTwistClassicCubePackage.ps1 @@ -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)) diff --git a/scripts/Invoke-HyperTwistHigherDimensionalPackage.ps1 b/scripts/Invoke-HyperTwistHigherDimensionalPackage.ps1 index e89dddd..0962a1a 100644 --- a/scripts/Invoke-HyperTwistHigherDimensionalPackage.ps1 +++ b/scripts/Invoke-HyperTwistHigherDimensionalPackage.ps1 @@ -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') diff --git a/scripts/Invoke-HyperTwistXrPackage.ps1 b/scripts/Invoke-HyperTwistXrPackage.ps1 new file mode 100644 index 0000000..d7eda8f --- /dev/null +++ b/scripts/Invoke-HyperTwistXrPackage.ps1 @@ -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 diff --git a/scripts/Launch-HyperTwistXrPackage.ps1 b/scripts/Launch-HyperTwistXrPackage.ps1 new file mode 100644 index 0000000..81c6ce3 --- /dev/null +++ b/scripts/Launch-HyperTwistXrPackage.ps1 @@ -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 +}