Harden classic cube validation and package reporting

This commit is contained in:
axiomlogicnexus 2026-06-13 04:18:05 +00:00
parent 10b66a37c3
commit 56cd8e7802
14 changed files with 649 additions and 53 deletions

View file

@ -2,11 +2,14 @@
#include "HAL/FileManager.h"
#include "HyperTwistBootstrap/HyperTwistContractLibrary.h"
#include "HyperTwistReplay/HyperTwistReplayLibrary.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
namespace HyperTwistReplayPersistenceLibraryInternal
{
const TCHAR* ReplayPacketVersion = TEXT("ht-replay/v1");
FString SanitizePathToken(const FString& Value)
{
FString Sanitized = Value;
@ -33,6 +36,59 @@ namespace HyperTwistReplayPersistenceLibraryInternal
{
return FPaths::ConvertRelativePathToFull(Path);
}
FString GetEventTypeToken(const EHyperTwistReplayEventType EventType)
{
switch (EventType)
{
case EHyperTwistReplayEventType::InspectionStart:
return TEXT("inspection_start");
case EHyperTwistReplayEventType::InspectionEnd:
return TEXT("inspection_end");
case EHyperTwistReplayEventType::TimerStart:
return TEXT("timer_start");
case EHyperTwistReplayEventType::Move:
return TEXT("move");
case EHyperTwistReplayEventType::Rotation:
return TEXT("rotation");
case EHyperTwistReplayEventType::StateSnapshot:
return TEXT("state_snapshot");
case EHyperTwistReplayEventType::PhaseMark:
return TEXT("phase_mark");
case EHyperTwistReplayEventType::DeviceSync:
return TEXT("device_sync");
case EHyperTwistReplayEventType::RecognitionPreview:
return TEXT("recognition_preview");
case EHyperTwistReplayEventType::RecognitionScan:
return TEXT("recognition_scan");
case EHyperTwistReplayEventType::RecognitionCommit:
return TEXT("recognition_commit");
case EHyperTwistReplayEventType::RecognitionCorrection:
return TEXT("recognition_correction");
case EHyperTwistReplayEventType::Pause:
return TEXT("pause");
case EHyperTwistReplayEventType::Resume:
return TEXT("resume");
case EHyperTwistReplayEventType::Annotation:
return TEXT("annotation");
case EHyperTwistReplayEventType::SolveEnd:
return TEXT("solve_end");
default:
return TEXT("event");
}
}
FString BuildFallbackEventId(
const FHyperTwistReplayEvent& ReplayEvent,
const int32 Sequence
)
{
return FString::Printf(
TEXT("%s_%03d"),
*GetEventTypeToken(ReplayEvent.EventType),
FMath::Max(Sequence, 1)
);
}
}
FString UHyperTwistReplayPersistenceLibrary::GetDefaultReplayDirectory()
@ -40,6 +96,65 @@ FString UHyperTwistReplayPersistenceLibrary::GetDefaultReplayDirectory()
return FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("HyperTwist"), TEXT("Replays"));
}
FHyperTwistReplayPacket UHyperTwistReplayPersistenceLibrary::NormalizeReplayPacket(
const FHyperTwistReplayPacket& ReplayPacket
)
{
FHyperTwistReplayPacket NormalizedPacket = ReplayPacket;
if (NormalizedPacket.PacketVersion.IsEmpty())
{
NormalizedPacket.PacketVersion =
HyperTwistReplayPersistenceLibraryInternal::ReplayPacketVersion;
}
if (NormalizedPacket.ReplayId.IsEmpty() && !NormalizedPacket.SessionId.IsEmpty())
{
NormalizedPacket.ReplayId = NormalizedPacket.SessionId;
}
if (NormalizedPacket.SessionId.IsEmpty() && !NormalizedPacket.ReplayId.IsEmpty())
{
NormalizedPacket.SessionId = NormalizedPacket.ReplayId;
}
int32 PreviousSequence = 0;
int32 PreviousTimeMs = 0;
for (FHyperTwistReplayEvent& ReplayEvent : NormalizedPacket.Events)
{
if (ReplayEvent.Sequence <= PreviousSequence)
{
ReplayEvent.Sequence = PreviousSequence + 1;
}
if (ReplayEvent.TimeMs < PreviousTimeMs)
{
ReplayEvent.TimeMs = PreviousTimeMs;
}
if (ReplayEvent.TimeMs < 0)
{
ReplayEvent.TimeMs = 0;
}
if (ReplayEvent.EventId.IsEmpty())
{
ReplayEvent.EventId =
HyperTwistReplayPersistenceLibraryInternal::BuildFallbackEventId(
ReplayEvent,
ReplayEvent.Sequence
);
}
PreviousSequence = ReplayEvent.Sequence;
PreviousTimeMs = ReplayEvent.TimeMs;
}
NormalizedPacket.DerivedSummary = NormalizedPacket.Events.Num() > 0
? UHyperTwistReplayLibrary::DeriveReplaySummary(NormalizedPacket)
: FHyperTwistDerivedReplaySummary();
return NormalizedPacket;
}
FString UHyperTwistReplayPersistenceLibrary::GetDefaultReplayPath(
const FHyperTwistReplayPacket& ReplayPacket
)
@ -80,13 +195,15 @@ bool UHyperTwistReplayPersistenceLibrary::SaveReplayPacketToFile(
FString& OutResolvedPath
)
{
OutResolvedPath = ResolveReplayPacketPath(ReplayPacket, ReplayPath);
if (!ReplayPacket.IsStructurallyValid() || OutResolvedPath.IsEmpty())
const FHyperTwistReplayPacket NormalizedPacket = NormalizeReplayPacket(ReplayPacket);
OutResolvedPath = ResolveReplayPacketPath(NormalizedPacket, ReplayPath);
if (!NormalizedPacket.IsStructurallyValid() || OutResolvedPath.IsEmpty())
{
return false;
}
const FString Json = UHyperTwistContractLibrary::SerializeReplayPacketToJson(ReplayPacket);
const FString Json =
UHyperTwistContractLibrary::SerializeReplayPacketToJson(NormalizedPacket);
if (Json.IsEmpty())
{
return false;
@ -121,6 +238,12 @@ bool UHyperTwistReplayPersistenceLibrary::LoadReplayPacketFromFile(
return false;
}
return UHyperTwistContractLibrary::DeserializeReplayPacketFromJson(Json, OutReplayPacket)
&& OutReplayPacket.IsStructurallyValid();
FHyperTwistReplayPacket LoadedPacket;
if (!UHyperTwistContractLibrary::DeserializeReplayPacketFromJson(Json, LoadedPacket))
{
return false;
}
OutReplayPacket = NormalizeReplayPacket(LoadedPacket);
return OutReplayPacket.IsStructurallyValid();
}

View file

@ -891,25 +891,12 @@ void AHyperTwistClassicCubeGameMode::RefreshLocalLeaderboardLine()
CurrentScrambleLength = ActiveReplayMetadata.ScrambleLength;
}
FHyperTwistClassicCubeLeaderboardEntry BestEntry;
if (UHyperTwistClassicCubeLeaderboardLibrary::FindBestEntry(
LeaderboardLineOverride =
UHyperTwistClassicCubeLeaderboardLibrary::BuildLeaderboardStatusLine(
LocalLeaderboardState,
ResolveActivePuzzleId(),
CurrentScrambleLength,
BestEntry))
{
LeaderboardLineOverride = FString::Printf(
TEXT("leaderboard: best %s for %d-move scramble"),
*HyperTwistClassicCubeGameModeInternal::FormatMilliseconds(BestEntry.FinalTimeMs),
BestEntry.ScrambleLength
CurrentScrambleLength
);
return;
}
LeaderboardLineOverride = FString::Printf(
TEXT("leaderboard: no local best yet for %d-move scramble"),
CurrentScrambleLength
);
}
void AHyperTwistClassicCubeGameMode::RecordSolvedAttemptToLocalLeaderboard()

