"""Generate docs/PATCHES.md (every patch and game setting) and site/src/data/patches.json (id -> anchor) from the
live patch registry, the bundled catalog and catalog/triage.yaml.
python scripts/patch_docs.py # rewrite both files
python scripts/patch_docs.py --check # exit 1 if either file is out of date (CI / tests)
Anchors: every entry starts with ``, where the anchor is the patch id with "." replaced by "-"
(`frame.unity_text_input` -> `#frame-unity_text_input`, `patch_copy_libraries` stays as it is). GitHub resolves such
ids (it prefixes them with "user-content-" and follows plain `#anchor` links), and the site's docs renderer uses them
as the heading id. Nothing here runs a patch's detection: "suggested when" is read from the reasons in the source.
"""
from __future__ import annotations
import argparse
import ast
import inspect
import json
import re
import sys
import textwrap
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
import yaml # noqa: E402
from frameport.patches import base, settings # noqa: E402
DOC = ROOT / "docs" / "PATCHES.md"
SITE_MAP = ROOT / "site" / "src" / "data" / "patches.json"
HEADER = ""
MAX_GAMES = 8
# the game page's group names (ui/views/game.py CATEGORY_TITLES) plus what each group is
CATEGORIES = {
"overport": ("Patches from OVRPort",
"OVRPort converts a Quest game's Meta VR code to standard OpenXR; FramePort picks which of its "
"patches run. The list comes from the OVRPort CLI at runtime (new OVRPort patches appear in the app "
"on their own); this page shows FramePort's bundled list (`patches/overport.py`)."),
"frame": ("Steam Frame patches",
"FramePort's own changes to a converted Quest game, for what the Steam Frame lacks or does differently."),
"adapter": ("Game settings",
"Settings of FrameBridge, FramePort's OpenXR layer inside every converted Quest game. Most can be "
"changed on the Frame later without a rebuild (game page → Game settings)."),
"device": ("Files and settings on the Frame",
"Files, environment and window options written on the Frame when a Quest game or Android app is "
"installed. They don't change the APK."),
"pcvr": ("PC VR (Proton and Revive)",
"Options for PC VR games, which run through Proton on the Frame (or Revive on this PC)."),
}
def anchor(pid: str) -> str:
return pid.replace(".", "-")
def one_line(text: str) -> str:
return " ".join(str(text).split())
def first_sentence(text: str) -> str:
text = one_line(text)
m = re.search(r"(?<=[.!?])\s+(?=[A-Z(])", text)
return text[:m.start()] if m else text
def md_escape(text: str) -> str:
"""Text for a markdown table cell or a plain line (no accidental HTML or cell breaks)."""
return one_line(text).replace("|", "\\|").replace("<", "<").replace(">", ">")
# ------------------------------------------------------------------ "suggested when", read from detect()'s source
def _string(node: ast.AST) -> str | None:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.JoinedStr):
parts = []
for v in node.values:
parts.append(v.value if isinstance(v, ast.Constant) else "…")
return "".join(parts)
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
a, b = _string(node.left), _string(node.right)
return a + b if a is not None and b is not None else None
return None
def _scope(test: ast.AST, patch: base.Patch) -> bool | None:
"""Whether an `if` test that names this kind of patch (self.key == "x", self.id == "x", self.default_on) holds for
`patch`; None when the test is about the game, not the patch."""
tests = test.values if isinstance(test, ast.BoolOp) and isinstance(test.op, ast.And) else [test]
for t in tests:
if isinstance(t, ast.Attribute) and isinstance(t.value, ast.Name) and t.value.id == "self" \
and t.attr == "default_on":
return bool(patch.default_on)
if isinstance(t, ast.Compare) and len(t.ops) == 1 and isinstance(t.left, ast.Attribute) \
and isinstance(t.left.value, ast.Name) and t.left.value.id == "self" and t.left.attr in ("key", "id"):
own = getattr(patch, t.left.attr, None)
right = t.comparators[0]
try:
value = ast.literal_eval(right)
except ValueError:
continue
if isinstance(t.ops[0], ast.Eq):
return own == value
if isinstance(t.ops[0], ast.In):
return own in value
return None
def suggestions(patch: base.Patch) -> list[tuple[bool, str, dict]]:
fn = type(patch).detect
if fn is base.Patch.detect:
return [(True, "On for every game.", {})] if patch.default_on else []
try:
tree = ast.parse(textwrap.dedent(inspect.getsource(fn)))
except (OSError, TypeError, SyntaxError):
return []
found: list[tuple[bool, str, dict]] = []
def visit(node: ast.AST) -> None:
if isinstance(node, ast.If):
ok = _scope(node.test, patch)
if ok is not False:
for n in node.body:
visit(n)
for n in node.orelse:
visit(n)
return
if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "Suggestion" and len(node.args) >= 2:
reason = _string(node.args[1])
if reason:
rec = node.args[0]
recommended = rec.value if isinstance(rec, ast.Constant) else True
params = {}
if len(node.args) > 2:
try:
params = ast.literal_eval(node.args[2])
except ValueError:
params = {}
found.append((bool(recommended), reason, params if isinstance(params, dict) else {}))
for child in ast.iter_child_nodes(node):
visit(child)
visit(tree)
seen, out = set(), []
for s in found:
if s[1] not in seen:
seen.add(s[1])
out.append(s)
return out
# ------------------------------------------------------------------ catalog and triage
CATALOG_FIELDS = {"overport_extra": None, "alt_overport": None, "frame": None, "device": None, "pcvr": None}
CATALOG_MAPS = {"device_files": "device.files", "config_sync": "device.config_sync", "lepton_env": "device.lepton_env",
"foveation": "device.foveation", "proton_env": "pcvr.proton_env", "proton_tool": "pcvr.proton_tool"}
def catalog_uses() -> dict[str, list[str]]:
"""patch id -> titles of bundled catalog games whose recipe names it."""
uses: dict[str, set[str]] = {}
for f in sorted((ROOT / "catalog" / "games").glob("*.yaml")):
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
title = one_line(d.get("title") or d.get("package") or f.stem)
if d.get("kind") == "rift":
title += " (PC VR)"
ids: set[str] = set()
for field in CATALOG_FIELDS:
ids.update(str(x) for x in d.get(field) or [])
for key in d.get("adapter") or {}:
ids.add(f"adapter.{key}")
for field, pid in CATALOG_MAPS.items():
if d.get(field):
ids.add(pid)
for pid in ids:
uses.setdefault(pid, set()).add(title)
return {k: sorted(v, key=str.casefold) for k, v in uses.items()}
def triage_uses() -> dict[str, list[tuple[str, str, str]]]:
"""patch id -> (signature id, severity, diagnosis) of the triage signatures that suggest it."""
data = yaml.safe_load((ROOT / "catalog" / "triage.yaml").read_text(encoding="utf-8")) or {}
out: dict[str, list[tuple[str, str, str]]] = {}
for sig in data.get("signatures") or []:
for item in sig.get("suggest") or []:
pid = str(item).split("=", 1)[0].strip()
diagnosis = first_sentence(sig.get("diagnosis", ""))
out.setdefault(pid, []).append((sig["id"], sig.get("severity", ""), diagnosis))
return {k: sorted(set(v)) for k, v in out.items()}
# ------------------------------------------------------------------ one entry
def changes(p: base.Patch) -> str:
if p.stage == "overport":
return "Changes the APK (during the OVRPort conversion)."
if p.stage == "apk":
return "Changes the APK (a rebuild and reinstall apply it)."
if p.category == "adapter":
text = (p.description + " " + settings.UI.get(getattr(p, "key", ""), {}).get("help", "")).lower()
if "rebuild" in text:
return "Game setting, stored in the build: needs a rebuild."
return "Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild."
if p.category == "pcvr":
return "Applied when the PC VR game is installed or started; no rebuild."
return "Install only: written on the Frame when the game is installed; the APK isn't changed."
def _value(v) -> str:
if v == "" or v is None:
return "empty"
if isinstance(v, dict):
return "none" if not v else json.dumps(v, sort_keys=True)
return f"`{v}`"
def options(p: base.Patch) -> list[str]:
lines = []
if p.category == "adapter":
key = p.key
spec = next(s for s in settings.SETTINGS if s[0] == key)
kind, default = spec[1], spec[2]
line = f"Value: {kind}, default {_value(default)}"
if key in settings.BASE_SETTINGS:
line += " (always written)"
lines.append(line + ".")
ui = settings.UI.get(key)
if ui and kind in ("int", "float") and key not in settings.HIDDEN:
group = dict(settings.GROUPS).get(ui["group"], ui["group"])
control = ui["control"]
where = f"Game settings → {group} → \"{ui['label']}\""
if ui.get("level") == "advanced":
where += " (advanced)"
if control[0] == "slider":
where += f": slider {control[1]:g}–{control[2]:g} in steps of {control[3]:g}"
elif control[0] == "choice":
where += ": " + ", ".join(f"{label} (`{v:g}`)" if isinstance(v, float) else f"{label} (`{v}`)"
for v, label in control[1])
if ui.get("depends"):
where += f"; shown while `adapter.{ui['depends']}` is on"
lines.append(where + ".")
else:
lines.append("Not in the Game settings dialog (set by a recipe or the CLI).")
return lines
for prm in p.params:
text = f"`{prm.key}`: {prm.kind}, default {_value(prm.default)}"
if prm.minimum is not None or prm.maximum is not None:
low = prm.minimum if prm.minimum is not None else "…"
high = prm.maximum if prm.maximum is not None else "…"
text += f", {low}–{high}"
if prm.help:
text += f" — {md_escape(prm.help)}"
lines.append(text)
return lines
def source_link(p: base.Patch) -> str:
try:
path = Path(inspect.getsourcefile(type(p))).resolve().relative_to(ROOT)
except (TypeError, ValueError):
return ""
return f"[{path.relative_to('src/frameport').as_posix()}](../{path.as_posix()})"
def entry(p: base.Patch, games: list[str], sigs: list[tuple[str, str, str]]) -> str:
out = [f"### {md_escape(p.title)}", ""]
if p.summary:
out += [md_escape(p.summary), ""]
facts = [f"`{p.id}`"]
if p.default_on:
facts.append("on by default")
if p.experimental:
facts.append("experimental")
if not p.needs_vr:
facts.append("also for 2D apps")
rev = p.revision
if rev > 1:
facts.append(f"revision {rev}")
out.append("- " + " · ".join(facts))
out.append(f"- **Changes:** {changes(p)}")
sug = suggestions(p)
for recommended, reason, params in sug:
label = "Suggested (on)" if recommended else "Offered (off)"
if params and p.category == "adapter" and "value" in params:
label += f", value `{params['value']}`"
out.append(f"- **{label}:** {md_escape(reason)}")
if not sug:
out.append("- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a "
"launch test.")
if getattr(p, "strict", False):
out.append("- **Only for its game's build:** outside it the whole OVRPort run fails, so recipes only use it "
"where it applies.")
if p.conflicts:
out.append("- **Conflicts with:** " + ", ".join(f"[`{c}`](#{anchor(c)})" for c in sorted(p.conflicts)))
if p.requires:
out.append("- **Requires:** " + ", ".join(f"[`{c}`](#{anchor(c)})" for c in sorted(p.requires)))
opts = options(p)
if len(opts) == 1:
out.append(f"- **Options:** {opts[0]}")
elif opts:
out.append("- **Options:**")
out += [f" - {o}" for o in opts]
if games:
shown = ", ".join(md_escape(g) for g in games[:MAX_GAMES])
more = f" and {len(games) - MAX_GAMES} more" if len(games) > MAX_GAMES else ""
out.append(f"- **Catalog games ({len(games)}):** {shown}{more}.")
if sigs:
out.append("- **Suggested by launch-test signatures:**")
out += [f" - `{sid}` ({sev}): {md_escape(diag)}" for sid, sev, diag in sigs]
src = source_link(p)
if src:
out.append(f"- **Source:** {src}")
desc = md_escape(p.description)
if desc and desc != md_escape(p.summary):
out += ["", f"> {desc}"]
return "\n".join(out) + "\n"
# ------------------------------------------------------------------ the page
def build() -> tuple[str, str]:
patches = base.all_patches()
ids = [p.id for p in patches]
anchors = [anchor(i) for i in ids]
if len(set(anchors)) != len(anchors):
raise SystemExit("two patches share an anchor")
uses, sigs = catalog_uses(), triage_uses()
cats = sorted({p.category for p in patches}, key=lambda c: (base.CATEGORIES.index(c), c))
by_cat = {c: sorted((p for p in patches if p.category == c), key=lambda p: p.id) for c in cats}
counts = {c: len(by_cat[c]) for c in cats}
title = lambda c: CATEGORIES.get(c, (c.capitalize(), "")) # noqa: E731
lines = [HEADER, "", "# Patches", "",
f"Every patch and game setting FramePort knows: {len(patches)} entries in {len(cats)} groups. A game's "
"recipe is its chosen patches and settings; tested games get theirs from the catalog "
"([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You "
"change them on the game page under Customize.", "",
"Each entry says what the patch does, whether it changes the APK or only the install, when FramePort "
"suggests it, its options, the catalog games whose recipe names it and the launch-test signatures "
"that suggest it. Patches that are on by default are on for every game, so the catalog rarely names "
"them.", "",
"Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_text_input` for "
"`frame.unity_text_input`.", "",
"## Groups", ""]
for c in cats:
lines.append(f"- [{title(c)[0]}](#{anchor('group-' + c)}) ({counts[c]})")
lines += ["", "## Index", "", "| Patch | Id | Group | Catalog games |", "| --- | --- | --- | --- |"]
for c in cats:
for p in by_cat[c]:
n = len(uses.get(p.id, []))
games = ("default" + (f" · {n}" if n else "")) if p.default_on else (str(n) if n else "")
lines.append(f"| [{md_escape(p.title)}](#{anchor(p.id)}) | `{p.id}` | {title(c)[0]} | {games} |")
lines.append("")
for c in cats:
name, intro = title(c)
lines += [f"## {name}", ""]
if intro:
lines += [intro, ""]
for p in by_cat[c]:
lines.append(entry(p, uses.get(p.id, []), sigs.get(p.id, [])))
unknown = sorted(set(sigs) - set(ids))
if unknown:
raise SystemExit(f"catalog/triage.yaml suggests unknown patches: {', '.join(unknown)}")
doc = "\n".join(lines).rstrip() + "\n"
site = {p.id: {"anchor": anchor(p.id), "title": one_line(p.title), "group": title(p.category)[0]}
for p in patches}
site_json = json.dumps(dict(sorted(site.items())), indent=1, ensure_ascii=False) + "\n"
return doc, site_json
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--check", action="store_true", help="only check that the files are up to date")
args = ap.parse_args()
doc, site_json = build()
targets = [(DOC, doc), (SITE_MAP, site_json)]
if args.check:
stale = [p for p, text in targets if not p.exists() or p.read_text(encoding="utf-8") != text]
for p in stale:
print(f"{p.relative_to(ROOT)} is out of date: run python scripts/patch_docs.py", file=sys.stderr)
return 1 if stale else 0
for p, text in targets:
p.write_text(text, encoding="utf-8", newline="\n")
print(f"wrote {p.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
sys.exit(main())