feat: add PC fan-game migration tooling for RomM windows platform
Place RPG-Maker/Essentials hacks into RomM's windows library with dry-run migration, Playnite Game.exe launcher, and Ludusavi save-path manifest.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a Ludusavi secondary manifest for the PC hacks served by RomM.
|
||||
|
||||
Pokémon Essentials (v18+) writes saves to:
|
||||
%USERPROFILE%/Saved Games/<Game.ini Title>/Game.rxdata
|
||||
The exact subfolder is the `Title=` field of each game's Game.ini, so we read it
|
||||
straight out of the placed archive instead of guessing. Older Essentials (<=v17)
|
||||
saved into the game folder itself — covered by the commented `<base>` fallback.
|
||||
|
||||
Point Ludusavi at the emitted YAML as a secondary/custom manifest, then pair its
|
||||
backup target with Syncthing to replicate saves across Windows / Steam Deck.
|
||||
|
||||
Run on valhalla after romhack-pc-migrate.py --apply:
|
||||
python3 gen-ludusavi-manifest.py > ludusavi/pokemon-pc-hacks.yaml
|
||||
"""
|
||||
import os, sys, zipfile, configparser, io
|
||||
|
||||
WIN = "/storage1/Emulation/roms/windows"
|
||||
|
||||
|
||||
def sq(s):
|
||||
"""YAML single-quoted scalar: an embedded apostrophe must be doubled."""
|
||||
return "'" + str(s).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def game_title(zip_path):
|
||||
"""Pull Title= from the first */Game.ini inside the archive, if present."""
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path) as z:
|
||||
ini = next((n for n in z.namelist() if n.lower().endswith("game.ini")), None)
|
||||
if not ini:
|
||||
return None
|
||||
raw = z.read(ini).decode("utf-8", "ignore")
|
||||
cp = configparser.ConfigParser(strict=False, interpolation=None)
|
||||
cp.read_string(raw if raw.lstrip().startswith("[") else "[Game]\n" + raw)
|
||||
for sect in cp.sections():
|
||||
if cp.has_option(sect, "Title"):
|
||||
return cp.get(sect, "Title").strip()
|
||||
except Exception as e:
|
||||
print(f"# WARN {os.path.basename(zip_path)}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
print("# Ludusavi secondary manifest — Pokémon PC hacks (generated)")
|
||||
print("# Add via Ludusavi > Settings > Manifests, or merge into a custom manifest.")
|
||||
print("# Save path = ~/Saved Games/<Game.ini Title> (Essentials v18+).")
|
||||
print("# Verify each on first save; for Essentials <=v17 uncomment the <base> line.\n")
|
||||
zips = sorted(f for f in os.listdir(WIN) if f.lower().endswith(".zip"))
|
||||
for f in zips:
|
||||
name = os.path.splitext(f)[0] # "Pokemon - <Hack> (Hack)"
|
||||
title = game_title(os.path.join(WIN, f))
|
||||
print(f"{sq(name)}:")
|
||||
print(" files:")
|
||||
if title:
|
||||
print(f" {sq('<home>/Saved Games/' + title)}:")
|
||||
print(" tags: [save]")
|
||||
else:
|
||||
print(f" # TODO no Game.ini Title found — set save path manually")
|
||||
print(f" {sq('<home>/Saved Games/' + name)}:")
|
||||
print(" tags: [save]")
|
||||
print(" # '<base>': # older Essentials saved in the game folder")
|
||||
print(" # tags: [save]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,66 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Launch a RomM-served Pokémon PC hack (RPG Maker XP / Essentials) from Playnite.
|
||||
|
||||
.DESCRIPTION
|
||||
The RomM Playnite plugin downloads each `windows`-platform hack and extracts the
|
||||
zip into a folder. These are RPG Maker XP / Pokémon Essentials games whose
|
||||
launcher is `Game.exe` at the extracted folder root, and RGSS REQUIRES the
|
||||
working directory to be that folder (relative paths to Data/, Audio/, Graphics/).
|
||||
|
||||
This script takes whatever path Playnite hands it (the extracted folder, a file
|
||||
inside it, or the zip's sibling folder), locates the correct Game.exe (shallowest
|
||||
match, ignoring dev tools like animmaker.exe / RPGXP.exe), sets CWD, and runs it.
|
||||
|
||||
Register it in Playnite as a Custom emulator (see scripts/README or the vault
|
||||
ROM Library note) so every `windows` hack imported from RomM uses it.
|
||||
|
||||
.PARAMETER Target
|
||||
Path Playnite passes — typically {ImagePath} or {StartupDir}. May be a file or
|
||||
a directory; the script resolves the game folder from either.
|
||||
|
||||
.EXAMPLE
|
||||
powershell -ExecutionPolicy Bypass -File launch-pc-hack.ps1 "{ImagePath}"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, Position = 0)]
|
||||
[string]$Target
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# dev/editor exes that ship alongside the game but are NOT the launcher
|
||||
$ignore = @('animmaker.exe', 'rpgxp.exe', 'rgss', 'editor.exe', 'unins', 'setup.exe')
|
||||
|
||||
function Find-GameExe([string]$root) {
|
||||
# Prefer Game.exe; fall back to the shallowest non-ignored .exe.
|
||||
$candidates = Get-ChildItem -LiteralPath $root -Recurse -File -Filter '*.exe' -ErrorAction SilentlyContinue |
|
||||
Where-Object { $n = $_.Name.ToLower(); -not ($ignore | Where-Object { $n -like "*$_*" }) } |
|
||||
Sort-Object { ($_.FullName -split '[\\/]').Count } # shallowest first
|
||||
|
||||
$game = $candidates | Where-Object { $_.Name -ieq 'Game.exe' } | Select-Object -First 1
|
||||
if (-not $game) { $game = $candidates | Select-Object -First 1 }
|
||||
return $game
|
||||
}
|
||||
|
||||
# Resolve the starting directory from whatever Playnite passed.
|
||||
if (Test-Path -LiteralPath $Target -PathType Leaf) {
|
||||
$start = Split-Path -LiteralPath $Target -Parent
|
||||
} elseif (Test-Path -LiteralPath $Target -PathType Container) {
|
||||
$start = $Target
|
||||
} else {
|
||||
throw "launch-pc-hack: path not found: $Target"
|
||||
}
|
||||
|
||||
$exe = Find-GameExe $start
|
||||
if (-not $exe) {
|
||||
# The extracted game may sit one level up (Playnite handed us a sibling file).
|
||||
$exe = Find-GameExe (Split-Path -LiteralPath $start -Parent)
|
||||
}
|
||||
if (-not $exe) { throw "launch-pc-hack: no Game.exe found under $start" }
|
||||
|
||||
$dir = Split-Path -LiteralPath $exe.FullName -Parent
|
||||
Write-Host "Launching $($exe.Name) in $dir"
|
||||
# RGSS needs CWD = game folder. Start and wait so Playnite tracks play session.
|
||||
Start-Process -FilePath $exe.FullName -WorkingDirectory $dir -Wait
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Migrate the PC/Windows Pokémon fan-games into the RomM-served library.
|
||||
|
||||
The console hacks live under /storage1/Emulation/roms/<plat>/ and are served by
|
||||
RomM (roms.ginnoir.com). The PC hacks (RPG-Maker / Essentials fan-games) instead
|
||||
sit in the Discord-scrape staging tree /storage1/labdata/romhacks/library/<dir>/
|
||||
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> (Hack).<ext>
|
||||
- `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
|
||||
`Hacks/` subfolder would make RomM treat the whole folder as ONE multi-file
|
||||
game, which is wrong.
|
||||
- Same filesystem as labdata (st_dev match) so copy is cheap; the labdata copy
|
||||
is intentionally LEFT in place (it backs the vault art + romhacks-files Caddy
|
||||
serve + note.md/handoff.json).
|
||||
|
||||
Each labdata folder may hold several archives (versions, languages, spoiler
|
||||
guides, DLC, a pre-extracted dir). Selection:
|
||||
- GUIDE/EXTRA archives are filtered by keyword (locations, spoiler, tutor, ...).
|
||||
- ARTIFACT_OVERRIDE pins the correct primary archive for the messy multi-file
|
||||
folders (resolved by inspection 2026-06-22).
|
||||
- 1 remaining candidate -> PLACE
|
||||
- 0 candidates -> MISSING (the 8 gated fan-games are catalog-only)
|
||||
- >1 and no override -> AMBIGUOUS (listed, not placed without a pick)
|
||||
|
||||
Run on valhalla:
|
||||
python3 romhack-pc-migrate.py --catalog wiki/catalog.json # dry run
|
||||
python3 romhack-pc-migrate.py --catalog wiki/catalog.json --apply # copy
|
||||
"""
|
||||
import sys, os, re, json, shutil, unicodedata, tempfile, subprocess, zipfile
|
||||
|
||||
LIB = "/storage1/labdata/romhacks/library"
|
||||
DEST = "/storage1/Emulation/roms/windows"
|
||||
ARCHIVE_EXT = {".zip", ".rar", ".7z"}
|
||||
REPACK_EXT = {".rar", ".7z"} # repackaged to .zip so Playnite auto-extract is reliable
|
||||
|
||||
# Archives that are companion material, not the game. Matched case-insensitively
|
||||
# against the filename. Keeps Hydro Bliss's ~15 guide zips etc. out of the pick.
|
||||
GUIDE_KEYWORDS = [
|
||||
"location", "spoiler", "tutor", "merchant", "aide", "prof's aide",
|
||||
"shortcut", "field effect", "gym field", "legendary list", "legendary loc",
|
||||
"tm location", "item location", "zygarde", "ace trainer", "city park",
|
||||
"landmark", "town merchant", "wife location", "how to transfer",
|
||||
"(obsolete)", "obsolete", "regionals", "trainer.zip",
|
||||
]
|
||||
|
||||
# Folders with multiple real game archives: pin the primary (latest / English).
|
||||
# key = on-disk folder name, value = exact archive filename to place.
|
||||
ARTIFACT_OVERRIDE = {
|
||||
"Pokemon_Asther_Violet": "PokemonAsterViolet-v1.59.210901a.zip",
|
||||
"Pokemon_Chaos_in_Vesita": "Pokémon Chaos in Vesita (1.1.5).zip",
|
||||
"Pokemon_Gadir_Deluxe": "Pokémon Gadir Deluxe ENG v1.3.zip",
|
||||
"Pokemon_Hydro_Bliss": "Hydro Bliss 2.13.3.zip",
|
||||
"Pokemon_Z": "POKEMON Z V2.16.zip",
|
||||
"Pokemon_Re_Union_DX": "Pokemon Reunion EX Trilogy 4.26.zip",
|
||||
"Pokemon_Solar_Light_Lunar_Dark": "PokmonSolarLightLunarDark1.0.1.zip",
|
||||
"Pokemon_Steve_the_Bibarel_Heart_of_Darkness": "Steve the Bibarel_HoD_2021.1.zip",
|
||||
"Pokemon_Thyme": "Pokémon Thyme.zip",
|
||||
"Pokemon_AfricanVs": "Pokémon Africanvs Definitive Edition.rar",
|
||||
"Pokemon_Birch_s_Folly": "birchs-folly-v1.2.zip",
|
||||
"Pokemon_Nebula_3": "Pokemon Nebula 3 v1.1.5.rar",
|
||||
"Pokemon_Realidea_System": "Realidea V4.1.zip",
|
||||
"Pokemon_A_il": "Pokemon Añil V2.09G English.rar", # English (vs ANIL V3.06 Spanish), ginnoir 2026-06-22
|
||||
}
|
||||
|
||||
|
||||
def clean_name(title):
|
||||
"""Catalog title -> canonical hack name for the library filename."""
|
||||
name = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode()
|
||||
name = re.sub(r"^\s*pokemon\s*-?\s*", "", name, flags=re.I).strip()
|
||||
name = re.sub(r"[\\/:*?\"<>|]", "", name).strip() # FAT/NTFS-illegal chars
|
||||
return name or title
|
||||
|
||||
|
||||
def repackage_to_zip(src_archive, dest_zip):
|
||||
"""Extract a .rar/.7z and re-zip its contents (structure preserved) into
|
||||
dest_zip. Uses bsdtar (libarchive) which reads rar4/rar5/7z. Returns
|
||||
(ok, message). Writes to a temp zip then atomically renames into place."""
|
||||
tmp = tempfile.mkdtemp(prefix="pcrepack_")
|
||||
try:
|
||||
r = subprocess.run(["bsdtar", "-xf", src_archive, "-C", tmp],
|
||||
capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
return False, "bsdtar extract failed: " + (r.stderr or "").strip()[:80]
|
||||
entries = os.listdir(tmp)
|
||||
if not entries:
|
||||
return False, "archive extracted empty"
|
||||
tmp_zip = dest_zip + ".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))
|
||||
os.replace(tmp_zip, dest_zip)
|
||||
return True, "repacked rar/7z -> zip"
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def resolve_dir(scrape_dir, ondisk):
|
||||
if scrape_dir in ondisk:
|
||||
return scrape_dir
|
||||
alt = scrape_dir.replace("Pokemon_", "Pok_mon_")
|
||||
return alt if alt in ondisk else None
|
||||
|
||||
|
||||
def is_guide(fn):
|
||||
low = fn.lower()
|
||||
return any(k in low for k in GUIDE_KEYWORDS)
|
||||
|
||||
|
||||
def pick_archive(folder, disk):
|
||||
"""Return (chosen_filename | None, all_game_candidates, status)."""
|
||||
files = os.listdir(folder)
|
||||
archives = [f for f in files
|
||||
if os.path.splitext(f)[1].lower() in ARCHIVE_EXT
|
||||
and os.path.isfile(os.path.join(folder, f))]
|
||||
games = [f for f in archives if not is_guide(f)]
|
||||
if disk in ARTIFACT_OVERRIDE:
|
||||
pin = ARTIFACT_OVERRIDE[disk]
|
||||
if pin in files:
|
||||
return pin, games, "PLACE"
|
||||
return None, games, "OVERRIDE-MISSING" # override names a file that's gone
|
||||
if len(games) == 1:
|
||||
return games[0], games, "PLACE"
|
||||
if len(games) == 0:
|
||||
return None, archives, "MISSING"
|
||||
return None, games, "AMBIGUOUS"
|
||||
|
||||
|
||||
def main():
|
||||
apply = "--apply" in sys.argv
|
||||
repack = "--no-repack" not in sys.argv
|
||||
catalog = "wiki/catalog.json"
|
||||
if "--catalog" in sys.argv:
|
||||
catalog = sys.argv[sys.argv.index("--catalog") + 1]
|
||||
cat = json.load(open(catalog, encoding="utf-8"))
|
||||
pc = [h for h in cat["hacks"] if str(h.get("platform")) == "PC"]
|
||||
ondisk = set(os.listdir(LIB))
|
||||
|
||||
rows = [] # (status, title, detail, dest)
|
||||
placed = skipped = 0
|
||||
if apply:
|
||||
os.makedirs(DEST, exist_ok=True)
|
||||
|
||||
for it in pc:
|
||||
title = it.get("title") or it.get("stem") or "?"
|
||||
disk = resolve_dir(it["scrape_dir"], ondisk)
|
||||
if not disk:
|
||||
rows.append(("NO-FILES", title, "catalog-only (gated/Wanted)", ""))
|
||||
continue
|
||||
folder = os.path.join(LIB, disk)
|
||||
chosen, cands, status = pick_archive(folder, disk)
|
||||
if status != "PLACE":
|
||||
detail = {
|
||||
"MISSING": "no game archive on disk",
|
||||
"AMBIGUOUS": "multiple: " + " | ".join(cands),
|
||||
"OVERRIDE-MISSING": "pinned archive not found",
|
||||
}.get(status, status)
|
||||
rows.append((status, title, detail, ""))
|
||||
continue
|
||||
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}"
|
||||
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 "")
|
||||
if os.path.exists(dest):
|
||||
rows.append(("EXISTS", title, chosen + note, f"windows/{out_name}"))
|
||||
skipped += 1
|
||||
continue
|
||||
if apply:
|
||||
src = os.path.join(folder, chosen)
|
||||
if will_repack:
|
||||
ok, msg = repackage_to_zip(src, dest)
|
||||
if not ok:
|
||||
rows.append(("REPACK-FAIL", title, f"{chosen}: {msg}", ""))
|
||||
continue
|
||||
else:
|
||||
shutil.copy2(src, dest)
|
||||
rows.append(("PLACE", title, chosen + note, f"windows/{out_name}"))
|
||||
placed += 1
|
||||
|
||||
order = {"PLACE": 0, "EXISTS": 1, "REPACK-FAIL": 2, "AMBIGUOUS": 3,
|
||||
"MISSING": 4, "OVERRIDE-MISSING": 5, "NO-FILES": 6}
|
||||
print(f"\n=== PC MIGRATE ({'APPLIED' if apply else 'DRY RUN'}) -> {DEST} ===")
|
||||
for st, title, detail, dest in sorted(rows, key=lambda r: (order.get(r[0], 9), r[1])):
|
||||
print(f"[{st:16}] {title[:40]:40} {detail}")
|
||||
if dest:
|
||||
print(f"{'':19}-> {dest}")
|
||||
from collections import Counter
|
||||
c = Counter(r[0] for r in rows)
|
||||
print(f"\nsummary: {dict(sorted(c.items()))}")
|
||||
print(f"{'copied' if apply else 'would copy'}: {placed} already present: {skipped}")
|
||||
if not apply and placed:
|
||||
print("\nre-run with --apply to copy. Resolve AMBIGUOUS via ARTIFACT_OVERRIDE first.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user