diff --git a/scripts/cucholix/cucholix_aio.py b/scripts/cucholix/cucholix_aio.py new file mode 100644 index 0000000..3398ed0 --- /dev/null +++ b/scripts/cucholix/cucholix_aio.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +""" +Headless extractor using unar/7z (no Keka). Designed for the NX-IPS-romfs-compilation repo. +Installs required: `brew install unar p7zip` +Set KEEP_TMP=1 to preserve temp extraction folders for debugging. +""" +import os, re, sys, shutil, tempfile, subprocess, unicodedata, urllib.request, zipfile + +# ---------------- Config ---------------- +TMP_ROOT = os.path.expanduser("~/keka_extract_tmp") # safe, in HOME +KEEP_TMP = os.environ.get("KEEP_TMP", "") != "" +# ---------------- Helpers (naming/title) ---------------- +def sanitize_name(name): + n = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii") + for ch in ("'", "’", "`", '"'): + n = n.replace(ch, "") + n = n.replace(" - ", " ") + return " ".join(n.split()).strip() + +def capitalize_hyphenated(word): + parts = word.split("-") + out = [] + for p in parts: + if not p: + out.append("") + elif len(p) == 1: + out.append(p.upper()) + else: + out.append(p[0].upper() + p[1:].lower()) + return "-".join(out) + +ROMAN_NUMERAL_PATTERN = re.compile(r"^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$", re.IGNORECASE) +ACRONYMS = {"HD","2D","3D","4K","VR","AI","API","USB","CPU","GPU","DVD","CD", + "RPG","FPS","MMO","MMORPG","LAN","GUI","NPC", + "FFVII","FFVIII","FFIX","FFX","FFXII","FX","2K","5K","8K","V1","V2","V3","V4","DOF"} + +def is_roman_numeral(w): return bool(ROMAN_NUMERAL_PATTERN.match(w)) + +def title_case_preserve_numbers(name): + lowercase_exceptions = {"a","an","and","as","at","but","by","for","from","in","nor","of","on","or","so","the","to","with","yet"} + subtitle_markers = {":","~","-","–","—"} + words = name.split() + result = [] + force_cap = False + for idx, word in enumerate(words): + contains_marker = any(m in word for m in subtitle_markers) + parts = re.split(r'([:~\-–—])', word) + out_parts = [] + for part in parts: + if part in subtitle_markers: + out_parts.append(part) + force_cap = True + continue + lp = part.lower() + is_first = idx == 0 + is_last = idx == len(words) - 1 + def cap_special(w): + if w.upper() in ACRONYMS: return w.upper() + if is_roman_numeral(w): return w.upper() + for sep in ('&','+','|'): + if sep in w: + sub = w.split(sep) + if all(is_roman_numeral(x) for x in sub): + return sep.join(x.upper() for x in sub) + return capitalize_hyphenated(w) + if force_cap or is_first or is_last or (lp not in lowercase_exceptions): + out_parts.append('-'.join(cap_special(sp) for sp in part.split('-'))) + else: + out_parts.append(lp) + result.append(''.join(out_parts)) + if not contains_marker: + force_cap = False + if result: + result[0] = '-'.join(sp.upper() if (sp.upper() in ACRONYMS or is_roman_numeral(sp)) else capitalize_hyphenated(sp) for sp in result[0].split('-')) + result[-1] = '-'.join(sp.upper() if (sp.upper() in ACRONYMS or is_roman_numeral(sp)) else capitalize_hyphenated(sp) for sp in result[-1].split('-')) + return ' '.join(result) + +def find_title_path(path): + hex_re = re.compile(r'^[0-9A-Fa-f]{16}$') + for root, dirs, _ in os.walk(path): + for d in dirs: + if hex_re.match(d): + return os.path.join(root, d) + return None + +# ---------------- Extraction functions ---------------- +def which_exec(names): + for n in names: + p = shutil.which(n) + if p: + return p + return None + +UNAR = which_exec(["unar"]) +SEVENZ = which_exec(["7z", "7zz"]) # prefer 7z if present + +def extract_with_unar(archive_path, outdir): + if UNAR is None: + return False, "unar not installed" + os.makedirs(outdir, exist_ok=True) + cmd = [UNAR, "-o", outdir, "-f", archive_path] # -f overwrite if needed + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out = proc.stdout.decode(errors="replace") + err = proc.stderr.decode(errors="replace") + ok = (proc.returncode == 0) + return ok, f"rc={proc.returncode} stdout={out[:1000]!r} stderr={err[:1000]!r}" + +def extract_with_7z(archive_path, outdir): + if SEVENZ is None: + return False, "7z not installed" + os.makedirs(outdir, exist_ok=True) + # 7z expects -o (no space) — but passing as single arg works too for many wrappers; use concatenated to be safe + cmd = [SEVENZ, "x", archive_path, f"-o{outdir}", "-y"] + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out = proc.stdout.decode(errors="replace") + err = proc.stderr.decode(errors="replace") + ok = (proc.returncode == 0) + return ok, f"rc={proc.returncode} stdout={out[:1000]!r} stderr={err[:1000]!r}" + +def extract_archive_headless(archive_path, outdir): + """ + Try best extractor for extension: + .rar -> unar then 7z + .7z -> 7z then unar + Returns (True, debugstr) or (False, debugstr) + """ + ext = archive_path.lower().rsplit(".",1)[-1] + if ext == "rar": + # prefer unar for rar + if UNAR: + ok, dbg = extract_with_unar(archive_path, outdir) + if ok: return True, "unar: " + dbg + # else try 7z + if SEVENZ: + ok, dbg = extract_with_7z(archive_path, outdir) + if ok: return True, "7z: " + dbg + return False, "no suitable extractor succeeded; tried unar and 7z. " + (dbg if 'dbg' in locals() else "") + elif ext == "7z": + if SEVENZ: + ok, dbg = extract_with_7z(archive_path, outdir) + if ok: return True, "7z: " + dbg + if UNAR: + ok, dbg = extract_with_unar(archive_path, outdir) + if ok: return True, "unar: " + dbg + return False, "no suitable extractor succeeded for .7z" + else: + # generic try unar then 7z + if UNAR: + ok, dbg = extract_with_unar(archive_path, outdir) + if ok: return True, "unar: " + dbg + if SEVENZ: + ok, dbg = extract_with_7z(archive_path, outdir) + if ok: return True, "7z: " + dbg + return False, "no extractor available for extension: " + ext + +# ---------------- Processing per archive ---------------- +def process_rar(root_folder, rar_relpath, output_root): + subdir, filename = os.path.split(rar_relpath) + version_match = re.match(r"release_(.+?)(?:\.part\d+)?\.(rar|7z)$", filename, re.IGNORECASE) + if not version_match: + print(f"❌ Invalid release name: {filename}") + return + version = version_match.group(1) + raw_game_name = os.path.basename(subdir) + cleaned_name = sanitize_name(raw_game_name) + game_name = title_case_preserve_numbers(cleaned_name) + pack_label = f"{game_name} - Graphics Pack" + + rar_path = os.path.join(root_folder, rar_relpath) + + os.makedirs(TMP_ROOT, exist_ok=True) + tmpdir = tempfile.mkdtemp(prefix="extract_", dir=TMP_ROOT) + try: + ok, dbg = extract_archive_headless(rar_path, tmpdir) + if not ok: + print(f"❌ Extraction error (headless) on {rar_relpath}: {dbg}") + if KEEP_TMP: + print("Temp dir kept at:", tmpdir) + return + + title_path = find_title_path(tmpdir) + if not title_path: + # maybe files extracted but no TitleID folder, show debugging + any_files = False + for _r,_d,files in os.walk(tmpdir): + if files: + any_files = True + break + if any_files: + print(f"❌ Extracted but no TitleID found for {rar_relpath}. Inspect: {tmpdir} — dbg: {dbg}") + else: + print(f"❌ No files extracted for {rar_relpath}. dbg: {dbg}") + if KEEP_TMP: + print("Temp dir kept at:", tmpdir) + return + + title_id = os.path.basename(title_path) + version_dir = os.path.join(output_root, pack_label, version) + os.makedirs(version_dir, exist_ok=True) + dst = os.path.join(version_dir, title_id) + try: + shutil.copytree(title_path, dst, dirs_exist_ok=True) + print(f"✅ {rar_relpath} → {os.path.join(pack_label, version, title_id)}") + except Exception as e: + print(f"❌ Copy failed for {rar_relpath}: {e}") + + finally: + if KEEP_TMP: + print("Kept tmpdir for debugging:", tmpdir) + else: + try: + shutil.rmtree(tmpdir) + except Exception: + pass + +# ---------------- pchtxt processing (unchanged) ---------------- +def process_pchtxt(root_folder, output_path): + os.makedirs(output_path, exist_ok=True) + print(f"Creating formatted pchtxt structure at: {output_path}\n") + for current_root, dirs, files in os.walk(root_folder): + if 'contents_formatted' in current_root or 'pchtxt_formatted' in current_root: + continue + for file in files: + if not file.lower().endswith('.pchtxt'): + continue + version = file[:-len('.pchtxt')].strip() + parent_dir = os.path.basename(current_root) + game_name = sanitize_name(parent_dir) + if game_name.endswith("Graphics"): + game_name = game_name[:-len("Graphics")].strip() + game_name = title_case_preserve_numbers(game_name) + mod_name = "Graphics Mods" + target_dir = os.path.join(output_path, f"{game_name} - {mod_name}") + os.makedirs(target_dir, exist_ok=True) + source_path = os.path.join(current_root, file) + dest_path = os.path.join(target_dir, f"{version}.pchtxt") + shutil.copy2(source_path, dest_path) + print(f"Copied {source_path} → {dest_path}") + print("\nDone processing pchtxt!") + +# ---------------- main ---------------- +def main(): + # refuse to run as root — running as root was causing /var/root/ issues earlier + if hasattr(os, "geteuid") and os.geteuid() == 0: + print("Refusing to run as root. Run this script as your normal user (no sudo).") + sys.exit(1) + + zip_url = "https://github.com/cucholix/NX-IPS-romfs-compilation/archive/refs/heads/main.zip" + zip_path = "NX-IPS-romfs-compilation-main.zip" + unzip_dir = "NX-IPS-romfs-compilation-main" + + if not os.path.exists(unzip_dir): + if not os.path.exists(zip_path): + print("Downloading repo zip...") + urllib.request.urlretrieve(zip_url, zip_path) + print("Download complete.") + print("Unzipping repo...") + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + zip_ref.extractall(".") + print("Unzip complete.") + + root = unzip_dir + output_contents = os.path.join(".", "contents_formatted") + os.makedirs(output_contents, exist_ok=True) + + tasks = [] + for dirpath, _, files in os.walk(root): + for fn in files: + if re.match(r"release_(.+?)(?:\.part\d+)?\.(rar|7z)$", fn, re.IGNORECASE): + if fn.lower().endswith(".part01.rar") or (fn.lower().endswith(".rar") and ".part" not in fn.lower()) or fn.lower().endswith(".7z"): + rel = os.path.relpath(os.path.join(dirpath, fn), root) + tasks.append(rel) + + if not UNAR and not SEVENZ: + print("ERROR: neither 'unar' nor '7z' found. Install with: brew install unar p7zip") + sys.exit(1) + + for relpath in tasks: + process_rar(root, relpath, output_contents) + + print("✅ Done processing content mods.") + output_pchtxt = os.path.join(".", "pchtxt_formatted") + process_pchtxt(root, output_pchtxt) + +if __name__ == '__main__': + main()