#!/usr/bin/env python3 """Migrate the PC/Windows Pokémon fan-games into the RomM-served library. The console hacks live under /storage1/Emulation/roms// and are served by RomM (roms.ginnoir.com). The PC hacks (RPG-Maker / Essentials fan-games) instead sit in the Discord-scrape staging tree /storage1/labdata/romhacks/library// and were never folded into RomM. This script places them as single archives into the `windows` RomM platform so RomM catalogs them and the Playnite plugin can download + extract + launch them like any other game. Target: /storage1/Emulation/roms/windows/Pokemon - [Hack]. - `windows` is the RomM "Microsoft Windows" platform (NOT excluded in RomM's config.yml; `pc` IS excluded, so do not use it). - Files go in the platform ROOT (mirrors the console-hack convention); a `Hacks/` subfolder would make RomM treat the whole folder as ONE multi-file game, which is wrong. - Same filesystem as labdata (st_dev match) so copy is cheap; the labdata copy is intentionally LEFT in place (it backs the vault art + romhacks-files Caddy serve + note.md/handoff.json). Each labdata folder may hold several archives (versions, languages, spoiler guides, DLC, a pre-extracted dir). Selection: - GUIDE/EXTRA archives are filtered by keyword (locations, spoiler, tutor, ...). - ARTIFACT_OVERRIDE pins the correct primary archive for the messy multi-file folders (resolved by inspection 2026-06-22). - 1 remaining candidate -> PLACE - 0 candidates -> MISSING (the 8 gated fan-games are catalog-only) - >1 and no override -> AMBIGUOUS (listed, not placed without a pick) Run on valhalla: python3 romhack-pc-migrate.py --catalog wiki/catalog.json # dry run python3 romhack-pc-migrate.py --catalog wiki/catalog.json --apply # copy """ import sys, os, re, json, shutil, unicodedata, tempfile, subprocess, zipfile LIB = "/storage1/labdata/romhacks/library" DEST = "/storage1/Emulation/roms/windows" ARCHIVE_EXT = {".zip", ".rar", ".7z"} REPACK_EXT = {".rar", ".7z"} # repackaged to .zip so Playnite auto-extract is reliable # Archives that are companion material, not the game. Matched case-insensitively # against the filename. Keeps Hydro Bliss's ~15 guide zips etc. out of the pick. GUIDE_KEYWORDS = [ "location", "spoiler", "tutor", "merchant", "aide", "prof's aide", "shortcut", "field effect", "gym field", "legendary list", "legendary loc", "tm location", "item location", "zygarde", "ace trainer", "city park", "landmark", "town merchant", "wife location", "how to transfer", "(obsolete)", "obsolete", "regionals", "trainer.zip", ] # Folders with multiple real game archives: pin the primary (latest / English). # key = on-disk folder name, value = exact archive filename to place. ARTIFACT_OVERRIDE = { "Pokemon_Asther_Violet": "PokemonAsterViolet-v1.59.210901a.zip", "Pokemon_Chaos_in_Vesita": "Pokémon Chaos in Vesita (1.1.5).zip", "Pokemon_Gadir_Deluxe": "Pokémon Gadir Deluxe ENG v1.3.zip", "Pokemon_Hydro_Bliss": "Hydro Bliss 2.13.3.zip", "Pokemon_Z": "POKEMON Z V2.16.zip", "Pokemon_Re_Union_DX": "Pokemon Reunion EX Trilogy 4.26.zip", "Pokemon_Solar_Light_Lunar_Dark": "PokmonSolarLightLunarDark1.0.1.zip", "Pokemon_Steve_the_Bibarel_Heart_of_Darkness": "Steve the Bibarel_HoD_2021.1.zip", "Pokemon_Thyme": "Pokémon Thyme.zip", "Pokemon_AfricanVs": "Pokémon Africanvs Definitive Edition.rar", "Pokemon_Birch_s_Folly": "birchs-folly-v1.2.zip", "Pokemon_Nebula_3": "Pokemon Nebula 3 v1.1.5.rar", "Pokemon_Realidea_System": "Realidea V4.1.zip", "Pokemon_A_il": "Pokemon Añil V2.09G English.rar", # English (vs ANIL V3.06 Spanish), ginnoir 2026-06-22 } def clean_name(title): """Catalog title -> canonical hack name for the library filename.""" name = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode() name = re.sub(r"^\s*pokemon\s*-?\s*", "", name, flags=re.I).strip() name = re.sub(r"[\\/:*?\"<>|]", "", name).strip() # FAT/NTFS-illegal chars return name or title def repackage_to_zip(src_archive, dest_zip): """Extract a .rar/.7z and re-zip its contents (structure preserved) into dest_zip. Uses bsdtar (libarchive) which reads rar4/rar5/7z. Returns (ok, message). Writes to a temp zip then atomically renames into place.""" tmp = tempfile.mkdtemp(prefix="pcrepack_") try: r = subprocess.run(["bsdtar", "-xf", src_archive, "-C", tmp], capture_output=True, text=True) if r.returncode != 0: return False, "bsdtar extract failed: " + (r.stderr or "").strip()[:80] entries = os.listdir(tmp) if not entries: return False, "archive extracted empty" tmp_zip = dest_zip + ".part" with zipfile.ZipFile(tmp_zip, "w", zipfile.ZIP_DEFLATED) as z: for root, _, files in os.walk(tmp): for fn in files: fp = os.path.join(root, fn) z.write(fp, os.path.relpath(fp, tmp)) os.replace(tmp_zip, dest_zip) return True, "repacked rar/7z -> zip" finally: shutil.rmtree(tmp, ignore_errors=True) def resolve_dir(scrape_dir, ondisk): if scrape_dir in ondisk: return scrape_dir alt = scrape_dir.replace("Pokemon_", "Pok_mon_") return alt if alt in ondisk else None def is_guide(fn): low = fn.lower() return any(k in low for k in GUIDE_KEYWORDS) def pick_archive(folder, disk): """Return (chosen_filename | None, all_game_candidates, status).""" files = os.listdir(folder) archives = [f for f in files if os.path.splitext(f)[1].lower() in ARCHIVE_EXT and os.path.isfile(os.path.join(folder, f))] games = [f for f in archives if not is_guide(f)] if disk in ARTIFACT_OVERRIDE: pin = ARTIFACT_OVERRIDE[disk] if pin in files: return pin, games, "PLACE" return None, games, "OVERRIDE-MISSING" # override names a file that's gone if len(games) == 1: return games[0], games, "PLACE" if len(games) == 0: return None, archives, "MISSING" return None, games, "AMBIGUOUS" def main(): apply = "--apply" in sys.argv repack = "--no-repack" not in sys.argv catalog = "wiki/catalog.json" if "--catalog" in sys.argv: catalog = sys.argv[sys.argv.index("--catalog") + 1] cat = json.load(open(catalog, encoding="utf-8")) pc = [h for h in cat["hacks"] if str(h.get("platform")) == "PC"] ondisk = set(os.listdir(LIB)) rows = [] # (status, title, detail, dest) placed = skipped = 0 if apply: os.makedirs(DEST, exist_ok=True) for it in pc: title = it.get("title") or it.get("stem") or "?" disk = resolve_dir(it["scrape_dir"], ondisk) if not disk: rows.append(("NO-FILES", title, "catalog-only (gated/Wanted)", "")) continue folder = os.path.join(LIB, disk) chosen, cands, status = pick_archive(folder, disk) if status != "PLACE": detail = { "MISSING": "no game archive on disk", "AMBIGUOUS": "multiple: " + " | ".join(cands), "OVERRIDE-MISSING": "pinned archive not found", }.get(status, status) rows.append((status, title, detail, "")) continue src_ext = os.path.splitext(chosen)[1].lower() will_repack = repack and src_ext in REPACK_EXT out_ext = ".zip" if will_repack else src_ext out_name = f"Pokemon - {clean_name(title)} [Hack]{out_ext}" dest = os.path.join(DEST, out_name) note = " [repack rar/7z->zip]" if will_repack else ( " [RAR as-is; extract manually if Playnite balks]" if src_ext == ".rar" else "") if os.path.exists(dest): rows.append(("EXISTS", title, chosen + note, f"windows/{out_name}")) skipped += 1 continue if apply: src = os.path.join(folder, chosen) if will_repack: ok, msg = repackage_to_zip(src, dest) if not ok: rows.append(("REPACK-FAIL", title, f"{chosen}: {msg}", "")) continue else: shutil.copy2(src, dest) rows.append(("PLACE", title, chosen + note, f"windows/{out_name}")) placed += 1 order = {"PLACE": 0, "EXISTS": 1, "REPACK-FAIL": 2, "AMBIGUOUS": 3, "MISSING": 4, "OVERRIDE-MISSING": 5, "NO-FILES": 6} print(f"\n=== PC MIGRATE ({'APPLIED' if apply else 'DRY RUN'}) -> {DEST} ===") for st, title, detail, dest in sorted(rows, key=lambda r: (order.get(r[0], 9), r[1])): print(f"[{st:16}] {title[:40]:40} {detail}") if dest: print(f"{'':19}-> {dest}") from collections import Counter c = Counter(r[0] for r in rows) print(f"\nsummary: {dict(sorted(c.items()))}") print(f"{'copied' if apply else 'would copy'}: {placed} already present: {skipped}") if not apply and placed: print("\nre-run with --apply to copy. Resolve AMBIGUOUS via ARTIFACT_OVERRIDE first.") if __name__ == "__main__": main()