diff --git a/UnrealHyperTwist/Config/DefaultEngine.ini b/UnrealHyperTwist/Config/DefaultEngine.ini index 0f8c4fc..04f4877 100644 --- a/UnrealHyperTwist/Config/DefaultEngine.ini +++ b/UnrealHyperTwist/Config/DefaultEngine.ini @@ -34,8 +34,8 @@ r.DefaultFeature.LocalExposure.HighlightContrastScale=0.8 r.DefaultFeature.LocalExposure.ShadowContrastScale=0.8 [/Script/WindowsTargetPlatform.WindowsTargetSettings] -DefaultGraphicsRHI=DefaultGraphicsRHI_DX12 -DefaultGraphicsRHI=DefaultGraphicsRHI_DX12 +; Prefer the broadly compatible packaged Alpha path; D3D12 remains available via -d3d12. +DefaultGraphicsRHI=DefaultGraphicsRHI_DX11 -D3D12TargetedShaderFormats=PCD3D_SM5 +D3D12TargetedShaderFormats=PCD3D_SM6 -D3D11TargetedShaderFormats=PCD3D_SM5 diff --git a/UnrealHyperTwist/Config/DefaultGame.ini b/UnrealHyperTwist/Config/DefaultGame.ini index 099bd95..9aaf785 100644 --- a/UnrealHyperTwist/Config/DefaultGame.ini +++ b/UnrealHyperTwist/Config/DefaultGame.ini @@ -7,7 +7,7 @@ ProjectID=11C9116142A58F76A4E7F987AE989538 [/Script/UnrealHyperTwist.HyperTwistHttpVisionClient] ProviderLabel=local-http-sidecar -ServiceBaseUrl=http://127.0.0.1:8766 +ServiceBaseUrl="http://127.0.0.1:8766" OpenSessionPath=/vision/session/open SubmitFramePath=/vision/frame CommitObservationPath=/vision/commit @@ -18,7 +18,7 @@ RequestTimeoutSeconds=5.0 [/Script/UnrealHyperTwist.HyperTwistHttpSpeechClient] ProviderLabel=local-http-sidecar -ServiceBaseUrl=http://127.0.0.1:8766 +ServiceBaseUrl="http://127.0.0.1:8766" OpenSessionPath=/speech/session/open TranscribeUtterancePath=/speech/utterance CloseSessionPath=/speech/session/close @@ -27,7 +27,7 @@ RequestTimeoutSeconds=5.0 [/Script/UnrealHyperTwist.HyperTwistHttpVoiceClient] ProviderLabel=local-http-sidecar -ServiceBaseUrl=http://127.0.0.1:8766 +ServiceBaseUrl="http://127.0.0.1:8766" ProfilesPath=/voice/profiles SynthesizePath=/voice/narrate HealthPath=/voice/health diff --git a/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining.umap b/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining.umap index 028fa9f..a3f89e5 100644 Binary files a/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining.umap and b/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining.umap differ diff --git a/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining.umap b/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining.umap index 133dda3..e23e0c3 100644 Binary files a/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining.umap and b/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining.umap differ diff --git a/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining.umap b/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining.umap index 9564d08..be4aedd 100644 Binary files a/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining.umap and b/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining.umap differ diff --git a/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining.umap b/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining.umap index d7e3b4f..57d2724 100644 Binary files a/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining.umap and b/UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining.umap differ diff --git a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Back.uasset b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Back.uasset index 6ca4f0b..613955a 100644 Binary files a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Back.uasset and b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Back.uasset differ diff --git a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Down.uasset b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Down.uasset index ba11c61..0da5a29 100644 Binary files a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Down.uasset and b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Down.uasset differ diff --git a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Front.uasset b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Front.uasset index 0193cad..a5dc732 100644 Binary files a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Front.uasset and b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Front.uasset differ diff --git a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Internal.uasset b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Internal.uasset index 0ec367b..384e64d 100644 Binary files a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Internal.uasset and b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Internal.uasset differ diff --git a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Left.uasset b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Left.uasset index 81e7288..3b8c881 100644 Binary files a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Left.uasset and b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Left.uasset differ diff --git a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Right.uasset b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Right.uasset index 678e7ca..ead2bf4 100644 Binary files a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Right.uasset and b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Right.uasset differ diff --git a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Up.uasset b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Up.uasset index cc73c0a..0f83f9a 100644 Binary files a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Up.uasset and b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/MI_HT_ClassicCube_Up.uasset differ diff --git a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/M_HT_ClassicCubeFaceMaster.uasset b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/M_HT_ClassicCubeFaceMaster.uasset index 9b858b9..3cf9646 100644 Binary files a/UnrealHyperTwist/Content/HyperTwistTraining/Materials/M_HT_ClassicCubeFaceMaster.uasset and b/UnrealHyperTwist/Content/HyperTwistTraining/Materials/M_HT_ClassicCubeFaceMaster.uasset differ diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistDiagnostics/HyperTwistRuntimeDiagnostics.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistDiagnostics/HyperTwistRuntimeDiagnostics.cpp new file mode 100644 index 0000000..3aa44cc --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistDiagnostics/HyperTwistRuntimeDiagnostics.cpp @@ -0,0 +1,351 @@ +#include "HyperTwistDiagnostics/HyperTwistRuntimeDiagnostics.h" + +#include "Containers/Ticker.h" +#include "Engine/Engine.h" +#include "Engine/GameViewportClient.h" +#include "HAL/CriticalSection.h" +#include "HAL/FileManager.h" +#include "HAL/PlatformProcess.h" +#include "HAL/PlatformTime.h" +#include "Misc/CommandLine.h" +#include "Misc/DateTime.h" +#include "Misc/FileHelper.h" +#include "Misc/Parse.h" +#include "Misc/Paths.h" +#include "Misc/ScopeLock.h" +#include "UnrealClient.h" + +DEFINE_LOG_CATEGORY_STATIC(LogHyperTwistRuntimeDiagnostics, Log, All); + +namespace +{ + constexpr int32 ReadyFrameWarmupPassCount = 3; + constexpr double ReadyFrameInterCaptureDelaySeconds = 0.75; + constexpr double ReadyFrameReceiptTimeoutSeconds = 10.0; + const TCHAR* DefaultDiagnosticsLogFileName = TEXT("HyperTwistRuntime-latest.log"); + + FCriticalSection DiagnosticsLock; + FString DiagnosticsLogPath; + bool bDiagnosticsInitialized = false; + + FString MakeSingleLine(FString Value) + { + Value.ReplaceInline(TEXT("\r"), TEXT(" ")); + Value.ReplaceInline(TEXT("\n"), TEXT(" ")); + return Value.TrimStartAndEnd(); + } + + bool InitializeSessionLocked() + { + if (bDiagnosticsInitialized) + { + return !DiagnosticsLogPath.IsEmpty(); + } + + bDiagnosticsInitialized = true; + FString RequestedPath; + FParse::Value( + FCommandLine::Get(), + TEXT("HyperTwistDiagnosticsLog="), + RequestedPath); + RequestedPath.TrimStartAndEndInline(); + if (RequestedPath.Len() >= 2 + && RequestedPath.StartsWith(TEXT("\"")) + && RequestedPath.EndsWith(TEXT("\""))) + { + RequestedPath = RequestedPath.Mid(1, RequestedPath.Len() - 2); + } + + DiagnosticsLogPath = RequestedPath.IsEmpty() + ? FPaths::Combine(FPaths::ProjectLogDir(), DefaultDiagnosticsLogFileName) + : FPaths::ConvertRelativePathToFull(RequestedPath); + FPaths::NormalizeFilename(DiagnosticsLogPath); + + const FString DiagnosticsDirectory = FPaths::GetPath(DiagnosticsLogPath); + if (DiagnosticsDirectory.IsEmpty() + || !IFileManager::Get().MakeDirectory(*DiagnosticsDirectory, true)) + { + DiagnosticsLogPath.Reset(); + return false; + } + + TArray HeaderLines; + HeaderLines.Reserve(5); + HeaderLines.Add(TEXT("format=hypertwist-runtime-diagnostics/v1")); + HeaderLines.Add(FString::Printf( + TEXT("session=%s"), + *FDateTime::UtcNow().ToString(TEXT("%Y%m%dT%H%M%SZ")))); + HeaderLines.Add(FString::Printf( + TEXT("process_id=%u"), + FPlatformProcess::GetCurrentProcessId())); + HeaderLines.Add(FString::Printf( + TEXT("command_line=%s"), + *MakeSingleLine(FCommandLine::Get()))); + HeaderLines.Add(FString::Printf( + TEXT("diagnostics_path=%s"), + *DiagnosticsLogPath)); + + if (!FFileHelper::SaveStringArrayToFile( + HeaderLines, + *DiagnosticsLogPath, + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + DiagnosticsLogPath.Reset(); + return false; + } + + return true; + } +} + +namespace HyperTwistRuntimeDiagnostics +{ + bool InitializeSession() + { + const FScopeLock Lock(&DiagnosticsLock); + return InitializeSessionLocked(); + } + + FString GetDiagnosticsLogPath() + { + const FScopeLock Lock(&DiagnosticsLock); + InitializeSessionLocked(); + return DiagnosticsLogPath; + } + + bool AppendEvent(const FString& Stage, const FString& Message) + { + const FScopeLock Lock(&DiagnosticsLock); + if (!InitializeSessionLocked()) + { + return false; + } + + const FString Line = FString::Printf( + TEXT("[%s] [%s] %s%s"), + *FDateTime::UtcNow().ToString(TEXT("%Y-%m-%dT%H:%M:%SZ")), + *MakeSingleLine(Stage.IsEmpty() ? TEXT("Runtime") : Stage), + *MakeSingleLine(Message), + LINE_TERMINATOR); + return FFileHelper::SaveStringToFile( + Line, + *DiagnosticsLogPath, + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM, + &IFileManager::Get(), + FILEWRITE_Append); + } + + bool IsReadyFrameCaptureRequested() + { + return FParse::Param(FCommandLine::Get(), TEXT("HyperTwistCaptureWhenReady")); + } + + bool RequestReadyFrameCapture(const FString& SurfaceId) + { + if (!IsReadyFrameCaptureRequested()) + { + return false; + } + + FString SafeSurfaceId = SurfaceId.IsEmpty() ? TEXT("runtime-ready") : SurfaceId; + for (TCHAR& Character : SafeSurfaceId) + { + if (!FChar::IsAlnum(Character) && Character != TEXT('-') && Character != TEXT('_')) + { + Character = TEXT('-'); + } + } + + const FString CaptureDirectory = FPaths::Combine( + FPaths::ProjectSavedDir(), + TEXT("Screenshots"), + TEXT("HyperTwistDiagnostics")); + if (!IFileManager::Get().MakeDirectory(*CaptureDirectory, true)) + { + UE_LOG( + LogHyperTwistRuntimeDiagnostics, + Error, + TEXT("Could not create semantic-ready capture directory '%s'."), + *CaptureDirectory); + return false; + } + + const FString CapturePath = FPaths::Combine( + CaptureDirectory, + FString::Printf(TEXT("%s.png"), *SafeSurfaceId)); + const FString WarmupCapturePath = FPaths::Combine( + CaptureDirectory, + FString::Printf(TEXT("%s-warmup.png"), *SafeSurfaceId)); + if (IFileManager::Get().FileExists(*CapturePath) + && !IFileManager::Get().Delete(*CapturePath, false, true)) + { + UE_LOG( + LogHyperTwistRuntimeDiagnostics, + Error, + TEXT("Could not replace stale semantic-ready frame '%s'."), + *CapturePath); + return false; + } + if (IFileManager::Get().FileExists(*WarmupCapturePath) + && !IFileManager::Get().Delete(*WarmupCapturePath, false, true)) + { + UE_LOG( + LogHyperTwistRuntimeDiagnostics, + Error, + TEXT("Could not replace stale semantic-ready warm-up frame '%s'."), + *WarmupCapturePath); + return false; + } + + // Repeated UI-inclusive captures prime and settle Slate's font atlas in + // packaged off-screen rendering. A single warm-up can still leave random + // glyphs absent, so the authoritative frame follows three acknowledged + // passes separated by real presentation time. File polling remains + // necessary because this viewport does not reliably deliver the + // screenshot-processed delegate. + UE_LOG( + LogHyperTwistRuntimeDiagnostics, + Display, + TEXT("Queued UI-inclusive semantic-ready frame for %s after presentation stabilization."), + *SurfaceId); + FTSTicker::GetCoreTicker().AddTicker( + FTickerDelegate::CreateLambda( + [ + CapturePath, + WarmupCapturePath, + SurfaceId, + WarmupPassesCompleted = 0, + bWarmupRequestInFlight = false, + bFinalRequested = false, + NextCaptureRequestAt = 0.0, + ReceiptDeadline = 0.0 + ](const float DeltaTime) mutable + { + static_cast(DeltaTime); + const double Now = FPlatformTime::Seconds(); + if (WarmupPassesCompleted < ReadyFrameWarmupPassCount) + { + if (!bWarmupRequestInFlight) + { + if (Now < NextCaptureRequestAt) + { + return true; + } + if (IFileManager::Get().FileExists(*WarmupCapturePath) + && !IFileManager::Get().Delete(*WarmupCapturePath, false, true)) + { + UE_LOG( + LogHyperTwistRuntimeDiagnostics, + Error, + TEXT("Could not reset semantic-ready warm-up frame '%s'."), + *WarmupCapturePath); + return false; + } + + FScreenshotRequest::RequestScreenshot( + WarmupCapturePath, + true, + false, + false); + bWarmupRequestInFlight = true; + ReceiptDeadline = Now + ReadyFrameReceiptTimeoutSeconds; + UE_LOG( + LogHyperTwistRuntimeDiagnostics, + Display, + TEXT("Requested UI-inclusive semantic-ready warm-up frame %d/%d for %s at '%s'."), + WarmupPassesCompleted + 1, + ReadyFrameWarmupPassCount, + *SurfaceId, + *WarmupCapturePath); + return true; + } + + if (IFileManager::Get().FileSize(*WarmupCapturePath) > 0) + { + if (!IFileManager::Get().Delete(*WarmupCapturePath, false, true)) + { + UE_LOG( + LogHyperTwistRuntimeDiagnostics, + Error, + TEXT("Could not retire semantic-ready warm-up frame '%s'."), + *WarmupCapturePath); + return false; + } + + ++WarmupPassesCompleted; + bWarmupRequestInFlight = false; + NextCaptureRequestAt = Now + ReadyFrameInterCaptureDelaySeconds; + UE_LOG( + LogHyperTwistRuntimeDiagnostics, + Display, + TEXT("Completed UI-inclusive semantic-ready warm-up frame %d/%d for %s."), + WarmupPassesCompleted, + ReadyFrameWarmupPassCount, + *SurfaceId); + return true; + } + + if (Now >= ReceiptDeadline) + { + UE_LOG( + LogHyperTwistRuntimeDiagnostics, + Error, + TEXT("Could not save UI-inclusive semantic-ready warm-up frame %d/%d for %s at '%s'."), + WarmupPassesCompleted + 1, + ReadyFrameWarmupPassCount, + *SurfaceId, + *WarmupCapturePath); + return false; + } + return true; + } + + if (!bFinalRequested) + { + if (Now < NextCaptureRequestAt) + { + return true; + } + + FScreenshotRequest::RequestScreenshot( + CapturePath, + true, + false, + false); + bFinalRequested = true; + ReceiptDeadline = Now + ReadyFrameReceiptTimeoutSeconds; + UE_LOG( + LogHyperTwistRuntimeDiagnostics, + Display, + TEXT("Requested UI-inclusive semantic-ready frame for %s at '%s' after font-atlas warm-up."), + *SurfaceId, + *CapturePath); + return true; + } + + if (IFileManager::Get().FileSize(*CapturePath) > 0) + { + UE_LOG( + LogHyperTwistRuntimeDiagnostics, + Display, + TEXT("Saved UI-inclusive semantic-ready frame for %s at '%s'."), + *SurfaceId, + *CapturePath); + return false; + } + if (Now >= ReceiptDeadline) + { + UE_LOG( + LogHyperTwistRuntimeDiagnostics, + Error, + TEXT("Could not save UI-inclusive semantic-ready frame for %s at '%s'."), + *SurfaceId, + *CapturePath); + return false; + } + return true; + }), + 0.5f); + return true; + } +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistRecognition/HyperTwistHttpSpeechClient.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistRecognition/HyperTwistHttpSpeechClient.cpp index 6b79cd6..8ab5188 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistRecognition/HyperTwistHttpSpeechClient.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistRecognition/HyperTwistHttpSpeechClient.cpp @@ -37,12 +37,23 @@ namespace HyperTwistHttpSpeechClientInternal FString BuildUrl(const FString& BaseUrl, const FString& Path) { - if (BaseUrl.IsEmpty()) + FString TrimmedBase = BaseUrl.TrimStartAndEnd(); + const int32 AuthorityStart = TrimmedBase.StartsWith(TEXT("http://"), ESearchCase::IgnoreCase) + ? 7 + : TrimmedBase.StartsWith(TEXT("https://"), ESearchCase::IgnoreCase) ? 8 : INDEX_NONE; + if (AuthorityStart == INDEX_NONE || TrimmedBase.Len() <= AuthorityStart) { return FString(); } - const FString TrimmedBase = BaseUrl.EndsWith(TEXT("/")) ? BaseUrl.LeftChop(1) : BaseUrl; + while (TrimmedBase.Len() > AuthorityStart && TrimmedBase.EndsWith(TEXT("/"))) + { + TrimmedBase.LeftChopInline(1, EAllowShrinking::No); + } + if (TrimmedBase.Len() <= AuthorityStart) + { + return FString(); + } if (Path.IsEmpty()) { return TrimmedBase; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistRecognition/HyperTwistHttpVisionClient.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistRecognition/HyperTwistHttpVisionClient.cpp index 36c0adf..dec6042 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistRecognition/HyperTwistHttpVisionClient.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistRecognition/HyperTwistHttpVisionClient.cpp @@ -36,12 +36,23 @@ namespace HyperTwistHttpVisionClientInternal FString BuildUrl(const FString& BaseUrl, const FString& Path) { - if (BaseUrl.IsEmpty()) + FString TrimmedBase = BaseUrl.TrimStartAndEnd(); + const int32 AuthorityStart = TrimmedBase.StartsWith(TEXT("http://"), ESearchCase::IgnoreCase) + ? 7 + : TrimmedBase.StartsWith(TEXT("https://"), ESearchCase::IgnoreCase) ? 8 : INDEX_NONE; + if (AuthorityStart == INDEX_NONE || TrimmedBase.Len() <= AuthorityStart) { return FString(); } - const FString TrimmedBase = BaseUrl.EndsWith(TEXT("/")) ? BaseUrl.LeftChop(1) : BaseUrl; + while (TrimmedBase.Len() > AuthorityStart && TrimmedBase.EndsWith(TEXT("/"))) + { + TrimmedBase.LeftChopInline(1, EAllowShrinking::No); + } + if (TrimmedBase.Len() <= AuthorityStart) + { + return FString(); + } if (Path.IsEmpty()) { return TrimmedBase; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistRecognition/HyperTwistHttpVoiceClient.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistRecognition/HyperTwistHttpVoiceClient.cpp index ca2f08a..3f40e7c 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistRecognition/HyperTwistHttpVoiceClient.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistRecognition/HyperTwistHttpVoiceClient.cpp @@ -28,12 +28,23 @@ namespace HyperTwistHttpVoiceClientInternal FString BuildUrl(const FString& BaseUrl, const FString& Path) { - if (BaseUrl.IsEmpty()) + FString TrimmedBase = BaseUrl.TrimStartAndEnd(); + const int32 AuthorityStart = TrimmedBase.StartsWith(TEXT("http://"), ESearchCase::IgnoreCase) + ? 7 + : TrimmedBase.StartsWith(TEXT("https://"), ESearchCase::IgnoreCase) ? 8 : INDEX_NONE; + if (AuthorityStart == INDEX_NONE || TrimmedBase.Len() <= AuthorityStart) { return FString(); } - const FString TrimmedBase = BaseUrl.EndsWith(TEXT("/")) ? BaseUrl.LeftChop(1) : BaseUrl; + while (TrimmedBase.Len() > AuthorityStart && TrimmedBase.EndsWith(TEXT("/"))) + { + TrimmedBase.LeftChopInline(1, EAllowShrinking::No); + } + if (TrimmedBase.Len() <= AuthorityStart) + { + return FString(); + } if (Path.IsEmpty()) { return TrimmedBase; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeActor.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeActor.cpp index d0c4324..92820de 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeActor.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeActor.cpp @@ -1,11 +1,14 @@ #include "HyperTwistSimulation/HyperTwistClassicCubeActor.h" #include "Engine/World.h" #include "HyperTwistSimulation/HyperTwistClassicCubeCommandLibrary.h" +#include "HAL/PlatformTime.h" #include "Materials/MaterialInstanceDynamic.h" +#include "Materials/MaterialInterface.h" #include "ProceduralMeshComponent/Public/ProceduralMeshComponent.h" #include "ThirdParty/rob-twophase/cubie.h" #include "ThirdParty/rob-twophase/face.h" #include "ThirdParty/rob-twophase/move.h" +#include "UObject/ConstructorHelpers.h" #include @@ -27,7 +30,40 @@ namespace HyperTwistClassicCubeActorInternal AHyperTwistClassicCubeActor::AHyperTwistClassicCubeActor() { PrimaryActorTick.bCanEverTick = true; + PrimaryActorTick.bStartWithTickEnabled = true; + PrimaryActorTick.bTickEvenWhenPaused = true; RootComponent = CreateDefaultSubobject(TEXT("Root")); + + FaceMaterials.SetNum(static_cast(EHyperTwistClassicCubeFace::Right) + 1); + static ConstructorHelpers::FObjectFinder UpMaterial( + TEXT("/Game/HyperTwistTraining/Materials/MI_HT_ClassicCube_Up.MI_HT_ClassicCube_Up") + ); + static ConstructorHelpers::FObjectFinder DownMaterial( + TEXT("/Game/HyperTwistTraining/Materials/MI_HT_ClassicCube_Down.MI_HT_ClassicCube_Down") + ); + static ConstructorHelpers::FObjectFinder FrontMaterial( + TEXT("/Game/HyperTwistTraining/Materials/MI_HT_ClassicCube_Front.MI_HT_ClassicCube_Front") + ); + static ConstructorHelpers::FObjectFinder BackMaterial( + TEXT("/Game/HyperTwistTraining/Materials/MI_HT_ClassicCube_Back.MI_HT_ClassicCube_Back") + ); + static ConstructorHelpers::FObjectFinder LeftMaterial( + TEXT("/Game/HyperTwistTraining/Materials/MI_HT_ClassicCube_Left.MI_HT_ClassicCube_Left") + ); + static ConstructorHelpers::FObjectFinder RightMaterial( + TEXT("/Game/HyperTwistTraining/Materials/MI_HT_ClassicCube_Right.MI_HT_ClassicCube_Right") + ); + static ConstructorHelpers::FObjectFinder InternalFaceMaterial( + TEXT("/Game/HyperTwistTraining/Materials/MI_HT_ClassicCube_Internal.MI_HT_ClassicCube_Internal") + ); + + FaceMaterials[static_cast(EHyperTwistClassicCubeFace::Up)] = UpMaterial.Object; + FaceMaterials[static_cast(EHyperTwistClassicCubeFace::Down)] = DownMaterial.Object; + FaceMaterials[static_cast(EHyperTwistClassicCubeFace::Front)] = FrontMaterial.Object; + FaceMaterials[static_cast(EHyperTwistClassicCubeFace::Back)] = BackMaterial.Object; + FaceMaterials[static_cast(EHyperTwistClassicCubeFace::Left)] = LeftMaterial.Object; + FaceMaterials[static_cast(EHyperTwistClassicCubeFace::Right)] = RightMaterial.Object; + InternalMaterial = InternalFaceMaterial.Object; } void AHyperTwistClassicCubeActor::OnConstruction(const FTransform& Transform) @@ -42,29 +78,34 @@ void AHyperTwistClassicCubeActor::OnConstruction(const FTransform& Transform) void AHyperTwistClassicCubeActor::BeginPlay() { Super::BeginPlay(); - if (!bGenerateOnConstruction) - { - ResetCube(); - } + SetActorTickEnabled(true); + EnsureRenderablePresentation(); } void AHyperTwistClassicCubeActor::Tick(float DeltaTime) { Super::Tick(DeltaTime); - if (!bIsAnimating || !ActiveRotation.Pivot) + if (!bIsAnimating) { return; } + if (!IsValid(ActiveRotation.Pivot)) + { + FinalizeRotation(); + return; + } - const float Now = GetWorld()->GetTimeSeconds(); - const float Elapsed = Now - ActiveRotation.StartTime; - float Alpha = FMath::Clamp(Elapsed / ActiveRotation.Duration, 0.0f, 1.0f); + const double Elapsed = FPlatformTime::Seconds() - ActiveRotation.StartTime; + float Alpha = FMath::Clamp( + static_cast(Elapsed / FMath::Max(ActiveRotation.Duration, SMALL_NUMBER)), + 0.0f, + 1.0f); // Smooth step for nicer animation feel Alpha = Alpha * Alpha * (3.0f - 2.0f * Alpha); const FQuat CurrentRot = FQuat::Slerp(ActiveRotation.PivotStartRot, ActiveRotation.PivotTargetRot, Alpha); - ActiveRotation.Pivot->SetWorldRotation(CurrentRot); + ActiveRotation.Pivot->SetRelativeRotation(CurrentRot); if (Alpha >= 1.0f) { @@ -258,14 +299,14 @@ void AHyperTwistClassicCubeActor::ClearPieces() { for (const FTrackedPiece& Piece : TrackedPieces) { - if (Piece.Mesh && Piece.Mesh->IsValidLowLevel()) + if (IsValid(Piece.Mesh)) { Piece.Mesh->DestroyComponent(); } } TrackedPieces.Empty(); - if (ActiveRotation.Pivot && ActiveRotation.Pivot->IsValidLowLevel()) + if (IsValid(ActiveRotation.Pivot)) { ActiveRotation.Pivot->DestroyComponent(); } @@ -326,7 +367,13 @@ void AHyperTwistClassicCubeActor::CreatePiece(const FVector& GridPos, const TArr UProceduralMeshComponent* Mesh = nullptr; if (GetWorld() != nullptr) { - Mesh = NewObject(this, NAME_None, RF_Transactional); + Mesh = NewObject( + this, + NAME_None, + RF_Transactional | RF_Transient + ); + AddInstanceComponent(Mesh); + Mesh->SetCanEverAffectNavigation(false); Mesh->RegisterComponent(); Mesh->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepRelativeTransform); @@ -394,72 +441,48 @@ void AHyperTwistClassicCubeActor::CreateCubeletFace(UProceduralMeshComponent* Me case EHyperTwistClassicCubeFace::Up: // +Z Normal = FVector(0, 0, 1); TangentX = FVector(1, 0, 0); - FaceVerts = { - Center + FVector(-HalfSize, -HalfSize, +HalfSize), - Center + FVector(+HalfSize, -HalfSize, +HalfSize), - Center + FVector(+HalfSize, +HalfSize, +HalfSize), - Center + FVector(-HalfSize, +HalfSize, +HalfSize) - }; break; case EHyperTwistClassicCubeFace::Down: // -Z Normal = FVector(0, 0, -1); TangentX = FVector(-1, 0, 0); - FaceVerts = { - Center + FVector(+HalfSize, -HalfSize, -HalfSize), - Center + FVector(-HalfSize, -HalfSize, -HalfSize), - Center + FVector(-HalfSize, +HalfSize, -HalfSize), - Center + FVector(+HalfSize, +HalfSize, -HalfSize) - }; break; case EHyperTwistClassicCubeFace::Front: // +Y Normal = FVector(0, 1, 0); TangentX = FVector(1, 0, 0); - FaceVerts = { - Center + FVector(-HalfSize, +HalfSize, -HalfSize), - Center + FVector(+HalfSize, +HalfSize, -HalfSize), - Center + FVector(+HalfSize, +HalfSize, +HalfSize), - Center + FVector(-HalfSize, +HalfSize, +HalfSize) - }; break; case EHyperTwistClassicCubeFace::Back: // -Y Normal = FVector(0, -1, 0); TangentX = FVector(-1, 0, 0); - FaceVerts = { - Center + FVector(+HalfSize, -HalfSize, -HalfSize), - Center + FVector(-HalfSize, -HalfSize, -HalfSize), - Center + FVector(-HalfSize, -HalfSize, +HalfSize), - Center + FVector(+HalfSize, -HalfSize, +HalfSize) - }; break; case EHyperTwistClassicCubeFace::Right: // +X Normal = FVector(1, 0, 0); TangentX = FVector(0, 1, 0); - FaceVerts = { - Center + FVector(+HalfSize, -HalfSize, -HalfSize), - Center + FVector(+HalfSize, +HalfSize, -HalfSize), - Center + FVector(+HalfSize, +HalfSize, +HalfSize), - Center + FVector(+HalfSize, -HalfSize, +HalfSize) - }; break; case EHyperTwistClassicCubeFace::Left: // -X Normal = FVector(-1, 0, 0); TangentX = FVector(0, -1, 0); - FaceVerts = { - Center + FVector(-HalfSize, +HalfSize, -HalfSize), - Center + FVector(-HalfSize, -HalfSize, -HalfSize), - Center + FVector(-HalfSize, -HalfSize, +HalfSize), - Center + FVector(-HalfSize, +HalfSize, +HalfSize) - }; break; } + const FVector TangentY = FVector::CrossProduct(Normal, TangentX).GetSafeNormal(); + const FVector FaceCenter = Center + Normal * HalfSize; + FaceVerts = { + FaceCenter - TangentX * HalfSize - TangentY * HalfSize, + FaceCenter + TangentX * HalfSize - TangentY * HalfSize, + FaceCenter + TangentX * HalfSize + TangentY * HalfSize, + FaceCenter - TangentX * HalfSize + TangentY * HalfSize + }; + Vertices = FaceVerts; - Triangles = { 0, 1, 2, 0, 2, 3 }; + // Unreal renders clockwise procedural-mesh winding from the outward side. + // The vertex basis above is mathematically counter-clockwise around Normal, + // so reverse each triangle rather than exposing the cubelet interior. + Triangles = { 0, 2, 1, 0, 3, 2 }; for (int32 i = 0; i < 4; ++i) { @@ -475,7 +498,7 @@ void AHyperTwistClassicCubeActor::CreateCubeletFace(UProceduralMeshComponent* Me if (UMaterialInstanceDynamic* DynamicMaterial = Mesh->CreateDynamicMaterialInstance(SectionIndex, Material)) { - DynamicMaterial->SetScalarParameterValue(TEXT("GlowStrength"), 0.0f); + DynamicMaterial->SetScalarParameterValue(TEXT("GlowStrength"), BaseGlowStrength); DynamicMaterial->SetScalarParameterValue(TEXT("FaceOpacity"), 1.0f); } else @@ -495,6 +518,294 @@ bool AHyperTwistClassicCubeActor::IsSettled() const return !bIsAnimating && RotationQueue.IsEmpty(); } +int32 AHyperTwistClassicCubeActor::GetRenderablePieceCount() const +{ + int32 RenderablePieceCount = 0; + for (const FTrackedPiece& Piece : TrackedPieces) + { + if (IsValid(Piece.Mesh) + && Piece.Mesh->IsRegistered() + && Piece.Mesh->GetNumSections() > 0) + { + ++RenderablePieceCount; + } + } + return RenderablePieceCount; +} + +bool AHyperTwistClassicCubeActor::HasValidRenderableState() const +{ + return GetRenderableStateValidationError().IsEmpty(); +} + +FString AHyperTwistClassicCubeActor::GetRenderableStateValidationError() const +{ + auto IsOutwardFaceAtGridPosition = [](const EHyperTwistClassicCubeFace Face, const FVector& Position) + { + switch (Face) + { + case EHyperTwistClassicCubeFace::Up: + return FMath::IsNearlyEqual(Position.Z, 1.0f); + case EHyperTwistClassicCubeFace::Down: + return FMath::IsNearlyEqual(Position.Z, -1.0f); + case EHyperTwistClassicCubeFace::Front: + return FMath::IsNearlyEqual(Position.Y, 1.0f); + case EHyperTwistClassicCubeFace::Back: + return FMath::IsNearlyEqual(Position.Y, -1.0f); + case EHyperTwistClassicCubeFace::Left: + return FMath::IsNearlyEqual(Position.X, -1.0f); + case EHyperTwistClassicCubeFace::Right: + return FMath::IsNearlyEqual(Position.X, 1.0f); + } + return false; + }; + + TSet OccupiedGridIndices; + TArray StickerCountByWorldFace; + StickerCountByWorldFace.Init(0, 6); + int32 VisiblePieceCount = 0; + int32 TotalStickerCount = 0; + const bool bRequireLiveMeshes = GetWorld() != nullptr; + const float CubeletSpacing = CubeletSize + Gap; + + for (int32 PieceIndex = 0; PieceIndex < TrackedPieces.Num(); ++PieceIndex) + { + const FTrackedPiece& Piece = TrackedPieces[PieceIndex]; + if (Piece.IdentityFaces.IsEmpty()) + { + continue; + } + + ++VisiblePieceCount; + const FVector RoundedPosition( + FMath::RoundToFloat(Piece.GridPos.X), + FMath::RoundToFloat(Piece.GridPos.Y), + FMath::RoundToFloat(Piece.GridPos.Z)); + if (!Piece.GridPos.Equals(RoundedPosition, KINDA_SMALL_NUMBER) + || FMath::Abs(RoundedPosition.X) > 1.0f + || FMath::Abs(RoundedPosition.Y) > 1.0f + || FMath::Abs(RoundedPosition.Z) > 1.0f + || RoundedPosition.IsNearlyZero()) + { + return FString::Printf( + TEXT("piece %d occupies invalid grid position %s"), + PieceIndex, + *Piece.GridPos.ToCompactString()); + } + + const int32 GridIndex = GridToIndex(RoundedPosition); + if (OccupiedGridIndices.Contains(GridIndex)) + { + return FString::Printf( + TEXT("piece %d duplicates occupied grid position %s"), + PieceIndex, + *RoundedPosition.ToCompactString()); + } + OccupiedGridIndices.Add(GridIndex); + + const int32 ExpectedStickerCount = + (FMath::IsNearlyEqual(FMath::Abs(RoundedPosition.X), 1.0f) ? 1 : 0) + + (FMath::IsNearlyEqual(FMath::Abs(RoundedPosition.Y), 1.0f) ? 1 : 0) + + (FMath::IsNearlyEqual(FMath::Abs(RoundedPosition.Z), 1.0f) ? 1 : 0); + if (Piece.IdentityFaces.Num() != ExpectedStickerCount) + { + return FString::Printf( + TEXT("piece %d has %d stickers at a %d-sticker grid position"), + PieceIndex, + Piece.IdentityFaces.Num(), + ExpectedStickerCount); + } + + TSet PieceWorldFaces; + for (const EHyperTwistClassicCubeFace IdentityFace : Piece.IdentityFaces) + { + EHyperTwistClassicCubeFace WorldFace = EHyperTwistClassicCubeFace::Up; + if (!TryResolveStickerWorldFace(Piece, IdentityFace, WorldFace)) + { + return FString::Printf( + TEXT("piece %d has a sticker orientation that does not resolve to a cube axis"), + PieceIndex); + } + if (PieceWorldFaces.Contains(WorldFace)) + { + return FString::Printf( + TEXT("piece %d resolves multiple stickers to world face %d"), + PieceIndex, + static_cast(WorldFace)); + } + if (!IsOutwardFaceAtGridPosition(WorldFace, RoundedPosition)) + { + return FString::Printf( + TEXT("piece %d resolves sticker %d to inward world face %d at %s"), + PieceIndex, + static_cast(IdentityFace), + static_cast(WorldFace), + *RoundedPosition.ToCompactString()); + } + + PieceWorldFaces.Add(WorldFace); + const int32 WorldFaceIndex = static_cast(WorldFace); + if (!StickerCountByWorldFace.IsValidIndex(WorldFaceIndex)) + { + return FString::Printf( + TEXT("piece %d resolved an out-of-range world face %d"), + PieceIndex, + WorldFaceIndex); + } + ++StickerCountByWorldFace[WorldFaceIndex]; + ++TotalStickerCount; + } + + if (!bRequireLiveMeshes) + { + continue; + } + if (!IsValid(Piece.Mesh) || !Piece.Mesh->IsRegistered() || Piece.Mesh->GetNumSections() != 6) + { + return FString::Printf(TEXT("piece %d is missing its six-section live mesh"), PieceIndex); + } + + const FVector SolvedCenter = + GetExpectedGridPositionFromIdentity(Piece.IdentityFaces) * CubeletSpacing; + for (int32 SectionIndex = 0; SectionIndex < 6; ++SectionIndex) + { + const FProcMeshSection* Section = Piece.Mesh->GetProcMeshSection(SectionIndex); + if (Section == nullptr + || !Section->bSectionVisible + || Section->ProcVertexBuffer.Num() != 4 + || Section->ProcIndexBuffer.Num() != 6) + { + return FString::Printf( + TEXT("piece %d section %d is not a visible four-vertex cubelet face"), + PieceIndex, + SectionIndex); + } + + FVector LocalFaceCenter = FVector::ZeroVector; + for (const FProcMeshVertex& Vertex : Section->ProcVertexBuffer) + { + LocalFaceCenter += Vertex.Position; + } + LocalFaceCenter /= static_cast(Section->ProcVertexBuffer.Num()); + + const EHyperTwistClassicCubeFace SectionFace = + static_cast(SectionIndex); + const FVector LocalFaceNormal = GetFaceNormal(SectionFace); + const FVector ExpectedLocalFaceCenter = + SolvedCenter + LocalFaceNormal * (CubeletSize * 0.5f); + if (!LocalFaceCenter.Equals(ExpectedLocalFaceCenter, 0.01f)) + { + return FString::Printf( + TEXT("piece %d section %d is centered at %s instead of %s"), + PieceIndex, + SectionIndex, + *LocalFaceCenter.ToCompactString(), + *ExpectedLocalFaceCenter.ToCompactString()); + } + + const uint32 IndexA = Section->ProcIndexBuffer[0]; + const uint32 IndexB = Section->ProcIndexBuffer[1]; + const uint32 IndexC = Section->ProcIndexBuffer[2]; + if (!Section->ProcVertexBuffer.IsValidIndex(static_cast(IndexA)) + || !Section->ProcVertexBuffer.IsValidIndex(static_cast(IndexB)) + || !Section->ProcVertexBuffer.IsValidIndex(static_cast(IndexC))) + { + return FString::Printf( + TEXT("piece %d section %d contains an invalid triangle index"), + PieceIndex, + SectionIndex); + } + + const FVector EdgeAB = + Section->ProcVertexBuffer[static_cast(IndexB)].Position + - Section->ProcVertexBuffer[static_cast(IndexA)].Position; + const FVector EdgeAC = + Section->ProcVertexBuffer[static_cast(IndexC)].Position + - Section->ProcVertexBuffer[static_cast(IndexA)].Position; + // Unreal treats clockwise procedural triangles as front-facing. Their + // mathematical cross product therefore points opposite the authored + // outward vertex normal. + const FVector FrontWindingNormal = + -FVector::CrossProduct(EdgeAB, EdgeAC).GetSafeNormal(); + if (FVector::DotProduct(FrontWindingNormal, LocalFaceNormal) + < HyperTwistClassicCubeActorInternal::RotationDotTolerance) + { + return FString::Printf( + TEXT("piece %d section %d has inward-facing triangle winding"), + PieceIndex, + SectionIndex); + } + } + + const FVector ExpectedWorldCenter = GetActorTransform().TransformPosition( + RoundedPosition * CubeletSpacing); + const FVector RenderedWorldCenter = + Piece.Mesh->GetComponentTransform().TransformPosition(SolvedCenter); + if (!RenderedWorldCenter.Equals(ExpectedWorldCenter, 0.05f)) + { + return FString::Printf( + TEXT("piece %d renders at %s instead of expected center %s"), + PieceIndex, + *RenderedWorldCenter.ToCompactString(), + *ExpectedWorldCenter.ToCompactString()); + } + + for (const EHyperTwistClassicCubeFace IdentityFace : Piece.IdentityFaces) + { + EHyperTwistClassicCubeFace WorldFace = EHyperTwistClassicCubeFace::Up; + TryResolveStickerWorldFace(Piece, IdentityFace, WorldFace); + const FVector RenderedWorldNormal = Piece.Mesh->GetComponentQuat().RotateVector( + GetFaceNormal(IdentityFace)).GetSafeNormal(); + const FVector ExpectedWorldNormal = GetActorQuat().RotateVector( + GetFaceNormal(WorldFace)).GetSafeNormal(); + if (FVector::DotProduct(RenderedWorldNormal, ExpectedWorldNormal) + < HyperTwistClassicCubeActorInternal::RotationDotTolerance) + { + return FString::Printf( + TEXT("piece %d live mesh sticker normal diverges from tracked orientation"), + PieceIndex); + } + } + } + + if (VisiblePieceCount != ExpectedRenderablePieceCount + || OccupiedGridIndices.Num() != ExpectedRenderablePieceCount) + { + return FString::Printf( + TEXT("cube has %d visible pieces across %d unique positions instead of %d"), + VisiblePieceCount, + OccupiedGridIndices.Num(), + ExpectedRenderablePieceCount); + } + if (TotalStickerCount != 54) + { + return FString::Printf(TEXT("cube has %d outward stickers instead of 54"), TotalStickerCount); + } + for (int32 FaceIndex = 0; FaceIndex < StickerCountByWorldFace.Num(); ++FaceIndex) + { + if (StickerCountByWorldFace[FaceIndex] != 9) + { + return FString::Printf( + TEXT("world face %d has %d stickers instead of 9"), + FaceIndex, + StickerCountByWorldFace[FaceIndex]); + } + } + + return FString(); +} + +bool AHyperTwistClassicCubeActor::EnsureRenderablePresentation() +{ + if (GetRenderablePieceCount() == ExpectedRenderablePieceCount) + { + return false; + } + + ResetCube(); + return true; +} + bool AHyperTwistClassicCubeActor::IsSolved() const { if (bIsAnimating || !RotationQueue.IsEmpty()) @@ -834,11 +1145,18 @@ void AHyperTwistClassicCubeActor::StartFaceRotation(EHyperTwistClassicCubeFace F return; } - // Create pivot at face center - USceneComponent* Pivot = NewObject(this, NAME_None, RF_Transactional); + // Keep the pivot in the cube actor's local frame. A world-space pivot makes + // a placed cube orbit the map origin during every animated turn. + USceneComponent* Pivot = NewObject( + this, + NAME_None, + RF_Transactional | RF_Transient + ); + AddInstanceComponent(Pivot); + Pivot->AttachToComponent(RootComponent, FAttachmentTransformRules::SnapToTargetNotIncludingScale); Pivot->RegisterComponent(); - Pivot->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepRelativeTransform); - Pivot->SetWorldLocation(GetFaceCenter(Face) * (CubeletSize + Gap)); + Pivot->SetRelativeLocation(GetFaceCenter(Face) * (CubeletSize + Gap)); + Pivot->SetRelativeRotation(FQuat::Identity); // Attach face pieces to pivot (keep world transform) for (int32 Idx : Indices) @@ -854,7 +1172,7 @@ void AHyperTwistClassicCubeActor::StartFaceRotation(EHyperTwistClassicCubeFace F ActiveRotation.PieceIndices = Indices; ActiveRotation.PivotStartRot = FQuat::Identity; ActiveRotation.PivotTargetRot = FaceRot; - ActiveRotation.StartTime = GetWorld()->GetTimeSeconds(); + ActiveRotation.StartTime = FPlatformTime::Seconds(); ActiveRotation.Duration = TurnDuration; ActiveRotation.Face = Face; ActiveRotation.Direction = Direction; @@ -863,9 +1181,28 @@ void AHyperTwistClassicCubeActor::StartFaceRotation(EHyperTwistClassicCubeFace F void AHyperTwistClassicCubeActor::FinalizeRotation() { - if (!ActiveRotation.Pivot) + if (!IsValid(ActiveRotation.Pivot)) { + const EHyperTwistClassicCubeFace InterruptedFace = ActiveRotation.Face; + const EHyperTwistRotationDirection InterruptedDirection = ActiveRotation.Direction; + const bool bInterruptedGameplayMove = ActiveRotation.bGameplayMove; + for (const int32 PieceIndex : ActiveRotation.PieceIndices) + { + if (TrackedPieces.IsValidIndex(PieceIndex) + && IsValid(TrackedPieces[PieceIndex].Mesh) + && RootComponent != nullptr) + { + TrackedPieces[PieceIndex].Mesh->AttachToComponent( + RootComponent, + FAttachmentTransformRules::KeepWorldTransform); + } + } + ActiveRotation = FActiveRotation(); bIsAnimating = false; + ApplyQuarterTurnImmediate( + InterruptedFace, + InterruptedDirection, + bInterruptedGameplayMove); ProcessRotationQueue(); return; } @@ -936,48 +1273,27 @@ FVector AHyperTwistClassicCubeActor::IndexToGrid(int32 Index) const FVector AHyperTwistClassicCubeActor::RotateGridPosition(const FVector& Pos, EHyperTwistClassicCubeFace Face, EHyperTwistRotationDirection Direction) { - const float X = Pos.X; - const float Y = Pos.Y; - const float Z = Pos.Z; - - if (Direction == EHyperTwistRotationDirection::Clockwise) - { - switch (Face) - { - case EHyperTwistClassicCubeFace::Up: return FVector( Y, -X, Z); - case EHyperTwistClassicCubeFace::Down: return FVector(-Y, X, Z); - case EHyperTwistClassicCubeFace::Front: return FVector( Z, Y, -X); - case EHyperTwistClassicCubeFace::Back: return FVector(-Z, Y, X); - case EHyperTwistClassicCubeFace::Right: return FVector( X, -Z, Y); - case EHyperTwistClassicCubeFace::Left: return FVector( X, Z, -Y); - } - } - else - { - switch (Face) - { - case EHyperTwistClassicCubeFace::Up: return FVector(-Y, X, Z); - case EHyperTwistClassicCubeFace::Down: return FVector( Y, -X, Z); - case EHyperTwistClassicCubeFace::Front: return FVector(-Z, Y, X); - case EHyperTwistClassicCubeFace::Back: return FVector( Z, Y, -X); - case EHyperTwistClassicCubeFace::Right: return FVector( X, Z, -Y); - case EHyperTwistClassicCubeFace::Left: return FVector( X, -Z, Y); - } - } - return Pos; + return GetFaceRotationQuat(Face, Direction).RotateVector(Pos); } FQuat AHyperTwistClassicCubeActor::GetFaceRotationQuat(EHyperTwistClassicCubeFace Face, EHyperTwistRotationDirection Direction) { const bool bCW = (Direction == EHyperTwistRotationDirection::Clockwise); + const float QuarterTurnRadians = PI * 0.5f; switch (Face) { - case EHyperTwistClassicCubeFace::Up: return FQuat(FRotator(0.0f, bCW ? -90.0f : 90.0f, 0.0f)); - case EHyperTwistClassicCubeFace::Down: return FQuat(FRotator(0.0f, bCW ? 90.0f : -90.0f, 0.0f)); - case EHyperTwistClassicCubeFace::Front: return FQuat(FRotator(bCW ? 90.0f : -90.0f, 0.0f, 0.0f)); - case EHyperTwistClassicCubeFace::Back: return FQuat(FRotator(bCW ? -90.0f : 90.0f, 0.0f, 0.0f)); - case EHyperTwistClassicCubeFace::Right: return FQuat(FRotator(0.0f, 0.0f, bCW ? 90.0f : -90.0f)); - case EHyperTwistClassicCubeFace::Left: return FQuat(FRotator(0.0f, 0.0f, bCW ? -90.0f : 90.0f)); + case EHyperTwistClassicCubeFace::Up: + return FQuat(FVector::UpVector, bCW ? -QuarterTurnRadians : QuarterTurnRadians); + case EHyperTwistClassicCubeFace::Down: + return FQuat(FVector::UpVector, bCW ? QuarterTurnRadians : -QuarterTurnRadians); + case EHyperTwistClassicCubeFace::Front: + return FQuat(FVector::YAxisVector, bCW ? QuarterTurnRadians : -QuarterTurnRadians); + case EHyperTwistClassicCubeFace::Back: + return FQuat(FVector::YAxisVector, bCW ? -QuarterTurnRadians : QuarterTurnRadians); + case EHyperTwistClassicCubeFace::Right: + return FQuat(FVector::XAxisVector, bCW ? QuarterTurnRadians : -QuarterTurnRadians); + case EHyperTwistClassicCubeFace::Left: + return FQuat(FVector::XAxisVector, bCW ? -QuarterTurnRadians : QuarterTurnRadians); } return FQuat::Identity; } @@ -1211,7 +1527,7 @@ void AHyperTwistClassicCubeActor::UpdateHintMaterialState() DynamicMaterial->SetScalarParameterValue(TEXT("GlowStrength"), GlowStrength); DynamicMaterial->SetScalarParameterValue( TEXT("HintActive"), - GlowStrength > 0.0f ? 1.0f : 0.0f + GlowStrength > BaseGlowStrength + KINDA_SMALL_NUMBER ? 1.0f : 0.0f ); } } @@ -1224,14 +1540,14 @@ float AHyperTwistClassicCubeActor::ResolveHintGlowStrength( { if (!bHasHintedFace || !Piece.IdentityFaces.Contains(IdentityFace)) { - return 0.0f; + return BaseGlowStrength; } EHyperTwistClassicCubeFace WorldFace = EHyperTwistClassicCubeFace::Up; if (!TryResolveStickerWorldFace(Piece, IdentityFace, WorldFace)) { - return 0.0f; + return BaseGlowStrength; } - return WorldFace == HintedFace ? 7.5f : 0.0f; + return WorldFace == HintedFace ? HintGlowStrength : BaseGlowStrength; } diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp index 47b6384..9344c1d 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp @@ -1,8 +1,12 @@ #include "HyperTwistSimulation/HyperTwistClassicCubeGameMode.h" +#include "Async/Async.h" +#include "HyperTwistDiagnostics/HyperTwistRuntimeDiagnostics.h" #include "Blueprint/UserWidget.h" #include "Components/AudioComponent.h" +#include "Components/DirectionalLightComponent.h" #include "Engine/GameInstance.h" +#include "Engine/DirectionalLight.h" #include "Engine/World.h" #include "EngineUtils.h" #include "GameFramework/PlayerController.h" @@ -30,6 +34,8 @@ #include "UObject/UnrealType.h" #include "VoiceModule.h" +DEFINE_LOG_CATEGORY_STATIC(LogHyperTwistClassicCubeUi, Log, All); + namespace HyperTwistClassicCubeGameModeInternal { constexpr float AudioDuckMultiplier = 0.15f; @@ -309,6 +315,8 @@ namespace HyperTwistClassicCubeGameModeInternal AHyperTwistClassicCubeGameMode::AHyperTwistClassicCubeGameMode() { PrimaryActorTick.bCanEverTick = true; + PrimaryActorTick.bStartWithTickEnabled = true; + PrimaryActorTick.bTickEvenWhenPaused = true; PlayerControllerClass = AHyperTwistClassicCubePlayerController::StaticClass(); DefaultPawnClass = AHyperTwistClassicCubeOrbitPawn::StaticClass(); CubeSpawnLocation = FVector(0.0f, 0.0f, 120.0f); @@ -365,7 +373,13 @@ bool AHyperTwistClassicCubeGameMode::IsClassicCubeAnyActionButtonHovered_Impleme void AHyperTwistClassicCubeGameMode::BeginPlay() { Super::BeginPlay(); + SetActorTickEnabled(true); + HyperTwistRuntimeDiagnostics::AppendEvent( + TEXT("MapReady"), + FString::Printf( + TEXT("Runtime map ready: %s."), + GetWorld() != nullptr ? *GetWorld()->GetPathName() : TEXT("unknown"))); ApplyLaunchOverridesFromCommandLine(); if (APlayerController* PlayerController = @@ -376,7 +390,28 @@ void AHyperTwistClassicCubeGameMode::BeginPlay() PlayerController->bEnableMouseOverEvents = true; } - ResolveOrSpawnCubeActor(); + ActiveCubeActor = ResolveOrSpawnCubeActor(); + EnsureRuntimeLighting(); + ActiveOrbitPawn = ResolveOrSpawnOrbitPawn(); + if (ActiveCubeActor != nullptr) + { + const bool bRecoveredPresentation = ActiveCubeActor->EnsureRenderablePresentation(); + UE_LOG( + LogHyperTwistClassicCubeUi, + Display, + TEXT("Classic cube presentation initialized with %d renderable pieces and orbit pawn %s%s."), + ActiveCubeActor->GetRenderablePieceCount(), + ActiveOrbitPawn != nullptr ? TEXT("ready") : TEXT("pending"), + bRecoveredPresentation ? TEXT(" after runtime geometry recovery") : TEXT("") + ); + HyperTwistRuntimeDiagnostics::AppendEvent( + TEXT("ClassicPresentation"), + FString::Printf( + TEXT("Classic cube presentation initialized with %d renderable pieces and orbit pawn %s%s."), + ActiveCubeActor->GetRenderablePieceCount(), + ActiveOrbitPawn != nullptr ? TEXT("ready") : TEXT("pending"), + bRecoveredPresentation ? TEXT(" after runtime geometry recovery") : TEXT(""))); + } if (bAutoCreateHud) { ResolveOrCreateHudWidget(); @@ -414,6 +449,8 @@ void AHyperTwistClassicCubeGameMode::Tick(const float DeltaSeconds) Super::Tick(DeltaSeconds); ResolveOrSpawnCubeActor(); + ResolveOrSpawnOrbitPawn(); + EnsureRuntimeLighting(); if (bAutoCreateHud) { ResolveOrCreateHudWidget(); @@ -424,11 +461,52 @@ void AHyperTwistClassicCubeGameMode::Tick(const float DeltaSeconds) TickReplayAutoExit(DeltaSeconds); UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem(); + bool bCaptureSettledRuntimeFrame = false; if (ActiveCubeActor != nullptr) { if (bAwaitingScrambleSettlement && IsCubeSettled()) { bAwaitingScrambleSettlement = false; + const FString RenderableStateError = + ActiveCubeActor->GetRenderableStateValidationError(); + if (RenderableStateError.IsEmpty()) + { + UE_LOG( + LogHyperTwistClassicCubeUi, + Display, + TEXT("Classic cube scramble settled with %d completed quarter turns, %d queued rotations, and %d renderable pieces; renderable state valid."), + ActiveCubeActor->GetTotalCompletedMoveCount(), + ActiveCubeActor->GetQueuedRotationCount(), + ActiveCubeActor->GetRenderablePieceCount()); + HyperTwistRuntimeDiagnostics::AppendEvent( + TEXT("ClassicScrambleSettlement"), + FString::Printf( + TEXT("Classic cube scramble settled with %d completed quarter turns, %d queued rotations, and %d renderable pieces; renderable state valid."), + ActiveCubeActor->GetTotalCompletedMoveCount(), + ActiveCubeActor->GetQueuedRotationCount(), + ActiveCubeActor->GetRenderablePieceCount())); + } + else + { + UE_LOG( + LogHyperTwistClassicCubeUi, + Error, + TEXT("Classic cube scramble settled with %d completed quarter turns, %d queued rotations, and %d renderable pieces; renderable state invalid: %s."), + ActiveCubeActor->GetTotalCompletedMoveCount(), + ActiveCubeActor->GetQueuedRotationCount(), + ActiveCubeActor->GetRenderablePieceCount(), + *RenderableStateError); + HyperTwistRuntimeDiagnostics::AppendEvent( + TEXT("ClassicScrambleSettlementError"), + FString::Printf( + TEXT("Classic cube scramble settled with %d completed quarter turns, %d queued rotations, and %d renderable pieces; renderable state invalid: %s."), + ActiveCubeActor->GetTotalCompletedMoveCount(), + ActiveCubeActor->GetQueuedRotationCount(), + ActiveCubeActor->GetRenderablePieceCount(), + *RenderableStateError)); + } + bCaptureSettledRuntimeFrame = + HyperTwistRuntimeDiagnostics::IsReadyFrameCaptureRequested(); if (TrainingSubsystem != nullptr) { TrainingSubsystem->StartActiveLiveTimer(); @@ -452,6 +530,18 @@ void AHyperTwistClassicCubeGameMode::Tick(const float DeltaSeconds) } RefreshHud(); + if (bCaptureSettledRuntimeFrame) + { + RequestRuntimeReadyDiagnosticsCapture(); + } +} + +void AHyperTwistClassicCubeGameMode::RequestRuntimeReadyDiagnosticsCapture() +{ + HyperTwistRuntimeDiagnostics::RequestReadyFrameCapture( + ActiveSessionMode == EHyperTwistClassicCubeSessionMode::FollowAlong + ? TEXT("classic-cube-follow-along-settled") + : TEXT("classic-cube-free-play-settled")); } void AHyperTwistClassicCubeGameMode::StartFreshAttempt() @@ -1233,6 +1323,123 @@ AHyperTwistClassicCubeActor* AHyperTwistClassicCubeGameMode::ResolveOrSpawnCubeA return ActiveCubeActor; } +AHyperTwistClassicCubeOrbitPawn* AHyperTwistClassicCubeGameMode::ResolveOrSpawnOrbitPawn() +{ + if (GetWorld() == nullptr) + { + return nullptr; + } + + APlayerController* PlayerController = GetWorld()->GetFirstPlayerController(); + if (PlayerController == nullptr) + { + return nullptr; + } + + if (AHyperTwistClassicCubeOrbitPawn* PossessedOrbitPawn = + Cast(PlayerController->GetPawn())) + { + ActiveOrbitPawn = PossessedOrbitPawn; + return ActiveOrbitPawn; + } + + if (ActiveOrbitPawn == nullptr) + { + for (TActorIterator ActorIt(GetWorld()); ActorIt; ++ActorIt) + { + if (ActorIt->GetController() == nullptr || ActorIt->GetController() == PlayerController) + { + ActiveOrbitPawn = *ActorIt; + break; + } + } + } + + if (ActiveOrbitPawn == nullptr && bEnsurePlayableOrbitPawn) + { + FActorSpawnParameters SpawnParameters; + SpawnParameters.Owner = PlayerController; + SpawnParameters.SpawnCollisionHandlingOverride = + ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + const FVector SpawnLocation = ActiveCubeActor != nullptr + ? ActiveCubeActor->GetActorLocation() + : CubeSpawnLocation; + ActiveOrbitPawn = GetWorld()->SpawnActor( + AHyperTwistClassicCubeOrbitPawn::StaticClass(), + SpawnLocation, + FRotator::ZeroRotator, + SpawnParameters + ); + if (ActiveOrbitPawn != nullptr) + { + UE_LOG( + LogHyperTwistClassicCubeUi, + Warning, + TEXT("Recovered a missing authored PlayerStart/default pawn with a runtime orbit pawn.") + ); + } + } + + if (ActiveOrbitPawn != nullptr && PlayerController->GetPawn() != ActiveOrbitPawn) + { + PlayerController->Possess(ActiveOrbitPawn); + PlayerController->SetViewTarget(ActiveOrbitPawn); + } + + return ActiveOrbitPawn; +} + +void AHyperTwistClassicCubeGameMode::EnsureRuntimeLighting() +{ + if (bRuntimeLightingReady || GetWorld() == nullptr) + { + return; + } + if (!bEnsureRuntimeLighting) + { + bRuntimeLightingReady = true; + return; + } + + for (TActorIterator ActorIt(GetWorld()); ActorIt; ++ActorIt) + { + if (ActorIt->GetLightComponent() != nullptr + && ActorIt->GetLightComponent()->IsVisible()) + { + bRuntimeLightingReady = true; + return; + } + } + + FActorSpawnParameters SpawnParameters; + SpawnParameters.SpawnCollisionHandlingOverride = + ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + ADirectionalLight* RuntimeLight = GetWorld()->SpawnActor( + ADirectionalLight::StaticClass(), + CubeSpawnLocation + FVector(-240.0f, -180.0f, 320.0f), + FRotator(-38.0f, -42.0f, 0.0f), + SpawnParameters + ); + UDirectionalLightComponent* LightComponent = RuntimeLight != nullptr + ? Cast(RuntimeLight->GetLightComponent()) + : nullptr; + if (LightComponent == nullptr) + { + return; + } + + LightComponent->SetMobility(EComponentMobility::Movable); + LightComponent->SetIntensity(8.0f); + LightComponent->SetLightColor(FLinearColor(0.80f, 0.90f, 1.0f)); + RuntimeLight->Tags.AddUnique(FName(TEXT("HyperTwistRuntimePresentationLight"))); + bRuntimeLightingReady = true; + UE_LOG( + LogHyperTwistClassicCubeUi, + Warning, + TEXT("Recovered missing authored lighting with the runtime presentation key light.") + ); +} + UHyperTwistClassicCubeHUDWidget* AHyperTwistClassicCubeGameMode::ResolveOrCreateHudWidget() { if (ActiveHudWidget != nullptr) @@ -1258,6 +1465,16 @@ UHyperTwistClassicCubeHUDWidget* AHyperTwistClassicCubeGameMode::ResolveOrCreate ActiveHudWidget = CreateWidget(PlayerController, ResolvedClass); if (ActiveHudWidget != nullptr) { + if (!ActiveHudWidget->PrepareClassicCubeHudSurface()) + { + UE_LOG( + LogHyperTwistClassicCubeUi, + Error, + TEXT("Classic cube HUD creation produced no renderable root widget.") + ); + ActiveHudWidget = nullptr; + return nullptr; + } ActiveHudWidget->AddToViewport(HudZOrder); } @@ -1432,15 +1649,19 @@ void AHyperTwistClassicCubeGameMode::RefreshSolverGuidance(const bool bForceClea if (!IsCubeSettled()) { + ++SolverGuidanceRequestSerial; + PendingSolverFaceletString.Reset(); + bPendingSolverForceClearHint = false; HintLineOverride = TEXT("hint: waiting for cube to settle"); return; } - const bool bIsFollowAlong = - ActiveSessionMode == EHyperTwistClassicCubeSessionMode::FollowAlong; const FString FaceletString = ActiveCubeActor->GetSolverFaceletString(); if (FaceletString.IsEmpty() || !UHyperTwistSolverLibrary::VerifyFaceletString(FaceletString)) { + ++SolverGuidanceRequestSerial; + PendingSolverFaceletString.Reset(); + bPendingSolverForceClearHint = false; ActiveSolutionNotation.Reset(); ActiveSolutionQuarterTurns.Reset(); FollowAlongGuideQuarterTurns.Reset(); @@ -1448,64 +1669,171 @@ void AHyperTwistClassicCubeGameMode::RefreshSolverGuidance(const bool bForceClea return; } - ActiveSolutionNotation = UHyperTwistSolverLibrary::SolveClassicState(FaceletString, 1500, 32, 1); - ActiveSolutionQuarterTurns = - UHyperTwistClassicCubeCommandLibrary::ExpandMoveNotationSequence(ActiveSolutionNotation); - - if (bIsFollowAlong - && (bForceClearHint || FollowAlongGuideQuarterTurns.IsEmpty())) - { - FollowAlongGuideQuarterTurns = ActiveSolutionQuarterTurns; - } - - const TArray& GuidanceMoves = - bIsFollowAlong ? FollowAlongGuideQuarterTurns : ActiveSolutionQuarterTurns; - - if (GuidanceMoves.IsEmpty()) + if (ActiveCubeActor->IsSolved()) { + ++SolverGuidanceRequestSerial; + PendingSolverFaceletString.Reset(); + bPendingSolverForceClearHint = false; + ActiveSolutionNotation.Reset(); + ActiveSolutionQuarterTurns.Reset(); + FollowAlongGuideQuarterTurns.Reset(); ActiveCubeActor->ClearHintedFace(); - HintLineOverride = ActiveCubeActor->IsSolved() - ? TEXT("hint: cube solved") - : TEXT("hint: no solver guidance returned"); + HintLineOverride = TEXT("hint: cube solved"); return; } - if (bIsFollowAlong) + ++SolverGuidanceRequestSerial; + PendingSolverFaceletString = FaceletString; + bPendingSolverForceClearHint = bForceClearHint; + ActiveSolutionNotation.Reset(); + ActiveSolutionQuarterTurns.Reset(); + HintLineOverride = TEXT("hint: preparing solver guidance in background"); + StartPendingSolverGuidanceTask(); +} + +void AHyperTwistClassicCubeGameMode::StartPendingSolverGuidanceTask() +{ + if (bSolverGuidanceTaskActive || PendingSolverFaceletString.IsEmpty()) { - if (!GuidanceMoves.IsValidIndex(FollowAlongStepIndex)) + return; + } + + const uint64 RequestSerial = SolverGuidanceRequestSerial; + const FString FaceletString = MoveTemp(PendingSolverFaceletString); + const bool bForceClearHint = bPendingSolverForceClearHint; + const int32 TimeLimitMs = FMath::Max(SolverTimeLimitMs, 1); + const int32 MaximumMoveCount = FMath::Max(SolverMaximumMoveCount, 1); + PendingSolverFaceletString.Reset(); + bPendingSolverForceClearHint = false; + bSolverGuidanceTaskActive = true; + ActiveSolverFaceletString = FaceletString; + + UE_LOG( + LogHyperTwistClassicCubeUi, + Display, + TEXT("Classic cube solver guidance request %llu queued on the background worker."), + RequestSerial); + + const TWeakObjectPtr WeakGameMode(this); + Async( + EAsyncExecution::ThreadPool, + [WeakGameMode, RequestSerial, FaceletString, bForceClearHint, TimeLimitMs, MaximumMoveCount]() + { + TArray SolutionNotation = UHyperTwistSolverLibrary::SolveClassicState( + FaceletString, + TimeLimitMs, + MaximumMoveCount, + 1); + AsyncTask( + ENamedThreads::GameThread, + [WeakGameMode, + RequestSerial, + FaceletString, + bForceClearHint, + SolutionNotation = MoveTemp(SolutionNotation)]() mutable { - ActiveCubeActor->ClearHintedFace(); - HintLineOverride = TEXT("hint: guided run complete"); - ModeLineOverride = FString::Printf( - TEXT("mode: follow-along complete, correct %d, wrong %d"), - FollowAlongCorrectMoveCount, - FollowAlongIncorrectMoveCount - ); - return; + if (AHyperTwistClassicCubeGameMode* GameMode = WeakGameMode.Get()) + { + GameMode->CompleteSolverGuidanceTask( + RequestSerial, + FaceletString, + bForceClearHint, + MoveTemp(SolutionNotation)); + } + }); + }); +} + +void AHyperTwistClassicCubeGameMode::CompleteSolverGuidanceTask( + const uint64 RequestSerial, + const FString& FaceletString, + const bool bForceClearHint, + TArray&& SolutionNotation) +{ + bSolverGuidanceTaskActive = false; + ActiveSolverFaceletString.Reset(); + + ActiveCubeActor = ResolveOrSpawnCubeActor(); + const bool bResultMatchesCurrentState = + RequestSerial == SolverGuidanceRequestSerial + && ActiveCubeActor != nullptr + && IsCubeSettled() + && ActiveCubeActor->GetSolverFaceletString().Equals(FaceletString, ESearchCase::CaseSensitive); + if (bResultMatchesCurrentState) + { + ActiveSolutionNotation = MoveTemp(SolutionNotation); + ActiveSolutionQuarterTurns = + UHyperTwistClassicCubeCommandLibrary::ExpandMoveNotationSequence(ActiveSolutionNotation); + const bool bIsFollowAlong = + ActiveSessionMode == EHyperTwistClassicCubeSessionMode::FollowAlong; + if (bIsFollowAlong + && (bForceClearHint || FollowAlongGuideQuarterTurns.IsEmpty())) + { + FollowAlongGuideQuarterTurns = ActiveSolutionQuarterTurns; } - const FHyperTwistClassicCubeMoveDescriptor& GuidedMove = - GuidanceMoves[FollowAlongStepIndex]; - ActiveCubeActor->SetHintedFace(GuidedMove.Face); - HintLineOverride = FString::Printf( - TEXT("hint: next move %s"), - *GuidedMove.Notation - ); - ModeLineOverride = FString::Printf( - TEXT("mode: follow-along step %d/%d, correct %d, wrong %d"), - FMath::Clamp(FollowAlongStepIndex + 1, 1, FMath::Max(GuidanceMoves.Num(), 1)), - FMath::Max(GuidanceMoves.Num(), 1), - FollowAlongCorrectMoveCount, - FollowAlongIncorrectMoveCount - ); - return; + const TArray& GuidanceMoves = + bIsFollowAlong ? FollowAlongGuideQuarterTurns : ActiveSolutionQuarterTurns; + if (GuidanceMoves.IsEmpty()) + { + ActiveCubeActor->ClearHintedFace(); + HintLineOverride = ActiveCubeActor->IsSolved() + ? TEXT("hint: cube solved") + : TEXT("hint: no solver guidance returned"); + } + else if (bIsFollowAlong) + { + if (!GuidanceMoves.IsValidIndex(FollowAlongStepIndex)) + { + ActiveCubeActor->ClearHintedFace(); + HintLineOverride = TEXT("hint: guided run complete"); + ModeLineOverride = FString::Printf( + TEXT("mode: follow-along complete, correct %d, wrong %d"), + FollowAlongCorrectMoveCount, + FollowAlongIncorrectMoveCount); + } + else + { + const FHyperTwistClassicCubeMoveDescriptor& GuidedMove = + GuidanceMoves[FollowAlongStepIndex]; + ActiveCubeActor->SetHintedFace(GuidedMove.Face); + HintLineOverride = FString::Printf( + TEXT("hint: next move %s"), + *GuidedMove.Notation); + ModeLineOverride = FString::Printf( + TEXT("mode: follow-along step %d/%d, correct %d, wrong %d"), + FMath::Clamp(FollowAlongStepIndex + 1, 1, FMath::Max(GuidanceMoves.Num(), 1)), + FMath::Max(GuidanceMoves.Num(), 1), + FollowAlongCorrectMoveCount, + FollowAlongIncorrectMoveCount); + } + } + else + { + ActiveCubeActor->SetHintedFace(GuidanceMoves[0].Face); + HintLineOverride = FString::Printf( + TEXT("hint: next move %s"), + *GuidanceMoves[0].Notation); + } + + UE_LOG( + LogHyperTwistClassicCubeUi, + Display, + TEXT("Classic cube solver guidance request %llu completed with %d moves."), + RequestSerial, + ActiveSolutionNotation.Num()); + RefreshHud(); + } + else + { + UE_LOG( + LogHyperTwistClassicCubeUi, + Verbose, + TEXT("Discarded stale classic cube solver guidance request %llu."), + RequestSerial); } - ActiveCubeActor->SetHintedFace(GuidanceMoves[0].Face); - HintLineOverride = FString::Printf( - TEXT("hint: next move %s"), - *GuidanceMoves[0].Notation - ); + StartPendingSolverGuidanceTask(); } void AHyperTwistClassicCubeGameMode::RefreshVoiceProfiles() diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.cpp index c5eb153..86a7e3b 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.cpp @@ -1,9 +1,16 @@ #include "HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h" #include "Blueprint/WidgetTree.h" +#include "Components/Border.h" #include "Components/Button.h" #include "Components/ButtonSlot.h" +#include "Components/HorizontalBox.h" +#include "Components/HorizontalBoxSlot.h" +#include "Components/ScrollBox.h" +#include "Components/SizeBox.h" #include "Components/TextBlock.h" +#include "Components/UniformGridPanel.h" +#include "Components/UniformGridSlot.h" #include "Components/VerticalBox.h" #include "Components/VerticalBoxSlot.h" #include "GameFramework/GameModeBase.h" @@ -11,6 +18,12 @@ namespace HyperTwistClassicCubeHUDWidgetInternal { + const FLinearColor PanelColor(0.012f, 0.025f, 0.045f, 0.94f); + const FLinearColor AccentColor(0.15f, 0.90f, 0.95f, 1.0f); + const FLinearColor PrimaryTextColor(0.92f, 0.97f, 1.0f, 1.0f); + const FLinearColor MutedTextColor(0.58f, 0.70f, 0.78f, 1.0f); + const FLinearColor ButtonColor(0.04f, 0.16f, 0.23f, 1.0f); + UObject* ResolveOperatorSurfaceObject(const UUserWidget* Widget) { if (Widget == nullptr || Widget->GetWorld() == nullptr) @@ -29,12 +42,41 @@ namespace HyperTwistClassicCubeHUDWidgetInternal } } +void UHyperTwistClassicCubeHUDWidget::NativeOnInitialized() +{ + Super::NativeOnInitialized(); + EnsureWidgetTreeBuilt(); +} + void UHyperTwistClassicCubeHUDWidget::NativeConstruct() { Super::NativeConstruct(); EnsureWidgetTreeBuilt(); } +TSharedRef UHyperTwistClassicCubeHUDWidget::RebuildWidget() +{ + Initialize(); + EnsureWidgetTreeBuilt(); + return Super::RebuildWidget(); +} + +bool UHyperTwistClassicCubeHUDWidget::PrepareClassicCubeHudSurface() +{ + EnsureWidgetTreeBuilt(); + return IsClassicCubeHudSurfaceReady(); +} + +bool UHyperTwistClassicCubeHUDWidget::IsClassicCubeHudSurfaceReady() const +{ + return WidgetTree != nullptr + && WidgetTree->RootWidget != nullptr + && StatusTextBlock != nullptr + && TimerTextBlock != nullptr + && NewScrambleButton != nullptr + && HintButton != nullptr; +} + void UHyperTwistClassicCubeHUDWidget::SetHudLines( const FString& InStatusLine, const FString& InScrambleLine, @@ -147,64 +189,132 @@ void UHyperTwistClassicCubeHUDWidget::EnsureWidgetTreeBuilt() return; } - UVerticalBox* RootLayout = - WidgetTree->ConstructWidget(UVerticalBox::StaticClass(), TEXT("ClassicCubeHudRoot")); - WidgetTree->RootWidget = RootLayout; + UHorizontalBox* ViewportLayout = WidgetTree->ConstructWidget( + UHorizontalBox::StaticClass(), + TEXT("ClassicCubeHudRoot") + ); + ViewportLayout->SetVisibility(ESlateVisibility::SelfHitTestInvisible); + WidgetTree->RootWidget = ViewportLayout; - StatusTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudStatus")); - ScrambleTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudScramble")); - TimerTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudTimer")); - InspectionTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudInspection")); - ResultTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudResult")); - HintTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudHint")); - SolutionTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudSolution")); - ReplayTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudReplay")); - LeaderboardTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudLeaderboard")); - ModeTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudMode")); - VoiceTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudVoice")); - ControlsTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudControls")); + USizeBox* PanelSize = WidgetTree->ConstructWidget( + USizeBox::StaticClass(), + TEXT("ClassicCubeHudPanelSize") + ); + PanelSize->SetWidthOverride(520.0f); + PanelSize->SetMaxDesiredHeight(1000.0f); + if (UHorizontalBoxSlot* PanelSlot = ViewportLayout->AddChildToHorizontalBox(PanelSize)) + { + PanelSlot->SetPadding(FMargin(18.0f)); + PanelSlot->SetVerticalAlignment(VAlign_Top); + } + + UBorder* PanelBorder = WidgetTree->ConstructWidget( + UBorder::StaticClass(), + TEXT("ClassicCubeHudPanel") + ); + PanelBorder->SetPadding(FMargin(18.0f)); + PanelBorder->SetBrushColor(HyperTwistClassicCubeHUDWidgetInternal::PanelColor); + PanelSize->AddChild(PanelBorder); + + UScrollBox* PanelScroll = WidgetTree->ConstructWidget( + UScrollBox::StaticClass(), + TEXT("ClassicCubeHudScroll") + ); + PanelBorder->AddChild(PanelScroll); + + UVerticalBox* RootLayout = WidgetTree->ConstructWidget( + UVerticalBox::StaticClass(), + TEXT("ClassicCubeHudContent") + ); + PanelScroll->AddChild(RootLayout); + + UTextBlock* TitleText = AddLine( + RootLayout, + TEXT("ClassicCubeHudTitle"), + 20, + HyperTwistClassicCubeHUDWidgetInternal::AccentColor + ); + if (TitleText != nullptr) + { + TitleText->SetText(FText::FromString(TEXT("CLASSIC CUBE // TRAINING"))); + } + + StatusTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudStatus"), 15, HyperTwistClassicCubeHUDWidgetInternal::PrimaryTextColor); + TimerTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudTimer"), 27, HyperTwistClassicCubeHUDWidgetInternal::PrimaryTextColor); + InspectionTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudInspection"), 14, HyperTwistClassicCubeHUDWidgetInternal::AccentColor); + ScrambleTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudScramble"), 13, HyperTwistClassicCubeHUDWidgetInternal::MutedTextColor); + ResultTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudResult"), 14, HyperTwistClassicCubeHUDWidgetInternal::PrimaryTextColor); + HintTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudHint"), 13, HyperTwistClassicCubeHUDWidgetInternal::MutedTextColor); + SolutionTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudSolution"), 13, HyperTwistClassicCubeHUDWidgetInternal::MutedTextColor); + ModeTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudMode"), 13, HyperTwistClassicCubeHUDWidgetInternal::PrimaryTextColor); + ReplayTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudReplay"), 12, HyperTwistClassicCubeHUDWidgetInternal::MutedTextColor); + LeaderboardTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudLeaderboard"), 12, HyperTwistClassicCubeHUDWidgetInternal::MutedTextColor); + VoiceTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudVoice"), 12, HyperTwistClassicCubeHUDWidgetInternal::MutedTextColor); + ControlsTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudControls"), 12, HyperTwistClassicCubeHUDWidgetInternal::MutedTextColor); + + UUniformGridPanel* ActionGrid = WidgetTree->ConstructWidget( + UUniformGridPanel::StaticClass(), + TEXT("ClassicCubeHudActionGrid") + ); + ActionGrid->SetMinDesiredSlotWidth(220.0f); + if (UVerticalBoxSlot* GridSlot = RootLayout->AddChildToVerticalBox(ActionGrid)) + { + GridSlot->SetPadding(FMargin(0.0f, 12.0f, 0.0f, 0.0f)); + } NewScrambleButton = CreateActionButton( - RootLayout, + ActionGrid, TEXT("ClassicCubeHudNewScrambleButton"), TEXT("ClassicCubeHudNewScrambleButtonLabel"), NewScrambleButtonLabel, - TEXT("New Scramble") + TEXT("New Scramble"), + 0, + 0 ); HintButton = CreateActionButton( - RootLayout, + ActionGrid, TEXT("ClassicCubeHudHintButton"), TEXT("ClassicCubeHudHintButtonLabel"), HintButtonLabel, - TEXT("Hint") + TEXT("Hint"), + 0, + 1 ); SubmitSolveButton = CreateActionButton( - RootLayout, + ActionGrid, TEXT("ClassicCubeHudSubmitSolveButton"), TEXT("ClassicCubeHudSubmitSolveButtonLabel"), SubmitSolveButtonLabel, - TEXT("Submit Solve") + TEXT("Submit Solve"), + 1, + 0 ); ModeToggleButton = CreateActionButton( - RootLayout, + ActionGrid, TEXT("ClassicCubeHudModeToggleButton"), TEXT("ClassicCubeHudModeToggleButtonLabel"), ModeToggleButtonLabel, - TEXT("Switch To Follow Along") + TEXT("Follow Along"), + 1, + 1 ); VoiceHoldButton = CreateActionButton( - RootLayout, + ActionGrid, TEXT("ClassicCubeHudVoiceHoldButton"), TEXT("ClassicCubeHudVoiceHoldButtonLabel"), VoiceHoldButtonLabel, - TEXT("Hold To Talk") + TEXT("Hold To Talk"), + 2, + 0 ); VoiceCycleButton = CreateActionButton( - RootLayout, + ActionGrid, TEXT("ClassicCubeHudVoiceCycleButton"), TEXT("ClassicCubeHudVoiceCycleButtonLabel"), VoiceCycleButtonLabel, - TEXT("Voice") + TEXT("Voice"), + 2, + 1 ); if (NewScrambleButton != nullptr) @@ -249,7 +359,12 @@ void UHyperTwistClassicCubeHUDWidget::EnsureWidgetTreeBuilt() ); } -UTextBlock* UHyperTwistClassicCubeHUDWidget::AddLine(UVerticalBox* Parent, const TCHAR* WidgetName) +UTextBlock* UHyperTwistClassicCubeHUDWidget::AddLine( + UVerticalBox* Parent, + const TCHAR* WidgetName, + const int32 FontSize, + const FLinearColor& Color +) { if (WidgetTree == nullptr || Parent == nullptr) { @@ -262,6 +377,11 @@ UTextBlock* UHyperTwistClassicCubeHUDWidget::AddLine(UVerticalBox* Parent, const { return nullptr; } + TextBlock->SetAutoWrapText(true); + TextBlock->SetColorAndOpacity(FSlateColor(Color)); + FSlateFontInfo Font = TextBlock->GetFont(); + Font.Size = FontSize; + TextBlock->SetFont(Font); if (UVerticalBoxSlot* VerticalBoxSlot = Parent->AddChildToVerticalBox(TextBlock)) { @@ -272,11 +392,13 @@ UTextBlock* UHyperTwistClassicCubeHUDWidget::AddLine(UVerticalBox* Parent, const } UButton* UHyperTwistClassicCubeHUDWidget::CreateActionButton( - UVerticalBox* Parent, + UUniformGridPanel* Parent, const TCHAR* ButtonName, const TCHAR* LabelName, TObjectPtr& OutLabel, - const FString& InitialLabel + const FString& InitialLabel, + const int32 Row, + const int32 Column ) { if (WidgetTree == nullptr || Parent == nullptr) @@ -292,18 +414,22 @@ UButton* UHyperTwistClassicCubeHUDWidget::CreateActionButton( { return nullptr; } + ActionButton->SetBackgroundColor(HyperTwistClassicCubeHUDWidgetInternal::ButtonColor); OutLabel = WidgetTree->ConstructWidget(UTextBlock::StaticClass(), LabelName); if (OutLabel != nullptr) { OutLabel->SetText(FText::FromString(InitialLabel)); + OutLabel->SetAutoWrapText(true); + OutLabel->SetJustification(ETextJustify::Center); + OutLabel->SetColorAndOpacity(FSlateColor(HyperTwistClassicCubeHUDWidgetInternal::PrimaryTextColor)); + FSlateFontInfo Font = OutLabel->GetFont(); + Font.Size = 13; + OutLabel->SetFont(Font); ActionButton->AddChild(OutLabel); } - if (UVerticalBoxSlot* VerticalBoxSlot = Parent->AddChildToVerticalBox(ActionButton)) - { - VerticalBoxSlot->SetPadding(FMargin(0.0f, 10.0f, 0.0f, 0.0f)); - } + Parent->AddChildToUniformGrid(ActionButton, Row, Column); if (UButtonSlot* ButtonSlot = Cast(OutLabel != nullptr ? OutLabel->Slot : nullptr)) { diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSolverLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSolverLibrary.cpp index 62fb69d..a4ee25e 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSolverLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSolverLibrary.cpp @@ -1,16 +1,9 @@ #include "HyperTwistSolverLibrary.h" -#include "HAL/PlatformFilemanager.h" +#include "HAL/FileManager.h" #include "HAL/PlatformMisc.h" +#include "HAL/PlatformTime.h" #include "Misc/Paths.h" -#include "Misc/FileHelper.h" - -#if PLATFORM_WINDOWS -#include -#define chdir _chdir -#else -#include -#endif #include "ThirdParty/rob-twophase/face.h" #include "ThirdParty/rob-twophase/move.h" @@ -20,10 +13,13 @@ #include "ThirdParty/rob-twophase/prun.h" #include "ThirdParty/rob-twophase/solve.h" +#include #include -static bool bSolverInitialized = false; +static std::atomic_bool bSolverInitialized{false}; +static std::once_flag CoreTablesInitOnceFlag; static std::once_flag InitOnceFlag; +static std::mutex SolverInvocationMutex; namespace { @@ -55,41 +51,77 @@ namespace return false; } + + void EnsureCoreTablesInitialized() + { + std::call_once(CoreTablesInitOnceFlag, []() + { + face::init(); + move::init(); + }); + } } static void DoInitSolver() { - // rob-twophase saves/loads pruning tables from the current working directory. - // Temporarily switch to ProjectSavedDir so tables are persisted across runs. - FString SavedDir = FPaths::ProjectSavedDir(); - FString OriginalDir = FPaths::ProjectDir(); - - if (FPaths::DirectoryExists(SavedDir)) + const FString SavedDirectory = FPaths::ConvertRelativePathToFull(FPaths::ProjectSavedDir()); + if (!IFileManager::Get().MakeDirectory(*SavedDirectory, true)) { - chdir(TCHAR_TO_UTF8(*SavedDir)); + UE_LOG( + LogTemp, + Error, + TEXT("HyperTwistSolver: Could not create solver cache directory '%s'."), + *SavedDirectory); + return; } - face::init(); - move::init(); + const FString PruningTablePath = FPaths::Combine( + SavedDirectory, + UTF8_TO_TCHAR(prun::SAVE.c_str())); + const bool bExistingTable = IFileManager::Get().FileSize(*PruningTablePath) > 0; + const double InitializationStartedAt = FPlatformTime::Seconds(); + UE_LOG( + LogTemp, + Display, + TEXT("HyperTwistSolver: %s pruning table at '%s'."), + bExistingTable ? TEXT("Loading") : TEXT("Generating first-run"), + *PruningTablePath); + + EnsureCoreTablesInitialized(); coord::init(); sym::init(); - const bool bPruningTablesReady = prun::init(true); // true = try to load tables, generate if missing - - // Restore working directory - chdir(TCHAR_TO_UTF8(*OriginalDir)); - - bSolverInitialized = bPruningTablesReady; + const bool bPruningTablesReady = prun::init( + true, + TCHAR_TO_UTF8(*PruningTablePath)); + bSolverInitialized.store(bPruningTablesReady, std::memory_order_release); + if (bPruningTablesReady) + { + UE_LOG( + LogTemp, + Display, + TEXT("HyperTwistSolver: Pruning table ready in %.2f seconds (%lld bytes)."), + FPlatformTime::Seconds() - InitializationStartedAt, + IFileManager::Get().FileSize(*PruningTablePath)); + } + else + { + UE_LOG( + LogTemp, + Error, + TEXT("HyperTwistSolver: Pruning table failed after %.2f seconds."), + FPlatformTime::Seconds() - InitializationStartedAt); + } } bool UHyperTwistSolverLibrary::IsSolverInitialized() { - return bSolverInitialized; + return bSolverInitialized.load(std::memory_order_acquire); } bool UHyperTwistSolverLibrary::InitializeSolver() { std::call_once(InitOnceFlag, DoInitSolver); - return bSolverInitialized; + return bSolverInitialized.load(std::memory_order_acquire); } TArray UHyperTwistSolverLibrary::SolveClassicState( @@ -99,6 +131,7 @@ TArray UHyperTwistSolverLibrary::SolveClassicState( int32 NumSolutions ) { + const std::lock_guard SolverLock(SolverInvocationMutex); TArray Result; if (!InitializeSolver()) @@ -171,6 +204,7 @@ bool UHyperTwistSolverLibrary::VerifyFaceletString(const FString& FaceletString) return false; } + EnsureCoreTablesInitialized(); std::string Facelets(TCHAR_TO_UTF8(*FaceletString)); cubie::cube Cube; return face::to_cubie(Facelets, Cube) == 0; @@ -181,6 +215,7 @@ bool UHyperTwistSolverLibrary::VerifySolution( const TArray& Moves ) { + const std::lock_guard SolverLock(SolverInvocationMutex); if (!InitializeSolver() || !VerifyFaceletString(FaceletString)) { return false; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardActor.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardActor.cpp index 1d594fa..26ba686 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardActor.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardActor.cpp @@ -1,8 +1,11 @@ #include "HyperTwistTraining/HyperTwistCoachDashboardActor.h" #include "Blueprint/UserWidget.h" +#include "GameFramework/PlayerController.h" #include "HyperTwistTraining/HyperTwistCoachDashboardWidget.h" +DEFINE_LOG_CATEGORY_STATIC(LogHyperTwistCoachDashboardUi, Log, All); + AHyperTwistCoachDashboardActor::AHyperTwistCoachDashboardActor() { PrimaryActorTick.bCanEverTick = false; @@ -42,13 +45,36 @@ UHyperTwistCoachDashboardWidget* AHyperTwistCoachDashboardActor::CreateAndShowDa return nullptr; } - ActiveDashboardWidget = CreateWidget(GetWorld(), ResolvedWidgetClass); + if (APlayerController* PlayerController = GetWorld()->GetFirstPlayerController()) + { + ActiveDashboardWidget = CreateWidget( + PlayerController, + ResolvedWidgetClass + ); + } + else + { + ActiveDashboardWidget = CreateWidget( + GetWorld(), + ResolvedWidgetClass + ); + } if (ActiveDashboardWidget == nullptr) { return nullptr; } ApplyDashboardDefaults(ActiveDashboardWidget); + if (!ActiveDashboardWidget->PrepareCoachDashboardSurface()) + { + UE_LOG( + LogHyperTwistCoachDashboardUi, + Error, + TEXT("Coach dashboard creation produced no renderable root widget.") + ); + ActiveDashboardWidget = nullptr; + return nullptr; + } ActiveDashboardWidget->AddToViewport(DashboardZOrder); if (bRefreshDashboardOnCreate) diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp index 1354320..e18d711 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp @@ -6,11 +6,13 @@ #include "HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingSubsystem.h" #include "Blueprint/WidgetTree.h" +#include "Components/Border.h" #include "Components/Button.h" #include "Components/ButtonSlot.h" #include "Components/EditableTextBox.h" #include "Components/HorizontalBox.h" #include "Components/HorizontalBoxSlot.h" +#include "Components/ScrollBox.h" #include "Components/TextBlock.h" #include "Components/VerticalBox.h" #include "Components/VerticalBoxSlot.h" @@ -4543,6 +4545,43 @@ namespace HyperTwistCoachDashboardWidgetInternal Slot->SetPadding(FMargin(0.0f, 0.0f, 0.0f, static_cast(PaddingBottom))); } TextBlock->SetAutoWrapText(true); + TextBlock->SetLineHeightPercentage(1.05f); + + const FString Name = WidgetName.ToString(); + int32 FontSize = 12; + FLinearColor TextColor(0.69f, 0.75f, 0.81f, 1.0f); + bool bUseEmphasis = false; + if (Name.Contains(TEXT("Headline"))) + { + FontSize = 20; + TextColor = FLinearColor(0.93f, 0.97f, 1.0f, 1.0f); + bUseEmphasis = true; + } + else if (Name.Contains(TEXT("Title"))) + { + FontSize = 13; + TextColor = FLinearColor(0.04f, 0.93f, 0.89f, 1.0f); + bUseEmphasis = true; + } + else if (Name.EndsWith(TEXT("Header"))) + { + FontSize = 13; + TextColor = FLinearColor(0.32f, 0.86f, 1.0f, 1.0f); + bUseEmphasis = true; + } + else if (Name.Contains(TEXT("Status")) || Name.Contains(TEXT("Focus"))) + { + TextColor = FLinearColor(0.86f, 0.91f, 0.95f, 1.0f); + } + + FSlateFontInfo Font = TextBlock->GetFont(); + Font.Size = FontSize; + if (bUseEmphasis) + { + Font.TypefaceFontName = FName(TEXT("Bold")); + } + TextBlock->SetFont(Font); + TextBlock->SetColorAndOpacity(FSlateColor(TextColor)); return TextBlock; } @@ -5808,6 +5847,12 @@ namespace HyperTwistCoachDashboardWidgetInternal } } +void UHyperTwistCoachDashboardWidget::NativeOnInitialized() +{ + Super::NativeOnInitialized(); + EnsureDefaultDashboardBuilt(); +} + void UHyperTwistCoachDashboardWidget::NativeConstruct() { EnsureDefaultDashboardBuilt(); @@ -5815,6 +5860,24 @@ void UHyperTwistCoachDashboardWidget::NativeConstruct() RefreshCoachDashboardView(); } +TSharedRef UHyperTwistCoachDashboardWidget::RebuildWidget() +{ + Initialize(); + EnsureDefaultDashboardBuilt(); + return Super::RebuildWidget(); +} + +bool UHyperTwistCoachDashboardWidget::PrepareCoachDashboardSurface() +{ + EnsureDefaultDashboardBuilt(); + return IsCoachDashboardSurfaceReady(); +} + +bool UHyperTwistCoachDashboardWidget::IsCoachDashboardSurfaceReady() const +{ + return WidgetTree != nullptr && WidgetTree->RootWidget != nullptr; +} + void UHyperTwistCoachDashboardWidget::NativeTick(const FGeometry& MyGeometry, float InDeltaTime) { Super::NativeTick(MyGeometry, InDeltaTime); @@ -18225,13 +18288,38 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt() return; } - RootLayout = WidgetTree->ConstructWidget(UVerticalBox::StaticClass(), TEXT("CoachDashboardRoot")); - if (RootLayout == nullptr) + UBorder* RootBorder = WidgetTree->ConstructWidget( + UBorder::StaticClass(), + TEXT("CoachDashboardRoot") + ); + UScrollBox* RootScroll = WidgetTree->ConstructWidget( + UScrollBox::StaticClass(), + TEXT("CoachDashboardScroll") + ); + RootLayout = WidgetTree->ConstructWidget( + UVerticalBox::StaticClass(), + TEXT("CoachDashboardContent") + ); + if (RootBorder == nullptr || RootScroll == nullptr || RootLayout == nullptr) { return; } - WidgetTree->RootWidget = RootLayout; + RootBorder->SetPadding(FMargin(24.0f)); + RootBorder->SetBrushColor(FLinearColor(0.008f, 0.014f, 0.024f, 0.97f)); + RootScroll->AddChild(RootLayout); + RootBorder->AddChild(RootScroll); + WidgetTree->RootWidget = RootBorder; + + if (UTextBlock* DashboardTitleBlock = HyperTwistCoachDashboardWidgetInternal::AddTextRow( + WidgetTree, + RootLayout, + TEXT("CoachDashboardTitle"), + 8 + )) + { + DashboardTitleBlock->SetText(FText::FromString(TEXT("HYPERTWIST // COACH DASHBOARD"))); + } HeadlineTextBlock = HyperTwistCoachDashboardWidgetInternal::AddTextRow( WidgetTree, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistFirstRunLaunchLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistFirstRunLaunchLibrary.cpp index b01bb7e..2f6a5a3 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistFirstRunLaunchLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistFirstRunLaunchLibrary.cpp @@ -1,10 +1,13 @@ #include "HyperTwistTraining/HyperTwistFirstRunLaunchLibrary.h" +#include "Misc/CommandLine.h" + 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* PackagedStartupRouteArgumentName = TEXT("HyperTwistStartupRoute"); const TCHAR* FirstRunGameModeClassPath = TEXT("/Script/UnrealHyperTwist.HyperTwistFirstRunLaunchGameMode"); const TCHAR* CoachDashboardGameModeClassPath = @@ -13,6 +16,8 @@ namespace HyperTwistFirstRunLaunchLibraryInternal TEXT("/Script/UnrealHyperTwist.HyperTwistClassicCubeGameMode"); const TCHAR* ClassicCubeFollowAlongGameModeClassPath = TEXT("/Script/UnrealHyperTwist.HyperTwistClassicCubeFollowAlongGameMode"); + const TCHAR* HigherDimensionalTrainingGameModeClassPath = + TEXT("/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingGameMode"); const TCHAR* XrTrainingGameModeClassPath = TEXT("/Script/UnrealHyperTwist.HyperTwistXrTrainingGameMode"); const TCHAR* ClassicCubeMapPath = @@ -23,7 +28,7 @@ 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* RuntimeLogFileName = TEXT("HyperTwistRuntime-latest.log"); const TCHAR* StartupDiagnosticsLogFileName = TEXT("HyperTwistFirstRunLaunch-latest.log"); FString BuildLaunchUrl(const FString& MapAssetPath, const FString& GameModeClassPath) @@ -115,6 +120,11 @@ FString UHyperTwistFirstRunLaunchLibrary::GetPackagedStartupMapAssetPath() return HyperTwistFirstRunLaunchLibraryInternal::ClassicCubeMapPath; } +FString UHyperTwistFirstRunLaunchLibrary::GetPackagedStartupRouteArgumentName() +{ + return HyperTwistFirstRunLaunchLibraryInternal::PackagedStartupRouteArgumentName; +} + FString UHyperTwistFirstRunLaunchLibrary::GetRuntimeLogFileName() { return HyperTwistFirstRunLaunchLibraryInternal::RuntimeLogFileName; @@ -133,7 +143,7 @@ TArray UHyperTwistFirstRunLaunchLibrary::BuildFirstRunGuidanceLines() 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("If first launch stalls, inspect Saved/Logs/UnrealHyperTwist.log and Saved/Logs/HyperTwistFirstRunLaunch-latest.log."), + TEXT("If first launch stalls, inspect Saved/Logs/HyperTwistRuntime-latest.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.") }; } @@ -197,7 +207,7 @@ UHyperTwistFirstRunLaunchLibrary::BuildFirstRunLaunchRoutes() TEXT("Magic120Cell Training"), TEXT("Launch the family-owned 120-cell runtime-state, projection, persistence, and dedicated-map training surface."), Magic120CellMapPath, - CoachDashboardGameModeClassPath, + HigherDimensionalTrainingGameModeClassPath, TEXT("keyboard-mouse-higher-dimensional-training"), TEXT("ready-dedicated-family-map"), false, @@ -213,7 +223,7 @@ UHyperTwistFirstRunLaunchLibrary::BuildFirstRunLaunchRoutes() TEXT("MagicCube5D Training"), TEXT("Launch the family-owned 5D runtime-state, projection, persistence, and dedicated-map training surface."), MagicCube5DMapPath, - CoachDashboardGameModeClassPath, + HigherDimensionalTrainingGameModeClassPath, TEXT("keyboard-mouse-higher-dimensional-training"), TEXT("ready-dedicated-family-map"), false, @@ -260,3 +270,112 @@ bool UHyperTwistFirstRunLaunchLibrary::TryFindFirstRunLaunchRoute( OutRoute = FHyperTwistFirstRunLaunchRoute(); return false; } + +EHyperTwistPackagedStartupRouteParseResult +UHyperTwistFirstRunLaunchLibrary::ParsePackagedStartupRouteArgument( + const TCHAR* CommandLine, + FHyperTwistFirstRunLaunchRoute& OutRoute, + FString& OutFailureReason +) +{ + using namespace HyperTwistFirstRunLaunchLibraryInternal; + + OutRoute = FHyperTwistFirstRunLaunchRoute(); + OutFailureReason.Reset(); + if (CommandLine == nullptr || CommandLine[0] == TEXT('\0')) + { + return EHyperTwistPackagedStartupRouteParseResult::NotPresent; + } + + TArray Tokens; + TArray Switches; + FCommandLine::Parse(CommandLine, Tokens, Switches); + + const FString ArgumentName(PackagedStartupRouteArgumentName); + const FString ArgumentPrefix = ArgumentName + TEXT("="); + TArray RouteValues; + for (FString Switch : Switches) + { + while (Switch.StartsWith(TEXT("-"))) + { + Switch.RightChopInline(1, EAllowShrinking::No); + } + + if (Switch.Equals(ArgumentName, ESearchCase::IgnoreCase)) + { + RouteValues.Add(FString()); + } + else if (Switch.StartsWith(ArgumentPrefix, ESearchCase::IgnoreCase)) + { + RouteValues.Add(Switch.Mid(ArgumentPrefix.Len())); + } + } + + if (RouteValues.IsEmpty()) + { + return EHyperTwistPackagedStartupRouteParseResult::NotPresent; + } + if (RouteValues.Num() != 1) + { + OutFailureReason = FString::Printf( + TEXT("Expected exactly one -%s argument, but received %d."), + PackagedStartupRouteArgumentName, + RouteValues.Num() + ); + return EHyperTwistPackagedStartupRouteParseResult::Invalid; + } + + FString RouteId = RouteValues[0].TrimStartAndEnd(); + if (RouteId.Len() >= 2 + && RouteId.StartsWith(TEXT("\"")) + && RouteId.EndsWith(TEXT("\""))) + { + RouteId = RouteId.Mid(1, RouteId.Len() - 2).TrimStartAndEnd(); + } + if (RouteId.IsEmpty()) + { + OutFailureReason = FString::Printf( + TEXT("-%s requires a registered route identifier."), + PackagedStartupRouteArgumentName + ); + return EHyperTwistPackagedStartupRouteParseResult::Invalid; + } + if (RouteId.Len() > 64) + { + OutFailureReason = FString::Printf( + TEXT("-%s exceeded the 64-character route identifier limit."), + PackagedStartupRouteArgumentName + ); + return EHyperTwistPackagedStartupRouteParseResult::Invalid; + } + + for (const TCHAR Character : RouteId) + { + const bool bAllowedCharacter = + (Character >= TEXT('a') && Character <= TEXT('z')) + || (Character >= TEXT('0') && Character <= TEXT('9')) + || Character == TEXT('-'); + if (!bAllowedCharacter) + { + OutFailureReason = FString::Printf( + TEXT("-%s contains an invalid route identifier."), + PackagedStartupRouteArgumentName + ); + return EHyperTwistPackagedStartupRouteParseResult::Invalid; + } + } + + if (!TryFindFirstRunLaunchRoute(RouteId, OutRoute) + || !OutRoute.IsStructurallyValid()) + { + OutRoute = FHyperTwistFirstRunLaunchRoute(); + OutFailureReason = FString::Printf( + TEXT("-%s requested unknown route '%s'."), + PackagedStartupRouteArgumentName, + *RouteId + ); + return EHyperTwistPackagedStartupRouteParseResult::Invalid; + } + + return EHyperTwistPackagedStartupRouteParseResult::Valid; +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistFirstRunLaunchPlayerController.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistFirstRunLaunchPlayerController.cpp index 02d082f..963b866 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistFirstRunLaunchPlayerController.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistFirstRunLaunchPlayerController.cpp @@ -3,12 +3,15 @@ #include "Blueprint/UserWidget.h" #include "Engine/Engine.h" #include "EngineUtils.h" +#include "HyperTwistDiagnostics/HyperTwistRuntimeDiagnostics.h" #include "HyperTwistTraining/HyperTwistCoachDashboardActor.h" #include "HyperTwistTraining/HyperTwistFirstRunLaunchLibrary.h" #include "HyperTwistTraining/HyperTwistFirstRunLaunchWidget.h" #include "Kismet/GameplayStatics.h" +#include "Misc/CommandLine.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" +#include "TimerManager.h" DEFINE_LOG_CATEGORY_STATIC(LogHyperTwistFirstRunLaunch, Log, All); @@ -30,6 +33,11 @@ void AHyperTwistFirstRunLaunchPlayerController::BeginPlay() ); ApplyFirstRunInputMode(); + if (TryOpenCommandLineStartupRoute()) + { + return; + } + if (bShowFirstRunLaunchOnBeginPlay) { if (ShowFirstRunLaunchMenu() == nullptr) @@ -38,7 +46,6 @@ void AHyperTwistFirstRunLaunchPlayerController::BeginPlay() return; } - PersistStartupDiagnostics(TEXT("launch-menu-visible")); return; } @@ -49,17 +56,90 @@ void AHyperTwistFirstRunLaunchPlayerController::BeginPlay() PersistStartupDiagnostics(TEXT("launch-menu-disabled")); } +bool AHyperTwistFirstRunLaunchPlayerController::TryOpenCommandLineStartupRoute() +{ + FHyperTwistFirstRunLaunchRoute Route; + FString FailureReason; + const EHyperTwistPackagedStartupRouteParseResult ParseResult = + UHyperTwistFirstRunLaunchLibrary::ParsePackagedStartupRouteArgument( + FCommandLine::Get(), + Route, + FailureReason + ); + if (ParseResult == EHyperTwistPackagedStartupRouteParseResult::NotPresent) + { + return false; + } + if (ParseResult == EHyperTwistPackagedStartupRouteParseResult::Invalid) + { + const FString Message = FString::Printf( + TEXT("Rejected packaged startup-route override: %s Continuing with the visible first-run menu."), + FailureReason.IsEmpty() ? TEXT("invalid argument.") : *FailureReason + ); + AppendStartupDiagnosticsLine(TEXT("PackagedStartupRoute"), Message, true); + HyperTwistRuntimeDiagnostics::AppendEvent(TEXT("PackagedStartupRoute"), Message); + PersistStartupDiagnostics(TEXT("command-line-route-rejected")); + return false; + } + + const FString AcceptedMessage = FString::Printf( + TEXT("Accepted packaged startup route '%s': map='%s', game_mode='%s'."), + *Route.RouteId, + Route.MapAssetPath.IsEmpty() ? TEXT("") : *Route.MapAssetPath, + Route.GameModeClassPath.IsEmpty() ? TEXT("") : *Route.GameModeClassPath + ); + AppendStartupDiagnosticsLine(TEXT("PackagedStartupRoute"), AcceptedMessage); + HyperTwistRuntimeDiagnostics::AppendEvent(TEXT("PackagedStartupRoute"), AcceptedMessage); + if (OpenFirstRunRoute(Route.RouteId)) + { + const FString OpenedMessage = FString::Printf( + TEXT("Opened packaged startup route '%s' through first-run product authority."), + *Route.RouteId + ); + AppendStartupDiagnosticsLine(TEXT("PackagedStartupRoute"), OpenedMessage); + HyperTwistRuntimeDiagnostics::AppendEvent(TEXT("PackagedStartupRoute"), OpenedMessage); + PersistStartupDiagnostics(TEXT("command-line-route-opened")); + return true; + } + + const FString OpenFailureMessage = FString::Printf( + TEXT("Could not open packaged startup route '%s'; continuing with the visible first-run menu."), + *Route.RouteId + ); + AppendStartupDiagnosticsLine(TEXT("PackagedStartupRoute"), OpenFailureMessage, true); + HyperTwistRuntimeDiagnostics::AppendEvent( + TEXT("PackagedStartupRoute"), + OpenFailureMessage + ); + PersistStartupDiagnostics(TEXT("command-line-route-open-failed")); + return false; +} + UHyperTwistFirstRunLaunchWidget* AHyperTwistFirstRunLaunchPlayerController::ShowFirstRunLaunchMenu() { if (ActiveFirstRunLaunchWidget != nullptr) { ActiveFirstRunLaunchWidget->RebuildFirstRunLaunchSurface(); + if (!ActiveFirstRunLaunchWidget->IsFirstRunLaunchSurfaceReady()) + { + AppendStartupDiagnosticsLine( + TEXT("ShowFirstRunLaunchMenu"), + TEXT("The existing first-run widget has no renderable root tree."), + true + ); + PersistStartupDiagnostics(TEXT("launch-widget-root-missing")); + return nullptr; + } + if (!ActiveFirstRunLaunchWidget->IsInViewport()) + { + ActiveFirstRunLaunchWidget->AddToViewport(FirstRunLaunchZOrder); + } AppendStartupDiagnosticsLine( TEXT("ShowFirstRunLaunchMenu"), - TEXT("Rebuilt the existing first-run launch widget.") + TEXT("Prepared the existing first-run launch widget for layout validation.") ); - PersistStartupDiagnostics(TEXT("launch-menu-rebuilt")); + ScheduleFirstRunSurfaceValidation(); return ActiveFirstRunLaunchWidget; } @@ -102,6 +182,19 @@ AHyperTwistFirstRunLaunchPlayerController::ShowFirstRunLaunchMenu() this, &AHyperTwistFirstRunLaunchPlayerController::HandleFirstRunRouteRequested ); + ActiveFirstRunLaunchWidget->RebuildFirstRunLaunchSurface(); + if (!ActiveFirstRunLaunchWidget->IsFirstRunLaunchSurfaceReady()) + { + AppendStartupDiagnosticsLine( + TEXT("ShowFirstRunLaunchMenu"), + TEXT("The first-run widget could not construct its renderable root tree."), + true + ); + ActiveFirstRunLaunchWidget = nullptr; + PersistStartupDiagnostics(TEXT("launch-widget-root-missing")); + return nullptr; + } + ActiveFirstRunLaunchWidget->AddToViewport(FirstRunLaunchZOrder); AppendStartupDiagnosticsLine( TEXT("ShowFirstRunLaunchMenu"), @@ -111,7 +204,7 @@ AHyperTwistFirstRunLaunchPlayerController::ShowFirstRunLaunchMenu() ) ); ApplyFirstRunInputMode(); - PersistStartupDiagnostics(TEXT("launch-widget-added")); + ScheduleFirstRunSurfaceValidation(); return ActiveFirstRunLaunchWidget; } @@ -119,6 +212,8 @@ void AHyperTwistFirstRunLaunchPlayerController::RemoveFirstRunLaunchMenu() { if (ActiveFirstRunLaunchWidget != nullptr) { + GetWorldTimerManager().ClearTimer(FirstRunSurfaceValidationTimerHandle); + FirstRunSurfaceValidationAttempt = 0; ActiveFirstRunLaunchWidget->RemoveFromParent(); ActiveFirstRunLaunchWidget = nullptr; AppendStartupDiagnosticsLine( @@ -486,6 +581,72 @@ bool AHyperTwistFirstRunLaunchPlayerController::TryRecoverFromFirstRunFailure( return bRecovered; } +void AHyperTwistFirstRunLaunchPlayerController::ScheduleFirstRunSurfaceValidation() +{ + FirstRunSurfaceValidationAttempt = 0; + GetWorldTimerManager().ClearTimer(FirstRunSurfaceValidationTimerHandle); + GetWorldTimerManager().SetTimer( + FirstRunSurfaceValidationTimerHandle, + this, + &AHyperTwistFirstRunLaunchPlayerController::ValidateFirstRunSurfaceLayout, + FMath::Max(FirstRunSurfaceValidationIntervalSeconds, 0.01f), + false + ); + PersistStartupDiagnostics(TEXT("launch-widget-added-pending-layout")); +} + +void AHyperTwistFirstRunLaunchPlayerController::ValidateFirstRunSurfaceLayout() +{ + if (ActiveFirstRunLaunchWidget == nullptr) + { + return; + } + + ++FirstRunSurfaceValidationAttempt; + const FVector2D LocalSize = ActiveFirstRunLaunchWidget->GetCachedGeometry().GetLocalSize(); + const bool bHasRenderableLayout = ActiveFirstRunLaunchWidget->IsInViewport() + && ActiveFirstRunLaunchWidget->IsVisible() + && ActiveFirstRunLaunchWidget->IsFirstRunLaunchSurfaceReady() + && LocalSize.X > 1.0f + && LocalSize.Y > 1.0f; + if (bHasRenderableLayout) + { + AppendStartupDiagnosticsLine( + TEXT("ValidateFirstRunSurfaceLayout"), + FString::Printf( + TEXT("Confirmed a renderable first-run layout at %.0fx%.0f after %d attempt(s)."), + LocalSize.X, + LocalSize.Y, + FirstRunSurfaceValidationAttempt + ) + ); + PersistStartupDiagnostics(TEXT("launch-menu-renderable")); + HyperTwistRuntimeDiagnostics::RequestReadyFrameCapture(TEXT("first-run-launch-ready")); + return; + } + + if (FirstRunSurfaceValidationAttempt < FMath::Max(MaxFirstRunSurfaceValidationAttempts, 1)) + { + GetWorldTimerManager().SetTimer( + FirstRunSurfaceValidationTimerHandle, + this, + &AHyperTwistFirstRunLaunchPlayerController::ValidateFirstRunSurfaceLayout, + FMath::Max(FirstRunSurfaceValidationIntervalSeconds, 0.01f), + false + ); + return; + } + + TryRecoverFromFirstRunFailure( + FString::Printf( + TEXT("The first-run launch widget never produced a visible non-zero layout after %d attempts (last size %.0fx%.0f)."), + FirstRunSurfaceValidationAttempt, + LocalSize.X, + LocalSize.Y + ) + ); +} + FString AHyperTwistFirstRunLaunchPlayerController::DescribeCurrentWorldName() const { const UWorld* World = GetWorld(); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistFirstRunLaunchWidget.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistFirstRunLaunchWidget.cpp index 9f78c8e..11cff86 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistFirstRunLaunchWidget.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistFirstRunLaunchWidget.cpp @@ -10,15 +10,28 @@ #include "Components/VerticalBox.h" #include "Components/VerticalBoxSlot.h" +void UHyperTwistFirstRunLaunchWidget::NativeOnInitialized() +{ + Super::NativeOnInitialized(); + RebuildFirstRunLaunchSurface(); +} + void UHyperTwistFirstRunLaunchWidget::NativeConstruct() { Super::NativeConstruct(); RebuildFirstRunLaunchSurface(); } +TSharedRef UHyperTwistFirstRunLaunchWidget::RebuildWidget() +{ + Initialize(); + RebuildFirstRunLaunchSurface(); + return Super::RebuildWidget(); +} + void UHyperTwistFirstRunLaunchWidget::RebuildFirstRunLaunchSurface() { - if (WidgetTree == nullptr) + if (WidgetTree == nullptr || WidgetTree->RootWidget != nullptr) { return; } @@ -106,6 +119,15 @@ void UHyperTwistFirstRunLaunchWidget::RebuildFirstRunLaunchSurface() } } +bool UHyperTwistFirstRunLaunchWidget::IsFirstRunLaunchSurfaceReady() const +{ + return WidgetTree != nullptr + && WidgetTree->RootWidget != nullptr + && WidgetTree->FindWidget(FName(TEXT("HyperTwistFirstRunRoot"))) != nullptr + && WidgetTree->FindWidget(FName(TEXT("CoachDashboardRouteButton"))) != nullptr + && WidgetTree->FindWidget(FName(TEXT("ClassicCubeRouteButton"))) != nullptr; +} + void UHyperTwistFirstRunLaunchWidget::OpenCoachDashboard() { RequestRoute(TEXT("coach-dashboard")); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistHigherDimensionalTrainingGameMode.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistHigherDimensionalTrainingGameMode.cpp new file mode 100644 index 0000000..2a71331 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistHigherDimensionalTrainingGameMode.cpp @@ -0,0 +1,598 @@ +#include "HyperTwistTraining/HyperTwistHigherDimensionalTrainingGameMode.h" + +#include "Camera/CameraComponent.h" +#include "CanvasItem.h" +#include "Components/DirectionalLightComponent.h" +#include "Components/SceneComponent.h" +#include "Engine/Canvas.h" +#include "Engine/DirectionalLight.h" +#include "Engine/Engine.h" +#include "Engine/Font.h" +#include "Engine/World.h" +#include "EngineUtils.h" +#include "GameFramework/SpringArmComponent.h" +#include "HAL/FileManager.h" +#include "HyperTwistDiagnostics/HyperTwistRuntimeDiagnostics.h" +#include "HyperTwistTraining/HyperTwistCoachDashboardActor.h" +#include "HyperTwistTraining/HyperTwistHigherDimensionalTrainingShellActor.h" +#include "InputCoreTypes.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" + +DEFINE_LOG_CATEGORY_STATIC(LogHyperTwistHigherDimensionalGameMode, Log, All); + +namespace HyperTwistHigherDimensionalTrainingGameModeInternal +{ + const TCHAR* Magic120CellFamilyKey = TEXT("magic120cell"); + const TCHAR* MagicCube5DFamilyKey = TEXT("magiccube5d"); + + FString BuildReadinessResult(const FString& FamilyKey) + { + return FamilyKey.Equals(Magic120CellFamilyKey, ESearchCase::IgnoreCase) + ? TEXT("higher-dimensional-runtime-ready-magic120cell") + : TEXT("higher-dimensional-runtime-ready-magiccube5d"); + } +} + +AHyperTwistHigherDimensionalOrbitPawn::AHyperTwistHigherDimensionalOrbitPawn() +{ + PrimaryActorTick.bCanEverTick = true; + + SceneRoot = CreateDefaultSubobject(TEXT("SceneRoot")); + SetRootComponent(SceneRoot); + + SpringArm = CreateDefaultSubobject(TEXT("SpringArm")); + SpringArm->SetupAttachment(SceneRoot); + SpringArm->bDoCollisionTest = false; + SpringArm->bEnableCameraLag = true; + SpringArm->CameraLagSpeed = 12.0f; + SpringArm->bUsePawnControlRotation = false; + SpringArm->TargetArmLength = InitialArmLength; + + CameraComponent = CreateDefaultSubobject(TEXT("Camera")); + CameraComponent->SetupAttachment(SpringArm, USpringArmComponent::SocketName); + CameraComponent->bUsePawnControlRotation = false; +} + +void AHyperTwistHigherDimensionalOrbitPawn::BeginPlay() +{ + Super::BeginPlay(); + ResetOrbit(); +} + +void AHyperTwistHigherDimensionalOrbitPawn::Tick(const float DeltaSeconds) +{ + Super::Tick(DeltaSeconds); + static_cast(DeltaSeconds); + + RefreshOrbitFocusPoint(); + APlayerController* PlayerController = Cast(GetController()); + if (PlayerController == nullptr) + { + return; + } + + if (SpringArm != nullptr) + { + const float MouseWheelDelta = PlayerController->GetInputAnalogKeyState(EKeys::MouseWheelAxis); + if (!FMath::IsNearlyZero(MouseWheelDelta)) + { + SpringArm->TargetArmLength = FMath::Clamp( + SpringArm->TargetArmLength - (MouseWheelDelta * ZoomStep), + MinimumArmLength, + MaximumArmLength); + } + } + + if (!ShouldOrbitFromMouseInput()) + { + return; + } + + float MouseDeltaX = 0.0f; + float MouseDeltaY = 0.0f; + PlayerController->GetInputMouseDelta(MouseDeltaX, MouseDeltaY); + if (FMath::IsNearlyZero(MouseDeltaX) && FMath::IsNearlyZero(MouseDeltaY)) + { + return; + } + + CurrentYawDegrees += MouseDeltaX * OrbitDegreesPerPixel; + CurrentPitchDegrees = FMath::Clamp( + CurrentPitchDegrees - (MouseDeltaY * OrbitDegreesPerPixel), + -78.0f, + 55.0f); + ApplyOrbitTransform(); +} + +void AHyperTwistHigherDimensionalOrbitPawn::ResetOrbit() +{ + CurrentYawDegrees = InitialYawDegrees; + CurrentPitchDegrees = InitialPitchDegrees; + if (SpringArm != nullptr) + { + SpringArm->TargetArmLength = FMath::Clamp( + InitialArmLength, + MinimumArmLength, + MaximumArmLength); + } + RefreshOrbitFocusPoint(); + ApplyOrbitTransform(); +} + +void AHyperTwistHigherDimensionalOrbitPawn::RefreshOrbitFocusPoint() +{ + if (GetWorld() == nullptr) + { + return; + } + + TActorIterator ActorIt(GetWorld()); + if (ActorIt) + { + const FVector NewFocusPoint = ActorIt->GetActorLocation() + + FVector(0.0f, 0.0f, ActorIt->PreviewHeightOffset); + if (!NewFocusPoint.Equals(OrbitFocusPoint)) + { + OrbitFocusPoint = NewFocusPoint; + ApplyOrbitTransform(); + } + } +} + +void AHyperTwistHigherDimensionalOrbitPawn::ApplyOrbitTransform() +{ + SetActorLocation(OrbitFocusPoint); + if (SpringArm != nullptr) + { + SpringArm->SetRelativeRotation(FRotator(CurrentPitchDegrees, CurrentYawDegrees, 0.0f)); + } +} + +bool AHyperTwistHigherDimensionalOrbitPawn::ShouldOrbitFromMouseInput() const +{ + const APlayerController* PlayerController = Cast(GetController()); + return PlayerController != nullptr + && (PlayerController->IsInputKeyDown(EKeys::MiddleMouseButton) + || (PlayerController->IsInputKeyDown(EKeys::RightMouseButton) + && (PlayerController->IsInputKeyDown(EKeys::LeftShift) + || PlayerController->IsInputKeyDown(EKeys::RightShift)))); +} + +void AHyperTwistHigherDimensionalTrainingHUD::DrawHUD() +{ + Super::DrawHUD(); + if (Canvas == nullptr) + { + return; + } + + AHyperTwistHigherDimensionalTrainingShellActor* TrainingShell = ResolveTrainingShell(); + if (TrainingShell == nullptr) + { + return; + } + + const float PanelX = 18.0f; + const float PanelY = 18.0f; + const float PanelWidth = FMath::Min(540.0f, Canvas->SizeX - 36.0f); + const float PanelHeight = 270.0f; + FCanvasTileItem Background( + FVector2D(PanelX, PanelY), + GWhiteTexture, + FVector2D(PanelWidth, PanelHeight), + FLinearColor(0.018f, 0.035f, 0.060f, 0.93f)); + Background.BlendMode = SE_BLEND_Translucent; + Canvas->DrawItem(Background); + + const FLinearColor AccentColor = TrainingShell->FamilyKey.Equals( + HyperTwistHigherDimensionalTrainingGameModeInternal::Magic120CellFamilyKey, + ESearchCase::IgnoreCase) + ? FLinearColor(0.12f, 0.95f, 1.0f) + : FLinearColor(1.0f, 0.60f, 0.22f); + FCanvasTileItem Accent( + FVector2D(PanelX, PanelY), + GWhiteTexture, + FVector2D(6.0f, PanelHeight), + AccentColor); + Canvas->DrawItem(Accent); + + float CursorY = PanelY + 16.0f; + DrawStatusLine(TrainingShell->Title, PanelX + 20.0f, CursorY, AccentColor, 1.15f); + DrawStatusLine(TrainingShell->GetRuntimeStatusSummary(), PanelX + 20.0f, CursorY, FLinearColor::White); + DrawStatusLine( + FString::Printf(TEXT("projection: %s"), *TrainingShell->ProjectionProfileId), + PanelX + 20.0f, + CursorY, + FLinearColor(0.72f, 0.80f, 0.88f), + 0.86f); + DrawStatusLine( + FString::Printf(TEXT("persistence: %s"), *TrainingShell->LastPersistenceStatus), + PanelX + 20.0f, + CursorY, + FLinearColor(0.72f, 0.80f, 0.88f), + 0.86f); + DrawStatusLine( + FString::Printf(TEXT("last action: %s"), *TrainingShell->LastRuntimeAction), + PanelX + 20.0f, + CursorY, + FLinearColor(0.72f, 0.80f, 0.88f), + 0.86f); + + CursorY += 5.0f; + DrawStatusLine( + TEXT("SPACE auto-rotate | Q / E rotate | PAGE UP / DOWN layers"), + PanelX + 20.0f, + CursorY, + FLinearColor::White, + 0.88f); + DrawStatusLine( + TEXT("N new state | R reset | S save | L load | HOME camera"), + PanelX + 20.0f, + CursorY, + FLinearColor::White, + 0.88f); + DrawStatusLine( + TEXT("Wheel zoom | MMB or SHIFT+RMB orbit | D diagnostics"), + PanelX + 20.0f, + CursorY, + FLinearColor::White, + 0.88f); +} + +AHyperTwistHigherDimensionalTrainingShellActor* +AHyperTwistHigherDimensionalTrainingHUD::ResolveTrainingShell() +{ + if (CachedTrainingShell.IsValid()) + { + return CachedTrainingShell.Get(); + } + if (GetWorld() == nullptr) + { + return nullptr; + } + + TActorIterator ActorIt(GetWorld()); + if (ActorIt) + { + CachedTrainingShell = *ActorIt; + return *ActorIt; + } + return nullptr; +} + +void AHyperTwistHigherDimensionalTrainingHUD::DrawStatusLine( + const FString& Text, + const float X, + float& InOutY, + const FLinearColor& Color, + const float Scale) +{ + UFont* Font = GEngine != nullptr ? GEngine->GetSmallFont() : nullptr; + if (Canvas == nullptr || Font == nullptr) + { + return; + } + + FCanvasTextItem TextItem(FVector2D(X, InOutY), FText::FromString(Text), Font, Color); + TextItem.Scale = FVector2D(Scale); + TextItem.EnableShadow(FLinearColor(0.0f, 0.0f, 0.0f, 0.75f)); + Canvas->DrawItem(TextItem); + InOutY += 23.0f * Scale; +} + +AHyperTwistHigherDimensionalTrainingPlayerController:: + AHyperTwistHigherDimensionalTrainingPlayerController() +{ + bBootstrapCoachDashboardOnBeginPlay = false; + bShowCoachDashboardMouseCursor = true; + bUseGameAndUiInputMode = true; + bEnableClickEvents = true; + bEnableMouseOverEvents = true; +} + +void AHyperTwistHigherDimensionalTrainingPlayerController::BeginPlay() +{ + Super::BeginPlay(); + + bShowMouseCursor = true; + FInputModeGameAndUI InputMode; + InputMode.SetHideCursorDuringCapture(false); + InputMode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock); + SetInputMode(InputMode); +} + +void AHyperTwistHigherDimensionalTrainingPlayerController::PlayerTick(const float DeltaTime) +{ + Super::PlayerTick(DeltaTime); + + AHyperTwistHigherDimensionalTrainingShellActor* TrainingShell = ResolveTrainingShell(); + if (TrainingShell == nullptr) + { + return; + } + + if (WasInputKeyJustPressed(EKeys::SpaceBar)) + { + TrainingShell->ToggleAutoRotateProjection(); + } + if (WasInputKeyJustPressed(EKeys::Q)) + { + TrainingShell->NudgeProjection(-12.0f); + } + if (WasInputKeyJustPressed(EKeys::E)) + { + TrainingShell->NudgeProjection(12.0f); + } + if (WasInputKeyJustPressed(EKeys::PageUp)) + { + TrainingShell->CycleProjectionLayer(1); + } + if (WasInputKeyJustPressed(EKeys::PageDown)) + { + TrainingShell->CycleProjectionLayer(-1); + } + if (WasInputKeyJustPressed(EKeys::N)) + { + TrainingShell->CreateScrambledRuntimeState(); + } + if (WasInputKeyJustPressed(EKeys::R)) + { + TrainingShell->ResetProjection(); + } + if (WasInputKeyJustPressed(EKeys::S)) + { + TrainingShell->SaveRuntimeState(); + } + if (WasInputKeyJustPressed(EKeys::L)) + { + TrainingShell->LoadRuntimeState(); + } + if (WasInputKeyJustPressed(EKeys::Home)) + { + if (AHyperTwistHigherDimensionalOrbitPawn* OrbitPawn = + Cast(GetPawn())) + { + OrbitPawn->ResetOrbit(); + TrainingShell->LastRuntimeAction = TEXT("camera orbit reset"); + } + } + if (WasInputKeyJustPressed(EKeys::D)) + { + ToggleCoachDashboardSurface(); + } +} + +AHyperTwistHigherDimensionalTrainingShellActor* +AHyperTwistHigherDimensionalTrainingPlayerController::ResolveTrainingShell() +{ + if (CachedTrainingShell.IsValid()) + { + return CachedTrainingShell.Get(); + } + if (GetWorld() == nullptr) + { + return nullptr; + } + + TActorIterator ActorIt(GetWorld()); + if (ActorIt) + { + CachedTrainingShell = *ActorIt; + return *ActorIt; + } + return nullptr; +} + +void AHyperTwistHigherDimensionalTrainingPlayerController::ToggleCoachDashboardSurface() +{ + if (ActiveDashboardActor != nullptr && ActiveDashboardActor->HasActiveDashboard()) + { + ActiveDashboardActor->RemoveDashboard(); + return; + } + + EnsureCoachDashboard(); +} + +AHyperTwistHigherDimensionalTrainingGameMode::AHyperTwistHigherDimensionalTrainingGameMode() +{ + DefaultPawnClass = AHyperTwistHigherDimensionalOrbitPawn::StaticClass(); + PlayerControllerClass = AHyperTwistHigherDimensionalTrainingPlayerController::StaticClass(); + HUDClass = AHyperTwistHigherDimensionalTrainingHUD::StaticClass(); + TrainingShellActorClass = AHyperTwistHigherDimensionalTrainingShellActor::StaticClass(); +} + +void AHyperTwistHigherDimensionalTrainingGameMode::BeginPlay() +{ + Super::BeginPlay(); + + HyperTwistRuntimeDiagnostics::AppendEvent( + TEXT("MapReady"), + FString::Printf( + TEXT("Runtime map ready: %s."), + GetWorld() != nullptr ? *GetWorld()->GetPathName() : TEXT("unknown"))); + + const FString FamilyKey = ResolveCurrentFamilyKey(); + EnsureRuntimeLighting(FamilyKey); + ActiveTrainingShell = FindExistingTrainingShell(); + if (ActiveTrainingShell == nullptr) + { + ActiveTrainingShell = SpawnTrainingShell(FamilyKey); + } + else + { + ActiveTrainingShell->ConfigureForFamily(FamilyKey); + } + + if (ActiveTrainingShell == nullptr + || ActiveTrainingShell->GetRenderableElementCount() + != ActiveTrainingShell->GetCanonicalElementCount()) + { + UE_LOG( + LogHyperTwistHigherDimensionalGameMode, + Error, + TEXT("Dedicated higher-dimensional runtime failed to initialize for family %s."), + *FamilyKey); + return; + } + + PersistRuntimeReadinessDiagnostics(FamilyKey, ActiveTrainingShell); + UE_LOG( + LogHyperTwistHigherDimensionalGameMode, + Display, + TEXT("Dedicated higher-dimensional runtime ready for %s with %d canonical renderable elements."), + *FamilyKey, + ActiveTrainingShell->GetRenderableElementCount()); + if (HyperTwistRuntimeDiagnostics::IsReadyFrameCaptureRequested()) + { + RequestRuntimeReadyDiagnosticsCapture(); + } +} + +FString AHyperTwistHigherDimensionalTrainingGameMode::ResolveCurrentFamilyKey() const +{ + const FString MapName = GetWorld() != nullptr ? GetWorld()->GetMapName() : FString(); + return MapName.Contains(TEXT("Magic120Cell"), ESearchCase::IgnoreCase) + ? HyperTwistHigherDimensionalTrainingGameModeInternal::Magic120CellFamilyKey + : HyperTwistHigherDimensionalTrainingGameModeInternal::MagicCube5DFamilyKey; +} + +AHyperTwistHigherDimensionalTrainingShellActor* +AHyperTwistHigherDimensionalTrainingGameMode::FindExistingTrainingShell() const +{ + if (GetWorld() == nullptr) + { + return nullptr; + } + + TActorIterator ActorIt(GetWorld()); + if (ActorIt) + { + return *ActorIt; + } + return nullptr; +} + +AHyperTwistHigherDimensionalTrainingShellActor* +AHyperTwistHigherDimensionalTrainingGameMode::SpawnTrainingShell(const FString& FamilyKey) +{ + if (GetWorld() == nullptr) + { + return nullptr; + } + + TSubclassOf ResolvedClass = + TrainingShellActorClass; + if (*ResolvedClass == nullptr) + { + ResolvedClass = AHyperTwistHigherDimensionalTrainingShellActor::StaticClass(); + } + + FTransform SpawnTransform; + AHyperTwistHigherDimensionalTrainingShellActor* TrainingShell = + GetWorld()->SpawnActorDeferred( + ResolvedClass, + SpawnTransform, + nullptr, + nullptr, + ESpawnActorCollisionHandlingMethod::AlwaysSpawn); + if (TrainingShell == nullptr) + { + return nullptr; + } + + TrainingShell->ConfigureForFamily(FamilyKey); + TrainingShell->FinishSpawning(SpawnTransform); + return TrainingShell; +} + +void AHyperTwistHigherDimensionalTrainingGameMode::EnsureRuntimeLighting(const FString& FamilyKey) +{ + if (GetWorld() == nullptr) + { + return; + } + + for (TActorIterator ActorIt(GetWorld()); ActorIt; ++ActorIt) + { + if (ActorIt->GetLightComponent() != nullptr && ActorIt->GetLightComponent()->IsVisible()) + { + ActivePresentationLight = *ActorIt; + return; + } + } + + FActorSpawnParameters SpawnParameters; + SpawnParameters.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + ActivePresentationLight = GetWorld()->SpawnActor( + ADirectionalLight::StaticClass(), + FVector(-340.0f, 120.0f, 470.0f), + FamilyKey.Equals(HyperTwistHigherDimensionalTrainingGameModeInternal::Magic120CellFamilyKey) + ? FRotator(-34.0f, -22.0f, 0.0f) + : FRotator(-38.0f, 34.0f, 0.0f), + SpawnParameters); + UDirectionalLightComponent* LightComponent = ActivePresentationLight != nullptr + ? Cast(ActivePresentationLight->GetLightComponent()) + : nullptr; + if (LightComponent == nullptr) + { + UE_LOG( + LogHyperTwistHigherDimensionalGameMode, + Error, + TEXT("Failed to create the self-healing higher-dimensional presentation light.")); + return; + } + + LightComponent->SetMobility(EComponentMobility::Movable); + LightComponent->SetIntensity( + FamilyKey.Equals(HyperTwistHigherDimensionalTrainingGameModeInternal::Magic120CellFamilyKey) + ? 2.8f + : 3.2f); + LightComponent->SetLightColor(FLinearColor(0.86f, 0.93f, 1.0f)); + ActivePresentationLight->Tags.AddUnique(FName(TEXT("HyperTwistHigherDimensionalRuntimeLight"))); + UE_LOG( + LogHyperTwistHigherDimensionalGameMode, + Display, + TEXT("Created self-healing runtime presentation lighting for family %s."), + *FamilyKey); +} + +void AHyperTwistHigherDimensionalTrainingGameMode::RequestRuntimeReadyDiagnosticsCapture() +{ + const FString SurfaceId = FString::Printf( + TEXT("%s-runtime-ready"), + *ResolveCurrentFamilyKey()); + HyperTwistRuntimeDiagnostics::RequestReadyFrameCapture(SurfaceId); +} + +void AHyperTwistHigherDimensionalTrainingGameMode::PersistRuntimeReadinessDiagnostics( + const FString& FamilyKey, + const AHyperTwistHigherDimensionalTrainingShellActor* TrainingShell) const +{ + if (TrainingShell == nullptr) + { + return; + } + + const FString DiagnosticsPath = FPaths::Combine( + FPaths::ProjectLogDir(), + TEXT("HyperTwistFirstRunLaunch-latest.log")); + const FString Result = + HyperTwistHigherDimensionalTrainingGameModeInternal::BuildReadinessResult(FamilyKey); + const FString Diagnostics = FString::Printf( + TEXT("[%s] [HigherDimensionalRuntime] family=%s canonical_elements=%d renderable_elements=%d projection=%s persistence=%s\nresult=%s\n"), + *FDateTime::UtcNow().ToIso8601(), + *FamilyKey, + TrainingShell->GetCanonicalElementCount(), + TrainingShell->GetRenderableElementCount(), + *TrainingShell->ProjectionProfileId, + *TrainingShell->PrimaryPersistenceBoundaryId, + *Result); + FFileHelper::SaveStringToFile( + Diagnostics, + *DiagnosticsPath, + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM, + &IFileManager::Get(), + FILEWRITE_Append); +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistHigherDimensionalTrainingShellActor.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistHigherDimensionalTrainingShellActor.cpp index a311ab2..f1264c2 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistHigherDimensionalTrainingShellActor.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistHigherDimensionalTrainingShellActor.cpp @@ -2,40 +2,116 @@ #include "Components/InstancedStaticMeshComponent.h" #include "Components/SceneComponent.h" +#include "Components/StaticMeshComponent.h" #include "Components/TextRenderComponent.h" #include "Engine/StaticMesh.h" +#include "HAL/FileManager.h" +#include "HyperTwistDiagnostics/HyperTwistRuntimeDiagnostics.h" +#include "Materials/MaterialInstanceDynamic.h" +#include "Materials/MaterialInterface.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" #include "UObject/ConstructorHelpers.h" +DEFINE_LOG_CATEGORY_STATIC(LogHyperTwistHigherDimensionalRuntime, Log, All); + namespace HyperTwistHigherDimensionalTrainingShellActorInternal { const TCHAR* Magic120CellFamilyKey = TEXT("magic120cell"); + const TCHAR* MagicCube5DFamilyKey = TEXT("magiccube5d"); + constexpr int32 PreviewColorLayerCount = 6; + constexpr int32 Magic120CellElementCount = 120; + constexpr int32 MagicCube5DOrderThreeElementCount = 242; + constexpr float ProjectionRefreshIntervalSeconds = 1.0f / 30.0f; + + bool IsEvenPermutation(const int32 A, const int32 B, const int32 C, const int32 D) + { + const int32 Values[4] = {A, B, C, D}; + int32 InversionCount = 0; + for (int32 Left = 0; Left < 4; ++Left) + { + for (int32 Right = Left + 1; Right < 4; ++Right) + { + if (Values[Left] > Values[Right]) + { + ++InversionCount; + } + } + } + + return InversionCount % 2 == 0; + } + + float ApplySign(const float Value, const bool bNegative) + { + return bNegative ? -Value : Value; + } } AHyperTwistHigherDimensionalTrainingShellActor::AHyperTwistHigherDimensionalTrainingShellActor() { - PrimaryActorTick.bCanEverTick = false; + PrimaryActorTick.bCanEverTick = true; SceneRoot = CreateDefaultSubobject(TEXT("SceneRoot")); SetRootComponent(SceneRoot); + PresentationFloor = CreateDefaultSubobject(TEXT("PresentationFloor")); + PresentationFloor->SetupAttachment(SceneRoot); + PresentationFloor->SetMobility(EComponentMobility::Movable); + PresentationFloor->SetCollisionEnabled(ECollisionEnabled::NoCollision); + PresentationFloor->SetCastShadow(false); + PresentationFloor->SetCanEverAffectNavigation(false); + PresentationFloor->SetRelativeLocation(FVector(0.0f, 0.0f, -360.0f)); + PresentationFloor->SetRelativeScale3D(FVector(14.0f, 14.0f, 0.05f)); + PreviewInstances = CreateDefaultSubobject(TEXT("PreviewInstances")); PreviewInstances->SetupAttachment(SceneRoot); - PreviewInstances->SetMobility(EComponentMobility::Static); - PreviewInstances->SetCollisionEnabled(ECollisionEnabled::NoCollision); - PreviewInstances->SetCastShadow(false); + PreviewColorLayers.Add(PreviewInstances); + + for (int32 LayerIndex = 1; + LayerIndex < HyperTwistHigherDimensionalTrainingShellActorInternal::PreviewColorLayerCount; + ++LayerIndex) + { + const FName ComponentName(*FString::Printf(TEXT("PreviewInstances%d"), LayerIndex)); + UInstancedStaticMeshComponent* PreviewLayer = + CreateDefaultSubobject(ComponentName); + PreviewLayer->SetupAttachment(SceneRoot); + PreviewColorLayers.Add(PreviewLayer); + } + + for (UInstancedStaticMeshComponent* PreviewLayer : PreviewColorLayers) + { + if (PreviewLayer == nullptr) + { + continue; + } + + PreviewLayer->SetMobility(EComponentMobility::Movable); + PreviewLayer->SetCollisionEnabled(ECollisionEnabled::NoCollision); + PreviewLayer->SetCastShadow(false); + PreviewLayer->SetCanEverAffectNavigation(false); + } LabelComponent = CreateDefaultSubobject(TEXT("LabelComponent")); LabelComponent->SetupAttachment(SceneRoot); LabelComponent->SetHorizontalAlignment(EHorizTextAligment::EHTA_Center); LabelComponent->SetVerticalAlignment(EVerticalTextAligment::EVRTA_TextCenter); - LabelComponent->SetWorldSize(56.0f); - LabelComponent->SetRelativeLocation(FVector(0.0f, 0.0f, 220.0f)); + LabelComponent->SetWorldSize(42.0f); + LabelComponent->SetRelativeLocation(FVector(0.0f, 0.0f, 470.0f)); + LabelComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision); + // The native HUD owns readable status. A fixed world-space label becomes + // mirrored as soon as the orbit camera reaches its back face. + LabelComponent->SetVisibility(false); + LabelComponent->SetHiddenInGame(true); static ConstructorHelpers::FObjectFinder CubeMeshFinder( TEXT("/Engine/BasicShapes/Cube.Cube")); if (CubeMeshFinder.Succeeded()) { CubePreviewMesh = CubeMeshFinder.Object; + PresentationFloor->SetStaticMesh(CubeMeshFinder.Object); } static ConstructorHelpers::FObjectFinder SphereMeshFinder( @@ -44,6 +120,79 @@ AHyperTwistHigherDimensionalTrainingShellActor::AHyperTwistHigherDimensionalTrai { SpherePreviewMesh = SphereMeshFinder.Object; } + + static ConstructorHelpers::FObjectFinder RedMaterial( + TEXT("/Game/HyperTwistTraining/Materials/MI_HT_ClassicCube_Right.MI_HT_ClassicCube_Right")); + static ConstructorHelpers::FObjectFinder BlueMaterial( + TEXT("/Game/HyperTwistTraining/Materials/MI_HT_ClassicCube_Back.MI_HT_ClassicCube_Back")); + static ConstructorHelpers::FObjectFinder GreenMaterial( + TEXT("/Game/HyperTwistTraining/Materials/MI_HT_ClassicCube_Front.MI_HT_ClassicCube_Front")); + static ConstructorHelpers::FObjectFinder WhiteMaterial( + TEXT("/Game/HyperTwistTraining/Materials/MI_HT_ClassicCube_Up.MI_HT_ClassicCube_Up")); + static ConstructorHelpers::FObjectFinder YellowMaterial( + TEXT("/Game/HyperTwistTraining/Materials/MI_HT_ClassicCube_Down.MI_HT_ClassicCube_Down")); + static ConstructorHelpers::FObjectFinder OrangeMaterial( + TEXT("/Game/HyperTwistTraining/Materials/MI_HT_ClassicCube_Left.MI_HT_ClassicCube_Left")); + static ConstructorHelpers::FObjectFinder FloorMaterial( + TEXT("/Game/HyperTwistTraining/Materials/MI_HT_ClassicCube_Internal.MI_HT_ClassicCube_Internal")); + + PreviewMaterials = + { + RedMaterial.Object, + BlueMaterial.Object, + GreenMaterial.Object, + YellowMaterial.Object, + OrangeMaterial.Object, + WhiteMaterial.Object + }; + if (FloorMaterial.Succeeded()) + { + PresentationFloor->SetMaterial(0, FloorMaterial.Object); + } +} + +void AHyperTwistHigherDimensionalTrainingShellActor::BeginPlay() +{ + Super::BeginPlay(); + + LoadRuntimeState(); + RefreshPreview(); + + UE_LOG( + LogHyperTwistHigherDimensionalRuntime, + Display, + TEXT("Higher-dimensional presentation initialized with %d renderable elements (canonical=%d) for family %s."), + GetRenderableElementCount(), + GetCanonicalElementCount(), + *GetNormalizedFamilyKey()); + HyperTwistRuntimeDiagnostics::AppendEvent( + TEXT("HigherDimensionalPresentation"), + FString::Printf( + TEXT("Higher-dimensional presentation initialized with %d renderable elements (canonical=%d) for family %s."), + GetRenderableElementCount(), + GetCanonicalElementCount(), + *GetNormalizedFamilyKey())); + UE_LOG( + LogHyperTwistHigherDimensionalRuntime, + Display, + TEXT("Higher-dimensional projection palette initialized with %d distinct layer materials."), + GetDistinctPreviewMaterialCount()); + HyperTwistRuntimeDiagnostics::AppendEvent( + TEXT("HigherDimensionalPalette"), + FString::Printf( + TEXT("Higher-dimensional projection palette initialized with %d distinct layer materials."), + GetDistinctPreviewMaterialCount())); +} + +void AHyperTwistHigherDimensionalTrainingShellActor::EndPlay( + const EEndPlayReason::Type EndPlayReason) +{ + if (EndPlayReason == EEndPlayReason::Quit || EndPlayReason == EEndPlayReason::LevelTransition) + { + SaveRuntimeState(); + } + + Super::EndPlay(EndPlayReason); } void AHyperTwistHigherDimensionalTrainingShellActor::OnConstruction(const FTransform& Transform) @@ -55,35 +204,306 @@ void AHyperTwistHigherDimensionalTrainingShellActor::OnConstruction(const FTrans RefreshMetadataTags(); } -void AHyperTwistHigherDimensionalTrainingShellActor::RefreshPreview() +void AHyperTwistHigherDimensionalTrainingShellActor::Tick(const float DeltaSeconds) { - if (PreviewInstances == nullptr) + Super::Tick(DeltaSeconds); + + if (!bAutoRotateProjection || FMath::IsNearlyZero(ProjectionRotationSpeedDegrees)) { return; } - PreviewInstances->ClearInstances(); - - UStaticMesh* PreviewMesh = UsesMagic120CellPreview() ? SpherePreviewMesh : CubePreviewMesh; - if (PreviewMesh == nullptr) + ProjectionAngleDegrees = FMath::Fmod( + ProjectionAngleDegrees + (ProjectionRotationSpeedDegrees * DeltaSeconds), + 360.0f); + ProjectionRefreshAccumulator += DeltaSeconds; + if (ProjectionRefreshAccumulator >= + HyperTwistHigherDimensionalTrainingShellActorInternal::ProjectionRefreshIntervalSeconds) { - PreviewMesh = CubePreviewMesh != nullptr ? CubePreviewMesh : SpherePreviewMesh; + ProjectionRefreshAccumulator = 0.0f; + UpdatePreviewTransforms(); } +} - if (PreviewMesh == nullptr) - { - return; - } +void AHyperTwistHigherDimensionalTrainingShellActor::ConfigureForFamily( + const FString& InFamilyKey) +{ + const bool bUseMagic120Cell = InFamilyKey.Equals( + HyperTwistHigherDimensionalTrainingShellActorInternal::Magic120CellFamilyKey, + ESearchCase::IgnoreCase); - PreviewInstances->SetStaticMesh(PreviewMesh); - if (UsesMagic120CellPreview()) + FamilyKey = bUseMagic120Cell + ? HyperTwistHigherDimensionalTrainingShellActorInternal::Magic120CellFamilyKey + : HyperTwistHigherDimensionalTrainingShellActorInternal::MagicCube5DFamilyKey; + if (bUseMagic120Cell) { - BuildMagic120CellPreview(); + TrainingShellId = TEXT("phase6c/magic120cell/dedicated-training-shell"); + Title = TEXT("Magic120Cell // 120-cell projection lab"); + Summary = TEXT("Interactive first-party 4D projection surface with exactly 120 cell centers, layer focus, runtime-state seeding, and local persistence."); + MapAssetPath = TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining"); + ActivationProfileId = TEXT("magic120cell-cleanroom-runtime-activation"); + HostSurfaceId = TEXT("phase6c/magic120cell/runtime-host-surface"); + LaunchSurfaceId = TEXT("phase6c/magic120cell/dedicated-training-launch-surface"); + ViewContextSurfaceId = TEXT("phase6c/magic120cell/dedicated-training-view-context-surface"); + SessionSurfaceId = TEXT("phase6c/magic120cell/dedicated-training-session-surface"); + InteractiveSceneSurfaceId = TEXT("phase6c/magic120cell/interactive-scene-surface"); + SceneContextId = TEXT("phase6c/magic120cell/interactive-scene-context"); + PuzzleId = TEXT("polychoron/magic120cell"); + RuntimeModeId = TEXT("magic120cell-full-color-runtime-v1"); + ProjectionProfileId = TEXT("magic120cell-4d-projection-distance-v1"); + PrimaryPersistenceBoundaryId = TEXT("magic120cell-persistence-boundary"); } else { - BuildMagicCube5DPreview(); + TrainingShellId = TEXT("phase6c/magiccube5d/dedicated-training-shell"); + Title = TEXT("MagicCube5D // order-3 projection lab"); + Summary = TEXT("Interactive first-party 5D projection surface with all 242 non-central order-3 cells, depth-layer focus, runtime-state seeding, and local persistence."); + MapAssetPath = TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining"); + ActivationProfileId = TEXT("magiccube5d-cleanroom-runtime-activation"); + HostSurfaceId = TEXT("phase6c/magiccube5d/runtime-host-surface"); + LaunchSurfaceId = TEXT("phase6c/magiccube5d/dedicated-training-launch-surface"); + ViewContextSurfaceId = TEXT("phase6c/magiccube5d/dedicated-training-view-context-surface"); + SessionSurfaceId = TEXT("phase6c/magiccube5d/dedicated-training-session-surface"); + InteractiveSceneSurfaceId = TEXT("phase6c/magiccube5d/interactive-scene-surface"); + SceneContextId = TEXT("phase6c/magiccube5d/interactive-scene-context"); + PuzzleId = TEXT("hypercube/magiccube5d/order3"); + RuntimeModeId = TEXT("magiccube5d-order3-runtime-v1"); + ProjectionProfileId = TEXT("magiccube5d-5d-projection-distance-v1"); + PrimaryPersistenceBoundaryId = TEXT("magiccube5d-persistence-boundary"); } + + AuthoringManifestRelativePath = + TEXT("docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_map_manifest.json"); + bDedicatedFamilyOwnership = true; + TrainingShellTags = + { + TEXT("phase6c"), + FString::Printf(TEXT("family:%s"), *FamilyKey), + TEXT("host:dedicated-family-map"), + FString::Printf(TEXT("projection:%s"), *ProjectionProfileId), + FString::Printf(TEXT("persistence:%s"), *PrimaryPersistenceBoundaryId), + TEXT("presentation:runtime-owned") + }; + + VisibleProjectionLayer = FMath::Clamp(VisibleProjectionLayer, -1, GetProjectionLayerCount() - 1); + RefreshPreview(); + RefreshLabel(); + RefreshMetadataTags(); +} + +void AHyperTwistHigherDimensionalTrainingShellActor::ToggleAutoRotateProjection() +{ + bAutoRotateProjection = !bAutoRotateProjection; + LastRuntimeAction = bAutoRotateProjection ? TEXT("auto-rotation resumed") : TEXT("auto-rotation paused"); +} + +void AHyperTwistHigherDimensionalTrainingShellActor::NudgeProjection(const float DeltaDegrees) +{ + ProjectionAngleDegrees = FMath::Fmod(ProjectionAngleDegrees + DeltaDegrees + 360.0f, 360.0f); + LastRuntimeAction = FString::Printf(TEXT("projection rotated %.0f degrees"), DeltaDegrees); + UpdatePreviewTransforms(); +} + +void AHyperTwistHigherDimensionalTrainingShellActor::CycleProjectionLayer(const int32 Direction) +{ + const int32 LayerCount = GetProjectionLayerCount(); + if (LayerCount <= 0 || Direction == 0) + { + return; + } + + const int32 StateCount = LayerCount + 1; + const int32 CurrentState = VisibleProjectionLayer + 1; + const int32 NextState = ((CurrentState + Direction) % StateCount + StateCount) % StateCount; + VisibleProjectionLayer = NextState - 1; + LastRuntimeAction = FString::Printf(TEXT("layer focus: %s"), *GetProjectionLayerLabel()); + UpdatePreviewTransforms(); +} + +void AHyperTwistHigherDimensionalTrainingShellActor::ResetProjection() +{ + ProjectionAngleDegrees = 0.0f; + VisibleProjectionLayer = -1; + RuntimeStateSeed = 0; + bAutoRotateProjection = true; + LastRuntimeAction = TEXT("projection and state reset to solved defaults"); + UpdatePreviewTransforms(); +} + +void AHyperTwistHigherDimensionalTrainingShellActor::CreateScrambledRuntimeState() +{ + RuntimeStateSeed = RuntimeStateSeed >= MAX_int32 - 1 ? 1 : RuntimeStateSeed + 1; + ProjectionAngleDegrees = FMath::Fmod( + ProjectionAngleDegrees + 37.0f + static_cast(RuntimeStateSeed % 29), + 360.0f); + LastRuntimeAction = FString::Printf(TEXT("runtime state seed %d applied"), RuntimeStateSeed); + UpdatePreviewTransforms(); +} + +bool AHyperTwistHigherDimensionalTrainingShellActor::SaveRuntimeState() +{ + const FString StatePath = GetRuntimeStatePath(); + if (!IFileManager::Get().MakeDirectory(*FPaths::GetPath(StatePath), true)) + { + LastPersistenceStatus = TEXT("save failed: state directory unavailable"); + return false; + } + + const TSharedRef StateObject = MakeShared(); + StateObject->SetStringField(TEXT("schemaVersion"), TEXT("hypertwist/higher-dimensional-runtime-state/v1")); + StateObject->SetStringField(TEXT("familyKey"), GetNormalizedFamilyKey()); + StateObject->SetStringField(TEXT("puzzleId"), PuzzleId); + StateObject->SetNumberField(TEXT("projectionAngleDegrees"), ProjectionAngleDegrees); + StateObject->SetNumberField(TEXT("visibleProjectionLayer"), VisibleProjectionLayer); + StateObject->SetNumberField(TEXT("runtimeStateSeed"), RuntimeStateSeed); + StateObject->SetBoolField(TEXT("autoRotateProjection"), bAutoRotateProjection); + StateObject->SetStringField(TEXT("savedAtUtc"), FDateTime::UtcNow().ToIso8601()); + + FString SerializedState; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&SerializedState); + if (!FJsonSerializer::Serialize(StateObject, Writer) + || !FFileHelper::SaveStringToFile(SerializedState, *StatePath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + LastPersistenceStatus = TEXT("save failed: state file could not be written"); + return false; + } + + LastPersistenceStatus = TEXT("saved local runtime state"); + LastRuntimeAction = LastPersistenceStatus; + return true; +} + +bool AHyperTwistHigherDimensionalTrainingShellActor::LoadRuntimeState() +{ + const FString StatePath = GetRuntimeStatePath(); + FString SerializedState; + if (!FFileHelper::LoadFileToString(SerializedState, *StatePath)) + { + LastPersistenceStatus = TEXT("no saved state; using solved defaults"); + return false; + } + + TSharedPtr StateObject; + if (!FJsonSerializer::Deserialize(TJsonReaderFactory<>::Create(SerializedState), StateObject) + || !StateObject.IsValid()) + { + LastPersistenceStatus = TEXT("load rejected: malformed state file"); + return false; + } + + FString StoredFamilyKey; + if (!StateObject->TryGetStringField(TEXT("familyKey"), StoredFamilyKey) + || !StoredFamilyKey.Equals(GetNormalizedFamilyKey(), ESearchCase::IgnoreCase)) + { + LastPersistenceStatus = TEXT("load rejected: family mismatch"); + return false; + } + + double StoredProjectionAngle = 0.0; + double StoredVisibleLayer = -1.0; + double StoredRuntimeStateSeed = 0.0; + bool bStoredAutoRotate = true; + if (!StateObject->TryGetNumberField(TEXT("projectionAngleDegrees"), StoredProjectionAngle) + || !StateObject->TryGetNumberField(TEXT("visibleProjectionLayer"), StoredVisibleLayer) + || !StateObject->TryGetNumberField(TEXT("runtimeStateSeed"), StoredRuntimeStateSeed) + || !StateObject->TryGetBoolField(TEXT("autoRotateProjection"), bStoredAutoRotate)) + { + LastPersistenceStatus = TEXT("load rejected: incomplete state fields"); + return false; + } + + ProjectionAngleDegrees = FMath::Fmod(static_cast(StoredProjectionAngle) + 360.0f, 360.0f); + VisibleProjectionLayer = FMath::Clamp( + static_cast(StoredVisibleLayer), + -1, + GetProjectionLayerCount() - 1); + RuntimeStateSeed = FMath::Max(static_cast(StoredRuntimeStateSeed), 0); + bAutoRotateProjection = bStoredAutoRotate; + LastPersistenceStatus = TEXT("loaded local runtime state"); + LastRuntimeAction = LastPersistenceStatus; + UpdatePreviewTransforms(); + return true; +} + +int32 AHyperTwistHigherDimensionalTrainingShellActor::GetRenderableElementCount() const +{ + int32 ElementCount = 0; + for (const UInstancedStaticMeshComponent* PreviewLayer : PreviewColorLayers) + { + if (PreviewLayer != nullptr) + { + ElementCount += PreviewLayer->GetInstanceCount(); + } + } + return ElementCount; +} + +int32 AHyperTwistHigherDimensionalTrainingShellActor::GetCanonicalElementCount() const +{ + return GetCanonicalElementCountForFamily(GetNormalizedFamilyKey()); +} + +int32 AHyperTwistHigherDimensionalTrainingShellActor::GetProjectionLayerCount() const +{ + return UsesMagic120CellPreview() ? 6 : 3; +} + +int32 AHyperTwistHigherDimensionalTrainingShellActor::GetDistinctPreviewMaterialCount() const +{ + TSet DistinctMaterials; + for (const UMaterialInterface* Material : PreviewMaterials) + { + if (Material != nullptr) + { + DistinctMaterials.Add(Material); + } + } + + return DistinctMaterials.Num(); +} + +FString AHyperTwistHigherDimensionalTrainingShellActor::GetProjectionLayerLabel() const +{ + return VisibleProjectionLayer < 0 + ? TEXT("all layers") + : FString::Printf(TEXT("layer %d of %d"), VisibleProjectionLayer + 1, GetProjectionLayerCount()); +} + +FString AHyperTwistHigherDimensionalTrainingShellActor::GetRuntimeStatusSummary() const +{ + return FString::Printf( + TEXT("%d / %d elements | %s | state seed %d | %s"), + GetRenderableElementCount(), + GetCanonicalElementCount(), + *GetProjectionLayerLabel(), + RuntimeStateSeed, + bAutoRotateProjection ? TEXT("auto-rotating") : TEXT("rotation paused")); +} + +FString AHyperTwistHigherDimensionalTrainingShellActor::GetRuntimeStatePath() const +{ + return FPaths::Combine( + FPaths::ProjectSavedDir(), + TEXT("HyperTwist"), + TEXT("HigherDimensional"), + FString::Printf(TEXT("%s-runtime-state.json"), *GetNormalizedFamilyKey())); +} + +int32 AHyperTwistHigherDimensionalTrainingShellActor::GetCanonicalElementCountForFamily( + const FString& InFamilyKey) +{ + return InFamilyKey.Equals( + HyperTwistHigherDimensionalTrainingShellActorInternal::Magic120CellFamilyKey, + ESearchCase::IgnoreCase) + ? HyperTwistHigherDimensionalTrainingShellActorInternal::Magic120CellElementCount + : HyperTwistHigherDimensionalTrainingShellActorInternal::MagicCube5DOrderThreeElementCount; +} + +void AHyperTwistHigherDimensionalTrainingShellActor::RefreshPreview() +{ + RebuildSourcePoints(); + ConfigurePreviewComponents(); + UpdatePreviewTransforms(); } void AHyperTwistHigherDimensionalTrainingShellActor::RefreshLabel() @@ -93,9 +513,10 @@ void AHyperTwistHigherDimensionalTrainingShellActor::RefreshLabel() return; } + LabelComponent->SetRelativeLocation(FVector(0.0f, 0.0f, PreviewHeightOffset + PreviewRadius + 120.0f)); LabelComponent->SetText(BuildLabelText()); LabelComponent->SetTextRenderColor( - UsesMagic120CellPreview() ? FColor(96, 208, 255) : FColor(255, 164, 96)); + UsesMagic120CellPreview() ? FColor(54, 244, 255) : FColor(255, 177, 74)); } void AHyperTwistHigherDimensionalTrainingShellActor::RefreshMetadataTags() @@ -137,56 +558,305 @@ void AHyperTwistHigherDimensionalTrainingShellActor::RefreshMetadataTags() Tags = MoveTemp(NewTags); } -void AHyperTwistHigherDimensionalTrainingShellActor::BuildMagic120CellPreview() +void AHyperTwistHigherDimensionalTrainingShellActor::RebuildSourcePoints() { - AddPreviewInstance(FVector(0.0f, 0.0f, PreviewHeightOffset), 1.1f); + SourcePoints4D.Reset(); + SourceFifthCoordinates.Reset(); + SourceLayerIndices.Reset(); + SourceColorIndices.Reset(); - for (int32 Index = 0; Index < 12; ++Index) + if (UsesMagic120CellPreview()) { - const float AngleRadians = - (static_cast(Index) / 12.0f) * 2.0f * PI; - const float AlternatingHeight = - PreviewHeightOffset + ((Index % 2 == 0) ? 45.0f : -45.0f); - const FVector Position( - FMath::Cos(AngleRadians) * PreviewRadius, - FMath::Sin(AngleRadians) * PreviewRadius, - AlternatingHeight); - AddPreviewInstance(Position, 0.48f); + BuildMagic120CellSourcePoints(); + } + else + { + BuildMagicCube5DSourcePoints(); } - - AddPreviewInstance(FVector(0.0f, 0.0f, PreviewHeightOffset + 190.0f), 0.42f); - AddPreviewInstance(FVector(0.0f, 0.0f, PreviewHeightOffset - 190.0f), 0.42f); } -void AHyperTwistHigherDimensionalTrainingShellActor::BuildMagicCube5DPreview() +void AHyperTwistHigherDimensionalTrainingShellActor::BuildMagic120CellSourcePoints() { - AddPreviewInstance(FVector(0.0f, 0.0f, PreviewHeightOffset), 1.0f); + using namespace HyperTwistHigherDimensionalTrainingShellActorInternal; - const TArray AxisOffsets = + for (int32 Axis = 0; Axis < 4; ++Axis) { - FVector(PreviewRadius, 0.0f, PreviewHeightOffset), - FVector(-PreviewRadius, 0.0f, PreviewHeightOffset), - FVector(0.0f, PreviewRadius, PreviewHeightOffset), - FVector(0.0f, -PreviewRadius, PreviewHeightOffset), - FVector(0.0f, 0.0f, PreviewHeightOffset + PreviewRadius), - FVector(0.0f, 0.0f, PreviewHeightOffset - PreviewRadius), - FVector(PreviewRadius * 0.78f, PreviewRadius * 0.78f, PreviewHeightOffset), - FVector(-PreviewRadius * 0.78f, -PreviewRadius * 0.78f, PreviewHeightOffset), - FVector(PreviewRadius * 0.78f, -PreviewRadius * 0.78f, PreviewHeightOffset), - FVector(-PreviewRadius * 0.78f, PreviewRadius * 0.78f, PreviewHeightOffset) + for (int32 Sign = -1; Sign <= 1; Sign += 2) + { + float Coordinates[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + Coordinates[Axis] = static_cast(Sign); + const int32 Layer = FMath::Clamp( + FMath::FloorToInt(((Coordinates[3] + 1.001f) / 2.002f) * 6.0f), + 0, + 5); + AddSourcePoint( + FVector4(Coordinates[0], Coordinates[1], Coordinates[2], Coordinates[3]), + 0.0f, + Layer, + SourcePoints4D.Num() % PreviewColorLayerCount); + } + } + + for (int32 SignMask = 0; SignMask < 16; ++SignMask) + { + const FVector4 Point( + ApplySign(0.5f, (SignMask & 1) != 0), + ApplySign(0.5f, (SignMask & 2) != 0), + ApplySign(0.5f, (SignMask & 4) != 0), + ApplySign(0.5f, (SignMask & 8) != 0)); + const int32 Layer = FMath::Clamp( + FMath::FloorToInt(((Point.W + 1.001f) / 2.002f) * 6.0f), + 0, + 5); + AddSourcePoint(Point, 0.0f, Layer, SourcePoints4D.Num() % PreviewColorLayerCount); + } + + const float GoldenRatio = (1.0f + FMath::Sqrt(5.0f)) * 0.5f; + const float BaseValues[4] = + { + 0.0f, + 0.5f, + GoldenRatio * 0.5f, + 1.0f / (2.0f * GoldenRatio) }; - for (const FVector& Offset : AxisOffsets) + for (int32 A = 0; A < 4; ++A) { - AddPreviewInstance(Offset, 0.42f); + for (int32 B = 0; B < 4; ++B) + { + if (B == A) + { + continue; + } + for (int32 C = 0; C < 4; ++C) + { + if (C == A || C == B) + { + continue; + } + for (int32 D = 0; D < 4; ++D) + { + if (D == A || D == B || D == C || !IsEvenPermutation(A, B, C, D)) + { + continue; + } + + const int32 Permutation[4] = {A, B, C, D}; + for (int32 SignMask = 0; SignMask < 8; ++SignMask) + { + float Coordinates[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + int32 SignIndex = 0; + for (int32 CoordinateIndex = 0; CoordinateIndex < 4; ++CoordinateIndex) + { + const float Value = BaseValues[Permutation[CoordinateIndex]]; + if (FMath::IsNearlyZero(Value)) + { + continue; + } + Coordinates[CoordinateIndex] = ApplySign( + Value, + (SignMask & (1 << SignIndex)) != 0); + ++SignIndex; + } + + const int32 Layer = FMath::Clamp( + FMath::FloorToInt(((Coordinates[3] + 1.001f) / 2.002f) * 6.0f), + 0, + 5); + AddSourcePoint( + FVector4(Coordinates[0], Coordinates[1], Coordinates[2], Coordinates[3]), + 0.0f, + Layer, + SourcePoints4D.Num() % PreviewColorLayerCount); + } + } + } + } } + + ensureMsgf( + SourcePoints4D.Num() == Magic120CellElementCount, + TEXT("Magic120Cell projection source must contain exactly 120 cell centers; observed %d."), + SourcePoints4D.Num()); +} + +void AHyperTwistHigherDimensionalTrainingShellActor::BuildMagicCube5DSourcePoints() +{ + using namespace HyperTwistHigherDimensionalTrainingShellActorInternal; + + for (int32 X = -1; X <= 1; ++X) + { + for (int32 Y = -1; Y <= 1; ++Y) + { + for (int32 Z = -1; Z <= 1; ++Z) + { + for (int32 W = -1; W <= 1; ++W) + { + for (int32 V = -1; V <= 1; ++V) + { + if (X == 0 && Y == 0 && Z == 0 && W == 0 && V == 0) + { + continue; + } + + const int32 ColorIndex = FMath::Abs( + (X * 31) + (Y * 17) + (Z * 13) + (W * 7) + (V * 3)) + % PreviewColorLayerCount; + AddSourcePoint( + FVector4( + static_cast(X), + static_cast(Y), + static_cast(Z), + static_cast(W)), + static_cast(V), + V + 1, + ColorIndex); + } + } + } + } + } + + ensureMsgf( + SourcePoints4D.Num() == MagicCube5DOrderThreeElementCount, + TEXT("MagicCube5D order-3 projection source must contain exactly 242 non-central cells; observed %d."), + SourcePoints4D.Num()); +} + +void AHyperTwistHigherDimensionalTrainingShellActor::UpdatePreviewTransforms() +{ + ClearPreviewInstances(); + if (SourcePoints4D.Num() != SourceFifthCoordinates.Num() + || SourcePoints4D.Num() != SourceLayerIndices.Num() + || SourcePoints4D.Num() != SourceColorIndices.Num()) + { + return; + } + + const bool bMagic120Cell = UsesMagic120CellPreview(); + for (int32 Index = 0; Index < SourcePoints4D.Num(); ++Index) + { + if (VisibleProjectionLayer >= 0 && SourceLayerIndices[Index] != VisibleProjectionLayer) + { + continue; + } + + const FVector ProjectedPoint = bMagic120Cell + ? ProjectMagic120CellPoint(SourcePoints4D[Index]) + : ProjectMagicCube5DPoint(SourcePoints4D[Index], SourceFifthCoordinates[Index]); + const int32 ColorIndex = FMath::Abs( + SourceColorIndices[Index] + RuntimeStateSeed + (Index * FMath::Max(RuntimeStateSeed, 1))) + % HyperTwistHigherDimensionalTrainingShellActorInternal::PreviewColorLayerCount; + const float ElementScale = bMagic120Cell ? 0.145f : 0.092f; + AddPreviewInstance(ProjectedPoint, ElementScale, ColorIndex); + } +} + +void AHyperTwistHigherDimensionalTrainingShellActor::ClearPreviewInstances() +{ + for (UInstancedStaticMeshComponent* PreviewLayer : PreviewColorLayers) + { + if (PreviewLayer != nullptr) + { + PreviewLayer->ClearInstances(); + } + } +} + +void AHyperTwistHigherDimensionalTrainingShellActor::ConfigurePreviewComponents() +{ + using namespace HyperTwistHigherDimensionalTrainingShellActorInternal; + + PreviewDynamicMaterials.SetNum(PreviewColorLayerCount); + UStaticMesh* PreviewMesh = UsesMagic120CellPreview() ? SpherePreviewMesh : CubePreviewMesh; + if (PreviewMesh == nullptr) + { + PreviewMesh = CubePreviewMesh != nullptr ? CubePreviewMesh : SpherePreviewMesh; + } + + for (int32 LayerIndex = 0; LayerIndex < PreviewColorLayers.Num(); ++LayerIndex) + { + UInstancedStaticMeshComponent* PreviewLayer = PreviewColorLayers[LayerIndex]; + if (PreviewLayer == nullptr) + { + continue; + } + + PreviewLayer->SetStaticMesh(PreviewMesh); + if (PreviewMaterials.IsValidIndex(LayerIndex) && PreviewMaterials[LayerIndex] != nullptr) + { + UMaterialInstanceDynamic* LayerMaterial = + PreviewDynamicMaterials.IsValidIndex(LayerIndex) + ? PreviewDynamicMaterials[LayerIndex] + : nullptr; + if (LayerMaterial == nullptr) + { + LayerMaterial = UMaterialInstanceDynamic::Create( + PreviewMaterials[LayerIndex], + this, + FName(*FString::Printf(TEXT("HigherDimensionalLayerMaterial%d"), LayerIndex))); + PreviewDynamicMaterials[LayerIndex] = LayerMaterial; + } + if (LayerMaterial != nullptr) + { + LayerMaterial->SetScalarParameterValue(TEXT("GlowStrength"), 0.8f); + LayerMaterial->SetScalarParameterValue(TEXT("FaceOpacity"), 1.0f); + PreviewLayer->SetMaterial(0, LayerMaterial); + } + else + { + PreviewLayer->SetMaterial(0, PreviewMaterials[LayerIndex]); + } + } + } + + if (PresentationFloor != nullptr) + { + UMaterialInterface* FloorMaterial = PresentationFloor->GetMaterial(0); + if (FloorDynamicMaterial == nullptr && FloorMaterial != nullptr) + { + FloorDynamicMaterial = UMaterialInstanceDynamic::Create( + FloorMaterial, + this, + TEXT("HigherDimensionalFloorMaterial")); + } + if (FloorDynamicMaterial != nullptr) + { + const FLinearColor FloorColor = UsesMagic120CellPreview() + ? FLinearColor(0.008f, 0.018f, 0.032f) + : FLinearColor(0.026f, 0.012f, 0.006f); + FloorDynamicMaterial->SetVectorParameterValue(TEXT("BaseColor"), FloorColor); + FloorDynamicMaterial->SetScalarParameterValue(TEXT("GlowStrength"), 0.015f); + PresentationFloor->SetMaterial(0, FloorDynamicMaterial); + } + } +} + +void AHyperTwistHigherDimensionalTrainingShellActor::AddSourcePoint( + const FVector4& Point4D, + const float FifthCoordinate, + const int32 LayerIndex, + const int32 ColorIndex) +{ + SourcePoints4D.Add(Point4D); + SourceFifthCoordinates.Add(FifthCoordinate); + SourceLayerIndices.Add(LayerIndex); + SourceColorIndices.Add(ColorIndex); } void AHyperTwistHigherDimensionalTrainingShellActor::AddPreviewInstance( const FVector& RelativeLocation, - const float UniformScale) + const float UniformScale, + const int32 ColorIndex) { - if (PreviewInstances == nullptr) + if (PreviewColorLayers.Num() == 0) + { + return; + } + + const int32 LayerIndex = FMath::Abs(ColorIndex) % PreviewColorLayers.Num(); + UInstancedStaticMeshComponent* PreviewLayer = PreviewColorLayers[LayerIndex]; + if (PreviewLayer == nullptr || PreviewLayer->GetStaticMesh() == nullptr) { return; } @@ -194,23 +864,79 @@ void AHyperTwistHigherDimensionalTrainingShellActor::AddPreviewInstance( FTransform InstanceTransform; InstanceTransform.SetLocation(RelativeLocation); InstanceTransform.SetScale3D(FVector(UniformScale)); - PreviewInstances->AddInstance(InstanceTransform); + PreviewLayer->AddInstance(InstanceTransform); +} + +FVector AHyperTwistHigherDimensionalTrainingShellActor::ProjectMagic120CellPoint( + const FVector4& Point) const +{ + const float Angle = FMath::DegreesToRadians(ProjectionAngleDegrees); + const float SecondaryAngle = Angle * 0.37f; + const float CosAngle = FMath::Cos(Angle); + const float SinAngle = FMath::Sin(Angle); + const float CosSecondary = FMath::Cos(SecondaryAngle); + const float SinSecondary = FMath::Sin(SecondaryAngle); + + const float RotatedX = (Point.X * CosAngle) - (Point.W * SinAngle); + const float RotatedW = (Point.X * SinAngle) + (Point.W * CosAngle); + const float RotatedY = (Point.Y * CosSecondary) - (Point.Z * SinSecondary); + const float RotatedZ = (Point.Y * SinSecondary) + (Point.Z * CosSecondary); + constexpr float ProjectionDistance = 3.2f; + const float Perspective = ProjectionDistance / + FMath::Max(ProjectionDistance - RotatedW, 0.35f); + + return FVector(RotatedX, RotatedY, RotatedZ) * PreviewRadius * Perspective + + FVector(0.0f, 0.0f, PreviewHeightOffset); +} + +FVector AHyperTwistHigherDimensionalTrainingShellActor::ProjectMagicCube5DPoint( + const FVector4& Point, + const float FifthCoordinate) const +{ + const float Angle = FMath::DegreesToRadians(ProjectionAngleDegrees); + const float CosAngle = FMath::Cos(Angle); + const float SinAngle = FMath::Sin(Angle); + const float SecondaryAngle = Angle * 0.73f; + const float CosSecondary = FMath::Cos(SecondaryAngle); + const float SinSecondary = FMath::Sin(SecondaryAngle); + + const float RotatedX = (Point.X * CosAngle) - (Point.W * SinAngle); + const float RotatedW = (Point.X * SinAngle) + (Point.W * CosAngle); + const float RotatedY = (Point.Y * CosSecondary) - (FifthCoordinate * SinSecondary); + const float RotatedV = (Point.Y * SinSecondary) + (FifthCoordinate * CosSecondary); + + constexpr float FiveDimensionalProjectionDistance = 3.6f; + const float FiveDimensionalPerspective = FiveDimensionalProjectionDistance / + FMath::Max(FiveDimensionalProjectionDistance - RotatedV, 0.4f); + const FVector4 Projected4D( + RotatedX * FiveDimensionalPerspective, + RotatedY * FiveDimensionalPerspective, + Point.Z * FiveDimensionalPerspective, + RotatedW * FiveDimensionalPerspective); + + constexpr float FourDimensionalProjectionDistance = 4.2f; + const float FourDimensionalPerspective = FourDimensionalProjectionDistance / + FMath::Max(FourDimensionalProjectionDistance - Projected4D.W, 0.45f); + const float SpatialScale = PreviewRadius * 0.42f; + return FVector(Projected4D.X, Projected4D.Y, Projected4D.Z) + * SpatialScale + * FourDimensionalPerspective + + FVector(0.0f, 0.0f, PreviewHeightOffset); } FText AHyperTwistHigherDimensionalTrainingShellActor::BuildLabelText() const { const FString EffectiveTitle = !Title.IsEmpty() ? Title - : (UsesMagic120CellPreview() ? TEXT("Magic120Cell Training Shell") : TEXT("MagicCube5D Training Shell")); - - const FString EffectiveSceneContextId = !SceneContextId.IsEmpty() - ? SceneContextId - : TEXT("scene-context/pending"); + : (UsesMagic120CellPreview() + ? TEXT("Magic120Cell // 120-cell projection lab") + : TEXT("MagicCube5D // order-3 projection lab")); return FText::FromString(FString::Printf( - TEXT("%s\n%s"), + TEXT("%s\n%d canonical elements // %s"), *EffectiveTitle, - *EffectiveSceneContextId)); + GetCanonicalElementCount(), + *GetProjectionLayerLabel())); } bool AHyperTwistHigherDimensionalTrainingShellActor::UsesMagic120CellPreview() const @@ -219,3 +945,10 @@ bool AHyperTwistHigherDimensionalTrainingShellActor::UsesMagic120CellPreview() c HyperTwistHigherDimensionalTrainingShellActorInternal::Magic120CellFamilyKey, ESearchCase::IgnoreCase); } + +FString AHyperTwistHigherDimensionalTrainingShellActor::GetNormalizedFamilyKey() const +{ + return UsesMagic120CellPreview() + ? HyperTwistHigherDimensionalTrainingShellActorInternal::Magic120CellFamilyKey + : HyperTwistHigherDimensionalTrainingShellActorInternal::MagicCube5DFamilyKey; +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/prun.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/prun.cpp index dc685b7..be22af1 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/prun.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/prun.cpp @@ -375,7 +375,7 @@ namespace prun { return precheck[coord::N_SLICE2 * corners + coord::slice_to_slice2(slice)]; } - bool init(bool file) { + bool init(bool file, const std::string& save_path) { init_base(); if (!file) { @@ -385,7 +385,7 @@ namespace prun { return true; } - FILE *f = open_file(SAVE, "rb"); + FILE *f = open_file(save_path, "rb"); int err = 0; if (f == NULL) { @@ -393,7 +393,9 @@ namespace prun { init_phase2(); init_precheck(); - f = open_file(SAVE, "wb"); + f = open_file(save_path, "wb"); + if (f == NULL) + return false; if (fwrite(phase1, sizeof(prun1), N_FS1TWIST, f) != N_FS1TWIST) err = 1; if (fwrite(phase2, sizeof(uint8_t), N_CORNUD2, f) != N_CORNUD2) @@ -401,7 +403,7 @@ namespace prun { if (fwrite(precheck, sizeof(uint8_t), N_CSLICE2, f) != N_CSLICE2) err = 1; if (err) - remove(SAVE.c_str()); // delete file if there was some error writing it + remove(save_path.c_str()); // delete file if there was some error writing it } else { phase1 = new prun1[N_FS1TWIST]; phase2 = new uint8_t[N_CORNUD2]; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/prun.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/prun.h index c52a172..0bb2baa 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/prun.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/ThirdParty/rob-twophase/prun.h @@ -6,6 +6,7 @@ #define __PRUN__ #include +#include #include "coord.h" #include "sym.h" @@ -24,12 +25,13 @@ namespace prun { extern prun1 *phase1; extern uint8_t *phase2; extern uint8_t *precheck; + extern const std::string SAVE; int get_phase1(int flip, int slice, int twist, int togo, move::mask& next); int get_phase2(int corners, int udedges); int get_precheck(int corners, int slice); - bool init(bool file = true); + bool init(bool file = true, const std::string& save_path = SAVE); } diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistDiagnostics/HyperTwistRuntimeDiagnostics.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistDiagnostics/HyperTwistRuntimeDiagnostics.h new file mode 100644 index 0000000..8b5f494 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistDiagnostics/HyperTwistRuntimeDiagnostics.h @@ -0,0 +1,31 @@ +#pragma once + +#include "CoreMinimal.h" + +namespace HyperTwistRuntimeDiagnostics +{ + /** + * Initialize the per-process diagnostics file. The optional + * -HyperTwistDiagnosticsLog= command-line value overrides the + * normal Saved/Logs/HyperTwistRuntime-latest.log destination. + */ + UNREALHYPERTWIST_API bool InitializeSession(); + + /** Return the resolved direct diagnostics path for this process. */ + UNREALHYPERTWIST_API FString GetDiagnosticsLogPath(); + + /** + * Append a timestamped semantic event without relying on UE_LOG. This + * remains available in stock Shipping builds where engine logging is + * compiled out. + */ + UNREALHYPERTWIST_API bool AppendEvent( + const FString& Stage, + const FString& Message); + + /** True only when the packaged process explicitly requests a semantic-ready frame. */ + UNREALHYPERTWIST_API bool IsReadyFrameCaptureRequested(); + + /** Capture the complete game window, including Slate, into Saved diagnostics evidence. */ + UNREALHYPERTWIST_API bool RequestReadyFrameCapture(const FString& SurfaceId); +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeActor.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeActor.h index b4b0a7f..ecf6c07 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeActor.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeActor.h @@ -15,6 +15,9 @@ class UNREALHYPERTWIST_API AHyperTwistClassicCubeActor : public AActor public: AHyperTwistClassicCubeActor(); + /** A 3x3x3 cube has 26 visible cubies; the enclosed core is intentionally not rendered. */ + static constexpr int32 ExpectedRenderablePieceCount = 26; + /** Size of each cubelet (one small cube) in unreal units. */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Cube") float CubeletSize = 10.0f; @@ -39,6 +42,14 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Cube") UMaterialInterface* InternalMaterial; + /** Low emissive baseline that keeps every sticker readable in sparse training maps. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Cube|Presentation", meta = (ClampMin = "0.0")) + float BaseGlowStrength = 0.65f; + + /** Emissive strength applied to the solver-hinted world-facing side. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Cube|Presentation", meta = (ClampMin = "0.0")) + float HintGlowStrength = 7.5f; + /** Duration of a quarter-turn animation in seconds. */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Cube") float TurnDuration = 0.15f; @@ -138,6 +149,22 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Cube") bool IsSettled() const; + /** Number of generated, registered cubelet mesh components with geometry. */ + UFUNCTION(BlueprintPure, Category = "HyperTwist|Cube|Diagnostics") + int32 GetRenderablePieceCount() const; + + /** True when cubie occupancy, sticker orientation, and live mesh transforms form one coherent 3x3x3 cube. */ + UFUNCTION(BlueprintPure, Category = "HyperTwist|Cube|Diagnostics") + bool HasValidRenderableState() const; + + /** Empty for a coherent renderable cube; otherwise identifies the first violated presentation invariant. */ + UFUNCTION(BlueprintPure, Category = "HyperTwist|Cube|Diagnostics") + FString GetRenderableStateValidationError() const; + + /** Rebuild transient procedural geometry when a cooked map did not preserve a complete presentation. */ + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Cube|Diagnostics") + bool EnsureRenderablePresentation(); + virtual void OnConstruction(const FTransform& Transform) override; protected: @@ -167,7 +194,7 @@ private: TArray PieceIndices; FQuat PivotStartRot; FQuat PivotTargetRot; - float StartTime = 0.0f; + double StartTime = 0.0; float Duration = 0.15f; EHyperTwistClassicCubeFace Face = EHyperTwistClassicCubeFace::Up; EHyperTwistRotationDirection Direction = EHyperTwistRotationDirection::Clockwise; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h index 393d5c5..9785cdd 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h @@ -29,18 +29,6 @@ class UNREALHYPERTWIST_API AHyperTwistClassicCubeGameMode public: AHyperTwistClassicCubeGameMode(); - virtual void BeginPlay() override; - virtual void Tick(float DeltaSeconds) override; - virtual void RequestClassicCubeFreshAttempt_Implementation() override; - virtual void RequestClassicCubeHint_Implementation() override; - virtual void RequestClassicCubeSubmitSolve_Implementation() override; - virtual void RequestClassicCubeToggleFollowAlongMode_Implementation() override; - virtual void RequestClassicCubeBeginVoiceCommandCapture_Implementation() override; - virtual void RequestClassicCubeEndVoiceCommandCapture_Implementation() override; - virtual void RequestClassicCubeCycleVoiceProfile_Implementation() override; - virtual bool CanClassicCubeAcceptGameplayMoveInput_Implementation() const override; - virtual bool IsClassicCubeAnyActionButtonHovered_Implementation() const override; - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|HUD") bool bAutoCreateHud = true; @@ -53,6 +41,14 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube") bool bAutoSpawnCubeActor = true; + /** Recover a missing PlayerStart/default pawn with a possessed orbit camera. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Presentation") + bool bEnsurePlayableOrbitPawn = true; + + /** Supply a first-party key light when a sparse or damaged map has none. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Presentation") + bool bEnsureRuntimeLighting = true; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube") FVector CubeSpawnLocation = FVector::ZeroVector; @@ -78,6 +74,14 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Solver") bool bShowFullSolutionSequence = true; + /** Search budget used by the non-blocking classic-cube guidance worker. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Solver", meta = (ClampMin = "1")) + int32 SolverTimeLimitMs = 1500; + + /** Maximum notation length accepted from the classic-cube guidance worker. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Solver", meta = (ClampMin = "1")) + int32 SolverMaximumMoveCount = 32; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Speech") bool bEnableVoiceCommandCapture = true; @@ -96,6 +100,9 @@ public: UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|ClassicCube") TObjectPtr ActiveCubeActor = nullptr; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|ClassicCube|Presentation") + TObjectPtr ActiveOrbitPawn = nullptr; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|ClassicCube|HUD") TObjectPtr ActiveHudWidget = nullptr; @@ -137,10 +144,24 @@ public: bool IsVoiceCommandCaptureActive() const; protected: + virtual void BeginPlay() override; + virtual void Tick(float DeltaSeconds) override; + virtual void RequestClassicCubeFreshAttempt_Implementation() override; + virtual void RequestClassicCubeHint_Implementation() override; + virtual void RequestClassicCubeSubmitSolve_Implementation() override; + virtual void RequestClassicCubeToggleFollowAlongMode_Implementation() override; + virtual void RequestClassicCubeBeginVoiceCommandCapture_Implementation() override; + virtual void RequestClassicCubeEndVoiceCommandCapture_Implementation() override; + virtual void RequestClassicCubeCycleVoiceProfile_Implementation() override; + virtual bool CanClassicCubeAcceptGameplayMoveInput_Implementation() const override; + virtual bool IsClassicCubeAnyActionButtonHovered_Implementation() const override; + void ApplyLaunchOverridesFromCommandLine(); void QueueReplayAutoExit(double CountdownSeconds); UHyperTwistTrainingSubsystem* ResolveTrainingSubsystem() const; AHyperTwistClassicCubeActor* ResolveOrSpawnCubeActor(); + AHyperTwistClassicCubeOrbitPawn* ResolveOrSpawnOrbitPawn(); + void EnsureRuntimeLighting(); UHyperTwistClassicCubeHUDWidget* ResolveOrCreateHudWidget(); void RefreshHud(); void TickReplayPlayback(float DeltaSeconds); @@ -154,6 +175,13 @@ protected: void RefreshLocalLeaderboardLine(); void RecordSolvedAttemptToLocalLeaderboard(); void RefreshSolverGuidance(bool bForceClearHint = false); + void StartPendingSolverGuidanceTask(); + void CompleteSolverGuidanceTask( + uint64 RequestSerial, + const FString& FaceletString, + bool bForceClearHint, + TArray&& SolutionNotation + ); void RefreshVoiceProfiles(); TArray ResolveScrambleMovesForCurrentSession() const; void ProcessCompletedMoveHistory(); @@ -210,6 +238,8 @@ private: FString HintLineOverride; FString ModeLineOverride; FString VoiceLineOverride; + FString PendingSolverFaceletString; + FString ActiveSolverFaceletString; bool bAwaitingScrambleSettlement = false; bool bInspectionStarted = false; bool bSolveStartedFromGameplayMove = false; @@ -224,6 +254,9 @@ private: bool bVoiceCommandCaptureActive = false; bool bScrambleReadyNarrated = false; bool bSolveStartNarrated = false; + bool bRuntimeLightingReady = false; + bool bSolverGuidanceTaskActive = false; + bool bPendingSolverForceClearHint = false; int32 ObservedCompletedMoveCount = 0; int32 FollowAlongStepIndex = 0; int32 FollowAlongCorrectMoveCount = 0; @@ -231,10 +264,13 @@ private: int32 ReplayPlaybackNextMoveIndex = 0; int32 VoiceCaptureSampleRateHz = 16000; int32 VoiceCaptureChannelCount = 1; + uint64 SolverGuidanceRequestSerial = 0; double ReplayPlaybackElapsedSeconds = 0.0; double ReplayAutoQuitTailSeconds = 1.5; double ReplayAutoQuitCountdownSeconds = -1.0; double VoiceCaptureStartedAtSeconds = 0.0; + + void RequestRuntimeReadyDiagnosticsCapture(); }; UCLASS(BlueprintType, Blueprintable) diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h index 7f36411..4fe9f4b 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h @@ -6,6 +6,7 @@ class UButton; class UTextBlock; +class UUniformGridPanel; class UVerticalBox; UCLASS(BlueprintType, Blueprintable) @@ -14,8 +15,15 @@ class UNREALHYPERTWIST_API UHyperTwistClassicCubeHUDWidget : public UUserWidget GENERATED_BODY() public: + virtual void NativeOnInitialized() override; virtual void NativeConstruct() override; + UFUNCTION(BlueprintCallable, Category = "HyperTwist|ClassicCube|HUD") + bool PrepareClassicCubeHudSurface(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|ClassicCube|HUD") + bool IsClassicCubeHudSurfaceReady() const; + UFUNCTION(BlueprintCallable, Category = "HyperTwist|ClassicCube|HUD") void SetHudLines( const FString& InStatusLine, @@ -44,15 +52,25 @@ public: UFUNCTION(BlueprintCallable, Category = "HyperTwist|ClassicCube|HUD") void SetVoiceCycleButtonLabel(const FString& InLabel); +protected: + virtual TSharedRef RebuildWidget() override; + private: void EnsureWidgetTreeBuilt(); - UTextBlock* AddLine(UVerticalBox* Parent, const TCHAR* WidgetName); - UButton* CreateActionButton( + UTextBlock* AddLine( UVerticalBox* Parent, + const TCHAR* WidgetName, + int32 FontSize, + const FLinearColor& Color + ); + UButton* CreateActionButton( + UUniformGridPanel* Parent, const TCHAR* ButtonName, const TCHAR* LabelName, TObjectPtr& OutLabel, - const FString& InitialLabel + const FString& InitialLabel, + int32 Row, + int32 Column ); UFUNCTION() diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeOrbitPawn.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeOrbitPawn.h index 968a863..27ceaf1 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeOrbitPawn.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeOrbitPawn.h @@ -27,16 +27,16 @@ public: bool bFollowActiveCubeActor = true; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") - float InitialArmLength = 360.0f; + float InitialArmLength = 118.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") - float MinimumArmLength = 180.0f; + float MinimumArmLength = 72.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") - float MaximumArmLength = 720.0f; + float MaximumArmLength = 480.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") - float ZoomStep = 42.0f; + float ZoomStep = 24.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") float OrbitYawDegreesPerPixel = 0.25f; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardActor.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardActor.h index c4db972..aa17da5 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardActor.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardActor.h @@ -25,7 +25,7 @@ public: bool bRemoveDashboardOnEndPlay = true; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Coach|Dashboard") - int32 DashboardZOrder = 0; + int32 DashboardZOrder = 100; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Coach|Dashboard") TSubclassOf DashboardWidgetClass; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h index 75c1b8e..608fb69 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h @@ -557,9 +557,16 @@ class UNREALHYPERTWIST_API UHyperTwistCoachDashboardWidget : public UHyperTwistT GENERATED_BODY() public: + virtual void NativeOnInitialized() override; virtual void NativeConstruct() override; virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override; + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard") + bool PrepareCoachDashboardSurface(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Coach|Dashboard") + bool IsCoachDashboardSurfaceReady() const; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Coach|Dashboard") int32 DefaultDeferredHours = 24; @@ -1187,6 +1194,8 @@ public: bool UndoLastLiveAttemptSplit(); protected: + virtual TSharedRef RebuildWidget() override; + UPROPERTY(Transient) TObjectPtr RootLayout = nullptr; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistFirstRunLaunchLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistFirstRunLaunchLibrary.h index 4b566e1..d8485f7 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistFirstRunLaunchLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistFirstRunLaunchLibrary.h @@ -5,6 +5,13 @@ #include "HyperTwistFirstRunLaunchLibrary.generated.h" +enum class EHyperTwistPackagedStartupRouteParseResult : uint8 +{ + NotPresent, + Valid, + Invalid +}; + USTRUCT(BlueprintType) struct UNREALHYPERTWIST_API FHyperTwistFirstRunLaunchRoute { @@ -80,6 +87,9 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|FirstRun") static FString GetPackagedStartupMapAssetPath(); + UFUNCTION(BlueprintPure, Category = "HyperTwist|FirstRun") + static FString GetPackagedStartupRouteArgumentName(); + UFUNCTION(BlueprintPure, Category = "HyperTwist|FirstRun") static FString GetRuntimeLogFileName(); @@ -97,4 +107,10 @@ public: const FString& RouteId, FHyperTwistFirstRunLaunchRoute& OutRoute ); + + static EHyperTwistPackagedStartupRouteParseResult ParsePackagedStartupRouteArgument( + const TCHAR* CommandLine, + FHyperTwistFirstRunLaunchRoute& OutRoute, + FString& OutFailureReason + ); }; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistFirstRunLaunchPlayerController.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistFirstRunLaunchPlayerController.h index c6cb10d..9f59672 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistFirstRunLaunchPlayerController.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistFirstRunLaunchPlayerController.h @@ -24,7 +24,13 @@ public: bool bShowFirstRunLaunchOnBeginPlay = true; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|FirstRun") - int32 FirstRunLaunchZOrder = 0; + int32 FirstRunLaunchZOrder = 100; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|FirstRun|Diagnostics", meta = (ClampMin = "1")) + int32 MaxFirstRunSurfaceValidationAttempts = 20; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|FirstRun|Diagnostics", meta = (ClampMin = "0.01")) + float FirstRunSurfaceValidationIntervalSeconds = 0.1f; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|FirstRun") TSubclassOf FirstRunLaunchWidgetClass; @@ -67,6 +73,7 @@ public: protected: void ApplyFirstRunInputMode(); + bool TryOpenCommandLineStartupRoute(); void OpenMapRoute(const FString& MapAssetPath, const FString& GameModeClassPath); AHyperTwistCoachDashboardActor* FindExistingDashboardActor(); AHyperTwistCoachDashboardActor* SpawnDashboardActor(); @@ -80,6 +87,8 @@ protected: void PersistStartupDiagnostics(const FString& ResultStatus) const; bool TryRecoverFromFirstRunFailure(const FString& FailureReason); FString DescribeCurrentWorldName() const; + void ScheduleFirstRunSurfaceValidation(); + void ValidateFirstRunSurfaceLayout(); UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|FirstRun|Diagnostics") FString StartupDiagnosticsLogPath; @@ -97,4 +106,6 @@ protected: bool bStartupRecoveryAttempted = false; TArray StartupDiagnosticsLines; + FTimerHandle FirstRunSurfaceValidationTimerHandle; + int32 FirstRunSurfaceValidationAttempt = 0; }; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistFirstRunLaunchWidget.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistFirstRunLaunchWidget.h index 1c67511..7f2d384 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistFirstRunLaunchWidget.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistFirstRunLaunchWidget.h @@ -24,6 +24,7 @@ class UNREALHYPERTWIST_API UHyperTwistFirstRunLaunchWidget GENERATED_BODY() public: + virtual void NativeOnInitialized() override; virtual void NativeConstruct() override; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|FirstRun") @@ -44,7 +45,12 @@ public: UFUNCTION(BlueprintCallable, Category = "HyperTwist|FirstRun") void RebuildFirstRunLaunchSurface(); + UFUNCTION(BlueprintPure, Category = "HyperTwist|FirstRun") + bool IsFirstRunLaunchSurfaceReady() const; + protected: + virtual TSharedRef RebuildWidget() override; + UFUNCTION() void OpenCoachDashboard(); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistHigherDimensionalTrainingGameMode.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistHigherDimensionalTrainingGameMode.h new file mode 100644 index 0000000..295d4fe --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistHigherDimensionalTrainingGameMode.h @@ -0,0 +1,135 @@ +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/GameModeBase.h" +#include "GameFramework/HUD.h" +#include "GameFramework/Pawn.h" +#include "HyperTwistTraining/HyperTwistCoachDashboardPlayerController.h" +#include "HyperTwistHigherDimensionalTrainingGameMode.generated.h" + +class AHyperTwistHigherDimensionalTrainingShellActor; +class ADirectionalLight; +class UCameraComponent; +class USceneComponent; +class USpringArmComponent; + +UCLASS(BlueprintType, Blueprintable) +class UNREALHYPERTWIST_API AHyperTwistHigherDimensionalOrbitPawn : public APawn +{ + GENERATED_BODY() + +public: + AHyperTwistHigherDimensionalOrbitPawn(); + + virtual void BeginPlay() override; + virtual void Tick(float DeltaSeconds) override; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|HigherDimensional|Camera") + TObjectPtr SceneRoot = nullptr; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|HigherDimensional|Camera") + TObjectPtr SpringArm = nullptr; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|HigherDimensional|Camera") + TObjectPtr CameraComponent = nullptr; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|HigherDimensional|Camera") + float InitialArmLength = 820.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|HigherDimensional|Camera") + float MinimumArmLength = 360.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|HigherDimensional|Camera") + float MaximumArmLength = 1550.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|HigherDimensional|Camera") + float InitialYawDegrees = -34.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|HigherDimensional|Camera") + float InitialPitchDegrees = -16.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|HigherDimensional|Camera") + float OrbitDegreesPerPixel = 0.24f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|HigherDimensional|Camera") + float ZoomStep = 85.0f; + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|HigherDimensional|Camera") + void ResetOrbit(); + +private: + FVector OrbitFocusPoint = FVector(0.0f, 0.0f, 90.0f); + float CurrentYawDegrees = -34.0f; + float CurrentPitchDegrees = -16.0f; + + void RefreshOrbitFocusPoint(); + void ApplyOrbitTransform(); + bool ShouldOrbitFromMouseInput() const; +}; + +UCLASS(BlueprintType, Blueprintable) +class UNREALHYPERTWIST_API AHyperTwistHigherDimensionalTrainingHUD : public AHUD +{ + GENERATED_BODY() + +public: + virtual void DrawHUD() override; + +private: + TWeakObjectPtr CachedTrainingShell; + + AHyperTwistHigherDimensionalTrainingShellActor* ResolveTrainingShell(); + void DrawStatusLine(const FString& Text, float X, float& InOutY, const FLinearColor& Color, float Scale = 1.0f); +}; + +UCLASS(BlueprintType, Blueprintable) +class UNREALHYPERTWIST_API AHyperTwistHigherDimensionalTrainingPlayerController + : public AHyperTwistCoachDashboardPlayerController +{ + GENERATED_BODY() + +public: + AHyperTwistHigherDimensionalTrainingPlayerController(); + + virtual void BeginPlay() override; + virtual void PlayerTick(float DeltaTime) override; + +private: + TWeakObjectPtr CachedTrainingShell; + + AHyperTwistHigherDimensionalTrainingShellActor* ResolveTrainingShell(); + void ToggleCoachDashboardSurface(); +}; + +UCLASS(BlueprintType, Blueprintable) +class UNREALHYPERTWIST_API AHyperTwistHigherDimensionalTrainingGameMode : public AGameModeBase +{ + GENERATED_BODY() + +public: + AHyperTwistHigherDimensionalTrainingGameMode(); + + virtual void BeginPlay() override; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|HigherDimensional") + TSubclassOf TrainingShellActorClass; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|HigherDimensional") + TObjectPtr ActiveTrainingShell = nullptr; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|HigherDimensional|Presentation") + TObjectPtr ActivePresentationLight = nullptr; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|HigherDimensional") + FString ResolveCurrentFamilyKey() const; + +private: + AHyperTwistHigherDimensionalTrainingShellActor* FindExistingTrainingShell() const; + AHyperTwistHigherDimensionalTrainingShellActor* SpawnTrainingShell(const FString& FamilyKey); + void EnsureRuntimeLighting(const FString& FamilyKey); + void RequestRuntimeReadyDiagnosticsCapture(); + void PersistRuntimeReadinessDiagnostics( + const FString& FamilyKey, + const AHyperTwistHigherDimensionalTrainingShellActor* TrainingShell + ) const; +}; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistHigherDimensionalTrainingShellActor.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistHigherDimensionalTrainingShellActor.h index 41d0e43..8dcb831 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistHigherDimensionalTrainingShellActor.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistHigherDimensionalTrainingShellActor.h @@ -5,8 +5,11 @@ #include "HyperTwistHigherDimensionalTrainingShellActor.generated.h" class UInstancedStaticMeshComponent; +class UMaterialInstanceDynamic; +class UMaterialInterface; class USceneComponent; class UStaticMesh; +class UStaticMeshComponent; class UTextRenderComponent; UCLASS(BlueprintType) @@ -17,7 +20,10 @@ class UNREALHYPERTWIST_API AHyperTwistHigherDimensionalTrainingShellActor : publ public: AHyperTwistHigherDimensionalTrainingShellActor(); + virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; virtual void OnConstruction(const FTransform& Transform) override; + virtual void Tick(float DeltaSeconds) override; UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Shell") TObjectPtr SceneRoot = nullptr; @@ -25,6 +31,12 @@ public: UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Shell") TObjectPtr PreviewInstances = nullptr; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Shell") + TArray> PreviewColorLayers; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Shell") + TObjectPtr PresentationFloor = nullptr; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Shell") TObjectPtr LabelComponent = nullptr; @@ -91,16 +103,121 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Shell") float PreviewHeightOffset = 90.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Shell|Projection") + bool bAutoRotateProjection = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Shell|Projection", meta = (ClampMin = "0.0")) + float ProjectionRotationSpeedDegrees = 12.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Shell|Projection", meta = (ClampMin = "0.0")) + float ProjectionAngleDegrees = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Shell|Projection") + int32 VisibleProjectionLayer = -1; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Shell|State") + int32 RuntimeStateSeed = 0; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Shell|State") + FString LastRuntimeAction = TEXT("ready"); + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Shell|Persistence") + FString LastPersistenceStatus = TEXT("not-loaded"); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Shell") + void ConfigureForFamily(const FString& InFamilyKey); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Shell|Projection") + void ToggleAutoRotateProjection(); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Shell|Projection") + void NudgeProjection(float DeltaDegrees); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Shell|Projection") + void CycleProjectionLayer(int32 Direction); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Shell|Projection") + void ResetProjection(); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Shell|State") + void CreateScrambledRuntimeState(); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Shell|Persistence") + bool SaveRuntimeState(); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Shell|Persistence") + bool LoadRuntimeState(); + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Shell") + int32 GetRenderableElementCount() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Shell") + int32 GetCanonicalElementCount() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Shell") + int32 GetProjectionLayerCount() const; + + /** Number of distinct first-party materials assigned to the six projection layers. */ + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Shell|Diagnostics") + int32 GetDistinctPreviewMaterialCount() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Shell") + FString GetProjectionLayerLabel() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Shell") + FString GetRuntimeStatusSummary() const; + + UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|Shell|Persistence") + FString GetRuntimeStatePath() const; + + static int32 GetCanonicalElementCountForFamily(const FString& InFamilyKey); + private: + UPROPERTY(Transient) TObjectPtr CubePreviewMesh = nullptr; + + UPROPERTY(Transient) TObjectPtr SpherePreviewMesh = nullptr; + UPROPERTY(Transient) + TArray> PreviewMaterials; + + /** Retained emissive variants prevent GC and preserve layer color on cooked ISM geometry. */ + UPROPERTY(Transient) + TArray> PreviewDynamicMaterials; + + UPROPERTY(Transient) + TObjectPtr FloorDynamicMaterial = nullptr; + + TArray SourcePoints4D; + TArray SourceFifthCoordinates; + TArray SourceLayerIndices; + TArray SourceColorIndices; + float ProjectionRefreshAccumulator = 0.0f; + void RefreshPreview(); void RefreshLabel(); void RefreshMetadataTags(); - void BuildMagic120CellPreview(); - void BuildMagicCube5DPreview(); - void AddPreviewInstance(const FVector& RelativeLocation, float UniformScale); + void RebuildSourcePoints(); + void BuildMagic120CellSourcePoints(); + void BuildMagicCube5DSourcePoints(); + void UpdatePreviewTransforms(); + void ClearPreviewInstances(); + void ConfigurePreviewComponents(); + void AddSourcePoint( + const FVector4& Point4D, + float FifthCoordinate, + int32 LayerIndex, + int32 ColorIndex + ); + void AddPreviewInstance( + const FVector& RelativeLocation, + float UniformScale, + int32 ColorIndex + ); + FVector ProjectMagic120CellPoint(const FVector4& Point) const; + FVector ProjectMagicCube5DPoint(const FVector4& Point, float FifthCoordinate) const; FText BuildLabelText() const; bool UsesMagic120CellPreview() const; + FString GetNormalizedFamilyKey() const; }; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeActorTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeActorTest.cpp index 6401dd1..685d7f8 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeActorTest.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeActorTest.cpp @@ -25,6 +25,70 @@ namespace HyperTwistClassicCubeActorTestInternal } } +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistClassicCubePresentationDefaultsTest, + "HyperTwist.Simulation.ClassicCube.PresentationDefaults", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistClassicCubePresentationDefaultsTest::RunTest(const FString& Parameters) +{ + AHyperTwistClassicCubeActor* CubeActor = + NewObject(GetTransientPackage()); + TestNotNull(TEXT("The classic cube actor must be constructible for presentation checks."), CubeActor); + if (CubeActor == nullptr) + { + return false; + } + + TestEqual( + TEXT("The runtime fallback cube must own all six first-party sticker materials."), + CubeActor->FaceMaterials.Num(), + 6 + ); + for (int32 MaterialIndex = 0; MaterialIndex < CubeActor->FaceMaterials.Num(); ++MaterialIndex) + { + TestNotNull( + *FString::Printf(TEXT("Sticker material %d must be available without map-authored overrides."), MaterialIndex), + CubeActor->FaceMaterials[MaterialIndex] + ); + } + TestNotNull( + TEXT("The runtime fallback cube must own its first-party internal material."), + CubeActor->InternalMaterial + ); + TestTrue( + TEXT("The base glow must keep stickers readable in sparse maps."), + CubeActor->BaseGlowStrength > 0.0f + ); + TestTrue( + TEXT("The hint glow must remain stronger than the presentation baseline."), + CubeActor->HintGlowStrength > CubeActor->BaseGlowStrength + ); + TestEqual( + TEXT("The presentation invariant must cover all 26 visible cubies without inventing a rendered core."), + AHyperTwistClassicCubeActor::ExpectedRenderablePieceCount, + 26 + ); + TestTrue( + TEXT("Classic turn animation must remain tick-capable in packaged runtime."), + CubeActor->PrimaryActorTick.bCanEverTick + ); + TestTrue( + TEXT("Classic turn animation must start with actor ticking enabled."), + CubeActor->PrimaryActorTick.bStartWithTickEnabled + ); + TestTrue( + TEXT("Classic turn animation must be able to finish through transient application pause states."), + CubeActor->PrimaryActorTick.bTickEvenWhenPaused + ); + TestTrue( + TEXT("Classic quarter turns must retain a positive animation duration."), + CubeActor->TurnDuration > 0.0f + ); + return true; +} + IMPLEMENT_SIMPLE_AUTOMATION_TEST( FHyperTwistClassicCubeImpactNormalMappingTest, "HyperTwist.Simulation.ClassicCube.ImpactNormalMapping", diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeBehaviorTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeBehaviorTest.cpp index ba651f9..eda7fd0 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeBehaviorTest.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeBehaviorTest.cpp @@ -239,6 +239,66 @@ bool FHyperTwistClassicCubeRotationTest::RunTest(const FString& Parameters) CubeActor->GetGameplayCompletedMoveCount(), 1 ); + TestTrue( + TEXT("A single R turn must preserve a coherent 26-cubie, 54-sticker renderable state."), + CubeActor->HasValidRenderableState() + ); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistClassicCubeRenderableStateInvariantTest, + "HyperTwist.Simulation.ClassicCube.Behavior.RenderableStateInvariant", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistClassicCubeRenderableStateInvariantTest::RunTest(const FString& Parameters) +{ + AHyperTwistClassicCubeActor* CubeActor = + HyperTwistClassicCubeBehaviorTestInternal::MakeCubeActor(); + TestNotNull(TEXT("The classic cube actor must exist for renderable-state checks."), CubeActor); + if (CubeActor == nullptr) + { + return false; + } + + FRandomStream ScrambleStream(0xA17FA11A); + const TArray Moves = + HyperTwistClassicCubeBehaviorTestInternal::GenerateDeterministicScramble( + ScrambleStream, + 120); + for (int32 MoveIndex = 0; MoveIndex < Moves.Num(); ++MoveIndex) + { + FHyperTwistClassicCubeMoveDescriptor MoveDescriptor; + if (!TestTrue( + *FString::Printf(TEXT("Move %d must parse for renderable-state validation."), MoveIndex), + UHyperTwistClassicCubeCommandLibrary::TryParseMoveNotation( + Moves[MoveIndex], + MoveDescriptor))) + { + return false; + } + if (!TestTrue( + *FString::Printf(TEXT("Move %d must execute for renderable-state validation."), MoveIndex), + CubeActor->ExecuteMoveDescriptorImmediately(MoveDescriptor))) + { + return false; + } + + const FString ValidationError = CubeActor->GetRenderableStateValidationError(); + if (!TestTrue( + *FString::Printf( + TEXT("Move %d (%s) must preserve coherent cubie occupancy and outward sticker placement: %s"), + MoveIndex, + *Moves[MoveIndex], + *ValidationError), + ValidationError.IsEmpty())) + { + return false; + } + } + + AddInfo(TEXT("Validated cubie occupancy and all 54 outward stickers after 120 deterministic moves.")); return true; } diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeGameModeTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeGameModeTest.cpp index e2a7cd4..efc66f8 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeGameModeTest.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeGameModeTest.cpp @@ -36,6 +36,14 @@ bool FHyperTwistClassicCubeGameModeDefaultsTest::RunTest(const FString& Paramete ); TestTrue(TEXT("The classic cube game mode must still auto-create its HUD by default."), GameMode->bAutoCreateHud); TestTrue(TEXT("The classic cube game mode must still auto-spawn the cube actor by default."), GameMode->bAutoSpawnCubeActor); + TestTrue( + TEXT("The classic cube game mode must recover a playable orbit camera when map anchors are missing."), + GameMode->bEnsurePlayableOrbitPawn + ); + TestTrue( + TEXT("The classic cube game mode must recover presentation lighting in sparse maps."), + GameMode->bEnsureRuntimeLighting + ); TestTrue( TEXT("The classic cube game mode must keep its default cube spawn above the generic validation-map floor."), GameMode->CubeSpawnLocation.Z > 0.0f @@ -45,6 +53,26 @@ bool FHyperTwistClassicCubeGameModeDefaultsTest::RunTest(const FString& Paramete GameMode->DefaultSessionMode, EHyperTwistClassicCubeSessionMode::FreePlay ); + TestTrue( + TEXT("The Classic game mode must keep its runtime settlement loop tick-capable."), + GameMode->PrimaryActorTick.bCanEverTick + ); + TestTrue( + TEXT("The Classic game mode must start with settlement processing enabled."), + GameMode->PrimaryActorTick.bStartWithTickEnabled + ); + TestTrue( + TEXT("The Classic game mode must service wall-clock settlement diagnostics while the world is paused."), + GameMode->PrimaryActorTick.bTickEvenWhenPaused + ); + TestTrue( + TEXT("The classic solver worker must have a positive time budget."), + GameMode->SolverTimeLimitMs > 0 + ); + TestTrue( + TEXT("The classic solver worker must accept at least one move."), + GameMode->SolverMaximumMoveCount > 0 + ); return true; } @@ -97,6 +125,10 @@ bool FHyperTwistClassicCubeOrbitPawnDefaultsTest::RunTest(const FString& Paramet OrbitPawn->MinimumArmLength < OrbitPawn->InitialArmLength && OrbitPawn->InitialArmLength < OrbitPawn->MaximumArmLength ); + TestTrue( + TEXT("The default orbit framing must keep the 3x3 puzzle legible at desktop resolution."), + OrbitPawn->InitialArmLength <= 125.0f + ); TestTrue( TEXT("The orbit pawn must follow the active cube actor by default."), OrbitPawn->bFollowActiveCubeActor diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistFirstRunLaunchSurfaceTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistFirstRunLaunchSurfaceTest.cpp index c150f6c..039d9ed 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistFirstRunLaunchSurfaceTest.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistFirstRunLaunchSurfaceTest.cpp @@ -3,6 +3,10 @@ #include "Misc/AutomationTest.h" #include "Misc/ConfigCacheIni.h" +#include "Blueprint/WidgetTree.h" +#include "Components/Button.h" +#include "HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h" +#include "HyperTwistTraining/HyperTwistCoachDashboardWidget.h" #include "HyperTwistTraining/HyperTwistFirstRunLaunchGameMode.h" #include "HyperTwistTraining/HyperTwistFirstRunLaunchLibrary.h" #include "HyperTwistTraining/HyperTwistFirstRunLaunchPlayerController.h" @@ -78,16 +82,20 @@ bool FHyperTwistFirstRunLaunchRouteContractTest::RunTest(const FString& Paramete ); TestTrue( - TEXT("The Magic120Cell route must target its dedicated family map."), + TEXT("The Magic120Cell route must target its dedicated family map and runtime game mode."), UHyperTwistFirstRunLaunchLibrary::TryFindFirstRunLaunchRoute(TEXT("magic-120-cell-training"), Route) && Route.MapAssetPath == TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining") + && Route.GameModeClassPath == TEXT("/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingGameMode") + && Route.LaunchUrl == TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining?game=/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingGameMode") && Route.RuntimeProofStatus == TEXT("ready-dedicated-family-map") ); TestTrue( - TEXT("The MagicCube5D route must target its dedicated family map."), + TEXT("The MagicCube5D route must target its dedicated family map and runtime game mode."), UHyperTwistFirstRunLaunchLibrary::TryFindFirstRunLaunchRoute(TEXT("magic-cube-5d-training"), Route) && Route.MapAssetPath == TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining") + && Route.GameModeClassPath == TEXT("/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingGameMode") + && Route.LaunchUrl == TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining?game=/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingGameMode") && Route.RuntimeProofStatus == TEXT("ready-dedicated-family-map") ); @@ -103,6 +111,92 @@ bool FHyperTwistFirstRunLaunchRouteContractTest::RunTest(const FString& Paramete return true; } +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistPackagedStartupRouteArgumentContractTest, + "HyperTwist.FirstRun.PackagedStartupRouteArgumentContract", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistPackagedStartupRouteArgumentContractTest::RunTest(const FString& Parameters) +{ + TestEqual( + TEXT("The packaged startup-route switch must have one stable public name."), + UHyperTwistFirstRunLaunchLibrary::GetPackagedStartupRouteArgumentName(), + FString(TEXT("HyperTwistStartupRoute")) + ); + + FHyperTwistFirstRunLaunchRoute Route; + FString FailureReason; + TestTrue( + TEXT("An unrelated command line must not request a startup route."), + UHyperTwistFirstRunLaunchLibrary::ParsePackagedStartupRouteArgument( + TEXT("-windowed -notraceserver"), + Route, + FailureReason + ) == EHyperTwistPackagedStartupRouteParseResult::NotPresent + ); + TestTrue(TEXT("An absent route must not fabricate a route."), Route.RouteId.IsEmpty()); + TestTrue(TEXT("An absent route must not fabricate an error."), FailureReason.IsEmpty()); + + TestTrue( + TEXT("A registered follow-along route must parse through first-run authority."), + UHyperTwistFirstRunLaunchLibrary::ParsePackagedStartupRouteArgument( + TEXT("-windowed -HyperTwistStartupRoute=follow-along-training"), + Route, + FailureReason + ) == EHyperTwistPackagedStartupRouteParseResult::Valid + ); + TestEqual( + TEXT("The parsed route must retain the registered identifier."), + Route.RouteId, + FString(TEXT("follow-along-training")) + ); + TestEqual( + TEXT("The parsed route must retain its dedicated map authority."), + Route.MapAssetPath, + FString(TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining")) + ); + TestTrue(TEXT("A valid route must not retain an error."), FailureReason.IsEmpty()); + + TestTrue( + TEXT("An unknown route identifier must be rejected."), + UHyperTwistFirstRunLaunchLibrary::ParsePackagedStartupRouteArgument( + TEXT("-HyperTwistStartupRoute=unknown-training"), + Route, + FailureReason + ) == EHyperTwistPackagedStartupRouteParseResult::Invalid + ); + TestTrue(TEXT("An unknown route must not escape as resolved state."), Route.RouteId.IsEmpty()); + TestTrue(TEXT("An unknown route must explain its rejection."), !FailureReason.IsEmpty()); + + TestTrue( + TEXT("Duplicate startup-route switches must be rejected rather than resolved ambiguously."), + UHyperTwistFirstRunLaunchLibrary::ParsePackagedStartupRouteArgument( + TEXT("-HyperTwistStartupRoute=follow-along-training -HyperTwistStartupRoute=magic-120-cell-training"), + Route, + FailureReason + ) == EHyperTwistPackagedStartupRouteParseResult::Invalid + ); + TestTrue( + TEXT("Map-option injection must be rejected before route lookup."), + UHyperTwistFirstRunLaunchLibrary::ParsePackagedStartupRouteArgument( + TEXT("-HyperTwistStartupRoute=follow-along-training?game=Other"), + Route, + FailureReason + ) == EHyperTwistPackagedStartupRouteParseResult::Invalid + ); + TestTrue( + TEXT("A value-less startup-route switch must be rejected."), + UHyperTwistFirstRunLaunchLibrary::ParsePackagedStartupRouteArgument( + TEXT("-HyperTwistStartupRoute"), + Route, + FailureReason + ) == EHyperTwistPackagedStartupRouteParseResult::Invalid + ); + + return true; +} + IMPLEMENT_SIMPLE_AUTOMATION_TEST( FHyperTwistFirstRunLaunchGuidanceContractTest, "HyperTwist.FirstRun.LaunchGuidanceContract", @@ -137,7 +231,7 @@ bool FHyperTwistFirstRunLaunchGuidanceContractTest::RunTest(const FString& Param TEXT("The first-run guidance must tell operators where to find runtime and startup logs."), HyperTwistFirstRunLaunchSurfaceTestInternal::ContainsGuidanceSubstring( Lines, - TEXT("Saved/Logs/UnrealHyperTwist.log") + TEXT("Saved/Logs/HyperTwistRuntime-latest.log") ) && HyperTwistFirstRunLaunchSurfaceTestInternal::ContainsGuidanceSubstring( Lines, @@ -183,7 +277,7 @@ bool FHyperTwistFirstRunLaunchGameModeDefaultsTest::RunTest(const FString& Param TestEqual( TEXT("The first-run contract must publish the default runtime log file name."), UHyperTwistFirstRunLaunchLibrary::GetRuntimeLogFileName(), - FString(TEXT("UnrealHyperTwist.log")) + FString(TEXT("HyperTwistRuntime-latest.log")) ); TestEqual( TEXT("The first-run contract must publish the startup diagnostics log file name."), @@ -198,6 +292,16 @@ bool FHyperTwistFirstRunLaunchGameModeDefaultsTest::RunTest(const FString& Param TEXT("The first-run player controller must auto-show the launch menu by default."), PlayerController != nullptr && PlayerController->bShowFirstRunLaunchOnBeginPlay ); + TestTrue( + TEXT("The first-run surface must render above ordinary gameplay HUD layers."), + PlayerController != nullptr && PlayerController->FirstRunLaunchZOrder > 0 + ); + TestTrue( + TEXT("The first-run surface must use bounded delayed layout validation."), + PlayerController != nullptr + && PlayerController->MaxFirstRunSurfaceValidationAttempts > 1 + && PlayerController->FirstRunSurfaceValidationIntervalSeconds > 0.0f + ); TestTrue( TEXT("The first-run player controller must default to the native first-run widget."), PlayerController != nullptr && PlayerController->FirstRunLaunchWidgetClass.Get() == nullptr @@ -261,4 +365,74 @@ bool FHyperTwistFirstRunLaunchGameModeDefaultsTest::RunTest(const FString& Param return true; } +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistNativeWidgetRootLifecycleTest, + "HyperTwist.FirstRun.NativeWidgetRootLifecycle", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistNativeWidgetRootLifecycleTest::RunTest(const FString& Parameters) +{ + UHyperTwistFirstRunLaunchWidget* FirstRunWidget = + NewObject(); + TestNotNull(TEXT("The native first-run widget must be constructible."), FirstRunWidget); + if (FirstRunWidget != nullptr) + { + FirstRunWidget->TakeWidget(); + TestTrue( + TEXT("TakeWidget must adopt a fully built first-run tree rather than caching an empty spacer."), + FirstRunWidget->IsFirstRunLaunchSurfaceReady() + ); + + const UButton* CoachDashboardButton = FirstRunWidget->WidgetTree != nullptr + ? Cast(FirstRunWidget->WidgetTree->FindWidget(FName(TEXT("CoachDashboardRouteButton")))) + : nullptr; + const UButton* ClassicCubeButton = FirstRunWidget->WidgetTree != nullptr + ? Cast(FirstRunWidget->WidgetTree->FindWidget(FName(TEXT("ClassicCubeRouteButton")))) + : nullptr; + TestNotNull(TEXT("The native launch tree must retain the coach-dashboard route button."), CoachDashboardButton); + TestNotNull(TEXT("The native launch tree must retain the Classic cube route button."), ClassicCubeButton); + TestTrue( + TEXT("The coach-dashboard route button must retain its click delegate after Slate rebuild."), + CoachDashboardButton != nullptr && CoachDashboardButton->OnClicked.IsBound() + ); + TestTrue( + TEXT("The Classic cube route button must retain its click delegate after Slate rebuild."), + ClassicCubeButton != nullptr && ClassicCubeButton->OnClicked.IsBound() + ); + } + + UHyperTwistClassicCubeHUDWidget* ClassicHudWidget = + NewObject(); + TestNotNull(TEXT("The native classic cube HUD must be constructible."), ClassicHudWidget); + if (ClassicHudWidget != nullptr) + { + ClassicHudWidget->TakeWidget(); + TestTrue( + TEXT("TakeWidget must adopt the native classic cube HUD tree."), + ClassicHudWidget->IsClassicCubeHudSurfaceReady() + ); + } + + UHyperTwistCoachDashboardWidget* DashboardWidget = + NewObject(); + TestNotNull(TEXT("The native coach dashboard must be constructible."), DashboardWidget); + if (DashboardWidget != nullptr) + { + DashboardWidget->TakeWidget(); + TestTrue( + TEXT("TakeWidget must adopt the scrollable native coach dashboard tree."), + DashboardWidget->IsCoachDashboardSurfaceReady() + ); + TestNotNull( + TEXT("The native coach dashboard must expose its scroll container."), + DashboardWidget->WidgetTree != nullptr + ? DashboardWidget->WidgetTree->FindWidget(FName(TEXT("CoachDashboardScroll"))) + : nullptr + ); + } + + return true; +} + #endif diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistHigherDimensionalPhase6CActivationContractTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistHigherDimensionalPhase6CActivationContractTest.cpp index 65d966a..295564c 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistHigherDimensionalPhase6CActivationContractTest.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistHigherDimensionalPhase6CActivationContractTest.cpp @@ -14,7 +14,7 @@ #include "Serialization/JsonSerializer.h" #include "HyperTwistSimulation/HyperTwistSimulationLibrary.h" -#include "HyperTwistTraining/HyperTwistCoachDashboardGameMode.h" +#include "HyperTwistTraining/HyperTwistHigherDimensionalTrainingGameMode.h" #include "HyperTwistTraining/HyperTwistHigherDimensionalTrainingShellActor.h" #include "HyperTwistTraining/HyperTwistTrainingLibrary.h" #include "HyperTwistTraining/HyperTwistTrainingRuntimeLibrary.h" @@ -43,6 +43,10 @@ namespace HyperTwistHigherDimensionalPhase6CTestInternal FString RuntimeModeId; FString ProjectionProfileId; FString PrimaryPersistenceBoundaryId; + FString AuthoredViaGameModeClassPath; + FString RuntimePresentationClassPath; + FString PresentationEnvironmentOwnership; + int32 CanonicalRenderableElementCount = 0; }; struct FPhase6CDedicatedMapManifest @@ -171,8 +175,9 @@ namespace HyperTwistHigherDimensionalPhase6CTestInternal FPhase6CDedicatedMapManifestEntry& OutEntry ) { - return JsonObject.IsValid() - && JsonObject->TryGetStringField(TEXT("mapKind"), OutEntry.MapKind) + double CanonicalRenderableElementCount = 0.0; + const bool bParsed = JsonObject.IsValid() + && JsonObject->TryGetStringField(TEXT("mapKind"), OutEntry.MapKind) && JsonObject->TryGetStringField(TEXT("familyKey"), OutEntry.FamilyKey) && JsonObject->TryGetStringField(TEXT("mapAssetPath"), OutEntry.MapAssetPath) && JsonObject->TryGetStringField(TEXT("mapFileRelativePath"), OutEntry.MapFileRelativePath) @@ -186,9 +191,18 @@ namespace HyperTwistHigherDimensionalPhase6CTestInternal && JsonObject->TryGetStringField(TEXT("interactiveSceneSurfaceId"), OutEntry.InteractiveSceneSurfaceId) && JsonObject->TryGetStringField(TEXT("sceneContextId"), OutEntry.SceneContextId) && JsonObject->TryGetStringField(TEXT("puzzleId"), OutEntry.PuzzleId) - && JsonObject->TryGetStringField(TEXT("runtimeModeId"), OutEntry.RuntimeModeId) - && JsonObject->TryGetStringField(TEXT("projectionProfileId"), OutEntry.ProjectionProfileId) - && JsonObject->TryGetStringField(TEXT("primaryPersistenceBoundaryId"), OutEntry.PrimaryPersistenceBoundaryId); + && JsonObject->TryGetStringField(TEXT("runtimeModeId"), OutEntry.RuntimeModeId) + && JsonObject->TryGetStringField(TEXT("projectionProfileId"), OutEntry.ProjectionProfileId) + && JsonObject->TryGetStringField(TEXT("primaryPersistenceBoundaryId"), OutEntry.PrimaryPersistenceBoundaryId) + && JsonObject->TryGetStringField(TEXT("authoredViaGameModeClassPath"), OutEntry.AuthoredViaGameModeClassPath) + && JsonObject->TryGetStringField(TEXT("runtimePresentationClassPath"), OutEntry.RuntimePresentationClassPath) + && JsonObject->TryGetStringField(TEXT("presentationEnvironmentOwnership"), OutEntry.PresentationEnvironmentOwnership) + && JsonObject->TryGetNumberField(TEXT("canonicalRenderableElementCount"), CanonicalRenderableElementCount); + if (bParsed) + { + OutEntry.CanonicalRenderableElementCount = static_cast(CanonicalRenderableElementCount); + } + return bParsed; } bool TryLoadDedicatedMapManifest(FPhase6CDedicatedMapManifest& OutManifest) @@ -704,6 +718,69 @@ bool FHyperTwistHigherDimensionalPhase6CDedicatedMapOwnedShellTest::RunTest( return false; } + TestEqual( + TEXT("The runtime-owned Magic120Cell projection must expose exactly 120 canonical cell centers."), + AHyperTwistHigherDimensionalTrainingShellActor::GetCanonicalElementCountForFamily( + TEXT("magic120cell")), + 120 + ); + TestEqual( + TEXT("The runtime-owned order-3 MagicCube5D projection must expose all 242 non-central cells."), + AHyperTwistHigherDimensionalTrainingShellActor::GetCanonicalElementCountForFamily( + TEXT("magiccube5d")), + 242 + ); + + const AHyperTwistHigherDimensionalTrainingGameMode* RuntimeGameModeDefaults = + GetDefault(); + TestNotNull( + TEXT("The dedicated higher-dimensional runtime game mode defaults must exist."), + RuntimeGameModeDefaults + ); + if (RuntimeGameModeDefaults == nullptr) + { + return false; + } + TestEqual( + TEXT("The dedicated higher-dimensional runtime must use the orbit pawn."), + RuntimeGameModeDefaults->DefaultPawnClass.Get(), + AHyperTwistHigherDimensionalOrbitPawn::StaticClass() + ); + TestEqual( + TEXT("The dedicated higher-dimensional runtime must use its non-modal player controller."), + RuntimeGameModeDefaults->PlayerControllerClass.Get(), + AHyperTwistHigherDimensionalTrainingPlayerController::StaticClass() + ); + TestEqual( + TEXT("The dedicated higher-dimensional runtime must expose its native control HUD."), + RuntimeGameModeDefaults->HUDClass.Get(), + AHyperTwistHigherDimensionalTrainingHUD::StaticClass() + ); + const AHyperTwistHigherDimensionalTrainingShellActor* RuntimeShellDefaults = + GetDefault(); + TestNotNull( + TEXT("The runtime-owned higher-dimensional shell defaults must exist."), + RuntimeShellDefaults + ); + if (RuntimeShellDefaults == nullptr) + { + return false; + } + TestNotNull( + TEXT("The runtime-owned higher-dimensional shell must self-provision its presentation floor."), + RuntimeShellDefaults->PresentationFloor.Get() + ); + TestEqual( + TEXT("The runtime-owned higher-dimensional shell must expose six independently colored preview layers."), + RuntimeShellDefaults->PreviewColorLayers.Num(), + 6 + ); + TestEqual( + TEXT("The runtime-owned higher-dimensional shell must bind six distinct cook-safe first-party materials."), + RuntimeShellDefaults->GetDistinctPreviewMaterialCount(), + 6 + ); + const auto VerifyDedicatedMapShell = [this](const FPhase6CDedicatedMapManifestEntry& Entry) -> bool { @@ -736,12 +813,13 @@ bool FHyperTwistHigherDimensionalPhase6CDedicatedMapOwnedShellTest::RunTest( const int32 TrainingShellActorCount = HyperTwistHigherDimensionalPhase6CTestInternal::CountActorsOfClass< AHyperTwistHigherDimensionalTrainingShellActor>(MapWorld); - TestTrue( - *FString::Printf( - TEXT("The dedicated-family map must contain at most one optional training shell actor for %s."), - *Entry.FamilyKey), - TrainingShellActorCount <= 1 - ); + TestEqual( + *FString::Printf( + TEXT("The dedicated-family map must leave shell presentation exclusively to runtime ownership for %s."), + *Entry.FamilyKey), + TrainingShellActorCount, + 0 + ); TestEqual( *FString::Printf( TEXT("The dedicated-family map must contain exactly one PlayerStart anchor for %s."), @@ -806,15 +884,44 @@ bool FHyperTwistHigherDimensionalPhase6CDedicatedMapOwnedShellTest::RunTest( return Actor != nullptr && Actor->ActorHasTag(FName(*Tag)); }; - TestEqual( - *FString::Printf( - TEXT("The dedicated-family world settings must preserve the coach dashboard game mode for %s."), + TestEqual( + *FString::Printf( + TEXT("The dedicated-family world settings must preserve the interactive higher-dimensional game mode for %s."), *Entry.FamilyKey), WorldSettings->DefaultGameMode != nullptr ? WorldSettings->DefaultGameMode->GetPathName() : FString(), - AHyperTwistCoachDashboardGameMode::StaticClass()->GetPathName() - ); + AHyperTwistHigherDimensionalTrainingGameMode::StaticClass()->GetPathName() + ); + TestEqual( + *FString::Printf( + TEXT("The manifest must bind the dedicated game mode for %s."), + *Entry.FamilyKey), + Entry.AuthoredViaGameModeClassPath, + AHyperTwistHigherDimensionalTrainingGameMode::StaticClass()->GetPathName() + ); + TestEqual( + *FString::Printf( + TEXT("The manifest must bind the runtime shell presentation class for %s."), + *Entry.FamilyKey), + Entry.RuntimePresentationClassPath, + AHyperTwistHigherDimensionalTrainingShellActor::StaticClass()->GetPathName() + ); + TestEqual( + *FString::Printf( + TEXT("The manifest must preserve runtime presentation-environment ownership for %s."), + *Entry.FamilyKey), + Entry.PresentationEnvironmentOwnership, + TEXT("runtime-game-mode-and-shell-components") + ); + TestEqual( + *FString::Printf( + TEXT("The manifest must preserve the exact canonical runtime element count for %s."), + *Entry.FamilyKey), + Entry.CanonicalRenderableElementCount, + AHyperTwistHigherDimensionalTrainingShellActor::GetCanonicalElementCountForFamily( + Entry.FamilyKey) + ); if (TrainingShellActor != nullptr) { TestEqual( diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistHttpClientConfigurationTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistHttpClientConfigurationTest.cpp new file mode 100644 index 0000000..8b05111 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistHttpClientConfigurationTest.cpp @@ -0,0 +1,51 @@ +#include "Misc/AutomationTest.h" + +#include "HyperTwistRecognition/HyperTwistSpeechClient.h" +#include "HyperTwistRecognition/HyperTwistVisionClient.h" +#include "HyperTwistRecognition/HyperTwistVoiceClient.h" + +#if WITH_DEV_AUTOMATION_TESTS + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistHttpClientConfigurationTest, + "HyperTwist.Recognition.HttpClients.DefaultConfiguration", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistHttpClientConfigurationTest::RunTest(const FString& Parameters) +{ + static_cast(Parameters); + + const UHyperTwistHttpVisionClient* VisionClient = GetDefault(); + const UHyperTwistHttpSpeechClient* SpeechClient = GetDefault(); + const UHyperTwistHttpVoiceClient* VoiceClient = GetDefault(); + + TestNotNull(TEXT("The default vision client must be available."), VisionClient); + TestNotNull(TEXT("The default speech client must be available."), SpeechClient); + TestNotNull(TEXT("The default voice client must be available."), VoiceClient); + if (VisionClient == nullptr || SpeechClient == nullptr || VoiceClient == nullptr) + { + return false; + } + + const auto TestServiceUrl = [this](const TCHAR* Label, const FString& ServiceBaseUrl) + { + TestTrue( + FString::Printf(TEXT("%s must retain a complete HTTP service URL after config parsing."), Label), + ServiceBaseUrl.StartsWith(TEXT("http://"), ESearchCase::IgnoreCase) + || ServiceBaseUrl.StartsWith(TEXT("https://"), ESearchCase::IgnoreCase) + ); + TestFalse( + FString::Printf(TEXT("%s must not contain a collapsed single-slash scheme."), Label), + ServiceBaseUrl.StartsWith(TEXT("http:/"), ESearchCase::IgnoreCase) + && !ServiceBaseUrl.StartsWith(TEXT("http://"), ESearchCase::IgnoreCase) + ); + }; + + TestServiceUrl(TEXT("VisionClient.ServiceBaseUrl"), VisionClient->ServiceBaseUrl); + TestServiceUrl(TEXT("SpeechClient.ServiceBaseUrl"), SpeechClient->ServiceBaseUrl); + TestServiceUrl(TEXT("VoiceClient.ServiceBaseUrl"), VoiceClient->ServiceBaseUrl); + return true; +} + +#endif diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/UnrealHyperTwist.Build.cs b/UnrealHyperTwist/Source/UnrealHyperTwist/UnrealHyperTwist.Build.cs index e666c11..2fe0711 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/UnrealHyperTwist.Build.cs +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/UnrealHyperTwist.Build.cs @@ -12,7 +12,7 @@ public class UnrealHyperTwist : ModuleRules PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "Json", "JsonUtilities", "UMG", "Voice", "ProceduralMeshComponent", "HeadMountedDisplay", "XRBase" }); PrivateDependencyModuleNames.AddRange( - new string[] { "Slate", "SlateCore", "HTTP", "WebBrowser", "WebBrowserWidget", "Projects" } + new string[] { "Slate", "SlateCore", "HTTP", "WebBrowser", "WebBrowserWidget", "Projects", "RenderCore" } ); // External donor repo include paths diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/UnrealHyperTwist.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/UnrealHyperTwist.cpp index 547fbc8..6a484e4 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/UnrealHyperTwist.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/UnrealHyperTwist.cpp @@ -1,6 +1,28 @@ // Copyright Epic Games, Inc. All Rights Reserved. #include "UnrealHyperTwist.h" +#include "HyperTwistDiagnostics/HyperTwistRuntimeDiagnostics.h" #include "Modules/ModuleManager.h" -IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, UnrealHyperTwist, "UnrealHyperTwist"); +class FUnrealHyperTwistModule final : public FDefaultGameModuleImpl +{ +public: + virtual void StartupModule() override + { + FDefaultGameModuleImpl::StartupModule(); + HyperTwistRuntimeDiagnostics::InitializeSession(); + HyperTwistRuntimeDiagnostics::AppendEvent( + TEXT("Process"), + TEXT("HyperTwist runtime module initialized.")); + } + + virtual void ShutdownModule() override + { + HyperTwistRuntimeDiagnostics::AppendEvent( + TEXT("Process"), + TEXT("HyperTwist runtime module shutdown requested.")); + FDefaultGameModuleImpl::ShutdownModule(); + } +}; + +IMPLEMENT_PRIMARY_GAME_MODULE(FUnrealHyperTwistModule, UnrealHyperTwist, "UnrealHyperTwist"); diff --git a/docs/generated/alpha_test_20260723/README.md b/docs/generated/alpha_test_20260723/README.md new file mode 100644 index 0000000..30d31a7 --- /dev/null +++ b/docs/generated/alpha_test_20260723/README.md @@ -0,0 +1,41 @@ +# HyperTwist Alpha Test Evidence - 2026-07-23 + +This directory is the durable, source-controlled evidence index for the +Release 17 Shipping Alpha delivered to the Windows Downloads folder. + +## Current delivery + +- folder: + `C:\Users\Anthracite Ace\Downloads\HyperTwist-Alpha-Test-20260723` +- ZIP: + `C:\Users\Anthracite Ace\Downloads\HyperTwist-Alpha-Test-20260723.zip` +- outer executable SHA-256: + `886f69c6a823770b3ed68db4fe3790a488124c99c98dbd41944a6bed5d634dc6` +- ZIP SHA-256: + `1ad8e1c0fc967b227ca09fb68c515ed644eca3e4bda07fdf8c378b438000fc42` +- ZIP: `103` entries, `1082134734` bytes + +## Evidence map + +- `first_run_automation_index.json` - `5/5` focused first-run tests green +- `desktop_package_validation_report.json` - four semantic Shipping smokes + green for Classic, Follow-Along, Magic120Cell, and MagicCube5D +- `desktop_launch_surface_report.json` - corrected stock-Shipping launch gate +- `final_delivery_bootstrap_verification.json` - exact outer bootstrap resource +- `final_delivery_zip_export_report.json` - reopened archive plus seven + critical-entry stream hashes +- `final_delivery_first_run_visual_report.json` and + `final_delivery_first_run.png` - exact Downloads executable rendered the + first-run product surface +- `classic_route_visual_report.json` and `classic_route.png` - visible Classic + route click, exact map, 26 cubies, settled valid state, HUD, and camera +- `final_delivery_dashboard_nullrhi_diagnostics.log` - final executable + accepted and opened the registered coach-dashboard route under direct + Shipping diagnostics +- `superseded_july9_retirement_report.json` - old folder/ZIP retirement after + replacement verification + +The dashboard diagnostics are route/runtime proof, not a substitute for a +fresh dashboard screenshot. A later visual retry occurred without a live +interactive desktop and was rejected. No failed or desktop-leaking image is +promoted as release evidence. diff --git a/docs/generated/alpha_test_20260723/classic_route.png b/docs/generated/alpha_test_20260723/classic_route.png new file mode 100644 index 0000000..553dbb7 Binary files /dev/null and b/docs/generated/alpha_test_20260723/classic_route.png differ diff --git a/docs/generated/alpha_test_20260723/classic_route_visual_report.json b/docs/generated/alpha_test_20260723/classic_route_visual_report.json new file mode 100644 index 0000000..4726471 --- /dev/null +++ b/docs/generated/alpha_test_20260723/classic_route_visual_report.json @@ -0,0 +1,97 @@ +{ + "reportVersion": "ht-packaged-window-visual/v1", + "generatedAtUtc": "2026-07-23T08:05:02.2199519Z", + "launchStartedAtUtc": "2026-07-23T08:05:02.3060951Z", + "executablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe", + "executableSha256": "886f69c6a823770b3ed68db4fe3790a488124c99c98dbd41944a6bed5d634dc6", + "launchArguments": [ + "-windowed", + "-ResX=1280", + "-ResY=720", + "-HyperTwistCaptureWhenReady", + "-HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\visual\\classic-route-window-message\\HyperTwist-window-20260723T080501Z.hyperdiagnostics.log\"" + ], + "perMonitorV2DpiAware": true, + "processId": 24536, + "windowTitle": "UnrealHyperTwist ", + "windowClassName": "UnrealWindow", + "expectedWindowClassName": "UnrealWindow", + "clientBounds": { + "x": 368, + "y": 297, + "width": 1280, + "height": 720 + }, + "screenshotPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\visual\\classic-route-window-message\\HyperTwist-window-20260723T080501Z.png", + "progressPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\visual\\classic-route-window-message\\HyperTwist-window-20260723T080501Z.progress.log", + "screenshotSha256": "6e6b8f2c06f6ff8a68c2f4d9e0a576305056cd6f4d7353e9894a05e63d853ab5", + "captureMethod": "hyper-twist-semantic-ready-frame", + "expectedSemanticReadyCaptureName": "classic-cube-free-play-settled.png", + "semanticReadyCaptureSourcePath": "C:\\Users\\Anthracite Ace\\AppData\\Local\\UnrealHyperTwist\\Saved\\Screenshots\\HyperTwistDiagnostics\\classic-cube-free-play-settled.png", + "semanticReadyCaptureSourceSha256": "4da3bc875f3e8551d427df1f894b806152a3064d0bf350f77207eec9ce84345e", + "metrics": { + "sampleStride": 6, + "sampleCount": 25680, + "nonBlackRatio": 0.937733644859813, + "brightRatio": 0.92628504672897194, + "averageLuminance": 136.14746721184946, + "luminanceDeviation": 60.493912122602573, + "minimumLuminance": 0.0722, + "maximumLuminance": 250.94099999999997, + "luminanceRange": 250.86879999999996, + "quantizedColorBucketCount": 218 + }, + "thresholds": { + "minimumNonBlackRatio": 0.02, + "minimumBrightRatio": 0.001, + "minimumLuminanceDeviation": 2.5, + "minimumLuminanceRange": 20 + }, + "interaction": { + "requested": true, + "normalizedX": 0.5, + "normalizedY": 0.566, + "screenX": 1008, + "screenY": 704, + "clientX": 640, + "clientY": 407, + "injectionMethod": "window-client-message", + "clicked": true, + "expectedStartupDiagnosticsResult": "map-route-opened" + }, + "startupDiagnosticsPath": "C:\\Users\\Anthracite Ace\\AppData\\Local\\UnrealHyperTwist\\Saved\\Logs\\HyperTwistFirstRunLaunch-latest.log", + "startupDiagnosticsLastWriteUtc": "2026-07-23T08:05:09.5575483Z", + "startupDiagnosticsResult": "result=map-route-opened", + "runtimeDiagnosticsPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\visual\\classic-route-window-message\\HyperTwist-window-20260723T080501Z.hyperdiagnostics.log", + "runtimeDiagnosticsExists": true, + "runtimeDiagnosticsLines": [ + "format=hypertwist-runtime-diagnostics/v1", + "session=20260723T080504Z", + "process_id=24536", + "command_line=-notraceserver -traceautostart=0 -windowed -ResX=1280 -ResY=720 -HyperTwistCaptureWhenReady -HyperTwistDiagnosticsLog=C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\visual\\classic-route-window-message\\HyperTwist-window-20260723T080501Z.hyperdiagnostics.log", + "diagnostics_path=C:/HyperTwist_worktrees/phase10validate_packaged_alpha_release17_20260723/validation/visual/classic-route-window-message/HyperTwist-window-20260723T080501Z.hyperdiagnostics.log", + "[2026-07-23T08:05:04Z] [Process] HyperTwist runtime module initialized.", + "[2026-07-23T08:05:09Z] [MapReady] Runtime map ready: /Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining.L_HyperTwist_ClassicTraining.", + "[2026-07-23T08:05:09Z] [ClassicPresentation] Classic cube presentation initialized with 26 renderable pieces and orbit pawn ready.", + "[2026-07-23T08:05:21Z] [ClassicScrambleSettlement] Classic cube scramble settled with 29 completed quarter turns, 0 queued rotations, and 26 renderable pieces; renderable state valid." + ], + "processCommandLines": [ + "\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe\" -windowed -ResX=1280 -ResY=720 -HyperTwistCaptureWhenReady -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\visual\\classic-route-window-message\\HyperTwist-window-20260723T080501Z.hyperdiagnostics.log\" ", + "\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist\\Binaries\\Win64\\UnrealHyperTwist-Win64-Shipping.exe\" UnrealHyperTwist -notraceserver -traceautostart=0 -windowed -ResX=1280 -ResY=720 -HyperTwistCaptureWhenReady -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\visual\\classic-route-window-message\\HyperTwist-window-20260723T080501Z.hyperdiagnostics.log\" ", + "C:/HyperTwist_worktrees/phase10validate_packaged_alpha_release17_20260723/Windows/Engine/Binaries/Win64/EpicWebHelper.exe --type=gpu-process --no-sandbox --use-adapter-luid=0,66319 --use-angle=d3d11 --start-stack-profiler --user-data-dir=\"C:\\Users\\Anthracite Ace\\AppData\\Local\\UnrealHyperTwist\\Saved\\webcache_6613\" --locales-dir-path=C:/HyperTwist_worktrees/phase10validate_packaged_alpha_release17_20260723/Windows/Engine/Binaries/ThirdParty/CEF3/Win64/128.4.13+ge76af7e+chromium-128.0.6613.138/Resources/locales --log-severity=warning --resources-dir-path=C:/HyperTwist_worktrees/phase10validate_packaged_alpha_release17_20260723/Windows/Engine/Binaries/ThirdParty/CEF3/Win64/128.4.13+ge76af7e+chromium-128.0.6613.138/Resources --user-agent-product=\"UnrealHyperTwist/++UE5+Release-5.7-CL-51494982 UnrealEngine/5.7.4-51494982+++UE5+Release-5.7 Chrome/128.0.6613.138\" --gpu-preferences=UAAAAAAAAADgABAMAAAAAAAAAAAAAAAAAABgAAEAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAA --ipc-connection-timeout=60 --field-trial-handle=3112,i,12427174811344002632,6413397226995935749,262144 --variations-seed-version --enable-logging=handle --log-file=3196 --mojo-platform-channel-handle=3100 /prefetch:2", + "C:/HyperTwist_worktrees/phase10validate_packaged_alpha_release17_20260723/Windows/Engine/Binaries/Win64/EpicWebHelper.exe --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US --service-sandbox-type=none --no-sandbox --use-angle=d3d11 --start-stack-profiler --user-data-dir=\"C:\\Users\\Anthracite Ace\\AppData\\Local\\UnrealHyperTwist\\Saved\\webcache_6613\" --locales-dir-path=C:/HyperTwist_worktrees/phase10validate_packaged_alpha_release17_20260723/Windows/Engine/Binaries/ThirdParty/CEF3/Win64/128.4.13+ge76af7e+chromium-128.0.6613.138/Resources/locales --log-severity=warning --resources-dir-path=C:/HyperTwist_worktrees/phase10validate_packaged_alpha_release17_20260723/Windows/Engine/Binaries/ThirdParty/CEF3/Win64/128.4.13+ge76af7e+chromium-128.0.6613.138/Resources --user-agent-product=\"UnrealHyperTwist/++UE5+Release-5.7-CL-51494982 UnrealEngine/5.7.4-51494982+++UE5+Release-5.7 Chrome/128.0.6613.138\" --ipc-connection-timeout=60 --field-trial-handle=2980,i,12427174811344002632,6413397226995935749,262144 --variations-seed-version --enable-logging=handle --log-file=2984 --mojo-platform-channel-handle=2968 /prefetch:11", + "C:/HyperTwist_worktrees/phase10validate_packaged_alpha_release17_20260723/Windows/Engine/Binaries/Win64/EpicWebHelper.exe --type=utility --utility-sub-type=storage.mojom.StorageService --lang=en-US --service-sandbox-type=service --no-sandbox --use-angle=d3d11 --user-data-dir=\"C:\\Users\\Anthracite Ace\\AppData\\Local\\UnrealHyperTwist\\Saved\\webcache_6613\" --locales-dir-path=C:/HyperTwist_worktrees/phase10validate_packaged_alpha_release17_20260723/Windows/Engine/Binaries/ThirdParty/CEF3/Win64/128.4.13+ge76af7e+chromium-128.0.6613.138/Resources/locales --log-severity=warning --resources-dir-path=C:/HyperTwist_worktrees/phase10validate_packaged_alpha_release17_20260723/Windows/Engine/Binaries/ThirdParty/CEF3/Win64/128.4.13+ge76af7e+chromium-128.0.6613.138/Resources --user-agent-product=\"UnrealHyperTwist/++UE5+Release-5.7-CL-51494982 UnrealEngine/5.7.4-51494982+++UE5+Release-5.7 Chrome/128.0.6613.138\" --ipc-connection-timeout=60 --field-trial-handle=3352,i,12427174811344002632,6413397226995935749,262144 --variations-seed-version --enable-logging=handle --log-file=3104 --mojo-platform-channel-handle=3160 /prefetch:13" + ], + "ownedListeningTcpEndpoints": [ + + ], + "traceControlListenerLines": [ + + ], + "traceControlListeningEndpoints": [ + + ], + "focusMethod": "topmost-attempt-1", + "result": "visual-passed", + "error": null +} \ No newline at end of file diff --git a/docs/generated/alpha_test_20260723/desktop_launch_surface_report.json b/docs/generated/alpha_test_20260723/desktop_launch_surface_report.json new file mode 100644 index 0000000..e6f0f99 --- /dev/null +++ b/docs/generated/alpha_test_20260723/desktop_launch_surface_report.json @@ -0,0 +1,71 @@ +{ + "reportVersion": "ht-desktop-package-launch-surface/v2", + "generatedAtUtc": "2026-07-23T07:42:23.9424772Z", + "packageRoot": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723", + "executablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe", + "mapUrl": "", + "runtimeLogPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\desktop-runtime.log", + "runtimeLogExists": false, + "runtimeDiagnosticsPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\desktop-runtime.hyperdiagnostics.log", + "runtimeDiagnosticsExists": true, + "runtimeDiagnosticsInitialized": true, + "detectedFatalLogLines": [ + + ], + "smokeSeconds": 30, + "resolution": { + "width": 1600, + "height": 900 + }, + "keepRunning": false, + "useNullRhi": true, + "noSound": true, + "requireStartupDiagnostics": true, + "expectedStartupDiagnosticsResult": "", + "startupDiagnosticsLogPath": "C:\\Users\\Anthracite Ace\\AppData\\Local\\UnrealHyperTwist\\Saved\\Logs\\HyperTwistFirstRunLaunch-latest.log", + "startupDiagnosticsLogExists": true, + "startupDiagnosticsTail": [ + "session=20260723T074227Z", + "world=L_HyperTwist_ClassicTraining", + "runtime_log=C:/Users/Anthracite Ace/AppData/Local/UnrealHyperTwist/Saved/Logs/HyperTwistRuntime-latest.log", + "latest_diagnostics_log=C:/Users/Anthracite Ace/AppData/Local/UnrealHyperTwist/Saved/Logs/HyperTwistFirstRunLaunch-latest.log", + "[2026-07-23T07:42:27Z] [BeginPlay] First-run controller booted on world \u0027L_HyperTwist_ClassicTraining\u0027.", + "[2026-07-23T07:42:27Z] [ApplyFirstRunInputMode] Applied game-and-UI input mode with unlocked cursor capture.", + "[2026-07-23T07:42:27Z] [ShowFirstRunLaunchMenu] Fell back to the native first-run widget class.", + "[2026-07-23T07:42:27Z] [ShowFirstRunLaunchMenu] Added the first-run launch widget to the viewport at z-order 100.", + "[2026-07-23T07:42:27Z] [ApplyFirstRunInputMode] Applied game-and-UI input mode with unlocked cursor capture.", + "[2026-07-23T07:42:29Z] [StartupRecovery] The first-run launch widget never produced a visible non-zero layout after 20 attempts (last size 0x0).", + "[2026-07-23T07:42:29Z] [StartupRecovery] Attempting fallback route \u0027classic-cube-training\u0027.", + "[2026-07-23T07:42:29Z] [OpenFirstRunRoute] Opening route \u0027classic-cube-training\u0027 (dedicated-map).", + "[2026-07-23T07:42:29Z] [OpenMapRoute] Opening map \u0027/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining\u0027 with options \u0027game=/Script/UnrealHyperTwist.HyperTwistClassicCubeGameMode\u0027.", + "result=startup-recovered", + "last_failure=The first-run launch widget never produced a visible non-zero layout after 20 attempts (last size 0x0)." + ], + "startupDiagnosticsResult": "result=startup-recovered", + "result": "passed", + "processId": 4848, + "processIds": [ + 4848, + 3556 + ], + "processCommandLines": [ + "\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe\" -ResX=1600 -ResY=900 -windowed -log -FORCELOGFLUSH -abslog=C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\desktop-runtime.log -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\desktop-runtime.hyperdiagnostics.log\" -NullRHI -nosound ", + "\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist\\Binaries\\Win64\\UnrealHyperTwist-Win64-Shipping.exe\" UnrealHyperTwist -notraceserver -traceautostart=0 -ResX=1600 -ResY=900 -windowed -log -FORCELOGFLUSH -abslog=C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\desktop-runtime.log -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\desktop-runtime.hyperdiagnostics.log\" -NullRHI -nosound " + ], + "ownedListeningTcpEndpoints": [ + + ], + "traceControlListenerLines": [ + + ], + "traceControlListeningEndpoints": [ + + ], + "processStopped": true, + "stoppedProcessIds": [ + 4848, + 3556 + ], + "exitCode": null, + "error": null +} \ No newline at end of file diff --git a/docs/generated/alpha_test_20260723/desktop_package_validation_report.json b/docs/generated/alpha_test_20260723/desktop_package_validation_report.json new file mode 100644 index 0000000..2cf13db --- /dev/null +++ b/docs/generated/alpha_test_20260723/desktop_package_validation_report.json @@ -0,0 +1,480 @@ +{ + "reportVersion": "ht-classic-cube-package-validation/v2", + "generatedAtUtc": "2026-07-23T07:32:10.4163716Z", + "projectRoot": "C:\\HyperTwist_worktrees\\phase10validate", + "archiveDirectory": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723", + "configuration": "Shipping", + "cookMaps": [ + "/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining", + "/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining", + "/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining", + "/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining" + ], + "smokeMaps": [ + "/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining", + "/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining", + "/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining", + "/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining" + ], + "additionalCookerOptions": [ + "-DisablePlugins=MovieRenderPipeline", + "-SkipCookingEditorContent" + ], + "headlessSmoke": true, + "cleanArchive": true, + "skipBuild": false, + "skipLaunch": false, + "result": "passed", + "packagedExecutablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe", + "bootstrapLaunchArguments": { + "result": "passed", + "traceControlProtection": "shipping-compile-time-disabled", + "expectedArguments": [ + "-notraceserver", + "-traceautostart=0" + ], + "patchReportPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\bootstrap-launch-arguments-report.json", + "patchReport": { + "reportVersion": "ht-bootstrap-launch-arguments/v1", + "generatedAtUtc": "2026-07-23T07:37:11.2688738Z", + "executablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe", + "resourceType": 10, + "resourceId": 202, + "verifyOnly": false, + "expectedArguments": [ + "UnrealHyperTwist", + "-notraceserver", + "-traceautostart=0" + ], + "expectedResourceValue": "UnrealHyperTwist -notraceserver -traceautostart=0", + "originalResourceValue": "UnrealHyperTwist", + "finalResourceValue": "UnrealHyperTwist -notraceserver -traceautostart=0", + "originalSha256": "b7c268fc083a565b7c3c1e0a84db773c9714e4f23f14cca3bffb14145e48f88f", + "finalSha256": "886f69c6a823770b3ed68db4fe3790a488124c99c98dbd41944a6bed5d634dc6", + "changed": true, + "result": "passed", + "error": null + }, + "runtimeProofs": [ + { + "runtimeLogPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_ClassicTraining.log", + "runtimeLogExists": false, + "runtimeDiagnosticsPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_ClassicTraining.hyperdiagnostics.log", + "runtimeDiagnosticsExists": true, + "observedCommandLines": [ + "\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe\" -ResX=1600 -ResY=900 -windowed -log -FORCELOGFLUSH -abslog=C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_ClassicTraining.log -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_ClassicTraining.hyperdiagnostics.log\" -NullRHI -nosound " + ], + "expectedArguments": [ + "-notraceserver", + "-traceautostart=0" + ], + "matchedArguments": [ + "-notraceserver", + "-traceautostart=0" + ], + "missingArguments": [ + + ], + "traceControlListenerLines": [ + + ], + "traceControlListeningEndpoints": [ + + ], + "result": "passed", + "error": null + }, + { + "runtimeLogPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_FollowAlongTraining.log", + "runtimeLogExists": false, + "runtimeDiagnosticsPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_FollowAlongTraining.hyperdiagnostics.log", + "runtimeDiagnosticsExists": true, + "observedCommandLines": [ + "\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe\" -HyperTwistStartupRoute=follow-along-training -ResX=1600 -ResY=900 -windowed -log -FORCELOGFLUSH -abslog=C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_FollowAlongTraining.log -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_FollowAlongTraining.hyperdiagnostics.log\" -NullRHI -nosound " + ], + "expectedArguments": [ + "-notraceserver", + "-traceautostart=0" + ], + "matchedArguments": [ + "-notraceserver", + "-traceautostart=0" + ], + "missingArguments": [ + + ], + "traceControlListenerLines": [ + + ], + "traceControlListeningEndpoints": [ + + ], + "result": "passed", + "error": null + }, + { + "runtimeLogPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_Magic120CellTraining.log", + "runtimeLogExists": false, + "runtimeDiagnosticsPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_Magic120CellTraining.hyperdiagnostics.log", + "runtimeDiagnosticsExists": true, + "observedCommandLines": [ + "\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe\" -HyperTwistStartupRoute=magic-120-cell-training -ResX=1600 -ResY=900 -windowed -log -FORCELOGFLUSH -abslog=C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_Magic120CellTraining.log -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_Magic120CellTraining.hyperdiagnostics.log\" -NullRHI -nosound " + ], + "expectedArguments": [ + "-notraceserver", + "-traceautostart=0" + ], + "matchedArguments": [ + "-notraceserver", + "-traceautostart=0" + ], + "missingArguments": [ + + ], + "traceControlListenerLines": [ + + ], + "traceControlListeningEndpoints": [ + + ], + "result": "passed", + "error": null + }, + { + "runtimeLogPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_MagicCube5DTraining.log", + "runtimeLogExists": false, + "runtimeDiagnosticsPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_MagicCube5DTraining.hyperdiagnostics.log", + "runtimeDiagnosticsExists": true, + "observedCommandLines": [ + "\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe\" -HyperTwistStartupRoute=magic-cube-5d-training -ResX=1600 -ResY=900 -windowed -log -FORCELOGFLUSH -abslog=C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_MagicCube5DTraining.log -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_MagicCube5DTraining.hyperdiagnostics.log\" -NullRHI -nosound " + ], + "expectedArguments": [ + "-notraceserver", + "-traceautostart=0" + ], + "matchedArguments": [ + "-notraceserver", + "-traceautostart=0" + ], + "missingArguments": [ + + ], + "traceControlListenerLines": [ + + ], + "traceControlListeningEndpoints": [ + + ], + "result": "passed", + "error": null + } + ] + }, + "solverTable": { + "result": "passed", + "sourcePath": "C:\\HyperTwist_worktrees\\phase10validate\\UnrealHyperTwist\\Saved\\twophase-ht.tbl", + "expectedSizeBytes": 676207080, + "expectedSha256": "dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034", + "sourceEvidence": { + "path": "C:\\HyperTwist_worktrees\\phase10validate\\UnrealHyperTwist\\Saved\\twophase-ht.tbl", + "sizeBytes": 676207080, + "sha256": "dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034" + }, + "packagedEvidence": { + "path": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist\\Saved\\twophase-ht.tbl", + "sizeBytes": 676207080, + "sha256": "dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034" + } + }, + "smokeReports": [ + { + "reportVersion": "ht-desktop-map-package-smoke/v7", + "generatedAtUtc": "2026-07-23T07:37:17.9023928Z", + "packageRoot": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723", + "launchMode": "bootstrap-default-map", + "startupRouteId": "", + "launcherExecutablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe", + "executablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe", + "mapUrl": "/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining", + "runtimeLogPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_ClassicTraining.log", + "runtimeLogExists": false, + "runtimeDiagnosticsPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_ClassicTraining.hyperdiagnostics.log", + "runtimeDiagnosticsExists": true, + "detectedFatalLogLines": [ + + ], + "mapLoadLogLines": [ + "[2026-07-23T07:37:23Z] [MapReady] Runtime map ready: /Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining.L_HyperTwist_ClassicTraining." + ], + "requiresClassicPresentation": true, + "classicPresentationLogLines": [ + "[2026-07-23T07:37:23Z] [ClassicPresentation] Classic cube presentation initialized with 26 renderable pieces and orbit pawn ready." + ], + "classicScrambleSettlementLogLines": [ + "[2026-07-23T07:37:34Z] [ClassicScrambleSettlement] Classic cube scramble settled with 27 completed quarter turns, 0 queued rotations, and 26 renderable pieces; renderable state valid." + ], + "higherDimensionalFamily": "", + "expectedHigherDimensionalElementCount": 0, + "higherDimensionalPresentationLogLines": [ + + ], + "higherDimensionalPaletteLogLines": [ + + ], + "smokeSeconds": 30, + "resolution": { + "width": 1600, + "height": 900 + }, + "keepRunning": false, + "useNullRhi": true, + "noSound": true, + "result": "passed", + "processId": 22668, + "processIds": [ + 22668 + ], + "processCommandLines": [ + "\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe\" -ResX=1600 -ResY=900 -windowed -log -FORCELOGFLUSH -abslog=C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_ClassicTraining.log -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_ClassicTraining.hyperdiagnostics.log\" -NullRHI -nosound " + ], + "ownedListeningTcpEndpoints": [ + + ], + "traceControlListenerLines": [ + + ], + "traceControlListeningEndpoints": [ + + ], + "startupRouteCommandLineEvidence": [ + + ], + "startupRouteEvidenceLines": [ + + ], + "processStopped": true, + "stoppedProcessIds": [ + 22668 + ], + "exitCode": null, + "error": null + }, + { + "reportVersion": "ht-desktop-map-package-smoke/v7", + "generatedAtUtc": "2026-07-23T07:37:53.2904656Z", + "packageRoot": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723", + "launchMode": "first-party-startup-route", + "startupRouteId": "follow-along-training", + "launcherExecutablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe", + "executablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe", + "mapUrl": "/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining", + "runtimeLogPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_FollowAlongTraining.log", + "runtimeLogExists": false, + "runtimeDiagnosticsPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_FollowAlongTraining.hyperdiagnostics.log", + "runtimeDiagnosticsExists": true, + "detectedFatalLogLines": [ + + ], + "mapLoadLogLines": [ + "[2026-07-23T07:37:55Z] [MapReady] Runtime map ready: /Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining.L_HyperTwist_FollowAlongTraining." + ], + "requiresClassicPresentation": true, + "classicPresentationLogLines": [ + "[2026-07-23T07:37:55Z] [ClassicPresentation] Classic cube presentation initialized with 26 renderable pieces and orbit pawn ready." + ], + "classicScrambleSettlementLogLines": [ + "[2026-07-23T07:38:03Z] [ClassicScrambleSettlement] Classic cube scramble settled with 11 completed quarter turns, 0 queued rotations, and 26 renderable pieces; renderable state valid." + ], + "higherDimensionalFamily": "", + "expectedHigherDimensionalElementCount": 0, + "higherDimensionalPresentationLogLines": [ + + ], + "higherDimensionalPaletteLogLines": [ + + ], + "smokeSeconds": 30, + "resolution": { + "width": 1600, + "height": 900 + }, + "keepRunning": false, + "useNullRhi": true, + "noSound": true, + "result": "passed", + "processId": 21852, + "processIds": [ + 21852 + ], + "processCommandLines": [ + "\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe\" -HyperTwistStartupRoute=follow-along-training -ResX=1600 -ResY=900 -windowed -log -FORCELOGFLUSH -abslog=C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_FollowAlongTraining.log -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_FollowAlongTraining.hyperdiagnostics.log\" -NullRHI -nosound " + ], + "ownedListeningTcpEndpoints": [ + + ], + "traceControlListenerLines": [ + + ], + "traceControlListeningEndpoints": [ + + ], + "startupRouteCommandLineEvidence": [ + "\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe\" -HyperTwistStartupRoute=follow-along-training -ResX=1600 -ResY=900 -windowed -log -FORCELOGFLUSH -abslog=C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_FollowAlongTraining.log -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_FollowAlongTraining.hyperdiagnostics.log\" -NullRHI -nosound " + ], + "startupRouteEvidenceLines": [ + "[2026-07-23T07:37:55Z] [PackagedStartupRoute] Accepted packaged startup route \u0027follow-along-training\u0027: map=\u0027/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining\u0027, game_mode=\u0027/Script/UnrealHyperTwist.HyperTwistClassicCubeFollowAlongGameMode\u0027." + ], + "processStopped": true, + "stoppedProcessIds": [ + 21852 + ], + "exitCode": null, + "error": null + }, + { + "reportVersion": "ht-desktop-map-package-smoke/v7", + "generatedAtUtc": "2026-07-23T07:38:26.2167211Z", + "packageRoot": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723", + "launchMode": "first-party-startup-route", + "startupRouteId": "magic-120-cell-training", + "launcherExecutablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe", + "executablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe", + "mapUrl": "/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining", + "runtimeLogPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_Magic120CellTraining.log", + "runtimeLogExists": false, + "runtimeDiagnosticsPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_Magic120CellTraining.hyperdiagnostics.log", + "runtimeDiagnosticsExists": true, + "detectedFatalLogLines": [ + + ], + "mapLoadLogLines": [ + "[2026-07-23T07:38:29Z] [MapReady] Runtime map ready: /Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining.L_HyperTwist_Magic120CellTraining." + ], + "requiresClassicPresentation": false, + "classicPresentationLogLines": [ + + ], + "classicScrambleSettlementLogLines": [ + + ], + "higherDimensionalFamily": "magic120cell", + "expectedHigherDimensionalElementCount": 120, + "higherDimensionalPresentationLogLines": [ + "[2026-07-23T07:38:29Z] [HigherDimensionalPresentation] Higher-dimensional presentation initialized with 120 renderable elements (canonical=120) for family magic120cell." + ], + "higherDimensionalPaletteLogLines": [ + "[2026-07-23T07:38:29Z] [HigherDimensionalPalette] Higher-dimensional projection palette initialized with 6 distinct layer materials." + ], + "smokeSeconds": 30, + "resolution": { + "width": 1600, + "height": 900 + }, + "keepRunning": false, + "useNullRhi": true, + "noSound": true, + "result": "passed", + "processId": 17440, + "processIds": [ + 17440 + ], + "processCommandLines": [ + "\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe\" -HyperTwistStartupRoute=magic-120-cell-training -ResX=1600 -ResY=900 -windowed -log -FORCELOGFLUSH -abslog=C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_Magic120CellTraining.log -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_Magic120CellTraining.hyperdiagnostics.log\" -NullRHI -nosound " + ], + "ownedListeningTcpEndpoints": [ + + ], + "traceControlListenerLines": [ + + ], + "traceControlListeningEndpoints": [ + + ], + "startupRouteCommandLineEvidence": [ + "\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe\" -HyperTwistStartupRoute=magic-120-cell-training -ResX=1600 -ResY=900 -windowed -log -FORCELOGFLUSH -abslog=C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_Magic120CellTraining.log -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_Magic120CellTraining.hyperdiagnostics.log\" -NullRHI -nosound " + ], + "startupRouteEvidenceLines": [ + "[2026-07-23T07:38:29Z] [PackagedStartupRoute] Accepted packaged startup route \u0027magic-120-cell-training\u0027: map=\u0027/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining\u0027, game_mode=\u0027/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingGameMode\u0027." + ], + "processStopped": true, + "stoppedProcessIds": [ + 17440 + ], + "exitCode": null, + "error": null + }, + { + "reportVersion": "ht-desktop-map-package-smoke/v7", + "generatedAtUtc": "2026-07-23T07:38:59.0982085Z", + "packageRoot": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723", + "launchMode": "first-party-startup-route", + "startupRouteId": "magic-cube-5d-training", + "launcherExecutablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe", + "executablePath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe", + "mapUrl": "/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining", + "runtimeLogPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_MagicCube5DTraining.log", + "runtimeLogExists": false, + "runtimeDiagnosticsPath": "C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_MagicCube5DTraining.hyperdiagnostics.log", + "runtimeDiagnosticsExists": true, + "detectedFatalLogLines": [ + + ], + "mapLoadLogLines": [ + "[2026-07-23T07:39:02Z] [MapReady] Runtime map ready: /Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining.L_HyperTwist_MagicCube5DTraining." + ], + "requiresClassicPresentation": false, + "classicPresentationLogLines": [ + + ], + "classicScrambleSettlementLogLines": [ + + ], + "higherDimensionalFamily": "magiccube5d", + "expectedHigherDimensionalElementCount": 242, + "higherDimensionalPresentationLogLines": [ + "[2026-07-23T07:39:02Z] [HigherDimensionalPresentation] Higher-dimensional presentation initialized with 242 renderable elements (canonical=242) for family magiccube5d." + ], + "higherDimensionalPaletteLogLines": [ + "[2026-07-23T07:39:02Z] [HigherDimensionalPalette] Higher-dimensional projection palette initialized with 6 distinct layer materials." + ], + "smokeSeconds": 30, + "resolution": { + "width": 1600, + "height": 900 + }, + "keepRunning": false, + "useNullRhi": true, + "noSound": true, + "result": "passed", + "processId": 2936, + "processIds": [ + 2936 + ], + "processCommandLines": [ + "\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe\" -HyperTwistStartupRoute=magic-cube-5d-training -ResX=1600 -ResY=900 -windowed -log -FORCELOGFLUSH -abslog=C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_MagicCube5DTraining.log -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_MagicCube5DTraining.hyperdiagnostics.log\" -NullRHI -nosound " + ], + "ownedListeningTcpEndpoints": [ + + ], + "traceControlListenerLines": [ + + ], + "traceControlListeningEndpoints": [ + + ], + "startupRouteCommandLineEvidence": [ + "\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\Windows\\UnrealHyperTwist.exe\" -HyperTwistStartupRoute=magic-cube-5d-training -ResX=1600 -ResY=900 -windowed -log -FORCELOGFLUSH -abslog=C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_MagicCube5DTraining.log -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate_packaged_alpha_release17_20260723\\validation\\logs\\_Game_HyperTwistTraining_Maps_L_HyperTwist_MagicCube5DTraining.hyperdiagnostics.log\" -NullRHI -nosound " + ], + "startupRouteEvidenceLines": [ + "[2026-07-23T07:39:02Z] [PackagedStartupRoute] Accepted packaged startup route \u0027magic-cube-5d-training\u0027: map=\u0027/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining\u0027, game_mode=\u0027/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingGameMode\u0027." + ], + "processStopped": true, + "stoppedProcessIds": [ + 2936 + ], + "exitCode": null, + "error": null + } + ], + "error": null +} \ No newline at end of file diff --git a/docs/generated/alpha_test_20260723/final_delivery_bootstrap_verification.json b/docs/generated/alpha_test_20260723/final_delivery_bootstrap_verification.json new file mode 100644 index 0000000..1cadafc --- /dev/null +++ b/docs/generated/alpha_test_20260723/final_delivery_bootstrap_verification.json @@ -0,0 +1,21 @@ +{ + "reportVersion": "ht-bootstrap-launch-arguments/v1", + "generatedAtUtc": "2026-07-23T08:07:08.1612366Z", + "executablePath": "C:\\Users\\Anthracite Ace\\Downloads\\HyperTwist-Alpha-Test-20260723\\UnrealHyperTwist.exe", + "resourceType": 10, + "resourceId": 202, + "verifyOnly": true, + "expectedArguments": [ + "UnrealHyperTwist", + "-notraceserver", + "-traceautostart=0" + ], + "expectedResourceValue": "UnrealHyperTwist -notraceserver -traceautostart=0", + "originalResourceValue": "UnrealHyperTwist -notraceserver -traceautostart=0", + "finalResourceValue": "UnrealHyperTwist -notraceserver -traceautostart=0", + "originalSha256": "886f69c6a823770b3ed68db4fe3790a488124c99c98dbd41944a6bed5d634dc6", + "finalSha256": "886f69c6a823770b3ed68db4fe3790a488124c99c98dbd41944a6bed5d634dc6", + "changed": false, + "result": "passed", + "error": null +} \ No newline at end of file diff --git a/docs/generated/alpha_test_20260723/final_delivery_dashboard_nullrhi_diagnostics.log b/docs/generated/alpha_test_20260723/final_delivery_dashboard_nullrhi_diagnostics.log new file mode 100644 index 0000000..b0ec8b2 --- /dev/null +++ b/docs/generated/alpha_test_20260723/final_delivery_dashboard_nullrhi_diagnostics.log @@ -0,0 +1,8 @@ +format=hypertwist-runtime-diagnostics/v1 +session=20260723T082546Z +process_id=9132 +command_line=-notraceserver -traceautostart=0 -nullrhi -unattended -nosound -HyperTwistStartupRoute=coach-dashboard -HyperTwistDiagnosticsLog=C:\HyperTwist_worktrees\phase10validate\UnrealHyperTwist\Saved\ValidationEvidence\alpha-release17-20260723\final-delivery-dashboard-nullrhi\dashboard.hyperdiagnostics.log +diagnostics_path=C:/HyperTwist_worktrees/phase10validate/UnrealHyperTwist/Saved/ValidationEvidence/alpha-release17-20260723/final-delivery-dashboard-nullrhi/dashboard.hyperdiagnostics.log +[2026-07-23T08:25:46Z] [Process] HyperTwist runtime module initialized. +[2026-07-23T08:25:46Z] [PackagedStartupRoute] Accepted packaged startup route 'coach-dashboard': map='', game_mode='/Script/UnrealHyperTwist.HyperTwistCoachDashboardGameMode'. +[2026-07-23T08:25:46Z] [PackagedStartupRoute] Opened packaged startup route 'coach-dashboard' through first-run product authority. diff --git a/docs/generated/alpha_test_20260723/final_delivery_first_run.png b/docs/generated/alpha_test_20260723/final_delivery_first_run.png new file mode 100644 index 0000000..324a719 Binary files /dev/null and b/docs/generated/alpha_test_20260723/final_delivery_first_run.png differ diff --git a/docs/generated/alpha_test_20260723/final_delivery_first_run_visual_report.json b/docs/generated/alpha_test_20260723/final_delivery_first_run_visual_report.json new file mode 100644 index 0000000..22c7dfb --- /dev/null +++ b/docs/generated/alpha_test_20260723/final_delivery_first_run_visual_report.json @@ -0,0 +1,94 @@ +{ + "reportVersion": "ht-packaged-window-visual/v1", + "generatedAtUtc": "2026-07-23T08:13:54.9632236Z", + "launchStartedAtUtc": "2026-07-23T08:13:55.0738236Z", + "executablePath": "C:\\Users\\Anthracite Ace\\Downloads\\HyperTwist-Alpha-Test-20260723\\UnrealHyperTwist.exe", + "executableSha256": "886f69c6a823770b3ed68db4fe3790a488124c99c98dbd41944a6bed5d634dc6", + "launchArguments": [ + "-windowed", + "-ResX=1280", + "-ResY=720", + "-HyperTwistCaptureWhenReady", + "-HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate\\UnrealHyperTwist\\Saved\\ValidationEvidence\\alpha-release17-20260723\\final-delivery-first-run-visual\\HyperTwist-window-20260723T081354Z.hyperdiagnostics.log\"" + ], + "perMonitorV2DpiAware": true, + "processId": 21520, + "windowTitle": "UnrealHyperTwist ", + "windowClassName": "UnrealWindow", + "expectedWindowClassName": "UnrealWindow", + "clientBounds": { + "x": 368, + "y": 297, + "width": 1280, + "height": 720 + }, + "screenshotPath": "C:\\HyperTwist_worktrees\\phase10validate\\UnrealHyperTwist\\Saved\\ValidationEvidence\\alpha-release17-20260723\\final-delivery-first-run-visual\\HyperTwist-window-20260723T081354Z.png", + "progressPath": "C:\\HyperTwist_worktrees\\phase10validate\\UnrealHyperTwist\\Saved\\ValidationEvidence\\alpha-release17-20260723\\final-delivery-first-run-visual\\HyperTwist-window-20260723T081354Z.progress.log", + "screenshotSha256": "6f5e3443d7b9188d6d3642c0a41f703e5a3beed7cee348982f7915621e786919", + "captureMethod": "hyper-twist-semantic-ready-frame", + "expectedSemanticReadyCaptureName": "first-run-launch-ready.png", + "semanticReadyCaptureSourcePath": "C:\\Users\\Anthracite Ace\\AppData\\Local\\UnrealHyperTwist\\Saved\\Screenshots\\HyperTwistDiagnostics\\first-run-launch-ready.png", + "semanticReadyCaptureSourceSha256": "96ff8611601efea7d62cfa5f964ae8ddf2f389966d03cffebe505b203b30b8c8", + "metrics": { + "sampleStride": 6, + "sampleCount": 25680, + "nonBlackRatio": 0.99941588785046731, + "brightRatio": 0.795638629283489, + "averageLuminance": 68.259309470418017, + "luminanceDeviation": 47.014937250449606, + "minimumLuminance": 0, + "maximumLuminance": 254.99999999999997, + "luminanceRange": 254.99999999999997, + "quantizedColorBucketCount": 79 + }, + "thresholds": { + "minimumNonBlackRatio": 0.02, + "minimumBrightRatio": 0.001, + "minimumLuminanceDeviation": 2.5, + "minimumLuminanceRange": 20 + }, + "interaction": { + "requested": false, + "normalizedX": -1, + "normalizedY": -1, + "screenX": null, + "screenY": null, + "clientX": null, + "clientY": null, + "injectionMethod": null, + "clicked": false, + "expectedStartupDiagnosticsResult": "launch-menu-renderable" + }, + "startupDiagnosticsPath": "C:\\Users\\Anthracite Ace\\AppData\\Local\\UnrealHyperTwist\\Saved\\Logs\\HyperTwistFirstRunLaunch-latest.log", + "startupDiagnosticsLastWriteUtc": "2026-07-23T08:13:58.9072912Z", + "startupDiagnosticsResult": "result=launch-menu-renderable", + "runtimeDiagnosticsPath": "C:\\HyperTwist_worktrees\\phase10validate\\UnrealHyperTwist\\Saved\\ValidationEvidence\\alpha-release17-20260723\\final-delivery-first-run-visual\\HyperTwist-window-20260723T081354Z.hyperdiagnostics.log", + "runtimeDiagnosticsExists": true, + "runtimeDiagnosticsLines": [ + "format=hypertwist-runtime-diagnostics/v1", + "session=20260723T081358Z", + "process_id=21520", + "command_line=-notraceserver -traceautostart=0 -windowed -ResX=1280 -ResY=720 -HyperTwistCaptureWhenReady -HyperTwistDiagnosticsLog=C:\\HyperTwist_worktrees\\phase10validate\\UnrealHyperTwist\\Saved\\ValidationEvidence\\alpha-release17-20260723\\final-delivery-first-run-visual\\HyperTwist-window-20260723T081354Z.hyperdiagnostics.log", + "diagnostics_path=C:/HyperTwist_worktrees/phase10validate/UnrealHyperTwist/Saved/ValidationEvidence/alpha-release17-20260723/final-delivery-first-run-visual/HyperTwist-window-20260723T081354Z.hyperdiagnostics.log", + "[2026-07-23T08:13:58Z] [Process] HyperTwist runtime module initialized." + ], + "processCommandLines": [ + "\"C:\\Users\\Anthracite Ace\\Downloads\\HyperTwist-Alpha-Test-20260723\\UnrealHyperTwist.exe\" -windowed -ResX=1280 -ResY=720 -HyperTwistCaptureWhenReady -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate\\UnrealHyperTwist\\Saved\\ValidationEvidence\\alpha-release17-20260723\\final-delivery-first-run-visual\\HyperTwist-window-20260723T081354Z.hyperdiagnostics.log\" ", + "\"C:\\Users\\Anthracite Ace\\Downloads\\HyperTwist-Alpha-Test-20260723\\UnrealHyperTwist\\Binaries\\Win64\\UnrealHyperTwist-Win64-Shipping.exe\" UnrealHyperTwist -notraceserver -traceautostart=0 -windowed -ResX=1280 -ResY=720 -HyperTwistCaptureWhenReady -HyperTwistDiagnosticsLog=\"C:\\HyperTwist_worktrees\\phase10validate\\UnrealHyperTwist\\Saved\\ValidationEvidence\\alpha-release17-20260723\\final-delivery-first-run-visual\\HyperTwist-window-20260723T081354Z.hyperdiagnostics.log\" ", + "\"C:/Users/Anthracite Ace/Downloads/HyperTwist-Alpha-Test-20260723/Engine/Binaries/Win64/EpicWebHelper.exe\" --type=gpu-process --no-sandbox --use-adapter-luid=0,66319 --use-angle=d3d11 --start-stack-profiler --user-data-dir=\"C:\\Users\\Anthracite Ace\\AppData\\Local\\UnrealHyperTwist\\Saved\\webcache_6613\" --locales-dir-path=\"C:/Users/Anthracite Ace/Downloads/HyperTwist-Alpha-Test-20260723/Engine/Binaries/ThirdParty/CEF3/Win64/128.4.13+ge76af7e+chromium-128.0.6613.138/Resources/locales\" --log-severity=warning --resources-dir-path=\"C:/Users/Anthracite Ace/Downloads/HyperTwist-Alpha-Test-20260723/Engine/Binaries/ThirdParty/CEF3/Win64/128.4.13+ge76af7e+chromium-128.0.6613.138/Resources\" --user-agent-product=\"UnrealHyperTwist/++UE5+Release-5.7-CL-51494982 UnrealEngine/5.7.4-51494982+++UE5+Release-5.7 Chrome/128.0.6613.138\" --gpu-preferences=UAAAAAAAAADgABAMAAAAAAAAAAAAAAAAAABgAAEAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAIAAAAAAAAAAgAAAAAAAAA --ipc-connection-timeout=60 --field-trial-handle=3112,i,10081791697659688799,9021224499203579802,262144 --variations-seed-version --enable-logging=handle --log-file=3228 --mojo-platform-channel-handle=3108 /prefetch:2", + "\"C:/Users/Anthracite Ace/Downloads/HyperTwist-Alpha-Test-20260723/Engine/Binaries/Win64/EpicWebHelper.exe\" --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US --service-sandbox-type=none --no-sandbox --use-angle=d3d11 --start-stack-profiler --user-data-dir=\"C:\\Users\\Anthracite Ace\\AppData\\Local\\UnrealHyperTwist\\Saved\\webcache_6613\" --locales-dir-path=\"C:/Users/Anthracite Ace/Downloads/HyperTwist-Alpha-Test-20260723/Engine/Binaries/ThirdParty/CEF3/Win64/128.4.13+ge76af7e+chromium-128.0.6613.138/Resources/locales\" --log-severity=warning --resources-dir-path=\"C:/Users/Anthracite Ace/Downloads/HyperTwist-Alpha-Test-20260723/Engine/Binaries/ThirdParty/CEF3/Win64/128.4.13+ge76af7e+chromium-128.0.6613.138/Resources\" --user-agent-product=\"UnrealHyperTwist/++UE5+Release-5.7-CL-51494982 UnrealEngine/5.7.4-51494982+++UE5+Release-5.7 Chrome/128.0.6613.138\" --ipc-connection-timeout=60 --field-trial-handle=3120,i,10081791697659688799,9021224499203579802,262144 --variations-seed-version --enable-logging=handle --log-file=3240 --mojo-platform-channel-handle=3236 /prefetch:11", + "\"C:/Users/Anthracite Ace/Downloads/HyperTwist-Alpha-Test-20260723/Engine/Binaries/Win64/EpicWebHelper.exe\" --type=utility --utility-sub-type=storage.mojom.StorageService --lang=en-US --service-sandbox-type=service --no-sandbox --use-angle=d3d11 --user-data-dir=\"C:\\Users\\Anthracite Ace\\AppData\\Local\\UnrealHyperTwist\\Saved\\webcache_6613\" --locales-dir-path=\"C:/Users/Anthracite Ace/Downloads/HyperTwist-Alpha-Test-20260723/Engine/Binaries/ThirdParty/CEF3/Win64/128.4.13+ge76af7e+chromium-128.0.6613.138/Resources/locales\" --log-severity=warning --resources-dir-path=\"C:/Users/Anthracite Ace/Downloads/HyperTwist-Alpha-Test-20260723/Engine/Binaries/ThirdParty/CEF3/Win64/128.4.13+ge76af7e+chromium-128.0.6613.138/Resources\" --user-agent-product=\"UnrealHyperTwist/++UE5+Release-5.7-CL-51494982 UnrealEngine/5.7.4-51494982+++UE5+Release-5.7 Chrome/128.0.6613.138\" --ipc-connection-timeout=60 --field-trial-handle=3312,i,10081791697659688799,9021224499203579802,262144 --variations-seed-version --enable-logging=handle --log-file=3340 --mojo-platform-channel-handle=3332 /prefetch:13" + ], + "ownedListeningTcpEndpoints": [ + + ], + "traceControlListenerLines": [ + + ], + "traceControlListeningEndpoints": [ + + ], + "focusMethod": "topmost-attempt-1", + "result": "visual-passed", + "error": null +} \ No newline at end of file diff --git a/docs/generated/alpha_test_20260723/final_delivery_zip_export_report.json b/docs/generated/alpha_test_20260723/final_delivery_zip_export_report.json new file mode 100644 index 0000000..ca16ff5 --- /dev/null +++ b/docs/generated/alpha_test_20260723/final_delivery_zip_export_report.json @@ -0,0 +1,101 @@ +{ + "reportVersion": "ht-packaged-build-zip-export/v2", + "generatedAtUtc": "2026-07-23T08:07:25.2519424Z", + "packageRoot": "C:\\Users\\Anthracite Ace\\Downloads\\HyperTwist-Alpha-Test-20260723", + "packagedExecutablePath": "C:\\Users\\Anthracite Ace\\Downloads\\HyperTwist-Alpha-Test-20260723\\UnrealHyperTwist.exe", + "destinationZipPath": "C:\\Users\\Anthracite Ace\\Downloads\\HyperTwist-Alpha-Test-20260723.zip", + "result": "passed", + "entryCount": 103, + "packagedExecutableEntry": "UnrealHyperTwist.exe", + "requiredEntries": [ + { + "kind": "launcher-executable", + "relativePath": "UnrealHyperTwist.exe", + "sizeBytes": 165376, + "sha256": "886f69c6a823770b3ed68db4fe3790a488124c99c98dbd41944a6bed5d634dc6" + }, + { + "kind": "game-executable", + "relativePath": "UnrealHyperTwist/Binaries/Win64/UnrealHyperTwist-Win64-Shipping.exe", + "sizeBytes": 172797440, + "sha256": "77309cde7d35200c0e8e364697e90eec9c5abbe7048ed87c97c519ca96267ca1" + }, + { + "kind": "runtime-dependency-tbbmalloc", + "relativePath": "UnrealHyperTwist/Binaries/Win64/tbbmalloc.dll", + "sizeBytes": 117688, + "sha256": "f81a11f2e6e93036bab4e51a1daab9940fb8a9a5ec0f508c4cb76d398e27c100" + }, + { + "kind": "cooked-pak", + "relativePath": "UnrealHyperTwist/Content/Paks/UnrealHyperTwist-Windows.pak", + "sizeBytes": 11069853, + "sha256": "a7ab3f4d8d30bfd1615893e6151d41320260daeaa3e0b5b9768a514e61575179" + }, + { + "kind": "cooked-utoc", + "relativePath": "UnrealHyperTwist/Content/Paks/UnrealHyperTwist-Windows.utoc", + "sizeBytes": 214933, + "sha256": "00b6b0e5cb1ab919ae9d86941c39641aeca8751d247e2e0002969b1983d24c71" + }, + { + "kind": "cooked-ucas", + "relativePath": "UnrealHyperTwist/Content/Paks/UnrealHyperTwist-Windows.ucas", + "sizeBytes": 281146192, + "sha256": "01dbc31748cc0e429c4c0d05752207b1f032d192c5a1792722bc36590014a005" + }, + { + "kind": "solver-table", + "relativePath": "UnrealHyperTwist/Saved/twophase-ht.tbl", + "sizeBytes": 676207080, + "sha256": "dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034" + } + ], + "archivedRequiredEntries": [ + { + "kind": "launcher-executable", + "relativePath": "UnrealHyperTwist.exe", + "sizeBytes": 165376, + "sha256": "886f69c6a823770b3ed68db4fe3790a488124c99c98dbd41944a6bed5d634dc6" + }, + { + "kind": "game-executable", + "relativePath": "UnrealHyperTwist/Binaries/Win64/UnrealHyperTwist-Win64-Shipping.exe", + "sizeBytes": 172797440, + "sha256": "77309cde7d35200c0e8e364697e90eec9c5abbe7048ed87c97c519ca96267ca1" + }, + { + "kind": "runtime-dependency-tbbmalloc", + "relativePath": "UnrealHyperTwist/Binaries/Win64/tbbmalloc.dll", + "sizeBytes": 117688, + "sha256": "f81a11f2e6e93036bab4e51a1daab9940fb8a9a5ec0f508c4cb76d398e27c100" + }, + { + "kind": "cooked-pak", + "relativePath": "UnrealHyperTwist/Content/Paks/UnrealHyperTwist-Windows.pak", + "sizeBytes": 11069853, + "sha256": "a7ab3f4d8d30bfd1615893e6151d41320260daeaa3e0b5b9768a514e61575179" + }, + { + "kind": "cooked-utoc", + "relativePath": "UnrealHyperTwist/Content/Paks/UnrealHyperTwist-Windows.utoc", + "sizeBytes": 214933, + "sha256": "00b6b0e5cb1ab919ae9d86941c39641aeca8751d247e2e0002969b1983d24c71" + }, + { + "kind": "cooked-ucas", + "relativePath": "UnrealHyperTwist/Content/Paks/UnrealHyperTwist-Windows.ucas", + "sizeBytes": 281146192, + "sha256": "01dbc31748cc0e429c4c0d05752207b1f032d192c5a1792722bc36590014a005" + }, + { + "kind": "solver-table", + "relativePath": "UnrealHyperTwist/Saved/twophase-ht.tbl", + "sizeBytes": 676207080, + "sha256": "dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034" + } + ], + "zipSizeBytes": 1082134734, + "zipSha256": "1ad8e1c0fc967b227ca09fb68c515ed644eca3e4bda07fdf8c378b438000fc42", + "error": null +} \ No newline at end of file diff --git a/docs/generated/alpha_test_20260723/first_run_automation_index.json b/docs/generated/alpha_test_20260723/first_run_automation_index.json new file mode 100644 index 0000000..7983944 --- /dev/null +++ b/docs/generated/alpha_test_20260723/first_run_automation_index.json @@ -0,0 +1,104 @@ +{ + "devices": [ + { + "deviceName": "DESKTOP-KS3VGHU", + "instance": "D568E70942FCF242DDC9FA97E16B2D0C", + "instanceName": "DESKTOP-KS3VGHU-8840", + "platform": "WindowsEditor", + "oSVersion": "Windows 11 (23H2) [10.0.22631.4037] ", + "model": "Default", + "gPU": "GenericGPUBrand", + "cPUModel": "12th Gen Intel(R) Core(TM) i7-12700H", + "rAMInGB": 16, + "renderMode": "SM6", + "rHI": "", + "appInstanceLog": "" + } + ], + "reportCreatedOn": "2026.07.23-07.18.34", + "succeeded": 5, + "succeededWithWarnings": 0, + "failed": 0, + "notRun": 0, + "inProcess": 0, + "totalDuration": 0.46825212240219116, + "comparisonExported": false, + "comparisonExportDirectory": "", + "tests": [ + { + "testDisplayName": "GameModeDefaults", + "fullTestPath": "HyperTwist.FirstRun.GameModeDefaults", + "tags": [], + "state": "Success", + "deviceInstance": [ + "D568E70942FCF242DDC9FA97E16B2D0C" + ], + "duration": 0.015960302203893661, + "dateTime": "2026.07.23-07.18.33", + "entries": [], + "warnings": 0, + "errors": 0, + "artifacts": [] + }, + { + "testDisplayName": "LaunchGuidanceContract", + "fullTestPath": "HyperTwist.FirstRun.LaunchGuidanceContract", + "tags": [], + "state": "Success", + "deviceInstance": [ + "D568E70942FCF242DDC9FA97E16B2D0C" + ], + "duration": 0.016730200499296188, + "dateTime": "2026.07.23-07.18.33", + "entries": [], + "warnings": 0, + "errors": 0, + "artifacts": [] + }, + { + "testDisplayName": "LaunchRouteContract", + "fullTestPath": "HyperTwist.FirstRun.LaunchRouteContract", + "tags": [], + "state": "Success", + "deviceInstance": [ + "D568E70942FCF242DDC9FA97E16B2D0C" + ], + "duration": 0.016192298382520676, + "dateTime": "2026.07.23-07.18.33", + "entries": [], + "warnings": 0, + "errors": 0, + "artifacts": [] + }, + { + "testDisplayName": "NativeWidgetRootLifecycle", + "fullTestPath": "HyperTwist.FirstRun.NativeWidgetRootLifecycle", + "tags": [], + "state": "Success", + "deviceInstance": [ + "D568E70942FCF242DDC9FA97E16B2D0C" + ], + "duration": 0.40424719452857971, + "dateTime": "2026.07.23-07.18.33", + "entries": [], + "warnings": 0, + "errors": 0, + "artifacts": [] + }, + { + "testDisplayName": "PackagedStartupRouteArgumentContract", + "fullTestPath": "HyperTwist.FirstRun.PackagedStartupRouteArgumentContract", + "tags": [], + "state": "Success", + "deviceInstance": [ + "D568E70942FCF242DDC9FA97E16B2D0C" + ], + "duration": 0.015122100710868835, + "dateTime": "2026.07.23-07.18.33", + "entries": [], + "warnings": 0, + "errors": 0, + "artifacts": [] + } + ] +} \ No newline at end of file diff --git a/docs/generated/alpha_test_20260723/superseded_july9_retirement_report.json b/docs/generated/alpha_test_20260723/superseded_july9_retirement_report.json new file mode 100644 index 0000000..c648f0c --- /dev/null +++ b/docs/generated/alpha_test_20260723/superseded_july9_retirement_report.json @@ -0,0 +1,19 @@ +{ + "reportVersion": "ht-superseded-delivery-retirement/v1", + "generatedAtUtc": "2026-07-23T08:14:43.2559068Z", + "oldFolder": "C:\\Users\\Anthracite Ace\\Downloads\\HyperTwist-desktop-first-run-authority-repair-20260709", + "oldFolderExisted": true, + "oldFolderFileCount": 288, + "oldFolderBytes": 1587731789, + "oldFolderDeleted": true, + "oldZip": "C:\\Users\\Anthracite Ace\\Downloads\\HyperTwist-desktop-first-run-authority-repair-20260709.zip", + "oldZipExisted": true, + "oldZipBytes": 719569118, + "oldZipDeleted": true, + "replacementFolder": "C:\\Users\\Anthracite Ace\\Downloads\\HyperTwist-Alpha-Test-20260723", + "replacementFolderPresent": true, + "replacementZip": "C:\\Users\\Anthracite Ace\\Downloads\\HyperTwist-Alpha-Test-20260723.zip", + "replacementZipPresent": true, + "replacementZipBytes": 1082134734, + "result": "passed" +} \ No newline at end of file diff --git a/docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_map_manifest.json b/docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_map_manifest.json index 1a4b612..61bdadf 100644 --- a/docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_map_manifest.json +++ b/docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_map_manifest.json @@ -1,17 +1,17 @@ { "manifestId": "phase6c/dedicated-family-training-map-authoring", - "manifestVersion": "2026.06.18", + "manifestVersion": "2026.07.19", "authorTag": "HyperTwistHigherDimensionalTrainingShell", "authoringScriptRelativePath": "scripts/hypertwist_author_higher_dimensional_training_maps.py", - "authoredThroughGameModeClassPath": "/Script/UnrealHyperTwist.HyperTwistCoachDashboardGameMode", - "classicReferenceMapHashMd5": "7772cc42fd9fd129cb6d99c0b918a24e", + "authoredThroughGameModeClassPath": "/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingGameMode", + "classicReferenceMapHashMd5": "4ae31fc4007d4a931e0f4e9bfe319a63", "entries": [ { "mapKind": "magic120cell", "familyKey": "magic120cell", "mapAssetPath": "/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining", "mapFileRelativePath": "UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining.umap", - "mapHashMd5": "9d9b9301f14cdad8c263d2c57b5f2c7f", + "mapHashMd5": "9f8e311e23e72fe451ec6e926b4ec511", "trainingShellId": "phase6c/magic120cell/dedicated-training-shell", "activationProfileId": "magic120cell-cleanroom-runtime-activation", "hostSurfaceId": "phase6c/magic120cell/runtime-host-surface", @@ -26,7 +26,10 @@ "primaryPersistenceBoundaryId": "magic120cell-persistence-boundary", "authorTag": "HyperTwistHigherDimensionalTrainingShell", "authoringManifestRelativePath": "docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_map_manifest.json", - "authoredViaGameModeClassPath": "/Script/UnrealHyperTwist.HyperTwistCoachDashboardGameMode", + "authoredViaGameModeClassPath": "/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingGameMode", + "runtimePresentationClassPath": "/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingShellActor", + "presentationEnvironmentOwnership": "runtime-game-mode-and-shell-components", + "canonicalRenderableElementCount": 120, "trainingShellTags": [ "phase6c", "family:magic120cell", @@ -40,7 +43,7 @@ "familyKey": "magiccube5d", "mapAssetPath": "/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining", "mapFileRelativePath": "UnrealHyperTwist/Content/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining.umap", - "mapHashMd5": "15d2f7acfdce43402ad2fd291fc5c781", + "mapHashMd5": "411165b200e44ba442020739a53268c0", "trainingShellId": "phase6c/magiccube5d/dedicated-training-shell", "activationProfileId": "magiccube5d-cleanroom-runtime-activation", "hostSurfaceId": "phase6c/magiccube5d/runtime-host-surface", @@ -55,7 +58,10 @@ "primaryPersistenceBoundaryId": "magiccube5d-persistence-boundary", "authorTag": "HyperTwistHigherDimensionalTrainingShell", "authoringManifestRelativePath": "docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_map_manifest.json", - "authoredViaGameModeClassPath": "/Script/UnrealHyperTwist.HyperTwistCoachDashboardGameMode", + "authoredViaGameModeClassPath": "/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingGameMode", + "runtimePresentationClassPath": "/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingShellActor", + "presentationEnvironmentOwnership": "runtime-game-mode-and-shell-components", + "canonicalRenderableElementCount": 242, "trainingShellTags": [ "phase6c", "family:magiccube5d", diff --git a/docs/ops/HYPERTWIST_ALPHA_TEST_README_2026-07-23.txt b/docs/ops/HYPERTWIST_ALPHA_TEST_README_2026-07-23.txt new file mode 100644 index 0000000..5812be9 --- /dev/null +++ b/docs/ops/HYPERTWIST_ALPHA_TEST_README_2026-07-23.txt @@ -0,0 +1,85 @@ +HYPERTWIST ALPHA TEST - WINDOWS +Release date: 2026-07-23 + +START HERE + +1. Extract the entire ZIP before launching. Do not run HyperTwist from inside + the ZIP. +2. Keep the extracted folder intact. HyperTwist needs its UnrealHyperTwist + content folder, engine files, and runtime DLLs beside the launcher. +3. Run the top-level UnrealHyperTwist.exe. +4. The first-run launch menu should appear. A headset is not required: + keyboard and mouse are the primary Alpha test path. + +FIRST-RUN ROUTES + +- Coach Dashboard and Settings: operator status, controls, local settings, + training surfaces, browser-runtime status, and diagnostics. +- Classic Cube Free Play: interactive 3x3 cube, scramble/timer, hints, replay, + local best times, follow-along mode, and optional voice-command surfaces. +- Classic Cube Follow-Along: guided classic-cube practice. +- Magic120Cell Training: dedicated 120-element state, projection, visibility, + focus, and local persistence training surface. +- MagicCube5D Training: dedicated 242-element order-three 5D state, + projection, visibility, focus, and local persistence training surface. +- OpenXR Validation: bounded headset/controller readiness surface. Its + configuration is present, but physical headset and controller behavior is + still an explicit live-device Alpha validation gate. + +CLASSIC CUBE CONTROLS + +- Left click a cube piece: clockwise face turn. +- Right click a cube piece: counter-clockwise face turn. +- Middle-mouse drag: orbit camera. +- Mouse wheel: zoom. +- R: new scramble. +- H: hint. +- Enter: submit solve. +- F: toggle free-play/follow-along mode. +- Hold V: voice capture when a local speech provider is available. +- C: cycle voice profile. + +MAGIC120CELL AND MAGICCUBE5D CONTROLS + +- Space: toggle projection auto-rotation. +- Q / E: rotate projection. +- Page Up / Page Down: change visible projection layer. +- N: create a new scrambled runtime state. +- R: reset projection. +- S: save local state. +- L: load local state. +- Home: reset camera. +- Mouse wheel: zoom. +- Middle-mouse drag or Shift+right-mouse drag: orbit. +- D: toggle diagnostics/dashboard surface. + +GRAPHICS + +HyperTwist defaults to Direct3D 11 for broad first-run compatibility. D3D12 is +an opt-in test path: create a shortcut to UnrealHyperTwist.exe and append +"-d3d12" to its Target only when intentionally testing that renderer. + +DIAGNOSTICS + +HyperTwist Shipping builds retain first-party diagnostics without enabling +Unreal Development trace-control listeners. If startup fails or a surface is +blank, collect these files: + +- %LOCALAPPDATA%\UnrealHyperTwist\Saved\Logs\HyperTwistRuntime-latest.log +- %LOCALAPPDATA%\UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log +- %LOCALAPPDATA%\UnrealHyperTwist\Saved\Crashes (if present) + +Include the route selected, exact reproduction steps, a screenshot, Windows +version, GPU model/driver, and whether any optional local voice, speech, or +vision sidecar was running. Core keyboard/mouse simulation must start without +those optional sidecars. + +ALPHA BOUNDARY + +This package is an Alpha test build, not a final production installer. The +Classic Cube route owns interactive twist behavior. The higher-dimensional +routes currently own dedicated family state, projection, focus/visibility, +local persistence, maps, and launch surfaces; they do not yet claim complete +parity with every mature higher-dimensional twist mechanic. Live physical +OpenXR headset/controller observation also remains pending until hardware is +available. diff --git a/docs/ops/HYPERTWIST_ALPHA_TEST_TAKEOVER_BLACK_SCREEN_REPAIR_AND_VALIDATION_2026-07-19.md b/docs/ops/HYPERTWIST_ALPHA_TEST_TAKEOVER_BLACK_SCREEN_REPAIR_AND_VALIDATION_2026-07-19.md new file mode 100644 index 0000000..23d64e1 --- /dev/null +++ b/docs/ops/HYPERTWIST_ALPHA_TEST_TAKEOVER_BLACK_SCREEN_REPAIR_AND_VALIDATION_2026-07-19.md @@ -0,0 +1,394 @@ +# HyperTwist Alpha Test Takeover, Black-Screen Repair, and Validation + +Date: `2026-07-19` + +Last updated: `2026-07-23` + +Status: fixed Shipping Alpha delivered and archive-verified; first-run and +Classic interactive visuals green; coach-dashboard route green under +first-party headless diagnostics, with a fresh dashboard screenshot retained +as operator-connected visual QA rather than misreported from a disconnected +desktop + +## Purpose + +This document is the durable handoff and evidence authority for the first +operator-reported HyperTwist desktop Alpha failures: + +- an earlier extracted executable opened to a black or functionally empty scene +- an earlier ZIP could not be opened reliably +- another loose executable failed because `tbbmalloc.dll` was absent +- prior package smoke reports proved process survival but did not prove that a + rendered launch surface, gameplay map, camera, HUD, or cube was present + +The repair goal is stricter than "the process starts." A distributable is ready +only when the exact packaged binary: + +1. creates a real Unreal window in the interactive Windows desktop session +2. renders the first-run launch surface +3. opens the coach dashboard from that surface +4. opens the Classic Cube route from that surface +5. renders all 26 visible cubies, the authored environment, the orbit camera, + and the gameplay HUD +6. writes current runtime and startup diagnostics +7. survives fresh packaged launches for all four shipped training maps +8. is delivered as a complete folder and a ZIP whose entries and hash are + independently verified + +## Authority and source review + +The takeover review used the live HyperTwist repository as implementation +authority and cross-referenced the following context families: + +- `docs/`, including the comprehensive reconstruction roadmap, deep manual + pack, feature registry, development guide, packaging doctrine, hygiene + doctrine, reverse-SSH runbook, and previous Windows package evidence +- `/home/dev/src/visual_studio_solutions/multi_project/GPT 5.4 HyperTwist parse` + as historical product, donor, clean-room, and handoff lineage +- `/home/dev/src/Workspaces/HyperTwist` as repository-evaluation and clean-room + workspace lineage +- `/home/dev/src/Workspaces/HyperTwist-worktrees` as worktree-placement context +- the maintained Windows validation root + `C:\HyperTwist_worktrees\phase10validate` + +Historical parse documents remain useful lineage, but their May 2026 maturity +descriptions do not outrank the current source, current roadmap, or current +packaged-runtime evidence. + +## Failure analysis + +### 1. Native widget trees were built too late + +The first-run menu, Classic HUD, and coach dashboard are native `UUserWidget` +implementations. Their old lifecycle could let `Super::RebuildWidget()` cache +an empty fallback Slate widget before the code-created `WidgetTree` had a root. +Later construction changed UObject state, but not necessarily the already +cached rendered Slate tree. This explains a package that was alive and logging +without showing its intended UI. + +Repair: + +- build native widget roots before the superclass Slate rebuild completes +- keep initialization and construction idempotent +- expose explicit readiness predicates for all three surfaces +- reject and log a widget that has no renderable root +- place launch/dashboard surfaces above ordinary gameplay HUD layers +- validate first-run cached geometry after real layout ticks rather than + treating `AddToViewport()` as visual proof + +### 2. Cooked runtime geometry ownership was incomplete + +The Classic cube's 26 procedural cubie components could exist during editor +construction while not remaining authoritative in the cooked runtime. The old +`BeginPlay()` path skipped regeneration when construction generation was +enabled. A prior packaged log therefore truthfully reported `0 renderable +pieces` even though the map contained a placed cube actor. + +Repair: + +- define the physical invariant as 26 visible cubies; the enclosed core is not + rendered +- count registered procedural meshes with actual sections at runtime +- call `EnsureRenderablePresentation()` from both actor and game-mode startup +- regenerate when the count is incomplete +- own dynamic meshes and turn pivots as actor instance components +- mark runtime-generated presentation components transient so cooked map + serialization is not mistaken for runtime ownership +- log the exact final cubie count and whether recovery was required + +### 3. The authored maps were presentation-incomplete + +The earlier Classic and Follow-Along maps were too sparse to be trusted as +shipping launch surfaces. A later visual run also exposed a real-time SkyLight +without the required SkyAtmosphere context. + +Repair: + +- transactionally author both maps with a floor, directional light, SkyLight, + SkyAtmosphere, reflection capture, PlayerStart, and cube actor +- preserve the correct game-mode override per map +- validate the seven required actor labels before writing a durable completion + marker +- restore the pre-authoring map automatically if authoring does not complete +- use D3D11 offscreen authoring by default because the zero-extent headless + Plane path was unsafe on this UE 5.7 host +- use a thin Cube floor to retain volumetric bounds +- keep game-mode fallbacks for a missing orbit pawn or key light + +Current source map SHA-256 values after transactional authoring: + +- Classic: `b47d58cc2db66589cb0110548a002cf8446ce610c8448c50c9fbab329f3742ff` +- Follow-Along: `69a986cf36d53a6a22845dc89ecbe95119a1c2f383f344ecf020d6ee58ba0e47` + +### 4. Unquoted INI URLs were parsed incorrectly + +The configured local HTTP sidecar values used `http://...` without quotes. +Unreal INI parsing could collapse these values to `http:/...`, producing +malformed requests and noisy startup failures. + +Repair: + +- quote all three local HTTP service base URLs in `DefaultGame.ini` +- trim and validate complete `http://` or `https://` schemes in each HTTP + client before joining paths +- reject an absent authority rather than emitting a malformed request +- add a loaded-default configuration regression test for vision, speech, and + voice clients + +Connection-refused warnings remain acceptable when an optional local sidecar +is not running. A malformed URL is not acceptable. + +### 5. Package validation could report false success + +Several harness behaviors allowed stale or incomplete evidence: + +- old runtime/startup logs could be rediscovered +- a bootstrap process could exit while a child process survived untracked +- cleanup could leave packaged children running +- a raw path prefix could classify a sibling archive as an owned process +- a 10-second map smoke ended during plugin startup before any world loaded +- process survival and image brightness did not prove a specific UI route + +Repair: + +- remove the target runtime log before each launch and require the new file +- require fresh startup diagnostics by timestamp +- discover package descendants by executable path and protect pre-existing PIDs +- match owned paths at a directory boundary, not a raw string prefix +- stop the complete owned process family after validation +- classify Unreal low-level fatal, assertion, critical-error, swap-chain, and + unhandled-exception signatures as blocking +- require the expected `UnrealWindow` class for visual proof +- capture only the verified client rectangle with per-monitor DPI awareness +- inject interaction into the verified client and require a route-specific + startup-diagnostics result +- wait 30 seconds for packaged map smoke, require an actual target-map load + line, and require the exact 26-cubie marker for Classic and Follow-Along +- serialize plain strings into reports so PowerShell object formatting cannot + create a false result +- terminate timed-out interactive-task descendants before deleting the task + +### 6. Generic map arguments did not own packaged startup + +The project-level first-run game-mode authority can override a generic map +argument. Earlier package helpers therefore appeared to request a training map +while the product still resolved its normal first-launch owner. This was a +validation-route defect, not proof that the family map had opened. + +Repair: + +- add the bounded first-party + `-HyperTwistStartupRoute=` argument +- resolve the argument only through + `UHyperTwistFirstRunLaunchLibrary::BuildFirstRunLaunchRoutes()` +- reject empty, duplicate, unknown, overlong, case-drifted, or character- + injected route identifiers +- invoke the same `OpenFirstRunRoute()` authority used by visible UI buttons +- make package launchers use the public outer bootstrap and registered route + ids instead of treating raw map arguments as product authority +- cover absent, valid, and invalid parser states in focused automation + +### 7. Stock Shipping did not guarantee an Unreal log + +The final package uses an ordinary Unreal Shipping target. It does not enable +the incompatible `bUseLoggingInShipping` target posture, and an engine +`-log`/`-abslog` file is therefore optional rather than release authority. + +Repair: + +- add `HyperTwistRuntimeDiagnostics`, a first-party, Shipping-compatible, + append-only diagnostics surface independent of `UE_LOG` +- accept an explicit `-HyperTwistDiagnosticsLog=` for immutable + validation runs +- otherwise write `HyperTwistRuntime-latest.log` beneath the user Saved/Logs + directory +- require runtime initialization, semantic map/presentation markers, fatal + scans across every available log, package-owned process command lines, and + no unexpected listener/trace-control endpoint +- retain `HyperTwistFirstRunLaunch-latest.log` as the separate bounded + first-run/menu/route result authority + +### 8. Desktop pixels alone were not semantic proof + +`CopyFromScreen()` can capture the surrounding desktop when a window is hidden, +occluded, moved by DPI scaling, or unavailable in a disconnected session. A +bright screenshot is not sufficient evidence that HyperTwist rendered the +expected product surface. + +Repair: + +- have first-run, Classic, and higher-dimensional runtime owners request a + named ready-frame capture only after their semantic readiness invariant is + true +- require a fresh named PNG created after the launch timestamp for release + visual gates +- record both the copied evidence SHA-256 and the original semantic-frame + SHA-256 +- keep client-window screenshots without a semantic capture as operator + corroboration only +- do not convert a disconnected/locked Windows desktop failure into a green + visual result + +## Validation state + +### Source and editor gates green + +- UE 5.7 editor and game targets: `Result: Succeeded` +- final Shipping editor rebuild after the route and direct-diagnostics repair: + `Result: Succeeded`, 10 actions, UnrealBuildTool total `90.11 s` +- focused Classic automation: `16/16` green +- final focused first-run automation: `5/5` green, including + `PackagedStartupRouteArgumentContract` +- focused HTTP configuration automation: `1/1` green +- Sentrux source-only architecture check: all `7` rules pass +- GitNexus bounded source-only index: current and up to date +- Python map-authoring compile: green +- all current PowerShell package/launch/visual helpers parse successfully + +### Historical superseded package archive + +Build/archive root: + +`C:\HyperTwist_worktrees\phase10validate_packaged_alpha_final_20260719` + +Automated package facts: + +- configuration: `Development` +- cooked map count: `4` +- packaged executable SHA-256: + `ccbc74bb6bcc8b7340d74ca15109c8159a83250f1413ea01f7f61a0c091f2c45` +- package file count before final delivery metadata: `127` +- package bytes before final delivery metadata: `1577648281` +- all four initial process-survival map launches completed without fatal log + signatures +- desktop bootstrap launch completed with a current runtime log and bounded + NullRHI startup recovery + +The initial four-map report was not final semantic authority because its old +10-second interval ended before world load. It is retained only as failure +lineage and is superseded by the Release 17 Shipping evidence below. + +### Current Release 17 Shipping package + +Build/archive root: + +`C:\HyperTwist_worktrees\phase10validate_packaged_alpha_release17_20260723` + +Durable pulled evidence: + +`docs/generated/alpha_test_20260723` + +Final package facts: + +- configuration: `Shipping` +- outer executable SHA-256: + `886f69c6a823770b3ed68db4fe3790a488124c99c98dbd41944a6bed5d634dc6` +- bootstrap resource: + `UnrealHyperTwist -notraceserver -traceautostart=0` +- exact solver table: `676207080` bytes, SHA-256 + `dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034` +- Classic semantic smoke: exact map, `26` renderable pieces, settled valid, + no queued turns +- Follow-Along semantic smoke: accepted registered startup route, exact map, + `26` renderable pieces, settled valid, no queued turns +- Magic120Cell semantic smoke: accepted registered startup route, exact map, + `120` canonical renderable elements, `6` distinct layer materials +- MagicCube5D semantic smoke: accepted registered startup route, exact map, + `242` canonical renderable elements, `6` distinct layer materials +- all four smokes recorded runtime initialization and semantic `MapReady` + evidence with no fatal signature, trace listener, or package-owned listening + endpoint + +The first Release 17 aggregate status retained a historical false failure +because the old desktop validator still required an absent Shipping Unreal log. +The corrected v2 launch report is the current authority: direct runtime +diagnostics initialized, first-run diagnostics were fresh, package-owned +processes were live, and no fatal or trace-control evidence was present. + +Interactive proof: + +- the exact final Downloads executable rendered an `UnrealWindow` and produced + a fresh semantic first-run frame with result `launch-menu-renderable`, + screenshot SHA-256 + `6f5e3443d7b9188d6d3642c0a41f703e5a3beed7cee348982f7915621e786919` +- a real `WindowMessage` click on the visible Classic button produced + `map-route-opened`, the exact Classic map, `26` renderable pieces, `29` + completed quarter turns, `0` queued turns, and a fresh semantic frame with + screenshot SHA-256 + `6e6b8f2c06f6ff8a68c2f4d9e0a576305056cd6f4d7353e9894a05e63d853ab5` +- a later dashboard visual retry correctly failed when no live interactive + desktop was available; no screenshot from that attempt is release evidence +- the same final executable then passed a first-party NullRHI dashboard-route + probe: runtime initialized, `coach-dashboard` was accepted, and the route + opened through first-run product authority + +### Required final evidence + +The following remain the acceptance ledger: + +- [x] corrected v2 map-smoke reports prove all four target maps actually loaded +- [x] Classic and Follow-Along diagnostics confirm 26 renderable cubies +- [x] final first-run automation proves route delegates and strict packaged + startup-route parsing +- [x] interactive first-run screenshot and `launch-menu-renderable` +- [ ] fresh interactive coach-dashboard screenshot after an operator connects + or unlocks the Windows desktop; the route itself is green under direct + Shipping diagnostics and this visual-only item must not block use of the + delivered Alpha +- [x] interactive Classic route screenshot and `map-route-opened` +- [x] interactive Classic diagnostics have 26 cubies and no old presentation + warnings +- [x] complete folder copied to the Windows Downloads directory +- [x] ZIP created with `System.IO.Compression.ZipFile` +- [x] ZIP reopened, entries enumerated, and seven critical payloads stream- + hashed from the archive +- [x] final folder/ZIP SHA-256, sizes, and counts recorded + +## Operator diagnostics + +The delivered Alpha uses stock Shipping configuration and first-party direct +diagnostics. After launch, inspect: + +- `%LOCALAPPDATA%\UnrealHyperTwist\Saved\Logs\HyperTwistRuntime-latest.log` - + Shipping-compatible runtime initialization, map, presentation, and route + events +- `%LOCALAPPDATA%\UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log` + - bounded first-run menu, fallback, and route result + +An `UnrealHyperTwist.log` may be absent in stock Shipping and is not required +when direct diagnostics are present. + +The package-validation helpers also write immutable-per-run evidence beneath +the archive's `validation` directory. + +Do not diagnose a black screen by checking only that `UnrealHyperTwist.exe` is +running. Check the window class, current screenshots, startup result, target +map-load line, game-mode presentation marker, and fatal-signature scan. + +## Distribution rule + +Never distribute a loose `UnrealHyperTwist.exe`. The executable depends on the +complete staged `Windows` directory, including engine binaries, project +content containers, and runtime DLLs such as `tbbmalloc.dll`. + +The approved Alpha distribution shape is: + +- an extracted folder for direct operator testing +- a separately verified ZIP of that complete folder +- matching release metadata with SHA-256, byte count, file/entry count, and + validation references + +Approved current delivery: + +- folder: + `C:\Users\Anthracite Ace\Downloads\HyperTwist-Alpha-Test-20260723` +- folder facts: `103` files, `1887943160` bytes +- ZIP: + `C:\Users\Anthracite Ace\Downloads\HyperTwist-Alpha-Test-20260723.zip` +- ZIP facts: `103` entries, `1082134734` bytes, SHA-256 + `1ad8e1c0fc967b227ca09fb68c515ed644eca3e4bda07fdf8c378b438000fc42` + +The superseded July 9 folder and matching ZIP were deleted only after the +replacement folder, archive, critical-entry hashes, and final Downloads launch +were verified. The retirement report is retained at +`docs/generated/alpha_test_20260723/superseded_july9_retirement_report.json`. diff --git a/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md b/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md index c2c1baf..f4261c9 100644 --- a/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md +++ b/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md @@ -509,6 +509,40 @@ Routing correction: - [x] Hardening slice on `2026-06-18`: replay normalization now repairs missing replay/session ids, duplicate event ids, out-of-order sequences, and monotonic timestamp drift before persisted packets are reused; leaderboard normalization now repairs schema-light entries before best-time/status resolution; and the maintained package helper now fails fast on missing smoke JSON, unparseable smoke JSON, non-`passed` smoke results, or unexpected `mapUrl` drift. - [x] Validation evidence on `2026-06-18`: the same primary reverse-SSH `localhost:22022` lane rebuilt isolated worktree `C:\HyperTwist_worktrees\phase10validate` after the Phase 10B truthfulness correction and solver-wrapper distribution hardening, then refreshed `20` relevant tests with `0` failures in `134.97 s`, including green `HyperTwist.Simulation.ClassicCube.Behavior.*`, `HyperTwist.Solver.*`, `HyperTwist.Training.Timer.Accuracy`, `HyperTwist.Speech.Transcription.ClassicCubeMoveParsing`, and all `11` `HyperTwist.Integration.ClassicCube.*` cases. `RandomClassicStates` now records runtime facelet export, exact inverse-scramble verification, and runtime round-trip solve instead of pretending the native solver lane is a bounded 100-state benchmark; `Timer.Accuracy` remained green with environment-only unresolved `vision`/`speech` hostname warnings in the unattended lane. - [x] Package evidence on `2026-06-18`: the same primary reverse-SSH `localhost:22022` lane then re-ran the maintained helper on isolated worktree `C:\HyperTwist_worktrees\phase10validate` with explicit runtime-only cooker exclusions `-DisablePlugins=MovieRenderPipeline` and `-SkipCookingEditorContent`, clearing the editor-only `MovieRenderPipeline` blueprint contamination that had blocked the first repair attempt. `BuildCookRun` completed with `ExitCode=0`, `BuildCookRun time: 1381.37 s`, archive output in `C:\HyperTwist_worktrees\phase10validate_packaged_phase10b10c_repair4`, aggregate validation JSON at `validation\classic-cube-package-validation-report.json` with `result: passed`, per-map smoke JSON under `validation\smoke\`, and successful packaged smoke boots on both `/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining` and `/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining`. +- [x] Shipping Alpha repair evidence on `2026-07-23`: the maintained + `localhost:22023` reverse-SSH lane rebuilt the current first-party source in + `C:\HyperTwist_worktrees\phase10validate`, passed all `5` + `HyperTwist.FirstRun` tests, and produced Release 17 at + `C:\HyperTwist_worktrees\phase10validate_packaged_alpha_release17_20260723`. + The package now resolves nondefault startup through strict registered + `-HyperTwistStartupRoute` ids instead of ineffective raw map arguments, + writes first-party direct diagnostics in stock Shipping, and carries + compile-time-disabled trace control plus verified outer-bootstrap + `-notraceserver -traceautostart=0` arguments. +- [x] Release 17 package semantics on `2026-07-23`: all four fresh smokes + passed with exact `MapReady` evidence; Classic and Follow-Along each exposed + `26` renderable cubies and settled valid state, Magic120Cell exposed exactly + `120` canonical elements with `6` layer materials, and MagicCube5D exposed + exactly `242` canonical elements with `6` layer materials. No package-owned + listener, trace-control endpoint, or fatal signature was observed. +- [x] Release 17 visual/distribution proof on `2026-07-23`: the exact final + Downloads executable rendered a fresh semantic first-run frame with + `launch-menu-renderable`; a real client-window message on the visible + Classic button produced `map-route-opened`, the exact Classic map, 26 + renderable cubies, orbit camera, HUD, and settled valid state. The delivered + folder at + `C:\Users\Anthracite Ace\Downloads\HyperTwist-Alpha-Test-20260723` contains + `103` files and the verified sibling ZIP contains `103` entries, + `1082134734` bytes, SHA-256 + `1ad8e1c0fc967b227ca09fb68c515ed644eca3e4bda07fdf8c378b438000fc42`. + The ZIP was reopened and all seven critical payloads were independently + stream-hashed. Durable proof lives under + `docs/generated/alpha_test_20260723`. +- [ ] Operator-connected dashboard visual follow-up: the final Shipping + executable already accepts and opens the registered `coach-dashboard` route + under direct NullRHI diagnostics, but a fresh dashboard screenshot must wait + for an unlocked/live Windows desktop. Disconnected-session exit or + desktop-copy output must remain a rejected visual rather than a false green. **Estimated actions:** 20–30 **Estimated time:** 1–2 days @@ -553,6 +587,14 @@ Routing correction: ## Immediate Next Step +**Current Alpha next move (`2026-07-23`):** use the delivered Release 17 folder +for keyboard/mouse operator acceptance. On the next live Windows desktop +session, capture the remaining coach-dashboard visual, then prioritize +installer/uninstaller, code-signing, clean-machine prerequisite, entitlement, +and update-channel rollout gates before calling the desktop generally +production-deployable. Do not reopen simulator topology or renderer widening +to perform this release engineering. + **Phase 1 through Phase 5 are now closed on the intended simulator lane:** repo wiring, the classic-cube renderer/runtime loop, dedicated training maps and materials, solver-guided training, and speech command or narration surfaces are implemented and validated through Windows build, diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/DEVELOPMENT.md b/docs/v6_5_deep_manual_pack/HyperTwist/DEVELOPMENT.md index 7049acb..616e26c 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/DEVELOPMENT.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/DEVELOPMENT.md @@ -3772,3 +3772,92 @@ Later same-day recommendation-history micro-follow-up (`2026-06-30`): ownership reduction seam, especially inside `DeriveCoachRecommendationHistorySummary(...)`, rather than reopening a different product family prematurely + +## Shipping Alpha startup, diagnostics, and visual authority (`2026-07-23`) + +The Release 17 Alpha closes the user-reported black/empty first-launch and +corrupt/incomplete distribution lane with stronger product and validation +authority. + +### Packaged startup route + +Nondefault package launches must use: + +```text +-HyperTwistStartupRoute= +``` + +Supported ids resolve through `UHyperTwistFirstRunLaunchLibrary` and execute +through the same `OpenFirstRunRoute()` path as visible first-run buttons. Do not +use a raw map argument as proof that Follow-Along, Magic120Cell, MagicCube5D, or +another product route opened; the project first-run owner can supersede it. + +The parser is intentionally strict. Empty, duplicate, unknown, over-64- +character, uppercase, whitespace-bearing, or punctuation-injected values fail +closed and leave the normal first-run menu available. + +### Stock-Shipping diagnostics + +Do not enable an incompatible `bUseLoggingInShipping` target merely to satisfy +a validation script. The Shipping package owns: + +- `%LOCALAPPDATA%\UnrealHyperTwist\Saved\Logs\HyperTwistRuntime-latest.log` +- `%LOCALAPPDATA%\UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log` + +Validation can redirect the runtime file with +`-HyperTwistDiagnosticsLog=`. An Unreal `-log`/`-abslog` file is +optional in stock Shipping; direct runtime initialization, semantic +map/presentation events, first-run result, process ownership, fatal scanning, +and listener/trace checks are the gate. + +### Semantic visual gate + +Release visuals for first-run, Classic, and higher-dimensional maps should +pass `-HyperTwistCaptureWhenReady` and require the exact expected named PNG. +The product owner requests that capture only after its semantic readiness +invariant is true. The visual report records the copied evidence hash and +original semantic-ready hash. + +A `CopyFromScreen()` image without a fresh semantic capture is corroboration, +not release authority. This distinction is mandatory when the Windows desktop +is locked, disconnected, occluded, or running under a noninteractive SSH +session. + +### Current commands and evidence + +Primary helpers: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\Invoke-HyperTwistDesktopPackage.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\Launch-HyperTwistDesktopPackage.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\Test-HyperTwistPackagedWindowVisual.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\Export-HyperTwistPackagedBuildZip.ps1 +``` + +Current durable evidence index: + +`docs/generated/alpha_test_20260723/README.md` + +Current operator delivery: + +- `C:\Users\Anthracite Ace\Downloads\HyperTwist-Alpha-Test-20260723` +- `C:\Users\Anthracite Ace\Downloads\HyperTwist-Alpha-Test-20260723.zip` + +The ZIP has `103` entries, size `1082134734` bytes, and SHA-256 +`1ad8e1c0fc967b227ca09fb68c515ed644eca3e4bda07fdf8c378b438000fc42`. + +### Remote-probe hygiene + +A locally timed-out or interrupted SSH wrapper can leave its encoded +Windows PowerShell child running. Before another expensive build or visual +launch: + +1. inspect process id, parent id, session id, working set, and full command line +2. identify ownership from the exact encoded command or package root +3. stop only the confirmed HyperTwist probe and its confirmed package children +4. never kill unrelated editor, compiler, VectorShell, or user processes + +Avoid broad `Get-CimInstance Win32_Process` serialization inside a long-running +probe after a package launch; collecting and JSON-encoding every command line +can itself consume substantial memory. Prefer a narrow process-name/path query +and write the evidence report before cleanup. 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 63c836f..c13b109 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md @@ -398,6 +398,44 @@ When a new packet materially adds or changes a normalized feature: ## Summary +### Latest continuity note (`2026-07-23`) + +- the current desktop Alpha authority is the Release 17 stock-Shipping package + delivered at + `C:\Users\Anthracite Ace\Downloads\HyperTwist-Alpha-Test-20260723`, not the + July 9 repair artifact +- first-party packaged startup now owns a strict registered-route argument, + `-HyperTwistStartupRoute=`: + - the route id must resolve through the same source-owned catalog used by the + visible first-run buttons + - empty, duplicate, unknown, overlong, case-drifted, and injected values are + rejected + - Follow-Along, Magic120Cell, and MagicCube5D package validation therefore + proves product-route ownership rather than relying on a raw map argument +- stock Shipping now writes direct first-party runtime diagnostics independently + of `UE_LOG`, while retaining separate bounded first-run route diagnostics +- the semantic package matrix is green: + - Classic and Follow-Along: exact maps, `26` renderable cubies, settled valid + state + - Magic120Cell: exact dedicated map, `120` canonical elements, `6` layer + materials + - MagicCube5D: exact dedicated map, `242` canonical elements, `6` layer + materials +- the exact final Downloads executable rendered a fresh semantic first-run + frame, and an injected visible-button interaction opened Classic with the + orbit camera, HUD, cube, and valid settled runtime visible +- archive verification reopened the `103`-entry ZIP and stream-hashed the + outer launcher, inner Shipping executable, `tbbmalloc.dll`, cooked + `pak`/`utoc`/`ucas`, and exact solver table; ZIP SHA-256 is + `1ad8e1c0fc967b227ca09fb68c515ed644eca3e4bda07fdf8c378b438000fc42` +- the final executable also accepts and opens `coach-dashboard` under direct + Shipping diagnostics; a fresh dashboard screenshot remains a narrow + operator-connected visual check because a disconnected desktop must not be + treated as image authority +- the July 9 folder and ZIP were retired only after replacement launch and + archive proof; durable evidence is indexed in + `docs/generated/alpha_test_20260723` + ### Latest continuity note (`2026-07-02`) - the first-party native desktop boot path now has a code-owned first-run @@ -412,7 +450,8 @@ When a new packet materially adds or changes a normalized feature: Dashboard/settings, classic cube free play, follow-along training, `Magic120Cell`, `MagicCube5D`, OpenXR validation, the packaged startup map, the fallback route, and the runtime/startup log file names - - packaged first-run repair on `2026-07-09`: the maintained Windows package + - historical packaged first-run repair on `2026-07-09`, superseded by the + verified `2026-07-23` Shipping Alpha: the maintained Windows package lane proved that `GameDefaultMap` alone was not sufficient because `L_HyperTwist_ClassicTraining` still carried its own authored game-mode override; `DefaultEngine.ini` now also sets `LocalMapOptions` to @@ -425,7 +464,8 @@ When a new packet materially adds or changes a normalized feature: - `scripts/Export-HyperTwistPackagedBuildZip.ps1` - validates that the produced archive is non-empty and still contains a packaged `UnrealHyperTwist.exe` entry before reporting success - - refreshed Windows proof on `2026-07-09`: the repaired desktop artifact at + - historical Windows proof on `2026-07-09`, retained as provenance but no + longer distributed: the repaired desktop artifact at `C:\HyperTwist_worktrees\phase10validate\packaged\desktop-first-run-authority-repair-20260709` logged `BeginPlay`, `ApplyFirstRunInputMode`, and `ShowFirstRunLaunchMenu` from `LogHyperTwistFirstRunLaunch`, and the fresh @@ -433,7 +473,9 @@ When a new packet materially adds or changes a normalized feature: `C:\Users\Anthracite Ace\Downloads\HyperTwist-desktop-first-run-authority-repair-20260709.zip` passed archive verification with `142` entries, executable entry `Windows\UnrealHyperTwist.exe`, size `719569118` bytes, and SHA-256 - `4ec2d5f8e00e28d727590ca584ce40843eed5b85fa7e03c578b7a404c3f45d67` + `4ec2d5f8e00e28d727590ca584ce40843eed5b85fa7e03c578b7a404c3f45d67`; + both that Downloads folder and ZIP were deleted on `2026-07-23` after the + Release 17 replacement was fully verified - 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 diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md index d2f54ab..50cfbc0 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md @@ -486,6 +486,24 @@ Current consolidated milestone snapshot: `C:\Users\Anthracite Ace\Downloads\HyperTwist-desktop-first-run-authority-repair-20260709.zip` with `142` entries and SHA-256 `4ec2d5f8e00e28d727590ca584ce40843eed5b85fa7e03c578b7a404c3f45d67` +- the `2026-07-23` Release 17 Shipping Alpha supersedes that July 9 artifact: + strict registered `-HyperTwistStartupRoute` ownership replaced ineffective + generic map launch arguments, stock-Shipping direct diagnostics replaced the + old assumption that an Unreal log must exist, first-run and Classic release + visuals now require fresh named semantic frames, and all four package smokes + now prove exact map and presentation state for Classic (`26` pieces), + Follow-Along (`26` pieces), Magic120Cell (`120` elements / `6` materials), + and MagicCube5D (`242` elements / `6` materials) +- the current Windows operator delivery is + `C:\Users\Anthracite Ace\Downloads\HyperTwist-Alpha-Test-20260723` plus its + verified `103`-entry ZIP, SHA-256 + `1ad8e1c0fc967b227ca09fb68c515ed644eca3e4bda07fdf8c378b438000fc42`; + the archive was reopened and seven critical payloads were stream-hashed, + then the superseded July 9 folder and ZIP were retired +- the narrow remaining Alpha visual check is a fresh coach-dashboard screenshot + during an unlocked Windows desktop session; dashboard route execution itself + is already green under direct Shipping diagnostics, and this visual check + does not reopen simulator topology - classic-cube `Phase 9A` replay recording is now closed through first-party runtime capture, `.json` replay persistence, local playback reconstruction, schema-light replay normalization across save/load/viewer import, and live @@ -540,8 +558,11 @@ Current consolidated milestone snapshot: `221` retained `Phase J` files carrying `443` deprecated validation identifiers and `443` disabled flags with `0` remaining live `HyperTwist.Validation.*` or `HyperTwist.FirstParty.Validation.*` names -- no mandatory roadmap gate remains inside the current `Phase 1` through - `Phase 10` packet set; the remaining optional widening branches are the +- no implementation roadmap gate remains inside the current `Phase 1` through + `Phase 10` packet set; the remaining Alpha/release-engineering checks are the + operator-connected dashboard visual plus installer/uninstaller, code-signing, + clean-machine prerequisites, entitlement, and update-channel rollout proof, + while the remaining optional product-widening branches are the alternative full-browser client path and any later dedicated native `MagicTile` interaction/runtime replacement work rather than new structural-only `Phase J` growth diff --git a/scripts/Export-HyperTwistPackagedBuildZip.ps1 b/scripts/Export-HyperTwistPackagedBuildZip.ps1 index 39ad4a4..641fad9 100644 --- a/scripts/Export-HyperTwistPackagedBuildZip.ps1 +++ b/scripts/Export-HyperTwistPackagedBuildZip.ps1 @@ -1,7 +1,9 @@ param( [string]$PackageRoot = 'C:\HyperTwist\packaged\desktop', [string]$DestinationZipPath = '', - [string]$ReportPath = '' + [string]$ReportPath = '', + [Int64]$ExpectedSolverTableSizeBytes = 676207080, + [string]$ExpectedSolverTableSha256 = 'dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034' ) $ErrorActionPreference = 'Stop' @@ -90,6 +92,24 @@ function Resolve-PackagedExecutablePath { return $CandidateExecutablePaths | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 } +function Resolve-PackagedGameExecutablePath { + param( + [Parameter(Mandatory = $true)] + [string]$PackagedProjectRoot + ) + + $GameExecutableDirectory = Join-Path $PackagedProjectRoot 'Binaries\Win64' + $CandidateExecutablePaths = @( + (Join-Path $GameExecutableDirectory 'UnrealHyperTwist.exe'), + (Join-Path $GameExecutableDirectory 'UnrealHyperTwist-Win64-Shipping.exe'), + (Join-Path $GameExecutableDirectory 'UnrealHyperTwist-Win64-Development.exe') + ) + + return $CandidateExecutablePaths | + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | + Select-Object -First 1 +} + function Invoke-ZipCreationWithRetry { param( [Parameter(Mandatory = $true)] @@ -148,6 +168,87 @@ function Test-ZipEntryMatchesExecutablePath { return $NormalizedEntryPath.EndsWith('UnrealHyperTwist.exe', [System.StringComparison]::OrdinalIgnoreCase) } +function Get-NormalizedRelativePath { + param( + [Parameter(Mandatory = $true)] + [string]$RootPath, + [Parameter(Mandatory = $true)] + [string]$FilePath + ) + + $RootWithSeparator = $RootPath.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar + $FullFilePath = [System.IO.Path]::GetFullPath($FilePath) + if (-not $FullFilePath.StartsWith($RootWithSeparator, [System.StringComparison]::OrdinalIgnoreCase)) + { + throw "Required package file '$FullFilePath' is not beneath package root '$RootPath'." + } + + return $FullFilePath.Substring($RootWithSeparator.Length).Replace('\', '/') +} + +function Get-RequiredPackageFileEvidence { + param( + [Parameter(Mandatory = $true)] + [string]$RootPath, + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Kind, + [Int64]$ExpectedSizeBytes = -1, + [string]$ExpectedSha256 = '' + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) + { + throw "Required packaged $Kind file was not found at '$Path'." + } + + $Item = Get-Item -LiteralPath $Path + if ($Item.Length -le 0) + { + throw "Required packaged $Kind file '$Path' was empty." + } + + if ($ExpectedSizeBytes -ge 0 -and $Item.Length -ne $ExpectedSizeBytes) + { + throw "Required packaged $Kind file '$Path' was $($Item.Length) bytes; expected exactly $ExpectedSizeBytes bytes." + } + + $ActualSha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256) ` + -and $ActualSha256 -ne $ExpectedSha256.Trim().ToLowerInvariant()) + { + throw "Required packaged $Kind file '$Path' did not match the expected SHA-256." + } + + return [pscustomobject][ordered]@{ + kind = $Kind + relativePath = Get-NormalizedRelativePath -RootPath $RootPath -FilePath $Item.FullName + sizeBytes = [Int64]$Item.Length + sha256 = $ActualSha256 + } +} + +function Get-ZipEntrySha256 { + param( + [Parameter(Mandatory = $true)] + [System.IO.Compression.ZipArchiveEntry]$Entry + ) + + $Sha256 = [System.Security.Cryptography.SHA256]::Create() + $EntryStream = $Entry.Open() + try + { + $Digest = $Sha256.ComputeHash($EntryStream) + return ([System.BitConverter]::ToString($Digest)).Replace('-', '').ToLowerInvariant() + } + finally + { + $EntryStream.Dispose() + $Sha256.Dispose() + } +} + if (-not (Test-Path -LiteralPath $PackageRoot)) { throw "Package root '$PackageRoot' was not found." @@ -160,6 +261,47 @@ if ($null -eq $PackagedExecutablePath) throw "No packaged UnrealHyperTwist executable was found beneath '$ResolvedPackageRoot'." } +$PackagedExecutableDirectory = Split-Path -Parent $PackagedExecutablePath +$PackagedProjectRoot = Join-Path $PackagedExecutableDirectory 'UnrealHyperTwist' +$PackagedGameExecutablePath = Resolve-PackagedGameExecutablePath ` + -PackagedProjectRoot $PackagedProjectRoot +if ($null -eq $PackagedGameExecutablePath) +{ + throw "No packaged inner UnrealHyperTwist game executable was found beneath '$PackagedProjectRoot'." +} +$RequiredPackageFiles = @( + Get-RequiredPackageFileEvidence ` + -RootPath $ResolvedPackageRoot ` + -Path $PackagedExecutablePath ` + -Kind 'launcher-executable' + Get-RequiredPackageFileEvidence ` + -RootPath $ResolvedPackageRoot ` + -Path $PackagedGameExecutablePath ` + -Kind 'game-executable' + Get-RequiredPackageFileEvidence ` + -RootPath $ResolvedPackageRoot ` + -Path (Join-Path $PackagedProjectRoot 'Binaries\Win64\tbbmalloc.dll') ` + -Kind 'runtime-dependency-tbbmalloc' + Get-RequiredPackageFileEvidence ` + -RootPath $ResolvedPackageRoot ` + -Path (Join-Path $PackagedProjectRoot 'Content\Paks\UnrealHyperTwist-Windows.pak') ` + -Kind 'cooked-pak' + Get-RequiredPackageFileEvidence ` + -RootPath $ResolvedPackageRoot ` + -Path (Join-Path $PackagedProjectRoot 'Content\Paks\UnrealHyperTwist-Windows.utoc') ` + -Kind 'cooked-utoc' + Get-RequiredPackageFileEvidence ` + -RootPath $ResolvedPackageRoot ` + -Path (Join-Path $PackagedProjectRoot 'Content\Paks\UnrealHyperTwist-Windows.ucas') ` + -Kind 'cooked-ucas' + Get-RequiredPackageFileEvidence ` + -RootPath $ResolvedPackageRoot ` + -Path (Join-Path $PackagedProjectRoot 'Saved\twophase-ht.tbl') ` + -Kind 'solver-table' ` + -ExpectedSizeBytes $ExpectedSolverTableSizeBytes ` + -ExpectedSha256 $ExpectedSolverTableSha256 +) + if ([string]::IsNullOrWhiteSpace($DestinationZipPath)) { $PackageLeafName = Split-Path -Leaf $ResolvedPackageRoot @@ -188,7 +330,7 @@ if (Test-Path -LiteralPath $ResolvedDestinationZipPath) } $ZipReport = [ordered]@{ - reportVersion = 'ht-packaged-build-zip-export/v1' + reportVersion = 'ht-packaged-build-zip-export/v2' generatedAtUtc = [DateTime]::UtcNow.ToString('o') packageRoot = $ResolvedPackageRoot packagedExecutablePath = $PackagedExecutablePath @@ -196,6 +338,8 @@ $ZipReport = [ordered]@{ result = 'failed' entryCount = 0 packagedExecutableEntry = $null + requiredEntries = @($RequiredPackageFiles) + archivedRequiredEntries = @() zipSizeBytes = 0 zipSha256 = $null error = $null @@ -226,6 +370,47 @@ try { throw "The exported zip '$ResolvedDestinationZipPath' contained zero entries." } + + foreach ($RequiredFile in $RequiredPackageFiles) + { + $MatchingRequiredEntries = @($Entries | Where-Object { + $_.FullName.Replace('\', '/').Equals( + $RequiredFile.relativePath, + [System.StringComparison]::OrdinalIgnoreCase) + }) + if ($MatchingRequiredEntries.Count -ne 1) + { + throw ( + "The exported zip '$ResolvedDestinationZipPath' contained " + + "$($MatchingRequiredEntries.Count) copies of required $($RequiredFile.kind) " + + "entry '$($RequiredFile.relativePath)'; expected exactly one." + ) + } + + $RequiredEntry = $MatchingRequiredEntries[0] + if ([Int64]$RequiredEntry.Length -ne [Int64]$RequiredFile.sizeBytes) + { + throw "Required zip entry '$($RequiredFile.relativePath)' had an unexpected uncompressed size." + } + + $ArchivedSha256 = Get-ZipEntrySha256 -Entry $RequiredEntry + if ($ArchivedSha256 -ne $RequiredFile.sha256) + { + throw ( + "Required zip entry '$($RequiredFile.relativePath)' did not preserve " + + "the source SHA-256 '$($RequiredFile.sha256)'." + ) + } + + $ZipReport.archivedRequiredEntries += @( + [pscustomobject][ordered]@{ + kind = $RequiredFile.kind + relativePath = $RequiredFile.relativePath + sizeBytes = [Int64]$RequiredEntry.Length + sha256 = $ArchivedSha256 + } + ) + } } finally { diff --git a/scripts/Invoke-HyperTwistClassicCubeMapAuthoring.ps1 b/scripts/Invoke-HyperTwistClassicCubeMapAuthoring.ps1 index a150858..90f32fb 100644 --- a/scripts/Invoke-HyperTwistClassicCubeMapAuthoring.ps1 +++ b/scripts/Invoke-HyperTwistClassicCubeMapAuthoring.ps1 @@ -1,7 +1,9 @@ param( [string]$ProjectRoot = 'C:\HyperTwist', [string]$UnrealEditorCmdPath = 'C:\Program Files\Epic Games\UE_5.7\Engine\Binaries\Win64\UnrealEditor-Cmd.exe', - [string]$PythonScriptPath + [string]$PythonScriptPath, + [ValidateSet('d3d11', 'nullrhi')] + [string]$RenderBackend = 'd3d11' ) $ErrorActionPreference = 'Stop' @@ -12,6 +14,7 @@ if ([string]::IsNullOrWhiteSpace($PythonScriptPath)) } $UProjectPath = Join-Path $ProjectRoot 'UnrealHyperTwist\UnrealHyperTwist.uproject' +$AuthoringStateRoot = Join-Path $ProjectRoot 'UnrealHyperTwist\Saved\HyperTwistMapAuthoring' $ClassicMapPath = Join-Path $ProjectRoot 'UnrealHyperTwist\Content\HyperTwistTraining\Maps\L_HyperTwist_ClassicTraining.umap' $FollowAlongMapPath = Join-Path $ProjectRoot 'UnrealHyperTwist\Content\HyperTwistTraining\Maps\L_HyperTwist_FollowAlongTraining.umap' $ExpectedMaterialPaths = @( @@ -40,6 +43,8 @@ if (-not (Test-Path $PythonScriptPath)) throw "Classic-cube map authoring script was not found at '$PythonScriptPath'." } +[System.IO.Directory]::CreateDirectory($AuthoringStateRoot) | Out-Null + $NormalizedPythonScriptPath = $PythonScriptPath -replace '\\', '/' $AuthoredTargets = @( @{ @@ -54,49 +59,102 @@ $AuthoredTargets = @( foreach ($Target in $AuthoredTargets) { - $env:HYPERTWIST_CLASSIC_CUBE_MAP_KIND = $Target.Label - $AuthoringStartedAtUtc = [DateTime]::UtcNow - Write-Host "Authoring HyperTwist classic cube training map target '$($Target.Label)' through UnrealEditor-Cmd..." - & $UnrealEditorCmdPath ` - $UProjectPath ` - "-ExecutePythonScript=$NormalizedPythonScriptPath" ` - -unattended ` - -nop4 ` - -nullrhi ` - -nosound ` - -nosplash ` - -stdout ` - -FullStdOutLogOutput ` - -log + $MarkerPath = Join-Path $AuthoringStateRoot "$($Target.Label)-complete.json" + $BackupPath = Join-Path $AuthoringStateRoot "$($Target.Label)-pre-authoring.umap" + $HadOriginalMap = Test-Path $Target.ExpectedMapPath + $AuthoringSucceeded = $false - if ($LASTEXITCODE -ne 0) + Remove-Item $MarkerPath, $BackupPath -Force -ErrorAction SilentlyContinue + if ($HadOriginalMap) { - $FreshMapExists = $false - if (Test-Path $Target.ExpectedMapPath) - { - $FreshMapExists = (Get-Item $Target.ExpectedMapPath).LastWriteTimeUtc -ge $AuthoringStartedAtUtc.AddSeconds(-2) - } + Copy-Item $Target.ExpectedMapPath $BackupPath -Force + } - if ($FreshMapExists) + try + { + $env:HYPERTWIST_CLASSIC_CUBE_MAP_KIND = $Target.Label + $env:HYPERTWIST_CLASSIC_CUBE_AUTHORING_MARKER = $MarkerPath + $AuthoringStartedAtUtc = [DateTime]::UtcNow + $BackendArguments = if ($RenderBackend -eq 'nullrhi') { - Write-Warning "UnrealEditor-Cmd exited with code $LASTEXITCODE after freshly writing '$($Target.ExpectedMapPath)'. Continuing because this host currently crashes during post-save shutdown on the headless authoring lane." + @('-nullrhi') } else { - Remove-Item Env:\HYPERTWIST_CLASSIC_CUBE_MAP_KIND -ErrorAction SilentlyContinue - throw "Classic-cube map authoring failed with exit code $LASTEXITCODE." + @('-d3d11', '-RenderOffscreen') } - } + Write-Host "Authoring HyperTwist classic cube training map target '$($Target.Label)' through UnrealEditor-Cmd ($RenderBackend)..." + & $UnrealEditorCmdPath ` + $UProjectPath ` + "-ExecutePythonScript=$NormalizedPythonScriptPath" ` + -unattended ` + -nop4 ` + @BackendArguments ` + -nosound ` + -nosplash ` + -stdout ` + -FullStdOutLogOutput ` + -log - if (-not (Test-Path $Target.ExpectedMapPath)) + $EditorExitCode = $LASTEXITCODE + $FreshMapExists = (Test-Path $Target.ExpectedMapPath) -and ( + (Get-Item $Target.ExpectedMapPath).LastWriteTimeUtc -ge $AuthoringStartedAtUtc.AddSeconds(-2) + ) + $CompletionMarkerIsValid = $false + if (Test-Path $MarkerPath) + { + try + { + $CompletionMarker = Get-Content $MarkerPath -Raw | ConvertFrom-Json + $CompletionMarkerIsValid = ` + $CompletionMarker.schemaVersion -eq 1 -and ` + $CompletionMarker.stage -eq 'complete' -and ` + $CompletionMarker.target -eq $Target.Label -and ` + $CompletionMarker.mapAssetPath -eq ( + "/Game/HyperTwistTraining/Maps/" + [System.IO.Path]::GetFileNameWithoutExtension($Target.ExpectedMapPath) + ) -and ` + @($CompletionMarker.actorLabels).Count -ge 7 + } + catch + { + Write-Warning "Ignoring malformed authoring completion marker '$MarkerPath': $($_.Exception.Message)" + } + } + + if (-not $FreshMapExists -or -not $CompletionMarkerIsValid) + { + throw "Classic-cube map authoring did not produce a fresh, fully validated '$($Target.Label)' map (editor exit code $EditorExitCode)." + } + + if ($EditorExitCode -ne 0) + { + Write-Warning "UnrealEditor-Cmd exited with code $EditorExitCode after the complete map marker was durably written. The validated authored map is retained." + } + + $AuthoringSucceeded = $true + } + finally { Remove-Item Env:\HYPERTWIST_CLASSIC_CUBE_MAP_KIND -ErrorAction SilentlyContinue - throw "Expected authored map was not found at '$($Target.ExpectedMapPath)'." + Remove-Item Env:\HYPERTWIST_CLASSIC_CUBE_AUTHORING_MARKER -ErrorAction SilentlyContinue + + if (-not $AuthoringSucceeded) + { + if ($HadOriginalMap -and (Test-Path $BackupPath)) + { + Copy-Item $BackupPath $Target.ExpectedMapPath -Force + Write-Warning "Restored the pre-authoring map after an incomplete '$($Target.Label)' authoring run." + } + elseif (Test-Path $Target.ExpectedMapPath) + { + Remove-Item $Target.ExpectedMapPath -Force + } + } + + Remove-Item $BackupPath -Force -ErrorAction SilentlyContinue } } -Remove-Item Env:\HYPERTWIST_CLASSIC_CUBE_MAP_KIND -ErrorAction SilentlyContinue - foreach ($ExpectedMapPath in @($ClassicMapPath, $FollowAlongMapPath)) { if (-not (Test-Path $ExpectedMapPath)) diff --git a/scripts/Invoke-HyperTwistClassicCubePackage.ps1 b/scripts/Invoke-HyperTwistClassicCubePackage.ps1 index 80d00a7..b383b07 100644 --- a/scripts/Invoke-HyperTwistClassicCubePackage.ps1 +++ b/scripts/Invoke-HyperTwistClassicCubePackage.ps1 @@ -12,6 +12,13 @@ param( ), [bool]$HeadlessSmoke = $true, [string]$ValidationReportPath = '', + [string]$SolverTablePath = '', + [Int64]$ExpectedSolverTableSizeBytes = 676207080, + [string]$ExpectedSolverTableSha256 = 'dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034', + [string[]]$BootstrapLaunchArguments = @( + '-notraceserver', + '-traceautostart=0' + ), [switch]$CleanArchive, [switch]$SkipBuild, [switch]$SkipLaunch @@ -22,6 +29,7 @@ $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-HyperTwistClassicCubePackage.ps1' +$BootstrapLaunchArgumentsScriptPath = Join-Path $ProjectRoot 'scripts\Set-HyperTwistBootstrapLaunchArguments.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 @@ -48,6 +56,12 @@ $ExpectedClassicCubeMaterialPaths = @( ) $ValidationRootPath = Join-Path $ArchiveDirectory 'validation' $SmokeReportDirectory = Join-Path $ValidationRootPath 'smoke' +$BootstrapLaunchArgumentsReportPath = Join-Path $ValidationRootPath 'bootstrap-launch-arguments-report.json' + +if ([string]::IsNullOrWhiteSpace($SolverTablePath)) +{ + $SolverTablePath = Join-Path $ProjectRoot 'UnrealHyperTwist\Saved\twophase-ht.tbl' +} function Write-Utf8JsonFile { param( @@ -90,6 +104,169 @@ function Read-JsonFile { return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json } +function Get-BootstrapRuntimeArgumentEvidence { + param( + [string]$RuntimeLogPath, + [Parameter(Mandatory = $true)] + [string]$RuntimeDiagnosticsPath, + [string[]]$ObservedCommandLines = @(), + [object[]]$TraceControlListeningEndpoints = @(), + [Parameter(Mandatory = $true)] + [string[]]$ExpectedArguments + ) + + $RuntimeLogExists = -not [string]::IsNullOrWhiteSpace($RuntimeLogPath) ` + -and (Test-Path -LiteralPath $RuntimeLogPath -PathType Leaf) + $RuntimeDiagnosticsExists = -not [string]::IsNullOrWhiteSpace($RuntimeDiagnosticsPath) ` + -and (Test-Path -LiteralPath $RuntimeDiagnosticsPath -PathType Leaf) + if (-not $RuntimeDiagnosticsExists) + { + return [pscustomobject][ordered]@{ + runtimeLogPath = $RuntimeLogPath + runtimeLogExists = $false + runtimeDiagnosticsPath = $RuntimeDiagnosticsPath + runtimeDiagnosticsExists = $false + expectedArguments = @($ExpectedArguments) + matchedArguments = @() + missingArguments = @($ExpectedArguments) + traceControlListenerLines = @() + traceControlListeningEndpoints = @($TraceControlListeningEndpoints) + result = 'failed' + error = 'The direct HyperTwist diagnostics file was not found.' + } + } + + $RuntimeLogLines = [string[]]@( + if ($RuntimeLogExists) + { + Get-Content -LiteralPath $RuntimeLogPath | + ForEach-Object { $_.ToString() } + } + ) + $RuntimeDiagnosticsLines = [string[]]@( + if ($RuntimeDiagnosticsExists) + { + Get-Content -LiteralPath $RuntimeDiagnosticsPath | + ForEach-Object { $_.ToString() } + } + ) + $RuntimeEvidenceLines = [string[]]@( + @($RuntimeLogLines) + + @($RuntimeDiagnosticsLines) + + @($ObservedCommandLines) + ) + $RuntimeEvidenceText = [string]::Join([Environment]::NewLine, $RuntimeEvidenceLines) + $MatchedArguments = @( + $ExpectedArguments | + Where-Object { + $RuntimeEvidenceText.IndexOf( + $_, + [System.StringComparison]::OrdinalIgnoreCase + ) -ge 0 + } + ) + $MissingArguments = @( + $ExpectedArguments | + Where-Object { $MatchedArguments -notcontains $_ } + ) + $TraceControlListenerLines = [string[]]@( + @($RuntimeLogLines + $RuntimeDiagnosticsLines) | + Where-Object { + $_.IndexOf( + 'Control listening on port', + [System.StringComparison]::OrdinalIgnoreCase + ) -ge 0 + } + ) + $Passed = $MissingArguments.Count -eq 0 ` + -and $TraceControlListenerLines.Count -eq 0 ` + -and $TraceControlListeningEndpoints.Count -eq 0 + + return [pscustomobject][ordered]@{ + runtimeLogPath = $RuntimeLogPath + runtimeLogExists = $RuntimeLogExists + runtimeDiagnosticsPath = $RuntimeDiagnosticsPath + runtimeDiagnosticsExists = $RuntimeDiagnosticsExists + observedCommandLines = @($ObservedCommandLines) + expectedArguments = @($ExpectedArguments) + matchedArguments = @($MatchedArguments) + missingArguments = @($MissingArguments) + traceControlListenerLines = @($TraceControlListenerLines) + traceControlListeningEndpoints = @($TraceControlListeningEndpoints) + result = if ($Passed) { 'passed' } else { 'failed' } + error = if ($Passed) + { + $null + } + elseif ($MissingArguments.Count -gt 0) + { + "Runtime evidence omitted bootstrap arguments: $($MissingArguments -join ', ')" + } + else + { + 'Runtime opened an Unreal trace-control listener.' + } + } +} + +function Get-ValidatedSolverTableEvidence { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [Int64]$ExpectedSizeBytes, + [Parameter(Mandatory = $true)] + [string]$ExpectedSha256 + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) + { + throw "Required two-phase solver table was not found at '$Path'." + } + + $Item = Get-Item -LiteralPath $Path + if ($Item.Length -ne $ExpectedSizeBytes) + { + throw "Two-phase solver table '$Path' was $($Item.Length) bytes; expected exactly $ExpectedSizeBytes bytes." + } + + $ActualSha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + $NormalizedExpectedSha256 = $ExpectedSha256.Trim().ToLowerInvariant() + if ($ActualSha256 -ne $NormalizedExpectedSha256) + { + throw "Two-phase solver table '$Path' had SHA-256 '$ActualSha256'; expected '$NormalizedExpectedSha256'." + } + + return [pscustomobject][ordered]@{ + path = $Item.FullName + sizeBytes = [Int64]$Item.Length + sha256 = $ActualSha256 + } +} + +function Copy-ValidatedSolverTableToPackage { + param( + [Parameter(Mandatory = $true)] + [string]$SourcePath, + [Parameter(Mandatory = $true)] + [string]$PackagedExecutablePath, + [Parameter(Mandatory = $true)] + [Int64]$ExpectedSizeBytes, + [Parameter(Mandatory = $true)] + [string]$ExpectedSha256 + ) + + $ExecutableDirectory = Split-Path -Parent $PackagedExecutablePath + $DestinationPath = Join-Path $ExecutableDirectory 'UnrealHyperTwist\Saved\twophase-ht.tbl' + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $DestinationPath) | Out-Null + Copy-Item -LiteralPath $SourcePath -Destination $DestinationPath -Force + + return Get-ValidatedSolverTableEvidence ` + -Path $DestinationPath ` + -ExpectedSizeBytes $ExpectedSizeBytes ` + -ExpectedSha256 $ExpectedSha256 +} + function Resolve-PackagedExecutablePath { param( [string]$PackageRoot @@ -241,6 +418,11 @@ if (-not (Test-Path $UProjectPath)) throw "UnrealHyperTwist project file was not found at '$UProjectPath'." } +if (-not (Test-Path -LiteralPath $BootstrapLaunchArgumentsScriptPath -PathType Leaf)) +{ + throw "Bootstrap launch-argument helper was not found at '$BootstrapLaunchArgumentsScriptPath'." +} + foreach ($TargetCookMap in $CookMaps) { Assert-CookMapExists -RootPath $ProjectRoot -GameMapPath $TargetCookMap @@ -284,6 +466,7 @@ if ([string]::IsNullOrWhiteSpace($ValidationReportPath)) $RunUatArguments = @( 'BuildCookRun', + '-WaitForUATMutex', "-project=$UProjectPath", '-noP4', '-platform=Win64', @@ -311,7 +494,7 @@ if ($ResolvedAdditionalCookerOptions.Count -gt 0) } $ValidationReport = [ordered]@{ - reportVersion = 'ht-classic-cube-package-validation/v1' + reportVersion = 'ht-classic-cube-package-validation/v2' generatedAtUtc = [DateTime]::UtcNow.ToString('o') projectRoot = $ProjectRoot archiveDirectory = $ArchiveDirectory @@ -325,12 +508,40 @@ $ValidationReport = [ordered]@{ skipLaunch = [bool]$SkipLaunch result = 'failed' packagedExecutablePath = $null + bootstrapLaunchArguments = [ordered]@{ + result = 'pending' + traceControlProtection = if ($Configuration -eq 'Shipping') + { + 'shipping-compile-time-disabled' + } + else + { + 'runtime-listener-rejection-only' + } + expectedArguments = @($BootstrapLaunchArguments) + patchReportPath = $BootstrapLaunchArgumentsReportPath + patchReport = $null + runtimeProofs = @() + } + solverTable = [ordered]@{ + result = 'pending' + sourcePath = $SolverTablePath + expectedSizeBytes = $ExpectedSolverTableSizeBytes + expectedSha256 = $ExpectedSolverTableSha256.ToLowerInvariant() + sourceEvidence = $null + packagedEvidence = $null + } smokeReports = @() error = $null } try { + $ValidationReport.solverTable.sourceEvidence = Get-ValidatedSolverTableEvidence ` + -Path $SolverTablePath ` + -ExpectedSizeBytes $ExpectedSolverTableSizeBytes ` + -ExpectedSha256 $ExpectedSolverTableSha256 + Write-Host "Packaging HyperTwist classic-cube validation lane to '$ArchiveDirectory'..." & $RunUatPath @RunUatArguments @@ -346,6 +557,23 @@ try } $ValidationReport.packagedExecutablePath = $PackagedExecutablePath + & $BootstrapLaunchArgumentsScriptPath ` + -ExecutablePath $PackagedExecutablePath ` + -AdditionalArguments $BootstrapLaunchArguments ` + -ReportPath $BootstrapLaunchArgumentsReportPath | Out-Null + $BootstrapPatchReport = Read-JsonFile -Path $BootstrapLaunchArgumentsReportPath + if ($null -eq $BootstrapPatchReport -or $BootstrapPatchReport.result -ne 'passed') + { + throw "Packaged bootstrap launch-argument patch did not record a passed result." + } + $ValidationReport.bootstrapLaunchArguments.patchReport = $BootstrapPatchReport + + $ValidationReport.solverTable.packagedEvidence = Copy-ValidatedSolverTableToPackage ` + -SourcePath $SolverTablePath ` + -PackagedExecutablePath $PackagedExecutablePath ` + -ExpectedSizeBytes $ExpectedSolverTableSizeBytes ` + -ExpectedSha256 $ExpectedSolverTableSha256 + $ValidationReport.solverTable.result = 'passed' if (-not $SkipLaunch) { @@ -392,8 +620,28 @@ try throw "Packaged classic-cube smoke report '$SmokeReportPath' targeted '$($SmokeReport.mapUrl)' instead of '$SmokeMap'." } + $RuntimeArgumentEvidence = Get-BootstrapRuntimeArgumentEvidence ` + -RuntimeLogPath $SmokeReport.runtimeLogPath ` + -RuntimeDiagnosticsPath $SmokeReport.runtimeDiagnosticsPath ` + -ObservedCommandLines @($SmokeReport.processCommandLines) ` + -TraceControlListeningEndpoints @($SmokeReport.traceControlListeningEndpoints) ` + -ExpectedArguments $BootstrapLaunchArguments + $ValidationReport.bootstrapLaunchArguments.runtimeProofs += @( + $RuntimeArgumentEvidence + ) + if ($RuntimeArgumentEvidence.result -ne 'passed') + { + throw $RuntimeArgumentEvidence.error + } + $ValidationReport.smokeReports += @($SmokeReport) } + + $ValidationReport.bootstrapLaunchArguments.result = 'passed' + } + else + { + $ValidationReport.bootstrapLaunchArguments.result = 'patch-passed-runtime-not-run' } $ValidationReport.result = 'passed' diff --git a/scripts/Invoke-HyperTwistDesktopPackage.ps1 b/scripts/Invoke-HyperTwistDesktopPackage.ps1 index 8005b56..526a6dd 100644 --- a/scripts/Invoke-HyperTwistDesktopPackage.ps1 +++ b/scripts/Invoke-HyperTwistDesktopPackage.ps1 @@ -22,6 +22,9 @@ param( [bool]$HeadlessSmoke = $true, [string]$ValidationReportPath = '', [string]$LaunchSurfaceReportPath = '', + [string]$SolverTablePath = '', + [Int64]$ExpectedSolverTableSizeBytes = 676207080, + [string]$ExpectedSolverTableSha256 = 'dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034', [string]$ZipOutputPath = '', [string]$ZipExportReportPath = '', [switch]$CleanArchive, @@ -75,7 +78,11 @@ $InvokeParameters = @{ AdditionalCookMaps = $AdditionalCookMaps SmokeMaps = $SmokeMaps AdditionalCookerOptions = $AdditionalCookerOptions + HeadlessSmoke = $HeadlessSmoke ValidationReportPath = $ValidationReportPath + SolverTablePath = $SolverTablePath + ExpectedSolverTableSizeBytes = $ExpectedSolverTableSizeBytes + ExpectedSolverTableSha256 = $ExpectedSolverTableSha256 } if ($CleanArchive) @@ -97,12 +104,19 @@ if ($SkipLaunch) if (-not $SkipLaunch) { - & $DesktopLaunchScriptPath ` - -PackageRoot $ArchiveDirectory ` - -ReportPath $LaunchSurfaceReportPath ` - -UseNullRHI:$HeadlessSmoke ` - -NoSound:$HeadlessSmoke ` - -RequireStartupDiagnostics + $DesktopLaunchParameters = @{ + PackageRoot = $ArchiveDirectory + ReportPath = $LaunchSurfaceReportPath + UseNullRHI = $HeadlessSmoke + NoSound = $HeadlessSmoke + RequireStartupDiagnostics = $true + } + if (-not $HeadlessSmoke) + { + $DesktopLaunchParameters.ExpectedStartupDiagnosticsResult = 'launch-menu-renderable' + } + + & $DesktopLaunchScriptPath @DesktopLaunchParameters } if ($CreateZip) @@ -110,5 +124,7 @@ if ($CreateZip) & $ZipExportScriptPath ` -PackageRoot $ArchiveDirectory ` -DestinationZipPath $ZipOutputPath ` - -ReportPath $ZipExportReportPath + -ReportPath $ZipExportReportPath ` + -ExpectedSolverTableSizeBytes $ExpectedSolverTableSizeBytes ` + -ExpectedSolverTableSha256 $ExpectedSolverTableSha256 } diff --git a/scripts/Invoke-HyperTwistHigherDimensionalMapAuthoring.ps1 b/scripts/Invoke-HyperTwistHigherDimensionalMapAuthoring.ps1 index d0a203d..45e2b67 100644 --- a/scripts/Invoke-HyperTwistHigherDimensionalMapAuthoring.ps1 +++ b/scripts/Invoke-HyperTwistHigherDimensionalMapAuthoring.ps1 @@ -16,6 +16,7 @@ $Magic120CellMapPath = Join-Path $ProjectRoot 'UnrealHyperTwist\Content\HyperTwi $MagicCube5DMapPath = Join-Path $ProjectRoot 'UnrealHyperTwist\Content\HyperTwistTraining\Maps\L_HyperTwist_MagicCube5DTraining.umap' $ClassicMapPath = Join-Path $ProjectRoot 'UnrealHyperTwist\Content\HyperTwistTraining\Maps\L_HyperTwist_ClassicTraining.umap' $ManifestPath = Join-Path $ProjectRoot 'docs\generated\higher_dimensional_training_maps\phase6c_dedicated_family_map_manifest.json' +$ReceiptDirectory = Join-Path $ProjectRoot 'UnrealHyperTwist\Saved\HyperTwistMapAuthoring' if (-not (Test-Path $UnrealEditorCmdPath)) { @@ -37,10 +38,12 @@ $AuthoredTargets = @( @{ Label = 'magic120cell' ExpectedMapPath = $Magic120CellMapPath + ReceiptPath = Join-Path $ReceiptDirectory 'phase6c_magic120cell_complete.json' }, @{ Label = 'magiccube5d' ExpectedMapPath = $MagicCube5DMapPath + ReceiptPath = Join-Path $ReceiptDirectory 'phase6c_magiccube5d_complete.json' } ) @@ -48,6 +51,7 @@ foreach ($Target in $AuthoredTargets) { $env:HYPERTWIST_HIGHER_DIMENSIONAL_MAP_KIND = $Target.Label $AuthoringStartedAtUtc = [DateTime]::UtcNow + Remove-Item -LiteralPath $Target.ReceiptPath -Force -ErrorAction SilentlyContinue Write-Host "Authoring HyperTwist higher-dimensional training map target '$($Target.Label)' through UnrealEditor-Cmd..." & $UnrealEditorCmdPath ` $UProjectPath ` @@ -61,23 +65,44 @@ foreach ($Target in $AuthoredTargets) -FullStdOutLogOutput ` -log - if ($LASTEXITCODE -ne 0) + $AuthoringExitCode = $LASTEXITCODE + $Receipt = $null + if (Test-Path -LiteralPath $Target.ReceiptPath -PathType Leaf) { - $FreshMapExists = $false - if (Test-Path $Target.ExpectedMapPath) - { - $FreshMapExists = (Get-Item $Target.ExpectedMapPath).LastWriteTimeUtc -ge $AuthoringStartedAtUtc.AddSeconds(-2) - } + $Receipt = Get-Content -LiteralPath $Target.ReceiptPath -Raw | ConvertFrom-Json + } - if ($FreshMapExists) + $ExpectedMapHash = if (Test-Path -LiteralPath $Target.ExpectedMapPath -PathType Leaf) + { + (Get-FileHash -LiteralPath $Target.ExpectedMapPath -Algorithm MD5).Hash.ToLowerInvariant() + } + else + { + '' + } + $ReceiptIsFresh = $Receipt -and ` + (Get-Item -LiteralPath $Target.ReceiptPath).LastWriteTimeUtc -ge $AuthoringStartedAtUtc.AddSeconds(-2) + $ReceiptIsValid = $ReceiptIsFresh -and ` + $Receipt.authoringComplete -eq $true -and ` + $Receipt.mapKind -eq $Target.Label -and ` + $Receipt.gameModeClassPath -eq '/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingGameMode' -and ` + $Receipt.presentationEnvironmentOwnership -eq 'runtime-game-mode-and-shell-components' -and ` + $Receipt.mapHashMd5 -eq $ExpectedMapHash -and ` + @($Receipt.validatedActorLabels).Count -eq 1 + + if (-not $ReceiptIsValid) + { + Remove-Item Env:\HYPERTWIST_HIGHER_DIMENSIONAL_MAP_KIND -ErrorAction SilentlyContinue + if ($AuthoringExitCode -ne 0) { - Write-Warning "UnrealEditor-Cmd exited with code $LASTEXITCODE after freshly writing '$($Target.ExpectedMapPath)'. Continuing because this host may still crash during post-save shutdown on the headless authoring lane." - } - else - { - Remove-Item Env:\HYPERTWIST_HIGHER_DIMENSIONAL_MAP_KIND -ErrorAction SilentlyContinue - throw "Higher-dimensional map authoring failed with exit code $LASTEXITCODE." + throw "Higher-dimensional map authoring failed with exit code $AuthoringExitCode before a valid completion receipt was written for '$($Target.Label)'." } + throw "Higher-dimensional map authoring exited without a valid hash-bound completion receipt for '$($Target.Label)'." + } + + if ($AuthoringExitCode -ne 0) + { + Write-Warning "UnrealEditor-Cmd exited with code $AuthoringExitCode only after '$($Target.Label)' completed, validated, and wrote its hash-bound receipt. Accepting the known post-completion headless shutdown crash." } if (-not (Test-Path $Target.ExpectedMapPath)) @@ -126,7 +151,10 @@ $ManifestEntries = @( primaryPersistenceBoundaryId = 'magic120cell-persistence-boundary' authorTag = 'HyperTwistHigherDimensionalTrainingShell' authoringManifestRelativePath = 'docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_map_manifest.json' - authoredViaGameModeClassPath = '/Script/UnrealHyperTwist.HyperTwistCoachDashboardGameMode' + authoredViaGameModeClassPath = '/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingGameMode' + runtimePresentationClassPath = '/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingShellActor' + presentationEnvironmentOwnership = 'runtime-game-mode-and-shell-components' + canonicalRenderableElementCount = 120 trainingShellTags = @( 'phase6c', 'family:magic120cell', @@ -155,7 +183,10 @@ $ManifestEntries = @( primaryPersistenceBoundaryId = 'magiccube5d-persistence-boundary' authorTag = 'HyperTwistHigherDimensionalTrainingShell' authoringManifestRelativePath = 'docs/generated/higher_dimensional_training_maps/phase6c_dedicated_family_map_manifest.json' - authoredViaGameModeClassPath = '/Script/UnrealHyperTwist.HyperTwistCoachDashboardGameMode' + authoredViaGameModeClassPath = '/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingGameMode' + runtimePresentationClassPath = '/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingShellActor' + presentationEnvironmentOwnership = 'runtime-game-mode-and-shell-components' + canonicalRenderableElementCount = 242 trainingShellTags = @( 'phase6c', 'family:magiccube5d', @@ -168,10 +199,10 @@ $ManifestEntries = @( $Manifest = [ordered]@{ manifestId = 'phase6c/dedicated-family-training-map-authoring' - manifestVersion = '2026.06.18' + manifestVersion = '2026.07.19' authorTag = 'HyperTwistHigherDimensionalTrainingShell' authoringScriptRelativePath = 'scripts/hypertwist_author_higher_dimensional_training_maps.py' - authoredThroughGameModeClassPath = '/Script/UnrealHyperTwist.HyperTwistCoachDashboardGameMode' + authoredThroughGameModeClassPath = '/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingGameMode' classicReferenceMapHashMd5 = $ClassicReferenceHashMd5 entries = $ManifestEntries } diff --git a/scripts/Invoke-HyperTwistInteractiveDesktopTask.ps1 b/scripts/Invoke-HyperTwistInteractiveDesktopTask.ps1 index 1703b37..93025d2 100644 --- a/scripts/Invoke-HyperTwistInteractiveDesktopTask.ps1 +++ b/scripts/Invoke-HyperTwistInteractiveDesktopTask.ps1 @@ -50,6 +50,43 @@ function Convert-TaskToken { return ($Value -replace '[^A-Za-z0-9_-]', '_') } +function Get-TaskOwnedProcessIds { + param( + [Parameter(Mandatory = $true)] + [string]$RunnerPath + ) + + $ProcessSnapshots = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue) + $OwnedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]' + foreach ($ProcessSnapshot in $ProcessSnapshots) + { + if (-not [string]::IsNullOrWhiteSpace([string]$ProcessSnapshot.CommandLine) ` + -and ([string]$ProcessSnapshot.CommandLine).IndexOf( + $RunnerPath, + [System.StringComparison]::OrdinalIgnoreCase + ) -ge 0) + { + [void]$OwnedProcessIds.Add([int]$ProcessSnapshot.ProcessId) + } + } + + $AddedProcess = $true + while ($AddedProcess) + { + $AddedProcess = $false + foreach ($ProcessSnapshot in $ProcessSnapshots) + { + if ($OwnedProcessIds.Contains([int]$ProcessSnapshot.ParentProcessId) ` + -and $OwnedProcessIds.Add([int]$ProcessSnapshot.ProcessId)) + { + $AddedProcess = $true + } + } + } + + return @($OwnedProcessIds) +} + if ([string]::IsNullOrWhiteSpace($InlineCommand)) { throw 'Provide -InlineCommand.' @@ -126,9 +163,12 @@ $Result = [ordered]@{ createExitCode = $null runExitCode = $null taskOutput = $null + terminatedOwnedProcessIds = @() result = 'failed' error = $null } +$TaskCreated = $false +$FailureResultPath = $null try { @@ -137,7 +177,6 @@ try '/TN', $TaskName, '/SC', 'ONCE', '/ST', '23:59', - '/SD', (Get-Date).ToString('MM/dd/yyyy'), '/TR', ('C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -ExecutionPolicy Bypass -File {0}' -f $TaskRunnerPath), '/RU', 'INTERACTIVE' ) @@ -147,6 +186,7 @@ try { throw "Creating the interactive desktop task failed with exit code $($Result.createExitCode)." } + $TaskCreated = $true & schtasks.exe /Run /TN ('\' + $TaskName) | Out-Null $Result.runExitCode = $LASTEXITCODE @@ -201,7 +241,7 @@ catch { $Result.taskOutput = Read-JsonFile -Path $TaskResultPath } - $ResolvedFailureResultPath = if ([string]::IsNullOrWhiteSpace($ResultPath)) + $FailureResultPath = if ([string]::IsNullOrWhiteSpace($ResultPath)) { $TaskResultPath } @@ -209,18 +249,49 @@ catch { $ResultPath } - Write-Utf8JsonFile -Path $ResolvedFailureResultPath -Value $Result + Write-Utf8JsonFile -Path $FailureResultPath -Value $Result throw } finally { if (-not $KeepArtifacts) { - & schtasks.exe /Delete /TN $TaskName /F | Out-Null + $TaskOwnedProcessIds = if ($TaskCreated) + { + @(Get-TaskOwnedProcessIds -RunnerPath $TaskRunnerPath) + } + else + { + @() + } + if ($TaskCreated) + { + # A timed-out interactive child must not survive as a hidden, + # memory-consuming desktop task. + & schtasks.exe /End /TN ('\' + $TaskName) *> $null + if ($LASTEXITCODE -eq 0) + { + Start-Sleep -Milliseconds 250 + } + & schtasks.exe /Delete /TN $TaskName /F | Out-Null + } + foreach ($TaskOwnedProcessId in @($TaskOwnedProcessIds | Sort-Object -Descending)) + { + if ($TaskOwnedProcessId -ne $PID ` + -and $null -ne (Get-Process -Id $TaskOwnedProcessId -ErrorAction SilentlyContinue)) + { + Stop-Process -Id $TaskOwnedProcessId -Force -ErrorAction SilentlyContinue + $Result.terminatedOwnedProcessIds += [int]$TaskOwnedProcessId + } + } if (Test-Path -LiteralPath $TaskRunnerPath) { Remove-Item -LiteralPath $TaskRunnerPath -Force } + if (-not [string]::IsNullOrWhiteSpace($FailureResultPath)) + { + Write-Utf8JsonFile -Path $FailureResultPath -Value $Result + } } } diff --git a/scripts/Launch-HyperTwistClassicCubePackage.ps1 b/scripts/Launch-HyperTwistClassicCubePackage.ps1 index 5053794..3171c32 100644 --- a/scripts/Launch-HyperTwistClassicCubePackage.ps1 +++ b/scripts/Launch-HyperTwistClassicCubePackage.ps1 @@ -1,7 +1,7 @@ param( [string]$PackageRoot = 'C:\HyperTwist\packaged\classic-cube', [string]$MapUrl = '/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining', - [int]$SmokeSeconds = 10, + [int]$SmokeSeconds = 30, [int]$ResX = 1600, [int]$ResY = 900, [string]$ReportPath = '', @@ -44,6 +44,9 @@ function Get-FatalRuntimeLogMatches { $FatalPatterns = @( 'Fatal error:', + 'LowLevelFatalError', + 'Assertion failed:', + 'Critical error:', 'appError called:', 'DXGI_ERROR_NOT_CURRENTLY_AVAILABLE', 'CreateSwapChainResult failed', @@ -65,18 +68,138 @@ function Get-FatalRuntimeLogMatches { }) } +function Get-OwnedPackageProcessIds { + param( + [Parameter(Mandatory = $true)] + [string]$ResolvedPackageRoot, + [int[]]$ExcludedProcessIds = @() + ) + + return @( + Get-OwnedPackageProcessRecords ` + -ResolvedPackageRoot $ResolvedPackageRoot ` + -ExcludedProcessIds $ExcludedProcessIds | + Select-Object -ExpandProperty ProcessId + ) +} + +function Get-OwnedPackageProcessRecords { + param( + [Parameter(Mandatory = $true)] + [string]$ResolvedPackageRoot, + [int[]]$ExcludedProcessIds = @() + ) + + $RootPrefix = $ResolvedPackageRoot.TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + [System.IO.Path]::DirectorySeparatorChar + + return @( + Get-CimInstance Win32_Process -Filter "Name = 'UnrealHyperTwist.exe'" -ErrorAction SilentlyContinue | + Where-Object { + -not [string]::IsNullOrWhiteSpace([string]$_.ExecutablePath) ` + -and ([string]$_.ExecutablePath).StartsWith( + $RootPrefix, + [System.StringComparison]::OrdinalIgnoreCase + ) ` + -and $ExcludedProcessIds -notcontains [int]$_.ProcessId + } + ) +} + +function Get-OwnedListeningTcpEndpoints { + param( + [int[]]$ProcessIds + ) + + if ($ProcessIds.Count -eq 0) + { + return @() + } + + return @( + Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | + Where-Object { $ProcessIds -contains [int]$_.OwningProcess } | + Sort-Object OwningProcess, LocalPort | + ForEach-Object { + [pscustomobject][ordered]@{ + processId = [int]$_.OwningProcess + localAddress = [string]$_.LocalAddress + localPort = [int]$_.LocalPort + } + } + ) +} + +function Stop-OwnedPackageProcesses { + param( + [Parameter(Mandatory = $true)] + [string]$ResolvedPackageRoot, + [int[]]$ExcludedProcessIds = @() + ) + + $StoppedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]' + for ($Attempt = 0; $Attempt -lt 5; $Attempt += 1) + { + $OwnedProcessIds = @( + Get-OwnedPackageProcessIds ` + -ResolvedPackageRoot $ResolvedPackageRoot ` + -ExcludedProcessIds $ExcludedProcessIds + ) + if ($OwnedProcessIds.Count -eq 0) + { + break + } + + foreach ($OwnedProcessId in $OwnedProcessIds) + { + Stop-Process -Id $OwnedProcessId -Force -ErrorAction SilentlyContinue + [void]$StoppedProcessIds.Add([int]$OwnedProcessId) + } + Start-Sleep -Milliseconds 200 + } + + return @($StoppedProcessIds) +} + $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) +$LauncherExecutablePath = $CandidateExecutablePaths | + Where-Object { Test-Path $_ } | + Select-Object -First 1 +if ($null -eq $LauncherExecutablePath) { throw "No packaged UnrealHyperTwist executable was found beneath '$PackageRoot'." } +$PackagedExecutableDirectory = Split-Path -Parent $LauncherExecutablePath +$ClassicDefaultMap = '/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining' +$StartupRouteByMap = @{ + $ClassicDefaultMap = '' + '/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining' = 'follow-along-training' + '/Game/HyperTwistTraining/Maps/L_HyperTwist_Magic120CellTraining' = 'magic-120-cell-training' + '/Game/HyperTwistTraining/Maps/L_HyperTwist_MagicCube5DTraining' = 'magic-cube-5d-training' +} +if (-not $StartupRouteByMap.ContainsKey($MapUrl)) +{ + throw "No first-party packaged startup route is registered for smoke map '$MapUrl'." +} +$StartupRouteId = [string]$StartupRouteByMap[$MapUrl] +$LaunchMode = if ($MapUrl -eq $ClassicDefaultMap) +{ + 'bootstrap-default-map' +} +else +{ + 'first-party-startup-route' +} +$ExecutablePath = $LauncherExecutablePath + $LogDirectory = Join-Path $PackageRoot 'validation\logs' New-Item -ItemType Directory -Force -Path $LogDirectory | Out-Null $LogToken = if ([string]::IsNullOrWhiteSpace($MapUrl)) @@ -88,15 +211,26 @@ else ($MapUrl -replace '[\\/:*?"<>| ]', '_') } $RuntimeLogPath = Join-Path $LogDirectory ("{0}.log" -f $LogToken) +$RuntimeDiagnosticsPath = Join-Path $LogDirectory ("{0}.hyperdiagnostics.log" -f $LogToken) +Remove-Item -LiteralPath $RuntimeLogPath -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath $RuntimeDiagnosticsPath -Force -ErrorAction SilentlyContinue -$ArgumentList = @( - $MapUrl, +$ArgumentList = @() +if (-not [string]::IsNullOrWhiteSpace($StartupRouteId)) +{ + # Nondefault smokes use the same registered product route as the launch + # menu. Generic startup URLs are overridden by the packaged first-run + # game-mode authority and therefore are not valid route evidence. + $ArgumentList += "-HyperTwistStartupRoute=$StartupRouteId" +} +$ArgumentList += @( "-ResX=$ResX", "-ResY=$ResY", '-windowed', '-log', '-FORCELOGFLUSH', - "-abslog=$RuntimeLogPath" + "-abslog=$RuntimeLogPath", + "-HyperTwistDiagnosticsLog=`"$RuntimeDiagnosticsPath`"" ) if ($UseNullRHI) @@ -111,17 +245,58 @@ if ($NoSound) Write-Host "Launching packaged classic-cube validation lane from '$ExecutablePath'..." $ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path +$ExistingPackageProcessIds = @( + Get-OwnedPackageProcessIds -ResolvedPackageRoot $ResolvedPackageRoot +) $GeneratedAtUtc = [DateTime]::UtcNow.ToString('o') $Process = $null +$RequiresClassicPresentation = $MapUrl -match '/L_HyperTwist_(Classic|FollowAlong)Training$' +$HigherDimensionalFamily = if ($MapUrl -match '/L_HyperTwist_Magic120CellTraining$') +{ + 'magic120cell' +} +elseif ($MapUrl -match '/L_HyperTwist_MagicCube5DTraining$') +{ + 'magiccube5d' +} +else +{ + '' +} +$ExpectedHigherDimensionalElementCount = if ($HigherDimensionalFamily -eq 'magic120cell') +{ + 120 +} +elseif ($HigherDimensionalFamily -eq 'magiccube5d') +{ + 242 +} +else +{ + 0 +} $Report = [ordered]@{ - reportVersion = 'ht-classic-cube-package-smoke/v1' + reportVersion = 'ht-desktop-map-package-smoke/v7' generatedAtUtc = $GeneratedAtUtc packageRoot = $ResolvedPackageRoot + launchMode = $LaunchMode + startupRouteId = $StartupRouteId + launcherExecutablePath = $LauncherExecutablePath executablePath = $ExecutablePath mapUrl = $MapUrl runtimeLogPath = $RuntimeLogPath runtimeLogExists = $false + runtimeDiagnosticsPath = $RuntimeDiagnosticsPath + runtimeDiagnosticsExists = $false detectedFatalLogLines = @() + mapLoadLogLines = @() + requiresClassicPresentation = $RequiresClassicPresentation + classicPresentationLogLines = @() + classicScrambleSettlementLogLines = @() + higherDimensionalFamily = $HigherDimensionalFamily + expectedHigherDimensionalElementCount = $ExpectedHigherDimensionalElementCount + higherDimensionalPresentationLogLines = @() + higherDimensionalPaletteLogLines = @() smokeSeconds = $SmokeSeconds resolution = [ordered]@{ width = $ResX @@ -132,22 +307,60 @@ $Report = [ordered]@{ noSound = [bool]$NoSound result = 'failed' processId = $null + processIds = @() + processCommandLines = @() + ownedListeningTcpEndpoints = @() + traceControlListenerLines = @() + traceControlListeningEndpoints = @() + startupRouteCommandLineEvidence = @() + startupRouteEvidenceLines = @() processStopped = $false + stoppedProcessIds = @() exitCode = $null error = $null } try { - $Process = Start-Process -FilePath $ExecutablePath -ArgumentList $ArgumentList -PassThru + $Process = Start-Process ` + -FilePath $ExecutablePath ` + -ArgumentList $ArgumentList ` + -WorkingDirectory (Split-Path -Parent $ExecutablePath) ` + -PassThru Start-Sleep -Seconds $SmokeSeconds $Process.Refresh() $Report.processId = $Process.Id + $OwnedProcessRecords = @( + Get-OwnedPackageProcessRecords ` + -ResolvedPackageRoot $ResolvedPackageRoot ` + -ExcludedProcessIds $ExistingPackageProcessIds + ) + $Report.processIds = @($OwnedProcessRecords | Select-Object -ExpandProperty ProcessId) + $Report.processCommandLines = @( + $OwnedProcessRecords | + ForEach-Object { [string]$_.CommandLine } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + $Report.ownedListeningTcpEndpoints = @( + Get-OwnedListeningTcpEndpoints -ProcessIds $Report.processIds + ) + $Report.traceControlListeningEndpoints = @( + $Report.ownedListeningTcpEndpoints | + Where-Object { [int]$_.localPort -eq 1985 } + ) Start-Sleep -Milliseconds 500 $Report.runtimeLogExists = Test-Path -LiteralPath $RuntimeLogPath - $Report.detectedFatalLogLines = @(Get-FatalRuntimeLogMatches -RuntimeLogPath $RuntimeLogPath) + $Report.runtimeDiagnosticsExists = Test-Path -LiteralPath $RuntimeDiagnosticsPath + $Report.detectedFatalLogLines = @( + @(Get-FatalRuntimeLogMatches -RuntimeLogPath $RuntimeLogPath) + + @(Get-FatalRuntimeLogMatches -RuntimeLogPath $RuntimeDiagnosticsPath) + ) + if (-not $Report.runtimeDiagnosticsExists) + { + throw 'Packaged launch did not write its direct HyperTwist diagnostics log.' + } if ($Report.detectedFatalLogLines.Count -gt 0) { throw ( @@ -156,10 +369,157 @@ try ) } - if ($Process.HasExited) + $CurrentRuntimeLogLines = @( + if ($Report.runtimeLogExists) + { + Get-Content -LiteralPath $RuntimeLogPath + } + ) + $CurrentRuntimeDiagnosticsLines = @( + if ($Report.runtimeDiagnosticsExists) + { + Get-Content -LiteralPath $RuntimeDiagnosticsPath + } + ) + $CurrentEvidenceLines = @($CurrentRuntimeLogLines + $CurrentRuntimeDiagnosticsLines) + $Report.traceControlListenerLines = @( + $CurrentEvidenceLines | + Where-Object { + $_.ToString().IndexOf( + 'Control listening on port', + [System.StringComparison]::OrdinalIgnoreCase + ) -ge 0 + } | + ForEach-Object { $_.ToString() } + ) + if ($Report.traceControlListenerLines.Count -gt 0 ` + -or $Report.traceControlListeningEndpoints.Count -gt 0) { - $Report.exitCode = $Process.ExitCode - throw "Packaged classic-cube executable exited early with code $($Process.ExitCode)." + throw 'Packaged runtime opened an Unreal trace-control listener.' + } + + if (-not [string]::IsNullOrWhiteSpace($StartupRouteId)) + { + $ExpectedStartupRouteArgument = "-HyperTwistStartupRoute=$StartupRouteId" + $Report.startupRouteCommandLineEvidence = @( + $Report.processCommandLines | + Where-Object { + $_.IndexOf( + $ExpectedStartupRouteArgument, + [System.StringComparison]::OrdinalIgnoreCase + ) -ge 0 + } + ) + if ($Report.startupRouteCommandLineEvidence.Count -eq 0) + { + throw ( + "Packaged bootstrap did not propagate first-party startup route argument " + + "'$ExpectedStartupRouteArgument'." + ) + } + + $ExpectedStartupRouteMarker = "Accepted packaged startup route '$StartupRouteId'" + $Report.startupRouteEvidenceLines = @( + $CurrentEvidenceLines | + Where-Object { $_ -like "*$ExpectedStartupRouteMarker*" } | + ForEach-Object { $_.ToString() } + ) + if ($Report.startupRouteEvidenceLines.Count -eq 0) + { + throw ( + "Packaged runtime did not accept first-party startup route '$StartupRouteId' " + + 'through the launch controller.' + ) + } + } + + $ExpectedMapLeaf = ($MapUrl -split '/')[-1] + $Report.mapLoadLogLines = @( + $CurrentEvidenceLines | + Where-Object { + $_ -like "*LogLoad: LoadMap: $MapUrl*" ` + -or $_ -like "*Bringing World $MapUrl*up for play*" ` + -or $_ -like "*Runtime map ready:*$ExpectedMapLeaf*" + } | + ForEach-Object { $_.ToString() } + ) + if ($Report.mapLoadLogLines.Count -eq 0) + { + throw "Packaged launch stayed alive but did not load target map '$MapUrl' before the smoke interval ended." + } + + $Report.classicPresentationLogLines = @( + $CurrentEvidenceLines | + Where-Object { + $_ -like '*Classic cube presentation initialized with 26 renderable pieces*' + } | + ForEach-Object { $_.ToString() } + ) + if ($RequiresClassicPresentation -and $Report.classicPresentationLogLines.Count -eq 0) + { + throw ( + "Packaged map '$MapUrl' loaded, but the runtime did not confirm its complete 26-cubie " + + 'Classic cube presentation.' + ) + } + + $Report.classicScrambleSettlementLogLines = @( + $CurrentEvidenceLines | + Where-Object { + $_ -match 'Classic cube scramble settled with \d+ completed quarter turns, 0 queued rotations, and 26 renderable pieces; renderable state valid\.' + } | + ForEach-Object { $_.ToString() } + ) + if ($RequiresClassicPresentation -and $Report.classicScrambleSettlementLogLines.Count -eq 0) + { + throw ( + "Packaged map '$MapUrl' loaded its Classic presentation, but the initial scramble " + + 'did not settle into a coherent 26-cubie state before the smoke interval ended.' + ) + } + + if (-not [string]::IsNullOrWhiteSpace($HigherDimensionalFamily)) + { + $ExpectedPresentationMarker = ( + 'Higher-dimensional presentation initialized with {0} renderable elements ' + + '(canonical={0}) for family {1}.' + ) -f $ExpectedHigherDimensionalElementCount, $HigherDimensionalFamily + $Report.higherDimensionalPresentationLogLines = @( + $CurrentEvidenceLines | + Where-Object { $_ -like "*$ExpectedPresentationMarker*" } | + ForEach-Object { $_.ToString() } + ) + if ($Report.higherDimensionalPresentationLogLines.Count -eq 0) + { + throw ( + "Packaged map '$MapUrl' loaded, but the runtime did not confirm its " + + "$ExpectedHigherDimensionalElementCount-element $HigherDimensionalFamily presentation." + ) + } + + $Report.higherDimensionalPaletteLogLines = @( + $CurrentEvidenceLines | + Where-Object { + $_ -like '*Higher-dimensional projection palette initialized with 6 distinct layer materials.*' + } | + ForEach-Object { $_.ToString() } + ) + if ($Report.higherDimensionalPaletteLogLines.Count -eq 0) + { + throw ( + "Packaged map '$MapUrl' loaded its projection, but the runtime did not confirm " + + 'six distinct first-party layer materials.' + ) + } + } + + if ($Report.processIds.Count -eq 0) + { + if ($Process.HasExited) + { + $Report.exitCode = $Process.ExitCode + } + throw "Packaged classic-cube executable exited before the smoke interval completed." } $Report.result = 'passed' @@ -167,18 +527,18 @@ try catch { $Report.error = $_.Exception.Message - if ($null -ne $Process) + if ($null -ne $Process -and $Process.HasExited) { - $Process.Refresh() - if ($Process.HasExited) - { - $Report.exitCode = $Process.ExitCode - } - elseif (-not $KeepRunning) - { - Stop-Process -Id $Process.Id -Force - $Report.processStopped = $true - } + $Report.exitCode = $Process.ExitCode + } + if (-not $KeepRunning) + { + $Report.stoppedProcessIds = @( + Stop-OwnedPackageProcesses ` + -ResolvedPackageRoot $ResolvedPackageRoot ` + -ExcludedProcessIds $ExistingPackageProcessIds + ) + $Report.processStopped = $Report.stoppedProcessIds.Count -gt 0 } if (-not [string]::IsNullOrWhiteSpace($ReportPath)) @@ -192,9 +552,13 @@ catch Write-Host "Packaged classic-cube smoke launch succeeded (PID $($Process.Id))." if (-not $KeepRunning) { - Stop-Process -Id $Process.Id -Force - $Report.processStopped = $true - Write-Host 'Stopped packaged classic-cube smoke process after successful launch validation.' + $Report.stoppedProcessIds = @( + Stop-OwnedPackageProcesses ` + -ResolvedPackageRoot $ResolvedPackageRoot ` + -ExcludedProcessIds $ExistingPackageProcessIds + ) + $Report.processStopped = $Report.stoppedProcessIds.Count -gt 0 + Write-Host 'Stopped the complete packaged classic-cube smoke process tree after successful launch validation.' } if (-not [string]::IsNullOrWhiteSpace($ReportPath)) diff --git a/scripts/Launch-HyperTwistDesktopPackage.ps1 b/scripts/Launch-HyperTwistDesktopPackage.ps1 index 629be40..7230f7c 100644 --- a/scripts/Launch-HyperTwistDesktopPackage.ps1 +++ b/scripts/Launch-HyperTwistDesktopPackage.ps1 @@ -1,14 +1,15 @@ param( [string]$PackageRoot = 'C:\HyperTwist\packaged\desktop', [string]$MapUrl = '', - [int]$SmokeSeconds = 12, + [int]$SmokeSeconds = 30, [int]$ResX = 1600, [int]$ResY = 900, [string]$ReportPath = '', [switch]$KeepRunning, [switch]$UseNullRHI, [switch]$NoSound, - [switch]$RequireStartupDiagnostics + [switch]$RequireStartupDiagnostics, + [string]$ExpectedStartupDiagnosticsResult = '' ) $ErrorActionPreference = 'Stop' @@ -36,6 +37,8 @@ function Resolve-StartupDiagnosticsLogPath { param( [Parameter(Mandatory = $true)] [string]$ResolvedPackageRoot, + [Parameter(Mandatory = $true)] + [DateTime]$NotBeforeUtc, [int]$TimeoutSeconds = 8, [int]$PollMilliseconds = 250 ) @@ -59,7 +62,8 @@ function Resolve-StartupDiagnosticsLogPath { { foreach ($CandidatePath in $CandidatePaths) { - if (Test-Path -LiteralPath $CandidatePath) + if ((Test-Path -LiteralPath $CandidatePath) ` + -and (Get-Item -LiteralPath $CandidatePath).LastWriteTimeUtc -ge $NotBeforeUtc.AddSeconds(-2)) { return $CandidatePath } @@ -73,6 +77,7 @@ function Resolve-StartupDiagnosticsLogPath { } $NewestDiagnosticsLog = Get-ChildItem -LiteralPath $CandidateDirectory -Filter 'HyperTwistFirstRunLaunch*.log' -File -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTimeUtc -ge $NotBeforeUtc.AddSeconds(-2) } | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 if ($null -ne $NewestDiagnosticsLog) @@ -93,6 +98,102 @@ function Resolve-StartupDiagnosticsLogPath { return $null } +function Get-OwnedPackageProcessIds { + param( + [Parameter(Mandatory = $true)] + [string]$ResolvedPackageRoot, + [int[]]$ExcludedProcessIds = @() + ) + + return @( + Get-OwnedPackageProcessRecords ` + -ResolvedPackageRoot $ResolvedPackageRoot ` + -ExcludedProcessIds $ExcludedProcessIds | + Select-Object -ExpandProperty ProcessId + ) +} + +function Get-OwnedPackageProcessRecords { + param( + [Parameter(Mandatory = $true)] + [string]$ResolvedPackageRoot, + [int[]]$ExcludedProcessIds = @() + ) + + $RootPrefix = $ResolvedPackageRoot.TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + [System.IO.Path]::DirectorySeparatorChar + + return @( + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + ([string]$_.Name) -like 'UnrealHyperTwist*.exe' ` + -and -not [string]::IsNullOrWhiteSpace([string]$_.ExecutablePath) ` + -and ([string]$_.ExecutablePath).StartsWith( + $RootPrefix, + [System.StringComparison]::OrdinalIgnoreCase + ) ` + -and $ExcludedProcessIds -notcontains [int]$_.ProcessId + } + ) +} + +function Get-OwnedListeningTcpEndpoints { + param( + [int[]]$ProcessIds + ) + + if ($ProcessIds.Count -eq 0) + { + return @() + } + + return @( + Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | + Where-Object { $ProcessIds -contains [int]$_.OwningProcess } | + Sort-Object OwningProcess, LocalPort | + ForEach-Object { + [pscustomobject][ordered]@{ + processId = [int]$_.OwningProcess + localAddress = [string]$_.LocalAddress + localPort = [int]$_.LocalPort + } + } + ) +} + +function Stop-OwnedPackageProcesses { + param( + [Parameter(Mandatory = $true)] + [string]$ResolvedPackageRoot, + [int[]]$ExcludedProcessIds = @() + ) + + $StoppedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]' + for ($Attempt = 0; $Attempt -lt 5; $Attempt += 1) + { + $OwnedProcessIds = @( + Get-OwnedPackageProcessIds ` + -ResolvedPackageRoot $ResolvedPackageRoot ` + -ExcludedProcessIds $ExcludedProcessIds + ) + if ($OwnedProcessIds.Count -eq 0) + { + break + } + + foreach ($OwnedProcessId in $OwnedProcessIds) + { + Stop-Process -Id $OwnedProcessId -Force -ErrorAction SilentlyContinue + [void]$StoppedProcessIds.Add([int]$OwnedProcessId) + } + Start-Sleep -Milliseconds 200 + } + + return @($StoppedProcessIds) +} + function Get-FatalRuntimeLogMatches { param( [Parameter(Mandatory = $true)] @@ -106,6 +207,9 @@ function Get-FatalRuntimeLogMatches { $FatalPatterns = @( 'Fatal error:', + 'LowLevelFatalError', + 'Assertion failed:', + 'Critical error:', 'appError called:', 'DXGI_ERROR_NOT_CURRENTLY_AVAILABLE', 'CreateSwapChainResult failed', @@ -142,6 +246,9 @@ if ($null -eq $ExecutablePath) $LogDirectory = Join-Path $PackageRoot 'validation\logs' New-Item -ItemType Directory -Force -Path $LogDirectory | Out-Null $RuntimeLogPath = Join-Path $LogDirectory 'desktop-runtime.log' +$RuntimeDiagnosticsPath = Join-Path $LogDirectory 'desktop-runtime.hyperdiagnostics.log' +Remove-Item -LiteralPath $RuntimeLogPath -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath $RuntimeDiagnosticsPath -Force -ErrorAction SilentlyContinue $ArgumentList = @( "-ResX=$ResX", @@ -149,7 +256,8 @@ $ArgumentList = @( '-windowed', '-log', '-FORCELOGFLUSH', - "-abslog=$RuntimeLogPath" + "-abslog=$RuntimeLogPath", + "-HyperTwistDiagnosticsLog=`"$RuntimeDiagnosticsPath`"" ) if ($UseNullRHI) @@ -169,16 +277,22 @@ if (-not [string]::IsNullOrWhiteSpace($MapUrl)) Write-Host "Launching packaged HyperTwist desktop experience from '$ExecutablePath'..." $ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path +$ExistingPackageProcessIds = @( + Get-OwnedPackageProcessIds -ResolvedPackageRoot $ResolvedPackageRoot +) $GeneratedAtUtc = [DateTime]::UtcNow.ToString('o') $Process = $null $Report = [ordered]@{ - reportVersion = 'ht-desktop-package-launch-surface/v1' + reportVersion = 'ht-desktop-package-launch-surface/v2' generatedAtUtc = $GeneratedAtUtc packageRoot = $ResolvedPackageRoot executablePath = $ExecutablePath mapUrl = $MapUrl runtimeLogPath = $RuntimeLogPath runtimeLogExists = $false + runtimeDiagnosticsPath = $RuntimeDiagnosticsPath + runtimeDiagnosticsExists = $false + runtimeDiagnosticsInitialized = $false detectedFatalLogLines = @() smokeSeconds = $SmokeSeconds resolution = [ordered]@{ @@ -189,52 +303,152 @@ $Report = [ordered]@{ useNullRhi = [bool]$UseNullRHI noSound = [bool]$NoSound requireStartupDiagnostics = [bool]$RequireStartupDiagnostics + expectedStartupDiagnosticsResult = $ExpectedStartupDiagnosticsResult startupDiagnosticsLogPath = $null startupDiagnosticsLogExists = $false startupDiagnosticsTail = @() + startupDiagnosticsResult = $null result = 'failed' processId = $null + processIds = @() + processCommandLines = @() + ownedListeningTcpEndpoints = @() + traceControlListenerLines = @() + traceControlListeningEndpoints = @() processStopped = $false + stoppedProcessIds = @() exitCode = $null error = $null } try { + $LaunchStartedAtUtc = [DateTime]::UtcNow $Process = Start-Process -FilePath $ExecutablePath -ArgumentList $ArgumentList -PassThru Start-Sleep -Seconds $SmokeSeconds $Process.Refresh() $Report.processId = $Process.Id + $OwnedProcessRecords = @( + Get-OwnedPackageProcessRecords ` + -ResolvedPackageRoot $ResolvedPackageRoot ` + -ExcludedProcessIds $ExistingPackageProcessIds + ) + $Report.processIds = @($OwnedProcessRecords | Select-Object -ExpandProperty ProcessId) + $Report.processCommandLines = @( + $OwnedProcessRecords | + ForEach-Object { [string]$_.CommandLine } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + $Report.ownedListeningTcpEndpoints = @( + Get-OwnedListeningTcpEndpoints -ProcessIds $Report.processIds + ) + $Report.traceControlListeningEndpoints = @( + $Report.ownedListeningTcpEndpoints | + Where-Object { [int]$_.localPort -eq 1985 } + ) Start-Sleep -Milliseconds 500 $Report.runtimeLogExists = Test-Path -LiteralPath $RuntimeLogPath - $Report.detectedFatalLogLines = @(Get-FatalRuntimeLogMatches -RuntimeLogPath $RuntimeLogPath) - $StartupDiagnosticsLogPath = Resolve-StartupDiagnosticsLogPath -ResolvedPackageRoot $ResolvedPackageRoot + $Report.runtimeDiagnosticsExists = Test-Path -LiteralPath $RuntimeDiagnosticsPath + $Report.detectedFatalLogLines = @( + @(Get-FatalRuntimeLogMatches -RuntimeLogPath $RuntimeLogPath) + + @(Get-FatalRuntimeLogMatches -RuntimeLogPath $RuntimeDiagnosticsPath) + ) + $CurrentRuntimeLogLines = @( + if ($Report.runtimeLogExists) + { + Get-Content -LiteralPath $RuntimeLogPath + } + ) + $CurrentRuntimeDiagnosticsLines = @( + if ($Report.runtimeDiagnosticsExists) + { + Get-Content -LiteralPath $RuntimeDiagnosticsPath + } + ) + $CurrentRuntimeEvidenceLines = @( + $CurrentRuntimeLogLines + $CurrentRuntimeDiagnosticsLines + ) + $Report.runtimeDiagnosticsInitialized = @( + $CurrentRuntimeDiagnosticsLines | + Where-Object { + $_.ToString().IndexOf( + '[Process] HyperTwist runtime module initialized.', + [System.StringComparison]::OrdinalIgnoreCase + ) -ge 0 + } + ).Count -gt 0 + $Report.traceControlListenerLines = @( + $CurrentRuntimeEvidenceLines | + Where-Object { + $_.ToString().IndexOf( + 'Control listening on port', + [System.StringComparison]::OrdinalIgnoreCase + ) -ge 0 + } | + ForEach-Object { $_.ToString() } + ) + $StartupDiagnosticsLogPath = Resolve-StartupDiagnosticsLogPath ` + -ResolvedPackageRoot $ResolvedPackageRoot ` + -NotBeforeUtc $LaunchStartedAtUtc $Report.startupDiagnosticsLogPath = $StartupDiagnosticsLogPath $Report.startupDiagnosticsLogExists = $null -ne $StartupDiagnosticsLogPath if ($Report.startupDiagnosticsLogExists) { - $Report.startupDiagnosticsTail = @(Get-Content -LiteralPath $StartupDiagnosticsLogPath -Tail 40) + $Report.startupDiagnosticsTail = @( + Get-Content -LiteralPath $StartupDiagnosticsLogPath -Tail 40 | + ForEach-Object { $_.ToString() } + ) + $Report.startupDiagnosticsResult = [string]( + $Report.startupDiagnosticsTail | + Where-Object { $_ -like 'result=*' } | + Select-Object -Last 1 + ) } + if (-not $Report.runtimeDiagnosticsExists) + { + throw 'Packaged desktop launch did not write its direct HyperTwist diagnostics log.' + } + if (-not $Report.runtimeDiagnosticsInitialized) + { + throw 'Packaged desktop launch did not record direct HyperTwist runtime initialization.' + } if ($Report.detectedFatalLogLines.Count -gt 0) { throw ( - "Packaged desktop runtime log recorded fatal startup evidence: " + + "Packaged desktop runtime diagnostics recorded fatal startup evidence: " + ($Report.detectedFatalLogLines -join ' | ') ) } + if ($Report.traceControlListenerLines.Count -gt 0 ` + -or $Report.traceControlListeningEndpoints.Count -gt 0) + { + throw 'Packaged desktop runtime opened an Unreal trace-control listener.' + } if ($RequireStartupDiagnostics -and -not $Report.startupDiagnosticsLogExists) { throw "Packaged desktop launch did not write HyperTwistFirstRunLaunch-latest.log." } - if ($Process.HasExited) + if (-not [string]::IsNullOrWhiteSpace($ExpectedStartupDiagnosticsResult) ` + -and $Report.startupDiagnosticsResult -ne "result=$ExpectedStartupDiagnosticsResult") { - $Report.exitCode = $Process.ExitCode - throw "Packaged desktop executable exited early with code $($Process.ExitCode)." + throw ( + "Expected startup diagnostics result '$ExpectedStartupDiagnosticsResult' but observed " + + "'$($Report.startupDiagnosticsResult)'." + ) + } + + if ($Report.processIds.Count -eq 0) + { + if ($Process.HasExited) + { + $Report.exitCode = $Process.ExitCode + } + throw 'Packaged desktop executable exited before the smoke interval completed.' } $Report.result = 'passed' @@ -242,18 +456,18 @@ try catch { $Report.error = $_.Exception.Message - if ($null -ne $Process) + if ($null -ne $Process -and $Process.HasExited) { - $Process.Refresh() - if ($Process.HasExited) - { - $Report.exitCode = $Process.ExitCode - } - elseif (-not $KeepRunning) - { - Stop-Process -Id $Process.Id -Force - $Report.processStopped = $true - } + $Report.exitCode = $Process.ExitCode + } + if (-not $KeepRunning) + { + $Report.stoppedProcessIds = @( + Stop-OwnedPackageProcesses ` + -ResolvedPackageRoot $ResolvedPackageRoot ` + -ExcludedProcessIds $ExistingPackageProcessIds + ) + $Report.processStopped = $Report.stoppedProcessIds.Count -gt 0 } if (-not [string]::IsNullOrWhiteSpace($ReportPath)) @@ -267,9 +481,13 @@ catch Write-Host "Packaged desktop smoke launch succeeded (PID $($Process.Id))." if (-not $KeepRunning) { - Stop-Process -Id $Process.Id -Force - $Report.processStopped = $true - Write-Host 'Stopped packaged desktop smoke process after successful launch validation.' + $Report.stoppedProcessIds = @( + Stop-OwnedPackageProcesses ` + -ResolvedPackageRoot $ResolvedPackageRoot ` + -ExcludedProcessIds $ExistingPackageProcessIds + ) + $Report.processStopped = $Report.stoppedProcessIds.Count -gt 0 + Write-Host 'Stopped the complete packaged desktop smoke process tree after successful launch validation.' } if (-not [string]::IsNullOrWhiteSpace($ReportPath)) diff --git a/scripts/Set-HyperTwistBootstrapLaunchArguments.ps1 b/scripts/Set-HyperTwistBootstrapLaunchArguments.ps1 new file mode 100644 index 0000000..19691a8 --- /dev/null +++ b/scripts/Set-HyperTwistBootstrapLaunchArguments.ps1 @@ -0,0 +1,408 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ExecutablePath, + [string]$ProjectToken = 'UnrealHyperTwist', + [string[]]$AdditionalArguments = @( + '-notraceserver', + '-traceautostart=0' + ), + [string]$ReportPath = '', + [switch]$VerifyOnly +) + +$ErrorActionPreference = 'Stop' + +$RawDataResourceType = 10 +$BootstrapArgumentsResourceId = 202 + +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 + } + + [System.IO.File]::WriteAllText( + $Path, + ($Value | ConvertTo-Json -Depth 10), + (New-Object System.Text.UTF8Encoding($false)) + ) +} + +if (-not ('HyperTwistBootstrapResources.NativeMethods' -as [type])) +{ + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +namespace HyperTwistBootstrapResources +{ + public static class NativeMethods + { + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern IntPtr LoadLibraryEx( + string fileName, + IntPtr fileHandle, + uint flags + ); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool FreeLibrary(IntPtr moduleHandle); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr FindResource( + IntPtr moduleHandle, + IntPtr resourceName, + IntPtr resourceType + ); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern uint SizeofResource( + IntPtr moduleHandle, + IntPtr resourceInfo + ); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr LoadResource( + IntPtr moduleHandle, + IntPtr resourceInfo + ); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr LockResource(IntPtr resourceData); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern IntPtr BeginUpdateResource( + string fileName, + bool deleteExistingResources + ); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool UpdateResource( + IntPtr updateHandle, + IntPtr resourceType, + IntPtr resourceName, + ushort language, + byte[] data, + uint dataLength + ); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool EndUpdateResource( + IntPtr updateHandle, + bool discard + ); + } +} +'@ +} + +function New-Win32Exception { + param( + [Parameter(Mandatory = $true)] + [string]$Operation + ) + + $ErrorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + return New-Object ` + -TypeName ComponentModel.Win32Exception ` + -ArgumentList $ErrorCode, "$Operation failed with Win32 error $ErrorCode." +} + +function Get-BootstrapArgumentsResource { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $LoadLibraryAsDataFile = 0x00000002 + $LoadLibraryAsImageResource = 0x00000020 + $ModuleHandle = [HyperTwistBootstrapResources.NativeMethods]::LoadLibraryEx( + $Path, + [IntPtr]::Zero, + ($LoadLibraryAsDataFile -bor $LoadLibraryAsImageResource) + ) + if ($ModuleHandle -eq [IntPtr]::Zero) + { + throw (New-Win32Exception -Operation "LoadLibraryEx('$Path')") + } + + try + { + $ResourceInfo = [HyperTwistBootstrapResources.NativeMethods]::FindResource( + $ModuleHandle, + [IntPtr]$BootstrapArgumentsResourceId, + [IntPtr]$RawDataResourceType + ) + if ($ResourceInfo -eq [IntPtr]::Zero) + { + throw ( + New-Win32Exception -Operation ( + "FindResource(type=$RawDataResourceType,id=$BootstrapArgumentsResourceId)" + ) + ) + } + + $ResourceSize = [HyperTwistBootstrapResources.NativeMethods]::SizeofResource( + $ModuleHandle, + $ResourceInfo + ) + if ($ResourceSize -eq 0) + { + throw ( + New-Win32Exception -Operation ( + "SizeofResource(type=$RawDataResourceType,id=$BootstrapArgumentsResourceId)" + ) + ) + } + + $LoadedResource = [HyperTwistBootstrapResources.NativeMethods]::LoadResource( + $ModuleHandle, + $ResourceInfo + ) + if ($LoadedResource -eq [IntPtr]::Zero) + { + throw ( + New-Win32Exception -Operation ( + "LoadResource(type=$RawDataResourceType,id=$BootstrapArgumentsResourceId)" + ) + ) + } + + $ResourcePointer = [HyperTwistBootstrapResources.NativeMethods]::LockResource( + $LoadedResource + ) + if ($ResourcePointer -eq [IntPtr]::Zero) + { + throw ( + New-Win32Exception -Operation ( + "LockResource(type=$RawDataResourceType,id=$BootstrapArgumentsResourceId)" + ) + ) + } + + $ResourceBytes = New-Object byte[] $ResourceSize + [Runtime.InteropServices.Marshal]::Copy( + $ResourcePointer, + $ResourceBytes, + 0, + [int]$ResourceSize + ) + return [Text.Encoding]::Unicode.GetString($ResourceBytes).TrimEnd([char]0) + } + finally + { + [void][HyperTwistBootstrapResources.NativeMethods]::FreeLibrary($ModuleHandle) + } +} + +function Set-BootstrapArgumentsResource { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Value + ) + + $ResourceBytes = [Text.Encoding]::Unicode.GetBytes($Value + [char]0) + $UpdateHandle = [HyperTwistBootstrapResources.NativeMethods]::BeginUpdateResource( + $Path, + $false + ) + if ($UpdateHandle -eq [IntPtr]::Zero) + { + throw (New-Win32Exception -Operation "BeginUpdateResource('$Path')") + } + + $UpdateClosed = $false + try + { + if (-not [HyperTwistBootstrapResources.NativeMethods]::UpdateResource( + $UpdateHandle, + [IntPtr]$RawDataResourceType, + [IntPtr]$BootstrapArgumentsResourceId, + [uint16]0, + $ResourceBytes, + [uint32]$ResourceBytes.Length + )) + { + throw ( + New-Win32Exception -Operation ( + "UpdateResource(type=$RawDataResourceType,id=$BootstrapArgumentsResourceId)" + ) + ) + } + + $UpdateCompleted = [HyperTwistBootstrapResources.NativeMethods]::EndUpdateResource( + $UpdateHandle, + $false + ) + $UpdateClosed = $true + if (-not $UpdateCompleted) + { + throw (New-Win32Exception -Operation "EndUpdateResource('$Path')") + } + } + finally + { + if (-not $UpdateClosed) + { + [void][HyperTwistBootstrapResources.NativeMethods]::EndUpdateResource( + $UpdateHandle, + $true + ) + } + } +} + +if (-not (Test-Path -LiteralPath $ExecutablePath -PathType Leaf)) +{ + throw "HyperTwist bootstrap executable '$ExecutablePath' was not found." +} + +if ([string]::IsNullOrWhiteSpace($ProjectToken)) +{ + throw 'ProjectToken must not be empty.' +} + +$NormalizedAdditionalArguments = @( + $AdditionalArguments | + ForEach-Object { + if ($null -eq $_) + { + return + } + + $_.Trim() + } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } +) +foreach ($Argument in $NormalizedAdditionalArguments) +{ + if ($Argument.IndexOf([char]0) -ge 0 ` + -or $Argument.IndexOf("`r") -ge 0 ` + -or $Argument.IndexOf("`n") -ge 0) + { + throw "Bootstrap launch argument '$Argument' contains an unsupported control character." + } +} + +$ResolvedExecutablePath = (Resolve-Path -LiteralPath $ExecutablePath).Path +$ExpectedArguments = @($ProjectToken) + $NormalizedAdditionalArguments +$ExpectedResourceValue = $ExpectedArguments -join ' ' +if ([string]::IsNullOrWhiteSpace($ReportPath)) +{ + $ReportPath = "$ResolvedExecutablePath.bootstrap-launch-arguments.json" +} + +$OriginalHash = (Get-FileHash -LiteralPath $ResolvedExecutablePath -Algorithm SHA256).Hash.ToLowerInvariant() +$OriginalResourceValue = Get-BootstrapArgumentsResource -Path $ResolvedExecutablePath +$Result = [ordered]@{ + reportVersion = 'ht-bootstrap-launch-arguments/v1' + generatedAtUtc = [DateTime]::UtcNow.ToString('o') + executablePath = $ResolvedExecutablePath + resourceType = $RawDataResourceType + resourceId = $BootstrapArgumentsResourceId + verifyOnly = [bool]$VerifyOnly + expectedArguments = @($ExpectedArguments) + expectedResourceValue = $ExpectedResourceValue + originalResourceValue = $OriginalResourceValue + finalResourceValue = $null + originalSha256 = $OriginalHash + finalSha256 = $null + changed = $false + result = 'failed' + error = $null +} + +$TemporaryPath = $null +$BackupPath = $null +try +{ + if ($VerifyOnly) + { + if ($OriginalResourceValue -ne $ExpectedResourceValue) + { + throw ( + "Bootstrap argument resource was '$OriginalResourceValue'; " + + "expected '$ExpectedResourceValue'." + ) + } + } + elseif ($OriginalResourceValue -ne $ExpectedResourceValue) + { + $TemporaryPath = '{0}.bootstrap-{1}-{2}.tmp' -f ( + $ResolvedExecutablePath, + $PID, + [Guid]::NewGuid().ToString('N') + ) + $BackupPath = '{0}.bootstrap-{1}-{2}.bak' -f ( + $ResolvedExecutablePath, + $PID, + [Guid]::NewGuid().ToString('N') + ) + Copy-Item -LiteralPath $ResolvedExecutablePath -Destination $TemporaryPath -Force + Set-BootstrapArgumentsResource -Path $TemporaryPath -Value $ExpectedResourceValue + + $TemporaryResourceValue = Get-BootstrapArgumentsResource -Path $TemporaryPath + if ($TemporaryResourceValue -ne $ExpectedResourceValue) + { + throw ( + "Patched bootstrap argument resource was '$TemporaryResourceValue'; " + + "expected '$ExpectedResourceValue'." + ) + } + + [System.IO.File]::Replace( + $TemporaryPath, + $ResolvedExecutablePath, + $BackupPath, + $true + ) + $TemporaryPath = $null + $Result.changed = $true + } + + $Result.finalResourceValue = Get-BootstrapArgumentsResource -Path $ResolvedExecutablePath + if ($Result.finalResourceValue -ne $ExpectedResourceValue) + { + throw ( + "Final bootstrap argument resource was '$($Result.finalResourceValue)'; " + + "expected '$ExpectedResourceValue'." + ) + } + + $Result.finalSha256 = ( + Get-FileHash -LiteralPath $ResolvedExecutablePath -Algorithm SHA256 + ).Hash.ToLowerInvariant() + $Result.result = 'passed' +} +catch +{ + $Result.error = $_.Exception.Message + throw +} +finally +{ + if (-not [string]::IsNullOrWhiteSpace($TemporaryPath) ` + -and (Test-Path -LiteralPath $TemporaryPath)) + { + Remove-Item -LiteralPath $TemporaryPath -Force -ErrorAction SilentlyContinue + } + if (-not [string]::IsNullOrWhiteSpace($BackupPath) ` + -and (Test-Path -LiteralPath $BackupPath)) + { + Remove-Item -LiteralPath $BackupPath -Force -ErrorAction SilentlyContinue + } + + Write-Utf8JsonFile -Path $ReportPath -Value $Result +} + +$Result | ConvertTo-Json -Depth 10 diff --git a/scripts/Test-HyperTwistPackagedOffscreenVisual.ps1 b/scripts/Test-HyperTwistPackagedOffscreenVisual.ps1 new file mode 100644 index 0000000..a2f15ca --- /dev/null +++ b/scripts/Test-HyperTwistPackagedOffscreenVisual.ps1 @@ -0,0 +1,343 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ExecutablePath, + [Parameter(Mandatory = $true)] + [string]$OutputDirectory, + [string]$TargetMap = '', + [string[]]$ExpectedLogSubstring = @(), + [ValidateSet('Startup', 'RuntimeReady')] + [string]$CaptureTrigger = 'RuntimeReady', + [int]$TimeoutSeconds = 45, + [int]$SampleStride = 6, + [double]$MinimumNonBlackRatio = 0.02, + [double]$MinimumLuminanceDeviation = 2.5, + [double]$MinimumLuminanceRange = 20.0 +) + +$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 + } + + [System.IO.File]::WriteAllText( + $Path, + ($Value | ConvertTo-Json -Depth 12), + (New-Object System.Text.UTF8Encoding($false)) + ) +} + +function Update-HyperTwistOwnedProcessIds { + param( + [Parameter(Mandatory = $true)] + [System.Collections.Generic.HashSet[int]]$OwnedProcessIds, + [Parameter(Mandatory = $true)] + [string]$ProcessName, + [int[]]$IgnoredProcessIds = @() + ) + + foreach ($Process in @(Get-Process -Name $ProcessName -ErrorAction SilentlyContinue)) + { + if ($IgnoredProcessIds -notcontains $Process.Id) + { + [void]$OwnedProcessIds.Add([int]$Process.Id) + } + } +} + +function Measure-HyperTwistBitmap { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [int]$Stride = 6 + ) + + Add-Type -AssemblyName System.Drawing + $Bitmap = [System.Drawing.Bitmap]::FromFile($Path) + try + { + $SafeStride = [Math]::Max($Stride, 1) + $SampleCount = 0 + $NonBlackCount = 0 + $LuminanceSum = 0.0 + $LuminanceSquaredSum = 0.0 + $MinimumLuminance = 255.0 + $MaximumLuminance = 0.0 + + for ($Y = 0; $Y -lt $Bitmap.Height; $Y += $SafeStride) + { + for ($X = 0; $X -lt $Bitmap.Width; $X += $SafeStride) + { + $Pixel = $Bitmap.GetPixel($X, $Y) + $Luminance = (0.2126 * $Pixel.R) + (0.7152 * $Pixel.G) + (0.0722 * $Pixel.B) + $SampleCount += 1 + $LuminanceSum += $Luminance + $LuminanceSquaredSum += ($Luminance * $Luminance) + $MinimumLuminance = [Math]::Min($MinimumLuminance, $Luminance) + $MaximumLuminance = [Math]::Max($MaximumLuminance, $Luminance) + if ($Luminance -gt 10.0) + { + $NonBlackCount += 1 + } + } + } + + if ($SampleCount -le 0) + { + throw 'The off-screen frame contained no image samples.' + } + + $AverageLuminance = $LuminanceSum / $SampleCount + $Variance = [Math]::Max( + ($LuminanceSquaredSum / $SampleCount) - ($AverageLuminance * $AverageLuminance), + 0.0 + ) + + return [ordered]@{ + width = $Bitmap.Width + height = $Bitmap.Height + sampleStride = $SafeStride + sampleCount = $SampleCount + nonBlackRatio = $NonBlackCount / $SampleCount + averageLuminance = $AverageLuminance + luminanceDeviation = [Math]::Sqrt($Variance) + minimumLuminance = $MinimumLuminance + maximumLuminance = $MaximumLuminance + luminanceRange = $MaximumLuminance - $MinimumLuminance + } + } + finally + { + $Bitmap.Dispose() + } +} + +if (-not (Test-Path -LiteralPath $ExecutablePath -PathType Leaf)) +{ + throw "HyperTwist executable '$ExecutablePath' does not exist." +} + +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null +$ResolvedExecutablePath = (Resolve-Path -LiteralPath $ExecutablePath).Path +$ResolvedOutputDirectory = (Resolve-Path -LiteralPath $OutputDirectory).Path +$ExecutableDirectory = Split-Path -Parent $ResolvedExecutablePath +$ExecutableProcessName = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedExecutablePath) +$SavedDirectory = Join-Path $ExecutableDirectory 'UnrealHyperTwist\Saved' +$ScreenshotDirectory = if ($CaptureTrigger -eq 'RuntimeReady') +{ + Join-Path $SavedDirectory 'Screenshots\HyperTwistDiagnostics' +} +else +{ + Join-Path $SavedDirectory 'Screenshots\Windows' +} +$RuntimeLogPath = Join-Path $SavedDirectory 'Logs\UnrealHyperTwist.log' +$CapturedFramePath = Join-Path $ResolvedOutputDirectory 'HyperTwist-offscreen-frame.png' +$CapturedLogPath = Join-Path $ResolvedOutputDirectory 'UnrealHyperTwist.log' +$ReportPath = Join-Path $ResolvedOutputDirectory 'HyperTwist-offscreen-visual-report.json' +$ExistingProcessIds = @( + Get-Process -Name $ExecutableProcessName -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty Id +) +$ExistingScreenshotSignatures = @{} +foreach ($ExistingScreenshot in @( + Get-ChildItem -LiteralPath $ScreenshotDirectory -File -ErrorAction SilentlyContinue +)) +{ + $ExistingScreenshotSignatures[$ExistingScreenshot.FullName.ToLowerInvariant()] = + '{0}:{1}' -f $ExistingScreenshot.LastWriteTimeUtc.Ticks, $ExistingScreenshot.Length +} +$OwnedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]' +$LaunchArguments = @() +if (-not [string]::IsNullOrWhiteSpace($TargetMap)) +{ + $LaunchArguments += $TargetMap +} +$LaunchArguments += @( + '-RenderOffScreen', + '-windowed', + '-ResX=1280', + '-ResY=720' +) +if ($CaptureTrigger -eq 'RuntimeReady') +{ + $LaunchArguments += '-HyperTwistCaptureWhenReady' +} +else +{ + $LaunchArguments += '-ExecCmds="SHOT SHOWUI"' +} + +$Result = [ordered]@{ + reportVersion = 'ht-packaged-offscreen-visual/v1' + generatedAtUtc = [DateTime]::UtcNow.ToString('o') + executablePath = $ResolvedExecutablePath + executableSha256 = (Get-FileHash -LiteralPath $ResolvedExecutablePath -Algorithm SHA256).Hash.ToLowerInvariant() + targetMap = $TargetMap + captureTrigger = $CaptureTrigger + launchArguments = @($LaunchArguments) + expectedLogSubstrings = @($ExpectedLogSubstring) + matchedLogSubstrings = @() + screenshotPath = $CapturedFramePath + screenshotSha256 = $null + runtimeLogPath = $CapturedLogPath + metrics = $null + thresholds = [ordered]@{ + minimumNonBlackRatio = $MinimumNonBlackRatio + minimumLuminanceDeviation = $MinimumLuminanceDeviation + minimumLuminanceRange = $MinimumLuminanceRange + } + processIds = @() + terminatedProcessIds = @() + result = 'failed' + error = $null +} + +$LaunchedProcess = $null + +try +{ + $LaunchStartedAtUtc = [DateTime]::UtcNow + $LaunchedProcess = Start-Process ` + -FilePath $ResolvedExecutablePath ` + -ArgumentList $LaunchArguments ` + -WorkingDirectory $ExecutableDirectory ` + -PassThru + [void]$OwnedProcessIds.Add($LaunchedProcess.Id) + + $Deadline = $LaunchStartedAtUtc.AddSeconds([Math]::Max($TimeoutSeconds, 1)) + $FreshScreenshot = $null + $RuntimeLog = '' + while ([DateTime]::UtcNow -lt $Deadline) + { + Update-HyperTwistOwnedProcessIds ` + -OwnedProcessIds $OwnedProcessIds ` + -ProcessName $ExecutableProcessName ` + -IgnoredProcessIds $ExistingProcessIds + + $FreshScreenshot = Get-ChildItem -LiteralPath $ScreenshotDirectory -File -ErrorAction SilentlyContinue | + Where-Object { + $ScreenshotKey = $_.FullName.ToLowerInvariant() + $CurrentSignature = '{0}:{1}' -f $_.LastWriteTimeUtc.Ticks, $_.Length + -not $ExistingScreenshotSignatures.ContainsKey($ScreenshotKey) ` + -or $ExistingScreenshotSignatures[$ScreenshotKey] -ne $CurrentSignature + } | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + + if (Test-Path -LiteralPath $RuntimeLogPath) + { + $RuntimeLogItem = Get-Item -LiteralPath $RuntimeLogPath + if ($RuntimeLogItem.LastWriteTimeUtc -ge $LaunchStartedAtUtc.AddSeconds(-1)) + { + $LoadedRuntimeLog = Get-Content -LiteralPath $RuntimeLogPath -Raw + $RuntimeLog = if ($null -eq $LoadedRuntimeLog) + { + '' + } + else + { + [string]$LoadedRuntimeLog + } + } + } + + $MatchedLogSubstrings = @( + $ExpectedLogSubstring | + Where-Object { + $RuntimeLog.IndexOf($_, [System.StringComparison]::Ordinal) -ge 0 + } + ) + $Result.matchedLogSubstrings = @($MatchedLogSubstrings) + + if ($null -ne $FreshScreenshot ` + -and $MatchedLogSubstrings.Count -eq $ExpectedLogSubstring.Count) + { + break + } + + $LiveOwnedProcesses = @( + $OwnedProcessIds | + ForEach-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue } | + Where-Object { $null -ne $_ } + ) + if ($LiveOwnedProcesses.Count -eq 0) + { + throw 'HyperTwist exited before off-screen visual validation completed.' + } + + Start-Sleep -Milliseconds 500 + } + + if ($null -eq $FreshScreenshot) + { + throw "HyperTwist did not write a fresh UI-inclusive screenshot within $TimeoutSeconds seconds." + } + if ($Result.matchedLogSubstrings.Count -ne $ExpectedLogSubstring.Count) + { + $MissingLogSubstrings = @( + $ExpectedLogSubstring | + Where-Object { $Result.matchedLogSubstrings -notcontains $_ } + ) + throw "The current runtime log omitted required markers: $($MissingLogSubstrings -join '; ')" + } + if ($RuntimeLog -match '(?im)Fatal error:|Unhandled Exception|Assertion failed|CreateSwapChainResult failed') + { + throw 'The current runtime log contains a fatal startup signature.' + } + + Copy-Item -LiteralPath $FreshScreenshot.FullName -Destination $CapturedFramePath -Force + Copy-Item -LiteralPath $RuntimeLogPath -Destination $CapturedLogPath -Force + $Result.screenshotSha256 = ( + Get-FileHash -LiteralPath $CapturedFramePath -Algorithm SHA256 + ).Hash.ToLowerInvariant() + $Result.metrics = Measure-HyperTwistBitmap -Path $CapturedFramePath -Stride $SampleStride + if ($Result.metrics.nonBlackRatio -lt $MinimumNonBlackRatio ` + -or $Result.metrics.luminanceDeviation -lt $MinimumLuminanceDeviation ` + -or $Result.metrics.luminanceRange -lt $MinimumLuminanceRange) + { + throw 'The UI-inclusive off-screen frame did not satisfy the configured visual thresholds.' + } + + $Result.processIds = @($OwnedProcessIds | Sort-Object) + $Result.result = 'passed' +} +catch +{ + $Result.error = $_.Exception.Message +} +finally +{ + Update-HyperTwistOwnedProcessIds ` + -OwnedProcessIds $OwnedProcessIds ` + -ProcessName $ExecutableProcessName ` + -IgnoredProcessIds $ExistingProcessIds + foreach ($ProcessId in @($OwnedProcessIds | Sort-Object -Descending)) + { + $Process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue + if ($null -ne $Process) + { + Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue + $Result.terminatedProcessIds += $ProcessId + } + } + + Write-Utf8JsonFile -Path $ReportPath -Value $Result +} + +if ($Result.result -ne 'passed') +{ + throw $Result.error +} + +Write-Output "HyperTwist off-screen visual validation passed: $ReportPath" diff --git a/scripts/Test-HyperTwistPackagedWindowVisual.ps1 b/scripts/Test-HyperTwistPackagedWindowVisual.ps1 new file mode 100644 index 0000000..e529abb --- /dev/null +++ b/scripts/Test-HyperTwistPackagedWindowVisual.ps1 @@ -0,0 +1,939 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ExecutablePath, + [Parameter(Mandatory = $true)] + [string]$OutputDirectory, + [string[]]$LaunchArguments = @( + '-windowed', + '-ResX=1280', + '-ResY=720' + ), + [int]$StartupTimeoutSeconds = 45, + [int]$LayoutSettleSeconds = 5, + [int]$SampleStride = 6, + [double]$MinimumNonBlackRatio = 0.02, + [double]$MinimumBrightRatio = 0.001, + [double]$MinimumLuminanceDeviation = 2.5, + [double]$MinimumLuminanceRange = 20.0, + [double]$ClickNormalizedX = -1.0, + [double]$ClickNormalizedY = -1.0, + [ValidateSet('PhysicalMouse', 'WindowMessage')] + [string]$ClickInjectionMode = 'PhysicalMouse', + [int]$PostClickSettleSeconds = 5, + [string]$ExpectedWindowClassName = 'UnrealWindow', + [string]$ExpectedStartupDiagnosticsResult = '', + [string]$ExpectedSemanticReadyCaptureName = '', + [int]$SemanticReadyCaptureTimeoutSeconds = 30, + [switch]$KeepProcess +) + +$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 + } + + [System.IO.File]::WriteAllText( + $Path, + ($Value | ConvertTo-Json -Depth 12), + (New-Object System.Text.UTF8Encoding($false)) + ) +} + +function Write-HyperTwistVisualCheckpoint { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Stage + ) + + $Line = "{0}`t{1}{2}" -f ` + [DateTime]::UtcNow.ToString('o'), ` + $Stage, ` + [Environment]::NewLine + [System.IO.File]::AppendAllText( + $Path, + $Line, + (New-Object System.Text.UTF8Encoding($false)) + ) +} + +function Get-HyperTwistClientBounds { + param( + [Parameter(Mandatory = $true)] + [IntPtr]$WindowHandle + ) + + $ClientRect = New-Object HyperTwistVisualGate.NativeMethods+RECT + if (-not [HyperTwistVisualGate.NativeMethods]::GetClientRect($WindowHandle, [ref]$ClientRect)) + { + throw 'GetClientRect failed for the HyperTwist window.' + } + + $ClientOrigin = New-Object HyperTwistVisualGate.NativeMethods+POINT + $ClientOrigin.X = 0 + $ClientOrigin.Y = 0 + if (-not [HyperTwistVisualGate.NativeMethods]::ClientToScreen($WindowHandle, [ref]$ClientOrigin)) + { + throw 'ClientToScreen failed for the HyperTwist window.' + } + + return [ordered]@{ + x = $ClientOrigin.X + y = $ClientOrigin.Y + width = $ClientRect.Right - $ClientRect.Left + height = $ClientRect.Bottom - $ClientRect.Top + } +} + +function Set-HyperTwistWindowForeground { + param( + [Parameter(Mandatory = $true)] + [IntPtr]$WindowHandle, + [switch]$RequireForeground + ) + + for ($Attempt = 1; $Attempt -le 3; $Attempt += 1) + { + [void][HyperTwistVisualGate.NativeMethods]::ShowWindowAsync($WindowHandle, 9) + # Pulse topmost status to expose an obscured game window, then restore + # normal z-order before capture. + [void][HyperTwistVisualGate.NativeMethods]::SetWindowPos( + $WindowHandle, + [IntPtr](-1), + 0, + 0, + 0, + 0, + 0x0043 + ) + [void][HyperTwistVisualGate.NativeMethods]::SetWindowPos( + $WindowHandle, + [IntPtr](-2), + 0, + 0, + 0, + 0, + 0x0043 + ) + [void][HyperTwistVisualGate.NativeMethods]::BringWindowToTop($WindowHandle) + [void][HyperTwistVisualGate.NativeMethods]::SetForegroundWindow($WindowHandle) + Start-Sleep -Milliseconds 300 + if ([HyperTwistVisualGate.NativeMethods]::GetForegroundWindow() -eq $WindowHandle) + { + return "topmost-attempt-$Attempt" + } + + Start-Sleep -Milliseconds 300 + } + + if ($RequireForeground) + { + throw 'Could not focus the HyperTwist window before interactive visual capture.' + } + + return 'topmost-foreground-unconfirmed' +} + +function Measure-HyperTwistBitmap { + param( + [Parameter(Mandatory = $true)] + [System.Drawing.Bitmap]$Bitmap, + [int]$Stride = 6 + ) + + $SafeStride = [Math]::Max($Stride, 1) + $SampleCount = 0 + $NonBlackCount = 0 + $BrightCount = 0 + $LuminanceSum = 0.0 + $LuminanceSquaredSum = 0.0 + $MinimumLuminance = 255.0 + $MaximumLuminance = 0.0 + $ColorBuckets = New-Object 'System.Collections.Generic.HashSet[string]' + + for ($Y = 0; $Y -lt $Bitmap.Height; $Y += $SafeStride) + { + for ($X = 0; $X -lt $Bitmap.Width; $X += $SafeStride) + { + $Pixel = $Bitmap.GetPixel($X, $Y) + $Luminance = (0.2126 * $Pixel.R) + (0.7152 * $Pixel.G) + (0.0722 * $Pixel.B) + $SampleCount += 1 + $LuminanceSum += $Luminance + $LuminanceSquaredSum += ($Luminance * $Luminance) + $MinimumLuminance = [Math]::Min($MinimumLuminance, $Luminance) + $MaximumLuminance = [Math]::Max($MaximumLuminance, $Luminance) + if ($Luminance -gt 10.0) + { + $NonBlackCount += 1 + } + if ($Luminance -gt 35.0) + { + $BrightCount += 1 + } + + $Bucket = '{0}-{1}-{2}' -f ` + [Math]::Floor($Pixel.R / 16), ` + [Math]::Floor($Pixel.G / 16), ` + [Math]::Floor($Pixel.B / 16) + [void]$ColorBuckets.Add($Bucket) + } + } + + if ($SampleCount -le 0) + { + throw 'The captured HyperTwist client image contained no samples.' + } + + $AverageLuminance = $LuminanceSum / $SampleCount + $Variance = [Math]::Max( + ($LuminanceSquaredSum / $SampleCount) - ($AverageLuminance * $AverageLuminance), + 0.0 + ) + + return [ordered]@{ + sampleStride = $SafeStride + sampleCount = $SampleCount + nonBlackRatio = $NonBlackCount / $SampleCount + brightRatio = $BrightCount / $SampleCount + averageLuminance = $AverageLuminance + luminanceDeviation = [Math]::Sqrt($Variance) + minimumLuminance = $MinimumLuminance + maximumLuminance = $MaximumLuminance + luminanceRange = $MaximumLuminance - $MinimumLuminance + quantizedColorBucketCount = $ColorBuckets.Count + } +} + +function Update-HyperTwistOwnedProcessIds { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [System.Collections.Generic.HashSet[int]]$OwnedProcessIds, + [Parameter(Mandatory = $true)] + [string]$ResolvedExecutableRoot, + [int[]]$IgnoredProcessIds = @() + ) + + $RootPrefix = $ResolvedExecutableRoot.TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + [System.IO.Path]::DirectorySeparatorChar + $ProcessSnapshots = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue) + foreach ($ProcessSnapshot in $ProcessSnapshots) + { + $ExecutablePath = [string]$ProcessSnapshot.ExecutablePath + if ($IgnoredProcessIds -notcontains [int]$ProcessSnapshot.ProcessId ` + -and ([string]$ProcessSnapshot.Name) -like 'UnrealHyperTwist*.exe' ` + -and -not [string]::IsNullOrWhiteSpace($ExecutablePath) ` + -and $ExecutablePath.StartsWith( + $RootPrefix, + [System.StringComparison]::OrdinalIgnoreCase + )) + { + [void]$OwnedProcessIds.Add([int]$ProcessSnapshot.ProcessId) + } + } + + # Preserve ownership across the bootstrap-to-Shipping handoff even when + # Windows temporarily withholds a child's ExecutablePath during startup. + $AddedDescendant = $true + while ($AddedDescendant) + { + $AddedDescendant = $false + foreach ($ProcessSnapshot in $ProcessSnapshots) + { + if ($IgnoredProcessIds -notcontains [int]$ProcessSnapshot.ProcessId ` + -and $OwnedProcessIds.Contains([int]$ProcessSnapshot.ParentProcessId) ` + -and $OwnedProcessIds.Add([int]$ProcessSnapshot.ProcessId)) + { + $AddedDescendant = $true + } + } + } +} + +if (-not (Test-Path -LiteralPath $ExecutablePath -PathType Leaf)) +{ + throw "HyperTwist executable '$ExecutablePath' does not exist." +} + +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null +$ResolvedExecutablePath = (Resolve-Path -LiteralPath $ExecutablePath).Path +$ResolvedOutputDirectory = (Resolve-Path -LiteralPath $OutputDirectory).Path +$ExecutableDirectory = Split-Path -Parent $ResolvedExecutablePath +$CaptureToken = [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ') +$ScreenshotPath = Join-Path $ResolvedOutputDirectory "HyperTwist-window-$CaptureToken.png" +$ReportPath = Join-Path $ResolvedOutputDirectory "HyperTwist-window-$CaptureToken.json" +$ProgressPath = Join-Path $ResolvedOutputDirectory "HyperTwist-window-$CaptureToken.progress.log" +$RuntimeDiagnosticsPath = Join-Path $ResolvedOutputDirectory "HyperTwist-window-$CaptureToken.hyperdiagnostics.log" +$EffectiveLaunchArguments = @($LaunchArguments) +if (-not @($EffectiveLaunchArguments | Where-Object { + $_ -like '-HyperTwistDiagnosticsLog=*' +}).Count) +{ + $EffectiveLaunchArguments += "-HyperTwistDiagnosticsLog=`"$RuntimeDiagnosticsPath`"" +} +$SemanticReadyCaptureCandidates = @() +if (-not [string]::IsNullOrWhiteSpace($ExpectedSemanticReadyCaptureName)) +{ + if ([System.IO.Path]::GetFileName($ExpectedSemanticReadyCaptureName) ` + -ne $ExpectedSemanticReadyCaptureName ` + -or [System.IO.Path]::GetExtension($ExpectedSemanticReadyCaptureName) -ne '.png') + { + throw 'ExpectedSemanticReadyCaptureName must be one PNG file name without a path.' + } + + $SemanticReadyCaptureCandidates = @( + (Join-Path ` + $env:LOCALAPPDATA ` + "UnrealHyperTwist\Saved\Screenshots\HyperTwistDiagnostics\$ExpectedSemanticReadyCaptureName"), + (Join-Path ` + $ExecutableDirectory ` + "UnrealHyperTwist\Saved\Screenshots\HyperTwistDiagnostics\$ExpectedSemanticReadyCaptureName"), + (Join-Path ` + $ExecutableDirectory ` + "Saved\Screenshots\HyperTwistDiagnostics\$ExpectedSemanticReadyCaptureName") + ) | Select-Object -Unique +} +$ExistingOwnedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]' +Update-HyperTwistOwnedProcessIds ` + -OwnedProcessIds $ExistingOwnedProcessIds ` + -ResolvedExecutableRoot $ExecutableDirectory +$ExistingProcessIds = @($ExistingOwnedProcessIds) + +Add-Type -AssemblyName System.Drawing +if (-not ('HyperTwistVisualGate.NativeMethods' -as [type])) +{ + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace HyperTwistVisualGate +{ + public static class NativeMethods + { + [StructLayout(LayoutKind.Sequential)] + public struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + public struct POINT + { + public int X; + public int Y; + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool GetClientRect(IntPtr hWnd, ref RECT lpRect); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint); + + [DllImport("user32.dll")] + public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool BringWindowToTop(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetWindowPos( + IntPtr hWnd, + IntPtr hWndInsertAfter, + int x, + int y, + int cx, + int cy, + uint flags + ); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetCursorPos(int x, int y); + + [DllImport("user32.dll")] + public static extern void mouse_event( + uint dwFlags, + uint dx, + uint dy, + uint dwData, + UIntPtr dwExtraInfo + ); + + [DllImport("user32.dll")] + public static extern IntPtr SendMessage( + IntPtr hWnd, + uint msg, + IntPtr wParam, + IntPtr lParam + ); + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + private static extern int GetClassName( + IntPtr hWnd, + StringBuilder lpClassName, + int nMaxCount + ); + + public static string ReadWindowClassName(IntPtr hWnd) + { + var className = new StringBuilder(256); + return GetClassName(hWnd, className, className.Capacity) > 0 + ? className.ToString() + : String.Empty; + } + } +} +'@ +} + +# CopyFromScreen uses physical pixels. Match the calling thread to modern UE +# per-monitor DPI coordinates so the measured rectangle cannot drift onto the +# surrounding desktop at display scaling values above 100 percent. +$PreviousDpiAwarenessContext = [HyperTwistVisualGate.NativeMethods]::SetThreadDpiAwarenessContext( + [IntPtr](-4) +) +if ($PreviousDpiAwarenessContext -eq [IntPtr]::Zero) +{ + throw 'Could not enable per-monitor-v2 DPI awareness for the visual capture thread.' +} + +$Result = [ordered]@{ + reportVersion = 'ht-packaged-window-visual/v2' + generatedAtUtc = [DateTime]::UtcNow.ToString('o') + launchStartedAtUtc = $null + executablePath = $ResolvedExecutablePath + executableSha256 = (Get-FileHash -LiteralPath $ResolvedExecutablePath -Algorithm SHA256).Hash.ToLowerInvariant() + launchArguments = @($EffectiveLaunchArguments) + perMonitorV2DpiAware = $true + processId = $null + windowTitle = $null + windowClassName = $null + expectedWindowClassName = $ExpectedWindowClassName + clientBounds = $null + screenshotPath = $ScreenshotPath + progressPath = $ProgressPath + screenshotSha256 = $null + captureMethod = $null + expectedSemanticReadyCaptureName = $ExpectedSemanticReadyCaptureName + semanticReadyCaptureSourcePath = $null + semanticReadyCaptureSourceSha256 = $null + metrics = $null + thresholds = [ordered]@{ + minimumNonBlackRatio = $MinimumNonBlackRatio + minimumBrightRatio = $MinimumBrightRatio + minimumLuminanceDeviation = $MinimumLuminanceDeviation + minimumLuminanceRange = $MinimumLuminanceRange + } + interaction = [ordered]@{ + requested = $ClickNormalizedX -ge 0.0 -or $ClickNormalizedY -ge 0.0 + normalizedX = $ClickNormalizedX + normalizedY = $ClickNormalizedY + screenX = $null + screenY = $null + clientX = $null + clientY = $null + injectionMethod = $null + clicked = $false + expectedStartupDiagnosticsResult = $ExpectedStartupDiagnosticsResult + } + startupDiagnosticsPath = $null + startupDiagnosticsLastWriteUtc = $null + startupDiagnosticsResult = $null + runtimeDiagnosticsPath = $RuntimeDiagnosticsPath + runtimeDiagnosticsExists = $false + runtimeDiagnosticsLines = @() + processCommandLines = @() + ownedListeningTcpEndpoints = @() + traceControlListenerLines = @() + traceControlListeningEndpoints = @() + focusMethod = $null + result = 'failed' + error = $null +} + +$LaunchedProcess = $null +$WindowProcess = $null +$OwnedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]' + +try +{ + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'launch-starting' + $LaunchStartedAtUtc = [DateTime]::UtcNow + $Result.launchStartedAtUtc = $LaunchStartedAtUtc.ToString('o') + $LaunchedProcess = Start-Process ` + -FilePath $ResolvedExecutablePath ` + -ArgumentList $EffectiveLaunchArguments ` + -WorkingDirectory $ExecutableDirectory ` + -PassThru + [void]$OwnedProcessIds.Add($LaunchedProcess.Id) + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'launch-process-started' + + $Deadline = [DateTime]::UtcNow.AddSeconds([Math]::Max($StartupTimeoutSeconds, 1)) + while ([DateTime]::UtcNow -lt $Deadline) + { + Update-HyperTwistOwnedProcessIds ` + -OwnedProcessIds $OwnedProcessIds ` + -ResolvedExecutableRoot $ExecutableDirectory ` + -IgnoredProcessIds $ExistingProcessIds + $CandidateProcesses = @($OwnedProcessIds) | + Where-Object { $ExistingProcessIds -notcontains $_ } | + ForEach-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue } | + Where-Object { $null -ne $_ } + foreach ($Candidate in $CandidateProcesses) + { + [void]$OwnedProcessIds.Add($Candidate.Id) + $Candidate.Refresh() + if ($Candidate.MainWindowHandle -ne [IntPtr]::Zero) + { + $CandidateWindowClass = [HyperTwistVisualGate.NativeMethods]::ReadWindowClassName( + $Candidate.MainWindowHandle + ) + if ($CandidateWindowClass -eq 'ConsoleWindowClass') + { + continue + } + if (-not [string]::IsNullOrWhiteSpace($ExpectedWindowClassName) ` + -and $CandidateWindowClass -ne $ExpectedWindowClassName) + { + continue + } + $WindowProcess = $Candidate + break + } + } + + if ($null -ne $WindowProcess) + { + break + } + if ($LaunchedProcess.HasExited) + { + throw "HyperTwist exited with code $($LaunchedProcess.ExitCode) before creating a window." + } + + Start-Sleep -Milliseconds 500 + } + + if ($null -eq $WindowProcess) + { + throw "HyperTwist did not expose a top-level window within $StartupTimeoutSeconds seconds." + } + + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'window-detected' + Start-Sleep -Seconds ([Math]::Max($LayoutSettleSeconds, 0)) + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'initial-layout-settled' + $WindowProcess.Refresh() + if ($WindowProcess.HasExited -or $WindowProcess.MainWindowHandle -eq [IntPtr]::Zero) + { + throw 'The HyperTwist window closed before visual capture.' + } + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'initial-window-refreshed' + + $Bounds = Get-HyperTwistClientBounds -WindowHandle $WindowProcess.MainWindowHandle + if ($Bounds.width -lt 320 -or $Bounds.height -lt 200) + { + throw "HyperTwist exposed an invalid client area ($($Bounds.width)x$($Bounds.height))." + } + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'initial-bounds-measured' + $InteractionRequested = $ClickNormalizedX -ge 0.0 -or $ClickNormalizedY -ge 0.0 + $Result.focusMethod = Set-HyperTwistWindowForeground ` + -WindowHandle $WindowProcess.MainWindowHandle ` + -RequireForeground:$InteractionRequested + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'initial-window-focused' + + if ($InteractionRequested) + { + if ($ClickNormalizedX -lt 0.0 -or $ClickNormalizedX -gt 1.0 ` + -or $ClickNormalizedY -lt 0.0 -or $ClickNormalizedY -gt 1.0) + { + throw 'Provide both normalized click coordinates within the inclusive range 0.0 to 1.0.' + } + + $ClickX = [int][Math]::Round( + $Bounds.x + ([Math]::Max($Bounds.width - 1, 0) * $ClickNormalizedX) + ) + $ClickY = [int][Math]::Round( + $Bounds.y + ([Math]::Max($Bounds.height - 1, 0) * $ClickNormalizedY) + ) + $ClientClickX = [int][Math]::Round( + [Math]::Max($Bounds.width - 1, 0) * $ClickNormalizedX + ) + $ClientClickY = [int][Math]::Round( + [Math]::Max($Bounds.height - 1, 0) * $ClickNormalizedY + ) + if (-not [HyperTwistVisualGate.NativeMethods]::SetCursorPos($ClickX, $ClickY)) + { + throw 'Could not position the cursor for the requested HyperTwist interaction.' + } + Start-Sleep -Milliseconds 300 + + if ($ClickInjectionMode -eq 'PhysicalMouse') + { + [HyperTwistVisualGate.NativeMethods]::mouse_event( + 0x0002, + 0, + 0, + 0, + [UIntPtr]::Zero + ) + Start-Sleep -Milliseconds 75 + [HyperTwistVisualGate.NativeMethods]::mouse_event( + 0x0004, + 0, + 0, + 0, + [UIntPtr]::Zero + ) + $Result.interaction.injectionMethod = 'physical-mouse-event' + } + else + { + $PackedClientPoint = [IntPtr]( + (($ClientClickY -band 0xffff) -shl 16) ` + -bor ($ClientClickX -band 0xffff) + ) + [void][HyperTwistVisualGate.NativeMethods]::SendMessage( + $WindowProcess.MainWindowHandle, + 0x0200, + [IntPtr]::Zero, + $PackedClientPoint + ) + [void][HyperTwistVisualGate.NativeMethods]::SendMessage( + $WindowProcess.MainWindowHandle, + 0x0201, + [IntPtr](1), + $PackedClientPoint + ) + Start-Sleep -Milliseconds 75 + [void][HyperTwistVisualGate.NativeMethods]::SendMessage( + $WindowProcess.MainWindowHandle, + 0x0202, + [IntPtr]::Zero, + $PackedClientPoint + ) + $Result.interaction.injectionMethod = 'window-client-message' + } + $Result.interaction.screenX = $ClickX + $Result.interaction.screenY = $ClickY + $Result.interaction.clientX = $ClientClickX + $Result.interaction.clientY = $ClientClickY + $Result.interaction.clicked = $true + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'interaction-injected' + Start-Sleep -Seconds ([Math]::Max($PostClickSettleSeconds, 0)) + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'post-interaction-settled' + + $WindowProcess.Refresh() + if ($WindowProcess.HasExited -or $WindowProcess.MainWindowHandle -eq [IntPtr]::Zero) + { + throw 'The HyperTwist window closed after the requested interaction.' + } + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'post-interaction-window-refreshed' + $Bounds = Get-HyperTwistClientBounds -WindowHandle $WindowProcess.MainWindowHandle + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'post-interaction-bounds-measured' + } + # The initial focus is retained through physical interaction. Refocusing + # after OpenLevel is both redundant and unsafe because Windows can block an + # activation request while Unreal is replacing its viewport. + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'capture-window-ready' + + $SemanticReadyCaptureSourcePath = $null + if ($SemanticReadyCaptureCandidates.Count -gt 0) + { + Write-HyperTwistVisualCheckpoint ` + -Path $ProgressPath ` + -Stage 'semantic-ready-capture-waiting' + $SemanticReadyCaptureDeadline = [DateTime]::UtcNow.AddSeconds( + [Math]::Max($SemanticReadyCaptureTimeoutSeconds, 1) + ) + while ([DateTime]::UtcNow -lt $SemanticReadyCaptureDeadline) + { + $SemanticReadyCaptureSourcePath = $SemanticReadyCaptureCandidates | + Where-Object { + (Test-Path -LiteralPath $_ -PathType Leaf) ` + -and (Get-Item -LiteralPath $_).Length -gt 0 ` + -and (Get-Item -LiteralPath $_).LastWriteTimeUtc ` + -ge $LaunchStartedAtUtc.AddSeconds(-2) + } | + Select-Object -First 1 + if (-not [string]::IsNullOrWhiteSpace($SemanticReadyCaptureSourcePath)) + { + break + } + + Start-Sleep -Milliseconds 250 + } + + if ([string]::IsNullOrWhiteSpace($SemanticReadyCaptureSourcePath)) + { + throw ( + "HyperTwist did not write fresh semantic-ready frame " + + "'$ExpectedSemanticReadyCaptureName' within " + + "$SemanticReadyCaptureTimeoutSeconds seconds." + ) + } + Write-HyperTwistVisualCheckpoint ` + -Path $ProgressPath ` + -Stage 'semantic-ready-capture-detected' + } + + if (-not [string]::IsNullOrWhiteSpace($SemanticReadyCaptureSourcePath)) + { + $SemanticReadyImage = [System.Drawing.Image]::FromFile( + $SemanticReadyCaptureSourcePath + ) + try + { + $Bitmap = New-Object System.Drawing.Bitmap($SemanticReadyImage) + } + finally + { + $SemanticReadyImage.Dispose() + } + $Result.captureMethod = 'hyper-twist-semantic-ready-frame' + $Result.semanticReadyCaptureSourcePath = $SemanticReadyCaptureSourcePath + $Result.semanticReadyCaptureSourceSha256 = ( + Get-FileHash ` + -LiteralPath $SemanticReadyCaptureSourcePath ` + -Algorithm SHA256 + ).Hash.ToLowerInvariant() + } + else + { + $Bitmap = New-Object System.Drawing.Bitmap( + $Bounds.width, + $Bounds.height, + [System.Drawing.Imaging.PixelFormat]::Format32bppArgb + ) + $Result.captureMethod = 'physical-client-screen-copy' + } + + try + { + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'capture-bitmap-created' + if ([string]::IsNullOrWhiteSpace($SemanticReadyCaptureSourcePath)) + { + $Graphics = [System.Drawing.Graphics]::FromImage($Bitmap) + try + { + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'screen-copy-starting' + $Graphics.CopyFromScreen( + $Bounds.x, + $Bounds.y, + 0, + 0, + (New-Object System.Drawing.Size($Bounds.width, $Bounds.height)), + [System.Drawing.CopyPixelOperation]::SourceCopy + ) + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'screen-copy-complete' + } + finally + { + $Graphics.Dispose() + } + } + + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'bitmap-measurement-starting' + $Metrics = Measure-HyperTwistBitmap -Bitmap $Bitmap -Stride $SampleStride + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'bitmap-measurement-complete' + $Bitmap.Save($ScreenshotPath, [System.Drawing.Imaging.ImageFormat]::Png) + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'screenshot-saved' + } + finally + { + $Bitmap.Dispose() + } + + $Result.processId = $WindowProcess.Id + $Result.windowTitle = $WindowProcess.MainWindowTitle + $Result.windowClassName = [HyperTwistVisualGate.NativeMethods]::ReadWindowClassName( + $WindowProcess.MainWindowHandle + ) + if (-not [string]::IsNullOrWhiteSpace($ExpectedWindowClassName) ` + -and $Result.windowClassName -ne $ExpectedWindowClassName) + { + throw ( + "Expected window class '$ExpectedWindowClassName' but observed " + + "'$($Result.windowClassName)'." + ) + } + $Result.clientBounds = $Bounds + $Result.screenshotSha256 = (Get-FileHash -LiteralPath $ScreenshotPath -Algorithm SHA256).Hash.ToLowerInvariant() + $Result.metrics = $Metrics + + $StartupDiagnosticsCandidates = @( + (Join-Path $ExecutableDirectory 'UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log'), + (Join-Path $ExecutableDirectory 'Windows\UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log'), + (Join-Path $env:LOCALAPPDATA 'UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log') + ) + $StartupDiagnosticsPath = $StartupDiagnosticsCandidates | + Where-Object { + (Test-Path -LiteralPath $_ -PathType Leaf) ` + -and (Get-Item -LiteralPath $_).LastWriteTimeUtc -ge $LaunchStartedAtUtc.AddSeconds(-2) + } | + Select-Object -First 1 + if (-not [string]::IsNullOrWhiteSpace($StartupDiagnosticsPath)) + { + $Result.startupDiagnosticsPath = $StartupDiagnosticsPath + $Result.startupDiagnosticsLastWriteUtc = ( + Get-Item -LiteralPath $StartupDiagnosticsPath + ).LastWriteTimeUtc.ToString('o') + # Detach provider metadata before JSON serialization; decorated FileSystem + # strings otherwise expand recursively under Windows PowerShell 5.1. + $Result.startupDiagnosticsResult = [string]( + Get-Content -LiteralPath $StartupDiagnosticsPath | + ForEach-Object { $_.ToString() } | + Where-Object { $_ -like 'result=*' } | + Select-Object -Last 1 + ) + } + + if (-not [string]::IsNullOrWhiteSpace($ExpectedStartupDiagnosticsResult) ` + -and $Result.startupDiagnosticsResult -ne "result=$ExpectedStartupDiagnosticsResult") + { + throw ( + "Expected startup diagnostics result '$ExpectedStartupDiagnosticsResult' but observed " + + "'$($Result.startupDiagnosticsResult)'." + ) + } + + $Result.runtimeDiagnosticsExists = Test-Path -LiteralPath $RuntimeDiagnosticsPath -PathType Leaf + if (-not $Result.runtimeDiagnosticsExists) + { + throw 'HyperTwist did not write its direct Shipping-compatible runtime diagnostics file.' + } + $Result.runtimeDiagnosticsLines = @( + Get-Content -LiteralPath $RuntimeDiagnosticsPath | + ForEach-Object { $_.ToString() } + ) + if (-not @($Result.runtimeDiagnosticsLines | Where-Object { + $_ -like '*HyperTwist runtime module initialized.*' + }).Count) + { + throw 'HyperTwist direct diagnostics did not confirm runtime-module initialization.' + } + + Update-HyperTwistOwnedProcessIds ` + -OwnedProcessIds $OwnedProcessIds ` + -ResolvedExecutableRoot $ExecutableDirectory ` + -IgnoredProcessIds $ExistingProcessIds + $OwnedProcessIdSnapshot = [int[]]@($OwnedProcessIds) + $Result.processCommandLines = @( + foreach ($OwnedProcessId in $OwnedProcessIdSnapshot) + { + Get-CimInstance Win32_Process ` + -Filter "ProcessId = $OwnedProcessId" ` + -ErrorAction SilentlyContinue | + ForEach-Object { [string]$_.CommandLine } + } + ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + $Result.ownedListeningTcpEndpoints = @( + Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | + Where-Object { $OwnedProcessIdSnapshot -contains [int]$_.OwningProcess } | + Sort-Object OwningProcess, LocalPort | + ForEach-Object { + [pscustomobject][ordered]@{ + processId = [int]$_.OwningProcess + localAddress = [string]$_.LocalAddress + localPort = [int]$_.LocalPort + } + } + ) + $Result.traceControlListenerLines = @( + $Result.runtimeDiagnosticsLines | + Where-Object { + $_.IndexOf( + 'Control listening on port', + [System.StringComparison]::OrdinalIgnoreCase + ) -ge 0 + } + ) + $Result.traceControlListeningEndpoints = @( + $Result.ownedListeningTcpEndpoints | + Where-Object { [int]$_.localPort -eq 1985 } + ) + if ($Result.traceControlListenerLines.Count -gt 0 ` + -or $Result.traceControlListeningEndpoints.Count -gt 0) + { + throw 'HyperTwist opened a Development trace-control listener during the visual gate.' + } + + $VisualPassed = $Metrics.nonBlackRatio -ge $MinimumNonBlackRatio ` + -and $Metrics.brightRatio -ge $MinimumBrightRatio ` + -and $Metrics.luminanceDeviation -ge $MinimumLuminanceDeviation ` + -and $Metrics.luminanceRange -ge $MinimumLuminanceRange + if (-not $VisualPassed) + { + throw ( + 'HyperTwist client capture is blank or visually uniform: ' + + "nonBlack=$($Metrics.nonBlackRatio), bright=$($Metrics.brightRatio), " + + "deviation=$($Metrics.luminanceDeviation), range=$($Metrics.luminanceRange)." + ) + } + + $Result.result = 'visual-passed' + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'visual-gate-passed' +} +catch +{ + $Result.error = $_.Exception.Message + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'visual-gate-failed' +} +finally +{ + if (-not $KeepProcess) + { + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'owned-process-scan-starting' + Update-HyperTwistOwnedProcessIds ` + -OwnedProcessIds $OwnedProcessIds ` + -ResolvedExecutableRoot $ExecutableDirectory ` + -IgnoredProcessIds $ExistingProcessIds + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'owned-process-scan-complete' + foreach ($OwnedProcessId in @($OwnedProcessIds)) + { + Stop-Process -Id $OwnedProcessId -Force -ErrorAction SilentlyContinue + } + Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'owned-process-cleanup-complete' + } +} + +Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'report-write-starting' +Write-Utf8JsonFile -Path $ReportPath -Value $Result +Write-HyperTwistVisualCheckpoint -Path $ProgressPath -Stage 'report-write-complete' +$Result | ConvertTo-Json -Depth 12 +if ($Result.result -ne 'visual-passed') +{ + throw $Result.error +} diff --git a/scripts/hypertwist_author_classic_cube_training_maps.py b/scripts/hypertwist_author_classic_cube_training_maps.py index 7390fd5..fc94e8e 100644 --- a/scripts/hypertwist_author_classic_cube_training_maps.py +++ b/scripts/hypertwist_author_classic_cube_training_maps.py @@ -1,4 +1,6 @@ import os +import json +import datetime import traceback import unreal @@ -9,6 +11,7 @@ MATERIAL_ROOT = "/Game/HyperTwistTraining/Materials" CLASSIC_MAP_PATH = f"{MAP_ROOT}/L_HyperTwist_ClassicTraining" FOLLOW_ALONG_MAP_PATH = f"{MAP_ROOT}/L_HyperTwist_FollowAlongTraining" AUTHOR_TAG = "HyperTwistClassicCubeTrainingMap" +AUTHORING_MARKER_PATH = os.getenv("HYPERTWIST_CLASSIC_CUBE_AUTHORING_MARKER", "").strip() SAFE_TRANSIENT_LEVEL_PATH = "/Engine/Maps/Entry" MASTER_MATERIAL_PATH = f"{MATERIAL_ROOT}/M_HT_ClassicCubeFaceMaster" INTERNAL_MATERIAL_PATH = f"{MATERIAL_ROOT}/MI_HT_ClassicCube_Internal" @@ -67,78 +70,88 @@ def create_asset(asset_path: str, asset_class, factory): def ensure_face_master_material(): if unreal.EditorAssetLibrary.does_asset_exist(MASTER_MATERIAL_PATH): - return require_asset(MASTER_MATERIAL_PATH) + material = require_asset(MASTER_MATERIAL_PATH) + else: + material = create_asset( + MASTER_MATERIAL_PATH, + unreal.Material, + unreal.MaterialFactoryNew(), + ) + + base_color = unreal.MaterialEditingLibrary.create_material_expression( + material, + unreal.MaterialExpressionVectorParameter, + -640, + -120, + ) + base_color.set_editor_property("parameter_name", "BaseColor") + base_color.set_editor_property("default_value", unreal.LinearColor(1.0, 1.0, 1.0, 1.0)) + + glow_strength = unreal.MaterialEditingLibrary.create_material_expression( + material, + unreal.MaterialExpressionScalarParameter, + -640, + 80, + ) + glow_strength.set_editor_property("parameter_name", "GlowStrength") + glow_strength.set_editor_property("default_value", 0.0) + + face_opacity = unreal.MaterialEditingLibrary.create_material_expression( + material, + unreal.MaterialExpressionScalarParameter, + -640, + 240, + ) + face_opacity.set_editor_property("parameter_name", "FaceOpacity") + face_opacity.set_editor_property("default_value", 1.0) + + emissive = unreal.MaterialEditingLibrary.create_material_expression( + material, + unreal.MaterialExpressionMultiply, + -280, + 0, + ) + unreal.MaterialEditingLibrary.connect_material_expressions(base_color, "", emissive, "A") + unreal.MaterialEditingLibrary.connect_material_expressions(glow_strength, "", emissive, "B") + unreal.MaterialEditingLibrary.connect_material_property( + base_color, + "", + unreal.MaterialProperty.MP_BASE_COLOR, + ) + unreal.MaterialEditingLibrary.connect_material_property( + emissive, + "", + unreal.MaterialProperty.MP_EMISSIVE_COLOR, + ) + + unreal.MaterialEditingLibrary.layout_material_expressions(material) - material = create_asset( - MASTER_MATERIAL_PATH, - unreal.Material, - unreal.MaterialFactoryNew(), - ) material.set_editor_property("two_sided", False) - - base_color = unreal.MaterialEditingLibrary.create_material_expression( - material, - unreal.MaterialExpressionVectorParameter, - -640, - -120, - ) - base_color.set_editor_property("parameter_name", "BaseColor") - base_color.set_editor_property("default_value", unreal.LinearColor(1.0, 1.0, 1.0, 1.0)) - - glow_strength = unreal.MaterialEditingLibrary.create_material_expression( - material, - unreal.MaterialExpressionScalarParameter, - -640, - 80, - ) - glow_strength.set_editor_property("parameter_name", "GlowStrength") - glow_strength.set_editor_property("default_value", 0.0) - - face_opacity = unreal.MaterialEditingLibrary.create_material_expression( - material, - unreal.MaterialExpressionScalarParameter, - -640, - 240, - ) - face_opacity.set_editor_property("parameter_name", "FaceOpacity") - face_opacity.set_editor_property("default_value", 1.0) - - emissive = unreal.MaterialEditingLibrary.create_material_expression( - material, - unreal.MaterialExpressionMultiply, - -280, - 0, - ) - unreal.MaterialEditingLibrary.connect_material_expressions(base_color, "", emissive, "A") - unreal.MaterialEditingLibrary.connect_material_expressions(glow_strength, "", emissive, "B") - unreal.MaterialEditingLibrary.connect_material_property( - base_color, - "", - unreal.MaterialProperty.MP_BASE_COLOR, - ) - unreal.MaterialEditingLibrary.connect_material_property( - emissive, - "", - unreal.MaterialProperty.MP_EMISSIVE_COLOR, - ) - - unreal.MaterialEditingLibrary.layout_material_expressions(material) + # Phase 6C uses the same palette on instanced projection geometry. Without + # this authored usage flag, cooked builds silently substitute a neutral + # fallback shader even though the six material bindings remain non-null. + material.set_editor_property("used_with_instanced_static_meshes", True) unreal.MaterialEditingLibrary.recompile_material(material) if not unreal.EditorAssetLibrary.save_loaded_asset(material, False): raise RuntimeError(f"Failed to save face master material: {MASTER_MATERIAL_PATH}") - log(f"Created face master material {MASTER_MATERIAL_PATH}") + if not material.get_editor_property("used_with_instanced_static_meshes"): + raise RuntimeError( + "Face master material did not retain instanced-static-mesh usage after save." + ) + log(f"Verified face master material and instanced-mesh usage: {MASTER_MATERIAL_PATH}") return material def ensure_material_instance(asset_path: str, parent_material, color: unreal.LinearColor): if unreal.EditorAssetLibrary.does_asset_exist(asset_path): - return require_asset(asset_path) + instance = require_asset(asset_path) + else: + instance = create_asset( + asset_path, + unreal.MaterialInstanceConstant, + unreal.MaterialInstanceConstantFactoryNew(), + ) - instance = create_asset( - asset_path, - unreal.MaterialInstanceConstant, - unreal.MaterialInstanceConstantFactoryNew(), - ) unreal.MaterialEditingLibrary.set_material_instance_parent(instance, parent_material) unreal.MaterialEditingLibrary.set_material_instance_vector_parameter_value( instance, @@ -157,7 +170,7 @@ def ensure_material_instance(asset_path: str, parent_material, color: unreal.Lin ) if not unreal.EditorAssetLibrary.save_loaded_asset(instance, False): raise RuntimeError(f"Failed to save material instance: {asset_path}") - log(f"Created material instance {asset_path}") + log(f"Verified material instance {asset_path}") return instance @@ -194,16 +207,18 @@ def ensure_floor_plane() -> None: floor_actor = spawn_actor( unreal.StaticMeshActor, "HT_ClassicCubeTraining_Floor", - unreal.Vector(0.0, 0.0, -20.0), + unreal.Vector(0.0, 0.0, -24.0), unreal.Rotator(0.0, 0.0, 0.0), ) static_mesh_component = floor_actor.static_mesh_component static_mesh_component.set_editor_property( "static_mesh", - require_asset("/Engine/BasicShapes/Plane.Plane"), + require_asset("/Engine/BasicShapes/Cube.Cube"), ) static_mesh_component.set_editor_property("mobility", unreal.ComponentMobility.STATIC) - floor_actor.set_actor_scale3d(unreal.Vector(14.0, 14.0, 1.0)) + # A thin volumetric floor avoids the zero-extent bounds path that can crash + # UE 5.7 editor automation when a Plane is authored under a headless RHI. + floor_actor.set_actor_scale3d(unreal.Vector(14.0, 14.0, 0.08)) def ensure_directional_light() -> None: @@ -233,6 +248,15 @@ def ensure_skylight() -> None: skylight_component.set_editor_property("intensity", 1.2) +def ensure_sky_atmosphere() -> None: + spawn_actor( + unreal.SkyAtmosphere, + "HT_ClassicCubeTraining_Atmosphere", + unreal.Vector(0.0, 0.0, 0.0), + unreal.Rotator(0.0, 0.0, 0.0), + ) + + def ensure_reflection_capture() -> None: reflection_capture = spawn_actor( unreal.SphereReflectionCapture, @@ -276,6 +300,59 @@ def save_current_level_or_raise(level_subsystem, label: str) -> None: log(f"Saved current level after {label}") +def validate_authored_actor_family(): + actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + if actor_subsystem is None: + raise RuntimeError("EditorActorSubsystem was not available for authored-map validation.") + + expected_labels = { + "HT_ClassicCubeTraining_Floor", + "HT_ClassicCubeTraining_Sun", + "HT_ClassicCubeTraining_Sky", + "HT_ClassicCubeTraining_Atmosphere", + "HT_ClassicCubeTraining_Reflection", + "HT_ClassicCubeTraining_PlayerStart", + "HT_ClassicCubeTraining_Cube", + } + actor_labels = { + actor.get_actor_label() + for actor in actor_subsystem.get_all_level_actors() + if actor is not None + } + missing_labels = sorted(expected_labels - actor_labels) + if missing_labels: + raise RuntimeError( + "Authored map is missing required actors: " + ", ".join(missing_labels) + ) + return sorted(expected_labels) + + +def write_completion_marker(target: str, map_asset_path: str, actor_labels) -> None: + if not AUTHORING_MARKER_PATH: + raise RuntimeError( + "HYPERTWIST_CLASSIC_CUBE_AUTHORING_MARKER must identify the transactional completion marker." + ) + + marker_path = os.path.abspath(AUTHORING_MARKER_PATH) + os.makedirs(os.path.dirname(marker_path), exist_ok=True) + marker_payload = { + "schemaVersion": 1, + "stage": "complete", + "target": target, + "mapAssetPath": map_asset_path, + "actorLabels": actor_labels, + "completedAtUtc": datetime.datetime.now(datetime.timezone.utc).isoformat(), + } + temporary_path = marker_path + ".tmp" + with open(temporary_path, "w", encoding="utf-8", newline="\n") as marker_file: + json.dump(marker_payload, marker_file, indent=2, sort_keys=True) + marker_file.write("\n") + marker_file.flush() + os.fsync(marker_file.fileno()) + os.replace(temporary_path, marker_path) + log(f"Wrote transactional completion marker {marker_path}") + + def configure_world_settings(game_mode_class_path: str) -> None: editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem) if editor_subsystem is None: @@ -311,7 +388,7 @@ def recreate_level(level_subsystem, map_asset_path: str) -> None: log(f"Created new level {map_asset_path}") -def author_map(map_asset_path: str, game_mode_class_path: str) -> None: +def author_map(map_asset_path: str, game_mode_class_path: str): level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem) if level_subsystem is None: raise RuntimeError("LevelEditorSubsystem was not available.") @@ -326,13 +403,17 @@ def author_map(map_asset_path: str, game_mode_class_path: str) -> None: save_current_level_or_raise(level_subsystem, "directional light placement") ensure_skylight() save_current_level_or_raise(level_subsystem, "skylight placement") + ensure_sky_atmosphere() + save_current_level_or_raise(level_subsystem, "sky-atmosphere placement") ensure_reflection_capture() save_current_level_or_raise(level_subsystem, "reflection capture placement") ensure_player_start() save_current_level_or_raise(level_subsystem, "player start placement") ensure_cube_actor() save_current_level_or_raise(level_subsystem, "cube placement") + actor_labels = validate_authored_actor_family() log(f"Finished authoring {map_asset_path}") + return actor_labels def main() -> None: @@ -371,8 +452,14 @@ def main() -> None: f"Unsupported HYPERTWIST_CLASSIC_CUBE_MAP_KIND value: {requested_map_kind}" ) + if len(targets) != 1: + raise RuntimeError( + "Transactional map authoring requires one target per UnrealEditor process." + ) + for map_asset_path, game_mode_class_path in targets: - author_map(map_asset_path, game_mode_class_path) + actor_labels = author_map(map_asset_path, game_mode_class_path) + write_completion_marker(requested_map_kind, map_asset_path, actor_labels) log("Classic cube authored maps are ready.") diff --git a/scripts/hypertwist_author_higher_dimensional_training_maps.py b/scripts/hypertwist_author_higher_dimensional_training_maps.py index 4c4eeaf..9702811 100644 --- a/scripts/hypertwist_author_higher_dimensional_training_maps.py +++ b/scripts/hypertwist_author_higher_dimensional_training_maps.py @@ -12,7 +12,9 @@ SAFE_TRANSIENT_LEVEL_PATH = "/Engine/Maps/Entry" AUTHOR_TAG = "HyperTwistHigherDimensionalTrainingShell" LEGACY_CLASSIC_AUTHOR_TAG = "HyperTwistClassicCubeTrainingMap" TRAINING_SHELL_CLASS_PATH = "/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingShellActor" -COACH_DASHBOARD_GAME_MODE_PATH = "/Script/UnrealHyperTwist.HyperTwistCoachDashboardGameMode" +HIGHER_DIMENSIONAL_GAME_MODE_PATH = ( + "/Script/UnrealHyperTwist.HyperTwistHigherDimensionalTrainingGameMode" +) ENABLE_HEADLESS_TRAINING_SHELL_ACTOR = ( os.getenv("HYPERTWIST_ENABLE_TRAINING_SHELL_ACTOR", "").strip().lower() in {"1", "true", "yes"} @@ -30,6 +32,12 @@ MANIFEST_RELATIVE_PATH = os.path.join( "phase6c_dedicated_family_map_manifest.json", ) MANIFEST_ABSOLUTE_PATH = os.path.join(PROJECT_ROOT, MANIFEST_RELATIVE_PATH) +AUTHORING_RECEIPT_DIRECTORY = os.path.join( + PROJECT_ROOT, + "UnrealHyperTwist", + "Saved", + "HyperTwistMapAuthoring", +) MAP_CONFIGS = ( { @@ -61,10 +69,6 @@ MAP_CONFIGS = ( "projection_profile_id": "magic120cell-4d-projection-distance-v1", "primary_persistence_boundary_id": "magic120cell-persistence-boundary", "player_start_location": unreal.Vector(-560.0, 0.0, 190.0), - "sun_location": unreal.Vector(-340.0, 120.0, 470.0), - "sun_rotation": unreal.Rotator(-34.0, -22.0, 0.0), - "sun_intensity": 7.6, - "floor_scale": unreal.Vector(18.0, 18.0, 1.0), "shell_location": unreal.Vector(0.0, 0.0, 0.0), "shell_rotation": unreal.Rotator(0.0, 22.0, 0.0), "training_shell_tags": [ @@ -104,10 +108,6 @@ MAP_CONFIGS = ( "projection_profile_id": "magiccube5d-5d-projection-distance-v1", "primary_persistence_boundary_id": "magiccube5d-persistence-boundary", "player_start_location": unreal.Vector(-620.0, -120.0, 200.0), - "sun_location": unreal.Vector(-260.0, -200.0, 500.0), - "sun_rotation": unreal.Rotator(-38.0, 34.0, 0.0), - "sun_intensity": 8.8, - "floor_scale": unreal.Vector(20.0, 20.0, 1.0), "shell_location": unreal.Vector(0.0, 0.0, 0.0), "shell_rotation": unreal.Rotator(0.0, -18.0, 0.0), "training_shell_tags": [ @@ -125,13 +125,6 @@ def log(message: str) -> None: unreal.log(f"[HyperTwistHigherDimensionalMapAuthoring] {message}") -def require_asset(asset_path: str): - asset = unreal.EditorAssetLibrary.load_asset(asset_path) - if asset is None: - raise RuntimeError(f"Required asset was not found: {asset_path}") - return asset - - def require_class(class_path: str): loaded_class = unreal.load_class(None, class_path) if loaded_class is None: @@ -277,62 +270,6 @@ def configure_world_settings(game_mode_class_path: str) -> None: world_settings.set_editor_property("default_game_mode", require_class(game_mode_class_path)) -def ensure_floor_plane(config) -> None: - floor_actor = spawn_actor( - unreal.StaticMeshActor, - f"HT_{config['family_key']}_Floor", - unreal.Vector(0.0, 0.0, -20.0), - unreal.Rotator(0.0, 0.0, 0.0), - ) - static_mesh_component = floor_actor.static_mesh_component - static_mesh_component.set_editor_property( - "static_mesh", - require_asset("/Engine/BasicShapes/Plane.Plane"), - ) - static_mesh_component.set_editor_property("mobility", unreal.ComponentMobility.STATIC) - floor_actor.set_actor_scale3d(config["floor_scale"]) - - -def ensure_directional_light(config) -> None: - directional_light = spawn_actor( - unreal.DirectionalLight, - f"HT_{config['family_key']}_Sun", - config["sun_location"], - config["sun_rotation"], - ) - light_component = directional_light.get_component_by_class(unreal.DirectionalLightComponent) - if light_component is not None: - light_component.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE) - light_component.set_editor_property("intensity", config["sun_intensity"]) - - -def ensure_skylight(config) -> None: - skylight = spawn_actor( - unreal.SkyLight, - f"HT_{config['family_key']}_Sky", - unreal.Vector(0.0, 0.0, 260.0), - unreal.Rotator(0.0, 0.0, 0.0), - ) - skylight_component = skylight.get_component_by_class(unreal.SkyLightComponent) - if skylight_component is not None: - skylight_component.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE) - skylight_component.set_editor_property("real_time_capture", True) - skylight_component.set_editor_property( - "intensity", - 1.35 if config["family_key"] == "magic120cell" else 1.55, - ) - - -def ensure_reflection_capture(config) -> None: - reflection_capture = spawn_actor( - unreal.SphereReflectionCapture, - f"HT_{config['family_key']}_Reflection", - unreal.Vector(0.0, 0.0, 150.0), - unreal.Rotator(0.0, 0.0, 0.0), - ) - reflection_capture.set_actor_scale3d(unreal.Vector(14.0, 14.0, 14.0)) - - def ensure_player_start(config) -> None: player_start = spawn_actor( unreal.PlayerStart, @@ -446,6 +383,70 @@ def compute_md5(file_path: str) -> str: return digest.hexdigest() +def get_target_receipt_absolute_path(config) -> str: + return os.path.join( + AUTHORING_RECEIPT_DIRECTORY, + f"phase6c_{config['map_kind']}_complete.json", + ) + + +def validate_authored_map(config): + actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem) + if actor_subsystem is None or editor_subsystem is None: + raise RuntimeError("Editor subsystems were unavailable during authored-map validation.") + + actor_labels = { + actor.get_actor_label() + for actor in actor_subsystem.get_all_level_actors() + if actor is not None + } + expected_actor_labels = {f"HT_{config['family_key']}_DedicatedSurfaceAnchor"} + missing_actor_labels = sorted(expected_actor_labels - actor_labels) + if missing_actor_labels: + raise RuntimeError( + "Authored map is missing required presentation actors: " + + ", ".join(missing_actor_labels) + ) + + world = editor_subsystem.get_editor_world() + world_settings = world.get_world_settings() if world is not None else None + game_mode_class = ( + world_settings.get_editor_property("default_game_mode") + if world_settings is not None + else None + ) + if game_mode_class is None or game_mode_class.get_name() != "HyperTwistHigherDimensionalTrainingGameMode": + raise RuntimeError("Authored map does not own the dedicated higher-dimensional game mode.") + + return sorted(expected_actor_labels) + + +def write_target_completion_receipt(config, actor_labels) -> None: + map_file_absolute_path = resolve_map_file_absolute_path(config) + if not os.path.exists(map_file_absolute_path): + raise RuntimeError( + f"Cannot write completion receipt because the authored map is missing: {map_file_absolute_path}" + ) + + os.makedirs(AUTHORING_RECEIPT_DIRECTORY, exist_ok=True) + receipt_path = get_target_receipt_absolute_path(config) + receipt = { + "schemaVersion": "hypertwist/phase6c-map-authoring-receipt/v1", + "mapKind": config["map_kind"], + "mapAssetPath": config["map_asset_path"], + "mapHashMd5": compute_md5(map_file_absolute_path), + "gameModeClassPath": HIGHER_DIMENSIONAL_GAME_MODE_PATH, + "presentationEnvironmentOwnership": "runtime-game-mode-and-shell-components", + "validatedActorLabels": actor_labels, + "authoringComplete": True, + } + with open(receipt_path, "w", encoding="utf-8") as handle: + json.dump(receipt, handle, indent=2) + handle.write("\n") + log(f"Wrote hash-bound completion receipt to {receipt_path}") + + def ensure_manifest_directory() -> None: os.makedirs(os.path.dirname(MANIFEST_ABSOLUTE_PATH), exist_ok=True) @@ -478,7 +479,12 @@ def build_manifest_entries(): "primaryPersistenceBoundaryId": config["primary_persistence_boundary_id"], "authorTag": AUTHOR_TAG, "authoringManifestRelativePath": MANIFEST_RELATIVE_PATH.replace("\\", "/"), - "authoredViaGameModeClassPath": COACH_DASHBOARD_GAME_MODE_PATH, + "authoredViaGameModeClassPath": HIGHER_DIMENSIONAL_GAME_MODE_PATH, + "runtimePresentationClassPath": TRAINING_SHELL_CLASS_PATH, + "presentationEnvironmentOwnership": "runtime-game-mode-and-shell-components", + "canonicalRenderableElementCount": ( + 120 if config["family_key"] == "magic120cell" else 242 + ), "trainingShellTags": config["training_shell_tags"], } ) @@ -497,10 +503,10 @@ def write_manifest() -> None: ) manifest = { "manifestId": "phase6c/dedicated-family-training-map-authoring", - "manifestVersion": "2026.06.18", + "manifestVersion": "2026.07.19", "authorTag": AUTHOR_TAG, "authoringScriptRelativePath": "scripts/hypertwist_author_higher_dimensional_training_maps.py", - "authoredThroughGameModeClassPath": COACH_DASHBOARD_GAME_MODE_PATH, + "authoredThroughGameModeClassPath": HIGHER_DIMENSIONAL_GAME_MODE_PATH, "classicReferenceMapHashMd5": ( compute_md5(classic_map_absolute_path) if os.path.exists(classic_map_absolute_path) @@ -522,26 +528,34 @@ def author_map(config) -> None: log(f"Starting authored rebuild for {config['map_asset_path']}") recreate_level(level_subsystem, config) log("Created clean target level") - configure_world_settings(COACH_DASHBOARD_GAME_MODE_PATH) - log("Configured dedicated training-shell game mode") + save_current_level_or_raise(level_subsystem, "clean level recreation") + configure_world_settings(HIGHER_DIMENSIONAL_GAME_MODE_PATH) + log("Configured dedicated higher-dimensional runtime game mode") save_current_level_or_raise( level_subsystem, f"{config['family_key']} dedicated training-shell game mode", ) + ensure_player_start(config) + save_current_level_or_raise(level_subsystem, "dedicated surface-anchor placement") + log( + "Placed dedicated surface anchor; floor, lighting, and projection presentation " + "are self-healing runtime-owned surfaces" + ) if ENABLE_HEADLESS_TRAINING_SHELL_ACTOR: ensure_training_shell_actor(config) + save_current_level_or_raise(level_subsystem, "dedicated training-shell actor placement") log("Placed dedicated training shell actor") else: log( - "Skipped dedicated training shell actor on the headless authoring lane " - "because custom actor placement still crashes UnrealEditor-Cmd here" + "Skipped dedicated training shell actor on the headless authoring lane; " + "the dedicated game mode owns deterministic runtime spawning" ) - ensure_player_start(config) - log("Placed dedicated surface anchor") save_current_level_or_raise( level_subsystem, - f"{config['family_key']} dedicated shell surfaces", + f"{config['family_key']} final dedicated shell surfaces", ) + validated_actor_labels = validate_authored_map(config) + write_target_completion_receipt(config, validated_actor_labels) log(f"Finished authoring {config['map_asset_path']}") diff --git a/scripts/run-hypertwist-remote-windows-file-pull.sh b/scripts/run-hypertwist-remote-windows-file-pull.sh index 73fb280..6a1e28f 100644 --- a/scripts/run-hypertwist-remote-windows-file-pull.sh +++ b/scripts/run-hypertwist-remote-windows-file-pull.sh @@ -75,23 +75,17 @@ ps_single_quote() { } write_base64_to_file() { - local base64_payload="$1" - local destination_path="$2" + local destination_path="$1" - python3 - <<'PY' "$base64_payload" "$destination_path" + python3 -c ' import base64 import pathlib import sys -payload = sys.argv[1] -destination = pathlib.Path(sys.argv[2]) +destination = pathlib.Path(sys.argv[1]) destination.parent.mkdir(parents=True, exist_ok=True) - -if payload: - destination.write_bytes(base64.b64decode(payload.encode("ascii"))) -else: - destination.write_bytes(b"") -PY +destination.write_bytes(base64.b64decode(sys.stdin.buffer.read())) +' "$destination_path" } for mapping in "${maps[@]}"; do @@ -149,7 +143,7 @@ EOF | tr -d '\n' )" - write_base64_to_file "$base64_payload" "$local_destination" + printf '%s' "$base64_payload" | write_base64_to_file "$local_destination" local_hash="$(sha256sum "$local_destination" | awk '{print tolower($1)}')" if [[ "$local_hash" != "$remote_hash" ]]; then diff --git a/scripts/run-hypertwist-remote-windows-file-sync.sh b/scripts/run-hypertwist-remote-windows-file-sync.sh index afd8ca5..f5dd718 100644 --- a/scripts/run-hypertwist-remote-windows-file-sync.sh +++ b/scripts/run-hypertwist-remote-windows-file-sync.sh @@ -36,6 +36,11 @@ if ! command -v sha256sum >/dev/null 2>&1; then exit 1 fi +if ! command -v scp >/dev/null 2>&1; then + echo "scp is required but was not found on PATH." >&2 + exit 1 +fi + if ! command -v python3 >/dev/null 2>&1; then echo "python3 is required but was not found on PATH." >&2 exit 1 @@ -96,30 +101,30 @@ sys.stdout.write(base64.b64encode(script.encode("utf-16le")).decode("ascii")) PY } -stream_file_as_base64() { - local file_path="$1" - - python3 - <<'PY' "$file_path" -import base64 -import pathlib -import sys - -file_path = pathlib.Path(sys.argv[1]) -sys.stdout.write(base64.b64encode(file_path.read_bytes()).decode("ascii")) -PY +ps_single_quote() { + printf "'%s'" "${1//\'/\'\'}" } -measure_base64_length() { - local file_path="$1" +ssh_base=( + sshpass -p "${HYPERTWIST_REMOTE_WINDOWS_PASSWORD}" + ssh + -o StrictHostKeyChecking=no + -o PreferredAuthentications=password + -o PubkeyAuthentication=no + -p "${remote_port}" + -l "${remote_user}" + "${remote_host}" +) - python3 - <<'PY' "$file_path" -import pathlib -import sys - -size = pathlib.Path(sys.argv[1]).stat().st_size -sys.stdout.write(str(((size + 2) // 3) * 4)) -PY -} +scp_base=( + sshpass -p "${HYPERTWIST_REMOTE_WINDOWS_PASSWORD}" + scp + -q + -o StrictHostKeyChecking=no + -o PreferredAuthentications=password + -o PubkeyAuthentication=no + -P "${remote_port}" +) for relative_path in "${files[@]}"; do if [[ "$relative_path" = /* ]]; then @@ -135,59 +140,57 @@ for relative_path in "${files[@]}"; do windows_relative_path="${relative_path//\//\\}" windows_dest_path="${remote_root}\\${windows_relative_path}" local_hash="$(sha256sum "$relative_path" | awk '{print tolower($1)}')" - expected_base64_length="$(measure_base64_length "$relative_path")" + windows_incoming_path="${windows_dest_path}.hypertwist-incoming-${local_hash:0:12}" if [[ "$dry_run" == "true" ]]; then printf '%s -> %s (%s)\n' "$relative_path" "$windows_dest_path" "$local_hash" continue fi - ssh_base=( - sshpass -p "${HYPERTWIST_REMOTE_WINDOWS_PASSWORD}" - ssh - -o StrictHostKeyChecking=no - -o PreferredAuthentications=password - -o PubkeyAuthentication=no - -p "${remote_port}" - -l "${remote_user}" - "${remote_host}" - ) - - remote_write_script="$( + remote_prepare_script="$( cat </dev/null + + incoming_scp_path="${windows_incoming_path//\\//}" + "${scp_base[@]}" \ + "$relative_path" \ + "${remote_user}@${remote_host}:${incoming_scp_path}" + + remote_finalize_script="$( + cat <