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:
ginnoir
2026-06-23 00:35:16 -05:00
parent 84cc6f5d04
commit 0537ceba5d
5 changed files with 514 additions and 42 deletions
+145
View File
@@ -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()
+5 -4
View File
@@ -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)
+3 -1
View File
@@ -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"),
},
+101
View File
@@ -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()