feat: add PC fan-game migration tooling for RomM windows platform
Place RPG-Maker/Essentials hacks into RomM's windows library with dry-run migration, Playnite Game.exe launcher, and Ludusavi save-path manifest.
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user