View file

@ -33,6 +33,15 @@ namespace HyperTwistClassicCubeLeaderboardLibraryInternal
return FString::Printf(TEXT("%s|%d"), *PuzzleId, ScrambleLength);
}
FString FormatMilliseconds(const int32 Milliseconds)
{
const int32 SafeMilliseconds = FMath::Max(Milliseconds, 0);
const int32 Minutes = SafeMilliseconds / 60000;
const int32 Seconds = (SafeMilliseconds / 1000) % 60;
const int32 MillisRemainder = SafeMilliseconds % 1000;
return FString::Printf(TEXT("%02d:%02d.%03d"), Minutes, Seconds, MillisRemainder);
}
bool ShouldPreferEntry(
const FHyperTwistClassicCubeLeaderboardEntry& Candidate,
const FHyperTwistClassicCubeLeaderboardEntry& Existing
@ -125,6 +134,13 @@ FString UHyperTwistClassicCubeLeaderboardLibrary::GetDefaultLeaderboardPath()
);
}
FString UHyperTwistClassicCubeLeaderboardLibrary::FormatBestTimeMs(const int32 FinalTimeMs)
{
return HyperTwistClassicCubeLeaderboardLibraryInternal::FormatMilliseconds(
FinalTimeMs
);
}
FString UHyperTwistClassicCubeLeaderboardLibrary::ResolveLeaderboardPath(
const FString& LeaderboardPath
)
@ -326,3 +342,27 @@ bool UHyperTwistClassicCubeLeaderboardLibrary::FindBestEntry(
return bFoundBestEntry && OutEntry.IsStructurallyValid();
}
FString UHyperTwistClassicCubeLeaderboardLibrary::BuildLeaderboardStatusLine(
const FHyperTwistClassicCubeLeaderboardState& LeaderboardState,
const FString& PuzzleId,
const int32 ScrambleLength
)
{
const int32 SafeScrambleLength = FMath::Max(ScrambleLength, 0);
FHyperTwistClassicCubeLeaderboardEntry BestEntry;
if (!PuzzleId.IsEmpty()
&& FindBestEntry(LeaderboardState, PuzzleId, SafeScrambleLength, BestEntry))
{
return FString::Printf(
TEXT("leaderboard: best %s for %d-move scramble"),
*FormatBestTimeMs(BestEntry.FinalTimeMs),
BestEntry.ScrambleLength
);
}
return FString::Printf(
TEXT("leaderboard: no local best yet for %d-move scramble"),
SafeScrambleLength
);
}

View file

@ -7,6 +7,7 @@
#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h"
#include "HyperTwistReplay/HyperTwistReplayLibrary.h"
#include "HyperTwistReplay/HyperTwistReplayPersistenceLibrary.h"
#include "HyperTwistSimulation/HyperTwistViewerLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingClassicCubingLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingSpatialLibrary.h"
@ -617,7 +618,9 @@ void UHyperTwistPuzzleViewerComponent::LoadMoveList(const TArray<FHyperTwistView
bool UHyperTwistPuzzleViewerComponent::LoadReplayPacket(const FHyperTwistReplayPacket& InReplayPacket)
{
if (!InReplayPacket.IsStructurallyValid())
const FHyperTwistReplayPacket NormalizedPacket =
UHyperTwistReplayPersistenceLibrary::NormalizeReplayPacket(InReplayPacket);
if (!NormalizedPacket.IsStructurallyValid())
{
return false;
}
@ -626,20 +629,20 @@ bool UHyperTwistPuzzleViewerComponent::LoadReplayPacket(const FHyperTwistReplayP
const FHyperTwistViewerPositionSnapshot PreviousPositionSnapshot = PositionSnapshot;
const FHyperTwistViewerDirectionState PreviousDirectionState = DirectionState;
ReplayPacket = InReplayPacket;
ReplaySummary = UHyperTwistReplayLibrary::DeriveReplaySummary(InReplayPacket);
ReplayPacket = NormalizedPacket;
ReplaySummary = ReplayPacket.DerivedSummary;
bReplayPacketLoaded = true;
MoveList = HyperTwistPuzzleViewerComponentInternal::BuildReplayMoveList(InReplayPacket);
MoveList = HyperTwistPuzzleViewerComponentInternal::BuildReplayMoveList(ReplayPacket);
HostBootstrapState = FHyperTwistViewerEmbeddedHostBootstrapState();
VisualizationShellState = FHyperTwistViewerVisualizationShellState();
FHyperTwistSimulationSceneContext ReplaySceneContext;
if (HyperTwistPuzzleViewerComponentInternal::TryBuildSceneContextFromReplay(InReplayPacket, ReplaySceneContext))
if (HyperTwistPuzzleViewerComponentInternal::TryBuildSceneContextFromReplay(ReplayPacket, ReplaySceneContext))
{
SceneContext = ReplaySceneContext;
}
else if (!SceneContext.IsStructurallyValid()
|| SceneContext.PuzzleState.Definition.PuzzleId != InReplayPacket.PuzzleDefinition.PuzzleId)
|| SceneContext.PuzzleState.Definition.PuzzleId != ReplayPacket.PuzzleDefinition.PuzzleId)
{
SceneContext = FHyperTwistSimulationSceneContext();
}

View file

@ -14,6 +14,11 @@ public:
UFUNCTION(BlueprintPure, Category = "HyperTwist|Replay|Persistence")
static FString GetDefaultReplayDirectory();
UFUNCTION(BlueprintPure, Category = "HyperTwist|Replay|Persistence")
static FHyperTwistReplayPacket NormalizeReplayPacket(
const FHyperTwistReplayPacket& ReplayPacket
);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Replay|Persistence")
static FString GetDefaultReplayPath(const FHyperTwistReplayPacket& ReplayPacket);

View file

@ -78,6 +78,9 @@ public:
UFUNCTION(BlueprintPure, Category = "HyperTwist|ClassicCube|Leaderboard")
static FString GetDefaultLeaderboardPath();
UFUNCTION(BlueprintPure, Category = "HyperTwist|ClassicCube|Leaderboard")
static FString FormatBestTimeMs(int32 FinalTimeMs);
UFUNCTION(BlueprintPure, Category = "HyperTwist|ClassicCube|Leaderboard")
static FString ResolveLeaderboardPath(const FString& LeaderboardPath);
@ -113,4 +116,11 @@ public:
int32 ScrambleLength,
FHyperTwistClassicCubeLeaderboardEntry& OutEntry
);
UFUNCTION(BlueprintPure, Category = "HyperTwist|ClassicCube|Leaderboard")
static FString BuildLeaderboardStatusLine(
const FHyperTwistClassicCubeLeaderboardState& LeaderboardState,
const FString& PuzzleId,
int32 ScrambleLength
);
};

