hypertwist/Solvers/brownan/brownan_solver_wrapper.py
2026-06-11 06:32:20 +00:00

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)