Sync catalog with vault: engine facet, Jaizu Emerald set, and PC placements.
Add engine annotation + MOC/wiki export support, a PC import staging helper, and refresh catalog.json after placing gated fan-games and new hack notes.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Annotate ROM-hack notes with the engine/base framework they're built on.
|
||||
|
||||
Adds an `engine:` frontmatter key + an `· engine **X**` token to the note's
|
||||
`> [!info]` callout line. Idempotent: re-running skips notes already annotated,
|
||||
so it's safe to run after future enrichment passes.
|
||||
|
||||
WHY a frozen map: engine attribution is research, not scrape metadata. This file
|
||||
doubles as the provenance record — each entry is tagged confirmed vs likely with
|
||||
the evidence behind it (see comments). Confirmed = a source explicitly names the
|
||||
engine; likely = strong era + feature-fingerprint inference, flagged "(likely)"
|
||||
in the value so it stays honest and queryable.
|
||||
|
||||
Engine frameworks:
|
||||
- CFRU (Complete FireRed Upgrade) + DPE (Dynamic Pokémon Expansion) — Skeli789
|
||||
& ghoulslash. Binary engine injected into a FireRed ROM. The modern FireRed
|
||||
base for full-dex / Gen-9-mechanics hacks.
|
||||
- pokefirered (decomp) — pret's FireRed disassembly. The *other* modern FireRed
|
||||
base; distinct from CFRU (decomp vs binary).
|
||||
- pokeemerald-expansion — RHH (Rom Hack Hideout), built on pret's pokeemerald
|
||||
decomp. The Emerald analogue of CFRU.
|
||||
- pokeemerald (decomp) — pret's plain Emerald decomp / Pokabbie's rogue fork.
|
||||
- hg-engine — BluRosie. HeartGold engine overhaul (the HGSS analogue).
|
||||
|
||||
Run: python scripts/annotate-engines.py ["<path to Obsidian Vault>"]
|
||||
"""
|
||||
import os, re, sys, glob
|
||||
|
||||
DEFAULT_VAULT = r"C:\Users\MattC\Documents\Obsidian Vault"
|
||||
CATALOG = "Pokémon ROM Hacks"
|
||||
|
||||
# stem -> engine value. "(likely)" suffix = inferred, not source-confirmed.
|
||||
ENGINE_MAP = {
|
||||
# --- FireRed → CFRU + DPE (confirmed) -------------------------------------
|
||||
"Radical Red": "CFRU + DPE", # docs/community: CFRU+DPE, Skeli789
|
||||
"Unbound": "CFRU", # "powered by CFRU alongside custom code"
|
||||
"Fire Red 898": "CFRU + DPE", # uses CFRU+DPE+Leon's Rombase
|
||||
"Aesthetic Red": "CFRU + DPE", # PokeHarbor: CFRU+DPE engine base
|
||||
# --- FireRed → CFRU (likely: modern full-dex + CFRU feature fingerprint) ---
|
||||
"Roaring Red": "CFRU + DPE (likely)", # 2025, modern mechanics, 386 dex
|
||||
"Astral Red": "CFRU + DPE (likely)", # 2024; QoL list = CFRU defaults
|
||||
"Fire Red Extended": "CFRU (likely)", # being reworked onto CFRU
|
||||
# --- FireRed → pokefirered decomp (likely; NOT CFRU) ----------------------
|
||||
"FireRed Reignited": "pokefirered (decomp, likely)",
|
||||
"FireRed Reignited LeafGreen Regrown": "pokefirered (decomp, likely)",
|
||||
# --- Emerald → pokeemerald-expansion (RHH) --------------------------------
|
||||
"Inclement Emerald": "pokeemerald-expansion", # decomp difficulty hack
|
||||
"Emerald Seaglass": "pokeemerald-expansion", # confirmed
|
||||
"Modern Emerald": "pokeemerald-expansion", # confirmed
|
||||
"Energized Emerald": "pokeemerald-expansion", # confirmed
|
||||
"Inverse Emerald": "pokeemerald-expansion", # confirmed (pret + rh-hideout)
|
||||
"R.O.W.E.": "pokeemerald-expansion", # open-world decomp, Gen 9
|
||||
"Blazing Emerald": "pokeemerald-expansion (likely)",
|
||||
"Ephemerald": "pokeemerald-expansion (likely)",
|
||||
# --- Emerald → pokeemerald decomp (Pokabbie rogue fork) -------------------
|
||||
"Emerald Rogue": "pokeemerald (decomp)", # github.com/Pokabbie/pokeemerald-rogue
|
||||
"Emerald Rogue 2.0": "pokeemerald (decomp)",
|
||||
# --- Recharged series (Jaizu) → decomp; expansion-feature fingerprint -----
|
||||
# Vanilla+ QoL hacks with optional Nuzlocke/level-cap toggles; following mons,
|
||||
# mints/bottle caps, Fairy type, HM revamp == pokeemerald-expansion features.
|
||||
"Recharged Emerald": "pokeemerald-expansion (likely)",
|
||||
"Recharged Emerald Rebalanced": "pokeemerald-expansion (likely)", # rebalanced/difficulty variant
|
||||
"Recharged Pink": "pokeemerald-expansion (likely)",
|
||||
"Recharged Yellow (previously known as Pokémon Yellow Cross)": "pokeemerald-expansion (likely)",
|
||||
# Emerald Cross (Jaizu) — vanilla+ QoL/bugfix, no expansion → lighter decomp build
|
||||
"Emerald Cross": "pokeemerald (decomp, likely)",
|
||||
# --- HeartGold → hg-engine ------------------------------------------------
|
||||
"HeartGold Generation": "hg-engine", # body already says hg-engine
|
||||
}
|
||||
|
||||
# Notes whose scrape left base/version broken — fix while we're in there.
|
||||
FIXUPS = {
|
||||
"HeartGold Generation": {"base": "HeartGold", "version": "2.0"},
|
||||
}
|
||||
|
||||
|
||||
def annotate(path):
|
||||
text = open(path, encoding="utf-8").read()
|
||||
stem = os.path.splitext(os.path.basename(path))[0]
|
||||
engine = ENGINE_MAP[stem]
|
||||
fix = FIXUPS.get(stem, {})
|
||||
lines = text.split("\n")
|
||||
|
||||
# locate frontmatter bounds
|
||||
if lines[0].strip() != "---":
|
||||
return f" SKIP {stem}: no frontmatter"
|
||||
fm_end = next(i for i in range(1, len(lines)) if lines[i].strip() == "---")
|
||||
|
||||
changed = []
|
||||
|
||||
# --- fixups (base/version) in frontmatter + callout -----------------------
|
||||
for key, val in fix.items():
|
||||
for i in range(1, fm_end):
|
||||
m = re.match(rf'^{key}:\s*(.*)$', lines[i])
|
||||
if m and m.group(1).strip(' "') != val:
|
||||
lines[i] = f'{key}: {val}'
|
||||
changed.append(f"{key}->{val}")
|
||||
break
|
||||
|
||||
# --- engine: in frontmatter (after base:, idempotent) ---------------------
|
||||
if any(re.match(r'^engine:\s', l) for l in lines[1:fm_end]):
|
||||
pass # already present
|
||||
else:
|
||||
base_i = next((i for i in range(1, fm_end)
|
||||
if re.match(r'^base:\s', lines[i])), fm_end - 1)
|
||||
lines.insert(base_i + 1, f'engine: "{engine}"')
|
||||
fm_end += 1
|
||||
changed.append("frontmatter engine")
|
||||
|
||||
# --- callout token (append to the line after `> [!info]`) -----------------
|
||||
info_i = next((i for i, l in enumerate(lines)
|
||||
if l.strip().startswith("> [!info]")), None)
|
||||
if info_i is not None and info_i + 1 < len(lines):
|
||||
meta = lines[info_i + 1]
|
||||
if meta.lstrip().startswith(">") and "· engine **" not in meta:
|
||||
# apply base/version fixups to the callout line too
|
||||
for key, val in fix.items():
|
||||
meta = re.sub(rf'{key} \*\*[^*]*\*\*', f'{key} **{val}**', meta)
|
||||
lines[info_i + 1] = meta.rstrip() + f" · engine **{engine}**"
|
||||
changed.append("callout token")
|
||||
|
||||
if not changed:
|
||||
return f" ok {stem}: already annotated"
|
||||
open(path, "w", encoding="utf-8").write("\n".join(lines))
|
||||
return f" EDIT {stem}: {', '.join(changed)}"
|
||||
|
||||
|
||||
def main():
|
||||
vault = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_VAULT
|
||||
hacks = os.path.join(vault, CATALOG, "Hacks")
|
||||
if not os.path.isdir(hacks):
|
||||
sys.exit(f"Hacks dir not found: {hacks}")
|
||||
by_stem = {os.path.splitext(os.path.basename(p))[0]: p
|
||||
for p in glob.glob(os.path.join(hacks, "*.md"))}
|
||||
missing = [s for s in ENGINE_MAP if s not in by_stem]
|
||||
print(f"Annotating {len(ENGINE_MAP)} notes ({len(missing)} missing):")
|
||||
for stem in ENGINE_MAP:
|
||||
if stem in by_stem:
|
||||
print(annotate(by_stem[stem]))
|
||||
else:
|
||||
print(f" MISS {stem}: no note found")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -75,6 +75,7 @@ def load_records(hacks_dir):
|
||||
"base": fm.get("base") or DASH,
|
||||
"version": fm.get("version") or DASH,
|
||||
"status": fm.get("status") or DASH,
|
||||
"engine": fm.get("engine") or DASH,
|
||||
"type": typ,
|
||||
})
|
||||
return recs
|
||||
@@ -159,17 +160,17 @@ def platform_static_table(recs):
|
||||
|
||||
def base_static_table(recs, with_base):
|
||||
if with_base:
|
||||
head = "| Hack | Base | Version | Dev | Type |\n|---|---|---|---|---|"
|
||||
head = "| Hack | Base | Version | Dev | Engine | Type |\n|---|---|---|---|---|---|"
|
||||
rows = [
|
||||
f"| [[{r['stem']}]] | {cell(r['base'])} | {cell(r['version'])} | "
|
||||
f"{cell(r['status'])} | {types_cell(r['type'])} |"
|
||||
f"{cell(r['status'])} | {cell(r['engine'])} | {types_cell(r['type'])} |"
|
||||
for r in sorted(recs, key=key)
|
||||
]
|
||||
else:
|
||||
head = "| Hack | Version | Dev | Type |\n|---|---|---|---|"
|
||||
head = "| Hack | Version | Dev | Engine | Type |\n|---|---|---|---|---|"
|
||||
rows = [
|
||||
f"| [[{r['stem']}]] | {cell(r['version'])} | {cell(r['status'])} | "
|
||||
f"{types_cell(r['type'])} |"
|
||||
f"{cell(r['engine'])} | {types_cell(r['type'])} |"
|
||||
for r in sorted(recs, key=key)
|
||||
]
|
||||
return head + "\n" + "\n".join(rows)
|
||||
|
||||
@@ -21,7 +21,7 @@ OUT = os.path.join(OUT_DIR, "catalog.json")
|
||||
|
||||
SCALARS = ["title", "platform", "base", "version", "status", "generation",
|
||||
"developer", "release_date", "banner", "homepage", "source",
|
||||
"library_path", "play_status", "scrape_dir"]
|
||||
"library_path", "play_status", "scrape_dir", "engine"]
|
||||
|
||||
|
||||
def section(body, heading):
|
||||
@@ -79,6 +79,7 @@ def main():
|
||||
return dict(sorted(c.items(), key=lambda kv: (-kv[1], kv[0])))
|
||||
|
||||
type_counts = Counter(t for h in hacks for t in h["type"])
|
||||
engine_counts = Counter(h["engine"] for h in hacks if h.get("engine"))
|
||||
out = {
|
||||
"generated": date.today().isoformat(),
|
||||
"count": len(hacks),
|
||||
@@ -86,6 +87,7 @@ def main():
|
||||
"platform": facet("platform"),
|
||||
"base": facet("base"),
|
||||
"status": facet("status"),
|
||||
"engine": dict(sorted(engine_counts.items(), key=lambda kv: (-kv[1], kv[0]))),
|
||||
"type": dict(sorted(type_counts.items(), key=lambda kv: (-kv[1], kv[0]))),
|
||||
"play_status": facet("play_status"),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Repack staged PC downloads from /tmp/pc-import into roms/windows/."""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
import shutil
|
||||
|
||||
DEST = "/storage1/Emulation/roms/windows"
|
||||
STAGE = "/tmp/pc-import"
|
||||
|
||||
|
||||
def repack_rar_to_zip(rar_path, dest_zip):
|
||||
tmp = tempfile.mkdtemp(prefix="pcrepack_")
|
||||
try:
|
||||
r = subprocess.run(["bsdtar", "-xf", rar_path, "-C", tmp], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
return False, "bsdtar: " + (r.stderr or r.stdout)[:200]
|
||||
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)
|
||||
has_exe = any(fn.lower() == "game.exe" for fn in os.listdir(tmp))
|
||||
return True, f"{os.path.getsize(dest_zip):,} bytes, Game.exe={has_exe}"
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def merge_zip_overlay(base_zip, overlay_zip, dest_zip):
|
||||
tmp = tempfile.mkdtemp(prefix="pcmerge_")
|
||||
try:
|
||||
with zipfile.ZipFile(base_zip) as z:
|
||||
z.extractall(tmp)
|
||||
with zipfile.ZipFile(overlay_zip) as z:
|
||||
z.extractall(tmp)
|
||||
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)
|
||||
has_exe = os.path.exists(os.path.join(tmp, "Game.exe"))
|
||||
return True, f"{os.path.getsize(dest_zip):,} bytes, Game.exe={has_exe}"
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def apply_deserted():
|
||||
base_rar = update_rar = None
|
||||
for fn in os.listdir(STAGE):
|
||||
if "Deserted" not in fn:
|
||||
continue
|
||||
if "1.4" in fn:
|
||||
update_rar = os.path.join(STAGE, fn)
|
||||
else:
|
||||
base_rar = os.path.join(STAGE, fn)
|
||||
if not base_rar:
|
||||
return False, "base rar missing"
|
||||
tmp = tempfile.mkdtemp(prefix="pcdeserted_")
|
||||
try:
|
||||
r = subprocess.run(["bsdtar", "-xf", base_rar, "-C", tmp], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
return False, "base extract: " + (r.stderr or "")[:120]
|
||||
if update_rar:
|
||||
r = subprocess.run(["bsdtar", "-xf", update_rar, "-C", tmp], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
return False, "update extract: " + (r.stderr or "")[:120]
|
||||
dest_zip = os.path.join(DEST, "Pokemon - Deserted (Hack).zip")
|
||||
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)
|
||||
has_exe = os.path.exists(os.path.join(tmp, "Game.exe"))
|
||||
return True, f"{os.path.getsize(dest_zip):,} bytes, Game.exe={has_exe}, update={bool(update_rar)}"
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(DEST, exist_ok=True)
|
||||
for label, fn in [
|
||||
("bushido", lambda: repack_rar_to_zip(
|
||||
f"{STAGE}/bushido.rar", f"{DEST}/Pokemon - bushido (Hack).zip")),
|
||||
("rejuvenation", lambda: merge_zip_overlay(
|
||||
f"{STAGE}/rejuvenation.zip", f"{STAGE}/rejuvenation-patch.zip",
|
||||
f"{DEST}/Pokemon - Rejuvenation (Hack).zip")),
|
||||
("deserted", apply_deserted),
|
||||
]:
|
||||
ok, msg = fn()
|
||||
print(f"{label}: {ok} {msg}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user