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:
ginnoir
2026-06-23 00:09:44 -05:00
parent abd8dab7fa
commit 2ebc2cb276
5 changed files with 566 additions and 0 deletions
+30
View File
@@ -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/<scrape_dir>/<file>` (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/<dir>/`); 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> (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/<Game.ini Title>/`.
## Acquisition policy
Pre-3DS handhelds (gb/gbc/gba/nds) + pre-Wii consoles. Direct, non-anti-bot
+198
View File
@@ -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/<Game.ini Title> (Essentials v18+).
# Verify each on first save; for Essentials <=v17 uncomment the <base> line.
'Pokemon - A FarfetchD Story (Hack)':
files:
'<home>/Saved Games/Pokemon: A Farfetch''d Story':
tags: [save]
# '<base>': # 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
'<home>/Saved Games/Pokemon - A Star in the Desert (Hack)':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Acceptance (Hack)':
files:
'<home>/Saved Games/Pokemon Acceptance':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - AfricanVs (Hack)':
files:
'<home>/Saved Games/Pokemon Africanvs':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Anil (Hack)':
files:
'<home>/Saved Games/Pokemon Anil':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Ashen Frost (Hack)':
files:
'<home>/Saved Games/Pokemon Ashen Frost':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Asther Violet (Hack)':
files:
'<home>/Saved Games/Pokemon Violet':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Attack on the Space station (Hack)':
files:
'<home>/Saved Games/Pokmon: Attack On The Space Station':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Birchs Folly (Hack)':
files:
'<home>/Saved Games/Birch''s Folly':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Chaos in Vesita (Hack)':
files:
'<home>/Saved Games/Pokmon: Chaos in Vesita (Full Version)':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Decay (Hack)':
files:
'<home>/Saved Games/Pokemon Decay':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Empyrean (Hack)':
files:
'<home>/Saved Games/Pokmon Empyrean':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Gadir Deluxe (Hack)':
files:
'<home>/Saved Games/Pokemon Gadir Deluxe':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Hollow Woods (Hack)':
files:
'<home>/Saved Games/Pokmon Hollow Woods':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Hydro Bliss (Hack)':
files:
'<home>/Saved Games/Pokemon Hydro Bliss':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Infinity (Hack)':
files:
'<home>/Saved Games/Pokmon Infinity':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Insurgence (Hack)':
files:
'<home>/Saved Games/Pokemon Insurgence':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Keishou (Hack)':
files:
'<home>/Saved Games/Pokmon Essentials v20.1':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Mineral (Hack)':
files:
'<home>/Saved Games/Pokemon Mineral':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Nebula 1 (Hack)':
files:
'<home>/Saved Games/Pokemon Nebula':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Nebula 3 (Hack)':
files:
'<home>/Saved Games/Pokemon Nebula 3':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Poketropolis (Hack)':
files:
'<home>/Saved Games/Pokemon Essentials':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Radiant Topaz (Hack)':
files:
'<home>/Saved Games/POKEMON Radiant Topaz':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - ReUnion DX (Hack)':
files:
'<home>/Saved Games/Projet Reunion':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Realidea System (Hack)':
files:
'<home>/Saved Games/Realidea System':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Revitalus Immortalis (Hack)':
files:
'<home>/Saved Games/Revitalus Immortalis':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Shiny Island (Hack)':
files:
'<home>/Saved Games/POKEMON SHINY ISLAND':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Solar Light Lunar Dark (Hack)':
files:
'<home>/Saved Games/Pokmon Solar Light & Lunar Dark 1.0.1':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Steve the Bibarel Heart of Darkness (Hack)':
files:
'<home>/Saved Games/Steve the Bibarel: Heart of Darkness 1.4':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Thyme (Hack)':
files:
'<home>/Saved Games/Pokmon Thyme':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Void (Hack)':
files:
'<home>/Saved Games/Pokemon Void 0.1.5':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
'Pokemon - Z (Hack)':
files:
'<home>/Saved Games/Pokemon Z':
tags: [save]
# '<base>': # older Essentials saved in the game folder
# tags: [save]
+67
View File
@@ -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()
+66
View File
@@ -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
+205
View File
@@ -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()