Phase 1: IPC infrastructure + solver oracle scaffolding

- Add freestyle submodule (1B)
- Create HyperTwistIPCProcessManager for external process spawning
- Create HyperTwistSolverOracleLibrary with Blueprint wrappers
- Add brownan GPL solver standalone build files (1D)
- Add cahidenes Python solver wrapper using kociemba (1F)
- Update roadmap marking 1B/1D/1F in progress
This commit is contained in:
axiomlogicnexus 2026-06-10 17:35:08 +00:00
parent 86a0068117
commit 168c6fdd08
10 changed files with 588 additions and 9 deletions

1
.external/freestyle Submodule

@ -0,0 +1 @@
Subproject commit e7a317c944bced4287e1506fb40ac569704cf8bf

3
.gitmodules vendored
View file

@ -67,3 +67,6 @@
[submodule ".external/Rubiks-Cube-Solver"]
path = .external/Rubiks-Cube-Solver
url = https://github.com/brownan/Rubiks-Cube-Solver.git
[submodule ".external/freestyle"]
path = .external/freestyle
url = https://github.com/freestyle-voice/freestyle.git

View file

@ -0,0 +1,41 @@
cmake_minimum_required(VERSION 3.10)
project(brownan-solver C)
set(CMAKE_C_STANDARD 99)
if(MSVC)
add_compile_options(/W4 /O2)
else()
add_compile_options(-Wall -Wextra -O3)
endif()
set(SOURCES
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/cube.c
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/stack.c
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/common.c
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/cornertable.c
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/goal.c
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/edgetable.c
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/main.c
)
set(HEADERS
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/cube.h
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/stack.h
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/common.h
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/cornertable.h
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/goal.h
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/edgetable.h
)
add_executable(brownan-solver ${SOURCES} ${HEADERS})
# Copy tables next to executable after build
add_custom_command(TARGET brownan-solver POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/table_corner.rht
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/table_edge1.rht
${CMAKE_SOURCE_DIR}/../../.external/Rubiks-Cube-Solver/table_edge2.rht
$<TARGET_FILE_DIR:brownan-solver>
COMMENT "Copying solver tables"
)

31
Solvers/brownan/build.bat Normal file
View file

@ -0,0 +1,31 @@
@echo off
setlocal
REM Build brownan GPL solver as standalone executable
REM Source is in .external/Rubiks-Cube-Solver (GPL-2.0, NEVER link into Unreal)
set SRCDIR=..\..\.external\Rubiks-Cube-Solver
set OUTDIR=..\..\UnrealHyperTwist\Binaries\Win64\Solvers
if not exist %OUTDIR% mkdir %OUTDIR%
cl.exe /nologo /W4 /O2 /Fe%OUTDIR%\brownan-solver.exe ^
%SRCDIR%\cube.c ^
%SRCDIR%\stack.c ^
%SRCDIR%\common.c ^
%SRCDIR%\cornertable.c ^
%SRCDIR%\goal.c ^
%SRCDIR%\edgetable.c ^
%SRCDIR%\main.c
if errorlevel 1 (
echo Build failed
exit /b 1
)
REM Copy pre-generated tables if they exist
if exist %SRCDIR%\table_corner.rht copy /Y %SRCDIR%\table_corner.rht %OUTDIR%\
if exist %SRCDIR%\table_edge1.rht copy /Y %SRCDIR%\table_edge1.rht %OUTDIR%\
if exist %SRCDIR%\table_edge2.rht copy /Y %SRCDIR%\table_edge2.rht %OUTDIR%\
echo brownan-solver built successfully at %OUTDIR%\brownan-solver.exe

View file

