From 2ebc2cb276ae6fc321e337b9e115f1e876949d36 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Tue, 23 Jun 2026 00:09:44 -0500 Subject: [PATCH] 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. --- README.md | 30 +++++ ludusavi/pokemon-pc-hacks.yaml | 198 +++++++++++++++++++++++++++++ scripts/gen-ludusavi-manifest.py | 67 ++++++++++ scripts/launch-pc-hack.ps1 | 66 ++++++++++ scripts/romhack-pc-migrate.py | 205 +++++++++++++++++++++++++++++++ 5 files changed, 566 insertions(+) create mode 100644 ludusavi/pokemon-pc-hacks.yaml create mode 100644 scripts/gen-ludusavi-manifest.py create mode 100644 scripts/launch-pc-hack.ps1 create mode 100644 scripts/romhack-pc-migrate.py diff --git a/README.md b/README.md index 627042b..eb5fc54 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ served by the RomM stack (`roms.ginnoir.com`) — deployed from `homelabstack` | `scripts/romhack-import.py` | Import a drop folder of mixed ROMs/patches/docs into the valhalla library (validate, clean-name, apply patches, archive). Runs on valhalla. | | `scripts/romhack-fetch.py` | Acquire + patch + place worthwhile hacks from direct (non-gated) sources. Manifest-driven. Runs on valhalla. | | `scripts/romhack-apply.py` | Apply a single IPS/BPS/UPS patch to a base ROM with checksum verification. | +| `scripts/romhack-pc-migrate.py` | Place the PC/Windows fan-games (RPG-Maker/Essentials) from the scrape staging tree into RomM's `windows` platform so RomM catalogs + serves them like the console hacks. Dry-run default; `--apply` copies, repackaging `.rar`→`.zip`. Runs on valhalla. | +| `scripts/launch-pc-hack.ps1` | Playnite custom-emulator launcher for the PC hacks: finds `Game.exe` in the RomM-downloaded folder, sets CWD, runs it. Runs on the Windows client. | | `scripts/build-vault-mocs.py` | Regenerate the vault's static MOC tables + header counts (Index/Platforms/Bases) from the notes. Read-only on `Hacks/*.md`. Re-run after any note change. | | `scripts/scrape_match.py` | Match the Discord-scrape `metadata.json` entries to vault notes → `.scrape/_match_report.json` (curated-overlap / discord-match / unmatched). | | `scripts/enrich_vault.py` | Synthesize the scrape into notes: `backfill` (frontmatter for imports), `merge` (scrape into curated), `create` (new notes), `rebuild` (normalize import bodies). `--apply` writes; default dry-runs to `.scrape/_preview/`. | @@ -62,6 +64,34 @@ Art for every scraped hack is web-served at `https://romhacks-files.ginnoir.com/_meta//` (copied into the Caddy-served `library/_meta/` tree on valhalla). +## PC / Windows hacks + +The 40 `platform: PC` catalog entries are RPG-Maker XP / Pokémon Essentials +fan-games (`Game.exe` + `Data/*.rxdata`), not emulatable ROMs. 32 have files in +the scrape staging tree (`/storage1/labdata/romhacks/library//`); the other 8 +are gated (Reborn, Rejuvenation, Infinite Fusion, …) and live catalog-only on the +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 +make RomM treat the whole thing as one multi-file game). + +```bash +# place the PC hacks into roms/windows/ (dry-run first) +python3 scripts/romhack-pc-migrate.py --catalog wiki/catalog.json +python3 scripts/romhack-pc-migrate.py --catalog wiki/catalog.json --apply +# then scan just that platform in RomM, or via API +``` + +- **Windows play:** RomM Playnite plugin downloads + extracts the zip, then + launches via `scripts/launch-pc-hack.ps1` registered as a Custom emulator. +- **Steam Deck:** no automated path — RetroDECK has no Windows runtime. mkxp-z + ports (ship `mkxp.json`) run natively on Linux; the rest need manual Proton. +- **Save sync:** RomM's save sync is emulator-only (won't cover these). Use + Ludusavi + Syncthing; Essentials saves land in `~/Saved Games//`. + ## Acquisition policy Pre-3DS handhelds (gb/gbc/gba/nds) + pre-Wii consoles. Direct, non-anti-bot diff --git a/ludusavi/pokemon-pc-hacks.yaml b/ludusavi/pokemon-pc-hacks.yaml new file mode 100644 index 0000000..e7eef70 --- /dev/null +++ b/ludusavi/pokemon-pc-hacks.yaml @@ -0,0 +1,198 @@ +# Ludusavi secondary manifest — Pokémon PC hacks (generated) +# Add via Ludusavi > Settings > Manifests, or merge into a custom manifest. +# Save path = ~/Saved Games/ (Essentials v18+). +# Verify each on first save; for Essentials <=v17 uncomment the line. + +'Pokemon - A FarfetchD Story (Hack)': + files: + '/Saved Games/Pokemon: A Farfetch''d Story': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - A Star in the Desert (Hack)': + files: + # TODO no Game.ini Title found — set save path manually + '/Saved Games/Pokemon - A Star in the Desert (Hack)': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Acceptance (Hack)': + files: + '/Saved Games/Pokemon Acceptance': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - AfricanVs (Hack)': + files: + '/Saved Games/Pokemon Africanvs': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Anil (Hack)': + files: + '/Saved Games/Pokemon Anil': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Ashen Frost (Hack)': + files: + '/Saved Games/Pokemon Ashen Frost': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Asther Violet (Hack)': + files: + '/Saved Games/Pokemon Violet': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Attack on the Space station (Hack)': + files: + '/Saved Games/Pokmon: Attack On The Space Station': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Birchs Folly (Hack)': + files: + '/Saved Games/Birch''s Folly': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Chaos in Vesita (Hack)': + files: + '/Saved Games/Pokmon: Chaos in Vesita (Full Version)': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Decay (Hack)': + files: + '/Saved Games/Pokemon Decay': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Empyrean (Hack)': + files: + '/Saved Games/Pokmon Empyrean': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Gadir Deluxe (Hack)': + files: + '/Saved Games/Pokemon Gadir Deluxe': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Hollow Woods (Hack)': + files: + '/Saved Games/Pokmon Hollow Woods': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Hydro Bliss (Hack)': + files: + '/Saved Games/Pokemon Hydro Bliss': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Infinity (Hack)': + files: + '/Saved Games/Pokmon Infinity': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Insurgence (Hack)': + files: + '/Saved Games/Pokemon Insurgence': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Keishou (Hack)': + files: + '/Saved Games/Pokmon Essentials v20.1': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Mineral (Hack)': + files: + '/Saved Games/Pokemon Mineral': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Nebula 1 (Hack)': + files: + '/Saved Games/Pokemon Nebula': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Nebula 3 (Hack)': + files: + '/Saved Games/Pokemon Nebula 3': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Poketropolis (Hack)': + files: + '/Saved Games/Pokemon Essentials': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Radiant Topaz (Hack)': + files: + '/Saved Games/POKEMON Radiant Topaz': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - ReUnion DX (Hack)': + files: + '/Saved Games/Projet Reunion': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Realidea System (Hack)': + files: + '/Saved Games/Realidea System': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Revitalus Immortalis (Hack)': + files: + '/Saved Games/Revitalus Immortalis': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Shiny Island (Hack)': + files: + '/Saved Games/POKEMON SHINY ISLAND': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Solar Light Lunar Dark (Hack)': + files: + '/Saved Games/Pokmon Solar Light & Lunar Dark 1.0.1': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Steve the Bibarel Heart of Darkness (Hack)': + files: + '/Saved Games/Steve the Bibarel: Heart of Darkness 1.4': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Thyme (Hack)': + files: + '/Saved Games/Pokmon Thyme': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Void (Hack)': + files: + '/Saved Games/Pokemon Void 0.1.5': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] +'Pokemon - Z (Hack)': + files: + '/Saved Games/Pokemon Z': + tags: [save] + # '': # older Essentials saved in the game folder + # tags: [save] diff --git a/scripts/gen-ludusavi-manifest.py b/scripts/gen-ludusavi-manifest.py new file mode 100644 index 0000000..a53c782 --- /dev/null +++ b/scripts/gen-ludusavi-manifest.py @@ -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.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 `` 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/ (Essentials v18+).") + print("# Verify each on first save; for Essentials <=v17 uncomment the 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)" + title = game_title(os.path.join(WIN, f)) + print(f"{sq(name)}:") + print(" files:") + if title: + print(f" {sq('/Saved Games/' + title)}:") + print(" tags: [save]") + else: + print(f" # TODO no Game.ini Title found — set save path manually") + print(f" {sq('/Saved Games/' + name)}:") + print(" tags: [save]") + print(" # '': # older Essentials saved in the game folder") + print(" # tags: [save]") + + +if __name__ == "__main__": + main() diff --git a/scripts/launch-pc-hack.ps1 b/scripts/launch-pc-hack.ps1 new file mode 100644 index 0000000..0e81bc6 --- /dev/null +++ b/scripts/launch-pc-hack.ps1 @@ -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 diff --git a/scripts/romhack-pc-migrate.py b/scripts/romhack-pc-migrate.py new file mode 100644 index 0000000..691caf5 --- /dev/null +++ b/scripts/romhack-pc-migrate.py @@ -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// 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// +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). + - `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()