View file

@ -14,6 +14,7 @@
#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h"
#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h"
#include "JsonObjectConverter.h"
#include "Misc/FileHelper.h"
#include "Misc/Guid.h"
#include "Misc/Paths.h"
@ -434,6 +435,113 @@ bool FHyperTwistClassicCubeReplayViewerLoadTest::RunTest(const FString& Paramete
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistClassicCubeReplayPersistenceNormalizationTest,
"HyperTwist.Integration.ClassicCube.ReplayPersistenceNormalization",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)
bool FHyperTwistClassicCubeReplayPersistenceNormalizationTest::RunTest(const FString& Parameters)
{
FHyperTwistReplayPacket LegacyPacket =
HyperTwistClassicCubeIntegrationTestInternal::BuildReplayPacket();
LegacyPacket.PacketVersion.Reset();
LegacyPacket.DerivedSummary = FHyperTwistDerivedReplaySummary();
for (int32 EventIndex = 0; EventIndex < LegacyPacket.Events.Num(); ++EventIndex)
{
FHyperTwistReplayEvent& ReplayEvent = LegacyPacket.Events[EventIndex];
ReplayEvent.EventId.Reset();
ReplayEvent.Sequence = 0;
ReplayEvent.TimeMs = FMath::Max(LegacyPacket.Events.Num() - EventIndex - 1, 0);
}
FString LegacyJson;
TestTrue(
TEXT("The legacy replay packet must serialize for normalization checks."),
HyperTwistClassicCubeIntegrationTestInternal::SerializeStruct(
LegacyPacket,
LegacyJson
)
);
const FString ReplayPath =
HyperTwistClassicCubeIntegrationTestInternal::BuildReplayRoundTripPath();
IFileManager::Get().MakeDirectory(*FPaths::GetPath(ReplayPath), true);
TestTrue(
TEXT("The legacy replay packet JSON must write to disk."),
FFileHelper::SaveStringToFile(
LegacyJson,
*ReplayPath,
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM
)
);
FHyperTwistReplayPacket LoadedPacket;
FString LoadedPath;
TestTrue(
TEXT("The replay persistence library must normalize and load schema-light replay packets."),
UHyperTwistReplayPersistenceLibrary::LoadReplayPacketFromFile(
ReplayPath,
LoadedPacket,
LoadedPath
)
);
TestEqual(
TEXT("Replay normalization must restore the canonical packet version."),
LoadedPacket.PacketVersion,
TEXT("ht-replay/v1")
);
TestTrue(
TEXT("Replay normalization must restore a structurally valid packet."),
LoadedPacket.IsStructurallyValid()
);
TestEqual(
TEXT("Replay normalization must rebuild the derived move count."),
LoadedPacket.DerivedSummary.MoveCount,
10
);
TestEqual(
TEXT("Replay normalization must rebuild the completed result."),
LoadedPacket.DerivedSummary.Result,
TEXT("completed")
);
int32 PreviousSequence = 0;
int32 PreviousTimeMs = -1;
for (const FHyperTwistReplayEvent& ReplayEvent : LoadedPacket.Events)
{
TestFalse(
TEXT("Replay normalization must populate every event id."),
ReplayEvent.EventId.IsEmpty()
);
TestTrue(
TEXT("Replay normalization must restore strictly increasing event sequence numbers."),
ReplayEvent.Sequence > PreviousSequence
);
TestTrue(
TEXT("Replay normalization must restore monotonic event times."),
ReplayEvent.TimeMs >= PreviousTimeMs
);
PreviousSequence = ReplayEvent.Sequence;
PreviousTimeMs = ReplayEvent.TimeMs;
}
UHyperTwistPuzzleViewerComponent* ViewerComponent =
NewObject<UHyperTwistPuzzleViewerComponent>(GetTransientPackage());
TestNotNull(
TEXT("The replay viewer component must exist for normalized replay loading."),
ViewerComponent
);
TestTrue(
TEXT("The viewer must accept the normalized replay packet directly."),
ViewerComponent != nullptr && ViewerComponent->LoadReplayPacket(LegacyPacket)
);
IFileManager::Get().Delete(*ReplayPath);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistClassicCubeLeaderboardPersistenceTest,
"HyperTwist.Integration.ClassicCube.LeaderboardPersistence",
@ -616,6 +724,54 @@ bool FHyperTwistClassicCubeLeaderboardNormalizationTest::RunTest(const FString&
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistClassicCubeLeaderboardStatusLineTest,
"HyperTwist.Integration.ClassicCube.LeaderboardStatusLine",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)
bool FHyperTwistClassicCubeLeaderboardStatusLineTest::RunTest(const FString& Parameters)
{
FHyperTwistClassicCubeLeaderboardState LegacyState;
LegacyState.SchemaVersion.Reset();
FHyperTwistClassicCubeLeaderboardEntry SlowerEntry;
SlowerEntry.PuzzleId = TEXT("cube/3x3x3");
SlowerEntry.ScrambleLength = 20;
SlowerEntry.FinalTimeMs = 15321;
SlowerEntry.ReplayId = TEXT("replay-slower");
SlowerEntry.SessionId = TEXT("session-slower");
SlowerEntry.RecordedAtUtc = TEXT("2026-06-12T10:10:00Z");
FHyperTwistClassicCubeLeaderboardEntry FasterEntry = SlowerEntry;
FasterEntry.FinalTimeMs = 14321;
FasterEntry.ReplayId = TEXT("replay-faster");
FasterEntry.SessionId = TEXT("session-faster");
FasterEntry.RecordedAtUtc = TEXT("2026-06-12T10:05:00Z");
LegacyState.Entries = {SlowerEntry, FasterEntry};
TestEqual(
TEXT("The leaderboard status line must normalize duplicate buckets before choosing the best entry."),
UHyperTwistClassicCubeLeaderboardLibrary::BuildLeaderboardStatusLine(
LegacyState,
TEXT("cube/3x3x3"),
20
),
TEXT("leaderboard: best 00:14.321 for 20-move scramble")
);
TestEqual(
TEXT("The leaderboard status line must surface empty buckets explicitly."),
UHyperTwistClassicCubeLeaderboardLibrary::BuildLeaderboardStatusLine(
LegacyState,
TEXT("cube/3x3x3"),
25
),
TEXT("leaderboard: no local best yet for 25-move scramble")
);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistClassicCubeRuntimeReplaySmokeTest,
"HyperTwist.Integration.ClassicCube.RuntimeReplaySmoke",

View file

@ -409,6 +409,7 @@ Closure read:
- [x] Export to `.json` replay file
- [x] Load replay, reconstruct animation
- [x] Legacy-safe replay packet consumers now preserve current behavior without depending on hand-curated local state ordering
- [x] Replay save, load, and viewer-import paths now repair schema-light packet version, ids, monotonic event ordering, monotonic timestamps, and derived replay summary during normalization
### 9B — Media Export
- [ ] Use UE `MovieRenderQueue` as the primary video-export route; treat `remotion-dev/remotion` only as restrictive-custody reference context unless a later clean-room/specification pass explicitly reopens it
@ -423,6 +424,7 @@ Routing correction:
- [x] Store best times per puzzle type + scramble length
- [x] Display in HUD
- [x] Normalize schema-light or duplicate local JSON buckets before resolving the displayed best time
- [x] Centralize the displayed leaderboard status line so legacy-bucket repair and HUD best-time wording resolve through the same deterministic path
**Estimated actions:** 4060
**Estimated time:** 23 days
@ -450,6 +452,7 @@ Routing correction:
- [x] The packaged validation helper now smoke-boots every cooked classic-cube training map by default instead of depending on a remembered second manual launch
- [x] Validation evidence on `2026-06-13`: primary reverse-SSH `localhost:22022` lane, isolated Windows worktree `C:\HyperTwist_worktrees\phase10validate`, commandlet-safe `UnrealMCP` and `UnrealMCPChong` guards landed for unattended cook, `BuildCookRun` `ExitCode=0` with `BuildCookRun time: 164.59 s`, archive output in `C:\HyperTwist_worktrees\phase10validate_packaged`, packaged smoke boot on `L_HyperTwist_ClassicTraining`, and explicit packaged smoke boot on `/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining`
- [x] Repair validation evidence on `2026-06-13`: the maintained helper itself was re-run on the same primary reverse-SSH `localhost:22022` lane against isolated worktree `C:\HyperTwist_worktrees\phase10validate`, wrote archive output to `C:\HyperTwist_worktrees\phase10validate_packaged_repair`, completed `BuildCookRun` with `ExitCode=0` and `BuildCookRun time: 116.89 s`, then default-smoke-launched both `/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining` and `/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining` without a manual second launch step
- [x] Continuation evidence on `2026-06-13`: after a prior aborted broad tar sync corrupted remote `HyperTwistWhisperCppPhase6RESpeechTranscriptContractTest.cpp`, the primary reverse-SSH `localhost:22022` lane hash-matched the changed slice files back into `C:\HyperTwist_worktrees\phase10validate`, completed the canonical `Build.bat` pass with `Result: Succeeded` and UnrealBuildTool `Total execution time: 2029.78 seconds`, passed all `7` `HyperTwist.Integration.ClassicCube` tests including `ReplayPersistenceNormalization`, `LeaderboardNormalization`, and `LeaderboardStatusLine`, refreshed `Phase 10B` coverage through green `Rotation`, `Solve`, `RandomClassicStates`, `Timer.Accuracy`, and `ClassicCubeMoveParsing` commandlet passes, and revalidated the maintained package helper in `-SkipBuild` mode with `BuildCookRun time: 69.16 s`, archive output in `C:\HyperTwist_worktrees\phase10validate_packaged_phase10b10c9a9c`, aggregate JSON at `validation\classic-cube-package-validation-report.json`, per-map smoke JSON under `validation\smoke\`, and successful smoke boots on both classic-cube training maps
**Estimated actions:** 2030
**Estimated time:** 12 days

View file

@ -366,6 +366,57 @@ Operational rules reinforced by this proof:
first, validate both direct profile resolution and real training-run
definition propagation on the same Windows Unreal lane
## Addendum - 2026-06-13 (replay, leaderboard, and structured package-report proof)
Live follow-up on `2026-06-13` established these additional facts:
- the primary `localhost:22022` lane remained healthy for the current
replay/leaderboard/package continuation
- the validated route again used the isolated Windows tree
`C:\HyperTwist_worktrees\phase10validate`
- a prior aborted broad tar sync had left remote
`HyperTwistWhisperCppPhase6RESpeechTranscriptContractTest.cpp` corrupted in
that worktree; targeted re-sync plus hash parity across the changed slice
files repaired the lane before validation continued
- the canonical `Build.bat` pass then succeeded there with `Result: Succeeded`
and UnrealBuildTool `Total execution time: 2029.78 seconds`
- a fresh `UnrealEditor-Cmd` integration pass then found `7` tests under
`HyperTwist.Integration.ClassicCube` and passed all `7`, including
`ReplayPersistenceNormalization`, `LeaderboardNormalization`, and
`LeaderboardStatusLine`
- fresh focused `Phase 10B` commandlet passes on the same rebuilt binary also
passed:
- `HyperTwist.Simulation.ClassicCube.Behavior.Rotation`
- `HyperTwist.Simulation.ClassicCube.Behavior.Solve`
- `HyperTwist.Solver.Accuracy.RandomClassicStates`
- `HyperTwist.Training.Timer.Accuracy`
- `HyperTwist.Speech.Transcription.ClassicCubeMoveParsing`
- the timer-accuracy pass closed green but surfaced unrelated warning noise
from missing local `vision` and `speech` health hosts, so that warning
context should be recorded rather than misclassified as a test failure
- the maintained package helper was then revalidated in `-SkipBuild` mode
against the already-present packaged-game receipt, wrote archive output to
`C:\HyperTwist_worktrees\phase10validate_packaged_phase10b10c9a9c`,
completed `BuildCookRun` with `ExitCode=0` and `BuildCookRun time: 69.16 s`,
wrote aggregate JSON to
`C:\HyperTwist_worktrees\phase10validate_packaged_phase10b10c9a9c\validation\classic-cube-package-validation-report.json`,
wrote per-map smoke JSON beneath
`C:\HyperTwist_worktrees\phase10validate_packaged_phase10b10c9a9c\validation\smoke\`,
and smoke-launched both training maps successfully
Operational rules reinforced by this proof:
- when a broad reverse-sync is interrupted or aborted, hash-check or explicitly
re-sync the changed files before treating later remote compiler failures as
true source regressions
- when validating package-helper archive/report behavior and the packaged-game
receipt already exists, prefer a fresh `-SkipBuild` package proof over an
unrelated full game rebuild
- when one `UnrealEditor-Cmd` `ExecCmds` string attempts to queue multiple
disjoint `Automation RunTests` filters, inspect the exported report JSON or
rerun the filters explicitly; the first filter may be the only one reflected
in the report
## Addendum - 2026-06-03 (stale-listener recovery)
Live follow-up on `2026-06-03` established this additional operational rule:

View file

@ -177,6 +177,25 @@ including `RunStateActivationResolution`, with
earlier activation-catalog checkpoint into current active-run and
`RuntimeModeId` fallback proof on the same primary reverse-SSH lane.
Further `2026-06-13` continuation proof on that same primary lane recovered
the classic-cube replay/leaderboard/package slice after a corrupted reverse
sync incident: a prior aborted broad tar sync left remote
`HyperTwistWhisperCppPhase6RESpeechTranscriptContractTest.cpp` damaged inside
`C:\HyperTwist_worktrees\phase10validate`, a targeted hash-matched re-sync
repaired the affected files, the canonical `Build.bat` pass then completed with
`Result: Succeeded` and UnrealBuildTool `Total execution time: 2029.78
seconds`, and fresh `UnrealEditor-Cmd` automation passed all `7`
`HyperTwist.Integration.ClassicCube` tests plus refreshed `Phase 10B`
validation for `Rotation`, `Solve`, `RandomClassicStates`, `Timer.Accuracy`,
and `ClassicCubeMoveParsing`. The same continuation then revalidated the
maintained package helper in `-SkipBuild` mode against the existing packaged
receipt, finishing `BuildCookRun` in `69.16 s`, writing aggregate JSON to
`C:\HyperTwist_worktrees\phase10validate_packaged_phase10b10c9a9c\validation\classic-cube-package-validation-report.json`,
writing per-map smoke JSON beneath `...\validation\smoke\`, and successfully
smoke-booting both classic-cube training maps. This extends the doctrine from
generic package proof into current structured validation-report proof on the
same primary reverse-SSH lane.
Operational reading:
- use `localhost:22022` as the primary reverse-SSH lane
@ -195,6 +214,16 @@ Operational reading:
before rerunning package validation; otherwise the global UBT mutex and
AutomationTool log files can block a healthy source state from being
revalidated
- when a broad reverse-sync is interrupted or aborted, do not trust the remote
validation worktree blindly; hash-check or explicitly re-sync the changed
files before classifying later compiler failures as a real source regression
- when validating package-helper archive/report behavior and the packaged-game
receipt already exists, `-SkipBuild` is the preferred proof path over an
unrelated full game rebuild
- when one `UnrealEditor-Cmd` `ExecCmds` string tries to queue multiple
disjoint `Automation RunTests` filters, inspect the exported report JSON or
fall back to explicit per-filter invocations; a green exit alone is not
sufficient proof that every intended filter actually executed
- when an editor-facing plugin still enters `UnrealEditor-Cmd` during cook,
guard toolbar, Slate, and local bridge startup paths for
commandlet/unattended execution instead of assuming interactive editor-only

View file

@ -161,12 +161,12 @@ repo.
| Feature | Status | Primary authority | Notes |
|---|---|---|---|
| Native puzzle-state runtime | Implemented now | first-party runtime + landed donor packets | Core product identity. |
| Classic-cube playable runtime loop | Implemented now | first-party current code + landed timer/training substrate | First-party `AHyperTwistClassicCubeActor`, `AHyperTwistClassicCubeGameMode`, `AHyperTwistClassicCubePlayerController`, `AHyperTwistClassicCubeOrbitPawn`, and `UHyperTwistClassicCubeHUDWidget` now own the bounded classic-cube scramble/play/timer/solve loop with left-click, right-click, touch, middle-mouse drag orbit, scroll-wheel zoom, GUI or keyboard fresh-attempt restart, solved-state submission, active-cube orbit focus, solver hint glow, follow-along guidance, hold-to-talk voice commands, voice-profile cycling, and coach narration in code. Dedicated `L_HyperTwist_ClassicTraining` and `L_HyperTwist_FollowAlongTraining` maps plus the authored classic-cube material family are now source-controlled through `scripts/Invoke-HyperTwistClassicCubeMapAuthoring.ps1`, `scripts/hypertwist_author_classic_cube_training_maps.py`, `scripts/Invoke-HyperTwistClassicCubePackage.ps1`, and `scripts/Launch-HyperTwistClassicCubePackage.ps1`. That route was first package-proven on `2026-06-12` through the fallback reverse-SSH `localhost:22023` lane and then package-proven again on `2026-06-13` through the primary reverse-SSH `localhost:22022` lane on isolated worktree `C:\HyperTwist_worktrees\phase10validate`, including packaged smoke boots on both training maps. |
| Classic-cube playable runtime loop | Implemented now | first-party current code + landed timer/training substrate | First-party `AHyperTwistClassicCubeActor`, `AHyperTwistClassicCubeGameMode`, `AHyperTwistClassicCubePlayerController`, `AHyperTwistClassicCubeOrbitPawn`, and `UHyperTwistClassicCubeHUDWidget` now own the bounded classic-cube scramble/play/timer/solve loop with left-click, right-click, touch, middle-mouse drag orbit, scroll-wheel zoom, GUI or keyboard fresh-attempt restart, solved-state submission, active-cube orbit focus, solver hint glow, follow-along guidance, hold-to-talk voice commands, voice-profile cycling, and coach narration in code. Dedicated `L_HyperTwist_ClassicTraining` and `L_HyperTwist_FollowAlongTraining` maps plus the authored classic-cube material family are now source-controlled through `scripts/Invoke-HyperTwistClassicCubeMapAuthoring.ps1`, `scripts/hypertwist_author_classic_cube_training_maps.py`, `scripts/Invoke-HyperTwistClassicCubePackage.ps1`, and `scripts/Launch-HyperTwistClassicCubePackage.ps1`. That route was first package-proven on `2026-06-12` through the fallback reverse-SSH `localhost:22023` lane and then package-proven again on `2026-06-13` through the primary reverse-SSH `localhost:22022` lane on isolated worktree `C:\HyperTwist_worktrees\phase10validate`, including packaged smoke boots on both training maps. The maintained package helper now also emits aggregate validation JSON plus per-map smoke JSON under the archived `validation\` tree, and that report path was revalidated on `2026-06-13` through archive `C:\HyperTwist_worktrees\phase10validate_packaged_phase10b10c9a9c`. |
| Classic-cubing semantic/runtime adapter | Implemented now | landed `cubing/cubing.js` packet | Live adapter family. |
| Classic-cubing semantic, bridge, and `MPL`-boundary reference grounding | Implemented now | `cubing/cubing.js` retained boundary-sensitive lane + first-party current code | Current live `Classic Cubing Semantics and Runtime` reference side includes seven rewritten first-party contract/reference targets grounded in `cubing/cubing.js`: semantics, geometry, viewer adapter, device boundary, search contract, Melinda bridge, and explicit `MPL` compliance-boundary notes. This does not displace the landed `Phase 4R-A` first-party owner lane, the separate `cubing/twisty.js` replay shell lane, the separate `cubing/alg.js` parser/AST lane, or the explicit practical `MPL` path and notice-retention boundary. |
| Seeded competition scramble workflow and lightweight scramble-operator shell adjuncts | Deep-source grounded retained | `cubing/cubing.js` retained lane + `cubing/mark3` / `cubing/scramble.cubing.net` successor evaluation | Source-backed successor surfaces sharpen competition-spec workflow and operator-shell expectations above the retained scramble and visualization seams, but they do not displace `cubing/cubing.js` or `cubing/twisty.js`; `scramble-display` remains comparison-only. |
| Replay shell and timeline | Implemented now | landed `cubing/twisty.js` bounded packets | First-party `HyperTwistSimulation` now owns the bounded replay-player shell, cursor/timeline transport, adapter/bootstrap, and local visualization or fallback presentation contract grounded in `cubing/twisty.js`; classic-cubing semantics remain with `cubing/cubing.js`, parser and AST ownership remain with `cubing/alg.js`, and broader browser support ownership stays with the landed browser lanes. |
| Classic-cube runtime replay capture, JSON persistence, and playback reconstruction | Implemented now | first-party current code + landed replay/training substrate | Current classic-cube runtime records move and state-snapshot replay events with timestamps during live solves, persists replay packets as `.json` through `UHyperTwistReplayPersistenceLibrary`, restores replay metadata into `AHyperTwistClassicCubeGameMode`, and reconstructs saved move streams for local playback in current code. The primary reverse-SSH `localhost:22022` Windows validation lane passed `ReplayPersistenceRoundTrip`, `ReplayViewerLoad`, and `RuntimeReplaySmoke` on isolated worktree `C:\HyperTwist_worktrees\phase10validate` on `2026-06-12`. Current replay-adjacent local consumers are also now hardened so schema-light or duplicate local state does not silently distort the retained best-attempt view. |
| Classic-cube runtime replay capture, JSON persistence, and playback reconstruction | Implemented now | first-party current code + landed replay/training substrate | Current classic-cube runtime records move and state-snapshot replay events with timestamps during live solves, persists replay packets as `.json` through `UHyperTwistReplayPersistenceLibrary`, restores replay metadata into `AHyperTwistClassicCubeGameMode`, and reconstructs saved move streams for local playback in current code. Replay save, load, and viewer-import paths now normalize schema-light packets by repairing packet version, replay/session ids, event ids, monotonic sequences, monotonic timestamps, and derived replay summary before the packet is reused. The primary reverse-SSH `localhost:22022` Windows validation lane then refreshed the full `HyperTwist.Integration.ClassicCube` suite on isolated worktree `C:\HyperTwist_worktrees\phase10validate` on `2026-06-13`, passing `ReplayPersistenceRoundTrip`, `ReplayPersistenceNormalization`, `ReplayViewerLoad`, and `RuntimeReplaySmoke` alongside the related leaderboard integration cases. |
| Media-export, embedded-playback, and replay-explainer reference grounding | Implemented now | `remotion-dev/remotion` retained restrictive-custody lane + first-party current code | Current live `Media Export and Replay Explainers` reference side includes five rewritten first-party contract/reference targets grounded in `remotion-dev/remotion`: embedded playback, render orchestration, media parser, explainer-studio preview/output registration, and explicit commercial-license/package-split compliance-boundary notes. This does not displace the landed `Phase 4R-F` first-party owner lane, the broader browser/spatial/media adjunct family, or the explicit package-split commercial boundary. Current routing correction: preserve the already-landed first-party outputs, but treat future donor-backed widening from `remotion-dev/remotion` as restrictive-custody and prefer Unreal-native export for fresh shipping work. |
| Browser-viewer, compact-editor, export, and docs-boundary grounding | Implemented now | `google/model-viewer` retained permissive lane + first-party current code | Current live `Browser Viewer and Asset QA` reference side includes five rewritten first-party contract/reference targets grounded in the root `google/model-viewer` lane: viewer embed, compact editor/inspection, snippet/export, renderer comparison/fidelity, and docs/demo separation. The subordinate `space-opera`, `modelviewer.dev`, and `render-fidelity-tools` source attributions are absorbed here as support-only contributors rather than separate live owner lanes. This does not displace the landed `Phase 3R-D` first-party owner lane, the separate `KhronosGroup/glTF-Sample-Viewer` standards-aware QA lane, or the already explicit `google/model-viewer/packages/shared-assets` boundary-sensitive fixture lane. |
| Standards-aware asset-validation and statistics grounding | Implemented now | `KhronosGroup/glTF-Sample-Viewer` retained permissive support lane + first-party current code | Current live `Browser Viewer and Asset QA` reference side includes one rewritten first-party target grounded in `KhronosGroup/glTF-Sample-Viewer`: standards-aware asset validation and statistics. This does not displace the landed `Phase 3R-D` first-party browser viewer owner, the separate root `google/model-viewer` viewer/reference lane, or the already explicit shared-assets boundary-sensitive fixture lane. |
@ -249,7 +249,7 @@ repo.
| Feature | Status | Primary authority | Notes |
|---|---|---|---|
| Publication/leaderboard projection | Implemented now | landed `kash/cubedesk` `Bound 4` | Live bounded slice. |
| Classic-cube local leaderboard stub | Implemented now | first-party current code + landed timer/training substrate | Current classic-cube runtime persists best local solve times by puzzle id and scramble-length bucket to JSON, carries replay/session identity forward on winning entries, repairs schema-light or duplicate local leaderboard buckets before resolving the displayed best entry, and projects the best current-bucket line into the in-game HUD without requiring a backend. The primary reverse-SSH `localhost:22022` Windows validation lane passed `LeaderboardPersistence` on isolated worktree `C:\HyperTwist_worktrees\phase10validate` on `2026-06-12`. |
| Classic-cube local leaderboard stub | Implemented now | first-party current code + landed timer/training substrate | Current classic-cube runtime persists best local solve times by puzzle id and scramble-length bucket to JSON, carries replay/session identity forward on winning entries, repairs schema-light or duplicate local leaderboard buckets before resolving the displayed best entry, routes the user-facing HUD best-time wording through a centralized deterministic status-line formatter, and projects the best current-bucket line into the in-game HUD without requiring a backend. The primary reverse-SSH `localhost:22022` Windows validation lane refreshed the corresponding leaderboard coverage on isolated worktree `C:\HyperTwist_worktrees\phase10validate` on `2026-06-13`, passing `LeaderboardPersistence`, `LeaderboardNormalization`, and `LeaderboardStatusLine`. |
| Entitlement gating | Implemented now | landed `kash/cubedesk` `Bound 4` | Live bounded slice. |
| Local social challenge bundle | Implemented now | landed `kash/cubedesk` `Bound 5` | Live bounded slice. |
| Broader admin/report lane | Deep-source grounded retained | repo-local justification required | `Bound 6` is deferred and not currently justified for implementation. |

View file

@ -68,17 +68,20 @@ Current consolidated milestone snapshot:
targeted automation, and live Windows validation proof
- classic-cube `Phase 9A` replay recording is now closed through first-party
runtime capture, `.json` replay persistence, local playback reconstruction,
and live Windows integration proof on isolated worktree
schema-light replay normalization across save/load/viewer import, and live
Windows integration proof on isolated worktree
`C:\HyperTwist_worktrees\phase10validate`
- classic-cube `Phase 9C` local leaderboard stub is now closed through
first-party JSON persistence, scramble-length bucket best-time retention, HUD
display, legacy-state normalization for schema-light or duplicate local
buckets, and live Windows integration proof on isolated worktree
buckets, deterministic status-line resolution, and live Windows integration
proof on isolated worktree
`C:\HyperTwist_worktrees\phase10validate`
- classic-cube `Phase 10B` behavior validation is now closed through Windows
automation coverage for rotation, solve, solver accuracy, timer accuracy, and
speech-transcription parsing on the primary reverse-SSH `localhost:22022`
lane
lane, then refreshed again on `2026-06-13` after the replay/leaderboard
continuation patch on the same rebuilt Windows binary
- classic-cube `Phase 10C` integration validation is now closed through the
same primary reverse-SSH `localhost:22022` lane, packaged
`BuildCookRun`/archive proof on `C:\HyperTwist_worktrees\phase10validate`,
@ -87,7 +90,10 @@ Current consolidated milestone snapshot:
across the full cooked classic-cube training map set; that helper default was
revalidated on `2026-06-13` through archive
`C:\HyperTwist_worktrees\phase10validate_packaged_repair` with
`BuildCookRun time: 116.89 s`
`BuildCookRun time: 116.89 s`, then revalidated again through
`C:\HyperTwist_worktrees\phase10validate_packaged_phase10b10c9a9c` with
aggregate validation JSON plus per-map smoke JSON in `validation\` and
`BuildCookRun time: 69.16 s` in `-SkipBuild` mode
- the next best deliberate widening move is the now-opened `Phase 6C`
clean-room `Magic120Cell` / `MagicCube5D` runtime widening lane, starting
from the first-party higher-dimensional activation catalog now that the owned

View file

@ -6,6 +6,7 @@ param(
[string]$CookMap = '/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining',
[string[]]$AdditionalCookMaps = @('/Game/HyperTwistTraining/Maps/L_HyperTwist_FollowAlongTraining'),
[string[]]$SmokeMaps = @(),
[string]$ValidationReportPath = '',
[switch]$CleanArchive,
[switch]$SkipBuild,
[switch]$SkipLaunch
@ -39,6 +40,54 @@ $ExpectedClassicCubeMaterialPaths = @(
(Join-Path $ProjectRoot 'UnrealHyperTwist\Content\HyperTwistTraining\Materials\MI_HT_ClassicCube_Right.uasset'),
(Join-Path $ProjectRoot 'UnrealHyperTwist\Content\HyperTwistTraining\Materials\MI_HT_ClassicCube_Internal.uasset')
)
$ValidationRootPath = Join-Path $ArchiveDirectory 'validation'
$SmokeReportDirectory = Join-Path $ValidationRootPath 'smoke'
function Write-Utf8JsonFile {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[object]$Value
)
$ParentPath = Split-Path -Parent $Path
if (-not [string]::IsNullOrWhiteSpace($ParentPath))
{
New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null
}
$Json = $Value | ConvertTo-Json -Depth 10
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
}
function Convert-PathToken {
param(
[string]$Value
)
if ([string]::IsNullOrWhiteSpace($Value))
{
return 'unknown'
}
return ($Value -replace '[\\/:*?"<>| ]', '_')
}
function Resolve-PackagedExecutablePath {
param(
[string]$PackageRoot
)
$CandidateExecutablePaths = @(
(Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'),
(Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'),
(Join-Path $PackageRoot 'UnrealHyperTwist.exe')
)
return $CandidateExecutablePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
}
function Convert-GameMapPathToContentPath {
param(
@ -108,6 +157,13 @@ if ($CleanArchive -and (Test-Path $ArchiveDirectory))
New-Item -ItemType Directory -Force -Path $UnrealBuildToolSavedPath | Out-Null
New-Item -ItemType Directory -Force -Path $ArchiveDirectory | Out-Null
New-Item -ItemType Directory -Force -Path $ValidationRootPath | Out-Null
New-Item -ItemType Directory -Force -Path $SmokeReportDirectory | Out-Null
if ([string]::IsNullOrWhiteSpace($ValidationReportPath))
{
$ValidationReportPath = Join-Path $ValidationRootPath 'classic-cube-package-validation-report.json'
}
$RunUatArguments = @(
'BuildCookRun',
@ -131,28 +187,76 @@ if (-not $SkipBuild)
$RunUatArguments += '-build'
}
Write-Host "Packaging HyperTwist classic-cube validation lane to '$ArchiveDirectory'..."
& $RunUatPath @RunUatArguments
if ($LASTEXITCODE -ne 0)
{
throw "RunUAT packaging failed with exit code $LASTEXITCODE."
$ValidationReport = [ordered]@{
reportVersion = 'ht-classic-cube-package-validation/v1'
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
projectRoot = $ProjectRoot
archiveDirectory = $ArchiveDirectory
configuration = $Configuration
cookMaps = @($CookMaps)
smokeMaps = @($ResolvedSmokeMaps)
cleanArchive = [bool]$CleanArchive
skipBuild = [bool]$SkipBuild
skipLaunch = [bool]$SkipLaunch
result = 'failed'
packagedExecutablePath = $null
smokeReports = @()
error = $null
}
if (-not $SkipLaunch)
try
{
if (-not (Test-Path $LaunchScriptPath))
Write-Host "Packaging HyperTwist classic-cube validation lane to '$ArchiveDirectory'..."
& $RunUatPath @RunUatArguments
if ($LASTEXITCODE -ne 0)
{
throw "Launch script was not found at '$LaunchScriptPath'."
throw "RunUAT packaging failed with exit code $LASTEXITCODE."
}
foreach ($SmokeMap in $ResolvedSmokeMaps)
$PackagedExecutablePath = Resolve-PackagedExecutablePath -PackageRoot $ArchiveDirectory
if ($null -eq $PackagedExecutablePath)
{
Write-Host "Smoke validating packaged classic-cube map '$SmokeMap'..."
& $LaunchScriptPath -PackageRoot $ArchiveDirectory -MapUrl $SmokeMap
if ($LASTEXITCODE -ne 0)
throw "No packaged UnrealHyperTwist executable was found beneath '$ArchiveDirectory' after packaging."
}
$ValidationReport.packagedExecutablePath = $PackagedExecutablePath
if (-not $SkipLaunch)
{
if (-not (Test-Path $LaunchScriptPath))
{
throw "Packaged classic-cube smoke launch failed for '$SmokeMap' with exit code $LASTEXITCODE."
throw "Launch script was not found at '$LaunchScriptPath'."
}
foreach ($SmokeMap in $ResolvedSmokeMaps)
{
$SmokeReportPath = Join-Path $SmokeReportDirectory (
'{0}.json' -f (Convert-PathToken -Value $SmokeMap)
)
Write-Host "Smoke validating packaged classic-cube map '$SmokeMap'..."
& $LaunchScriptPath -PackageRoot $ArchiveDirectory -MapUrl $SmokeMap -ReportPath $SmokeReportPath
if ($LASTEXITCODE -ne 0)
{
throw "Packaged classic-cube smoke launch failed for '$SmokeMap' with exit code $LASTEXITCODE."
}
if (Test-Path $SmokeReportPath)
{
$ValidationReport.smokeReports += @(
Get-Content -LiteralPath $SmokeReportPath -Raw | ConvertFrom-Json
)
}
}
}
$ValidationReport.result = 'passed'
}
catch
{
$ValidationReport.error = $_.Exception.Message
Write-Utf8JsonFile -Path $ValidationReportPath -Value $ValidationReport
throw
}
Write-Utf8JsonFile -Path $ValidationReportPath -Value $ValidationReport

View file

@ -4,11 +4,31 @@ param(
[int]$SmokeSeconds = 10,
[int]$ResX = 1600,
[int]$ResY = 900,
[string]$ReportPath = '',
[switch]$KeepRunning
)
$ErrorActionPreference = 'Stop'
function Write-Utf8JsonFile {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[object]$Value
)
$ParentPath = Split-Path -Parent $Path
if (-not [string]::IsNullOrWhiteSpace($ParentPath))
{
New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null
}
$Json = $Value | ConvertTo-Json -Depth 8
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
}
$CandidateExecutablePaths = @(
(Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'),
(Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'),
@ -30,18 +50,77 @@ $ArgumentList = @(
)
Write-Host "Launching packaged classic-cube validation lane from '$ExecutablePath'..."
$Process = Start-Process -FilePath $ExecutablePath -ArgumentList $ArgumentList -PassThru
Start-Sleep -Seconds $SmokeSeconds
$ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path
$GeneratedAtUtc = [DateTime]::UtcNow.ToString('o')
$Process = $null
$Report = [ordered]@{
reportVersion = 'ht-classic-cube-package-smoke/v1'
generatedAtUtc = $GeneratedAtUtc
packageRoot = $ResolvedPackageRoot
executablePath = $ExecutablePath
mapUrl = $MapUrl
smokeSeconds = $SmokeSeconds
resolution = [ordered]@{
width = $ResX
height = $ResY
}
keepRunning = [bool]$KeepRunning
result = 'failed'
processId = $null
processStopped = $false
exitCode = $null
error = $null
}
$Process.Refresh()
if ($Process.HasExited)
try
{
throw "Packaged classic-cube executable exited early with code $($Process.ExitCode)."
$Process = Start-Process -FilePath $ExecutablePath -ArgumentList $ArgumentList -PassThru
Start-Sleep -Seconds $SmokeSeconds
$Process.Refresh()
$Report.processId = $Process.Id
if ($Process.HasExited)
{
$Report.exitCode = $Process.ExitCode
throw "Packaged classic-cube executable exited early with code $($Process.ExitCode)."
}
$Report.result = 'passed'
}
catch
{
$Report.error = $_.Exception.Message
if ($null -ne $Process)
{
$Process.Refresh()
if ($Process.HasExited)
{
$Report.exitCode = $Process.ExitCode
}
elseif (-not $KeepRunning)
{
Stop-Process -Id $Process.Id -Force
$Report.processStopped = $true
}
}
if (-not [string]::IsNullOrWhiteSpace($ReportPath))
{
Write-Utf8JsonFile -Path $ReportPath -Value $Report
}
throw
}
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.'
}
if (-not [string]::IsNullOrWhiteSpace($ReportPath))
{
Write-Utf8JsonFile -Path $ReportPath -Value $Report
}