fix: harden packaged first-run startup recovery

This commit is contained in:
axiomlogicnexus 2026-07-09 09:28:36 +00:00
parent d13d89805d
commit 01c7716fe7
11 changed files with 901 additions and 21 deletions

View file

@ -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]

View file

@ -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.")
};
}

View file

@ -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();
}

View file

@ -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();

View file

@ -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;
};

View file

@ -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;
}

View file

@ -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

View 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'."

View file

@ -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
}

View file

@ -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

View file

@ -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