312 lines
11 KiB
Python
312 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
HyperTwist IPC wrapper for brownan GPL Rubik's Cube solver oracle.
|
|
|
|
Input: Either a 54-character facelet string (kociemba order: U R F D L B)
|
|
or a 120-character brownan raw cube string.
|
|
|
|
Output: Space-separated WCA move notation (U, D, F, B, L, R, with ' and 2 suffixes).
|
|
Brownan native output uses faces F, T, L, B, D, R where T=U in WCA.
|
|
"""
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
# Map brownan native face letters to WCA face letters
|
|
BROWNAN_TO_WCA = {
|
|
"T": "U", # Top in brownan = Up in WCA
|
|
"D": "D",
|
|
"F": "F",
|
|
"B": "B",
|
|
"L": "L",
|
|
"R": "R",
|
|
}
|
|
|
|
VALID_BROWNAN_FACES = set(BROWNAN_TO_WCA.keys())
|
|
|
|
|
|
def find_brownan_exe():
|
|
"""Locate the brownan solver executable next to this wrapper."""
|
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
candidates = [
|
|
os.path.join(script_dir, "..", "..", "UnrealHyperTwist", "Binaries", "Win64", "Solvers", "brownan-solver.exe"),
|
|
os.path.join(script_dir, "brownan-solver.exe"),
|
|
os.path.join(script_dir, "..", "brownan-solver.exe"),
|
|
]
|
|
for candidate in candidates:
|
|
resolved = os.path.abspath(candidate)
|
|
if os.path.isfile(resolved):
|
|
return resolved
|
|
|
|
for candidate in ("brownan-solver.exe", "brownan-solver"):
|
|
resolved = shutil.which(candidate)
|
|
if resolved:
|
|
return os.path.abspath(resolved)
|
|
|
|
return None
|
|
|
|
|
|
def ensure_tables(brownan_exe: str) -> bool:
|
|
"""Check that brownan heuristic tables exist next to the executable."""
|
|
exe_dir = os.path.dirname(os.path.abspath(brownan_exe))
|
|
tables = ["table_corner.rht", "table_edge1.rht", "table_edge2.rht"]
|
|
missing = [t for t in tables if not os.path.exists(os.path.join(exe_dir, t))]
|
|
if missing:
|
|
print(
|
|
f"WARNING: Brownan tables missing: {missing}. "
|
|
"Solving without tables will be very slow. "
|
|
"Run table generation first by invoking brownan-solver.exe interactively "
|
|
"and selecting options 2, 3, and 4.",
|
|
file=sys.stderr,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def convert_move(brownan_move: str) -> str:
|
|
"""Convert a single brownan move token to WCA notation."""
|
|
token = brownan_move.strip()
|
|
if not token:
|
|
return ""
|
|
|
|
if token.startswith("2"):
|
|
face = token[1]
|
|
suffix = "2"
|
|
elif token.endswith("'"):
|
|
face = token[0]
|
|
suffix = "'"
|
|
else:
|
|
face = token[0]
|
|
suffix = ""
|
|
|
|
wca_face = BROWNAN_TO_WCA.get(face, face)
|
|
return f"{wca_face}{suffix}"
|
|
|
|
|
|
def is_brownan_move_token(token: str) -> bool:
|
|
"""Return True when a token matches brownan's native move format."""
|
|
token = token.strip()
|
|
if not token:
|
|
return False
|
|
if token.startswith("2"):
|
|
return len(token) == 2 and token[1] in VALID_BROWNAN_FACES
|
|
if token.endswith("'"):
|
|
return len(token) == 2 and token[0] in VALID_BROWNAN_FACES
|
|
return len(token) == 1 and token in VALID_BROWNAN_FACES
|
|
|
|
|
|
def parse_solution_moves(stdout: str) -> str:
|
|
"""Extract the first parseable brownan solution line from solver stdout."""
|
|
in_solution = False
|
|
for line in stdout.splitlines():
|
|
stripped = line.strip()
|
|
if stripped == "Solution found!":
|
|
in_solution = True
|
|
continue
|
|
if not in_solution or not stripped:
|
|
continue
|
|
# The actual move sequence can be preceded by brownan status chatter.
|
|
if stripped.startswith("Continuing"):
|
|
continue
|
|
|
|
tokens = stripped.split()
|
|
if not all(is_brownan_move_token(token) for token in tokens):
|
|
continue
|
|
|
|
moves = [convert_move(token) for token in tokens]
|
|
return " ".join(move for move in moves if move)
|
|
|
|
return ""
|
|
|
|
|
|
def facelet_to_brownan_raw(facelet: str) -> str:
|
|
"""Convert a 54-character kociemba facelet string to brownan 120-char raw format."""
|
|
if len(facelet) != 54:
|
|
raise ValueError(f"Facelet string must be 54 characters, got {len(facelet)}")
|
|
|
|
# Map input face labels/colors to brownan's fixed color scheme based on centers.
|
|
# Brownan fixed centers: U=r, L=b, F=w, R=g, B=y, D=o
|
|
color_map = {
|
|
facelet[4]: "r", # U center
|
|
facelet[13]: "g", # R center
|
|
facelet[22]: "w", # F center
|
|
facelet[31]: "o", # D center
|
|
facelet[40]: "b", # L center
|
|
facelet[49]: "y", # B center
|
|
}
|
|
|
|
if len(set(color_map.keys())) != 6:
|
|
raise ValueError("Invalid facelet string: center pieces are not all distinct")
|
|
|
|
def remap(s: str) -> str:
|
|
return "".join(color_map.get(c, c) for c in s)
|
|
|
|
# Build 48-position brownan layout string (1-indexed internally).
|
|
# brownan layout (centers fixed):
|
|
# 1 2 3
|
|
# 4 R 5
|
|
# 6 7 8
|
|
# 9 10 11 12 13 14 15 16 17 18 19 20
|
|
# 21 B 22 23 W 24 25 G 26 27 Y 28
|
|
# 29 30 31 32 33 34 35 36 37 38 39 40
|
|
# 41 42 43
|
|
# 44 O 45
|
|
# 46 47 48
|
|
layout = [""] * 49
|
|
|
|
u = remap(facelet[0:9])
|
|
layout[1], layout[2], layout[3] = u[0], u[1], u[2]
|
|
layout[4], layout[5] = u[3], u[5]
|
|
layout[6], layout[7], layout[8] = u[6], u[7], u[8]
|
|
|
|
r = remap(facelet[9:18])
|
|
layout[15], layout[16], layout[17] = r[0], r[1], r[2]
|
|
layout[25], layout[26] = r[3], r[5]
|
|
layout[35], layout[36], layout[37] = r[6], r[7], r[8]
|
|
|
|
f = remap(facelet[18:27])
|
|
layout[12], layout[13], layout[14] = f[0], f[1], f[2]
|
|
layout[23], layout[24] = f[3], f[5]
|
|
layout[32], layout[33], layout[34] = f[6], f[7], f[8]
|
|
|
|
d = remap(facelet[27:36])
|
|
layout[41], layout[42], layout[43] = d[0], d[1], d[2]
|
|
layout[44], layout[45] = d[3], d[5]
|
|
layout[46], layout[47], layout[48] = d[6], d[7], d[8]
|
|
|
|
l = remap(facelet[36:45])
|
|
layout[9], layout[10], layout[11] = l[0], l[1], l[2]
|
|
layout[21], layout[22] = l[3], l[5]
|
|
layout[29], layout[30], layout[31] = l[6], l[7], l[8]
|
|
|
|
# Back face may need mirroring/rotation in the unfolded net.
|
|
# Direct mapping is used here; adjust if brownan rejects valid cubes.
|
|
b = remap(facelet[45:54])
|
|
layout[18], layout[19], layout[20] = b[0], b[1], b[2]
|
|
layout[27], layout[28] = b[3], b[5]
|
|
layout[38], layout[39], layout[40] = b[6], b[7], b[8]
|
|
|
|
# Original cube_convert.py prepends '@' because the author used wrong indexes.
|
|
cc = "@" + "".join(layout[1:49])
|
|
|
|
# Extract 20 cubies exactly as cube_convert.py does.
|
|
cubies = [
|
|
"nn%s%s%sn" % (cc[29], cc[40], cc[46]),
|
|
"nnn%s%sn" % (cc[39], cc[47]),
|
|
"nnn%s%s%s" % (cc[38], cc[48], cc[37]),
|
|
"nn%s%snn" % (cc[21], cc[28]),
|
|
"nnn%sn%s" % (cc[27], cc[26]),
|
|
"n%s%s%snn" % (cc[1], cc[9], cc[20]),
|
|
"n%sn%snn" % (cc[2], cc[19]),
|
|
"n%sn%sn%s" % (cc[3], cc[18], cc[17]),
|
|
"nn%sn%sn" % (cc[30], cc[44]),
|
|
"nnnn%s%s" % (cc[45], cc[36]),
|
|
"n%s%snnn" % (cc[4], cc[10]),
|
|
"n%snnn%s" % (cc[5], cc[16]),
|
|
"%sn%sn%sn" % (cc[32], cc[31], cc[41]),
|
|
"%snnn%sn" % (cc[33], cc[42]),
|
|
"%snnn%s%s" % (cc[34], cc[43], cc[35]),
|
|
"%sn%snnn" % (cc[23], cc[22]),
|
|
"%snnnn%s" % (cc[24], cc[25]),
|
|
"%s%s%snnn" % (cc[12], cc[6], cc[11]),
|
|
"%s%snnnn" % (cc[13], cc[7]),
|
|
"%s%snnn%s" % (cc[14], cc[8], cc[15]),
|
|
]
|
|
|
|
def select(pred):
|
|
matches = [c for c in cubies if pred(c)]
|
|
if not matches:
|
|
raise ValueError("Invalid cube: not all cubies accounted for")
|
|
return matches[0]
|
|
|
|
fc = ""
|
|
fc += select(lambda a: "y" in a and "b" in a and "o" in a)
|
|
fc += select(lambda a: "y" in a and "o" in a and a.count("n") == 4)
|
|
fc += select(lambda a: "y" in a and "o" in a and "g" in a)
|
|
fc += select(lambda a: "y" in a and "b" in a and a.count("n") == 4)
|
|
fc += select(lambda a: "y" in a and "g" in a and a.count("n") == 4)
|
|
fc += select(lambda a: "y" in a and "b" in a and "r" in a)
|
|
fc += select(lambda a: "y" in a and "r" in a and a.count("n") == 4)
|
|
fc += select(lambda a: "y" in a and "r" in a and "g" in a)
|
|
fc += select(lambda a: "o" in a and "b" in a and a.count("n") == 4)
|
|
fc += select(lambda a: "o" in a and "g" in a and a.count("n") == 4)
|
|
fc += select(lambda a: "b" in a and "r" in a and a.count("n") == 4)
|
|
fc += select(lambda a: "g" in a and "r" in a and a.count("n") == 4)
|
|
fc += select(lambda a: "w" in a and "b" in a and "o" in a)
|
|
fc += select(lambda a: "w" in a and "o" in a and a.count("n") == 4)
|
|
fc += select(lambda a: "w" in a and "o" in a and "g" in a)
|
|
fc += select(lambda a: "w" in a and "b" in a and a.count("n") == 4)
|
|
fc += select(lambda a: "w" in a and "g" in a and a.count("n") == 4)
|
|
fc += select(lambda a: "w" in a and "b" in a and "r" in a)
|
|
fc += select(lambda a: "w" in a and "r" in a and a.count("n") == 4)
|
|
fc += select(lambda a: "w" in a and "r" in a and "g" in a)
|
|
|
|
if len(fc) != 120:
|
|
raise ValueError(f"Conversion error: final code length is {len(fc)}, expected 120")
|
|
|
|
return fc
|
|
|
|
|
|
def solve(cube_input: str, brownan_exe: str = None) -> str:
|
|
"""Solve a cube from either a 54-char facelet or 120-char brownan raw string."""
|
|
cube_input = cube_input.strip()
|
|
|
|
if len(cube_input) == 54:
|
|
raw_cube = facelet_to_brownan_raw(cube_input)
|
|
elif len(cube_input) == 120:
|
|
raw_cube = cube_input
|
|
else:
|
|
print(f"ERROR: Input must be 54-char facelet or 120-char brownan raw, got {len(cube_input)}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if brownan_exe is None:
|
|
brownan_exe = find_brownan_exe()
|
|
if not brownan_exe or not os.path.exists(brownan_exe):
|
|
print("ERROR: brownan-solver executable not found", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
ensure_tables(brownan_exe)
|
|
|
|
exe_dir = os.path.dirname(os.path.abspath(brownan_exe))
|
|
try:
|
|
result = subprocess.run(
|
|
[brownan_exe, raw_cube],
|
|
cwd=exe_dir,
|
|
text=True,
|
|
capture_output=True,
|
|
timeout=300,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
print("ERROR: Brownan solver timed out (consider generating heuristic tables)", file=sys.stderr)
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if result.returncode != 0:
|
|
details = result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}"
|
|
print(f"ERROR: Brownan solver failed: {details}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
solution = parse_solution_moves(result.stdout)
|
|
if solution:
|
|
return solution
|
|
|
|
stdout_preview = result.stdout.strip()
|
|
stderr_preview = result.stderr.strip()
|
|
details = stderr_preview or stdout_preview or "solver returned no output"
|
|
print(f"ERROR: Brownan solver returned no parseable solution: {details}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description="HyperTwist Brownan Solver Wrapper")
|
|
parser.add_argument("cube_input", help="54-char facelet string or 120-char brownan raw string")
|
|
parser.add_argument("--exe", help="Path to brownan-solver executable")
|
|
args = parser.parse_args()
|
|
|
|
result = solve(args.cube_input, args.exe)
|
|
print(result)
|