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>
261 lines
13 KiB
Python
261 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Acquire, patch, validate, and place worthwhile Pokémon ROM-hacks into the
|
|
EmuDeck library at /storage1/Emulation/roms/<platform>/ on valhalla.
|
|
|
|
Each hack is one MANIFEST entry naming its source. Two source kinds:
|
|
- "rom": a pre-patched full ROM is downloaded and placed as-is (validated).
|
|
- "patch": a patch (BPS/UPS/IPS) is downloaded and applied to a base ROM the
|
|
user already owns; BPS/UPS self-verify via embedded CRC32.
|
|
|
|
Sources are direct, no-anti-bot URLs only (GitHub release assets, archive.org).
|
|
hackdex/Discord/Drive-gated hacks are intentionally NOT here — they are reported
|
|
as identify-only because their hosts gate or token-protect downloads.
|
|
|
|
Output names follow the chosen scheme: "Pokemon - <Hack> (Hack).<ext>"
|
|
(parallels igir's `Title (Region)` No-Intro style, with a (Hack) tag).
|
|
|
|
Run on valhalla: python3 romhack-fetch.py [--place] [--only <substr>]
|
|
Without --place it downloads + validates but does not touch the library.
|
|
"""
|
|
import sys, os, json, zlib, shutil, subprocess, zipfile, re, urllib.request, urllib.parse
|
|
|
|
ROMS = "/storage1/Emulation/roms"
|
|
WORK = "/storage1/igir/romhacks/work"
|
|
PATCHES = "/storage1/igir/romhacks/patches"
|
|
APPLY = "/storage1/igir/romhacks/tools/apply.py"
|
|
UA = "Mozilla/5.0 (homelab personal archival)"
|
|
|
|
BASES = {
|
|
"crystal_rev1": f"{ROMS}/gbc/Pokemon - Crystal Version (USA, Europe) (Rev 1).gbc",
|
|
"gold": f"{ROMS}/gbc/Pokemon - Gold Version (USA, Europe) (SGB Enhanced) (GB Compatible).gbc",
|
|
"silver": f"{ROMS}/gbc/Pokemon - Silver Version (USA, Europe) (SGB Enhanced) (GB Compatible).gbc",
|
|
"ruby": f"{ROMS}/gba/Pokemon - Ruby Version (USA, Europe).gba",
|
|
"sapphire": f"{ROMS}/gba/Pokemon - Sapphire Version (USA, Europe).gba",
|
|
"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",
|
|
"red": f"{ROMS}/gb/Pokemon - Red Version (USA, Europe) (SGB Enhanced).gb",
|
|
"heartgold": f"{ROMS}/nds/Pokemon HeartGold Version.nds",
|
|
"soulsilver": f"{ROMS}/nds/Pokemon SoulSilver Version.nds",
|
|
}
|
|
|
|
def arc(idn, fn):
|
|
return f"https://archive.org/download/{idn}/{urllib.parse.quote(fn)}"
|
|
|
|
# --- the curated, source-resolved batch (directly downloadable only) ----------
|
|
MANIFEST = [
|
|
# GBC
|
|
{"hack": "Polished Crystal", "platform": "gbc", "ext": "gbc", "vfmt": "gbc",
|
|
"kind": "rom", "url": "https://github.com/Rangi42/polishedcrystal/releases/download/v3.2.3/polishedcrystal-3.2.3.gbc",
|
|
"note": "v3.2.3, official GitHub release ROM (author distributes full .gbc)"},
|
|
{"hack": "Crystal Clear", "platform": "gbc", "ext": "gbc", "vfmt": "gbc",
|
|
"kind": "patch", "base": "crystal_rev1",
|
|
"url": arc("pokemon_crystal_clear_v2.5.7", "2_5_7_Standard_1_1.bps"),
|
|
"note": "v2.5.7 Standard, BPS vs Crystal v1.1 (self-verifying)"},
|
|
# GBA
|
|
{"hack": "Light Platinum", "platform": "gba", "ext": "gba", "vfmt": "gba",
|
|
"kind": "patch", "base": "ruby",
|
|
"url": arc("pokemon-light-platinum-u-1.1", "Pokémon_Light_Platinum (U) 1.1.ips"),
|
|
"note": "v1.1 IPS vs Ruby (USA) — IPS has no checksum, header-validated after"},
|
|
{"hack": "Gaia", "platform": "gba", "ext": "gba", "vfmt": "gba",
|
|
"kind": "rom", "url": arc("pokemon-gaia", "GaiaB3.2.gba"),
|
|
"note": "v3.2 (final), pre-patched ROM"},
|
|
{"hack": "Radical Red", "platform": "gba", "ext": "gba", "vfmt": "gba",
|
|
"kind": "rom", "url": arc("pokemon-radical-red-v-4.1", "Pokemon Radical Red v4.1.gba"),
|
|
"note": "v4.1, pre-patched ROM"},
|
|
{"hack": "Inclement Emerald", "platform": "gba", "ext": "gba", "vfmt": "gba",
|
|
"kind": "rom", "url": arc("inclement-emerald_202310", "Inclement Emerald.gba"),
|
|
"note": "Oct-2023 build, pre-patched ROM"},
|
|
{"hack": "Glazed", "platform": "gba", "ext": "gba", "vfmt": "gba",
|
|
"kind": "rom", "url": arc("pokemon-glazed", "pokemon glazed.gba"),
|
|
"note": "pre-patched ROM; version unverified (Glazed has buggy early builds)"},
|
|
# NDS
|
|
{"hack": "Volt White", "platform": "nds", "ext": "nds", "vfmt": "nds",
|
|
"kind": "rom", "url": arc("pokemon-volt-white-v-3.1-complete", "Pokemon Volt White v3.1 - Complete.nds"),
|
|
"note": "v3.1 Complete (Drayano, base Black/White), pre-patched ROM"},
|
|
{"hack": "Renegade Platinum", "platform": "nds", "ext": "nds", "vfmt": "nds",
|
|
"kind": "rom", "url": arc("pokemon-renegade-platinum-v-1.3.0-complete-normal-shiny",
|
|
"Pokemon Renegade Platinum v1.3.0 Complete Normal Shiny.nds"),
|
|
"note": "v1.3.0 Complete (Drayano, base Platinum), pre-patched ROM"},
|
|
]
|
|
|
|
# --- batch 2: more direct-downloadable hacks (some zip-wrapped) ---------------
|
|
MANIFEST2 = [
|
|
{"hack": "Prism", "platform": "gbc", "ext": "gbc", "vfmt": "gbc",
|
|
"kind": "rom", "url": arc("pokeprism_202301", "pokeprism.gbc"),
|
|
"note": "Pokémon Prism (complete, Crystal-based), pre-patched ROM"},
|
|
{"hack": "Liquid Crystal", "platform": "gba", "ext": "gba", "vfmt": "gba",
|
|
"kind": "rom", "url": arc("pokemon-liquid-crystal-v-3.3.00512", "Pokemon - Liquid Crystal (v3.3.00512).gba"),
|
|
"note": "v3.3.00512 (final), Johto remake, pre-patched ROM"},
|
|
{"hack": "Theta Emerald EX", "platform": "gba", "ext": "gba", "vfmt": "gba",
|
|
"kind": "rom", "url": arc("pokemon-theta-emerald-ex_202407", "theta-emerald-ex-02-27-17.gba"),
|
|
"note": "EX build (all 800+ mons), pre-patched ROM"},
|
|
{"hack": "Snakewood", "platform": "gba", "ext": "gba", "vfmt": "gba",
|
|
"kind": "rom", "url": arc("pokemon-snakewood", "Snakewood.gba"),
|
|
"note": "zombie/horror hack (base Ruby), pre-patched ROM"},
|
|
{"hack": "Brown", "platform": "gb", "ext": "gb", "vfmt": "gbc",
|
|
"kind": "patch", "base": "red", "zip_member": r"\.(ips|bps|ups)$",
|
|
"url": arc("brown_20250526", "brown.zip"),
|
|
"note": "v1.1 (Koolboyman, base Red) — patch inside zip"},
|
|
{"hack": "Blaze Black 2 Redux", "platform": "nds", "ext": "nds", "vfmt": "nds",
|
|
"kind": "rom", "zip_member": r"\.nds$",
|
|
"url": arc("pokemon-blaze-black-2-redux-complete-v-1.3.0", "Pokemon Blaze Black 2 Redux (Complete v1.3.0).zip"),
|
|
"note": "Redux fork v1.3.0 Complete (base Black 2), ROM inside zip"},
|
|
{"hack": "Volt White 2 Redux", "platform": "nds", "ext": "nds", "vfmt": "nds",
|
|
"kind": "rom", "zip_member": r"\.nds$",
|
|
"url": arc("pokemon-volt-white-2-redux-complete-v-1.4.1", "Pokemon Volt White 2 Redux Complete (v1.4.1).zip"),
|
|
"note": "Redux fork v1.4.1 Complete (base White 2), ROM inside zip"},
|
|
]
|
|
|
|
# --- batch 3: additional well-regarded complete hacks (direct ROMs) ----------
|
|
MANIFEST3 = [
|
|
{"hack": "Blaze Black", "platform": "nds", "ext": "nds", "vfmt": "nds",
|
|
"kind": "rom", "url": arc("pokemon-blaze-black", "Blaze Black.nds"),
|
|
"note": "Drayano (base Black), pairs with Volt White, pre-patched ROM"},
|
|
{"hack": "Mega Power", "platform": "gba", "ext": "gba", "vfmt": "gba",
|
|
"kind": "rom", "url": arc("pokemon-mega-power-v-5.62", "Pokemon Mega Power (v5.62).gba"),
|
|
"note": "v5.62 (complete), pre-patched ROM"},
|
|
{"hack": "Resolute", "platform": "gba", "ext": "gba", "vfmt": "gba",
|
|
"kind": "rom", "url": arc("pokemon-resolute", "Resolute.gba"),
|
|
"note": "complete story hack, pre-patched ROM"},
|
|
]
|
|
|
|
# Original multi-file archive packs handled by one-off steps (not single-artifact
|
|
# manifest entries). Listed here so --retain can store the true source archives.
|
|
SPECIAL_SOURCES = [
|
|
("gba", arc("pokemon-emerald-kaizo", "Pokemon Emerald Kaizo.rar"),
|
|
"Emerald Kaizo source (rar -> IPS inside)"),
|
|
("gba", arc("pokemon-dark-rising-complete-pack", "Pokemon Dark Rising Complete Pack.zip"),
|
|
"Dark Rising 1/2/Order Destroyed/Kaizo (nested zips inside)"),
|
|
("nds", arc("pokemon-sacred-gold-and-storm-silver-1.5-fairy", "Pokemon Sacred Gold and Storm Silver 1.5.zip"),
|
|
"Sacred Gold + Storm Silver V1.05 xdelta patch pack"),
|
|
]
|
|
|
|
|
|
def basename_from_url(url):
|
|
return os.path.basename(urllib.parse.unquote(urllib.parse.urlparse(url).path))
|
|
|
|
|
|
def retain_all():
|
|
"""Re-download every source artifact and store it persistently under PATCHES/<platform>/."""
|
|
rows = []
|
|
items = [(m["platform"], m["url"], m["hack"]) for m in (MANIFEST + MANIFEST2 + MANIFEST3)]
|
|
items += [(plat, url, note) for plat, url, note in SPECIAL_SOURCES]
|
|
for plat, url, label in items:
|
|
dst_dir = os.path.join(PATCHES, plat)
|
|
os.makedirs(dst_dir, exist_ok=True)
|
|
dst = os.path.join(dst_dir, basename_from_url(url))
|
|
try:
|
|
sz = download(url, dst)
|
|
rows.append((label, plat, "OK", f"{sz:,}b", os.path.basename(dst)))
|
|
except Exception as e:
|
|
rows.append((label, plat, "FAIL", str(e)[:60], basename_from_url(url)))
|
|
print("\n=== RETAIN SOURCE ARTIFACTS ===")
|
|
for lbl, pl, st, dt, nm in rows:
|
|
print(f"[{st:4}] {pl:4} {dt:>16} {nm}")
|
|
ok = sum(1 for r in rows if r[2] == "OK")
|
|
print(f"\n{ok}/{len(rows)} stored under {PATCHES}/<platform>/")
|
|
|
|
|
|
def extract_member(zip_path, pattern, dest):
|
|
with zipfile.ZipFile(zip_path) as z:
|
|
members = [n for n in z.namelist() if not n.endswith("/") and re.search(pattern, n, re.I)]
|
|
if not members:
|
|
raise RuntimeError(f"no zip member matches {pattern} (have: {z.namelist()[:5]})")
|
|
# pick the largest matching member (avoids readme/junk)
|
|
members.sort(key=lambda n: z.getinfo(n).file_size, reverse=True)
|
|
with z.open(members[0]) as src, open(dest, "wb") as out:
|
|
shutil.copyfileobj(src, out)
|
|
return members[0]
|
|
|
|
def download(url, dest):
|
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
with urllib.request.urlopen(req, timeout=120) as r, open(dest, "wb") as f:
|
|
shutil.copyfileobj(r, f)
|
|
return os.path.getsize(dest)
|
|
|
|
def validate(path, vfmt):
|
|
"""Cheap structural sanity check so a mislabeled/corrupt file is caught."""
|
|
with open(path, "rb") as f:
|
|
data = f.read()
|
|
n = len(data)
|
|
if vfmt == "gba":
|
|
if n % (1024 * 1024) != 0 or not (4*1024*1024 <= n <= 32*1024*1024):
|
|
return f"bad GBA size {n}"
|
|
if data[0xB2] != 0x96:
|
|
return "GBA fixed byte 0xB2!=0x96 (not a GBA ROM?)"
|
|
elif vfmt == "gbc":
|
|
logo = bytes.fromhex("ceed6666cc0d")
|
|
if data[0x104:0x10A] != logo:
|
|
return "GBC Nintendo logo missing at 0x104"
|
|
elif vfmt == "nds":
|
|
if not (8*1024*1024 <= n <= 512*1024*1024):
|
|
return f"bad NDS size {n}"
|
|
if data[0x15C:0x15E] != b"\x56\xCF": # standard logo CRC16 0xCF56
|
|
return "NDS logo CRC16 != 0xCF56"
|
|
return None # ok
|
|
|
|
def main():
|
|
place = "--place" in sys.argv
|
|
only = None
|
|
if "--only" in sys.argv:
|
|
only = sys.argv[sys.argv.index("--only") + 1].lower()
|
|
if "--retain" in sys.argv:
|
|
retain_all()
|
|
return
|
|
batch = 1
|
|
if "--batch" in sys.argv:
|
|
batch = int(sys.argv[sys.argv.index("--batch") + 1])
|
|
manifest = {1: MANIFEST, 2: MANIFEST2, 3: MANIFEST3}[batch]
|
|
os.makedirs(WORK, exist_ok=True)
|
|
rows = []
|
|
for m in manifest:
|
|
if only and only not in m["hack"].lower():
|
|
continue
|
|
hack = m["hack"]; plat = m["platform"]; ext = m["ext"]
|
|
out_name = f"Pokemon - {hack} (Hack).{ext}"
|
|
tmp = os.path.join(WORK, f"dl_{hack.replace(' ', '_')}")
|
|
status = "OK"; detail = m.get("note", "")
|
|
try:
|
|
sz = download(m["url"], tmp)
|
|
if m.get("zip_member"):
|
|
inner = os.path.join(WORK, f"zx_{hack.replace(' ', '_')}")
|
|
picked = extract_member(tmp, m["zip_member"], inner)
|
|
detail += f" | zip:{os.path.basename(picked)}"
|
|
tmp = inner
|
|
if m["kind"] == "patch":
|
|
base = BASES[m["base"]]
|
|
if not os.path.exists(base):
|
|
raise RuntimeError(f"base missing: {base}")
|
|
final = os.path.join(WORK, f"out_{hack.replace(' ', '_')}.{ext}")
|
|
r = subprocess.run([sys.executable, APPLY, tmp, base, final],
|
|
capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
raise RuntimeError(f"apply failed: {r.stderr.strip()}")
|
|
detail = r.stdout.strip().split("crc32=")[-1].rstrip(")")
|
|
detail = f"applied, crc32={detail}"
|
|
src = final
|
|
else:
|
|
src = tmp
|
|
verr = validate(src, m["vfmt"])
|
|
if verr:
|
|
raise RuntimeError(f"validation: {verr}")
|
|
crc = zlib.crc32(open(src, "rb").read()) & 0xffffffff
|
|
if place:
|
|
dst_dir = os.path.join(ROMS, plat)
|
|
os.makedirs(dst_dir, exist_ok=True)
|
|
shutil.copy2(src, os.path.join(dst_dir, out_name))
|
|
detail += " | PLACED"
|
|
rows.append((hack, plat, "OK", f"{os.path.getsize(src):,}b crc={crc:08x}", out_name))
|
|
except Exception as e:
|
|
rows.append((hack, plat, "FAIL", str(e)[:80], out_name))
|
|
print("\n=== ROMHACK FETCH SUMMARY ===")
|
|
for hk, pl, st, dt, nm in rows:
|
|
print(f"[{st:4}] {pl:4} {hk:<20} {dt}")
|
|
if st == "OK":
|
|
print(f" -> {pl}/{nm}")
|
|
print(f"\nplaced: {'YES (--place)' if place else 'NO (dry run; pass --place)'}")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|