mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-12 05:01:12 +02:00
394 lines
17 KiB
Python
394 lines
17 KiB
Python
"""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("<", "<").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"### <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())
|