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:
@@ -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 (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 — 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()
|
||||
Reference in New Issue
Block a user