Place RPG-Maker/Essentials hacks into RomM's windows library with dry-run migration, Playnite Game.exe launcher, and Ludusavi save-path manifest.
206 lines
9.1 KiB
Python
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()
|