fix: harden packaged first-run startup recovery
This commit is contained in:
parent
d13d89805d
commit
01c7716fe7
11 changed files with 901 additions and 21 deletions
|
|
@ -1,8 +1,8 @@
|
|||
|
||||
|
||||
[/Script/EngineSettings.GameMapsSettings]
|
||||
GameDefaultMap=/Engine/Maps/Entry
|
||||
ServerDefaultMap=/Engine/Maps/Entry
|
||||
GameDefaultMap=/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining
|
||||
ServerDefaultMap=/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining
|
||||
GlobalDefaultGameMode=/Script/UnrealHyperTwist.HyperTwistFirstRunLaunchGameMode
|
||||
|
||||
[/Script/Engine.RendererSettings]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace HyperTwistFirstRunLaunchLibraryInternal
|
|||
{
|
||||
const TCHAR* SurfaceId = TEXT("first-run/native-launch-and-settings-surface");
|
||||
const TCHAR* DefaultRouteId = TEXT("coach-dashboard");
|
||||
const TCHAR* StartupFallbackRouteId = TEXT("classic-cube-training");
|
||||
const TCHAR* FirstRunGameModeClassPath =
|
||||
TEXT("/Script/UnrealHyperTwist.HyperTwistFirstRunLaunchGameMode");
|
||||
const TCHAR* CoachDashboardGameModeClassPath =
|
||||
|
|
@ -22,6 +23,8 @@ namespace HyperTwistFirstRunLaunchLibraryInternal
|
|||
TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining");
|
||||
const TCHAR* MagicCube5DMapPath =
|
||||
TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining");
|
||||
const TCHAR* RuntimeLogFileName = TEXT("UnrealHyperTwist.log");
|
||||
const TCHAR* StartupDiagnosticsLogFileName = TEXT("HyperTwistFirstRunLaunch-latest.log");
|
||||
|
||||
FString BuildLaunchUrl(const FString& MapAssetPath, const FString& GameModeClassPath)
|
||||
{
|
||||
|
|
@ -102,6 +105,26 @@ FString UHyperTwistFirstRunLaunchLibrary::GetDefaultRouteId()
|
|||
return HyperTwistFirstRunLaunchLibraryInternal::DefaultRouteId;
|
||||
}
|
||||
|
||||
FString UHyperTwistFirstRunLaunchLibrary::GetStartupFallbackRouteId()
|
||||
{
|
||||
return HyperTwistFirstRunLaunchLibraryInternal::StartupFallbackRouteId;
|
||||
}
|
||||
|
||||
FString UHyperTwistFirstRunLaunchLibrary::GetPackagedStartupMapAssetPath()
|
||||
{
|
||||
return HyperTwistFirstRunLaunchLibraryInternal::ClassicCubeMapPath;
|
||||
}
|
||||
|
||||
FString UHyperTwistFirstRunLaunchLibrary::GetRuntimeLogFileName()
|
||||
{
|
||||
return HyperTwistFirstRunLaunchLibraryInternal::RuntimeLogFileName;
|
||||
}
|
||||
|
||||
FString UHyperTwistFirstRunLaunchLibrary::GetStartupDiagnosticsLogFileName()
|
||||
{
|
||||
return HyperTwistFirstRunLaunchLibraryInternal::StartupDiagnosticsLogFileName;
|
||||
}
|
||||
|
||||
TArray<FString> UHyperTwistFirstRunLaunchLibrary::BuildFirstRunGuidanceLines()
|
||||
{
|
||||
return {
|
||||
|
|
@ -109,7 +132,9 @@ TArray<FString> UHyperTwistFirstRunLaunchLibrary::BuildFirstRunGuidanceLines()
|
|||
TEXT("Coach Dashboard opens local settings, control status, training diagnostics, browser shell status, and operator surfaces."),
|
||||
TEXT("Classic cube, follow-along, Magic120Cell, and MagicCube5D launch into dedicated first-party Unreal maps."),
|
||||
TEXT("XR launches through the OpenXR training game-mode override and remains gated until a live headset session plus controller input are observed."),
|
||||
TEXT("The web simulator is a lightweight preview and account surface; the downloadable Unreal build is the authoritative high-fidelity simulator.")
|
||||
TEXT("The web simulator is a lightweight preview and account surface; the downloadable Unreal build is the authoritative high-fidelity simulator."),
|
||||
TEXT("If first launch stalls, inspect Saved/Logs/UnrealHyperTwist.log and Saved/Logs/HyperTwistFirstRunLaunch-latest.log."),
|
||||
TEXT("If the launch menu cannot be shown, HyperTwist automatically falls back into Classic Cube Free Play instead of remaining on a blank boot map.")
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
#include "HyperTwistTraining/HyperTwistFirstRunLaunchPlayerController.h"
|
||||
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "Engine/Engine.h"
|
||||
#include "EngineUtils.h"
|
||||
#include "HyperTwistTraining/HyperTwistCoachDashboardActor.h"
|
||||
#include "HyperTwistTraining/HyperTwistFirstRunLaunchLibrary.h"
|
||||
#include "HyperTwistTraining/HyperTwistFirstRunLaunchWidget.h"
|
||||
#include "Kismet/GameplayStatics.h"
|
||||
#include "Misc/FileHelper.h"
|
||||
#include "Misc/Paths.h"
|
||||
|
||||
DEFINE_LOG_CATEGORY_STATIC(LogHyperTwistFirstRunLaunch, Log, All);
|
||||
|
||||
AHyperTwistFirstRunLaunchPlayerController::AHyperTwistFirstRunLaunchPlayerController()
|
||||
{
|
||||
|
|
@ -18,11 +23,30 @@ void AHyperTwistFirstRunLaunchPlayerController::BeginPlay()
|
|||
{
|
||||
Super::BeginPlay();
|
||||
|
||||
InitializeStartupDiagnostics();
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("BeginPlay"),
|
||||
FString::Printf(TEXT("First-run controller booted on world '%s'."), *DescribeCurrentWorldName())
|
||||
);
|
||||
|
||||
ApplyFirstRunInputMode();
|
||||
if (bShowFirstRunLaunchOnBeginPlay)
|
||||
{
|
||||
ShowFirstRunLaunchMenu();
|
||||
if (ShowFirstRunLaunchMenu() == nullptr)
|
||||
{
|
||||
TryRecoverFromFirstRunFailure(TEXT("Failed to create the first-run launch widget on begin play."));
|
||||
return;
|
||||
}
|
||||
|
||||
PersistStartupDiagnostics(TEXT("launch-menu-visible"));
|
||||
return;
|
||||
}
|
||||
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("BeginPlay"),
|
||||
TEXT("The first-run launch menu was intentionally disabled on begin play.")
|
||||
);
|
||||
PersistStartupDiagnostics(TEXT("launch-menu-disabled"));
|
||||
}
|
||||
|
||||
UHyperTwistFirstRunLaunchWidget*
|
||||
|
|
@ -31,6 +55,11 @@ AHyperTwistFirstRunLaunchPlayerController::ShowFirstRunLaunchMenu()
|
|||
if (ActiveFirstRunLaunchWidget != nullptr)
|
||||
{
|
||||
ActiveFirstRunLaunchWidget->RebuildFirstRunLaunchSurface();
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("ShowFirstRunLaunchMenu"),
|
||||
TEXT("Rebuilt the existing first-run launch widget.")
|
||||
);
|
||||
PersistStartupDiagnostics(TEXT("launch-menu-rebuilt"));
|
||||
return ActiveFirstRunLaunchWidget;
|
||||
}
|
||||
|
||||
|
|
@ -38,9 +67,19 @@ AHyperTwistFirstRunLaunchPlayerController::ShowFirstRunLaunchMenu()
|
|||
if (*ResolvedWidgetClass == nullptr)
|
||||
{
|
||||
ResolvedWidgetClass = UHyperTwistFirstRunLaunchWidget::StaticClass();
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("ShowFirstRunLaunchMenu"),
|
||||
TEXT("Fell back to the native first-run widget class.")
|
||||
);
|
||||
}
|
||||
if (*ResolvedWidgetClass == nullptr)
|
||||
{
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("ShowFirstRunLaunchMenu"),
|
||||
TEXT("No first-run widget class was available."),
|
||||
true
|
||||
);
|
||||
PersistStartupDiagnostics(TEXT("launch-widget-class-missing"));
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +89,12 @@ AHyperTwistFirstRunLaunchPlayerController::ShowFirstRunLaunchMenu()
|
|||
);
|
||||
if (ActiveFirstRunLaunchWidget == nullptr)
|
||||
{
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("ShowFirstRunLaunchMenu"),
|
||||
TEXT("CreateWidget returned null for the first-run launch surface."),
|
||||
true
|
||||
);
|
||||
PersistStartupDiagnostics(TEXT("launch-widget-create-failed"));
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +103,15 @@ AHyperTwistFirstRunLaunchPlayerController::ShowFirstRunLaunchMenu()
|
|||
&AHyperTwistFirstRunLaunchPlayerController::HandleFirstRunRouteRequested
|
||||
);
|
||||
ActiveFirstRunLaunchWidget->AddToViewport(FirstRunLaunchZOrder);
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("ShowFirstRunLaunchMenu"),
|
||||
FString::Printf(
|
||||
TEXT("Added the first-run launch widget to the viewport at z-order %d."),
|
||||
FirstRunLaunchZOrder
|
||||
)
|
||||
);
|
||||
ApplyFirstRunInputMode();
|
||||
PersistStartupDiagnostics(TEXT("launch-widget-added"));
|
||||
return ActiveFirstRunLaunchWidget;
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +121,11 @@ void AHyperTwistFirstRunLaunchPlayerController::RemoveFirstRunLaunchMenu()
|
|||
{
|
||||
ActiveFirstRunLaunchWidget->RemoveFromParent();
|
||||
ActiveFirstRunLaunchWidget = nullptr;
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("RemoveFirstRunLaunchMenu"),
|
||||
TEXT("Removed the active first-run launch widget from the viewport.")
|
||||
);
|
||||
PersistStartupDiagnostics(TEXT("launch-widget-removed"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,21 +135,52 @@ bool AHyperTwistFirstRunLaunchPlayerController::OpenFirstRunRoute(const FString&
|
|||
if (!UHyperTwistFirstRunLaunchLibrary::TryFindFirstRunLaunchRoute(RouteId, Route)
|
||||
|| !Route.IsStructurallyValid())
|
||||
{
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("OpenFirstRunRoute"),
|
||||
FString::Printf(TEXT("Rejected invalid first-run route '%s'."), *RouteId),
|
||||
true
|
||||
);
|
||||
PersistStartupDiagnostics(TEXT("invalid-route-rejected"));
|
||||
return false;
|
||||
}
|
||||
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("OpenFirstRunRoute"),
|
||||
FString::Printf(
|
||||
TEXT("Opening route '%s' (%s)."),
|
||||
*Route.RouteId,
|
||||
Route.bOpensDashboard ? TEXT("dashboard") : TEXT("dedicated-map")
|
||||
)
|
||||
);
|
||||
|
||||
if (Route.bOpensDashboard)
|
||||
{
|
||||
RemoveFirstRunLaunchMenu();
|
||||
return EnsureCoachDashboard() != nullptr;
|
||||
if (EnsureCoachDashboard() != nullptr)
|
||||
{
|
||||
PersistStartupDiagnostics(TEXT("dashboard-opened"));
|
||||
return true;
|
||||
}
|
||||
|
||||
TryRecoverFromFirstRunFailure(
|
||||
FString::Printf(TEXT("Failed to open dashboard route '%s'."), *Route.RouteId)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Route.bLaunchesDedicatedMap)
|
||||
{
|
||||
OpenMapRoute(Route.MapAssetPath, Route.GameModeClassPath);
|
||||
PersistStartupDiagnostics(TEXT("map-route-opened"));
|
||||
return true;
|
||||
}
|
||||
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("OpenFirstRunRoute"),
|
||||
FString::Printf(TEXT("Route '%s' did not match a supported launch action."), *Route.RouteId),
|
||||
true
|
||||
);
|
||||
PersistStartupDiagnostics(TEXT("route-without-launch-action"));
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +188,14 @@ void AHyperTwistFirstRunLaunchPlayerController::HandleFirstRunRouteRequested(
|
|||
const FString& RouteId
|
||||
)
|
||||
{
|
||||
OpenFirstRunRoute(RouteId);
|
||||
if (!OpenFirstRunRoute(RouteId))
|
||||
{
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("HandleFirstRunRouteRequested"),
|
||||
FString::Printf(TEXT("The requested route '%s' did not open successfully."), *RouteId),
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AHyperTwistCoachDashboardActor*
|
||||
|
|
@ -119,6 +215,19 @@ AHyperTwistFirstRunLaunchPlayerController::EnsureCoachDashboard()
|
|||
{
|
||||
ApplyDashboardDefaults(ActiveDashboardActor);
|
||||
ActiveDashboardActor->CreateAndShowDashboard();
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("EnsureCoachDashboard"),
|
||||
TEXT("Dashboard actor is ready and the dashboard surface was requested.")
|
||||
);
|
||||
PersistStartupDiagnostics(TEXT("dashboard-surface-requested"));
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("EnsureCoachDashboard"),
|
||||
TEXT("Dashboard actor could not be found or spawned."),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
ApplyFirstRunInputMode();
|
||||
|
|
@ -135,6 +244,10 @@ void AHyperTwistFirstRunLaunchPlayerController::ApplyFirstRunInputMode()
|
|||
InputMode.SetHideCursorDuringCapture(false);
|
||||
InputMode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock);
|
||||
SetInputMode(InputMode);
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("ApplyFirstRunInputMode"),
|
||||
TEXT("Applied game-and-UI input mode with unlocked cursor capture.")
|
||||
);
|
||||
}
|
||||
|
||||
void AHyperTwistFirstRunLaunchPlayerController::OpenMapRoute(
|
||||
|
|
@ -144,6 +257,11 @@ void AHyperTwistFirstRunLaunchPlayerController::OpenMapRoute(
|
|||
{
|
||||
if (MapAssetPath.IsEmpty())
|
||||
{
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("OpenMapRoute"),
|
||||
TEXT("Refused to open an empty map route."),
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -153,11 +271,19 @@ void AHyperTwistFirstRunLaunchPlayerController::OpenMapRoute(
|
|||
Options = FString::Printf(TEXT("game=%s"), *GameModeClassPath);
|
||||
}
|
||||
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("OpenMapRoute"),
|
||||
FString::Printf(
|
||||
TEXT("Opening map '%s' with options '%s'."),
|
||||
*MapAssetPath,
|
||||
Options.IsEmpty() ? TEXT("<none>") : *Options
|
||||
)
|
||||
);
|
||||
UGameplayStatics::OpenLevel(this, FName(*MapAssetPath), true, Options);
|
||||
}
|
||||
|
||||
AHyperTwistCoachDashboardActor*
|
||||
AHyperTwistFirstRunLaunchPlayerController::FindExistingDashboardActor() const
|
||||
AHyperTwistFirstRunLaunchPlayerController::FindExistingDashboardActor()
|
||||
{
|
||||
if (GetWorld() == nullptr)
|
||||
{
|
||||
|
|
@ -166,6 +292,10 @@ AHyperTwistFirstRunLaunchPlayerController::FindExistingDashboardActor() const
|
|||
|
||||
for (TActorIterator<AHyperTwistCoachDashboardActor> ActorIt(GetWorld()); ActorIt; ++ActorIt)
|
||||
{
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("FindExistingDashboardActor"),
|
||||
TEXT("Reused an existing dashboard actor from the world.")
|
||||
);
|
||||
return *ActorIt;
|
||||
}
|
||||
|
||||
|
|
@ -201,11 +331,20 @@ AHyperTwistFirstRunLaunchPlayerController::SpawnDashboardActor()
|
|||
);
|
||||
if (DashboardActor == nullptr)
|
||||
{
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("SpawnDashboardActor"),
|
||||
TEXT("SpawnActorDeferred returned null for the dashboard actor."),
|
||||
true
|
||||
);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ApplyDashboardDefaults(DashboardActor);
|
||||
DashboardActor->FinishSpawning(SpawnTransform);
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("SpawnDashboardActor"),
|
||||
TEXT("Spawned a new dashboard actor for the first-run shell.")
|
||||
);
|
||||
return DashboardActor;
|
||||
}
|
||||
|
||||
|
|
@ -223,3 +362,137 @@ void AHyperTwistFirstRunLaunchPlayerController::ApplyDashboardDefaults(
|
|||
DashboardActor->DashboardDefaultMode = DashboardDefaultMode;
|
||||
DashboardActor->DashboardDefaultCoachMaxCases = DashboardDefaultCoachMaxCases;
|
||||
}
|
||||
|
||||
void AHyperTwistFirstRunLaunchPlayerController::InitializeStartupDiagnostics()
|
||||
{
|
||||
if (!StartupDiagnosticsLines.IsEmpty() && !StartupSessionDiagnosticsLogPath.IsEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const FString LogDirectory = FPaths::ProjectLogDir();
|
||||
RuntimeLogPath = FPaths::Combine(
|
||||
LogDirectory,
|
||||
UHyperTwistFirstRunLaunchLibrary::GetRuntimeLogFileName()
|
||||
);
|
||||
StartupDiagnosticsLogPath = FPaths::Combine(
|
||||
LogDirectory,
|
||||
UHyperTwistFirstRunLaunchLibrary::GetStartupDiagnosticsLogFileName()
|
||||
);
|
||||
const FString SessionToken = FDateTime::UtcNow().ToString(TEXT("%Y%m%dT%H%M%SZ"));
|
||||
StartupSessionDiagnosticsLogPath = FPaths::Combine(
|
||||
LogDirectory,
|
||||
FString::Printf(TEXT("HyperTwistFirstRunLaunch-%s.log"), *SessionToken)
|
||||
);
|
||||
|
||||
StartupDiagnosticsLines.Reset();
|
||||
StartupDiagnosticsLines.Add(FString::Printf(TEXT("session=%s"), *SessionToken));
|
||||
StartupDiagnosticsLines.Add(FString::Printf(TEXT("world=%s"), *DescribeCurrentWorldName()));
|
||||
StartupDiagnosticsLines.Add(FString::Printf(TEXT("runtime_log=%s"), *RuntimeLogPath));
|
||||
StartupDiagnosticsLines.Add(FString::Printf(TEXT("latest_diagnostics_log=%s"), *StartupDiagnosticsLogPath));
|
||||
PersistStartupDiagnostics(TEXT("startup-initialized"));
|
||||
}
|
||||
|
||||
void AHyperTwistFirstRunLaunchPlayerController::AppendStartupDiagnosticsLine(
|
||||
const FString& Stage,
|
||||
const FString& Message,
|
||||
const bool bTreatAsError
|
||||
)
|
||||
{
|
||||
InitializeStartupDiagnostics();
|
||||
|
||||
const FString Line = FString::Printf(
|
||||
TEXT("[%s] [%s] %s"),
|
||||
*FDateTime::UtcNow().ToString(TEXT("%Y-%m-%dT%H:%M:%SZ")),
|
||||
*Stage,
|
||||
*Message
|
||||
);
|
||||
StartupDiagnosticsLines.Add(Line);
|
||||
|
||||
if (bTreatAsError)
|
||||
{
|
||||
LastStartupFailureReason = Message;
|
||||
UE_LOG(LogHyperTwistFirstRunLaunch, Error, TEXT("%s"), *Line);
|
||||
}
|
||||
else
|
||||
{
|
||||
UE_LOG(LogHyperTwistFirstRunLaunch, Display, TEXT("%s"), *Line);
|
||||
}
|
||||
|
||||
PersistStartupDiagnostics(bTreatAsError ? TEXT("startup-error") : TEXT("startup-progress"));
|
||||
}
|
||||
|
||||
void AHyperTwistFirstRunLaunchPlayerController::PersistStartupDiagnostics(
|
||||
const FString& ResultStatus
|
||||
) const
|
||||
{
|
||||
if (StartupDiagnosticsLogPath.IsEmpty() || StartupSessionDiagnosticsLogPath.IsEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TArray<FString> LinesToWrite = StartupDiagnosticsLines;
|
||||
LinesToWrite.Add(FString::Printf(TEXT("result=%s"), *ResultStatus));
|
||||
if (!LastStartupFailureReason.IsEmpty())
|
||||
{
|
||||
LinesToWrite.Add(FString::Printf(TEXT("last_failure=%s"), *LastStartupFailureReason));
|
||||
}
|
||||
|
||||
FFileHelper::SaveStringArrayToFile(LinesToWrite, *StartupDiagnosticsLogPath);
|
||||
FFileHelper::SaveStringArrayToFile(LinesToWrite, *StartupSessionDiagnosticsLogPath);
|
||||
}
|
||||
|
||||
bool AHyperTwistFirstRunLaunchPlayerController::TryRecoverFromFirstRunFailure(
|
||||
const FString& FailureReason
|
||||
)
|
||||
{
|
||||
LastStartupFailureReason = FailureReason;
|
||||
AppendStartupDiagnosticsLine(TEXT("StartupRecovery"), FailureReason, true);
|
||||
|
||||
if (bStartupRecoveryAttempted)
|
||||
{
|
||||
PersistStartupDiagnostics(TEXT("startup-recovery-already-attempted"));
|
||||
return false;
|
||||
}
|
||||
|
||||
bStartupRecoveryAttempted = true;
|
||||
const FString FallbackRouteId = UHyperTwistFirstRunLaunchLibrary::GetStartupFallbackRouteId();
|
||||
const FString RecoveryMessage = FString::Printf(
|
||||
TEXT("HyperTwist could not show the first-run launch surface. ")
|
||||
TEXT("Inspect Saved/Logs/%s and Saved/Logs/%s. ")
|
||||
TEXT("Attempting automatic fallback route '%s'."),
|
||||
*UHyperTwistFirstRunLaunchLibrary::GetRuntimeLogFileName(),
|
||||
*UHyperTwistFirstRunLaunchLibrary::GetStartupDiagnosticsLogFileName(),
|
||||
*FallbackRouteId
|
||||
);
|
||||
|
||||
if (GEngine != nullptr)
|
||||
{
|
||||
GEngine->AddOnScreenDebugMessage(
|
||||
-1,
|
||||
15.0f,
|
||||
FColor::Yellow,
|
||||
RecoveryMessage
|
||||
);
|
||||
}
|
||||
|
||||
AppendStartupDiagnosticsLine(
|
||||
TEXT("StartupRecovery"),
|
||||
FString::Printf(TEXT("Attempting fallback route '%s'."), *FallbackRouteId)
|
||||
);
|
||||
|
||||
const bool bRecovered = OpenFirstRunRoute(FallbackRouteId);
|
||||
PersistStartupDiagnostics(bRecovered ? TEXT("startup-recovered") : TEXT("startup-recovery-failed"));
|
||||
return bRecovered;
|
||||
}
|
||||
|
||||
FString AHyperTwistFirstRunLaunchPlayerController::DescribeCurrentWorldName() const
|
||||
{
|
||||
const UWorld* World = GetWorld();
|
||||
if (World == nullptr)
|
||||
{
|
||||
return TEXT("null-world");
|
||||
}
|
||||
|
||||
return World->GetMapName();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,18 @@ public:
|
|||
UFUNCTION(BlueprintPure, Category = "HyperTwist|FirstRun")
|
||||
static FString GetDefaultRouteId();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|FirstRun")
|
||||
static FString GetStartupFallbackRouteId();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|FirstRun")
|
||||
static FString GetPackagedStartupMapAssetPath();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|FirstRun")
|
||||
static FString GetRuntimeLogFileName();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|FirstRun")
|
||||
static FString GetStartupDiagnosticsLogFileName();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "HyperTwist|FirstRun")
|
||||
static TArray<FString> BuildFirstRunGuidanceLines();
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,33 @@ public:
|
|||
protected:
|
||||
void ApplyFirstRunInputMode();
|
||||
void OpenMapRoute(const FString& MapAssetPath, const FString& GameModeClassPath);
|
||||
AHyperTwistCoachDashboardActor* FindExistingDashboardActor() const;
|
||||
AHyperTwistCoachDashboardActor* FindExistingDashboardActor();
|
||||
AHyperTwistCoachDashboardActor* SpawnDashboardActor();
|
||||
void ApplyDashboardDefaults(AHyperTwistCoachDashboardActor* DashboardActor) const;
|
||||
void InitializeStartupDiagnostics();
|
||||
void AppendStartupDiagnosticsLine(
|
||||
const FString& Stage,
|
||||
const FString& Message,
|
||||
bool bTreatAsError = false
|
||||
);
|
||||
void PersistStartupDiagnostics(const FString& ResultStatus) const;
|
||||
bool TryRecoverFromFirstRunFailure(const FString& FailureReason);
|
||||
FString DescribeCurrentWorldName() const;
|
||||
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|FirstRun|Diagnostics")
|
||||
FString StartupDiagnosticsLogPath;
|
||||
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|FirstRun|Diagnostics")
|
||||
FString StartupSessionDiagnosticsLogPath;
|
||||
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|FirstRun|Diagnostics")
|
||||
FString RuntimeLogPath;
|
||||
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|FirstRun|Diagnostics")
|
||||
FString LastStartupFailureReason;
|
||||
|
||||
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|FirstRun|Diagnostics")
|
||||
bool bStartupRecoveryAttempted = false;
|
||||
|
||||
TArray<FString> StartupDiagnosticsLines;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright HyperTwist, Inc. All Rights Reserved.
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
#include "Misc/ConfigCacheIni.h"
|
||||
|
||||
#include "HyperTwistTraining/HyperTwistFirstRunLaunchGameMode.h"
|
||||
#include "HyperTwistTraining/HyperTwistFirstRunLaunchLibrary.h"
|
||||
|
|
@ -54,7 +55,12 @@ bool FHyperTwistFirstRunLaunchRouteContractTest::RunTest(const FString& Paramete
|
|||
)
|
||||
&& Route.bDefaultSafeChoice
|
||||
&& Route.bOpensDashboard
|
||||
&& !Route.bLaunchesDedicatedMap
|
||||
&& !Route.bLaunchesDedicatedMap
|
||||
);
|
||||
TestEqual(
|
||||
TEXT("The packaged startup fallback route must target classic free play."),
|
||||
UHyperTwistFirstRunLaunchLibrary::GetStartupFallbackRouteId(),
|
||||
FString(TEXT("classic-cube-training"))
|
||||
);
|
||||
|
||||
TestTrue(
|
||||
|
|
@ -124,7 +130,25 @@ bool FHyperTwistFirstRunLaunchGuidanceContractTest::RunTest(const FString& Param
|
|||
TEXT("The first-run guidance must clarify that the Unreal download is authoritative over the web simulator."),
|
||||
HyperTwistFirstRunLaunchSurfaceTestInternal::ContainsGuidanceSubstring(
|
||||
Lines,
|
||||
TEXT("downloadable Unreal build is the authoritative")
|
||||
TEXT("downloadable Unreal build is the authoritative")
|
||||
)
|
||||
);
|
||||
TestTrue(
|
||||
TEXT("The first-run guidance must tell operators where to find runtime and startup logs."),
|
||||
HyperTwistFirstRunLaunchSurfaceTestInternal::ContainsGuidanceSubstring(
|
||||
Lines,
|
||||
TEXT("Saved/Logs/UnrealHyperTwist.log")
|
||||
)
|
||||
&& HyperTwistFirstRunLaunchSurfaceTestInternal::ContainsGuidanceSubstring(
|
||||
Lines,
|
||||
TEXT("Saved/Logs/HyperTwistFirstRunLaunch-latest.log")
|
||||
)
|
||||
);
|
||||
TestTrue(
|
||||
TEXT("The first-run guidance must state the automatic gameplay fallback instead of leaving boot on a blank map."),
|
||||
HyperTwistFirstRunLaunchSurfaceTestInternal::ContainsGuidanceSubstring(
|
||||
Lines,
|
||||
TEXT("automatically falls back into Classic Cube Free Play")
|
||||
)
|
||||
);
|
||||
return true;
|
||||
|
|
@ -148,8 +172,23 @@ bool FHyperTwistFirstRunLaunchGameModeDefaultsTest::RunTest(const FString& Param
|
|||
|
||||
TestEqual(
|
||||
TEXT("The first-run game mode must boot through the first-run player controller."),
|
||||
GameMode->PlayerControllerClass.Get(),
|
||||
AHyperTwistFirstRunLaunchPlayerController::StaticClass()
|
||||
GameMode->PlayerControllerClass.Get(),
|
||||
AHyperTwistFirstRunLaunchPlayerController::StaticClass()
|
||||
);
|
||||
TestEqual(
|
||||
TEXT("The first-run contract must publish the packaged startup map."),
|
||||
UHyperTwistFirstRunLaunchLibrary::GetPackagedStartupMapAssetPath(),
|
||||
FString(TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining"))
|
||||
);
|
||||
TestEqual(
|
||||
TEXT("The first-run contract must publish the default runtime log file name."),
|
||||
UHyperTwistFirstRunLaunchLibrary::GetRuntimeLogFileName(),
|
||||
FString(TEXT("UnrealHyperTwist.log"))
|
||||
);
|
||||
TestEqual(
|
||||
TEXT("The first-run contract must publish the startup diagnostics log file name."),
|
||||
UHyperTwistFirstRunLaunchLibrary::GetStartupDiagnosticsLogFileName(),
|
||||
FString(TEXT("HyperTwistFirstRunLaunch-latest.log"))
|
||||
);
|
||||
|
||||
const AHyperTwistFirstRunLaunchPlayerController* PlayerController =
|
||||
|
|
@ -167,6 +206,39 @@ bool FHyperTwistFirstRunLaunchGameModeDefaultsTest::RunTest(const FString& Param
|
|||
const UHyperTwistFirstRunLaunchWidget* WidgetDefaults =
|
||||
GetDefault<UHyperTwistFirstRunLaunchWidget>();
|
||||
TestNotNull(TEXT("The first-run launch widget defaults must be constructible."), WidgetDefaults);
|
||||
|
||||
FString GameDefaultMap;
|
||||
FString ServerDefaultMap;
|
||||
TestTrue(
|
||||
TEXT("The loaded project config must expose a game default map for packaged boot."),
|
||||
GConfig != nullptr
|
||||
&& GConfig->GetString(
|
||||
TEXT("/Script/EngineSettings.GameMapsSettings"),
|
||||
TEXT("GameDefaultMap"),
|
||||
GameDefaultMap,
|
||||
GEngineIni
|
||||
)
|
||||
);
|
||||
TestTrue(
|
||||
TEXT("The loaded project config must expose a server default map for packaged boot."),
|
||||
GConfig != nullptr
|
||||
&& GConfig->GetString(
|
||||
TEXT("/Script/EngineSettings.GameMapsSettings"),
|
||||
TEXT("ServerDefaultMap"),
|
||||
ServerDefaultMap,
|
||||
GEngineIni
|
||||
)
|
||||
);
|
||||
TestEqual(
|
||||
TEXT("Packaged boot must no longer point at the blank Entry map."),
|
||||
GameDefaultMap,
|
||||
UHyperTwistFirstRunLaunchLibrary::GetPackagedStartupMapAssetPath()
|
||||
);
|
||||
TestEqual(
|
||||
TEXT("Server boot must match the same visible startup map rather than Entry."),
|
||||
ServerDefaultMap,
|
||||
UHyperTwistFirstRunLaunchLibrary::GetPackagedStartupMapAssetPath()
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -404,12 +404,19 @@ When a new packet materially adds or changes a normalized feature:
|
|||
launch and settings surface:
|
||||
- `AHyperTwistFirstRunLaunchGameMode` is the default project game mode
|
||||
- `AHyperTwistFirstRunLaunchPlayerController` owns the boot input mode,
|
||||
dashboard handoff, and map-opening route execution
|
||||
dashboard handoff, map-opening route execution, startup diagnostics, and
|
||||
automatic gameplay fallback if the launch widget cannot be shown
|
||||
- `UHyperTwistFirstRunLaunchWidget` renders the initial keyboard/mouse,
|
||||
route, web-versus-desktop, and XR live-proof guidance
|
||||
route, web-versus-desktop, XR live-proof, and operator log-path guidance
|
||||
- `UHyperTwistFirstRunLaunchLibrary` exposes stable contracts for Coach
|
||||
Dashboard/settings, classic cube free play, follow-along training,
|
||||
`Magic120Cell`, `MagicCube5D`, and OpenXR validation
|
||||
`Magic120Cell`, `MagicCube5D`, OpenXR validation, the packaged startup map,
|
||||
the fallback route, and the runtime/startup log file names
|
||||
- the packaged desktop lane also now has a first-party verified zip export
|
||||
helper:
|
||||
- `scripts/Export-HyperTwistPackagedBuildZip.ps1`
|
||||
- validates that the produced archive is non-empty and still contains a
|
||||
packaged `UnrealHyperTwist.exe` entry before reporting success
|
||||
- this makes first launch materially less raw while keeping the same honest
|
||||
boundary: the web simulator remains a lighter account/preview surface, the
|
||||
Unreal download remains simulator authority, and physical headset/controller
|
||||
|
|
|
|||
251
scripts/Export-HyperTwistPackagedBuildZip.ps1
Normal file
251
scripts/Export-HyperTwistPackagedBuildZip.ps1
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
param(
|
||||
[string]$PackageRoot = 'C:\HyperTwist\packaged\desktop',
|
||||
[string]$DestinationZipPath = '',
|
||||
[string]$ReportPath = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
|
||||
$ZipCreationRetryCount = 15
|
||||
$ZipCreationRetryDelayMilliseconds = 1000
|
||||
|
||||
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 10
|
||||
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
|
||||
}
|
||||
|
||||
function Test-IsTransientFileLockMessage {
|
||||
param(
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Message))
|
||||
{
|
||||
return $false
|
||||
}
|
||||
|
||||
return $Message -like '*being used by another process*' `
|
||||
-or $Message -like '*cannot access the file*'
|
||||
}
|
||||
|
||||
function Remove-ItemWithRetry {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
for ($AttemptIndex = 1; $AttemptIndex -le $ZipCreationRetryCount; $AttemptIndex++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Test-Path -LiteralPath $Path)
|
||||
{
|
||||
Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
catch
|
||||
{
|
||||
if ($AttemptIndex -ge $ZipCreationRetryCount `
|
||||
-or -not (Test-IsTransientFileLockMessage -Message $_.Exception.Message))
|
||||
{
|
||||
throw
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds $ZipCreationRetryDelayMilliseconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-PackagedExecutablePath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RootPath
|
||||
)
|
||||
|
||||
$CandidateExecutablePaths = @(
|
||||
(Join-Path $RootPath 'Windows\UnrealHyperTwist.exe'),
|
||||
(Join-Path $RootPath 'WindowsNoEditor\UnrealHyperTwist.exe'),
|
||||
(Join-Path $RootPath 'UnrealHyperTwist.exe')
|
||||
)
|
||||
|
||||
return $CandidateExecutablePaths | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
|
||||
}
|
||||
|
||||
function Invoke-ZipCreationWithRetry {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SourceDirectory,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DestinationZipPath
|
||||
)
|
||||
|
||||
$LastException = $null
|
||||
for ($AttemptIndex = 1; $AttemptIndex -le $ZipCreationRetryCount; $AttemptIndex++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Test-Path -LiteralPath $DestinationZipPath)
|
||||
{
|
||||
Remove-ItemWithRetry -Path $DestinationZipPath
|
||||
}
|
||||
|
||||
[System.IO.Compression.ZipFile]::CreateFromDirectory(
|
||||
$SourceDirectory,
|
||||
$DestinationZipPath,
|
||||
[System.IO.Compression.CompressionLevel]::Optimal,
|
||||
$false
|
||||
)
|
||||
return
|
||||
}
|
||||
catch
|
||||
{
|
||||
$LastException = $_.Exception
|
||||
if ($AttemptIndex -ge $ZipCreationRetryCount `
|
||||
-or -not (($_.Exception -is [System.IO.IOException]) `
|
||||
-or (Test-IsTransientFileLockMessage -Message $_.Exception.Message)))
|
||||
{
|
||||
break
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds $ZipCreationRetryDelayMilliseconds
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -ne $LastException)
|
||||
{
|
||||
throw $LastException
|
||||
}
|
||||
|
||||
throw "Packaged zip export failed for an unknown reason."
|
||||
}
|
||||
|
||||
function Test-ZipEntryMatchesExecutablePath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$EntryFullName
|
||||
)
|
||||
|
||||
$NormalizedEntryPath = $EntryFullName.Replace('\', '/')
|
||||
return $NormalizedEntryPath.EndsWith('UnrealHyperTwist.exe', [System.StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $PackageRoot))
|
||||
{
|
||||
throw "Package root '$PackageRoot' was not found."
|
||||
}
|
||||
|
||||
$ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path
|
||||
$PackagedExecutablePath = Resolve-PackagedExecutablePath -RootPath $ResolvedPackageRoot
|
||||
if ($null -eq $PackagedExecutablePath)
|
||||
{
|
||||
throw "No packaged UnrealHyperTwist executable was found beneath '$ResolvedPackageRoot'."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($DestinationZipPath))
|
||||
{
|
||||
$PackageLeafName = Split-Path -Leaf $ResolvedPackageRoot
|
||||
$DestinationZipPath = Join-Path (
|
||||
Split-Path -Parent $ResolvedPackageRoot
|
||||
) ("{0}-{1}.zip" -f $PackageLeafName, [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ'))
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ReportPath))
|
||||
{
|
||||
$ReportPath = Join-Path $ResolvedPackageRoot 'validation\zip-export-report.json'
|
||||
}
|
||||
|
||||
$ResolvedDestinationZipPath = [System.IO.Path]::GetFullPath($DestinationZipPath)
|
||||
$ResolvedPackageRootWithSeparator = $ResolvedPackageRoot.TrimEnd('\') + '\'
|
||||
if ($ResolvedDestinationZipPath.StartsWith($ResolvedPackageRootWithSeparator, [System.StringComparison]::OrdinalIgnoreCase))
|
||||
{
|
||||
throw "Destination zip path '$ResolvedDestinationZipPath' must live outside the package root '$ResolvedPackageRoot'."
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $ResolvedDestinationZipPath) | Out-Null
|
||||
|
||||
if (Test-Path -LiteralPath $ResolvedDestinationZipPath)
|
||||
{
|
||||
Remove-ItemWithRetry -Path $ResolvedDestinationZipPath
|
||||
}
|
||||
|
||||
$ZipReport = [ordered]@{
|
||||
reportVersion = 'ht-packaged-build-zip-export/v1'
|
||||
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
packageRoot = $ResolvedPackageRoot
|
||||
packagedExecutablePath = $PackagedExecutablePath
|
||||
destinationZipPath = $ResolvedDestinationZipPath
|
||||
result = 'failed'
|
||||
entryCount = 0
|
||||
packagedExecutableEntry = $null
|
||||
zipSizeBytes = 0
|
||||
zipSha256 = $null
|
||||
error = $null
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Invoke-ZipCreationWithRetry `
|
||||
-SourceDirectory $ResolvedPackageRoot `
|
||||
-DestinationZipPath $ResolvedDestinationZipPath
|
||||
|
||||
$ZipArchive = [System.IO.Compression.ZipFile]::OpenRead($ResolvedDestinationZipPath)
|
||||
try
|
||||
{
|
||||
$Entries = @($ZipArchive.Entries)
|
||||
$ZipReport.entryCount = $Entries.Count
|
||||
|
||||
$ExecutableEntry = $Entries | Where-Object {
|
||||
Test-ZipEntryMatchesExecutablePath -EntryFullName $_.FullName
|
||||
} | Select-Object -First 1
|
||||
if ($null -eq $ExecutableEntry)
|
||||
{
|
||||
throw "The exported zip '$ResolvedDestinationZipPath' did not contain a packaged UnrealHyperTwist executable entry."
|
||||
}
|
||||
|
||||
$ZipReport.packagedExecutableEntry = $ExecutableEntry.FullName
|
||||
if ($ZipReport.entryCount -le 0)
|
||||
{
|
||||
throw "The exported zip '$ResolvedDestinationZipPath' contained zero entries."
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if ($null -ne $ZipArchive)
|
||||
{
|
||||
$ZipArchive.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
$ZipItem = Get-Item -LiteralPath $ResolvedDestinationZipPath
|
||||
$ZipReport.zipSizeBytes = $ZipItem.Length
|
||||
$ZipReport.zipSha256 = (Get-FileHash -LiteralPath $ResolvedDestinationZipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$ZipReport.result = 'passed'
|
||||
}
|
||||
catch
|
||||
{
|
||||
$ZipReport.error = $_.Exception.Message
|
||||
Write-Utf8JsonFile -Path $ReportPath -Value $ZipReport
|
||||
throw
|
||||
}
|
||||
|
||||
Write-Utf8JsonFile -Path $ReportPath -Value $ZipReport
|
||||
Write-Host "Exported verified packaged zip to '$ResolvedDestinationZipPath'."
|
||||
|
|
@ -19,17 +19,22 @@ param(
|
|||
'-DisablePlugins=MovieRenderPipeline',
|
||||
'-SkipCookingEditorContent'
|
||||
),
|
||||
[bool]$HeadlessSmoke = $true,
|
||||
[string]$ValidationReportPath = '',
|
||||
[string]$LaunchSurfaceReportPath = '',
|
||||
[string]$ZipOutputPath = '',
|
||||
[string]$ZipExportReportPath = '',
|
||||
[switch]$CleanArchive,
|
||||
[switch]$SkipBuild,
|
||||
[switch]$SkipLaunch
|
||||
[switch]$SkipLaunch,
|
||||
[switch]$CreateZip
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$ClassicPackageScriptPath = Join-Path $PSScriptRoot 'Invoke-HyperTwistClassicCubePackage.ps1'
|
||||
$DesktopLaunchScriptPath = Join-Path $PSScriptRoot 'Launch-HyperTwistDesktopPackage.ps1'
|
||||
$ZipExportScriptPath = Join-Path $PSScriptRoot 'Export-HyperTwistPackagedBuildZip.ps1'
|
||||
$ValidationDirectory = Join-Path $ArchiveDirectory 'validation'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ClassicPackageScriptPath))
|
||||
|
|
@ -42,6 +47,11 @@ if (-not (Test-Path -LiteralPath $DesktopLaunchScriptPath))
|
|||
throw "Desktop launch helper was not found at '$DesktopLaunchScriptPath'."
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ZipExportScriptPath))
|
||||
{
|
||||
throw "Packaged-zip export helper was not found at '$ZipExportScriptPath'."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ValidationReportPath))
|
||||
{
|
||||
$ValidationReportPath = Join-Path $ValidationDirectory 'desktop-package-validation-report.json'
|
||||
|
|
@ -52,6 +62,11 @@ if ([string]::IsNullOrWhiteSpace($LaunchSurfaceReportPath))
|
|||
$LaunchSurfaceReportPath = Join-Path $ValidationDirectory 'desktop-launch-surface-report.json'
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ZipExportReportPath))
|
||||
{
|
||||
$ZipExportReportPath = Join-Path $ValidationDirectory 'desktop-zip-export-report.json'
|
||||
}
|
||||
|
||||
$InvokeParameters = @{
|
||||
ProjectRoot = $ProjectRoot
|
||||
ArchiveDirectory = $ArchiveDirectory
|
||||
|
|
@ -84,5 +99,16 @@ if (-not $SkipLaunch)
|
|||
{
|
||||
& $DesktopLaunchScriptPath `
|
||||
-PackageRoot $ArchiveDirectory `
|
||||
-ReportPath $LaunchSurfaceReportPath
|
||||
-ReportPath $LaunchSurfaceReportPath `
|
||||
-UseNullRHI:$HeadlessSmoke `
|
||||
-NoSound:$HeadlessSmoke `
|
||||
-RequireStartupDiagnostics
|
||||
}
|
||||
|
||||
if ($CreateZip)
|
||||
{
|
||||
& $ZipExportScriptPath `
|
||||
-PackageRoot $ArchiveDirectory `
|
||||
-DestinationZipPath $ZipOutputPath `
|
||||
-ReportPath $ZipExportReportPath
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ param(
|
|||
[int]$ResX = 1600,
|
||||
[int]$ResY = 900,
|
||||
[string]$ReportPath = '',
|
||||
[switch]$KeepRunning
|
||||
[switch]$KeepRunning,
|
||||
[switch]$UseNullRHI,
|
||||
[switch]$NoSound
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
|
@ -29,6 +31,40 @@ function Write-Utf8JsonFile {
|
|||
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
|
||||
}
|
||||
|
||||
function Get-FatalRuntimeLogMatches {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RuntimeLogPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $RuntimeLogPath))
|
||||
{
|
||||
return @()
|
||||
}
|
||||
|
||||
$FatalPatterns = @(
|
||||
'Fatal error:',
|
||||
'appError called:',
|
||||
'DXGI_ERROR_NOT_CURRENTLY_AVAILABLE',
|
||||
'CreateSwapChainResult failed',
|
||||
'Unhandled Exception:',
|
||||
'StaticShutdownAfterError'
|
||||
)
|
||||
|
||||
return @(Get-Content -LiteralPath $RuntimeLogPath | Where-Object {
|
||||
$Line = $_
|
||||
foreach ($Pattern in $FatalPatterns)
|
||||
{
|
||||
if ($Line -like "*$Pattern*")
|
||||
{
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
})
|
||||
}
|
||||
|
||||
$CandidateExecutablePaths = @(
|
||||
(Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'),
|
||||
(Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'),
|
||||
|
|
@ -41,14 +77,38 @@ if ($null -eq $ExecutablePath)
|
|||
throw "No packaged UnrealHyperTwist executable was found beneath '$PackageRoot'."
|
||||
}
|
||||
|
||||
$LogDirectory = Join-Path $PackageRoot 'validation\logs'
|
||||
New-Item -ItemType Directory -Force -Path $LogDirectory | Out-Null
|
||||
$LogToken = if ([string]::IsNullOrWhiteSpace($MapUrl))
|
||||
{
|
||||
'classic-cube-runtime'
|
||||
}
|
||||
else
|
||||
{
|
||||
($MapUrl -replace '[\\/:*?"<>| ]', '_')
|
||||
}
|
||||
$RuntimeLogPath = Join-Path $LogDirectory ("{0}.log" -f $LogToken)
|
||||
|
||||
$ArgumentList = @(
|
||||
$MapUrl,
|
||||
"-ResX=$ResX",
|
||||
"-ResY=$ResY",
|
||||
'-windowed',
|
||||
'-log'
|
||||
'-log',
|
||||
'-FORCELOGFLUSH',
|
||||
"-abslog=$RuntimeLogPath"
|
||||
)
|
||||
|
||||
if ($UseNullRHI)
|
||||
{
|
||||
$ArgumentList += '-NullRHI'
|
||||
}
|
||||
|
||||
if ($NoSound)
|
||||
{
|
||||
$ArgumentList += '-nosound'
|
||||
}
|
||||
|
||||
Write-Host "Launching packaged classic-cube validation lane from '$ExecutablePath'..."
|
||||
$ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path
|
||||
$GeneratedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
|
|
@ -59,12 +119,17 @@ $Report = [ordered]@{
|
|||
packageRoot = $ResolvedPackageRoot
|
||||
executablePath = $ExecutablePath
|
||||
mapUrl = $MapUrl
|
||||
runtimeLogPath = $RuntimeLogPath
|
||||
runtimeLogExists = $false
|
||||
detectedFatalLogLines = @()
|
||||
smokeSeconds = $SmokeSeconds
|
||||
resolution = [ordered]@{
|
||||
width = $ResX
|
||||
height = $ResY
|
||||
}
|
||||
keepRunning = [bool]$KeepRunning
|
||||
useNullRhi = [bool]$UseNullRHI
|
||||
noSound = [bool]$NoSound
|
||||
result = 'failed'
|
||||
processId = $null
|
||||
processStopped = $false
|
||||
|
|
@ -79,6 +144,18 @@ try
|
|||
|
||||
$Process.Refresh()
|
||||
$Report.processId = $Process.Id
|
||||
|
||||
Start-Sleep -Milliseconds 500
|
||||
$Report.runtimeLogExists = Test-Path -LiteralPath $RuntimeLogPath
|
||||
$Report.detectedFatalLogLines = @(Get-FatalRuntimeLogMatches -RuntimeLogPath $RuntimeLogPath)
|
||||
if ($Report.detectedFatalLogLines.Count -gt 0)
|
||||
{
|
||||
throw (
|
||||
"Packaged classic-cube runtime log recorded fatal startup evidence: " +
|
||||
($Report.detectedFatalLogLines -join ' | ')
|
||||
)
|
||||
}
|
||||
|
||||
if ($Process.HasExited)
|
||||
{
|
||||
$Report.exitCode = $Process.ExitCode
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ param(
|
|||
[int]$ResX = 1600,
|
||||
[int]$ResY = 900,
|
||||
[string]$ReportPath = '',
|
||||
[switch]$KeepRunning
|
||||
[switch]$KeepRunning,
|
||||
[switch]$UseNullRHI,
|
||||
[switch]$NoSound,
|
||||
[switch]$RequireStartupDiagnostics
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
|
@ -29,6 +32,64 @@ function Write-Utf8JsonFile {
|
|||
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
|
||||
}
|
||||
|
||||
function Resolve-StartupDiagnosticsLogPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ResolvedPackageRoot
|
||||
)
|
||||
|
||||
$CandidatePaths = @(
|
||||
(Join-Path $ResolvedPackageRoot 'Saved\Logs\HyperTwistFirstRunLaunch-latest.log'),
|
||||
(Join-Path $ResolvedPackageRoot 'Windows\Saved\Logs\HyperTwistFirstRunLaunch-latest.log'),
|
||||
(Join-Path $ResolvedPackageRoot 'Windows\UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log'),
|
||||
(Join-Path $env:LOCALAPPDATA 'UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log')
|
||||
) | Select-Object -Unique
|
||||
|
||||
foreach ($CandidatePath in $CandidatePaths)
|
||||
{
|
||||
if (Test-Path -LiteralPath $CandidatePath)
|
||||
{
|
||||
return $CandidatePath
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-FatalRuntimeLogMatches {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RuntimeLogPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $RuntimeLogPath))
|
||||
{
|
||||
return @()
|
||||
}
|
||||
|
||||
$FatalPatterns = @(
|
||||
'Fatal error:',
|
||||
'appError called:',
|
||||
'DXGI_ERROR_NOT_CURRENTLY_AVAILABLE',
|
||||
'CreateSwapChainResult failed',
|
||||
'Unhandled Exception:',
|
||||
'StaticShutdownAfterError'
|
||||
)
|
||||
|
||||
return @(Get-Content -LiteralPath $RuntimeLogPath | Where-Object {
|
||||
$Line = $_
|
||||
foreach ($Pattern in $FatalPatterns)
|
||||
{
|
||||
if ($Line -like "*$Pattern*")
|
||||
{
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
})
|
||||
}
|
||||
|
||||
$CandidateExecutablePaths = @(
|
||||
(Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'),
|
||||
(Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'),
|
||||
|
|
@ -41,13 +102,29 @@ if ($null -eq $ExecutablePath)
|
|||
throw "No packaged UnrealHyperTwist executable was found beneath '$PackageRoot'."
|
||||
}
|
||||
|
||||
$LogDirectory = Join-Path $PackageRoot 'validation\logs'
|
||||
New-Item -ItemType Directory -Force -Path $LogDirectory | Out-Null
|
||||
$RuntimeLogPath = Join-Path $LogDirectory 'desktop-runtime.log'
|
||||
|
||||
$ArgumentList = @(
|
||||
"-ResX=$ResX",
|
||||
"-ResY=$ResY",
|
||||
'-windowed',
|
||||
'-log'
|
||||
'-log',
|
||||
'-FORCELOGFLUSH',
|
||||
"-abslog=$RuntimeLogPath"
|
||||
)
|
||||
|
||||
if ($UseNullRHI)
|
||||
{
|
||||
$ArgumentList += '-NullRHI'
|
||||
}
|
||||
|
||||
if ($NoSound)
|
||||
{
|
||||
$ArgumentList += '-nosound'
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($MapUrl))
|
||||
{
|
||||
$ArgumentList = @($MapUrl) + $ArgumentList
|
||||
|
|
@ -63,12 +140,21 @@ $Report = [ordered]@{
|
|||
packageRoot = $ResolvedPackageRoot
|
||||
executablePath = $ExecutablePath
|
||||
mapUrl = $MapUrl
|
||||
runtimeLogPath = $RuntimeLogPath
|
||||
runtimeLogExists = $false
|
||||
detectedFatalLogLines = @()
|
||||
smokeSeconds = $SmokeSeconds
|
||||
resolution = [ordered]@{
|
||||
width = $ResX
|
||||
height = $ResY
|
||||
}
|
||||
keepRunning = [bool]$KeepRunning
|
||||
useNullRhi = [bool]$UseNullRHI
|
||||
noSound = [bool]$NoSound
|
||||
requireStartupDiagnostics = [bool]$RequireStartupDiagnostics
|
||||
startupDiagnosticsLogPath = $null
|
||||
startupDiagnosticsLogExists = $false
|
||||
startupDiagnosticsTail = @()
|
||||
result = 'failed'
|
||||
processId = $null
|
||||
processStopped = $false
|
||||
|
|
@ -83,6 +169,31 @@ try
|
|||
|
||||
$Process.Refresh()
|
||||
$Report.processId = $Process.Id
|
||||
|
||||
Start-Sleep -Milliseconds 500
|
||||
$Report.runtimeLogExists = Test-Path -LiteralPath $RuntimeLogPath
|
||||
$Report.detectedFatalLogLines = @(Get-FatalRuntimeLogMatches -RuntimeLogPath $RuntimeLogPath)
|
||||
$StartupDiagnosticsLogPath = Resolve-StartupDiagnosticsLogPath -ResolvedPackageRoot $ResolvedPackageRoot
|
||||
$Report.startupDiagnosticsLogPath = $StartupDiagnosticsLogPath
|
||||
$Report.startupDiagnosticsLogExists = $null -ne $StartupDiagnosticsLogPath
|
||||
if ($Report.startupDiagnosticsLogExists)
|
||||
{
|
||||
$Report.startupDiagnosticsTail = @(Get-Content -LiteralPath $StartupDiagnosticsLogPath -Tail 40)
|
||||
}
|
||||
|
||||
if ($Report.detectedFatalLogLines.Count -gt 0)
|
||||
{
|
||||
throw (
|
||||
"Packaged desktop runtime log recorded fatal startup evidence: " +
|
||||
($Report.detectedFatalLogLines -join ' | ')
|
||||
)
|
||||
}
|
||||
|
||||
if ($RequireStartupDiagnostics -and -not $Report.startupDiagnosticsLogExists)
|
||||
{
|
||||
throw "Packaged desktop launch did not write HyperTwistFirstRunLaunch-latest.log."
|
||||
}
|
||||
|
||||
if ($Process.HasExited)
|
||||
{
|
||||
$Report.exitCode = $Process.ExitCode
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue