Files
spoopyghosty0--frameport/scripts/patch_docs.py
T

394 lines
17 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.
"""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 `<a id="<anchor>"></a>`, 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 = "<!-- generated by scripts/patch_docs.py — don't edit by hand -->"
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("<", "&lt;").replace(">", "&gt;")
# ------------------------------------------------------------------ "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"### <a id=\"{anchor(p.id)}\"></a>{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"## <a id=\"{anchor('group-' + c)}\"></a>{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())