#!/usr/bin/env python3 """Regenerate the Pokémon ROM-hack vault's STATIC tables, header counts, and MOCs. Source of truth = the live vault notes at /Pokémon ROM Hacks/Hacks/*.md (their YAML frontmatter). This script READS those notes and rewrites only the *static mirror* surfaces that Dataview does not maintain: - Index.md — intro platform/base counts, the "Directory (static)" table (all hacks), the Platforms + Bases lists. - Platforms/

.md — H1 count + "## Static" table. Existing: GBA/GBC/GB/NDS. Created if missing: 3DS, PC, Patch, Unknown. - Bases/.md — H1 count + "## Static" table. FireRed/Emerald/Crystal/ Ruby/NDS. It NEVER touches Hacks/*.md, so curated type/rating/version data is preserved by construction. Dataview code blocks, prose intros, and footers in the MOC files are preserved verbatim — only the H1 count line and the static-table region are replaced. This SUPERSEDES build-romhack-vault.py for the live vault. That script is an archived bootstrap with a hardcoded 75-hack dataset; re-running it would regress the catalog. Use THIS one to refresh static surfaces after notes are added. Run: python scripts/build-vault-mocs.py [""] Default vault: C:\\Users\\MattC\\Documents\\Obsidian Vault """ import os, re, sys, glob DEFAULT_VAULT = r"C:\Users\MattC\Documents\Obsidian Vault" CATALOG = "Pokémon ROM Hacks" DASH = "—" # em-dash used for "unknown" # ---------------------------------------------------------------- frontmatter def _unquote(s): s = s.strip() if len(s) >= 2 and s[0] == '"' and s[-1] == '"': return s[1:-1] return s def parse_frontmatter(text): m = re.match(r"^---\n(.*?)\n---", text, re.S) if not m: return {} fm, lines, i = {}, m.group(1).split("\n"), 0 while i < len(lines): km = re.match(r"^([\w]+):\s*(.*)$", lines[i]) if km: k, v = km.group(1), km.group(2) if v == "" and i + 1 < len(lines) and re.match(r"^\s*-\s", lines[i + 1]): items = [] while i + 1 < len(lines) and re.match(r"^\s*-\s", lines[i + 1]): items.append(_unquote(lines[i + 1].strip()[2:])) i += 1 fm[k] = items elif v.strip().startswith("[") and v.strip().endswith("]"): inner = v.strip()[1:-1].strip() fm[k] = [_unquote(x) for x in inner.split(",")] if inner else [] else: fm[k] = _unquote(v) i += 1 return fm def load_records(hacks_dir): recs = [] for f in glob.glob(os.path.join(hacks_dir, "*.md")): fm = parse_frontmatter(open(f, encoding="utf-8").read()) stem = os.path.splitext(os.path.basename(f))[0] typ = fm.get("type") or [] if isinstance(typ, str): typ = [typ] if typ and typ != "[]" else [] recs.append({ "stem": stem, "platform": fm.get("platform") or DASH, "base": fm.get("base") or DASH, "version": fm.get("version") or DASH, "status": fm.get("status") or DASH, "type": typ, }) return recs # ---------------------------------------------------------------- table cells def cell(v): return str(v).replace("|", "\\|") if v not in (None, "") else DASH def types_cell(types): return ", ".join(types) if types else "" def key(r): return r["stem"].casefold() # ---------------------------------------------------------------- MOC config # Logical hardware order for human-facing lists. PLATFORM_ORDER = ["GB", "GBC", "GBA", "NDS", "3DS", "PC", "Patch", DASH] PLATFORM_LABEL = { "GB": "Game Boy", "GBC": "Game Boy Color", "GBA": "Game Boy Advance", "NDS": "Nintendo DS", "3DS": "Nintendo 3DS", "PC": "PC / Joiplay fan-games", "Patch": "Patch-only", DASH: "Unknown platform", } # Platform MOC files: filename -> (H1 title prefix, predicate) def plat_pred(p): return lambda r: r["platform"] == p PLATFORM_MOCS = { "GBA": ("Game Boy Advance (GBA)", plat_pred("GBA")), "GBC": ("Game Boy Color (GBC)", plat_pred("GBC")), "GB": ("Game Boy (GB)", plat_pred("GB")), "NDS": ("Nintendo DS (NDS)", plat_pred("NDS")), "3DS": ("Nintendo 3DS (3DS)", plat_pred("3DS")), "PC": ("PC / Joiplay Fan-games", plat_pred("PC")), "Patch": ("Patch-only", plat_pred("Patch")), "Unknown": ("Unknown platform", plat_pred(DASH)), } # New platform MOCs that may not exist yet — prose + dataview WHERE clause. NEW_PLATFORM_MOCS = { "3DS": ("3DS", "Nintendo 3DS Pokémon fan-games and hacks. A small but growing set — " "mostly Citra/Luma-targeted projects harvested from the Discord catalog.", 'WHERE platform = "3DS"'), "PC": ("PC", "RPG-Maker / Joiplay fan-games (run on PC or via Joiplay on Android). " "These are standalone games, **not** console ROM hacks — they live under " "`/storage1/labdata/romhacks/library/` and are **not** scanned into RomM.", 'WHERE platform = "PC"'), "Patch": ("Patch", "Patch-only entries — an IPS/UPS/xdelta patch was catalogued but no " "pre-patched ROM was placed. Apply the patch to the listed base to play.", 'WHERE platform = "Patch"'), "Unknown": ("Unknown", "Entries whose target platform could not be determined from the " "import metadata. Triage these and set `platform:` in the note's " "frontmatter to file them under the right system.", 'WHERE platform = "' + DASH + '"'), } # Base MOC files: filename -> (predicate, has_base_column) BASE_MOCS = { "FireRed": (lambda r: r["base"] == "FireRed", False), "Emerald": (lambda r: "Emerald" in r["base"], False), "Crystal": (lambda r: r["base"] == "Crystal", False), "Ruby": (lambda r: r["base"] == "Ruby", False), "NDS": (lambda r: r["platform"] == "NDS", True), } # ---------------------------------------------------------------- table builders def platform_static_table(recs): head = "| Hack | Base | Version | Status | Type |\n|---|---|---|---|---|" rows = [ f"| [[{r['stem']}]] | {cell(r['base'])} | {cell(r['version'])} | " f"{cell(r['status'])} | {types_cell(r['type'])} |" for r in sorted(recs, key=key) ] return head + "\n" + "\n".join(rows) def base_static_table(recs, with_base): if with_base: head = "| Hack | Base | Version | Dev | Type |\n|---|---|---|---|---|" rows = [ f"| [[{r['stem']}]] | {cell(r['base'])} | {cell(r['version'])} | " f"{cell(r['status'])} | {types_cell(r['type'])} |" for r in sorted(recs, key=key) ] else: head = "| Hack | Version | Dev | Type |\n|---|---|---|---|" rows = [ f"| [[{r['stem']}]] | {cell(r['version'])} | {cell(r['status'])} | " f"{types_cell(r['type'])} |" for r in sorted(recs, key=key) ] return head + "\n" + "\n".join(rows) # ---------------------------------------------------------------- surgical edit def replace_static_region(text, new_h1, static_section): """Keep H1 (swapped), prose, the dataview block, and the [[Index ...]] footer. Replace everything between the dataview block and the footer with static_section. """ lines = text.split("\n") # find dataview fence fopen = next((i for i, l in enumerate(lines) if l.strip().startswith("```dataview")), None) if fopen is None: raise ValueError("no dataview block found") fclose = next(i for i in range(fopen + 1, len(lines)) if lines[i].strip() == "```") # find footer: last line containing [[Index footer_idx = max(i for i, l in enumerate(lines) if "[[Index" in l) head = [new_h1] + lines[1:fclose + 1] footer = lines[footer_idx:] body = ["", static_section, ""] return "\n".join(head + body + footer).rstrip() + "\n" def new_platform_file(short, prose, where, title, count, static_section): dv = ( "```dataview\nTABLE base, version, status, type\n" f'FROM "{CATALOG}/Hacks"\n{where}\nSORT file.name ASC\n```' ) return ( f"# {title} {DASH} {count} hacks\n\n{prose}\n\n{dv}\n\n" f"## Static\n\n{static_section}\n\n[[Index|← back to directory]]\n" ) # ---------------------------------------------------------------- Index sections # Preserved verbatim blocks (stable taxonomy / dataview / play-queue text). INDEX_DATAVIEW = ( "```dataview\n" "TABLE platform AS Platform, base AS Base, version AS Version, status AS Status, " "type AS Type, play_status AS Play, default(rating, \"—\") AS ★\n" f'FROM "{CATALOG}/Hacks"\n' "SORT platform ASC, file.name ASC\n" "```" ) INDEX_BYTYPE = """## By type - [[Types#Vanilla+|Vanilla+]] — Official game enhanced — QoL, all species obtainable, light rebalance; same region/story. - [[Types#QoL|QoL]] — Primarily quality-of-life conveniences over an official base. - [[Types#Difficulty|Difficulty]] — Built to be hard — boosted AI/teams, kaizo, or competitive rulesets. - [[Types#Expansion|Expansion]] — Built on a decomp + expansion: many generations, modern mechanics, new abilities/moves. - [[Types#New Experience|New Experience]] — New region, story, and/or Pokédex — a fresh adventure, not the official game. - [[Types#Demake|Demake]] — Recreates a later-generation game on older hardware. - [[Types#Roguelite|Roguelite]] — Run-based, randomized, permadeath-flavored structure. - [[Types#Cosmetic|Cosmetic]] — Visual/audio overhaul (e.g. Moemon sprites, tilesets, music) over an existing game.""" INDEX_PLAYQUEUE = """## Play Queue [[Play Queue]] — track what you're playing, your backlog, completed hacks, and ratings (1–5).""" def index_directory_table(recs): head = ("| Hack | Platform | Base | Version | Status | Type |\n" "|---|---|---|---|---|---|") order = {p: i for i, p in enumerate(PLATFORM_ORDER)} rows = [ f"| [[{r['stem']}]] | {cell(r['platform'])} | {cell(r['base'])} | " f"{cell(r['version'])} | {cell(r['status'])} | {types_cell(r['type'])} |" for r in sorted(recs, key=lambda r: (order.get(r["platform"], 99), key(r))) ] return head + "\n" + "\n".join(rows) def build_index(recs): total = len(recs) pc = {p: sum(1 for r in recs if r["platform"] == p) for p in PLATFORM_ORDER} count_line = " · ".join( f"**{PLATFORM_LABEL[p]}**: {pc[p]}" for p in PLATFORM_ORDER if pc[p] ) base_counts = { "FireRed": sum(1 for r in recs if r["base"] == "FireRed"), "Emerald": sum(1 for r in recs if "Emerald" in r["base"]), "Crystal": sum(1 for r in recs if r["base"] == "Crystal"), "Ruby": sum(1 for r in recs if r["base"] == "Ruby"), "NDS": sum(1 for r in recs if r["platform"] == "NDS"), } bases_line = " · ".join( f"[[Bases/{b}|{b}]] ({base_counts[b]})" for b in ["FireRed", "Emerald", "Crystal", "Ruby", "NDS"] ) plat_links = " · ".join( f"[[{('Unknown' if p == DASH else p)}|{PLATFORM_LABEL[p]}]]" for p in PLATFORM_ORDER if pc[p] ) callout = ( f"> [!note] {total} hacks total — 75 hand-curated, ~210 imported from the " f"Discord catalog, and 83 added from the scrape (tag `source/discord`), " f"enriched 2026-06-08 with developer / release / banner art and uniform " f"Summary·Features·Story bodies.\n" "> The **Dataview** blocks are the live source of truth; the **static** " "tables below are regenerated from the notes by " "`scripts/build-vault-mocs.py`. Most carry `play_status: Unplayed` and no " "`rating` yet — curate as you play." ) return f"""# Pokémon ROM-Hack Library — Index A knowledge base for the Pokémon ROM-hacks in the valhalla library. One note per hack — see [[Types]] for the tag taxonomy and the [[#Platforms]] section for per-system views. {callout} **By platform** — {count_line} ## Directory (Dataview) > Requires the Dataview plugin. If you don't use it, the static table below mirrors it. {INDEX_DATAVIEW} ## Directory (static) {index_directory_table(recs)} ## Platforms - {plat_links} ## Bases Browse by base game — {bases_line} {INDEX_BYTYPE} {INDEX_PLAYQUEUE} --- *Static surfaces generated by `scripts/build-vault-mocs.py` from the live notes — re-run after adding hacks.* """ # ---------------------------------------------------------------- main def main(): vault = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_VAULT root = os.path.join(vault, CATALOG) hacks = os.path.join(root, "Hacks") if not os.path.isdir(hacks): sys.exit(f"Hacks dir not found: {hacks}") recs = load_records(hacks) written = [] # Index.md (full rebuild — all pieces known/preserved) idx_path = os.path.join(root, "Index.md") open(idx_path, "w", encoding="utf-8").write(build_index(recs)) written.append(("Index.md", len(recs))) # Platform MOCs plat_dir = os.path.join(root, "Platforms") os.makedirs(plat_dir, exist_ok=True) for fname, (title, pred) in PLATFORM_MOCS.items(): sel = [r for r in recs if pred(r)] path = os.path.join(plat_dir, fname + ".md") table = platform_static_table(sel) if os.path.exists(path): text = open(path, encoding="utf-8").read() new = replace_static_region(text, f"# {title} {DASH} {len(sel)} hacks", f"## Static\n\n{table}") else: short, prose, where = NEW_PLATFORM_MOCS[fname] new = new_platform_file(short, prose, where, title, len(sel), table) open(path, "w", encoding="utf-8").write(new) written.append((f"Platforms/{fname}.md", len(sel))) # Base MOCs base_dir = os.path.join(root, "Bases") for fname, (pred, with_base) in BASE_MOCS.items(): sel = [r for r in recs if pred(r)] path = os.path.join(base_dir, fname + ".md") table = base_static_table(sel, with_base) text = open(path, encoding="utf-8").read() new = replace_static_region(text, f"# {fname} {DASH} {len(sel)} Hacks", f"## Static\n\n{table}") open(path, "w", encoding="utf-8").write(new) written.append((f"Bases/{fname}.md", len(sel))) print(f"Regenerated {len(written)} files from {len(recs)} notes:") for name, n in written: print(f" {name:24s} {n}") if __name__ == "__main__": main()