Files
pokemon/scripts/build-vault-mocs.py
T
ginnoir 2ec8952f4f 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).
2026-06-08 04:49:56 -05:00

361 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()