@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""
HyperTwist IPC wrapper for cahidenes rubiks-cube-solver oracle.
Uses the kociemba package for solving (the original repo's vision module is unused here).
Input: 54-character facelet string (UBLFRD order)
Output: Space-separated WCA move notation
"""
import sys
import kociemba
def solve(facelets: str) -> str:
"""Solve a cube from a facelet string."""
if len(facelets) != 54:
print(f"ERROR: Facelet string must be 54 characters, got {len(facelets)}", file=sys.stderr)
sys.exit(1)
try:
solution = kociemba.solve(facelets)
return solution
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("ERROR: No facelet string provided", file=sys.stderr)
sys.exit(1)
facelets = sys.argv[1].strip()
result = solve(facelets)
print(result)

View file

@ -0,0 +1,223 @@
#include "HyperTwistIPC/HyperTwistIPCProcessManager.h"
UHyperTwistIPCProcessManager::UHyperTwistIPCProcessManager()
{
}
UHyperTwistIPCProcessManager* UHyperTwistIPCProcessManager::CreateIPCManager(UObject* WorldContextObject)
{
return NewObject<UHyperTwistIPCProcessManager>(WorldContextObject);
}
FString UHyperTwistIPCProcessManager::GetSolversDirectory()
{
return FPaths::ConvertRelativePathToFull(
FPaths::ProjectDir() / TEXT("Binaries/Win64/Solvers/"));
}
FHyperTwistIPCResult UHyperTwistIPCProcessManager::RunProcess(const FHyperTwistIPCProcessOptions& Options)
{
FHyperTwistIPCResult Result;
if (!FPaths::FileExists(Options.ExecutablePath))
{
Result.ErrorMessage = FString::Printf(TEXT("Executable not found: %s"), *Options.ExecutablePath);
return Result;
}
FString Params = FString::Join(Options.Arguments, TEXT(" "));
FString WorkingDir = Options.WorkingDirectory.IsEmpty()
? FPaths::GetPath(Options.ExecutablePath)
: Options.WorkingDirectory;
void* ReadPipe = nullptr;
void* WritePipe = nullptr;
void* StdErrPipe = nullptr;
FProcHandle ProcessHandle;
if (Options.bCaptureOutput)
{
FPlatformProcess::CreatePipe(ReadPipe, WritePipe);
if (Options.bRedirectInput)
{
FPlatformProcess::CreatePipe(StdErrPipe, nullptr);
}
ProcessHandle = FPlatformProcess::CreateProc(
*Options.ExecutablePath,
*Params,
false,
true,
true,
nullptr,
0,
*WorkingDir,
WritePipe,
ReadPipe);
}
else
{
ProcessHandle = FPlatformProcess::CreateProc(
*Options.ExecutablePath,
*Params,
false,
false,
false,
nullptr,
0,
*WorkingDir,
nullptr,
nullptr);
}
if (!ProcessHandle.IsValid())
{
Result.ErrorMessage = TEXT("Failed to create process");
FPlatformProcess::ClosePipe(ReadPipe, WritePipe);
return Result;
}
double StartTime = FPlatformTime::Seconds();
FString Output;
FString ErrorOutput;
while (FPlatformProcess::IsProcRunning(ProcessHandle))
{
if (Options.bCaptureOutput && ReadPipe)
{
Output += FPlatformProcess::ReadPipe(ReadPipe);
}
float Elapsed = static_cast<float>(FPlatformTime::Seconds() - StartTime);
if (Elapsed > Options.TimeoutSeconds)
{
FPlatformProcess::TerminateProc(ProcessHandle);
Result.ErrorMessage = FString::Printf(TEXT("Process timed out after %.1f seconds"), Options.TimeoutSeconds);
FPlatformProcess::ClosePipe(ReadPipe, WritePipe);
return Result;
}
FPlatformProcess::Sleep(0.05f);
}
if (Options.bCaptureOutput && ReadPipe)
{
Output += FPlatformProcess::ReadPipe(ReadPipe);
}
int32 ReturnCode = 0;
FPlatformProcess::GetProcReturnCode(ProcessHandle, &ReturnCode);
FPlatformProcess::CloseProc(ProcessHandle);
FPlatformProcess::ClosePipe(ReadPipe, WritePipe);
Result.bSuccess = (ReturnCode == 0);
Result.ExitCode = ReturnCode;
Result.StdOut = Output;
Result.StdErr = ErrorOutput;
return Result;
}
int32 UHyperTwistIPCProcessManager::StartProcess(const FHyperTwistIPCProcessOptions& Options)
{
if (!FPaths::FileExists(Options.ExecutablePath))
{
return -1;
}
FString Params = FString::Join(Options.Arguments, TEXT(" "));
FString WorkingDir = Options.WorkingDirectory.IsEmpty()
? FPaths::GetPath(Options.ExecutablePath)
: Options.WorkingDirectory;
void* ReadPipe = nullptr;
void* WritePipe = nullptr;
if (Options.bCaptureOutput)
{
FPlatformProcess::CreatePipe(ReadPipe, WritePipe);
}
FProcHandle ProcessHandle = FPlatformProcess::CreateProc(
*Options.ExecutablePath,
*Params,
false,
true,
true,
nullptr,
0,
*WorkingDir,
WritePipe,
ReadPipe);
if (!ProcessHandle.IsValid())
{
FPlatformProcess::ClosePipe(ReadPipe, WritePipe);
return -1;
}
int32 Id = NextProcessId++;
FActiveProcess Proc;
Proc.Handle = ProcessHandle;
Proc.ReadPipe = ReadPipe;
Proc.WritePipe = WritePipe;
Proc.StartTime = static_cast<float>(FPlatformTime::Seconds());
Proc.ExecutablePath = Options.ExecutablePath;
ActiveProcesses.Add(Id, Proc);
return Id;
}
bool UHyperTwistIPCProcessManager::WriteToProcess(int32 ProcessId, const FString& Line)
{
FActiveProcess* Proc = ActiveProcesses.Find(ProcessId);
if (!Proc || !Proc->WritePipe)
{
return false;
}
FString Text = Line + TEXT("\n");
FPlatformProcess::WritePipe(Proc->WritePipe, *Text);
return true;
}
FString UHyperTwistIPCProcessManager::ReadFromProcess(int32 ProcessId)
{
FActiveProcess* Proc = ActiveProcesses.Find(ProcessId);
if (!Proc || !Proc->ReadPipe)
{
return FString();
}
return FPlatformProcess::ReadPipe(Proc->ReadPipe);
}
bool UHyperTwistIPCProcessManager::TerminateProcess(int32 ProcessId)
{
FActiveProcess* Proc = ActiveProcesses.Find(ProcessId);
if (!Proc)
{
return false;
}
if (FPlatformProcess::IsProcRunning(Proc->Handle))
{
FPlatformProcess::TerminateProc(Proc->Handle);
}
FPlatformProcess::CloseProc(Proc->Handle);
FPlatformProcess::ClosePipe(Proc->ReadPipe, Proc->WritePipe);
ActiveProcesses.Remove(ProcessId);
return true;
}
bool UHyperTwistIPCProcessManager::IsProcessRunning(int32 ProcessId) const
{
const FActiveProcess* Proc = ActiveProcesses.Find(ProcessId);
if (!Proc)
{
return false;
}
return FPlatformProcess::IsProcRunning(Proc->Handle);
}

View file

@ -0,0 +1,98 @@
#include "HyperTwistIPC/HyperTwistSolverOracleLibrary.h"
#include "HyperTwistSolverLibrary.h"
#include "Misc/Paths.h"
#include "Misc/FileHelper.h"
#include "HAL/PlatformFilemanager.h"
FString UHyperTwistSolverOracleLibrary::GetBrownanSolverPath()
{
return FPaths::ConvertRelativePathToFull(
FPaths::ProjectDir() / TEXT("Binaries/Win64/Solvers/brownan-solver.exe"));
}
FString UHyperTwistSolverOracleLibrary::GetCahidenesWrapperPath()
{
return FPaths::ConvertRelativePathToFull(
FPaths::ProjectDir() / TEXT("Binaries/Win64/Solvers/cahidenes_solver_wrapper.py"));
}
bool UHyperTwistSolverOracleLibrary::AreSolverOraclesAvailable()
{
return FPaths::FileExists(GetBrownanSolverPath())
&& FPaths::FileExists(GetCahidenesWrapperPath());
}
bool UHyperTwistSolverOracleLibrary::VerifySolutionWithBrownanOracle(const FString& CubeState, const TArray<FString>& Moves)
{
FString SolverPath = GetBrownanSolverPath();
if (!FPaths::FileExists(SolverPath))
{
UE_LOG(LogTemp, Warning, TEXT("Brownan solver not found at %s"), *SolverPath);
return false;
}
UHyperTwistIPCProcessManager* IPC = UHyperTwistIPCProcessManager::CreateIPCManager(
GetTransientPackage());
FHyperTwistIPCProcessOptions Options;
Options.ExecutablePath = SolverPath;
Options.Arguments.Add(CubeState);
Options.TimeoutSeconds = 60.0f;
FHyperTwistIPCResult Result = IPC->RunProcess(Options);
if (!Result.bSuccess)
{
UE_LOG(LogTemp, Warning, TEXT("Brownan solver failed: %s"), *Result.ErrorMessage);
return false;
}
// brownan solver outputs solution moves to stdout; verify against provided moves
FString Output = Result.StdOut.TrimStartAndEnd();
FString Expected = FString::Join(Moves, TEXT(" ")).TrimStartAndEnd();
return Output.Equals(Expected, ESearchCase::IgnoreCase);
}
TArray<FString> UHyperTwistSolverOracleLibrary::SolveWithCahidenesOracle(const FString& FaceletString)
{
TArray<FString> EmptyResult;
FString WrapperPath = GetCahidenesWrapperPath();
if (!FPaths::FileExists(WrapperPath))
{
UE_LOG(LogTemp, Warning, TEXT("Cahidenes wrapper not found at %s"), *WrapperPath);
return EmptyResult;
}
UHyperTwistIPCProcessManager* IPC = UHyperTwistIPCProcessManager::CreateIPCManager(
GetTransientPackage());
FHyperTwistIPCProcessOptions Options;
Options.ExecutablePath = TEXT("python.exe");
Options.Arguments.Add(WrapperPath);
Options.Arguments.Add(FaceletString);
Options.TimeoutSeconds = 30.0f;
FHyperTwistIPCResult Result = IPC->RunProcess(Options);
if (!Result.bSuccess)
{
UE_LOG(LogTemp, Warning, TEXT("Cahidenes solver failed: %s"), *Result.ErrorMessage);
return EmptyResult;
}
FString Output = Result.StdOut.TrimStartAndEnd();
TArray<FString> Moves;
Output.ParseIntoArrayWS(Moves);
return Moves;
}
bool UHyperTwistSolverOracleLibrary::CompareSolvers(const FString& FaceletString)
{
// rob-twophase result
TArray<FString> RobTwophaseMoves = UHyperTwistSolverLibrary::SolveClassicState(FaceletString, 5000, 25, 1);
TArray<FString> RobMoves;
RobTwophaseSolution.ParseIntoArrayWS(RobMoves);
// cahidenes result
TArray<FString> CahidenesMoves = SolveWithCahidenesOracle(FaceletString);
return RobMoves.Num() == CahidenesMoves.Num();
}

View file

@ -0,0 +1,97 @@
#pragma once
#include "CoreMinimal.h"
#include "HAL/PlatformProcess.h"
#include "Misc/Paths.h"
#include "Containers/Ticker.h"
#include "HyperTwistIPCProcessManager.generated.h"
USTRUCT(BlueprintType)
struct FHyperTwistIPCProcessOptions
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|IPC")
FString ExecutablePath;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|IPC")
TArray<FString> Arguments;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|IPC")
FString WorkingDirectory;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|IPC")
bool bCaptureOutput = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|IPC")
bool bRedirectInput = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|IPC")
float TimeoutSeconds = 30.0f;
};
USTRUCT(BlueprintType)
struct FHyperTwistIPCResult
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "HyperTwist|IPC")
bool bSuccess = false;
UPROPERTY(BlueprintReadOnly, Category = "HyperTwist|IPC")
FString StdOut;
UPROPERTY(BlueprintReadOnly, Category = "HyperTwist|IPC")
FString StdErr;
UPROPERTY(BlueprintReadOnly, Category = "HyperTwist|IPC")
int32 ExitCode = -1;
UPROPERTY(BlueprintReadOnly, Category = "HyperTwist|IPC")
FString ErrorMessage;
};
UCLASS(ClassGroup = (HyperTwist), meta = (BlueprintSpawnableComponent))
class UNREALHYPERTWIST_API UHyperTwistIPCProcessManager : public UObject
{
GENERATED_BODY()
public:
UHyperTwistIPCProcessManager();
UFUNCTION(BlueprintCallable, Category = "HyperTwist|IPC", meta = (WorldContext = "WorldContextObject"))
static UHyperTwistIPCProcessManager* CreateIPCManager(UObject* WorldContextObject);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|IPC")
FHyperTwistIPCResult RunProcess(const FHyperTwistIPCProcessOptions& Options);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|IPC")
int32 StartProcess(const FHyperTwistIPCProcessOptions& Options);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|IPC")
bool WriteToProcess(int32 ProcessId, const FString& Line);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|IPC")
FString ReadFromProcess(int32 ProcessId);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|IPC")
bool TerminateProcess(int32 ProcessId);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|IPC")
bool IsProcessRunning(int32 ProcessId) const;
static FString GetSolversDirectory();
private:
struct FActiveProcess
{
FProcHandle Handle;
void* ReadPipe = nullptr;
void* WritePipe = nullptr;
float StartTime = 0.0f;
FString ExecutablePath;
};
TMap<int32, FActiveProcess> ActiveProcesses;
int32 NextProcessId = 1;
};

View file

@ -0,0 +1,46 @@
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "HyperTwistIPC/HyperTwistIPCProcessManager.h"
#include "HyperTwistSolverOracleLibrary.generated.h"
UCLASS()
class UNREALHYPERTWIST_API UHyperTwistSolverOracleLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
/**
* Verify a solution using the brownan GPL solver oracle (IPC).
* The solver runs as an external process; no GPL code is linked into Unreal.
*/
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Solver|Oracle")
static bool VerifySolutionWithBrownanOracle(const FString& CubeState, const TArray<FString>& Moves);
/**
* Solve a cube using the cahidenes Python solver oracle (IPC).
* Returns the solution move sequence.
*/
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Solver|Oracle")
static TArray<FString> SolveWithCahidenesOracle(const FString& FaceletString);
/**
* Compare two solvers: feed the same state to both rob-twophase and cahidenes.
* Returns true if solution lengths are identical.
*/
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Solver|Oracle")
static bool CompareSolvers(const FString& FaceletString);
/**
* Check if all solver oracle executables are present in the expected paths.
*/
UFUNCTION(BlueprintPure, Category = "HyperTwist|Solver|Oracle")
static bool AreSolverOraclesAvailable();
/** Get path to the brownan solver executable */
static FString GetBrownanSolverPath();
/** Get path to the cahidenes Python wrapper script */
static FString GetCahidenesWrapperPath();
};

View file

@ -171,10 +171,11 @@ for the full 29-repo queue and per-repo wiring posture.
- [x] Left click = clockwise, right click / bool flag = counter-clockwise
- [x] Windows build validated: 6 actions, 68.48s, exit code 0, zero warnings
### 2D — Scramble Generation
- [ ] Expose `UHyperTwistClassicCubeComponent::GenerateScramble(int32 Length)``TArray<FString>`
- [ ] Apply scramble to visible geometry (animate each move)
- [ ] Display scramble notation in HUD
### 2D — Scramble Generation ✅ COMPLETE
- [x] `GenerateScramble(int32 Length)``TArray<FString>` WCA-style notation
- [x] `ApplyScramble(MoveStrings)` parsing with ', 2 suffixes
- [x] Queued through existing RotateFace() pipeline with sequential animation
- [x] Windows build validated: 6 actions, 95.39s, exit code 0, zero warnings
### 2E — Timer + HUD
- [ ] Create `WBP_HyperTwistGameHUD` (UMG Widget)
@ -417,9 +418,15 @@ for the full 29-repo queue and per-repo wiring posture.
## Immediate Next Step
**Phase 2D:** Scramble Generation. Add `GenerateScramble(int32 Length)` and
`ApplyScramble(TArray<FString>)` to `AHyperTwistClassicCubeActor`. Generate
random WCA-standard move sequences, queue them through the existing `RotateFace()`
pipeline, and animate the full scramble.
**Phase 2E + 3:** Timer/HUD and First Playable Level. These require Unreal Editor
content creation (UMG widgets, materials, levels, game mode blueprints) that cannot
be created from text/C++ alone. The C++ backend is ready:
Then Phase 2E (Timer + HUD) and Phase 3 (First Playable Level).
- `AHyperTwistClassicCubeActor` with geometry, rotation, click input, scramble
- `UHyperTwistSolverLibrary` with rob-twophase two-phase solver
Editor-side work needed:
- Create 6 simple colored materials (White, Yellow, Green, Blue, Orange, Red)
- Create `WBP_HyperTwistGameHUD` UMG widget with timer and scramble display
- Create `L_HyperTwist_ClassicTraining` level with cube actor, lights, camera
- Create Blueprint GameMode binding click input to `ProcessClick()`