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:
co-authored by
Claude Opus 4.8
parent
52ee1b98a1
commit
ae56d101b7
@@ -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
|
||||||
|
|||||||
@@ -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]]",
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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 "")
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user