Files
pokemon/scripts/build-vault-mocs.py
T
ginnoir 52ee1b98a1 Complete curated-list import automation and vault sync.
Adds list-import tooling, expands romhack-import for PC variants and patch batches, and syncs catalog with 2026-06-23 valhalla placements (Vanguard, Xenoverse, GBA patches, Infinity 3.3.1).
2026-06-23 03:57:50 -05:00

369 lines
15 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,
"engine": fm.get("engine") 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()
def wikilink(stem: str) -> str:
"""Obsidian-safe wikilink for note stems that contain [ or ]."""
escaped = stem.replace("[", "\\[").replace("]", "\\]")
return f"[[{escaped}]]"
# ---------------------------------------------------------------- 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"| {wikilink(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 | Engine | Type |\n|---|---|---|---|---|---|"
rows = [
f"| {wikilink(r['stem'])} | {cell(r['base'])} | {cell(r['version'])} | "
f"{cell(r['status'])} | {cell(r['engine'])} | {types_cell(r['type'])} |"
for r in sorted(recs, key=key)
]
else:
head = "| Hack | Version | Dev | Engine | Type |\n|---|---|---|---|---|"
rows = [
f"| {wikilink(r['stem'])} | {cell(r['version'])} | {cell(r['status'])} | "
f"{cell(r['engine'])} | {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"| {wikilink(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()