Complete curated-list import automation and vault sync.

Adds list-import tooling, expands romhack-import for PC variants and patch batches, and syncs catalog with 2026-06-23 valhalla placements (Vanguard, Xenoverse, GBA patches, Infinity 3.3.1).
This commit is contained in:
ginnoir
2026-06-23 03:57:50 -05:00
parent 0537ceba5d
commit 52ee1b98a1
8 changed files with 1897 additions and 73 deletions
+160 -4
View File
@@ -21,6 +21,7 @@ import sys, os, re, zlib, shutil, subprocess, zipfile, unicodedata, tempfile
ROMS = "/storage1/Emulation/roms"
PATCHES = "/storage1/igir/romhacks/patches"
APPLY = "/storage1/igir/romhacks/tools/apply.py"
WIN_DEST = "/storage1/Emulation/roms/windows"
BASES = {
"crystal_rev1": f"{ROMS}/gbc/Pokemon - Crystal Version (USA, Europe) (Rev 1).gbc",
@@ -31,17 +32,20 @@ BASES = {
"emerald": f"{ROMS}/gba/Pokemon - Emerald Version (USA, Europe).gba",
"firered": f"{ROMS}/gba/Pokemon - FireRed Version (USA, Europe).gba",
"leafgreen": f"{ROMS}/gba/Pokemon - LeafGreen Version (USA, Europe).gba",
"firered_jp": f"{ROMS}/gba/Pokemon - Fire Red (J) (V1.0).gba",
"red": f"{ROMS}/gb/Pokemon - Red Version (USA, Europe) (SGB Enhanced).gb",
}
# candidate bases to try per target platform (order = preference)
CANDIDATES = {
"gba": ["emerald", "firered", "leafgreen", "ruby", "sapphire"],
"gba": ["firered_jp", "emerald", "firered", "leafgreen", "ruby", "sapphire"],
"gbc": ["crystal_rev1", "gold", "silver"],
"gb": ["red"],
}
# IPS patches (no checksum) need an explicit base by name substring
IPS_BASE_MAP = [
("seaglass", "emerald", "gba"),
("emeraldseaglass", "emerald", "gba"),
("azure horizons", "firered", "gba"),
("sienna", "ruby", "gba"),
("crystal kaizo", "crystal_rev1", "gbc"),
("kaizo", "crystal_rev1", "gbc"), # fallback for crystal kaizo rar
]
@@ -57,6 +61,23 @@ ARCHIVE_EXT = {".zip", ".rar", ".7z"}
NAME_OVERRIDES = [
("emeraldseaglass", "Emerald Seaglass"),
("emerald seaglass", "Emerald Seaglass"),
("azure horizons", "Azure Horizons"),
("vega fairy", "Vega Fairy EX"),
("spades", "Spades and Clubs"),
("ft ex", "Floral Tempus"),
("floral tempus", "Floral Tempus"),
("hero legacy", "Hero Legacy"),
("mega adventure", "Mega Adventures"),
("hgss sevii", "HGSS Sevii"),
("jam festival", "Jam Festival"),
("legends of the arena", "Legends of the Arena"),
("fire ash", "Fire Ash"),
("eon guardians", "Eon Guardians"),
("shattered light", "Shattered Light"),
("lightplatinum", "Light Platinum DS"),
("reminiscencia", "Reminiscencia"),
("xenoverse", "Xenoverse"),
("prisme", "Prisme"),
("lazarus", "Lazarus"),
("saiph 2", "Saiph 2"), ("saiph2", "Saiph 2"),
("saiph", "Saiph"),
@@ -68,12 +89,51 @@ NAME_OVERRIDES = [
]
# Files we will NOT auto-place (unidentified / need user to name). Reported instead.
SKIP_NAME_SUBSTR = ["beta 15 + expansion"]
# Known base ROMs in a drop folder — place under roms/<plat>/, not Hacks/
BASE_ROM_FILES = [
("fire red (j)", "gba", "Pokemon - Fire Red (J) (V1.0).gba"),
]
# Filename hints -> RomM variant tag (separate catalog/download entries).
VARIANT_RULES = [
(r"joiplay|\.jgp\b", "[Joiplay]"),
(r"android", "[Android]"),
(r"linux", "[Linux]"),
(r"macos|\.dmg\b", "[macOS]"),
(r"installer", "[Installer]"),
(r"where love lies", "[DLC]"),
(r"infinitefusion\.zip$", "[Full]"), # 4 GB build vs e18 primary
]
PLACE = False
report = []
ARCH_SUBDIR = "" # set while recursing an archive, groups its docs under _docs/<subdir>/
def detect_variant(filename):
low = os.path.basename(filename).lower()
for pat, tag in VARIANT_RULES:
if re.search(pat, low):
return tag
return None
def pc_dest_dir(variant):
if variant == "[Linux]":
return os.path.join(ROMS, "linux")
if variant == "[macOS]":
return os.path.join(ROMS, "macintosh")
return WIN_DEST
def is_pc_fan_game(root):
"""RPG-Maker / Essentials tree — Game.exe, Game.ini, or mkxp port."""
for walk_root, _, files in os.walk(root):
low = {f.lower() for f in files}
if "game.exe" in low or "game.ini" in low or "mkxp.json" in low:
return True
return False
def strip_trailing_groups(name):
"""Remove trailing (...) / [...] groups that look like version/variant junk."""
verkw = re.compile(r"(\d|version|hotfix|bug.?fix|final|anniversary|classic\+|"
@@ -187,6 +247,17 @@ def place_patch(patch, origin):
if not ok:
report.append(("PATCH", "FAIL", origin, f"ips/{bk}: {msg[:50]}", "")); return
place_rom(tmp_out, plat, hackname, origin); return
if ext == ".ups" and "vega" in patch.lower():
# Vega Fairy EX UPS targets 32 MB FireRed (US/EU); JP 1.0 is 16 MB.
for base_key in ("firered", "firered_jp"):
if not os.path.exists(BASES[base_key]):
continue
ok, msg = try_apply(patch, base_key, tmp_out)
if ok:
place_rom(tmp_out, "gba", hackname, origin + f" [base={base_key}]")
return
report.append(("PATCH", "FAIL", origin, f"vega ups: {msg[:50]}", ""))
return
if ext == ".xdelta":
report.append(("PATCH", "SKIP", origin, "xdelta: handle via xdelta tool", "")); return
# BPS / UPS: auto-detect base across all platforms (CRC-verified)
@@ -208,8 +279,20 @@ def archive_doc(path, origin):
report.append(("DOC", "OK", origin, f"{os.path.getsize(path):,}b", rel))
def place_base_rom(src, plat, out_name, origin):
if PLACE:
d = os.path.join(ROMS, plat); os.makedirs(d, exist_ok=True)
shutil.copy2(src, os.path.join(d, out_name))
report.append(("BASE", "OK", origin, f"{os.path.getsize(src):,}b", f"{plat}/{out_name}"))
def process_file(path, origin=None):
origin = origin or os.path.basename(path)
base = os.path.basename(path).lower()
for sub, plat, out_name in BASE_ROM_FILES:
if sub in base:
place_base_rom(path, plat, out_name, origin)
return
ext = os.path.splitext(path)[1].lower()
if ext in ROM_EXT:
place_rom(path, ROM_EXT[ext], clean_name(path), origin)
@@ -219,10 +302,70 @@ def process_file(path, origin=None):
archive_doc(path, origin)
elif ext in ARCHIVE_EXT:
process_archive(path, origin)
elif ext == ".exe":
place_pc_installer(path, clean_name(path), origin)
elif ext == ".dmg":
place_pc_archive(path, clean_name(path), origin)
elif ext == ".jgp":
place_pc_archive(path, clean_name(path), origin, variant="[Joiplay]")
else:
report.append(("SKIP", "?", origin, f"unknown ext {ext}", ""))
def place_pc_archive(path, hackname, origin, variant=None):
tag = variant or detect_variant(path) or ""
suffix = f" {tag}" if tag else ""
out_name = f"Pokemon - {hackname} (Hack){suffix}.zip"
dest_dir = pc_dest_dir(tag)
dest = os.path.join(dest_dir, out_name)
ext = os.path.splitext(path)[1].lower()
if ext == ".zip":
if PLACE:
os.makedirs(dest_dir, exist_ok=True)
shutil.copy2(path, dest)
report.append(("PC", "OK", origin, f"{os.path.getsize(path):,}b zip", f"{dest_dir.split('/')[-1]}/{out_name}"))
return
if ext == ".dmg":
# Repack macOS disk image -> zip for RomM download (extract with bsdtar).
ext = ".dmg"
tmp = tempfile.mkdtemp(prefix="pcrepack_")
try:
r = subprocess.run(["bsdtar", "-xf", path, "-C", tmp], capture_output=True, text=True)
if r.returncode != 0:
report.append(("PC", "FAIL", origin, (r.stderr or "bsdtar failed")[:60], "")); return
tmp_zip = dest + ".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))
if PLACE:
os.makedirs(dest_dir, exist_ok=True)
os.replace(tmp_zip, dest)
else:
os.remove(tmp_zip)
report.append(("PC", "OK", origin, f"repacked {ext}->zip", f"{dest_dir.split('/')[-1]}/{out_name}"))
finally:
shutil.rmtree(tmp, ignore_errors=True)
def place_pc_installer(path, hackname, origin):
"""Wrap a standalone .exe installer in a zip so RomM can serve it."""
tag = detect_variant(path) or "[Installer]"
out_name = f"Pokemon - {hackname} (Hack) {tag}.zip"
dest_dir = pc_dest_dir(tag)
dest = os.path.join(dest_dir, out_name)
tmp_zip = dest + ".part"
with zipfile.ZipFile(tmp_zip, "w", zipfile.ZIP_DEFLATED) as z:
z.write(path, os.path.basename(path))
if PLACE:
os.makedirs(dest_dir, exist_ok=True)
os.replace(tmp_zip, dest)
else:
os.remove(tmp_zip)
report.append(("PC", "OK", origin, "installer wrapped in zip", f"{dest_dir.split('/')[-1]}/{out_name}"))
def process_archive(path, origin):
# keep the source archive
if PLACE:
@@ -242,8 +385,12 @@ def process_archive(path, origin):
subprocess.run(["docker", "run", "--rm", "-v", f"{os.path.dirname(path)}:/in:ro",
"-v", f"{tmp}:/out", "node:lts", "bash", "-c",
"apt-get update -qq>/dev/null 2>&1; apt-get install -y -qq unar>/dev/null 2>&1; "
f"unar -force-overwrite -o /out '/in/{os.path.basename(path)}' >/dev/null"],
f"unar -force-overwrite -o /out '/in/{os.path.basename(path)}' >/dev/null; "
"chown -R $(stat -c %u:%g /out) /out"],
capture_output=True, text=True)
if is_pc_fan_game(tmp):
place_pc_archive(path, clean_name(path), origin)
return
for root, _, files in os.walk(tmp):
for fn in files:
fp = os.path.join(root, fn)
@@ -258,7 +405,16 @@ def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
PLACE = "--place" in sys.argv
incoming = args[0]
for fn in sorted(os.listdir(incoming)):
def _sort_key(fn):
low = fn.lower()
if "fire red (j)" in low:
return (0, fn)
if os.path.splitext(fn)[1].lower() in PATCH_EXT:
return (2, fn)
return (1, fn)
for fn in sorted(os.listdir(incoming), key=_sort_key):
fp = os.path.join(incoming, fn)
if os.path.isfile(fp):
process_file(fp)