"""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())