From ae56d101b7c288a6085ec46bd6f2d66101f78920 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Tue, 23 Jun 2026 18:08:31 -0500 Subject: [PATCH] refactor: standardize hack filenames to [Hack] and flatten to platform root Switch the library naming convention from "Pokemon - (Hack)" to "Pokemon - [Hack][version][tags]", and place hacks in the platform root (roms//) 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// - 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 --- README.md | 4 +- scripts/build-romhack-vault.py | 4 +- scripts/romhack-fetch.py | 6 +-- scripts/romhack-import.py | 20 ++++---- scripts/romhack-pc-migrate.py | 4 +- scripts/standardize-names.py | 93 ++++++++++++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 19 deletions(-) create mode 100644 scripts/standardize-names.py diff --git a/README.md b/README.md index 755dcbc..fb985db 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ valhalla homelab library. Spun off from the `homelabstack` repo on 2026-06-06. > or the `vault` skill. New hacks are added there **manually** (see the vault's > `_Claude.md` checklist). This repo is just the tooling. -The ROMs live on valhalla under `/storage1/Emulation/roms//Hacks/`, +The ROMs live on valhalla under `/storage1/Emulation/roms//`, served by the RomM stack (`roms.ginnoir.com`) — deployed from `homelabstack` (`stacks/roms/`). @@ -76,7 +76,7 @@ vault `Wanted` list. They are served through the **same RomM stack** as the console hacks, under the `windows` platform (RomM's "Microsoft Windows"; note `pc` is excluded in RomM's `config.yml`, `windows` is not). Each hack is a single archive in the platform -**root** — `Pokemon - (Hack).zip` — NOT in a subfolder (a subfolder would +**root** — `Pokemon - [Hack].zip` — NOT in a subfolder (a subfolder would make RomM treat the whole thing as one multi-file game). ```bash diff --git a/scripts/build-romhack-vault.py b/scripts/build-romhack-vault.py index 2fd5f46..3142a09 100644 --- a/scripts/build-romhack-vault.py +++ b/scripts/build-romhack-vault.py @@ -319,7 +319,7 @@ def gen_hack_note(h): f'status: "{status}"', f"type: {yaml_list(types)}", f'generation: "{gen}"', - f'library_path: "roms/{plat}/Hacks/{libname}"', + f'library_path: "roms/{plat}/{libname}"', f'source: "{url or lookup_url(title)}"', f"added: {ADDED}", f"tags: {yaml_list(tags)}", @@ -343,7 +343,7 @@ def gen_hack_note(h): f"- Lookup: {lookup_url(title)}", "", "## In the library", - f"- `roms/{plat}/Hacks/{libname}` on valhalla", + f"- `roms/{plat}/{libname}` on valhalla", f"- Patch/source artifact archived under `/storage1/igir/romhacks/patches/`", "", "[[Index|← back to directory]]", diff --git a/scripts/romhack-fetch.py b/scripts/romhack-fetch.py index edf8953..38e580e 100644 --- a/scripts/romhack-fetch.py +++ b/scripts/romhack-fetch.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """Acquire, patch, validate, and place worthwhile Pokémon ROM-hacks into the -EmuDeck library at /storage1/Emulation/roms//Hacks/ on valhalla. +EmuDeck library at /storage1/Emulation/roms// 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). @@ -242,7 +242,7 @@ def main(): raise RuntimeError(f"validation: {verr}") crc = zlib.crc32(open(src, "rb").read()) & 0xffffffff if place: - dst_dir = os.path.join(ROMS, plat, "Hacks") + 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" @@ -253,7 +253,7 @@ def main(): for hk, pl, st, dt, nm in rows: print(f"[{st:4}] {pl:4} {hk:<20} {dt}") if st == "OK": - print(f" -> {pl}/Hacks/{nm}") + print(f" -> {pl}/{nm}") print(f"\nplaced: {'YES (--place)' if place else 'NO (dry run; pass --place)'}") if __name__ == "__main__": diff --git a/scripts/romhack-import.py b/scripts/romhack-import.py index 2482a46..39df676 100644 --- a/scripts/romhack-import.py +++ b/scripts/romhack-import.py @@ -2,12 +2,12 @@ """Import a drop-folder of mixed Pokémon hack files into the valhalla library. Handles a folder containing any mix of: - - completed ROMs (.gba/.gbc/.gb/.nds) -> validated, clean-named, copied to roms//Hacks/ - - patches (.ips/.bps/.ups/.xdelta) -> applied to an owned base, placed in Hacks/, archived + - completed ROMs (.gba/.gbc/.gb/.nds) -> validated, clean-named, copied to roms// + - patches (.ips/.bps/.ups/.xdelta) -> applied to an owned base, placed in roms//, archived - documentation (.pdf/.txt/.png/.md) -> archived under PATCHES/_docs/ - archives (.zip/.rar/.7z) -> extracted and recursed (same rules), source kept -Naming matches the rest of the library: "Pokemon - (Hack)." +Naming matches the rest of the library: "Pokemon - [Hack]." (version/junk parentheticals are stripped from the source filename). Base ROM for a patch is auto-detected: BPS/UPS embed source CRC32 so the correct @@ -89,7 +89,7 @@ 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//, not Hacks/ +# Known base ROMs in a drop folder — placed under roms// (un-tagged, no [Hack]) BASE_ROM_FILES = [ ("fire red (j)", "gba", "Pokemon - Fire Red (J) (V1.0).gba"), ] @@ -205,7 +205,7 @@ def place_rom(src, plat, hackname, origin): if is_skip_name(origin): report.append(("ROM", "NEEDS-ID", origin, "unidentified hack — tell me the name", "")) return - out_name = f"Pokemon - {hackname} (Hack).{plat}" + out_name = f"Pokemon - {hackname} [Hack].{plat}" err, padto = validate(src, plat) if err: report.append(("ROM", "FAIL", origin, err, "")); return @@ -213,11 +213,11 @@ def place_rom(src, plat, hackname, origin): if padto and len(data) < padto: data += b"\xff" * (padto - len(data)) if PLACE: - d = os.path.join(ROMS, plat, "Hacks"); os.makedirs(d, exist_ok=True) + d = os.path.join(ROMS, plat); os.makedirs(d, exist_ok=True) with open(os.path.join(d, out_name), "wb") as f: f.write(data) report.append(("ROM", "OK", origin, f"{len(data):,}b crc={zlib.crc32(data)&0xffffffff:08x}", - f"{plat}/Hacks/{out_name}")) + f"{plat}/{out_name}")) def try_apply(patch, base_key, out): @@ -314,8 +314,8 @@ def process_file(path, origin=None): 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" + suffix = 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() @@ -352,7 +352,7 @@ def place_pc_archive(path, hackname, origin, variant=None): 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" + 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" diff --git a/scripts/romhack-pc-migrate.py b/scripts/romhack-pc-migrate.py index 691caf5..67b8cb4 100644 --- a/scripts/romhack-pc-migrate.py +++ b/scripts/romhack-pc-migrate.py @@ -8,7 +8,7 @@ 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). +Target: /storage1/Emulation/roms/windows/Pokemon - [Hack]. - `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 @@ -166,7 +166,7 @@ def main(): 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}" + 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 "") diff --git a/scripts/standardize-names.py b/scripts/standardize-names.py new file mode 100644 index 0000000..f944fd9 --- /dev/null +++ b/scripts/standardize-names.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Standardize valhalla Hack-library filenames to: + + Pokemon - [Hack][][]. + +Rule-based + self-fetching: queries valhalla for every file still tagged +"(Hack)" anywhere under the roms tree and rewrites it. + + - mechanical: " (Hack)" -> " [Hack]" (preserves other (...) like (FRLG+)) + - OVERRIDES: platform-port fan-games whose stem carries "--" + get the version pulled into [ver] and the OS kept as a tag. + - SKIP: non-game patcher utilities are left untouched. + +Dry-run prints OLD -> NEW; --apply runs `mv -n` over SSH. +Idempotent: already-[Hack] files don't match the (Hack) query, so re-runs +are no-ops. +""" +import subprocess, sys, shlex, os + +APPLY = "--apply" in sys.argv +HOST = "valhalla" +ROOT = "/storage1/Emulation/roms" + +# Non-game utilities — leave alone. +SKIP = { + "Pokemon - DeltaPatcherLite (Hack) [Installer].zip", + "Pokemon - Patcher (Hack) [Installer].zip", +} + +# Stem-encoded version/OS ports -> canonical [Hack][ver][OS]. +OVERRIDES = { + "Pokemon - Reborn-19.5.0-linux (Hack) [Linux].zip": "Pokemon - Reborn [Hack][19.5.0][Linux].zip", + "Pokemon - Rejuvenation-13.5.0-linux (Hack) [Linux].zip": "Pokemon - Rejuvenation [Hack][13.5.0][Linux].zip", + "Pokemon - Reborn-19.5.0-macos (Hack) [macOS].zip": "Pokemon - Reborn [Hack][19.5.0][macOS].zip", + "Pokemon - Rejuvenation-13.5.0-macos (Hack) [macOS].zip": "Pokemon - Rejuvenation [Hack][13.5.0][macOS].zip", +} + + +def new_name(base): + if base in SKIP: + return None + if base in OVERRIDES: + return OVERRIDES[base] + if " (Hack)" in base: + return base.replace(" (Hack)", " [Hack]") + return None + + +def fetch(): + cmd = f"find {shlex.quote(ROOT)} -type f -name '*(Hack)*'" + out = subprocess.run(["ssh", HOST, cmd], capture_output=True, text=True, check=True).stdout + return sorted(p for p in out.splitlines() if p.strip()) + + +def main(): + paths = fetch() + cmds, special, mech, skipped = [], [], 0, [] + for full in paths: + d, base = os.path.dirname(full), os.path.basename(full) + nn = new_name(base) + if nn is None: + skipped.append(base) + continue + cmds.append(f"mv -n -- {shlex.quote(full)} {shlex.quote(d + '/' + nn)}") + if base in OVERRIDES: + special.append((base, nn)) + else: + mech += 1 + + print(f"Found {len(paths)} files still tagged (Hack).\n") + print(f"Mechanical (Hack)->[Hack] swaps : {mech}") + print(f"Special version/OS overrides : {len(special)}") + for o, n in special: + print(f" {o}\n -> {n}") + print(f"Skipped (non-game utilities) : {len(skipped)}") + for s in skipped: + print(f" {s}") + + if not APPLY: + print("\n(dry-run — pass --apply to execute over SSH)") + return + # NB: pipe bytes with explicit \n line endings. On Windows, text=True wraps + # stdin in a TextIOWrapper that rewrites \n -> \r\n, which bash would append + # as a stray CR onto every destination filename. Encoding ourselves avoids it. + script = "set -e\n" + "\n".join(cmds) + "\n" + r = subprocess.run(["ssh", HOST, "bash -s"], input=script.encode("utf-8")) + print(f"\napplied {len(cmds)} renames (exit {r.returncode})") + sys.exit(r.returncode) + + +if __name__ == "__main__": + main()