refactor: standardize hack filenames to [Hack] and flatten to platform root

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>
This commit is contained in:
ginnoir
2026-06-23 18:08:31 -05:00
co-authored by Claude Opus 4.8
parent 52ee1b98a1
commit ae56d101b7
6 changed files with 112 additions and 19 deletions
+2 -2
View File
@@ -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 > or the `vault` skill. New hacks are added there **manually** (see the vault's
> `_Claude.md` checklist). This repo is just the tooling. > `_Claude.md` checklist). This repo is just the tooling.
The ROMs live on valhalla under `/storage1/Emulation/roms/<platform>/Hacks/`, The ROMs live on valhalla under `/storage1/Emulation/roms/<platform>/`,
served by the RomM stack (`roms.ginnoir.com`) — deployed from `homelabstack` served by the RomM stack (`roms.ginnoir.com`) — deployed from `homelabstack`
(`stacks/roms/`). (`stacks/roms/`).
@@ -76,7 +76,7 @@ vault `Wanted` list.
They are served through the **same RomM stack** as the console hacks, under the 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 `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 `config.yml`, `windows` is not). Each hack is a single archive in the platform
**root**`Pokemon - <Hack> (Hack).zip` — NOT in a subfolder (a subfolder would **root**`Pokemon - <Hack> [Hack].zip` — NOT in a subfolder (a subfolder would
make RomM treat the whole thing as one multi-file game). make RomM treat the whole thing as one multi-file game).
```bash ```bash
+2 -2
View File
@@ -319,7 +319,7 @@ def gen_hack_note(h):
f'status: "{status}"', f'status: "{status}"',
f"type: {yaml_list(types)}", f"type: {yaml_list(types)}",
f'generation: "{gen}"', f'generation: "{gen}"',
f'library_path: "roms/{plat}/Hacks/{libname}"', f'library_path: "roms/{plat}/{libname}"',
f'source: "{url or lookup_url(title)}"', f'source: "{url or lookup_url(title)}"',
f"added: {ADDED}", f"added: {ADDED}",
f"tags: {yaml_list(tags)}", f"tags: {yaml_list(tags)}",
@@ -343,7 +343,7 @@ def gen_hack_note(h):
f"- Lookup: {lookup_url(title)}", f"- Lookup: {lookup_url(title)}",
"", "",
"## In the library", "## 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/`", f"- Patch/source artifact archived under `/storage1/igir/romhacks/patches/`",
"", "",
"[[Index|← back to directory]]", "[[Index|← back to directory]]",
+3 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Acquire, patch, validate, and place worthwhile Pokémon ROM-hacks into the """Acquire, patch, validate, and place worthwhile Pokémon ROM-hacks into the
EmuDeck library at /storage1/Emulation/roms/<platform>/Hacks/ on valhalla. EmuDeck library at /storage1/Emulation/roms/<platform>/ on valhalla.
Each hack is one MANIFEST entry naming its source. Two source kinds: 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). - "rom": a pre-patched full ROM is downloaded and placed as-is (validated).
@@ -242,7 +242,7 @@ def main():
raise RuntimeError(f"validation: {verr}") raise RuntimeError(f"validation: {verr}")
crc = zlib.crc32(open(src, "rb").read()) & 0xffffffff crc = zlib.crc32(open(src, "rb").read()) & 0xffffffff
if place: if place:
dst_dir = os.path.join(ROMS, plat, "Hacks") dst_dir = os.path.join(ROMS, plat)
os.makedirs(dst_dir, exist_ok=True) os.makedirs(dst_dir, exist_ok=True)
shutil.copy2(src, os.path.join(dst_dir, out_name)) shutil.copy2(src, os.path.join(dst_dir, out_name))
detail += " | PLACED" detail += " | PLACED"
@@ -253,7 +253,7 @@ def main():
for hk, pl, st, dt, nm in rows: for hk, pl, st, dt, nm in rows:
print(f"[{st:4}] {pl:4} {hk:<20} {dt}") print(f"[{st:4}] {pl:4} {hk:<20} {dt}")
if st == "OK": 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)'}") print(f"\nplaced: {'YES (--place)' if place else 'NO (dry run; pass --place)'}")
if __name__ == "__main__": if __name__ == "__main__":
+10 -10
View File
@@ -2,12 +2,12 @@
"""Import a drop-folder of mixed Pokémon hack files into the valhalla library. """Import a drop-folder of mixed Pokémon hack files into the valhalla library.
Handles a folder containing any mix of: Handles a folder containing any mix of:
- completed ROMs (.gba/.gbc/.gb/.nds) -> validated, clean-named, copied to roms/<plat>/Hacks/ - completed ROMs (.gba/.gbc/.gb/.nds) -> validated, clean-named, copied to roms/<plat>/
- patches (.ips/.bps/.ups/.xdelta) -> applied to an owned base, placed in Hacks/, archived - patches (.ips/.bps/.ups/.xdelta) -> applied to an owned base, placed in roms/<plat>/, archived
- documentation (.pdf/.txt/.png/.md) -> archived under PATCHES/_docs/ - documentation (.pdf/.txt/.png/.md) -> archived under PATCHES/_docs/
- archives (.zip/.rar/.7z) -> extracted and recursed (same rules), source kept - archives (.zip/.rar/.7z) -> extracted and recursed (same rules), source kept
Naming matches the rest of the library: "Pokemon - <Hack> (Hack).<ext>" Naming matches the rest of the library: "Pokemon - <Hack> [Hack].<ext>"
(version/junk parentheticals are stripped from the source filename). (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 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. # Files we will NOT auto-place (unidentified / need user to name). Reported instead.
SKIP_NAME_SUBSTR = ["beta 15 + expansion"] SKIP_NAME_SUBSTR = ["beta 15 + expansion"]
# Known base ROMs in a drop folder — place under roms/<plat>/, not Hacks/ # Known base ROMs in a drop folder — placed under roms/<plat>/ (un-tagged, no [Hack])
BASE_ROM_FILES = [ BASE_ROM_FILES = [
("fire red (j)", "gba", "Pokemon - Fire Red (J) (V1.0).gba"), ("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): if is_skip_name(origin):
report.append(("ROM", "NEEDS-ID", origin, "unidentified hack — tell me the name", "")) report.append(("ROM", "NEEDS-ID", origin, "unidentified hack — tell me the name", ""))
return return
out_name = f"Pokemon - {hackname} (Hack).{plat}" out_name = f"Pokemon - {hackname} [Hack].{plat}"
err, padto = validate(src, plat) err, padto = validate(src, plat)
if err: if err:
report.append(("ROM", "FAIL", origin, err, "")); return report.append(("ROM", "FAIL", origin, err, "")); return
@@ -213,11 +213,11 @@ def place_rom(src, plat, hackname, origin):
if padto and len(data) < padto: if padto and len(data) < padto:
data += b"\xff" * (padto - len(data)) data += b"\xff" * (padto - len(data))
if PLACE: 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: with open(os.path.join(d, out_name), "wb") as f:
f.write(data) f.write(data)
report.append(("ROM", "OK", origin, f"{len(data):,}b crc={zlib.crc32(data)&0xffffffff:08x}", 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): 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): def place_pc_archive(path, hackname, origin, variant=None):
tag = variant or detect_variant(path) or "" tag = variant or detect_variant(path) or ""
suffix = f" {tag}" if tag else "" suffix = tag if tag else ""
out_name = f"Pokemon - {hackname} (Hack){suffix}.zip" out_name = f"Pokemon - {hackname} [Hack]{suffix}.zip"
dest_dir = pc_dest_dir(tag) dest_dir = pc_dest_dir(tag)
dest = os.path.join(dest_dir, out_name) dest = os.path.join(dest_dir, out_name)
ext = os.path.splitext(path)[1].lower() 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): def place_pc_installer(path, hackname, origin):
"""Wrap a standalone .exe installer in a zip so RomM can serve it.""" """Wrap a standalone .exe installer in a zip so RomM can serve it."""
tag = detect_variant(path) or "[Installer]" 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_dir = pc_dest_dir(tag)
dest = os.path.join(dest_dir, out_name) dest = os.path.join(dest_dir, out_name)
tmp_zip = dest + ".part" tmp_zip = dest + ".part"
+2 -2
View File
@@ -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 the `windows` RomM platform so RomM catalogs them and the Playnite plugin can
download + extract + launch them like any other game. download + extract + launch them like any other game.
Target: /storage1/Emulation/roms/windows/Pokemon - <Hack> (Hack).<ext> Target: /storage1/Emulation/roms/windows/Pokemon - <Hack> [Hack].<ext>
- `windows` is the RomM "Microsoft Windows" platform (NOT excluded in RomM's - `windows` is the RomM "Microsoft Windows" platform (NOT excluded in RomM's
config.yml; `pc` IS excluded, so do not use it). config.yml; `pc` IS excluded, so do not use it).
- Files go in the platform ROOT (mirrors the console-hack convention); a - 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() src_ext = os.path.splitext(chosen)[1].lower()
will_repack = repack and src_ext in REPACK_EXT will_repack = repack and src_ext in REPACK_EXT
out_ext = ".zip" if will_repack else src_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) dest = os.path.join(DEST, out_name)
note = " [repack rar/7z->zip]" if will_repack else ( note = " [repack rar/7z->zip]" if will_repack else (
" [RAR as-is; extract manually if Playnite balks]" if src_ext == ".rar" else "") " [RAR as-is; extract manually if Playnite balks]" if src_ext == ".rar" else "")
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Standardize valhalla Hack-library filenames to:
Pokemon - <Name> [Hack][<version>][<functional tags>].<ext>
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 "-<ver>-<os>"
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()