feat(vault): add static-surface generator for the ROM-hack catalog

Reads Pokémon ROM Hacks/Hacks/*.md as source of truth and rewrites the
static mirror tables + header counts in Index.md, Platforms/*.md, and
Bases/*.md. Dataview blocks, prose intros, and footers are preserved;
the notes themselves are never edited (curated type/rating/version safe).

Covers all 285 hacks; creates Platforms/{3DS,PC,Patch,Unknown}.md.
Supersedes the archived build-romhack-vault.py (a hardcoded 75-hack
bootstrap that would regress the live vault).
This commit is contained in:
ginnoir
2026-06-08 04:49:56 -05:00
parent ab7c746df3
commit 2ec8952f4f
+360
View File
@@ -0,0 +1,360 @@
#!/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 <VAULT>/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/<P>.md — H1 count + "## Static" table. Existing: GBA/GBC/GB/NDS.
Created if missing: 3DS, PC, Patch, Unknown.
- Bases/<B>.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 ["<path to Obsidian Vault>"]
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 (15)."""
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 — the original hand-curated 75 plus "
f"~210 auto-imported from the Discord catalog on 2026-06-08 (tag "
f"`source/discord`).\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`. Imports have `play_status: Unplayed`, "
"empty `type`, 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()