Files
pokemon/scripts/romhack-pc-migrate.py
ginnoirandClaude Opus 4.8 ae56d101b7 refactor: standardize hack filenames to [Hack] and flatten to platform root
Switch the library naming convention from "Pokemon - <Name> (Hack)" to
"Pokemon - <Name> [Hack][version][tags]", and place hacks in the platform
root (roms/<plat>/) instead of a Hacks/ subfolder. Applied across the live
valhalla library (272 hack/fan-game files) via scripts/standardize-names.py.

- romhack-import.py / romhack-fetch.py: emit [Hack], write to roms/<plat>/
- romhack-pc-migrate.py: emit [Hack]
- build-romhack-vault.py (archived): update documented library_path
- standardize-names.py: new one-shot, self-fetching standardizer (idempotent;
  pipes \n-only bytes over SSH to avoid Windows CRLF corrupting filenames)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:08:31 -05:00

206 lines
9.1 KiB
Python

#!/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/<plat>/ 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/<dir>/
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> [Hack].<ext>
- `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()