Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80ef9ca6a7 | ||
|
|
c744f8740b | ||
|
|
859f921361 | ||
|
|
6acf9d048e | ||
|
|
cf18b30be9 | ||
|
|
eec0218b1d | ||
|
|
526ba0a684 | ||
|
|
23b6ff1b5e | ||
|
|
00c6863e54 | ||
|
|
4863035a05 | ||
|
|
a30162ea63 | ||
|
|
24eaa97fde | ||
|
|
767b152423 | ||
|
|
10e7669f70 | ||
|
|
76c4f2d225 | ||
|
|
3a851e911f | ||
|
|
2912d637b5 | ||
|
|
5c070ccf23 | ||
|
|
b979067215 | ||
|
|
4440b459e5 | ||
|
|
09b8046337 | ||
|
|
0d67335775 | ||
|
|
867b9977d5 | ||
|
|
80092098e0 | ||
|
|
feab03e049 | ||
|
|
9713da222a | ||
|
|
82e617741e | ||
|
|
9de0707070 | ||
|
|
1343930ae0 | ||
|
|
5424e429e4 | ||
|
|
9149eaecd7 | ||
|
|
64278e9e2c | ||
|
|
1531acb575 | ||
|
|
6796e006d9 | ||
|
|
45f05dd3c6 | ||
|
|
5a2cd45fb8 | ||
|
|
757671b389 | ||
|
|
8a2029c079 |
No files matched your search
@@ -38,6 +38,6 @@ body:
|
||||
id: logs
|
||||
attributes:
|
||||
label: Diagnostics
|
||||
description: Drag the FramePort-diag-*.zip here (max 25 MB)
|
||||
description: "Drag the FramePort-diag-*.zip here (max 25 MB). If the box won't take it, submit the report and attach the zip in a comment."
|
||||
validations:
|
||||
required: true
|
||||
@@ -40,7 +40,8 @@ jobs:
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b "$branch"
|
||||
git add "catalog/games/$PKG.yaml"
|
||||
uv run python scripts/patch_docs.py # docs/PATCHES.md lists the catalog games per patch
|
||||
git add "catalog/games/$PKG.yaml" docs/PATCHES.md site/src/data/patches.json
|
||||
git commit -m "catalog: $PKG (from #$ISSUE)"
|
||||
git push --force origin "$branch"
|
||||
if ! gh pr view "$branch" >/dev/null 2>&1; then
|
||||
|
||||
@@ -69,6 +69,10 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
Live Frame data comes from one app-owned `app.monitor_hub` (`frame/monitor_hub.py`): subscribers name modules +
|
||||
interval, the hub runs one agent `_monitor` stream (union of modules, fastest interval; per-module collection
|
||||
needs agent v73), reconnects after a lost stream and stops it when nobody subscribes; `_poll` retries an "error".
|
||||
Games in a sample (agent v81): FramePort's own (launch.sh / container / Proton folder), any other game Steam's
|
||||
reaper runs (`steam:<appid>`, title from the appmanifest, e.g. Job Simulator from Steam's store; not SteamVR, not
|
||||
devkit entries; End game = Steam's Exit game), overlay apps flagged `overlay` and listed last; newest first. The
|
||||
UI takes `frame/monitor.main_game(sample)` (never an overlay app alone). The agent's own workers never count.
|
||||
Subscribers: "monitor" (the Monitor tab, everything, only while shown) and "card" = the sidebar's live Frame card
|
||||
(`ui/frame_card.py` helpers: battery ring, "now playing" row with fps + 2-min sparkline → click opens Monitor;
|
||||
games + battery every 5 s, paused while a job's stage is "Upload…", only while connected and setting
|
||||
@@ -323,8 +327,17 @@ Games tests: `pytest -m games` (FRAMEPORT_GAMES=<downloads dir>); on-device chec
|
||||
native layer test: `FRAMEPORT_NATIVE_TESTS=1 pytest -m native` (compiles with the NDK, ~2 min on NTFS).
|
||||
Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattributes`).
|
||||
- `FRAMEPORT_HOME=<dir>` isolates all app data (tests use it); `FRAMEPORT_JAVA/_OVERPORT_JAR/_APKSIGNER_JAR` override
|
||||
managed tools. Real app data (WSL): `~/.local/share/frameport` (tools, overport workspace + game keystores, library,
|
||||
artwork, frames.json, the app's SSH key which the dev Frame authorizes).
|
||||
managed tools. **The owner's real app data is the Windows copy's: `%LOCALAPPDATA%\FramePort`** (= `/mnt/c/Users/
|
||||
<user>/AppData/Local/FramePort`; tools, overport workspace + game keystores, library, artwork, frames.json, the app's
|
||||
SSH key which the dev Frame authorizes; moved from WSL's `~/.local/share/frameport` on 2026-10-11, which is now only
|
||||
a stale backup). The desktop shortcut "FramePort (dev)" runs the source checkout natively on Windows
|
||||
(`%LOCALAPPDATA%\FramePort-dev\launch-frameport-dev.vbs`: venv `FramePort-dev\venv` via `UV_PROJECT_ENVIRONMENT`,
|
||||
`uv sync --frozen`, log `FramePort\logs\dev-gui.log`). **WSL runs on that data need `FRAMEPORT_WINDOWS_DATA=1`**
|
||||
(set in `.claude/settings.local.json`; `paths.shares_windows_data`): the data folder becomes the Windows one, saved
|
||||
paths from the other side are translated on load (`paths.localize`: /mnt/d/X <-> D:\X in library.json, tools.json,
|
||||
PC deployment records), Java/Cpp2IL keep one copy per OS (tools.json `java-<os>-<arch>`), and the link handler isn't
|
||||
registered (it would point the Windows copy's links at WSL). Don't run a WSL GUI and the Windows GUI at once
|
||||
(the gui.lock flock/msvcrt locks don't see each other; only the gui.alive heartbeat does).
|
||||
- Device checks: `frameport test <pkg>` / `frameport parity-device --results <parity.json> --baseline <launch.txt>
|
||||
[--test-only]`; the pre-FramePort baseline is `PATCHED/_known-good-2026-09-28/_frame-state/baseline-launch.txt`.
|
||||
- Docs screenshots + videos (docs/SHOWCASE.md): never by hand. `uv run python scripts/showcase/render_docs.py`
|
||||
@@ -352,6 +365,11 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr
|
||||
`T.px(n)`, Material defaults through the theme's text_theme; never hardcode a bare pixel number in ui/.
|
||||
`scripts/ui_smoke.py --scale 1.5 --viewport 2560x1440` renders it. Flet draws a grey box for invalid layouts (e.g.
|
||||
an `expand` child in a `wrap=True` Row).
|
||||
- One GUI per data folder (`core/instance.py`, 2026-10-11): `<data>/gui.lock` (flock/msvcrt, pid inside); a second
|
||||
start asks the open window to come to the front (`gui.focus`, read by `_watch_links`) and exits; a lock held with no
|
||||
window (no `gui.alive`) and older than 30 s is a leftover FramePort process and is ended. `main()` ends the process
|
||||
(`os._exit`) once `ft.run` returns: background threads (re-analysis, session triage) used to keep a windowless
|
||||
process running. Session triage shows one pop-up for all games of a burst (`_announce_sessions`).
|
||||
- Stopping the GUI: `pkill -f` patterns match your own shell — use `pgrep -f "[b]in/frameport-gui|[f]let-desktop-light"`.
|
||||
|
||||
## Round-2 polish (2026-10-02)
|
||||
@@ -790,6 +808,13 @@ index lines; no index (fp_spirv) = the old newest-by-mtime 4 MB. One JSON line (
|
||||
is read whole by `Frame.agent` (fine at that size); `_Writer.fit` drops the least recently used modules last, after
|
||||
cutting logs, to stay under the 24 MB zip limit.
|
||||
**Unreal OBB check (GitHub #159, 2026-10-10):** Epic's DownloaderActivity checks OBB name+size (OBBData), then with `bVerifyOBBOnStartUp=true` CRCs the whole OBB; its screens are invisible in Lepton (stuck at `Displayed …/.DownloaderActivity`, triage `unreal-obb-check-stuck`) → `frame.unreal_skip_obb_check` (analysis `unreal_verify_obb`, ANALYSIS_VERSION 11) flips the manifest boolean (`axml.set_meta_data_bool`); skips only the CRC pass. Host-tested only.
|
||||
**Meta user id (GitHub #182, 2026-10-11):** OVRPort's platform loader (runtimes 3.4.3-23204ea and -aa54c3f) answers
|
||||
`ovr_GetLoggedInUserID` (and two other callers) without a Meta account with a made-up id from a helper (`stp; mov x29,
|
||||
sp; bl random; …; mov/movk x9 = 0x53444835ec580000 (6e18); add x0, x8, x9; ldp; ret`), kept only in memory → a new id
|
||||
every start; Walkabout Mini Golf files saves under `Profiles/Oculus/<id>/` and lost all progress. `frame.ovr_stable_user`
|
||||
(default_on, detect = the game has libovrplatformloader.so) rewrites the helper to `mov x0, <6e18 + 32 bits of
|
||||
sha256(package, keystore)>; ret`: stable per game and per user (keystores are personal), so online games never see two
|
||||
FramePort users as one. Found in every PATCHED build checked. Host-tested only; upstream candidate.
|
||||
**Vivox API 31 (GitHub #101, 2026-10-09):** newer Vivox builds (Green Hell VR) call Android 12 AudioManager
|
||||
communication-device methods from `com.vivox.sdk.AudioChangeListener` with no SDK check → NoSuchMethodError on Lepton's
|
||||
Android 11. `frame.vivox_audio_route` (analysis `vivox_api31`, ANALYSIS_VERSION 4) makes every such method return at
|
||||
@@ -820,7 +845,8 @@ on the dev Frame) in `files/framebridge-vrapi-fov.txt` and answers with it at th
|
||||
r_3dwidth/height, r_width/height from the runtime eye size (FrameBridge logs `runtime recommended eye size`) × scale,
|
||||
written at finalize, set_settings and by launch.sh before Lepton starts (`_config_sync`). Headless runs at scale 1.7
|
||||
end in `zink: DEVICE LOST` right after the bridge's 30 s no-tracking deadline tears the session down (gpu fault at the
|
||||
teardown; not at scale 1.0): a headless-only artifact, the headset never hits that deadline.
|
||||
teardown; not at scale 1.0): a headless-only artifact, the headset never hits that deadline. Launch tests count an exit/crash after
|
||||
such a line as a pass (triage.yaml `headless_end`, finding `headless-end`; GitHub #57 "Failed to launch" on install).
|
||||
**Session triage (agent v70, 2026-10-09):** launch tests never reach FOCUSED, so real play sessions are triaged
|
||||
too. list_installed gives each game `last_play` {start, end, test} from `<anchor>/plays.log` (launch tests write a
|
||||
`test <unix>` line first → test sessions are only marked); `session_log` returns the newest session's launch.log
|
||||
@@ -967,5 +993,7 @@ the unresolved eye distortion, and testing the release bundles on real Windows/m
|
||||
fallback (`core/cache.py`). Don't hardcode what can be discovered (e.g. Lepton path via appmanifests).
|
||||
- Keep APK edits minimal and byte-stable (parity depends on it). Every new fix: a patch module + a triage signature +
|
||||
a PLAYBOOK row + a unit test.
|
||||
- New or changed patch, game setting, catalog recipe or triage signature → run `python scripts/patch_docs.py`
|
||||
(regenerates docs/PATCHES.md + site/src/data/patches.json, the site's id → anchor map; a test runs `--check`).
|
||||
- Known-good backups of every working APK and the signing keys: `PATCHED/_known-good-2026-09-28/` (don't delete).
|
||||
- Your development Frame: `frameport frame info` (remembered in `<user data>/frames.json`); SSH as `steamos@<frame>`.
|
||||
@@ -28,7 +28,7 @@ Frame, patches each game so it runs there, uploads it and adds it to your Steam
|
||||
|
||||
- **Easy setup:** one line in the Frame's terminal. No root, no password. [What it changes](docs/FRAME_SETUP.md).
|
||||
- **One click per game:** patch, upload, add to Steam with artwork and test that it starts.
|
||||
- **Recipes:** tested patches and settings for 90+ games; other games get suggested patches, each explained in
|
||||
- **Recipes:** tested patches and settings for 120+ games; other games get suggested patches, each explained in
|
||||
plain words.
|
||||
- **Game settings:** sharpness, refresh rate, controllers, 360° video and mixed reality as simple switches.
|
||||
- **More than Quest games:** Android apps in a window, Linux apps, PC VR games and Windows programs.
|
||||
@@ -76,7 +76,7 @@ suggests patches. See the [list of tested games](docs/GAMES.md). Got a game work
|
||||
FrameDrop and Valve's own tools install an app as it is. FramePort differs in four ways:
|
||||
|
||||
- **Free and open source** (GPL-3.0). FrameDrop is free (donationware) without published source.
|
||||
- **Quest games that don't run on the Frame as they are** get converted and patched, with a tested recipe for 90+
|
||||
- **Quest games that don't run on the Frame as they are** get converted and patched, with a tested recipe for 120+
|
||||
games. The others install the game unchanged.
|
||||
- **Windows, macOS and Linux.** FrameDrop is for Windows.
|
||||
- **Wi-Fi or a USB cable**, and no **Pair new host** step.
|
||||
@@ -91,7 +91,7 @@ FrameDrop and Valve's own tools install an app as it is. FramePort differs in fo
|
||||
| First connection | One line in the Frame's terminal; it turns on Developer Mode itself. No password | Turn on Developer Mode, then **Pair new host** | Turn on Developer Mode and pair, or use Android's debug tool (adb) |
|
||||
| Wireless or cable | Wi-Fi, the Frame's hotspot or a USB cable | Same Wi-Fi network | Wi-Fi, or adb |
|
||||
| Quest games that don't run as they are | Converted and patched for the Frame | Installed as they are | Installed as they are |
|
||||
| Knows which patches a game needs | Tested recipes for 90+ games, updated without an app update | Not stated | No |
|
||||
| Knows which patches a game needs | Tested recipes for 120+ games, updated without an app update | Not stated | No |
|
||||
| In the Steam library | Yes, with artwork and tags | As a "Devkit Game" shortcut | As "Devkit Game: <title>"; not with adb |
|
||||
| Android apps, Linux apps, Windows programs | All three; Proton (runs Windows programs) is installed for you | All three; install Proton first | Yes; 2D Android apps need an extra file |
|
||||
| PC VR games | On your PC; some also on the Frame | Not supported | Not supported |
|
||||
|
||||
@@ -44,7 +44,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 78
|
||||
AGENT_VERSION = 81
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -5936,6 +5936,96 @@ def shader_dumps(files_dir, max_bytes=SHADER_DUMP_BYTES, session_bytes=SHADER_SE
|
||||
return out
|
||||
|
||||
|
||||
# arm64 syscall numbers seen in stuck threads (/proc/<tid>/syscall gives the number)
|
||||
ARM64_SYSCALLS = {22: "epoll_pwait", 29: "ioctl", 56: "openat", 63: "read", 64: "write", 73: "ppoll",
|
||||
98: "futex", 101: "nanosleep", 115: "clock_nanosleep", 203: "connect", 206: "sendto",
|
||||
207: "recvfrom", 212: "recvmsg", 260: "wait4"}
|
||||
TCP_STATES = {"01": "ESTABLISHED", "02": "SYN_SENT", "03": "SYN_RECV", "04": "FIN_WAIT1", "05": "FIN_WAIT2",
|
||||
"06": "TIME_WAIT", "07": "CLOSE", "08": "CLOSE_WAIT", "09": "LAST_ACK", "0A": "LISTEN", "0B": "CLOSING"}
|
||||
|
||||
|
||||
def _remote_kind(hexaddr):
|
||||
"""/proc/net/tcp(6) remote address → loopback / local / internet (never the address itself: it would need
|
||||
redacting, and what matters is whether the game waits on the internet)."""
|
||||
import ipaddress
|
||||
try:
|
||||
raw = bytes.fromhex(hexaddr)
|
||||
if len(raw) == 4:
|
||||
ip = ipaddress.IPv4Address(raw[::-1])
|
||||
else: # four little-endian 32-bit words
|
||||
ip = ipaddress.IPv6Address(b"".join(raw[i:i + 4][::-1] for i in range(0, 16, 4)))
|
||||
ip = ip.ipv4_mapped or ip
|
||||
except ValueError:
|
||||
return "?"
|
||||
if ip.is_unspecified:
|
||||
return "none"
|
||||
if ip.is_loopback:
|
||||
return "loopback"
|
||||
return "local" if ip.is_private or ip.is_link_local else "internet"
|
||||
|
||||
|
||||
def game_threads(pkg, proc=None, samples=3, gap=1.0, sleep=time.sleep):
|
||||
"""What a running (e.g. stuck) Android game's threads are doing (GitHub #157): for every process of `pkg` (its
|
||||
main process and `pkg:<name>` ones), each thread's state, CPU time used over the sampled seconds, kernel wait
|
||||
channel and current syscall, plus its TCP sockets. Readable as steamos: the container's user namespace belongs to
|
||||
steamos. None when the game isn't running."""
|
||||
proc = proc or PROC
|
||||
pids = []
|
||||
for d in os.listdir(proc):
|
||||
if not d.isdigit():
|
||||
continue
|
||||
cmd = (_rd(os.path.join(proc, d, "cmdline")) or "").split("\0")[0]
|
||||
if cmd == pkg or cmd.startswith(pkg + ":"):
|
||||
pids.append((int(d), cmd))
|
||||
if not pids:
|
||||
return None
|
||||
|
||||
def cpu(pid):
|
||||
out = {}
|
||||
for t in os.listdir(os.path.join(proc, str(pid), "task")) if os.path.isdir(os.path.join(proc, str(pid),
|
||||
"task")) else []:
|
||||
st = _rd(os.path.join(proc, str(pid), "task", t, "stat")) or ""
|
||||
f = st[st.rfind(")") + 2:].split()
|
||||
if len(f) > 12:
|
||||
out[t] = (f[0], int(f[11]) + int(f[12]))
|
||||
return out
|
||||
|
||||
first = {pid: cpu(pid) for pid, _ in sorted(pids)}
|
||||
for _ in range(max(0, samples - 1)):
|
||||
sleep(gap)
|
||||
span = max(0, samples - 1) * gap
|
||||
lines = [f"# threads of the running game {pkg}; cpu_ms = CPU time used in {span:g} s, wchan = kernel wait, "
|
||||
"syscall = what the thread is blocked in"]
|
||||
for pid, cmd in sorted(pids):
|
||||
last = cpu(pid)
|
||||
lines.append(f"\n## process {cmd} (pid {pid}, {len(last)} threads)")
|
||||
lines.append(f"{'tid':>7} st {'cpu_ms':>7} {'comm':<16} {'wchan':<24} syscall")
|
||||
rows = []
|
||||
for tid, (state, ticks) in last.items():
|
||||
used = (ticks - first.get(pid, {}).get(tid, (state, ticks))[1]) * 1000 // CLK_TCK
|
||||
tdir = os.path.join(proc, str(pid), "task", tid)
|
||||
comm = (_rd(os.path.join(tdir, "comm")) or "?").strip()
|
||||
wchan = (_rd(os.path.join(tdir, "wchan")) or "?").strip() or "-"
|
||||
sc = (_rd(os.path.join(tdir, "syscall")) or "?").split()
|
||||
call = sc[0] if sc else "?"
|
||||
if call.isdigit():
|
||||
call = ARM64_SYSCALLS.get(int(call), "syscall " + call)
|
||||
rows.append((-used, int(tid), f"{tid:>7} {state:>2} {used:>7} {comm:<16} {wchan:<24} {call}"))
|
||||
lines += [r[2] for r in sorted(rows)]
|
||||
socks = []
|
||||
for name in ("tcp", "tcp6"):
|
||||
for ln in (_rd(os.path.join(proc, str(pid), "net", name)) or "").splitlines()[1:]:
|
||||
f = ln.split()
|
||||
if len(f) > 3 and ":" in f[2]:
|
||||
addr, port = f[2].rsplit(":", 1)
|
||||
socks.append(f"{name:<4} {TCP_STATES.get(f[3], f[3]):<12} remote={_remote_kind(addr)}"
|
||||
f":{int(port, 16)}")
|
||||
if socks:
|
||||
lines.append("# TCP sockets (remote = loopback / local network / internet, and port)")
|
||||
lines += sorted(set(socks))
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def cmd_collect_diag(args):
|
||||
"""Everything useful for debugging without the game or the PC app: host runtime facts and, with a package, the
|
||||
game's launcher, settings, deployment, logs (launch, Lepton logcat, Proton/Revive/Unreal) and its file listing.
|
||||
@@ -6016,6 +6106,13 @@ def cmd_collect_diag(args):
|
||||
for p in sorted(cands, key=lambda p: next((i for i, k in enumerate(order) if k in os.path.basename(p)), 9)):
|
||||
name = os.path.basename(p)
|
||||
files[name if name.startswith("logcat") else "logcat-" + name] = _tail(p, max_bytes)
|
||||
if dep.get("kind", "quest") == "quest":
|
||||
try:
|
||||
threads = game_threads(pkg, samples=int(args.get("thread_samples", 3)))
|
||||
except OSError:
|
||||
threads = None
|
||||
if threads:
|
||||
files["threads.txt"] = threads
|
||||
shaders = shader_dumps(data_files_dir(base, pkg), int(args.get("shader_bytes", SHADER_DUMP_BYTES)),
|
||||
int(args.get("shader_session_bytes", SHADER_SESSION_BYTES)))
|
||||
if shaders:
|
||||
@@ -6507,6 +6604,10 @@ MON_CRITICAL = ("steam", "steamwebhelper", "reaper", "vrserver", "vrcompositor",
|
||||
# processes whose command line can name a game (only these are read: cmdlines of ~500 processes cost too much)
|
||||
MON_ROOT_COMMS = ("conmon", "launch.sh", "reaper", "bash", "sh")
|
||||
MON_ROOT_PREFIXES = ("python", "proton", "pv-", "wine", "steam-runtime")
|
||||
AGENT_FILES = {os.path.abspath(__file__), os.path.realpath(__file__)}
|
||||
STEAM_GAME = "steam:" # monitor: a game Steam started that FramePort didn't install ("steam:<appid>", e.g. a Lepton
|
||||
# game from Steam's store); its title comes from the appmanifest
|
||||
REAPER_APPID = re.compile(r"\bAppId=(\d+)")
|
||||
MON_GROUPS = ( # (group, comm prefixes), first match wins; games are found by their process tree
|
||||
("steamvr", ("vr", "XRService", "V4L2Cam", "eyetracking", "proxmicmute", "systemlayer")),
|
||||
("steam", ("steam", "reaper", "fossilize")),
|
||||
@@ -6838,6 +6939,7 @@ class Monitor:
|
||||
self.logs = {} # launch.log path -> [offset, fps, avg ms, time of the line]
|
||||
self.deps = []
|
||||
self.deps_at = -1e9
|
||||
self.steam_titles = {}
|
||||
self.tick = 0
|
||||
self.last = None # time of the previous sample
|
||||
self.disk_at = -1e9
|
||||
@@ -6938,10 +7040,16 @@ class Monitor:
|
||||
m = re.search(r"lepton-steamlaunch-(\d+)", cmd)
|
||||
if m and m.group(1) in by_appid:
|
||||
root = (by_appid[m.group(1)]["package"], "container")
|
||||
else:
|
||||
elif not any(a in cmd for a in AGENT_FILES):
|
||||
# the agent's own workers name a game's launch.sh too (uninstall's shortcut removal while Steam
|
||||
# restarts): they aren't the game (helpers launch.sh starts still count, as its children)
|
||||
pkg = next((k for path, k in anchors if path in cmd), None) or \
|
||||
next((k for base, k in bases if base in cmd), None)
|
||||
root = (pkg, "launcher") if pkg else None
|
||||
m = REAPER_APPID.search(cmd) if comm == "reaper" and not pkg else None
|
||||
# any other game Steam runs (its reaper); not SteamVR itself, not a devkit entry of ours
|
||||
if m and m.group(1) not in by_appid and m.group(1) != STEAMVR_APPID and "/devkit-game/" not in cmd:
|
||||
root = (STEAM_GAME + m.group(1), "steam")
|
||||
self.root_cache[key] = root
|
||||
if self.root_cache[key]:
|
||||
roots[pid] = self.root_cache[key]
|
||||
@@ -7019,7 +7127,7 @@ class Monitor:
|
||||
deps = {d["package"]: d for d in self.deployments(now)}
|
||||
out = []
|
||||
for pkg in sorted({v[0] for v in self.roots.values()}):
|
||||
d = deps.get(pkg) or {}
|
||||
d = deps.get(pkg) or (self.steam_game(pkg[len(STEAM_GAME):]) if pkg.startswith(STEAM_GAME) else {})
|
||||
pids = [p["pid"] for p in self.procs if p["game"] == pkg]
|
||||
cpu = sum(p["cpu"] for p in self.procs if p["game"] == pkg)
|
||||
rss = sum(p["rss"] for p in self.procs if p["game"] == pkg)
|
||||
@@ -7039,10 +7147,25 @@ class Monitor:
|
||||
"appid": d.get("appid"), "elapsed": max(ages) if ages else None,
|
||||
"cpu": round(max(cpu, 0.0), 1), "mem": mem if mem is not None else rss,
|
||||
"gpu": round(sum(self.gpu_pct.get(p, 0.0) for p in pids), 1),
|
||||
"fps": fps, "frame_ms": ms, "processes": len(pids)})
|
||||
"fps": fps, "frame_ms": ms, "processes": len(pids), "overlay": is_overlay(d)})
|
||||
# the game in front first: overlay apps (they run next to games) last, then the most recently started
|
||||
out.sort(key=lambda g: (g["overlay"], g["elapsed"] is None, g["elapsed"] or 0))
|
||||
self.games = out
|
||||
return out
|
||||
|
||||
def steam_game(self, appid):
|
||||
"""{title, kind, appid} of a Steam game FramePort didn't install (cached: appmanifest/shortcuts reads)."""
|
||||
if appid not in self.steam_titles:
|
||||
app = find_app_id(appid)
|
||||
name = (app or {}).get("name")
|
||||
if not name:
|
||||
for user in steam_users():
|
||||
name = shortcut_names(user).get(appid)
|
||||
if name:
|
||||
break
|
||||
self.steam_titles[appid] = name or f"App {appid}"
|
||||
return {"title": self.steam_titles[appid], "kind": "steam", "appid": appid}
|
||||
|
||||
def filtered(self, now=None):
|
||||
procs = self.procs
|
||||
span = (now - self.scan_prev) if now is not None and self.scan_prev is not None else 0.0
|
||||
@@ -7188,8 +7311,29 @@ class Monitor:
|
||||
return {"ended": False, "pid": pid, "name": comm}
|
||||
|
||||
|
||||
def steam_app_running(appid):
|
||||
"""A Steam reaper runs this app id (a Steam game FramePort didn't install)."""
|
||||
for name in os.listdir(PROC):
|
||||
if name.isdigit() and _rd(f"{PROC}/{name}/comm").strip() == "reaper" and \
|
||||
re.search(rf"\bAppId={appid}\b", _rd(f"{PROC}/{name}/cmdline").replace("\0", " ")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def monitor_end_game(pkg, wait=6.0):
|
||||
"""Steam's Exit game for a FramePort game (so Steam's session ends cleanly), then cmd_stop if it still runs."""
|
||||
"""Steam's Exit game for a FramePort game (so Steam's session ends cleanly), then cmd_stop if it still runs.
|
||||
A Steam game FramePort didn't install ("steam:<appid>"): Steam's Exit game only."""
|
||||
if str(pkg).startswith(STEAM_GAME):
|
||||
appid = str(pkg)[len(STEAM_GAME):]
|
||||
if not appid.isdigit():
|
||||
raise AgentError(f"bad Steam app id {appid!r}")
|
||||
if not steam_app_running(appid):
|
||||
return {"ended": True, "via": None, "package": pkg, "note": "not running"}
|
||||
steam_js(f"SteamClient.Apps.TerminateApp('{appid}', false)")
|
||||
end = time.monotonic() + wait
|
||||
while time.monotonic() < end and steam_app_running(appid):
|
||||
time.sleep(0.5)
|
||||
return {"ended": not steam_app_running(appid), "via": "steam", "package": pkg}
|
||||
pkg = check_pkg(pkg)
|
||||
dep = deployment(pkg)
|
||||
if not dep:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package: com.SchellGames.DieScreaming
|
||||
title: SilentSlayer
|
||||
status: works
|
||||
tested_version: 1.1.2+323863s
|
||||
engine: Unreal
|
||||
xr: OpenXR
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-11'
|
||||
known_good_sha256: 5cb085909f9a54488543102242f19c5b2f176a1439659a29a9cca04362601f68
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 176
|
||||
source_hint: Silent Slayer - Vault of the Vampire
|
||||
@@ -0,0 +1,25 @@
|
||||
package: com.WhiteDoorGames.Cosmodread
|
||||
title: Cosmodread
|
||||
status: works
|
||||
notes: First run wouldn't allow you to interact with the "New Game" button. Restarting it fixed it.
|
||||
tested_version: 1.0.0.28
|
||||
engine: Unity
|
||||
xr: VrApi
|
||||
frame:
|
||||
- frame.unity_oculus_check
|
||||
- frame.unity_text_input
|
||||
- frame.unity_runtime_msaa_off
|
||||
- frame.unity_gl_shim
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-11'
|
||||
known_good_sha256: 44911a4607d0de2770705eefb6339a4c7353cb5dfb49f389d6731edbb9cbea92
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 174
|
||||
source_hint: Cosmodread
|
||||
@@ -5,6 +5,8 @@ notes: Needed the LAUNCHER category fix.
|
||||
details: Source APK is a community 'patch+savefix+90Hz' build. Shares Vader Immortal's Unreal 4 Oculus input (GitHub
|
||||
49) - thumbs follow the touches (frame.unreal_thumb_touch) and controller poses are located at the right time
|
||||
(pose_time_fix); added 2026-10-09 from Vader's headset results, not yet checked in this game.
|
||||
sync_guard added 2026-10-11 for a vrclient xrSyncActions crash on the loading splash after the intro (GitHub
|
||||
#188; the same runtime race as Myst).
|
||||
tested_version: '1.0'
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
@@ -14,10 +16,11 @@ frame:
|
||||
- frame.unreal_thumb_touch
|
||||
adapter:
|
||||
pose_time_fix: 1
|
||||
sync_guard: 1
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 4ce6e3563da131e288645f4b62a634ac98043e55bc42b7c0ad5f461207d5f53c
|
||||
updated: '2026-10-09'
|
||||
updated: '2026-10-11'
|
||||
source_hint: Robo Recall- Unplugged
|
||||
@@ -0,0 +1,24 @@
|
||||
package: com.drawandcode.TaskmasterVR
|
||||
title: Taskmaster
|
||||
status: works
|
||||
notes: Launched and played a few tasks. Seems to work pretty much flawlessly other than some occasionally finicky
|
||||
grip detection - very possible that this is just the game itself though
|
||||
tested_version: 1.0.17557.29562
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-11'
|
||||
known_good_sha256: e3b3c49d77284afb76e9f960828cbfaa4250d66c11b7fd9e4053d489aca662ae
|
||||
app: 1.0.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 78
|
||||
issue: 183
|
||||
source_hint: Taskmaster VR
|
||||
@@ -0,0 +1,20 @@
|
||||
package: com.innerspacevr.afishermanstale
|
||||
title: Fisherman
|
||||
status: issues
|
||||
notes: Loading screens have corruption, but everything works otherwise.
|
||||
tested_version: '1.087'
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-11'
|
||||
known_good_sha256: c645ca6321845bd460b4ff1b8ac871e941adb26d280cccba0285e79e4a96a231
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 175
|
||||
source_hint: A Fisherman's Tale
|
||||
@@ -1,13 +1,12 @@
|
||||
package: com.owlchemylabs.jobsimulator
|
||||
title: Job Simulator
|
||||
status: works
|
||||
notes: 'Without Lepton''s Android window: with it, the hands froze after a few seconds (GitHub #193).'
|
||||
tested_version: 1.4.0.6515
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
frame_remove:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
known_good_sha256: 64638d7f93bb93cc61f422a43a08bb0d35b9bb4b463e90a42da7002147f03d1b
|
||||
@@ -16,4 +15,5 @@ verified:
|
||||
frame_build: '20260930.6234839'
|
||||
agent: 33
|
||||
issue: 11
|
||||
updated: '2026-10-11'
|
||||
source_hint: Job Simulator
|
||||
@@ -1,14 +1,13 @@
|
||||
package: com.owlchemylabs.vacationsimulator
|
||||
title: Vacation Simulator
|
||||
status: issues
|
||||
notes: Controllers aren't tracked (reported; not yet diagnosed).
|
||||
status: works
|
||||
notes: 'Without Lepton''s Android window: with it (or after clicking it), the hands stayed on the floor (GitHub
|
||||
#163).'
|
||||
tested_version: 1.3.0.40142
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
frame_remove:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
refresh_rate: 90.0
|
||||
@@ -20,4 +19,5 @@ verified:
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 163
|
||||
updated: '2026-10-11'
|
||||
source_hint: com.owlchemylabs.vacationsimulator
|
||||
@@ -0,0 +1,24 @@
|
||||
package: com.schellgames.ieytd3
|
||||
title: IEYTD3
|
||||
status: works
|
||||
tested_version: 1.0.1.211419 (ReleaseCandidate_1.0.0_QuestStore)
|
||||
engine: Unity
|
||||
xr: VrApi
|
||||
frame:
|
||||
- frame.unity_no_msaa
|
||||
- frame.unity_text_input
|
||||
- frame.unity_runtime_msaa_off
|
||||
- frame.unity_gl_shim
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-11'
|
||||
known_good_sha256: 06f901ae22f7c07a79e11fc1af6b3941a51bbc99624336d6a0939f9593f3bfa0
|
||||
app: 0.12.1.dev263
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 78
|
||||
issue: 181
|
||||
source_hint: 2026-10-08T23-57-41.310Z_211419
|
||||
@@ -74,7 +74,8 @@ signatures:
|
||||
diagnosis: Android's debug checks stopped the game. CheckJNI is on because the converted app is debuggable, and it aborts on sloppy JNI use.
|
||||
suggest: [frame.nodebug]
|
||||
- id: force-quit
|
||||
pattern: 'System\.exit|ForceQuit|FAndroidMisc::RequestExit'
|
||||
# not Android's own WebView helper, which calls System.exit at every container boot (GitHub #179)
|
||||
pattern: '(?m)^(?!.*WebViewLoader).*System\.exit|ForceQuit|FAndroidMisc::RequestExit'
|
||||
severity: error
|
||||
diagnosis: The game quit itself right after starting. Usually Unreal's ForceQuit after a failed Quest platform check; the alternate build leaves it out.
|
||||
suggest: [patch_remove_unreal_force_quit]
|
||||
@@ -340,6 +341,12 @@ signatures:
|
||||
severity: fatal
|
||||
diagnosis: 'Games can''t start until the Frame restarts: it ran out of kernel keys. Rootless podman leaks one session keyring per container start; the FramePort agent sets keyring=false in ~/.config/containers/containers.conf, and a reboot frees the leaked keys.'
|
||||
suggest: []
|
||||
- id: app-not-started
|
||||
pattern: 'Target app \(.*\) did not start'
|
||||
severity: error
|
||||
diagnosis: Lepton didn't start the game this time, which happens now and then on the first start after an install. Start it again; if it never starts, attach diagnostics.
|
||||
suggest: []
|
||||
supersedes: [force-quit]
|
||||
- id: container-not-started
|
||||
pattern: "is not a running context|OCI runtime error"
|
||||
unless: 'Boot complete!' # Lepton 3.x prints the error while its container is still starting, then boots fine
|
||||
@@ -474,6 +481,13 @@ signatures:
|
||||
the video was decoded in software instead, which is slow for 4K/8K. Turn off hardware video decoding in Steam's
|
||||
settings, restart Steam, then start the game again.
|
||||
suggest: []
|
||||
# Launch tests run without a worn headset. Some parts give up on tracking after a while and end the VR session; what
|
||||
# the game does after that (often a crash in the GPU driver while its renderer tears down) never happens in the
|
||||
# headset. In a launch test that already had paced frames before such a line, a later exit or crash still passes,
|
||||
# with a note (finding `headless-end`). Play sessions ignore this list.
|
||||
headless_end:
|
||||
- label: the VrApi bridge's 30 s tracking deadline
|
||||
pattern: 'EnterVrMode: no tracked head pose before deadline'
|
||||
milestones: # progress markers, in order; the furthest one reached is reported
|
||||
- {id: pcvr-launch, kind: pcvr, pattern: 'FramePort: launching|Launched injector with', label: Launcher started}
|
||||
- {id: pcvr-oculus-hmd, kind: pcvr, pattern: 'FramePort oculushmd: OculusHMDConnected event ready', label: Oculus headset event provided}
|
||||
@@ -482,7 +496,7 @@ milestones: # progress markers, in order; the furthest one reached is reported
|
||||
- {id: pcvr-openxr, kind: pcvr, pattern: 'wineopenxr: .*[Ss]ession|LibReviveXR|Revive.*OpenXR|xrCreateSession', label: OpenXR reached}
|
||||
- {id: pcvr-session, kind: pcvr, pattern: 'xrCreateSession|XR_SESSION_STATE_(READY|SYNCHRONIZED)', label: OpenXR session created}
|
||||
- {id: container, pattern: 'Boot complete!', label: Lepton container started}
|
||||
- {id: process, pattern: 'Start proc \d+:', label: App process started}
|
||||
- {id: process, pattern: 'Start proc \d+:(?!WebViewLoader|com\.android\.|android\.)', label: App process started}
|
||||
- {id: adapter, pattern: 'FrameBridge: scale=', label: FrameBridge adapter loaded}
|
||||
- {id: instance, pattern: 'xrCreateInstance result=0', label: OpenXR instance created}
|
||||
- {id: session, pattern: 'xrCreateSession|session state=1|Created OpenXR session', label: OpenXR session created}
|
||||
|
||||
@@ -40,6 +40,9 @@ games/<pkg>/target/ from the Frame (or the PC): launch.sh, settings.con
|
||||
ReviveInjector.txt / steam-<appid>.log / game-log-N.txt (PC VR), files.json (+ missing)
|
||||
(game-log-N.txt: Unreal Saved/Logs + crash summaries, Unity Player.log / Player-prev.log /
|
||||
output_log.txt + crash error.log from the Proton prefix, agent v67)
|
||||
threads.txt (agent v79): saved while a Quest game runs (e.g. hangs), each thread's
|
||||
state, CPU ms over 2 s, kernel wait and syscall, plus its TCP sockets (remote = loopback /
|
||||
local / internet + port, no addresses); read once from /proc, the game isn't paused
|
||||
games/<pkg>/target/shaders/ shader dumps (agent v72), when the game wrote any:
|
||||
fp_vk_shaders/ adapter vk_shader_dump=1 (Vulkan shim): <size>_<sha256>.spv + index.txt
|
||||
fp_spirv/ adapter zink_shader_dump=1 (OpenGL ES, shader-fix layer under Zink): <size>_<sha256>.spv
|
||||
|
||||
@@ -55,10 +55,150 @@ AXRB/ ← AXRB's download folder (Downloads/AX
|
||||
- **Several backups of one game:** FramePort uses the newest one. To use another, scan just that backup's folder.
|
||||
- **AXRB's patched builds** (`patched/`, `*-axrb.apk`) are converted for AXRB's PC runtime and lack the game's data, so
|
||||
FramePort skips them and, on the next scan, replaces a game that was added from one with the original download.
|
||||
- **AXRB export ZIPs:** unzip first. A game's ZIP holds `base.apk` and `Android/obb/<package>/`, which FramePort finds
|
||||
as is.
|
||||
- **The game page says the data file (.obb) is missing** although you have one: the backup didn't include it (copy
|
||||
- **AXRB export ZIPs:** unpack them first. A game's ZIP holds `base.apk` and `Android/obb/<package>/`, which FramePort
|
||||
finds as is.
|
||||
- **"Game data (.obb) is missing" on the game's page** although you have the file: the backup didn't include it (copy
|
||||
the game's `Android/obb/<package>` folder from the Quest with SideQuest), or it sits in a folder FramePort doesn't
|
||||
look in. Then use the layout in the previous answer.
|
||||
- **The game starts and stops at once (Steam shows Resume):** add the game again from its original download or
|
||||
backup, click **Update on Frame**, then **Report a problem…** on its page if it still stops.
|
||||
backup, click **Update on Frame**, then report it (last answer on this page) if it still stops.
|
||||
|
||||
## Where are a game's settings in FramePort?
|
||||
|
||||
Every game has three places; the answers below refer to them.
|
||||
|
||||
- **Game settings:** right-click the game in the **Library** → **Game settings…**. Sharpness, refresh rate,
|
||||
controllers, menus and more, in plain words. Changes apply the next time the game starts.
|
||||
- **Patches:** click the game in the **Library** to open its page, then **Customize** (in the **Patches** section).
|
||||
Turn a patch on or off, then click **Update on Frame** at the top of the page.
|
||||
- **The game's menu:** the **…** button at the top of its page (or right-click in the Library → **More actions…**).
|
||||
It has **Reset to suggested recipe…** and **Report a problem…**, among others.
|
||||
|
||||
The sidebar has two tabs for troubleshooting: **Monitor** (the running game's frame rate, CPU and graphics) and
|
||||
**Files** (each installed game's storage on the Frame, under **Game storage**).
|
||||
|
||||
## A game doesn't work right. Where do I start?
|
||||
|
||||
1. **Update FramePort** (it offers updates itself; Settings → Updates), then open the game's page: if it shows
|
||||
**Update on Frame**, click it. Recipes and patches improve often.
|
||||
2. **Read the game's page.** The last launch test and your last play session (**Last session**) leave their result
|
||||
there, often with a suggested patch or setting and a button that applies it. Try that first.
|
||||
3. **Changed patches yourself?** The game's menu → **Reset to suggested recipe…** brings back the tested ones.
|
||||
4. **Find your symptom below.** Each answer says how to tell what's wrong and what to do.
|
||||
5. **Still stuck?** Report it (last answer on this page).
|
||||
|
||||
## The game doesn't start, or stops right away
|
||||
|
||||
How to tell: the game's page shows the last launch test's result (**Last launch test:** …) with an explanation, and
|
||||
Steam returns to **Play** or **Resume** within seconds.
|
||||
|
||||
- **"Game data (.obb) is missing"** on the game's page: FramePort didn't find the game's data files, so they never
|
||||
reached the Frame.
|
||||
1. Find the game's `.obb` files (named like `main.<version>.<package>.obb`).
|
||||
2. Put them in a folder named exactly the package name (shown in the file names) next to the game's APK, as in the
|
||||
first answer on this page.
|
||||
3. Library → **Add games** → **Scan a folder…** and pick the game's folder.
|
||||
4. Open the game's page and click **Update on Frame**.
|
||||
- **"Lepton didn't start the game this time"**, or it failed only on the first start after an install: click **Play**
|
||||
again.
|
||||
- **"This game is 32-bit only"**: the Frame can't run 32-bit apps, and no patch changes that. Look for a PC VR version
|
||||
of the game.
|
||||
- **"The game quit itself right after starting"**: open the game's page and click **Update on Frame** so the suggested
|
||||
patches apply (many games check for a Quest at start, and a patch removes that).
|
||||
- **Nothing like that, or it still fails:** report it.
|
||||
|
||||
## It hangs on a logo, a black screen or a loading screen
|
||||
|
||||
How to tell what it's doing: leave it running and open **Monitor** in FramePort's sidebar.
|
||||
|
||||
- **CPU or graphics busy:** it's still working, often preparing its shaders on the first start. Wait up to 10 minutes
|
||||
once; the next start is quicker.
|
||||
- **CPU and graphics near zero:** it's stuck waiting for something. While it still hangs, open the game's menu →
|
||||
**Report a problem…** and save the diagnostics: that zip shows what each of the game's threads waits for (FramePort
|
||||
1.0.1 and newer), which a zip saved after quitting can't. Attach it to the report.
|
||||
- **The game starts with a video** and hangs at its logo: game page → **Customize** → turn off **Hardware video
|
||||
decoding** → **Update on Frame**, and try again.
|
||||
|
||||
## The picture is wrong
|
||||
|
||||
Most picture problems have a setting or a patch. Change one thing at a time, then start the game again (patches also
|
||||
need **Update on Frame**).
|
||||
|
||||
| You see | Try |
|
||||
|---|---|
|
||||
| Something shows in one eye only, or one eye is gray (Unity games) | Game page → **Customize** → [Unity: render each eye separately](PATCHES.md#frame-unity_multipass) |
|
||||
| Left and right look swapped (depth feels wrong) | **Game settings…** → **Swap eyes** |
|
||||
| Textures flicker, objects pop in and out | **Game settings…** → **Turn off space warp** |
|
||||
| One eye shimmers while menus look steady | Game page → **Customize** → **Eye-tracked foveation (Valve)**: **Fixed**, else **Off** |
|
||||
| Menus or screens upside down | **Game settings…** → **Fix upside-down menus** |
|
||||
| Curved menus or video screens missing | **Game settings…** → **Show curved menus and screens** |
|
||||
| Blurry | **Game settings…** → raise **Sharpness** (costs frame rate) |
|
||||
|
||||
The [patch reference](PATCHES.md) lists every patch with what it does. If nothing helps, report it with a short
|
||||
description of what you see.
|
||||
|
||||
## It stutters or runs slowly
|
||||
|
||||
How to tell: open **Monitor** in the sidebar while you play. Smooth play needs the game's frame rate at the refresh
|
||||
rate (for example 72 or 90 fps).
|
||||
|
||||
- **Below the refresh rate:** right-click the game → **Game settings…** → lower **Sharpness** (for example to 0.8×) or
|
||||
pick a lower **Refresh rate**. After a session, the game's page may suggest a value.
|
||||
- **At the refresh rate but it still judders:** report it; that points to timing, not speed.
|
||||
|
||||
## The game pauses or recenters by itself
|
||||
|
||||
The Frame briefly tells games they lost focus, for example when the headset's sensor thinks you took it off.
|
||||
Right-click the game → **Game settings…**: keep **Don't pause on short interruptions** on and raise **Ignore focus
|
||||
dips up to** (for example to 2.5 s).
|
||||
|
||||
## Controllers don't work in a game
|
||||
|
||||
Right-click the game → **Game settings…**:
|
||||
|
||||
- **No controllers, or only hands:** turn on **Use controllers**.
|
||||
- **Buttons work but you can't aim at menus:** adjust **Pointer angle**.
|
||||
- **Vibration too strong:** lower **Vibration strength**.
|
||||
|
||||
## The Frame turns off or gets hot during a game
|
||||
|
||||
How to tell: if it only happens on battery and never while plugged in, it's power, not FramePort. Heavy games can
|
||||
draw more than the battery delivers, especially at lower charge, and the Frame then turns off without an error.
|
||||
|
||||
- Play plugged in, or right-click the game → **Game settings…** → lower **Sharpness** to reduce the load.
|
||||
- It also turns off while plugged in: report it. The diagnostics include the log of the boot that ended.
|
||||
|
||||
## My progress isn't saved
|
||||
|
||||
1. Quit through the game's own menu, not Steam's **Exit game**. Exit game closes the game at once, and some games only
|
||||
save when they exit normally.
|
||||
2. Check whether it saves at all: **Files** in the sidebar → under **Game storage**, the game → look at the files'
|
||||
dates. Play, save in the game, quit from its menu and look again.
|
||||
3. Nothing changes: report it.
|
||||
|
||||
Saves stay on the Frame when you update or reinstall a game.
|
||||
|
||||
## A game with mods crashes
|
||||
|
||||
How to tell: play once without mods and custom content. If it then works, a mod is the cause; the diagnostics zip
|
||||
usually names the mod's library in the crash. Report it to the mod's author, and check for a mod version that matches
|
||||
your game version.
|
||||
|
||||
## The game needs files I supply (ports, emulators, mods)
|
||||
|
||||
How to tell where they go: the game says it on screen or in its instructions, and its log names the path (in the
|
||||
diagnostics zip, `launch.log`). It's usually `Android/data/<package>/files`.
|
||||
|
||||
1. Unpack or extract the files on your PC first: these apps read folders, not disc images (`.iso`) or archives.
|
||||
2. **Files** in the sidebar → under **Game storage**, the game → open that folder → **Upload folder…** (or
|
||||
**Upload files…**). The game's menu → **Add videos and files** opens the same place.
|
||||
|
||||
In-game "choose a file" buttons don't work on the Frame, which has no file picker.
|
||||
|
||||
## How do I report a problem well?
|
||||
|
||||
1. Reproduce it once, then right away open the game's menu (**…** on its page) → **Report a problem…**. For a hang,
|
||||
do it while the game still hangs.
|
||||
2. Say what you saw in the headset and what you already tried.
|
||||
3. **Collect and open GitHub** saves a diagnostics zip and opens a prefilled game report; attach the zip to it. The
|
||||
zip holds logs, not your game files, and personal details are removed.
|
||||
@@ -31,6 +31,9 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| An overlay app (fpsVR, a wrist watch) installed on the Frame never appears, or SteamVR doesn't start it | not registered with the Frame's SteamVR, or registered with a manifest SteamVR skips: on the Frame vrserver reads only `binary_path_linux_arm` (vrserver.txt: "must specify binary_path for launch_type binary. Skipping") | `pcvr.vr_overlay` (Windows) / a Linux app with a bundled `.vrmanifest` (`is_dashboard_overlay`): agent v75 `register_vr_overlay` (check `vrcmd --overlays`, `~/.config/openvr/config/appconfig.json` manifest_paths); the app's own `--install` writes binary_path_linux only. fpsVR under Proton never loads OpenVR (quits after 60 s): unsupported for now |
|
||||
| An overlay app with **Start with SteamVR** on doesn't start by itself after the Frame or SteamVR starts (it works when started by hand) | SteamVR's own auto-launch flag (SetApplicationAutoLaunch) didn't persist on the Frame (no autolaunch entry in `~/.config/openvr/config/steamvr.vrsettings`) | agent v76: FramePort's service `frameport-vr-overlays.service` starts it at every new vrserver. Check `systemctl --user status frameport-vr-overlays`, `~/.local/share/frameport/vr-overlays.log` (`new SteamVR (vrserver N)` → `<pkg>: started` / `running` / `couldn't start it: …`), the deployment's `overlay.autostart`, and that `~/.local/share/frameport/vr-overlays.disabled` doesn't exist; `frameport frame info` (ensure_host_fixes) installs a missing service |
|
||||
| Every game suddenly fails to start: `crun: create keyring …: Disk quota exceeded`, `is not a running context` | rootless podman leaked a kernel keyring per launch; 200-key quota exhausted | `keyring = false` in `~/.config/containers/containers.conf` (FramePort agent does it), then reboot the Frame once |
|
||||
| Launch test "failed" although the game runs in the headset: frames paced, then `OVRPortVrApi: EnterVrMode: no tracked head pose before deadline`, then a crash (e.g. `zink: DEVICE LOST` + SIGSEGV in a `gdrv` thread) (GitHub #57, BlazeRush) | the VrApi bridge ends the VR session after 30 s without head tracking, which only happens without a worn headset; the game's renderer then crashes while tearing down | none needed: launch tests (`triage(..., headless=True)`) treat an exit or crash after a `headless_end` line (catalog/triage.yaml) as a pass when frames were paced before it (info finding `headless-end`); play sessions ignore it. Add new such markers to `headless_end` |
|
||||
| Launch test EXITED, launch.log has `Target app (<pkg>) did not start` and no `Start proc …:<pkg>` (GitHub #179) | Lepton occasionally doesn't start the app on the first start after an APK change; Android's `WebViewLoader-: System.exit` at boot is not the game (triage `app-not-started`) | start it again |
|
||||
| A game keeps no progress between starts although it saves (its storage gets a new `Profiles/Oculus/<id>/`-style folder per start; Walkabout Mini Golf, GitHub #182) | OVRPort's platform loader answers `ovr_GetLoggedInUserID` with a made-up id (6e18 + `random()`), new at every start; games file saves under the user id | `frame.ovr_stable_user` (default for games with the platform library): a fixed id per game and keystore. Old progress: copy the newest old profile into the new id's folder |
|
||||
| A Unity app's text field shows a caret for a moment and loses focus; no keyboard appears (for example Stremio VR login) | Unity's TMP_InputField/InputField wait for Android's on-screen keyboard and close themselves without one (Lepton has none); headless VR apps also have no focused Android window, so no key press reaches them | `frame.unity_text_input` (Cpp2IL finds `TouchScreenKeyboardShouldBeUsed`/`isKeyboardUsingEvents`, rewritten to false/true) + `device.text_input_window` (lepton-show-flatscreen: Steam's keyboard and Type on Frame work) |
|
||||
| Play gives Steam's "Game configuration unavailable"; Steam's console_log: `GameAction [AppID <id>] … RequestingLicense → UpdatingAppInfo → LaunchApp failed with AppError_9` | this Frame's Steam never loaded FramePort's shortcuts.vdf entry (it treats the id as a store app); cause unknown (GitHub #21/#30) | automatic since agent v43: Play registers the game through Steam's devkit interface ("Devkit Game: …", `devkit_register`) and starts that; uninstall removes it. Agent v58: a devkit entry Steam forgot after a restart is added again live on Play (no NOT_IN_LIBRARY → no Steam restart), and installs/art updates of devkit games only copy art (no Steam restart, GitHub #41) |
|
||||
| Steam's Exit game leaves the game running (container `lepton-steamlaunch-<appid>` still up) | Steam stopped only its `reaper`; launch.sh and Lepton (setsid) never got a signal (GitHub #36) | agent v44: launch.sh's 2 s loop ends the game when its parent is gone (`upgrade_launchers` adds it to existing launchers); by hand: `podman kill lepton-steamlaunch-<appid>` |
|
||||
@@ -182,7 +185,9 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
touching new memory (`perf script -F comm,tid,ip,dso`); `-t <tid> -F 499` samples one thread. Map IL2CPP
|
||||
addresses with a full Cpp2IL diffable-cs dump (method `Offset` = address − the r-xp mapping of libil2cpp.so).
|
||||
Sample memory per mapping name from `/proc/<pid>/smaps` (`[anon:libc_malloc]` = native heap).
|
||||
- Hangs (every thread asleep): backtraces of all threads with gdb. Root inside the Lepton container may ptrace the
|
||||
- Hangs (every thread asleep): first ask for a diagnostics zip saved *while the game hangs*: `target/threads.txt`
|
||||
(agent v79) shows each thread's wait and syscall (futex = a lock, ppoll/recvfrom + an internet socket = network). Then
|
||||
backtraces of all threads with gdb. Root inside the Lepton container may ptrace the
|
||||
game (the host's `ptrace_scope` 1 doesn't apply there) and the guest has `gdbserver64`; `debuggerd -b` fails (no
|
||||
tombstoned). Copy every mapped library to a local sysroot (`podman exec <container> cat /proc/<pid>/maps`, then
|
||||
`tar -chf - -T <list>` through `podman exec -i`; the app's own libs from the installed `lepton-app/game.apk` at their
|
||||
|
||||
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 690 KiB After Width: | Height: | Size: 6.8 MiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 288 KiB |
@@ -0,0 +1,393 @@
|
||||
"""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())
|
||||
@@ -149,6 +149,7 @@ def main() -> int:
|
||||
("transit-activity", lambda a: a.show_activity(True))]
|
||||
if args.fake_frame: # GitHub #90: the Move to… dialog (drives load in the background)
|
||||
steps.append(("move-dialog", lambda a: (a.page.pop_dialog(), a.move_game(game))))
|
||||
steps.append(("uninstall-dialog", lambda a: (a.page.pop_dialog(), a.uninstall(game))))
|
||||
# last: the right-click menu stays open over whatever comes next
|
||||
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
|
||||
a.library_view.open_menu(game))))
|
||||
|
||||
@@ -556,6 +556,12 @@ const ICON: Record<string, string> = {
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
@keyframes halo { 50% { box-shadow: 0 0 0 2px var(--accent), 0 0 0 11px rgba(255, 138, 31, .12); } }
|
||||
|
||||
/* medium (tablets): the title keeps its own width; the buttons' "auto" column squeezed it to "Libr" and one word
|
||||
per line. The buttons wrap in what's left */
|
||||
@container (min-width: 701px) and (max-width: 960px) {
|
||||
.view :global(.lib-h) { grid-template-columns: max-content minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
/* narrow: the sidebar becomes an icon rail */
|
||||
@container (max-width: 700px) {
|
||||
.body { grid-template-columns: 56px minmax(0, 1fr); }
|
||||
@@ -567,5 +573,12 @@ const ICON: Record<string, string> = {
|
||||
.view :global(.mon-game) { grid-template-columns: 56px minmax(0, 1fr); }
|
||||
.view :global(.mon-game .big-spark) { grid-column: 1 / -1; }
|
||||
.view :global(.grid) { grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); }
|
||||
/* Library header: title and count on their own line, the search full width, the buttons wrapping below */
|
||||
.view :global(.lib-h) { grid-template-columns: minmax(0, 1fr); }
|
||||
.view :global(.lib-h .vh-act) { justify-content: flex-start; }
|
||||
.view :global(.lib-h .search-w) { flex: 1 1 100%; }
|
||||
.view :global(.lib-h .search-w .search) { flex: 1; min-width: 0; width: auto; }
|
||||
.view :global(.add-w) { grid-column: 1; justify-self: start; }
|
||||
.view :global(.add-w .menu) { right: auto; left: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -166,6 +166,9 @@ const ICON = {
|
||||
.heads { grid-template-columns: minmax(0, 1fr); }
|
||||
.hcard.foss { grid-template-columns: minmax(0, 1fr); }
|
||||
.hcard.foss .hicon { grid-row: auto; }
|
||||
/* one column: the title's "2 / -1" from the wide layout would add a second, implicit column and squeeze
|
||||
FramePort's text into the first */
|
||||
.hcard.foss h3 { grid-column: auto; }
|
||||
.hcard.foss .other { border-left: 0; padding-left: 0; border-top: 1px dashed var(--border-strong); padding-top: 8px; }
|
||||
th, td { padding: 10px 12px; }
|
||||
.rowh { width: 38%; }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// date in the browser from GitHub's main branch (scripts/catalog-live.ts), the way the app follows the catalog.
|
||||
import { loadSnapshot } from '../data/catalog';
|
||||
import { STATUS, counts, engines, latestCheck, rowsHtml, sortGames, type Game, type Status } from '../scripts/catalog';
|
||||
import { patchHref } from '../scripts/patchlinks';
|
||||
import { SHARE_CONFIG, REPORT_PROBLEM, blob } from '../data/site';
|
||||
|
||||
const snapshot = loadSnapshot();
|
||||
@@ -58,7 +59,7 @@ const latest = latestCheck(games);
|
||||
<span role="columnheader">Status</span>
|
||||
<span role="columnheader" class="c-date">Checked</span>
|
||||
</div>
|
||||
<ul class="rows" role="rowgroup" data-rows set:html={rowsHtml(games)} />
|
||||
<ul class="rows" role="rowgroup" data-rows set:html={rowsHtml(games, patchHref)} />
|
||||
<p class="empty" data-empty hidden>No tested game matches. It may still work: FramePort suggests patches.</p>
|
||||
</div>
|
||||
<div class="board-foot">
|
||||
@@ -78,7 +79,8 @@ const latest = latestCheck(games);
|
||||
<script type="application/json" data-snapshot set:html={JSON.stringify(snapshot).replaceAll('<', '\\u003c')} />
|
||||
|
||||
<script>
|
||||
import { counts, engines, latestCheck, rowsHtml, sortGames, type Snapshot } from '../scripts/catalog';
|
||||
import { counts, engines, latestCheck, rowsHtml, shuffleGames, type Game, type Snapshot } from '../scripts/catalog';
|
||||
import { patchHref } from '../scripts/patchlinks';
|
||||
import { refreshCatalog } from '../scripts/catalog-live';
|
||||
|
||||
const board = document.querySelector<HTMLElement>('[data-board]');
|
||||
@@ -137,6 +139,11 @@ const latest = latestCheck(games);
|
||||
apply();
|
||||
});
|
||||
search.addEventListener('input', apply);
|
||||
// a random order on every visit (working games still first); the built page lists them alphabetically
|
||||
const rank = new Map<string, number>();
|
||||
const built = Object.values((JSON.parse(document.querySelector('[data-snapshot]')?.textContent || '{"files":{}}') as
|
||||
Snapshot).files).map((f) => f.game).filter((g): g is Game => !!g);
|
||||
if (built.length) list.innerHTML = rowsHtml(shuffleGames(built, rank), patchHref);
|
||||
apply();
|
||||
|
||||
// bring the board up to date with the catalog on GitHub
|
||||
@@ -150,8 +157,8 @@ const latest = latestCheck(games);
|
||||
src.title = `The catalog on GitHub's main branch, as of ${when}`;
|
||||
src.classList.add('live');
|
||||
if (!live.changed) return;
|
||||
const games = sortGames(live.games);
|
||||
list.innerHTML = rowsHtml(games);
|
||||
const games = shuffleGames(live.games, rank);
|
||||
list.innerHTML = rowsHtml(games, patchHref);
|
||||
const n = counts(games);
|
||||
for (const s of ['works', 'issues', 'unsupported'] as const) set(`[data-n="${s}"]`, String(n[s]));
|
||||
set('[data-total]', String(games.length));
|
||||
@@ -221,6 +228,9 @@ const latest = latestCheck(games);
|
||||
.rows :global(.pt) { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.rows :global(.pt span) { font: 600 10.5px/1 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--text-2); margin-right: 4px; }
|
||||
.rows :global(.pt code) { font-size: 12px; padding: 3px 8px; border-radius: 6px; background: rgba(58, 168, 255, .1); color: var(--secondary); }
|
||||
.rows :global(.pt a) { text-decoration: none; border-radius: 6px; }
|
||||
.rows :global(.pt a code) { transition: background 160ms; }
|
||||
.rows :global(.pt a:hover code), .rows :global(.pt a:focus-visible code) { background: rgba(58, 168, 255, .22); text-decoration: underline; }
|
||||
.rows :global(.fx) { font: 400 12px/1.4 var(--font-mono); color: var(--text-3); }
|
||||
@keyframes open { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
||||
.rows :global(.row[hidden]) { display: none; }
|
||||
|
||||
@@ -3,8 +3,10 @@ import Wordmark from './Wordmark.astro';
|
||||
import Contributors from './Contributors.astro';
|
||||
import { REPO, REPORT_PROBLEM, url } from '../data/site';
|
||||
|
||||
interface Props { credits?: boolean }
|
||||
const { credits = true } = Astro.props;
|
||||
// "all" = the "Built on" section with the projects and the contributors (the homepage), "contributors" = only the
|
||||
// contributors (What's new), none by default (docs, download, privacy, 404 and the other utility pages)
|
||||
interface Props { credits?: 'all' | 'contributors' | false }
|
||||
const { credits = false } = Astro.props;
|
||||
// each project named links to its own page (Lepton has none of its own: it links to Waydroid, which it is built on)
|
||||
type Part = [name: string, href?: string];
|
||||
const builtOn: { parts: Part[]; what: string }[] = [
|
||||
@@ -23,7 +25,12 @@ const builtOn: { parts: Part[]; what: string }[] = [
|
||||
/** "a", "a and b", "a, b and c" */
|
||||
const joiner = (i: number, n: number) => (i === 0 ? '' : i === n - 1 ? ' and ' : ', ');
|
||||
---
|
||||
{credits && (
|
||||
{credits === 'contributors' && (
|
||||
<section class="section credits" id="contributors">
|
||||
<div class="wrap"><Contributors /></div>
|
||||
</section>
|
||||
)}
|
||||
{credits === 'all' && (
|
||||
<section class="section credits" id="credits">
|
||||
<div class="wrap">
|
||||
<div class="head reveal">
|
||||
|
||||
@@ -26,7 +26,8 @@ const logo = readFileSync(join(process.cwd(), '..', 'src/frameport/ui/icons/logo
|
||||
b.style.setProperty('--mx', `${((e.clientX - r.left) / r.width) * 100}%`);
|
||||
b.style.setProperty('--my', `${((e.clientY - r.top) / r.height) * 100}%`);
|
||||
});
|
||||
b.addEventListener('pointerdown', () => { b.classList.remove('pressed'); void b.offsetWidth; b.classList.add('pressed'); });
|
||||
// the press ripple is for the mouse: on a touch screen it fired on every scroll that started on the badge
|
||||
b.addEventListener('pointerdown', (e) => { if (e.pointerType === 'touch') return; b.classList.remove('pressed'); void b.offsetWidth; b.classList.add('pressed'); });
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Glyph from './Glyph.astro';
|
||||
import PcvrHow from './PcvrHow.astro';
|
||||
import { patchLabel, stageDetails, type LogGame } from '../scripts/logdetails';
|
||||
import { PATCH_DOC, patchHref, patchInfo } from '../scripts/patchlinks';
|
||||
|
||||
interface Props { example: LogGame; others: LogGame[] }
|
||||
const { example, others } = Astro.props;
|
||||
@@ -79,7 +80,10 @@ const frame: Stage[] = [
|
||||
<p class="foot muted reveal">
|
||||
<span class="recipe-box" aria-live="polite">
|
||||
<span class="rb-k">Recipe</span>
|
||||
<span class="rb-p" data-lg-patches>{example.patches.length ? example.patches.map((x) => <code>{patchLabel(x)}</code>) : <code>Default patches</code>}</span>
|
||||
<span class="rb-p" data-lg-patches>{example.patches.length
|
||||
? example.patches.map((x) => { const href = patchHref(x); return href
|
||||
? <a class="chip" href={href} title={patchInfo(x)!.title}><code>{patchLabel(x)}</code></a> : <code>{patchLabel(x)}</code>; })
|
||||
: <a class="chip" href={PATCH_DOC} title="Every patch FramePort knows"><code>Default patches</code></a>}</span>
|
||||
<span class="rb-n" data-lg-note>{example.note}</span>
|
||||
</span>
|
||||
<span class="foot-t"><span data-lg="foot">{example.title}</span>'s recipe from the catalog. Untested games get suggested patches.</span>
|
||||
@@ -93,6 +97,7 @@ const frame: Stage[] = [
|
||||
|
||||
<script>
|
||||
import { patchLabel, stageDetails, type LogGame } from '../scripts/logdetails';
|
||||
import { PATCH_DOC, patchHref, patchInfo } from '../scripts/patchlinks';
|
||||
// another tested game, its real engine, VR API and recipe; the log plays again
|
||||
const log = document.querySelector<HTMLElement>('[data-log]');
|
||||
const games = JSON.parse(document.querySelector('[data-log-games]')?.textContent || '[]') as
|
||||
@@ -112,7 +117,15 @@ const frame: Stage[] = [
|
||||
chips.replaceChildren(...(g.patches.length ? g.patches : ['Default patches']).map((x) => {
|
||||
const c = document.createElement('code');
|
||||
c.textContent = g.patches.length ? patchLabel(x) : x;
|
||||
return c;
|
||||
// each chip links to its entry in the patch reference (the default chip to the whole reference)
|
||||
const href = g.patches.length ? patchHref(x) : PATCH_DOC;
|
||||
if (!href) return c;
|
||||
const a = document.createElement('a');
|
||||
a.className = 'chip';
|
||||
a.href = href;
|
||||
a.title = g.patches.length ? patchInfo(x)!.title : 'Every patch FramePort knows';
|
||||
a.append(c);
|
||||
return a;
|
||||
}));
|
||||
}
|
||||
const note = document.querySelector('[data-lg-note]');
|
||||
@@ -178,7 +191,12 @@ const frame: Stage[] = [
|
||||
padding: 14px 16px; border-radius: var(--radius-sm); background: var(--surface); border: 1px solid var(--border); }
|
||||
.rb-k { font: 600 11px/1 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--accent); }
|
||||
.rb-p { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.rb-p code { font-size: 12px; padding: 3px 8px; border-radius: 6px; background: rgba(58, 168, 255, .1); color: var(--secondary);
|
||||
/* :global: "Try another game" builds the chips in the browser, without Astro's scoping attribute */
|
||||
/* chips are links without the running-text underline (global.css .foot a): the chip's fill brightens instead */
|
||||
.foot .rb-p :global(a.chip), .foot .rb-p :global(a.chip:hover) { text-decoration: none; background: none; padding: 0; border-radius: 6px; }
|
||||
.rb-p :global(a code) { transition: background 160ms; }
|
||||
.rb-p :global(a:hover code), .rb-p :global(a:focus-visible code) { background: rgba(58, 168, 255, .22); text-decoration: underline; }
|
||||
.rb-p :global(code) { font-size: 12px; padding: 3px 8px; border-radius: 6px; background: rgba(58, 168, 255, .1); color: var(--secondary);
|
||||
animation: chip .35s var(--ease-out) both; }
|
||||
.rb-n { grid-column: 2; color: var(--text-2); font-size: 14px; }
|
||||
.rb-n:empty { display: none; }
|
||||
|
||||
@@ -17,7 +17,7 @@ export const HEADLINES: Headline[] = [
|
||||
frameport: 'GPL-3.0. Read it, build it, change it, share it.',
|
||||
others: ['Free (donationware). Source not published.', 'Free, from Valve.'] },
|
||||
{ icon: 'game', title: "Quest games that don't run as they are",
|
||||
frameport: 'Patches them for the Frame, with a tested recipe for 90+ games.',
|
||||
frameport: 'Patches them for the Frame, with a tested recipe for 120+ games.',
|
||||
others: ['Installs the APK as it is.', 'Installs the APK as it is.'] },
|
||||
{ icon: 'pc', title: 'Your PC',
|
||||
frameport: 'Windows, macOS and Linux.',
|
||||
@@ -45,7 +45,7 @@ export const ROWS: Row[] = [
|
||||
y('Converted and patched for the Frame'),
|
||||
n('Installs the APK as it is'),
|
||||
n('Installs the APK as it is')] },
|
||||
{ label: 'Does it know which patches a game needs?', cells: [y('A tested recipe for 90+ games, updated without an app update'), u(), n('No')] },
|
||||
{ label: 'Does it know which patches a game needs?', cells: [y('A tested recipe for 120+ games, updated without an app update'), u(), n('No')] },
|
||||
{ label: 'Will the game be in my Steam library?', cells: [
|
||||
y('Yes, with artwork and tags'), p('As a "Devkit Game" shortcut'), p('As "Devkit Game: <title>"; not with adb')] },
|
||||
{ label: 'Android apps, Linux apps, Windows programs?', cells: [
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
{
|
||||
"updated": "2026-10-10",
|
||||
"updated": "2026-10-12",
|
||||
"people": [
|
||||
{
|
||||
"login": "spoopyghosty0",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/336754034?v=4",
|
||||
"url": "https://github.com/spoopyghosty0",
|
||||
"commits": 462,
|
||||
"issues": 4,
|
||||
"prs": 2,
|
||||
"total": 484
|
||||
"commits": 542,
|
||||
"issues": 5,
|
||||
"prs": 3,
|
||||
"total": 572
|
||||
},
|
||||
{
|
||||
"login": "Klownicle",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/6896081?v=4",
|
||||
"url": "https://github.com/Klownicle",
|
||||
"commits": 0,
|
||||
"issues": 20,
|
||||
"prs": 0,
|
||||
"total": 60
|
||||
},
|
||||
{
|
||||
"login": "StevenPartridge",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/22158507?v=4",
|
||||
"url": "https://github.com/StevenPartridge",
|
||||
"commits": 0,
|
||||
"issues": 15,
|
||||
"prs": 0,
|
||||
"total": 45
|
||||
},
|
||||
{
|
||||
"login": "tkp206093",
|
||||
@@ -20,22 +38,13 @@
|
||||
"total": 45
|
||||
},
|
||||
{
|
||||
"login": "StevenPartridge",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/22158507?v=4",
|
||||
"url": "https://github.com/StevenPartridge",
|
||||
"login": "Sheebalove",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/176067652?v=4",
|
||||
"url": "https://github.com/Sheebalove",
|
||||
"commits": 0,
|
||||
"issues": 14,
|
||||
"issues": 11,
|
||||
"prs": 0,
|
||||
"total": 42
|
||||
},
|
||||
{
|
||||
"login": "Klownicle",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/6896081?v=4",
|
||||
"url": "https://github.com/Klownicle",
|
||||
"commits": 0,
|
||||
"issues": 13,
|
||||
"prs": 0,
|
||||
"total": 39
|
||||
"total": 33
|
||||
},
|
||||
{
|
||||
"login": "inseend",
|
||||
@@ -100,6 +109,33 @@
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "Scoota23",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/27732436?v=4",
|
||||
"url": "https://github.com/Scoota23",
|
||||
"commits": 0,
|
||||
"issues": 5,
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "eepykith",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/338898488?v=4",
|
||||
"url": "https://github.com/eepykith",
|
||||
"commits": 0,
|
||||
"issues": 4,
|
||||
"prs": 0,
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "nickwilde77555",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/180547421?v=4",
|
||||
"url": "https://github.com/nickwilde77555",
|
||||
"commits": 0,
|
||||
"issues": 4,
|
||||
"prs": 0,
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "Rizsraketa",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/64963535?v=4",
|
||||
@@ -127,6 +163,15 @@
|
||||
"prs": 0,
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "trankillity",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/53357041?v=4",
|
||||
"url": "https://github.com/trankillity",
|
||||
"commits": 0,
|
||||
"issues": 4,
|
||||
"prs": 0,
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "xhuytox",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/20061642?v=4",
|
||||
@@ -163,15 +208,6 @@
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "eepykith",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/338898488?v=4",
|
||||
"url": "https://github.com/eepykith",
|
||||
"commits": 0,
|
||||
"issues": 3,
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "m4ddh7jm7c-bit",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/338352097?v=4",
|
||||
@@ -208,6 +244,15 @@
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "ganona",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/112239200?v=4",
|
||||
"url": "https://github.com/ganona",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "GreySyntax",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/242626?v=4",
|
||||
@@ -227,9 +272,9 @@
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "nickwilde77555",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/180547421?v=4",
|
||||
"url": "https://github.com/nickwilde77555",
|
||||
"login": "Minibattle",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/67839290?v=4",
|
||||
"url": "https://github.com/Minibattle",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
@@ -244,6 +289,15 @@
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "Sidilium",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/44266441?v=4",
|
||||
"url": "https://github.com/Sidilium",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "syberphunk",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/234857?v=4",
|
||||
@@ -262,6 +316,24 @@
|
||||
"prs": 1,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "AlexanderCowell",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/225416878?v=4",
|
||||
"url": "https://github.com/AlexanderCowell",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Aryetis",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/4246001?v=4",
|
||||
"url": "https://github.com/Aryetis",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "bcain2005-gif",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/258048498?v=4",
|
||||
@@ -289,6 +361,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "DolphinOfficial",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/92276540?v=4",
|
||||
"url": "https://github.com/DolphinOfficial",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "dontstealthisplease-gif",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/337959488?v=4",
|
||||
@@ -316,6 +397,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "felis-lynx",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/106830854?v=4",
|
||||
"url": "https://github.com/felis-lynx",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Froghut",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1702171?v=4",
|
||||
@@ -334,6 +424,24 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "gnargle",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/9372593?v=4",
|
||||
"url": "https://github.com/gnargle",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "hokey1981",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/42741984?v=4",
|
||||
"url": "https://github.com/hokey1981",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "InsideCat02",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/111202797?v=4",
|
||||
@@ -362,9 +470,18 @@
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Minibattle",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/67839290?v=4",
|
||||
"url": "https://github.com/Minibattle",
|
||||
"login": "Mitchlett",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/46537377?v=4",
|
||||
"url": "https://github.com/Mitchlett",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Momofier",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/13684228?v=4",
|
||||
"url": "https://github.com/Momofier",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
@@ -379,6 +496,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Obliviate28",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/341108069?v=4",
|
||||
"url": "https://github.com/Obliviate28",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Puchor",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/46112576?v=4",
|
||||
@@ -389,9 +515,18 @@
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Sidilium",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/44266441?v=4",
|
||||
"url": "https://github.com/Sidilium",
|
||||
"login": "s1lenceSRG",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/340965322?v=4",
|
||||
"url": "https://github.com/s1lenceSRG",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "ssgod101",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/49173466?v=4",
|
||||
"url": "https://github.com/ssgod101",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
@@ -415,15 +550,6 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "trankillity",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/53357041?v=4",
|
||||
"url": "https://github.com/trankillity",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "TylerJaacks",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1505374?v=4",
|
||||
|
||||
@@ -5,15 +5,20 @@ import { join } from 'node:path';
|
||||
import { Marked, type Tokens } from 'marked';
|
||||
import { REPO, url } from './site';
|
||||
import { fileTree } from '../scripts/filetree';
|
||||
import { patchHref } from '../scripts/patchlinks';
|
||||
|
||||
export interface DocInfo { file: string; slug: string; name: string; group: string; blurb: string; summary: string }
|
||||
export interface DocInfo {
|
||||
file: string; slug: string; name: string; group: string; blurb: string; summary: string;
|
||||
/** deepest heading level in the page's table of contents (default 3) */
|
||||
tocDepth?: number;
|
||||
}
|
||||
export const DOCS: DocInfo[] = [
|
||||
{ file: 'INSTALL.md', slug: 'install', name: 'Install and first steps', group: 'Get started', blurb: 'Download, connect the Frame, install games.',
|
||||
summary: 'Install FramePort on Windows, macOS or Linux, connect your Steam Frame once, then install and play Quest and PC VR games.' },
|
||||
{ file: 'FRAME_SETUP.md', slug: 'frame-setup', name: 'What the setup changes', group: 'Get started', blurb: 'Changes on the Frame, firewalls, undoing it.',
|
||||
summary: "What FramePort's one-time setup changes on the Steam Frame, how to undo it, and what your network and firewall need." },
|
||||
{ file: 'FAQ.md', slug: 'faq', name: 'FAQ', group: 'Get started', blurb: 'Game folders and common questions.',
|
||||
summary: "Common questions about FramePort: how to lay out game folders with APK and OBB files, and what to do when a game doesn't run." },
|
||||
{ file: 'FAQ.md', slug: 'faq', name: 'FAQ', group: 'Get started', blurb: 'Game folders and fixing problems.',
|
||||
summary: "Common questions about FramePort: how to lay out game folders with APK and OBB files, where a game's settings are, and how to fix games that don't start, hang, look wrong or stutter." },
|
||||
{ file: 'COMPATIBILITY.md', slug: 'compatibility', name: 'Compatibility', group: 'Using FramePort', blurb: 'What runs on the Frame.',
|
||||
summary: 'Which Quest games, PC VR games and Android apps run on the Steam Frame with FramePort, and how to share a recipe that works.' },
|
||||
{ file: 'THEMES.md', slug: 'themes', name: 'Themes', group: 'Using FramePort', blurb: 'Color themes and making your own.',
|
||||
@@ -22,6 +27,9 @@ export const DOCS: DocInfo[] = [
|
||||
summary: 'Add an Install with FramePort button to your download page or README, so one click installs your game on a Steam Frame.' },
|
||||
{ file: 'DIAGNOSTICS.md', slug: 'diagnostics', name: 'Diagnostics and problem reports', group: 'For developers', blurb: 'What a problem report contains.',
|
||||
summary: "What FramePort's problem reports and shared recipes contain, how personal data is removed, and how recipes reach the catalog." },
|
||||
{ file: 'PATCHES.md', slug: 'patches', name: 'Patch reference', group: 'For developers', blurb: 'Every patch and game setting.',
|
||||
summary: 'Every FramePort patch and game setting: what it does, whether it changes the APK, when it is suggested, its options and the games that use it.',
|
||||
tocDepth: 2 },
|
||||
{ file: 'ARCHITECTURE.md', slug: 'architecture', name: 'Architecture', group: 'For developers', blurb: 'How FramePort is built and what it adds.',
|
||||
summary: 'How FramePort is built: the projects it wraps, the pipeline from scan to launch test, and what it adds for the Steam Frame.' },
|
||||
{ file: 'PLAYBOOK.md', slug: 'playbook', name: 'Porting playbook', group: 'For developers', blurb: 'Symptoms and fixes from real games.',
|
||||
@@ -72,6 +80,8 @@ export function loadDoc(info: DocInfo): Doc {
|
||||
if (h1) md = md.replace(h1[0], '');
|
||||
const slug = slugger();
|
||||
const toc: Doc['toc'] = [];
|
||||
const tocDepth = info.tocDepth ?? 3;
|
||||
const linked = new WeakSet<object>(); // code spans inside a link: never a second link
|
||||
const marked = new Marked({
|
||||
gfm: true,
|
||||
walkTokens(token) {
|
||||
@@ -84,6 +94,7 @@ export function loadDoc(info: DocInfo): Doc {
|
||||
link.tokens = [{ type: 'text', raw: named.name, text: named.name }];
|
||||
}
|
||||
link.href = rewrite(link.href);
|
||||
for (const t of link.tokens ?? []) if (t.type === 'codespan') linked.add(t);
|
||||
}
|
||||
if (token.type === 'html') {
|
||||
(token as Tokens.HTML).text = (token as Tokens.HTML).text.replace(/(src|href)="([^"]+)"/g, (_, attr, v) => `${attr}="${rewrite(v)}"`);
|
||||
@@ -101,10 +112,20 @@ export function loadDoc(info: DocInfo): Doc {
|
||||
return `<figure class="diagram"><div class="diagram-scroll"><img src="${href}" alt="${alt}" loading="lazy" /></div>`
|
||||
+ '<figcaption>Scroll sideways or tap to enlarge.</figcaption></figure>';
|
||||
},
|
||||
// a heading that starts with its own anchor (<a id="…"></a>, as in PATCHES.md) keeps that id, so GitHub and the
|
||||
// site share one link target
|
||||
heading({ tokens, depth, text }) {
|
||||
const id = slug(text);
|
||||
if (depth <= 3) toc.push({ id, text: text.replace(/[`*_]/g, ''), depth });
|
||||
return `<h${depth} id="${id}"><a class="anchor" href="#${id}" aria-label="Link to this section">#</a>${this.parser.parseInline(tokens)}</h${depth}>\n`;
|
||||
const own = text.match(/^<a id="([^"]+)"><\/a>\s*/);
|
||||
const label = own ? text.slice(own[0].length) : text;
|
||||
const id = own ? own[1] : slug(text);
|
||||
if (depth <= tocDepth) toc.push({ id, text: label.replace(/[`*_]/g, ''), depth });
|
||||
const inner = this.parser.parseInline(tokens).replace(/^<a id="[^"]+"><\/a>\s*/, '');
|
||||
return `<h${depth} id="${id}"><a class="anchor" href="#${id}" aria-label="Link to this section">#</a>${inner}</h${depth}>\n`;
|
||||
},
|
||||
// a patch id in a code span (`frame.unity_text_input`) links to its entry in the patch reference
|
||||
codespan(token) {
|
||||
const href = linked.has(token) || !/^[\w.]+$/.test(token.text) ? null : patchHref(token.text);
|
||||
return href ? `<a class="patch-ref" href="${href}"><code>${token.text}</code></a>` : false;
|
||||
},
|
||||
table(token) {
|
||||
const head = token.header.map((c) => `<th>${this.parser.parseInline(c.tokens)}</th>`).join('');
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
{
|
||||
"adapter.aim_forward": {
|
||||
"anchor": "adapter-aim_forward",
|
||||
"title": "Pointer start",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.aim_pitch": {
|
||||
"anchor": "adapter-aim_pitch",
|
||||
"title": "Pointer angle",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.aim_yaw": {
|
||||
"anchor": "adapter-aim_yaw",
|
||||
"title": "Pointer turn",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.controller_fix": {
|
||||
"anchor": "adapter-controller_fix",
|
||||
"title": "Use controllers",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.controller_models": {
|
||||
"anchor": "adapter-controller_models",
|
||||
"title": "Show Steam Frame controllers",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.cube_standin": {
|
||||
"anchor": "adapter-cube_standin",
|
||||
"title": "Stand in for cube layers",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.cylinder_strips": {
|
||||
"anchor": "adapter-cylinder_strips",
|
||||
"title": "Show curved menus and screens",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.equirect_emul": {
|
||||
"anchor": "adapter-equirect_emul",
|
||||
"title": "Show 360° backgrounds and videos",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.equirect_face": {
|
||||
"anchor": "adapter-equirect_face",
|
||||
"title": "360° detail",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.equirect_flip": {
|
||||
"anchor": "adapter-equirect_flip",
|
||||
"title": "360° picture orientation",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.equirect_fps": {
|
||||
"anchor": "adapter-equirect_fps",
|
||||
"title": "360° video frame rate limit",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.equirect_res": {
|
||||
"anchor": "adapter-equirect_res",
|
||||
"title": "360° sharpness",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.equirect_stereo": {
|
||||
"anchor": "adapter-equirect_stereo",
|
||||
"title": "360° 3D",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.eye_debug": {
|
||||
"anchor": "adapter-eye_debug",
|
||||
"title": "Per-eye diagnostics (log)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.flip_emul": {
|
||||
"anchor": "adapter-flip_emul",
|
||||
"title": "Fix upside-down menus",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.flip_quads": {
|
||||
"anchor": "adapter-flip_quads",
|
||||
"title": "Rotate panels 180°",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.focus_hold": {
|
||||
"anchor": "adapter-focus_hold",
|
||||
"title": "Don't pause on short interruptions",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.focus_hold_ms": {
|
||||
"anchor": "adapter-focus_hold_ms",
|
||||
"title": "Ignore focus dips up to",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.foveation_fix": {
|
||||
"anchor": "adapter-foveation_fix",
|
||||
"title": "Hide Quest foveation",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.gl_hide_msrtt": {
|
||||
"anchor": "adapter-gl_hide_msrtt",
|
||||
"title": "Hide fast anti-aliasing (OpenGL ES)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.gl_hide_multiview": {
|
||||
"anchor": "adapter-gl_hide_multiview",
|
||||
"title": "Draw each eye separately (OpenGL ES)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.gl_highp_markers": {
|
||||
"anchor": "adapter-gl_highp_markers",
|
||||
"title": "GL shim: high-precision vertex shaders",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.gl_int_attribs": {
|
||||
"anchor": "adapter-gl_int_attribs",
|
||||
"title": "GL shim: integer vertex inputs",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.gl_mv_debug": {
|
||||
"anchor": "adapter-gl_mv_debug",
|
||||
"title": "Flat panel diagnostics (log)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.haptic_fix": {
|
||||
"anchor": "adapter-haptic_fix",
|
||||
"title": "Fix freezes when controllers vibrate",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.haptic_scale": {
|
||||
"anchor": "adapter-haptic_scale",
|
||||
"title": "Vibration strength",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.hide_space_warp": {
|
||||
"anchor": "adapter-hide_space_warp",
|
||||
"title": "Turn off space warp",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.input_diag": {
|
||||
"anchor": "adapter-input_diag",
|
||||
"title": "Controller input diagnostics (log)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.layer_debug": {
|
||||
"anchor": "adapter-layer_debug",
|
||||
"title": "Extra diagnostics (log)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.layer_fix": {
|
||||
"anchor": "adapter-layer_fix",
|
||||
"title": "Drop broken layers",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.mutable_fix": {
|
||||
"anchor": "adapter-mutable_fix",
|
||||
"title": "Vulkan format workaround",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.ovrp_begin_gate": {
|
||||
"anchor": "adapter-ovrp_begin_gate",
|
||||
"title": "Unity frame loop: wait for skipped frames",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.ovrp_hold_physics": {
|
||||
"anchor": "adapter-ovrp_hold_physics",
|
||||
"title": "Steadier hands (older Unity)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.passthrough_emul": {
|
||||
"anchor": "adapter-passthrough_emul",
|
||||
"title": "See your room",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.pose_consistency": {
|
||||
"anchor": "adapter-pose_consistency",
|
||||
"title": "Steadier head tracking",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.pose_debug": {
|
||||
"anchor": "adapter-pose_debug",
|
||||
"title": "Pose timing diagnostics (log)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.pose_time_fix": {
|
||||
"anchor": "adapter-pose_time_fix",
|
||||
"title": "Fix hands lagging behind controllers",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.profile_remap": {
|
||||
"anchor": "adapter-profile_remap",
|
||||
"title": "Treat newer Quest controllers as Touch",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.proximity_emul": {
|
||||
"anchor": "adapter-proximity_emul",
|
||||
"title": "Animate fingers from touch",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.rect_clamp": {
|
||||
"anchor": "adapter-rect_clamp",
|
||||
"title": "Keep pictures in bounds",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.refresh_rate": {
|
||||
"anchor": "adapter-refresh_rate",
|
||||
"title": "Refresh rate",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.release_wait": {
|
||||
"anchor": "adapter-release_wait",
|
||||
"title": "Wait for the game's GPU",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.respace_kick": {
|
||||
"anchor": "adapter-respace_kick",
|
||||
"title": "Re-create play space",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.scale": {
|
||||
"anchor": "adapter-scale",
|
||||
"title": "Sharpness",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.scene_depth": {
|
||||
"anchor": "adapter-scene_depth",
|
||||
"title": "Room depth",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.scene_emul": {
|
||||
"anchor": "adapter-scene_emul",
|
||||
"title": "Pretend room layout",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.scene_height": {
|
||||
"anchor": "adapter-scene_height",
|
||||
"title": "Room height",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.scene_width": {
|
||||
"anchor": "adapter-scene_width",
|
||||
"title": "Room width",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.snapshot": {
|
||||
"anchor": "adapter-snapshot",
|
||||
"title": "Eye-image snapshots (diagnostics)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.stable_local": {
|
||||
"anchor": "adapter-stable_local",
|
||||
"title": "Keep menus in place",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.strip_color_bias": {
|
||||
"anchor": "adapter-strip_color_bias",
|
||||
"title": "Drop layer color fades",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.strip_depth": {
|
||||
"anchor": "adapter-strip_depth",
|
||||
"title": "Strip depth layers",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.surface_emul": {
|
||||
"anchor": "adapter-surface_emul",
|
||||
"title": "Show video panels",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.surface_native": {
|
||||
"anchor": "adapter-surface_native",
|
||||
"title": "Native video surfaces (Batman renderer)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.swap_eyes": {
|
||||
"anchor": "adapter-swap_eyes",
|
||||
"title": "Swap eyes",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.swapchain_fix": {
|
||||
"anchor": "adapter-swapchain_fix",
|
||||
"title": "Retry refused picture formats",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.sync_guard": {
|
||||
"anchor": "adapter-sync_guard",
|
||||
"title": "Steady controller input after focus",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.vk_hide_fdm": {
|
||||
"anchor": "adapter-vk_hide_fdm",
|
||||
"title": "Hide Vulkan foveation",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.vk_query_slots": {
|
||||
"anchor": "adapter-vk_query_slots",
|
||||
"title": "Stop objects popping in and out",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.vk_shader_dump": {
|
||||
"anchor": "adapter-vk_shader_dump",
|
||||
"title": "Vulkan shader dump (diagnostics)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.vk_shader_fix": {
|
||||
"anchor": "adapter-vk_shader_fix",
|
||||
"title": "Vulkan shader fixes",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.vk_spec_fixes": {
|
||||
"anchor": "adapter-vk_spec_fixes",
|
||||
"title": "Fix depth flicker (Unreal Vulkan)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.vk_validation": {
|
||||
"anchor": "adapter-vk_validation",
|
||||
"title": "Vulkan error checks (log)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.zink_shader_dump": {
|
||||
"anchor": "adapter-zink_shader_dump",
|
||||
"title": "OpenGL ES shader dump (diagnostics)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.zink_shader_fix": {
|
||||
"anchor": "adapter-zink_shader_fix",
|
||||
"title": "OpenGL ES shader fixes",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"device.config_sync": {
|
||||
"anchor": "device-config_sync",
|
||||
"title": "Keep the game's saved resolution in step",
|
||||
"group": "Files and settings on the Frame"
|
||||
},
|
||||
"device.display_mode": {
|
||||
"anchor": "device-display_mode",
|
||||
"title": "Show as VR or as a flat window",
|
||||
"group": "Files and settings on the Frame"
|
||||
},
|
||||
"device.files": {
|
||||
"anchor": "device-files",
|
||||
"title": "Settings files for the game",
|
||||
"group": "Files and settings on the Frame"
|
||||
},
|
||||
"device.foveation": {
|
||||
"anchor": "device-foveation",
|
||||
"title": "Eye-tracked foveation (Valve)",
|
||||
"group": "Files and settings on the Frame"
|
||||
},
|
||||
"device.hide_navbar": {
|
||||
"anchor": "device-hide_navbar",
|
||||
"title": "Hide Android's navigation bar",
|
||||
"group": "Files and settings on the Frame"
|
||||
},
|
||||
"device.lepton_env": {
|
||||
"anchor": "device-lepton_env",
|
||||
"title": "Extra Lepton settings",
|
||||
"group": "Files and settings on the Frame"
|
||||
},
|
||||
"device.steam_gamepad": {
|
||||
"anchor": "device-steam_gamepad",
|
||||
"title": "Controllers as a gamepad (Steam Input)",
|
||||
"group": "Files and settings on the Frame"
|
||||
},
|
||||
"device.text_input_window": {
|
||||
"anchor": "device-text_input_window",
|
||||
"title": "Show the app's Android window (for typing)",
|
||||
"group": "Files and settings on the Frame"
|
||||
},
|
||||
"frame.adapter": {
|
||||
"anchor": "frame-adapter",
|
||||
"title": "FrameBridge OpenXR adapter",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.asset_files": {
|
||||
"anchor": "frame-asset_files",
|
||||
"title": "Content files from the game's data",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.avatar_stub": {
|
||||
"anchor": "frame-avatar_stub",
|
||||
"title": "Skip Meta avatars",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.blazerush": {
|
||||
"anchor": "frame-blazerush",
|
||||
"title": "BlazeRush: controllers without Meta avatars, own shader cache",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.gl_multiview_fbo": {
|
||||
"anchor": "frame-gl_multiview_fbo",
|
||||
"title": "OpenGL ES: multiview shaders on flat panels (HUD, menus)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.gl_shim": {
|
||||
"anchor": "frame-gl_shim",
|
||||
"title": "GL shim (Mesa GLSL compatibility)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.godot_clipboard": {
|
||||
"anchor": "frame-godot_clipboard",
|
||||
"title": "Godot apps: start without a clipboard service",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.hw_video_decode": {
|
||||
"anchor": "frame-hw_video_decode",
|
||||
"title": "Hardware video decoding (H.264, HEVC, VP9)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.langpacks": {
|
||||
"anchor": "frame-langpacks",
|
||||
"title": "Language packs from the game's files",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.launcher": {
|
||||
"anchor": "frame-launcher",
|
||||
"title": "Launcher category (INFO → LAUNCHER)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.ltw_depth": {
|
||||
"anchor": "frame-ltw_depth",
|
||||
"title": "GL wrapper: OpenGL ES depth formats (Minecraft)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.meta_permissions": {
|
||||
"anchor": "frame-meta_permissions",
|
||||
"title": "Declare Meta-only permissions",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.metaxr_telemetry": {
|
||||
"anchor": "frame-metaxr_telemetry",
|
||||
"title": "Meta XR Audio: skip Quest telemetry class",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.nodebug": {
|
||||
"anchor": "frame-nodebug",
|
||||
"title": "Clear android:debuggable",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.oculusos": {
|
||||
"anchor": "frame-oculusos",
|
||||
"title": "Oculus OS telemetry class stubs",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.ovr_microphone": {
|
||||
"anchor": "frame-ovr_microphone",
|
||||
"title": "Fix crash when the game prepares the microphone",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.ovr_stable_user": {
|
||||
"anchor": "frame-ovr_stable_user",
|
||||
"title": "Keep the same Meta user between starts",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.ovr_trace": {
|
||||
"anchor": "frame-ovr_trace",
|
||||
"title": "Trace Meta platform requests (diagnostics)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.ovrplatformcompat": {
|
||||
"anchor": "frame-ovrplatformcompat",
|
||||
"title": "Platform compat (ovrMessageType_ToString)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.ovrstubs": {
|
||||
"anchor": "frame-ovrstubs",
|
||||
"title": "Stub missing Meta platform functions",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.pac_hints": {
|
||||
"anchor": "frame-pac_hints",
|
||||
"title": "Unpaired pointer-authentication checks",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.sdl_clipboard": {
|
||||
"anchor": "frame-sdl_clipboard",
|
||||
"title": "SDL apps: start without a clipboard service",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.slz_vulkan_hooks": {
|
||||
"anchor": "frame-slz_vulkan_hooks",
|
||||
"title": "Stress Level Zero: let Unity start Vulkan itself",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.start_activity": {
|
||||
"anchor": "frame-start_activity",
|
||||
"title": "Start straight in VR (skip the 2D launcher)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.swapchain_limit": {
|
||||
"anchor": "frame-swapchain_limit",
|
||||
"title": "Allow swapchains larger than 4096 px",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.tbxr_vendor": {
|
||||
"anchor": "frame-tbxr_vendor",
|
||||
"title": "Team Beef ports: treat the Frame as a Meta headset",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unity_gl_shim": {
|
||||
"anchor": "frame-unity_gl_shim",
|
||||
"title": "GL shim for Unity (no multisampled render-to-texture)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unity_multipass": {
|
||||
"anchor": "frame-unity_multipass",
|
||||
"title": "Unity: render each eye separately (no multiview)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unity_no_msaa": {
|
||||
"anchor": "frame-unity_no_msaa",
|
||||
"title": "Unity: disable MSAA",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unity_no_overlay_copy": {
|
||||
"anchor": "frame-unity_no_overlay_copy",
|
||||
"title": "Unity: skip OVROverlay layers (fades, splash screens)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unity_oculus_check": {
|
||||
"anchor": "frame-unity_oculus_check",
|
||||
"title": "Unity: start VR without Meta's system apps",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unity_runtime_msaa_off": {
|
||||
"anchor": "frame-unity_runtime_msaa_off",
|
||||
"title": "Unity: keep MSAA off at runtime (OVRManager)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unity_text_input": {
|
||||
"anchor": "frame-unity_text_input",
|
||||
"title": "Make Unity text fields work",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unity_user_presence": {
|
||||
"anchor": "frame-unity_user_presence",
|
||||
"title": "Unity: headset always counts as worn",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unreal_gl_shim": {
|
||||
"anchor": "frame-unreal_gl_shim",
|
||||
"title": "GL shim for Unreal (no multisampled render-to-texture)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unreal_ovrp_entrypoints": {
|
||||
"anchor": "frame-unreal_ovrp_entrypoints",
|
||||
"title": "Unreal: stand-ins for OVRPlugin functions OVRPort lacks",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unreal_quest_keymap": {
|
||||
"anchor": "frame-unreal_quest_keymap",
|
||||
"title": "Unreal: Quest controller bindings (RPOC key map)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unreal_quest_precompile": {
|
||||
"anchor": "frame-unreal_quest_precompile",
|
||||
"title": "Unreal: don't wait for the Quest shader precompile",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unreal_skip_obb_check": {
|
||||
"anchor": "frame-unreal_skip_obb_check",
|
||||
"title": "Unreal: start without the OBB check",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unreal_thumb_touch": {
|
||||
"anchor": "frame-unreal_thumb_touch",
|
||||
"title": "Unreal: thumb follows the capacitive touches",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.vivox_audio_route": {
|
||||
"anchor": "frame-vivox_audio_route",
|
||||
"title": "Vivox voice chat: skip Android 12 audio routing",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.vk_sanitize": {
|
||||
"anchor": "frame-vk_sanitize",
|
||||
"title": "Vulkan: drop invalid render-pass pointers",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.vrapi_bridge": {
|
||||
"anchor": "frame-vrapi_bridge",
|
||||
"title": "VrApi → OpenXR bridge",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.vrapi_stub": {
|
||||
"anchor": "frame-vrapi_stub",
|
||||
"title": "Quiet Meta's VrApi loader (OpenXR games)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.zink_shader_fix": {
|
||||
"anchor": "frame-zink_shader_fix",
|
||||
"title": "OpenGL ES: shader fixes (Vulkan layer under Zink)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"patch_ac_nexus_no_appsw_72": {
|
||||
"anchor": "patch_ac_nexus_no_appsw_72",
|
||||
"title": "AC Nexus: no AppSW at 72 Hz",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_ac_nexus_no_appsw_90": {
|
||||
"anchor": "patch_ac_nexus_no_appsw_90",
|
||||
"title": "AC Nexus: no AppSW at 90 Hz",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_clean_up_frida": {
|
||||
"anchor": "patch_clean_up_frida",
|
||||
"title": "Clean up Frida leftovers in smali",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_copy_libraries": {
|
||||
"anchor": "patch_copy_libraries",
|
||||
"title": "Copy OVRPort libraries",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_copy_ovrplugin_vrapi": {
|
||||
"anchor": "patch_copy_ovrplugin_vrapi",
|
||||
"title": "Copy OVRPlugin if VrApi is present",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_disable_controller_offset": {
|
||||
"anchor": "patch_disable_controller_offset",
|
||||
"title": "Disable controller tracking offset",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_disable_meta_xr_audio_telemetry": {
|
||||
"anchor": "patch_disable_meta_xr_audio_telemetry",
|
||||
"title": "Disable Meta XR Audio telemetry",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_disable_space_warp": {
|
||||
"anchor": "patch_disable_space_warp",
|
||||
"title": "Disable application space warp if used",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_fix_min_android_sdk": {
|
||||
"anchor": "patch_fix_min_android_sdk",
|
||||
"title": "Fix minimal Android SDK",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_fix_unreal_crash": {
|
||||
"anchor": "patch_fix_unreal_crash",
|
||||
"title": "Fix UE4 crash with Unity stub",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_force_passthrough": {
|
||||
"anchor": "patch_force_passthrough",
|
||||
"title": "Force enable passthrough",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_generate_config": {
|
||||
"anchor": "patch_generate_config",
|
||||
"title": "Generate OVRPort config",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_launcher_entry": {
|
||||
"anchor": "patch_launcher_entry",
|
||||
"title": "Fix launcher icon entry",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_mark_allow_backup": {
|
||||
"anchor": "patch_mark_allow_backup",
|
||||
"title": "Mark application to allow backup",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_mark_as_debuggable": {
|
||||
"anchor": "patch_mark_as_debuggable",
|
||||
"title": "Mark application as debuggable",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_meta_xr_audio": {
|
||||
"anchor": "patch_meta_xr_audio",
|
||||
"title": "Patch Meta XR Audio",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_oculus_unity": {
|
||||
"anchor": "patch_oculus_unity",
|
||||
"title": "Patch Oculus detection for Unity",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_oculus_unreal": {
|
||||
"anchor": "patch_oculus_unreal",
|
||||
"title": "Patch Oculus detection for Unreal",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_remove_localized_names": {
|
||||
"anchor": "patch_remove_localized_names",
|
||||
"title": "Remove localized app names",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_remove_unreal_force_quit": {
|
||||
"anchor": "patch_remove_unreal_force_quit",
|
||||
"title": "Remove Unreal's ForceQuit",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_remove_uses_library": {
|
||||
"anchor": "patch_remove_uses_library",
|
||||
"title": "Remove uses-library",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_remove_vrapi": {
|
||||
"anchor": "patch_remove_vrapi",
|
||||
"title": "Remove VrApi library",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_replace_icon_label": {
|
||||
"anchor": "patch_replace_icon_label",
|
||||
"title": "Replace application label and icon",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_vr_metadata": {
|
||||
"anchor": "patch_vr_metadata",
|
||||
"title": "Pico/YVR/Quest metadata",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"patch_vrapi_openxr": {
|
||||
"anchor": "patch_vrapi_openxr",
|
||||
"title": "VrApi → OpenXR adapter (OVRPort)",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"pcvr.launch_args": {
|
||||
"anchor": "pcvr-launch_args",
|
||||
"title": "Launch options (SteamVR or OpenXR)",
|
||||
"group": "PC VR (Proton and Revive)"
|
||||
},
|
||||
"pcvr.libovr_redirect": {
|
||||
"anchor": "pcvr-libovr_redirect",
|
||||
"title": "Provide the Oculus runtime from Revive (Frame)",
|
||||
"group": "PC VR (Proton and Revive)"
|
||||
},
|
||||
"pcvr.no_crash_reporter": {
|
||||
"anchor": "pcvr-no_crash_reporter",
|
||||
"title": "No Unreal crash reporter",
|
||||
"group": "PC VR (Proton and Revive)"
|
||||
},
|
||||
"pcvr.oculus_unreal": {
|
||||
"anchor": "pcvr-oculus_unreal",
|
||||
"title": "Patch Oculus detection for Unreal (Frame)",
|
||||
"group": "PC VR (Proton and Revive)"
|
||||
},
|
||||
"pcvr.proton_env": {
|
||||
"anchor": "pcvr-proton_env",
|
||||
"title": "Extra Proton settings (Frame)",
|
||||
"group": "PC VR (Proton and Revive)"
|
||||
},
|
||||
"pcvr.proton_log": {
|
||||
"anchor": "pcvr-proton_log",
|
||||
"title": "Proton debug log (Frame)",
|
||||
"group": "PC VR (Proton and Revive)"
|
||||
},
|
||||
"pcvr.proton_tool": {
|
||||
"anchor": "pcvr-proton_tool",
|
||||
"title": "Proton version (Frame)",
|
||||
"group": "PC VR (Proton and Revive)"
|
||||
},
|
||||
"pcvr.repack_launcher": {
|
||||
"anchor": "pcvr-repack_launcher",
|
||||
"title": "Use the repack's launcher (bundled Revive)",
|
||||
"group": "PC VR (Proton and Revive)"
|
||||
},
|
||||
"pcvr.revive": {
|
||||
"anchor": "pcvr-revive",
|
||||
"title": "Revive (Oculus LibOVR → OpenXR)",
|
||||
"group": "PC VR (Proton and Revive)"
|
||||
},
|
||||
"pcvr.revive_openvr": {
|
||||
"anchor": "pcvr-revive_openvr",
|
||||
"title": "Revive: SteamVR (OpenVR) backend on PC",
|
||||
"group": "PC VR (Proton and Revive)"
|
||||
},
|
||||
"pcvr.steamvr_tuning": {
|
||||
"anchor": "pcvr-steamvr_tuning",
|
||||
"title": "Automatic SteamVR performance settings (this PC)",
|
||||
"group": "PC VR (Proton and Revive)"
|
||||
},
|
||||
"pcvr.vr_overlay": {
|
||||
"anchor": "pcvr-vr_overlay",
|
||||
"title": "SteamVR overlay app (Frame)",
|
||||
"group": "PC VR (Proton and Revive)"
|
||||
},
|
||||
"pcvr.xr_timefix": {
|
||||
"anchor": "pcvr-xr_timefix",
|
||||
"title": "Frame OpenXR compatibility layer",
|
||||
"group": "PC VR (Proton and Revive)"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,53 @@
|
||||
{
|
||||
"updated": "2026-10-10",
|
||||
"updated": "2026-10-12",
|
||||
"releases": [
|
||||
{
|
||||
"tag": "v1.0.4",
|
||||
"name": "FramePort v1.0.4",
|
||||
"date": "2026-10-11",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.0.4",
|
||||
"dev": false,
|
||||
"notes": "FramePort v1.0.4: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Launch tests no longer report \"Failed to launch\"** for games that run fine but crash once the test ends their VR session because no headset is worn (e.g. BlazeRush).\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
|
||||
},
|
||||
{
|
||||
"tag": "v1.0.3",
|
||||
"name": "FramePort v1.0.3",
|
||||
"date": "2026-10-11",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.0.3",
|
||||
"dev": false,
|
||||
"notes": "FramePort v1.0.3: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **One FramePort at a time:** starting it again brings the open window to the front, and FramePort fully closes with its window.\n- **After an update, FramePort restarts with a working window** instead of a black one (Windows).\n- The update dialogs' \"All releases\" and \"Release page\" buttons work again.\n- When several play sessions are checked at once, one message lists them instead of one per game.\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
|
||||
},
|
||||
{
|
||||
"tag": "v1.0.2",
|
||||
"name": "FramePort v1.0.2",
|
||||
"date": "2026-10-11",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.0.2",
|
||||
"dev": false,
|
||||
"notes": "FramePort v1.0.2: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Progress is kept:** games that save under your Meta user (for example Walkabout Mini Golf) no longer start with an empty profile each time. Click **Update on Frame** on their page.\n- A new troubleshooting FAQ in the docs: where a game's settings are and what to try when a game misbehaves.\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
|
||||
},
|
||||
{
|
||||
"tag": "v1.0.1",
|
||||
"name": "FramePort v1.0.1",
|
||||
"date": "2026-10-11",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.0.1",
|
||||
"dev": false,
|
||||
"notes": "FramePort v1.0.1: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Diagnostics for games that hang:** save diagnostics while the game is stuck, and the zip shows what the game is waiting for.\n- **Clearer launch tests:** when Lepton doesn't start a game, the test says so instead of reporting that the game quit itself.\n- A reference of every patch and game setting is now in the docs.\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
|
||||
},
|
||||
{
|
||||
"tag": "v1.0.0",
|
||||
"name": "FramePort v1.0.0",
|
||||
"date": "2026-10-11",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.0.0",
|
||||
"dev": false,
|
||||
"notes": "FramePort v1.0.0: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **A new look:** Portal and Original themes, an \"On your Frame\" shelf in the Library, and install progress shown as the game passing through a portal.\n- **Easier setup:** run `curl -fsSL https://frameport.app/s | bash` on the Frame and allow it on the PC. A Frame in Developer Mode pairs without a click.\n- **SteamVR overlay apps** (for example Temporal Reality) run on the Frame over your games and can start with SteamVR.\n- **More folders just work:** SideQuest backups and AXRB downloads import with their data files.\n- **Rename any game**, and the name in Steam follows.\n- **Hardware video decoding** for games that play videos (opt-in per game), a Steam Input gamepad for flat Android apps, and Godot games start.\n- **BlazeRush works** (fixes from a tester's guide), and Unreal games no longer hang on their OBB check.\n- **Play sessions are checked too:** after you play, FramePort reads the session's log and suggests fixes on the game's page.\n- **126 game recipes** (76 in 0.12.0), and fixes for Vader Immortal, VR4, Doom3Quest, Star Wars Pinball, Green Hell VR, Budget Cuts and more.\n- The update dialog lists every change since your version. Install buttons now use frameport.app links, and game reports are titled \"[Game report]\".\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
|
||||
},
|
||||
{
|
||||
"tag": "dev",
|
||||
"name": "FramePort dev build 0.12.1.dev253",
|
||||
"date": "2026-10-10",
|
||||
"name": "FramePort dev build 0.12.1.dev263",
|
||||
"date": "2026-10-11",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/dev",
|
||||
"dev": true,
|
||||
"notes": "Dev build **0.12.1.dev253** from commit ca14fad (2026-10-10). For testing fixes before\nthe next release; you get that release as a normal update.\n\n## Please test\n\nControllers as a gamepad for flat Android apps (#162, opt-in); Vulkan shader capture for GPU hangs (vk_shader_dump, #140); Update on Frame after adding a newer APK (#161); 15 new catalog entries.\n\n## Changes since v0.12.0\n\n- Steam Input gamepad for 2D Android apps (device.steam_gamepad, agent v72, GitHub #162)\n- Vulkan shim: vk_shader_dump writes the game's SPIR-V modules with a creation index (GitHub #140)\n- Triage: unity-data-missing fails a launch test when Unity can't read the game's data files (incomplete OBB or one from another version; Batman passed while st\n- Game page shows Update on Frame after a newer APK of an installed game was added (builds record the APK version they were made from; GitHub #161); triage only\n- Catalog: Eleven (Quest) and GOLF+ unsupported (Meta online services / the game's login refuse the platform), Beat Saber DLC isn't detected, Doom3Quest works (\n- Catalog: 13 shared working configs (Freedom, Zombieland VR, Caves, Ancient Dungeon, Containment Protocol, Down the Rabbit Hole, End Space, ExploreVR, Racket: \n- Shared video decoder (codec revision 8): hidden VP9 frames no longer end the EOS drain early\n- Shared video decoder: remove a game's old per-game codec folder once its launcher is converted, also when the recipe (not deployment.json) asks for hardware d\n- Shared video decoder: per game (frame.hw_video_decode) with a global off switch; 8K retry at start and VP9 fixes from the hw-video-decode branch\n- Catalog: Audica works (shared working config)\n- Uninstall dialog: option to also delete the game's saves and everything it stored on the Frame (mods, downloads); they survived every uninstall before (GitHub\n- Install on this PC works on Windows again: the agent (loaded for its VDF code) imported the POSIX-only fcntl at the top (ModuleNotFoundError for every PC VR i\n- Deploy shared hardware video decoding for compatible Lepton apps\n- Codec wrapper (PR #96): find the host's podman when Lepton calls it with the Android guest's PATH (its boot-wait, app-pid and logcat-mirror podman exec calls \n- Catalog: Doom3Quest uses frame.gl_multiview_fbo (HUD/PDA shown, no frame-rate drop: Xandrix1987's headset test, GitHub #77)\n- Catalog: cubism works (shared working config)\n- SUPERHOT VR (Quest): create its cloud save folder at install, so the launcher gives it group write permission before the very first start (the game creates an\n- Catalog: Vader Immortal III's campaign intro starts black by design (owner)\n- Catalog: Vader Immortal Episodes II and III work (owner's headset test) with Episode I's Unreal fixes (quest precompile, key map, thumb touch, pose_time_fix),\n- Docs: session triage (PLAYBOOK rows for the session findings, CLAUDE.md note incl. the pac_hints survey)\n- Triage real play sessions: the connection refresh fetches a finished session's log (agent v70) and triages it (launch-test signatures + new space-warp-used qu\n- Agent v70: session_log (the newest play session's launch.log sliced to 4 MB + its crash logcat + kernel GPU hang lines), last_play in list_installed, launch t\n- FrameBridge: always log the runtime's focus losses and returns with their length (focus: lost / back after N ms), before focus_hold hides a dip; for session t\n- Heuristics: suggest pose_time_fix for Unreal 4 games with Vader Immortal's Oculus input (the unreal_thumb_touch code match). Catalog: Phantom, Robo Recall and\n- Harden codec deployment and isolate native video hooks\n- Catalog: Clockwork (Quest), Beat Saber, Eleven: Table Tennis VR and Pistol Whip (PC VR, run directly without Revive) from shared working configs\n- Heuristics: suggest scene_emul for every game that declares Meta's USE_SCENE permission, not only mixed-reality-only ones (VR HOT's room setup retried forever\n- Agent v68: launch tests count their window from Lepton's 'Waiting for app' (up to 240 s more for boot + install). The first start after an APK change installs\n- frame.unreal_thumb_touch: UE4 OculusInput's ThumbUp from the capacitive touches instead of near-touch, which the Frame never reports (thumbs always pointed up\n- pose_time_fix: never move a recent predicted display time; monotonic times only told apart when the clocks are more than 4 display periods apart and the time \n- Lint: extraneous parentheses (ruff UP034)\n- Game page: a patch the build left out because the OVRPort runtime already has the fix (build.superseded, e.g. haptic_fix with runtime 3.4.3-aa54c3f) no longer\n- Triage: Lepton's own short lines survive the game-process filter. With the package known, game_lines dropped every line of 3 words or fewer, so Lepton's 'Boot\n- Catalog: Max Mustard is listed by its name, not its package name Matilda (GitHub #110)\n- frame.gl_multiview_fbo: single-view twins for multiview programs drawn into flat framebuffers Line truncated
|
||||
"notes": "Dev build **0.12.1.dev263** from commit dccdcaa (2026-10-11). For testing fixes before\nthe next release; you get that release as a normal update.\n\n## Please test\n\n1.0 release candidate. New since dev261: SteamVR overlay apps (for example Temporal Reality's Linux arm64 build) installed with Add a Linux app run on the Frame over your games, with a Start with SteamVR switch.\n\n## Changes since v0.12.0\n\n- SteamVR overlay apps on the Frame (agent v75)\n- Sharing: fall back to a plain issue when the form opens empty (#167)\n- Monitor: per-module collection needs agent 73 (main's 72 lacks it); correct agent versions in comments\n- serialize_flet_updates: tolerate Flet versions without Session.patch_control or Prop (log, keep the rest)\n- Issue forms: quote the descriptions so the templates are valid YAML\n- Auto pairing scan: skip container/VM/VPN interfaces and CGNAT, back off after empty scans\n- pyproject: Development Status 5 - Production/Stable\n- Catalog: Vacation Simulator starts; controllers aren't tracked yet (shared working config)\n- Catalog: The Thrill Of The Fight runs with Unity's overlay copy skipped; the right eye misses the opponent and referee (shared working config)\n- Catalog: Fruit Salon works (shared working config)\n- Auto pairing: hold off only while a setup request is open (not for allowed, denied or expired ones)\n- Docs: diagrams instead of ASCII art\n- Install links: FramePort's https://frameport.app/install?… page is the documented button link (pasted links parse too); README, docs, site and in-app texts \n- Pairing: setup requests expire when the Frame stops polling; setup.sh explains HTTP 429\n- Diagnostics: send every shader module of the newest session (#140)\n- CLI frame pair: don't announce the pairing server over mDNS (it can't answer setup-URL requests)\n- Issue reports: a game's report is titled \"[Game report] <game>\" with the game-compatibility label; reports about FramePort itself \"[App] FramePort\" with bug (\n- Setup line: curl -fsSL https://frameport.app/s (fail on HTTP errors, explicit https)\n- Site: Cloudflare and Google analytics, with a cookie choice\n- Docs: update screenshots and videos\n- Site: SEO for frameport.app\n- Site: What's new page and section from GitHub releases\n- Site: shorter, consistent copy, served at frameport.app\n- Docs: shorter, consistent copy\n- App: shorter, consistent interface text\n- Developer docs, CLI and launch-test diagnoses: consistent terms\n- Steam Input gamepad for 2D Android apps (device.steam_gamepad, agent v72, GitHub #162)\n- Wording: style word list and length rules\n- Vulkan shim: vk_shader_dump writes the game's SPIR-V modules with a creation index (GitHub #140)\n- Triage: unity-data-missing fails a launch test when Unity can't read the game's data files (incomplete OBB or one from another version; Batman passed while st\n- Game page shows Update on Frame after a newer APK of an installed game was added (builds record the APK version they were made from; GitHub #161); triage only\n- Catalog: Eleven (Quest) and GOLF+ unsupported (Meta online services / the game's login refuse the platform), Beat Saber DLC isn't detected, Doom3Quest works (\n- Catalog: 13 shared working configs (Freedom, Zombieland VR, Caves, Ancient Dungeon, Containment Protocol, Down the Rabbit Hole, End Space, ExploreVR, Racket: \n- Site: two neon worlds, a portal-first download page, one-eye live view\n- Game page: open the last launch test's log\n- Site: interactive docs, a download page, two more worlds, solid rocks\n- Site: three hero worlds, a 3D live view, a closer app trial, real screenshots\n- Install walkthrough: the setup line and Allow, in the video and on the site\n- Pair Frames in Developer Mode without a click on the PC\n- Shared video decoder (codec revision 8): hidden VP9 frames no longer end the EOS drain early\n"
|
||||
},
|
||||
{
|
||||
"tag": "v0.12.0",
|
||||
|
||||
@@ -104,7 +104,7 @@ const count = (n: number) => (n === 1 ? '1 change' : `${n} changes`);
|
||||
{RELEASES.length === 0 && <p class="lead">No releases yet. See <a href={`${REPO}/releases`}>GitHub</a>.</p>}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
<Footer credits="contributors" />
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -45,6 +45,8 @@ const prev = DOCS[i - 1], next = DOCS[i + 1];
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
/* long paths and identifiers (the patch reference has many) wrap instead of widening the page on phones */
|
||||
.prose :global(:is(p, li, blockquote)) { overflow-wrap: break-word; }
|
||||
.crumb { font-size: 13px; color: var(--text-2); margin-bottom: 10px; }
|
||||
.crumb a { color: var(--text-2); }
|
||||
.crumb span { margin: 0 4px; color: var(--text-3); }
|
||||
|
||||
@@ -60,5 +60,5 @@ const logGames = games.filter((g) => g.status !== 'unsupported' && g.platform ==
|
||||
<Install />
|
||||
<WhatsNew />
|
||||
</main>
|
||||
<Footer />
|
||||
<Footer credits="all" />
|
||||
</Layout>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rowHtml, short, sortGames, toGame } from './catalog.ts';
|
||||
import { rowHtml, short, shuffleGames, sortGames, toGame } from './catalog.ts';
|
||||
|
||||
test('catalog entries become rows like docs/GAMES.md', () => {
|
||||
assert.equal(toGame({ title: 'x', status: 'maybe' }), null);
|
||||
@@ -26,9 +26,33 @@ test('order: works, issues, unsupported, then title', () => {
|
||||
assert.deepEqual(sortGames(games).map((g) => g.title), ['a', 'z', 'a', 'b']);
|
||||
});
|
||||
|
||||
test('shuffle: status groups stay in order, a random order inside, kept across calls', () => {
|
||||
const games = ['unsupported:b', 'works:z', 'issues:a', 'works:a', 'works:m', 'issues:q'].map((s) => {
|
||||
const [status, title] = s.split(':');
|
||||
return toGame({ status, title, package: `p.${status}.${title}` });
|
||||
});
|
||||
const seq = [0.9, 0.1, 0.5, 0.3, 0.7, 0.2];
|
||||
const rank = new Map();
|
||||
const first = shuffleGames(games, rank, () => seq.shift());
|
||||
assert.deepEqual(first.map((g) => g.status), ['works', 'works', 'works', 'issues', 'issues', 'unsupported']);
|
||||
assert.deepEqual(first.filter((g) => g.status === 'works').map((g) => g.title), ['z', 'a', 'm']);
|
||||
// the same page reorders the same way (the live refresh), new games get their own place
|
||||
const again = shuffleGames([...games].reverse(), rank, () => 0.0);
|
||||
assert.deepEqual(again.map((g) => g.title), first.map((g) => g.title));
|
||||
});
|
||||
|
||||
test('rows escape what comes from GitHub', () => {
|
||||
const html = rowHtml(toGame({ title: '<img src=x onerror=alert(1)>', status: 'works', engine: '"x' }));
|
||||
assert.ok(!html.includes('<img'));
|
||||
assert.ok(html.includes('<img'));
|
||||
assert.ok(html.includes('data-engine=""x"'));
|
||||
});
|
||||
|
||||
test('recipe chips link to the patch reference when a link is given', () => {
|
||||
const g = toGame({ title: 'A', status: 'works', frame: ['frame.x', 'frame.unknown'] });
|
||||
const link = (id) => (id === 'frame.x' ? '/docs/patches/#frame-x' : null);
|
||||
const html = rowHtml(g, false, link);
|
||||
assert.match(html, /<a href="\/docs\/patches\/#frame-x" [^>]*><code>frame\.x<\/code><\/a>/);
|
||||
assert.match(html, /<code>frame\.unknown<\/code>/);
|
||||
assert.doesNotMatch(rowHtml(g), /<a /);
|
||||
});
|
||||
@@ -69,15 +69,31 @@ export function sortGames(games: Game[]): Game[] {
|
||||
|| a.title.localeCompare(b.title, 'en', { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
/** Working games first, then with issues, then not running; a random order inside each group, so every visit leads
|
||||
* with other games. `rank` keeps each game's place for the page's lifetime (the live refresh doesn't reshuffle). */
|
||||
export function shuffleGames(games: Game[], rank = new Map<string, number>(), rand: () => number = Math.random): Game[] {
|
||||
const key = (g: Game) => g.package || g.title;
|
||||
for (const g of games) if (!rank.has(key(g))) rank.set(key(g), rand());
|
||||
return [...games].sort((a, b) => ORDER[a.status] - ORDER[b.status] || rank.get(key(a))! - rank.get(key(b))!);
|
||||
}
|
||||
|
||||
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||
|
||||
export function rowHtml(g: Game, showPackage = false): string {
|
||||
/** Where a recipe chip links to (its entry in the patch reference), or null for none. */
|
||||
export type PatchLink = (id: string) => string | null;
|
||||
|
||||
const chip = (p: string, link?: PatchLink) => {
|
||||
const href = link?.(p);
|
||||
return href ? `<a href="${esc(href)}" title="What this patch does"><code>${esc(p)}</code></a>` : `<code>${esc(p)}</code>`;
|
||||
};
|
||||
|
||||
export function rowHtml(g: Game, showPackage = false, link?: PatchLink): string {
|
||||
const s = STATUS[g.status];
|
||||
const facts = [g.version && `tested with version ${g.version}`, g.app && `checked with FramePort ${g.app}`, g.checked]
|
||||
.filter(Boolean).join(' · ');
|
||||
const more = `<div class="more" role="cell" hidden>`
|
||||
+ (g.details ? `<p>${esc(g.details)}</p>` : '<p>Runs with the default patches; nothing special needed.</p>')
|
||||
+ (g.patches.length ? `<p class="pt"><span>Recipe</span>${g.patches.map((p) => `<code>${esc(p)}</code>`).join('')}</p>` : '')
|
||||
+ (g.patches.length ? `<p class="pt"><span>Recipe</span>${g.patches.map((p) => chip(p, link)).join('')}</p>` : '')
|
||||
+ (facts ? `<p class="fx">${esc(facts)}</p>` : '') + '</div>';
|
||||
return `<li class="row" role="row" tabindex="0" aria-expanded="false" data-status="${g.status}" data-engine="${esc(g.engine)}" `
|
||||
+ `data-text="${esc(`${g.title} ${g.engine} ${g.xr} ${g.platform} ${g.patches.join(' ')}`.toLowerCase())}">`
|
||||
@@ -92,11 +108,11 @@ export function rowHtml(g: Game, showPackage = false): string {
|
||||
}
|
||||
|
||||
/** All rows; games that share a title and platform (e.g. two builds) show their package to tell them apart. */
|
||||
export function rowsHtml(games: Game[]): string {
|
||||
export function rowsHtml(games: Game[], link?: PatchLink): string {
|
||||
const seen = new Map<string, number>();
|
||||
const key = (g: Game) => `${g.title.toLowerCase()}|${g.platform}`;
|
||||
for (const g of games) seen.set(key(g), (seen.get(key(g)) ?? 0) + 1);
|
||||
return games.map((g) => rowHtml(g, seen.get(key(g))! > 1)).join('');
|
||||
return games.map((g) => rowHtml(g, seen.get(key(g))! > 1, link)).join('');
|
||||
}
|
||||
|
||||
export function counts(games: Game[]): Record<Status, number> {
|
||||
|
||||
@@ -25,10 +25,12 @@ function cursorLight() {
|
||||
document.addEventListener('pointerleave', () => light.classList.remove('on'));
|
||||
}
|
||||
|
||||
/** Clicking empty space: the app's Play ripple, a blue ring then an orange one. */
|
||||
/** Clicking empty space: the app's Play ripple, a blue ring then an orange one. Mouse (and pen) only: on a touch
|
||||
* screen every tap or scroll start would draw it. */
|
||||
function clickRipples() {
|
||||
if (reduce) return;
|
||||
addEventListener('pointerdown', (e) => {
|
||||
if (e.pointerType === 'touch') return;
|
||||
const t = e.target as Element;
|
||||
if (t.closest('a, button, input, select, textarea, label, summary, [data-mock], [data-crossing], canvas, [role="button"], .row, [tabindex]')) return;
|
||||
for (const [i, cls] of ['b', 'o'].entries()) {
|
||||
@@ -129,17 +131,35 @@ export function countUp(root: ParentNode = document) {
|
||||
root.querySelectorAll<HTMLElement>('[data-count]').forEach((el) => io.observe(el));
|
||||
}
|
||||
|
||||
/** Scrolling away from the top flies the hero toward its portal; the hero's own scene leans in. */
|
||||
/** Scrolling away flies the hero toward its portal; the hero's own scene leans in (--sp, 0..1, from the page's
|
||||
* scroll over the hero's height). Stacked (phones, narrow windows) the scene sits below the text: measured that way it
|
||||
* was already blurred and enlarged when it came into view and unreadable in the middle of the screen, so there it
|
||||
* follows its own position: 0 until its middle rises above 40 % of the screen's height, 1 once it has left at the top. */
|
||||
function heroScroll() {
|
||||
const hero = document.querySelector<HTMLElement>('.hero');
|
||||
if (!hero || reduce) return;
|
||||
const copy = hero?.querySelector<HTMLElement>('.copy');
|
||||
const scene = hero?.querySelector<HTMLElement>('.scene');
|
||||
if (!hero || !copy || !scene || reduce) return;
|
||||
const stacked = matchMedia('(max-width: 900px)'); // Hero.astro's one-column breakpoint
|
||||
let raf = 0;
|
||||
const update = () => {
|
||||
raf = 0;
|
||||
const p = Math.min(1, Math.max(0, scrollY / Math.max(1, hero.offsetHeight)));
|
||||
hero.style.setProperty('--sp', p.toFixed(3));
|
||||
const page = Math.min(1, Math.max(0, scrollY / Math.max(1, hero.offsetHeight)));
|
||||
copy.style.setProperty('--sp', page.toFixed(3));
|
||||
let p = page;
|
||||
if (stacked.matches) {
|
||||
// the layout box, not the transformed one (the transform is what --sp drives)
|
||||
const parent = (scene.offsetParent as HTMLElement | null) ?? document.body;
|
||||
const centre = parent.getBoundingClientRect().top + scene.offsetTop + scene.offsetHeight / 2;
|
||||
// counting starts a little above the middle of the screen (fully sharp around the middle), or where the scene
|
||||
// sits at the top of the page if that's higher, so it never starts out blurred
|
||||
const start = Math.min(innerHeight * 0.4, centre + scrollY);
|
||||
p = Math.min(1, Math.max(0, (start - centre) / (start + scene.offsetHeight / 2)));
|
||||
}
|
||||
scene.style.setProperty('--sp', p.toFixed(3));
|
||||
};
|
||||
addEventListener('scroll', () => { if (!raf) raf = requestAnimationFrame(update); }, { passive: true });
|
||||
addEventListener('resize', update);
|
||||
update();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Patch ids → their entry in the patch reference (docs/PATCHES.md → /docs/patches/). The map is written by
|
||||
// scripts/patch_docs.py next to the markdown, so the site needs no Python to build.
|
||||
import map from '../data/patches.json';
|
||||
import { url } from '../data/site';
|
||||
|
||||
export interface PatchInfo { anchor: string; title: string; group: string }
|
||||
const PATCHES = map as Record<string, PatchInfo>;
|
||||
/** The patch reference page itself. */
|
||||
export const PATCH_DOC = url('docs/patches/');
|
||||
|
||||
/** The registry id behind a recipe reference: "overport.patch_x" (the board's OVRPort prefix) → "patch_x", lepton_env
|
||||
* keys ("env.X") → device.lepton_env, "adapter.scale=0.85" → "adapter.scale". */
|
||||
export function patchId(ref: string): string {
|
||||
const id = ref.trim().split('=')[0];
|
||||
if (id.startsWith('overport.')) return id.slice('overport.'.length);
|
||||
if (id.startsWith('env.')) return 'device.lepton_env';
|
||||
return id;
|
||||
}
|
||||
|
||||
export function patchInfo(ref: string): PatchInfo | undefined {
|
||||
return PATCHES[patchId(ref)];
|
||||
}
|
||||
|
||||
/** The site link to a patch's entry, or null for an id the reference doesn't know. */
|
||||
export function patchHref(ref: string): string | null {
|
||||
const info = patchInfo(ref);
|
||||
return info ? `${PATCH_DOC}#${info.anchor}` : null;
|
||||
}
|
||||
@@ -213,6 +213,7 @@ code, kbd, pre { font-family: var(--font-mono); }
|
||||
.click-ring { position: fixed; z-index: 95; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; pointer-events: none;
|
||||
border: 1px solid var(--secondary); opacity: 0; animation: click-ring .55s var(--ease-out) both; }
|
||||
.click-ring.o { border-color: var(--accent); }
|
||||
@media (hover: none) { .click-ring { display: none; } }
|
||||
@keyframes click-ring { 0% { opacity: .45; transform: scale(1); } 100% { opacity: 0; transform: scale(2.6); } }
|
||||
|
||||
/* headings: words rise in, the pointer lights them in the portal's colours */
|
||||
|
||||
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 229 KiB |
@@ -1,2 +1,2 @@
|
||||
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.0.4"
|
||||
@@ -0,0 +1,139 @@
|
||||
"""One FramePort window at a time (no Flet).
|
||||
|
||||
Two GUI processes on one data folder fought over library.json and both re-analysed the library and triaged the same
|
||||
play sessions (the Library flickered under a stream of pop-ups). The GUI holds an exclusive lock on
|
||||
`<data>/gui.lock` for its lifetime (released by the OS when the process ends, also after a crash). A second start:
|
||||
- the window is there (fresh `gui.alive` heartbeat): asks it to come to the front (`gui.focus`) and exits;
|
||||
- the lock is held but no window answers (a process left over after its window went away): ends that process (only
|
||||
when it is a FramePort GUI) and takes over.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import user_data_dir
|
||||
|
||||
_held = None # the open lock file (kept for the process lifetime)
|
||||
|
||||
|
||||
def lock_file() -> Path:
|
||||
return user_data_dir() / "gui.lock"
|
||||
|
||||
|
||||
def focus_file() -> Path:
|
||||
return user_data_dir() / "gui.focus"
|
||||
|
||||
|
||||
def _try_lock(f) -> bool:
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
f.seek(0)
|
||||
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def acquire(path: Path | None = None) -> bool:
|
||||
"""Take the GUI lock; False when another process holds it."""
|
||||
global _held
|
||||
path = path or lock_file()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
f = open(path, "a+") # noqa: SIM115 (kept open on purpose)
|
||||
if not _try_lock(f):
|
||||
f.close()
|
||||
return False
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
f.write(str(os.getpid()))
|
||||
f.flush()
|
||||
_held = f
|
||||
return True
|
||||
|
||||
|
||||
def holder(path: Path | None = None) -> int | None:
|
||||
try:
|
||||
return int((path or lock_file()).read_text().strip() or 0) or None
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _is_frameport_gui(pid: int) -> bool:
|
||||
try:
|
||||
import psutil
|
||||
|
||||
cmd = " ".join(psutil.Process(pid).cmdline()).lower()
|
||||
except Exception: # noqa: BLE001 (gone, or not ours to inspect)
|
||||
return False
|
||||
return "frameport" in cmd
|
||||
|
||||
|
||||
STARTUP_GRACE = 30.0 # a GUI this young may still be opening its window (no heartbeat yet): never end it
|
||||
|
||||
|
||||
def end_leftover(pid: int, wait: float = 5.0) -> bool:
|
||||
"""End a FramePort GUI process that has no window any more; True when it's gone."""
|
||||
if pid == os.getpid() or not _is_frameport_gui(pid):
|
||||
return False
|
||||
import psutil
|
||||
|
||||
try:
|
||||
p = psutil.Process(pid)
|
||||
if time.time() - p.create_time() < STARTUP_GRACE:
|
||||
return False
|
||||
p.terminate()
|
||||
try:
|
||||
p.wait(wait)
|
||||
except psutil.TimeoutExpired:
|
||||
p.kill()
|
||||
p.wait(wait)
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def ask_focus() -> None:
|
||||
try:
|
||||
focus_file().touch()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def take_focus_request() -> bool:
|
||||
"""True once after another start asked the window to come to the front."""
|
||||
try:
|
||||
focus_file().unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def claim(window_open, retries: int = 20) -> bool:
|
||||
"""Called at GUI start: True = this process is the GUI; False = another window is open (it was asked to come to
|
||||
the front). `window_open()` says whether a window answers (urlhandler.app_running)."""
|
||||
if acquire():
|
||||
return True
|
||||
if window_open():
|
||||
ask_focus()
|
||||
return False
|
||||
pid = holder()
|
||||
if pid:
|
||||
end_leftover(pid)
|
||||
for _ in range(retries): # the OS releases the lock when the process is gone
|
||||
if acquire():
|
||||
return True
|
||||
time.sleep(0.25)
|
||||
if window_open():
|
||||
ask_focus()
|
||||
return False
|
||||
@@ -10,7 +10,7 @@ from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
from .models import Analysis, Recipe
|
||||
from .paths import user_data_dir, write_atomic
|
||||
from .paths import localize, user_data_dir, write_atomic
|
||||
|
||||
# One lock for every read-modify-write of library.json: jobs, the poll thread and the UI all change it, and a change
|
||||
# made between another thread's load() and save() would be lost. Re-entrant so edit() can call load()/save().
|
||||
@@ -28,7 +28,7 @@ _updating = threading.local() # set while load() runs the migrations/catalog st
|
||||
def load() -> dict:
|
||||
with _lock:
|
||||
try:
|
||||
data = json.loads(_path().read_text(encoding="utf-8"))
|
||||
data = localize(json.loads(_path().read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError):
|
||||
return {"games": {}, "settings": {}}
|
||||
# The catalog step loads the catalog, whose first load reads a setting: that nested load() must not run the
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
@@ -45,10 +46,34 @@ def _on_frame() -> bool:
|
||||
return on_frame()
|
||||
|
||||
|
||||
def shares_windows_data() -> bool:
|
||||
"""WSL with FRAMEPORT_WINDOWS_DATA=1: use the Windows FramePort's data folder (one library for a Windows copy and
|
||||
WSL source runs; paths the other side saved are translated by `localize`)."""
|
||||
if not os.environ.get("FRAMEPORT_WINDOWS_DATA") or os.environ.get("FRAMEPORT_HOME"):
|
||||
return False
|
||||
from .winhost import is_wsl
|
||||
|
||||
return is_wsl()
|
||||
|
||||
|
||||
_win_data: list = [] # cached: finding %LOCALAPPDATA% from WSL starts cmd.exe
|
||||
|
||||
|
||||
def _windows_data_dir() -> Path | None:
|
||||
if not _win_data:
|
||||
from .winhost import env_path
|
||||
|
||||
local = env_path("LOCALAPPDATA")
|
||||
_win_data.append(local / "FramePort" if local else None)
|
||||
return _win_data[0]
|
||||
|
||||
|
||||
def user_data_dir() -> Path:
|
||||
"""Tools, keystores, work dirs and settings. Override with FRAMEPORT_HOME."""
|
||||
if os.environ.get("FRAMEPORT_HOME"):
|
||||
base = Path(os.environ["FRAMEPORT_HOME"])
|
||||
elif shares_windows_data() and _windows_data_dir() is not None:
|
||||
base = _windows_data_dir()
|
||||
elif sys.platform == "win32":
|
||||
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "FramePort"
|
||||
elif sys.platform == "darwin":
|
||||
@@ -63,6 +88,37 @@ def user_data_dir() -> Path:
|
||||
return base
|
||||
|
||||
|
||||
_WSL_DRIVE = re.compile(r"^/mnt/([a-zA-Z])(?:/(.*))?$")
|
||||
_WIN_DRIVE = re.compile(r"^([a-zA-Z]):[\\/](.*)$")
|
||||
|
||||
|
||||
def local_path(p: str) -> str:
|
||||
"""A path saved by FramePort on the other side of WSL in this side's spelling: /mnt/d/X -> D:\\X on Windows, and
|
||||
D:\\X -> /mnt/d/X in WSL runs on the Windows copy's data (`shares_windows_data`); anything else unchanged.
|
||||
Plain string mapping (drive letters only), so it is cheap enough for whole files."""
|
||||
if sys.platform == "win32":
|
||||
m = _WSL_DRIVE.match(p)
|
||||
return f"{m[1].upper()}:\\" + (m[2] or "").replace("/", "\\") if m else p
|
||||
if shares_windows_data():
|
||||
m = _WIN_DRIVE.match(p)
|
||||
if m:
|
||||
return f"/mnt/{m[1].lower()}" + ("/" + m[2].replace("\\", "/") if m[2] else "")
|
||||
return p
|
||||
|
||||
|
||||
def localize(obj):
|
||||
"""`local_path` applied to every string in loaded JSON (library.json, deployment records, tools.json): a data
|
||||
folder shared by a Windows copy and WSL source runs holds paths from both."""
|
||||
if isinstance(obj, str):
|
||||
return local_path(obj) if obj[:1] in "/" or obj[1:3] in (":\\", ":/") else obj
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
obj[k] = localize(v)
|
||||
elif isinstance(obj, list):
|
||||
obj[:] = [localize(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
_removed = threading.Event()
|
||||
|
||||
|
||||
|
||||
@@ -178,8 +178,9 @@ def _problem_fields(g: dict | None, description: str, env: dict, bundle_name: st
|
||||
a = g.get("analysis") or {}
|
||||
game = f"{title} — {g['package']} (version {a.get('version') or '?'}, {a.get('engine') or '?'} / " \
|
||||
f"{a.get('xr') or '?'})"
|
||||
logs = f"⬇ Drag and drop {bundle_name} (the diagnostics zip FramePort just saved) into this box." \
|
||||
if bundle_name else ""
|
||||
# some browsers don't let the prefilled box take a file (GitHub #196): the comment route always works
|
||||
logs = f"⬇ Drag and drop {bundle_name} (the diagnostics zip FramePort just saved) into this box. If it won't " \
|
||||
"take the file, submit the report and attach the zip in a comment." if bundle_name else ""
|
||||
return {
|
||||
# a game's report is about the game on the Frame, not a FramePort fault (titles read as a list of app bugs)
|
||||
"title": red.text(f"[Game report] {title}" if g else "[App] FramePort"),
|
||||
|
||||
@@ -179,13 +179,19 @@ class History:
|
||||
self.series.clear()
|
||||
|
||||
|
||||
def main_game(sample: dict | None) -> dict | None:
|
||||
"""The game in front: the agent lists games newest first with SteamVR overlay apps (they run next to games, e.g.
|
||||
a desktop overlay) last; an overlay alone isn't a game being played. Older agents: the first non-overlay."""
|
||||
return next((g for g in (sample or {}).get("games") or [] if not g.get("overlay")), None)
|
||||
|
||||
|
||||
def series_of(sample: dict) -> dict[str, float | None]:
|
||||
"""The values one sample adds to the History."""
|
||||
mem = sample.get("mem") or {}
|
||||
total = mem.get("total") or 0
|
||||
temps = sample.get("temps") or {}
|
||||
bat = sample.get("battery") or {}
|
||||
game = (sample.get("games") or [None])[0] or {}
|
||||
game = main_game(sample) or {}
|
||||
extra = {f"temp:{g}": v for g, v in temps.items()}
|
||||
if sample.get("fan") is not None:
|
||||
extra["fan"] = sample["fan"]
|
||||
|
||||
@@ -538,6 +538,10 @@
|
||||
"FramePort isn't sure which program starts this game. Open the game and use … → Change program… to pick it.": "",
|
||||
"FramePort keeps its own copies; nothing is installed system-wide": "",
|
||||
"FramePort live view": "",
|
||||
"FramePort looked at your last session of {n} game and has something to try.": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"FramePort looked at your last session of {title} and has something to try.": "",
|
||||
"FramePort picked {value} to start this game, but isn't sure. Check it before installing.": "",
|
||||
"FramePort saves a diagnostics zip (no game files or personal data) and opens a GitHub issue. Drag the zip into it and submit.": "",
|
||||
@@ -740,6 +744,7 @@
|
||||
"Keep menus in place": "",
|
||||
"Keep pictures in bounds": "",
|
||||
"Keep the game's saved resolution in step": "",
|
||||
"Keep the same Meta user between starts": "",
|
||||
"Keep the signing keys: without them, updating a game loses its saves.": "",
|
||||
"Keeps a smoothing setting off that the game turns on itself and that can freeze the Frame.": "",
|
||||
"Keeps every submitted image rect inside its swapchain. Unity can size the eye area a few pixels past the image on some Frames; SteamVR then rejects every frame (xrEndFrame -25, XR_ERROR_SWAPCHAIN_RECT_INVALID) and the game stops drawing (for example PowerWash Simulator stuck at \"Waiting\").": "",
|
||||
@@ -747,6 +752,7 @@
|
||||
"Keeps one label so the title is stable.": "",
|
||||
"Keeps one name for the game, so it shows the same everywhere.": "",
|
||||
"Keeps the resolution the game saves in step with the headset's picture size (else the picture is shifted, with black borders, after a sharpness change).": "",
|
||||
"Keeps your progress in games that file their saves under your Meta user.": "",
|
||||
"Keyboard connected to {label}.": "",
|
||||
"Keyboard disconnected: {error}": "",
|
||||
"KiB": "",
|
||||
@@ -988,6 +994,7 @@
|
||||
"Open in browser": "",
|
||||
"Open the Monitor": "",
|
||||
"Open {scheme}:// links with FramePort?": "",
|
||||
"Open {title}": "",
|
||||
"OpenGL ES games run through a translation to Vulkan that is stricter than Quest drivers. Vulkan games run directly.": "",
|
||||
"OpenGL ES shader dump (diagnostics)": "",
|
||||
"OpenGL ES shader fixes": "",
|
||||
@@ -1384,6 +1391,7 @@
|
||||
"Steam Frame": "",
|
||||
"Steam Frame patches": "",
|
||||
"Steam Frame: installed on the Frame, with an entry in its Steam library. This PC: a Steam shortcut that starts the game through Revive, for a headset connected to this PC.": "",
|
||||
"Steam game": "",
|
||||
"Steam on the Frame downloads Proton (about 1 GiB). Steam restarts once, which closes a running game.": "",
|
||||
"Steam on the Frame isn't responding. Restart the Frame and try again.": "",
|
||||
"SteamOS · Developer Mode": "",
|
||||
@@ -1738,6 +1746,7 @@
|
||||
"Windows' firewall blocks the Frame on public networks. Set this network to Private in Windows' network settings.": "",
|
||||
"Windows' firewall for WSL blocks the Frame. Show the setup command again and allow the change when Windows asks.": "",
|
||||
"With sound": "",
|
||||
"Without a Meta account, OVRPort's platform library makes up a new user id at every start (ovr_GetLoggedInUserID). Games that file saves under the user id (for example Walkabout Mini Golf) then start with an empty profile each time and seem to lose all progress. Gives the game a fixed id instead, different for each game and each FramePort user.": "",
|
||||
"Working": "",
|
||||
"Working…": "",
|
||||
"Works": "",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""OVRPort's platform loader gives the game a new Meta user id at every start, so games lose their progress.
|
||||
|
||||
Without a Meta account on the device, OVRPort's `ovr_GetLoggedInUserID` (and the logged-in user's `ovr_User_GetID`)
|
||||
answer with an id it makes up: 6e18 + a value from `random()`, kept only in memory. Games that file their saves under
|
||||
the user id (Walkabout Mini Golf: `Profiles/Oculus/<user id>/`, GitHub #182) open an empty profile at every start, so
|
||||
their progress seems gone (the old saves stay on the Frame, under the earlier ids).
|
||||
|
||||
This patch rewrites that helper to return a fixed id instead: 6e18 + 32 bits of a hash of the game's package name and
|
||||
its signing keystore. The id stays the same for every build and reinstall of the game (the keystore never changes), and
|
||||
differs between games and between FramePort users (each user's keystores are their own), so games that use the id
|
||||
online don't see two players as one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
from ...analysis import elf
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
|
||||
LOADER = "libovrplatformloader.so"
|
||||
BASE_ID = 0x53444835EC580000 # 6e18: OVRPort's own base for made-up ids
|
||||
|
||||
STP = 0xA9BF7BFD # stp x29, x30, [sp, #-16]!
|
||||
MOV_FP = 0x910003FD # mov x29, sp
|
||||
BL, BL_MASK = 0x94000000, 0xFC000000
|
||||
# the end of the helper, which loads 6e18 into x9 and adds it (same bytes in OVRPort runtimes 3.4.3-23204ea and
|
||||
# 3.4.3-aa54c3f)
|
||||
MOV_X9_LO = 0xD2BD8B09 # mov x9, #0xec580000
|
||||
MOVK_X9_32 = 0xF2C906A9 # movk x9, #0x4835, lsl #32
|
||||
MOVK_X9_48 = 0xF2EA6889 # movk x9, #0x5344, lsl #48
|
||||
ADD_X0 = 0x8B090100 # add x0, x8, x9
|
||||
LDP = 0xA8C17BFD # ldp x29, x30, [sp], #16
|
||||
RET = 0xD65F03C0
|
||||
SIZE = 19 # instructions in the helper, stp … ret
|
||||
|
||||
|
||||
def _words(data: bytes | bytearray, off: int, n: int) -> list[int]:
|
||||
return [int.from_bytes(data[off + 4 * i:off + 4 * i + 4], "little") for i in range(n)]
|
||||
|
||||
|
||||
def _text_ranges(data: bytes) -> list[tuple[int, int]]:
|
||||
"""File ranges of executable PT_LOAD segments."""
|
||||
try:
|
||||
e = elf._elf(data)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
return [(s["p_offset"], s["p_offset"] + s["p_filesz"]) for s in e.iter_segments()
|
||||
if s["p_type"] == "PT_LOAD" and s["p_flags"] & 1]
|
||||
|
||||
|
||||
def find_helper(data: bytes) -> int | None:
|
||||
"""File offset of OVRPort's made-up-user-id helper (stp; mov x29,sp; bl random; …; mov/movk x9 = 6e18; add x0,
|
||||
x8, x9; ldp; ret), or None. Exactly one match is required."""
|
||||
tail = [MOV_X9_LO, MOVK_X9_32, MOVK_X9_48, ADD_X0, LDP, RET]
|
||||
found = []
|
||||
for start, end in _text_ranges(data):
|
||||
off = data.find(MOV_X9_LO.to_bytes(4, "little") + MOVK_X9_32.to_bytes(4, "little"), start, end)
|
||||
while off != -1:
|
||||
head = off - 4 * (SIZE - len(tail))
|
||||
if head >= start and _words(data, off, len(tail)) == tail:
|
||||
w = _words(data, head, 3)
|
||||
if w[0] == STP and w[1] == MOV_FP and w[2] & BL_MASK == BL:
|
||||
found.append(head)
|
||||
off = data.find(MOV_X9_LO.to_bytes(4, "little") + MOVK_X9_32.to_bytes(4, "little"), off + 4, end)
|
||||
return found[0] if len(found) == 1 else None
|
||||
|
||||
|
||||
def stable_id(package: str, keystore: bytes) -> int:
|
||||
"""6e18 + 32 bits from the package name and the game's signing keystore."""
|
||||
digest = hashlib.sha256(package.encode() + b"\0" + keystore).digest()
|
||||
return BASE_ID + int.from_bytes(digest[:4], "little")
|
||||
|
||||
|
||||
def _mov_x0(value: int) -> list[int]:
|
||||
"""movz/movk x0 with a 64-bit value."""
|
||||
out = []
|
||||
for i in range(4):
|
||||
imm = (value >> (16 * i)) & 0xFFFF
|
||||
base = 0xD2800000 if i == 0 else 0xF2800000 # movz / movk (64-bit)
|
||||
out.append(base | (i << 21) | (imm << 5))
|
||||
return out
|
||||
|
||||
|
||||
def pin_user_id(data: bytes, user_id: int) -> bytes | None:
|
||||
"""The loader whose made-up-id helper returns `user_id`, or None when the helper isn't found (Meta's own loader,
|
||||
another OVRPort build, already patched)."""
|
||||
off = find_helper(data)
|
||||
if off is None:
|
||||
return None
|
||||
new = _mov_x0(user_id) + [RET]
|
||||
out = bytearray(data)
|
||||
for i, word in enumerate(new): # the rest of the helper stays (unreachable), so the size never changes
|
||||
out[off + 4 * i:off + 4 * i + 4] = word.to_bytes(4, "little")
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _keystore(package: str) -> bytes:
|
||||
from ...apk import sign
|
||||
|
||||
try:
|
||||
return sign.ensure_keystore(package).read_bytes()
|
||||
except OSError:
|
||||
return b""
|
||||
|
||||
|
||||
class OvrStableUser(Patch):
|
||||
id = "frame.ovr_stable_user"
|
||||
title = "Keep the same Meta user between starts"
|
||||
description = ("Without a Meta account, OVRPort's platform library makes up a new user id at every start "
|
||||
"(ovr_GetLoggedInUserID). Games that file saves under the user id (for example Walkabout Mini "
|
||||
"Golf) then start with an empty profile each time and seem to lose all progress. Gives the game "
|
||||
"a fixed id instead, different for each game and each FramePort user.")
|
||||
order = 47
|
||||
default_on = True # suggested only where the game has the platform library (detect)
|
||||
|
||||
def detect(self, a):
|
||||
if LOADER in a.libs:
|
||||
return Suggestion(True, "The game uses Meta's platform library: OVRPort's version gives it a new user "
|
||||
"id at every start, which loses saves filed under that id.")
|
||||
return None
|
||||
|
||||
def applies(self, a):
|
||||
return LOADER in a.libs
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
if not ws.has(ws.lib(LOADER)):
|
||||
return False
|
||||
package = ctx.analysis.package
|
||||
user_id = stable_id(package, _keystore(package))
|
||||
fixed = pin_user_id(ws.read_lib(LOADER), user_id)
|
||||
if fixed:
|
||||
ws.put(ws.lib(LOADER), fixed)
|
||||
ctx.notes.append(f"{LOADER}: the logged-in user id is always {user_id}")
|
||||
return bool(fixed)
|
||||
|
||||
|
||||
register(OvrStableUser)
|
||||
@@ -88,6 +88,7 @@ SUMMARIES = {
|
||||
"frame.asset_files": "Lets the game find extra content it keeps next to its data (for example Star Wars Tales' "
|
||||
"seasons).",
|
||||
"frame.ovr_microphone": "Stops a crash a few seconds after the logo in games with voice chat.",
|
||||
"frame.ovr_stable_user": "Keeps your progress in games that file their saves under your Meta user.",
|
||||
"frame.avatar_stub": "Lets games start that would stop at Meta's avatars (for example BlazeRush). No Meta "
|
||||
"avatars.",
|
||||
"frame.vrapi_bridge": "Lets games built on Meta's oldest VR interface run on the Frame.",
|
||||
|
||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
||||
from ..core import winhost
|
||||
from ..core.events import Reporter
|
||||
from ..core.models import Recipe
|
||||
from ..core.paths import agent_file, user_data_dir
|
||||
from ..core.paths import agent_file, localize, user_data_dir
|
||||
from ..patches.pcvr import game_args
|
||||
from ..validate.triage import triage
|
||||
from .base import PC_LABEL, Target
|
||||
@@ -77,7 +77,7 @@ def local_installs() -> dict[str, dict]:
|
||||
out = {}
|
||||
for f in sorted((user_data_dir() / "pc").glob("*/deployment.json")):
|
||||
try:
|
||||
dep = json.loads(f.read_text(encoding="utf-8"))
|
||||
dep = localize(json.loads(f.read_text(encoding="utf-8")))
|
||||
out[dep["package"]] = dep
|
||||
except (OSError, ValueError, KeyError):
|
||||
continue
|
||||
@@ -113,7 +113,7 @@ class PcReviveTarget(Target):
|
||||
out = []
|
||||
for f in sorted(pc_dir().glob("*/deployment.json")):
|
||||
try:
|
||||
dep = json.loads(f.read_text(encoding="utf-8"))
|
||||
dep = localize(json.loads(f.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
dep["apk_present"] = Path(dep.get("exe_local", "")).exists()
|
||||
@@ -122,7 +122,7 @@ class PcReviveTarget(Target):
|
||||
|
||||
def _dep(self, package: str) -> dict:
|
||||
try:
|
||||
return json.loads((pc_dir() / package / "deployment.json").read_text(encoding="utf-8"))
|
||||
return localize(json.loads((pc_dir() / package / "deployment.json").read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError):
|
||||
raise RuntimeError(f"{package} is not installed on this PC") from None
|
||||
|
||||
@@ -195,7 +195,7 @@ class PcReviveTarget(Target):
|
||||
exe_field, _, _ = shortcut_fields(dep)
|
||||
d = pc_dir() / package
|
||||
try: # a reinstall keeps the shortcut's appid: Steam's entry is matched by its Exe, whatever its name now
|
||||
old = json.loads((d / "deployment.json").read_text(encoding="utf-8"))
|
||||
old = localize(json.loads((d / "deployment.json").read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError):
|
||||
old = {}
|
||||
same_exe = bool(old.get("appid") and old.get("exe_win") and shortcut_fields(old)[0] == exe_field)
|
||||
|
||||
@@ -28,6 +28,10 @@ def asset_suffix() -> str:
|
||||
return "Linux-ARM64" if arm else "Linux"
|
||||
|
||||
|
||||
def _exe_name() -> str:
|
||||
return "Cpp2IL.exe" if sys.platform == "win32" else "Cpp2IL"
|
||||
|
||||
|
||||
def pick_release(releases: list[dict], suffix: str) -> tuple[str, str] | None:
|
||||
"""(version, download url) of the newest release that has an executable for this computer."""
|
||||
for rel in releases:
|
||||
@@ -40,7 +44,7 @@ def pick_release(releases: list[dict], suffix: str) -> tuple[str, str] | None:
|
||||
def path() -> Path | None:
|
||||
if os.environ.get("FRAMEPORT_CPP2IL"):
|
||||
return Path(os.environ["FRAMEPORT_CPP2IL"])
|
||||
found = sorted(tools_dir().glob("cpp2il-*/Cpp2IL*"))
|
||||
found = sorted(tools_dir().glob(f"cpp2il-*/{_exe_name()}")) # the data folder may hold other OSes' copies too
|
||||
return found[-1] if found else None
|
||||
|
||||
|
||||
@@ -59,7 +63,7 @@ def ensure(progress=None) -> Path:
|
||||
if picked is None:
|
||||
raise RuntimeError("couldn't find a Cpp2IL download for this computer")
|
||||
version, url = picked
|
||||
exe = tools_dir() / f"cpp2il-{version}" / ("Cpp2IL.exe" if sys.platform == "win32" else "Cpp2IL")
|
||||
exe = tools_dir() / f"cpp2il-{version}" / _exe_name()
|
||||
cache.download(url, exe, progress)
|
||||
exe.chmod(exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return exe
|
||||
@@ -19,7 +19,7 @@ from pathlib import Path
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from ..core import cache
|
||||
from ..core.paths import tools_dir, write_atomic
|
||||
from ..core.paths import localize, tools_dir, write_atomic
|
||||
|
||||
ADOPTIUM = "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture={arch}&image_type=jre&os={os}"
|
||||
# overport's CLI: maintained in the downstream fork Android-XR-Bridge/OVRPort since 1.2.5 (stable channel = plain
|
||||
@@ -54,7 +54,7 @@ def _state_file() -> Path:
|
||||
|
||||
def _state() -> dict:
|
||||
try:
|
||||
return json.loads(_state_file().read_text())
|
||||
return localize(json.loads(_state_file().read_text()))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
@@ -64,37 +64,49 @@ def _save_state(state: dict) -> None:
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Java
|
||||
def _java_key() -> str:
|
||||
"""The Java entry of tools.json: one per OS, since a data folder can be shared by Windows and WSL."""
|
||||
return "java-{}-{}".format(*_os_arch())
|
||||
|
||||
|
||||
def java_path() -> Path | None:
|
||||
if os.environ.get("FRAMEPORT_JAVA"):
|
||||
return Path(os.environ["FRAMEPORT_JAVA"])
|
||||
st = _state().get("java")
|
||||
state = _state()
|
||||
st = state.get(_java_key())
|
||||
if not st and state.get("java"): # before 1.0.4: one entry; it's this OS's if it has this OS's java name
|
||||
st = state["java"] if Path(state["java"]["path"]).name == _java_exe() else None
|
||||
if st and Path(st["path"]).exists():
|
||||
return Path(st["path"])
|
||||
return None
|
||||
|
||||
|
||||
def _java_exe() -> str:
|
||||
return "java.exe" if sys.platform == "win32" else "java"
|
||||
|
||||
|
||||
def install_java(progress=None) -> ToolStatus:
|
||||
osname, arch = _os_arch()
|
||||
info = cache.http_get(ADOPTIUM.format(os=osname, arch=arch)).json()[0]
|
||||
pkg = info["binary"]["package"]
|
||||
version = info["version"]["semver"]
|
||||
dest = tools_dir() / f"jre-{version}"
|
||||
dest = tools_dir() / f"jre-{version}-{osname}-{arch}"
|
||||
if not dest.exists():
|
||||
archive = cache.download(pkg["link"], tools_dir() / pkg["name"], progress, expected_sha256=pkg["checksum"])
|
||||
tmp = tools_dir() / f".jre-{version}.extract"
|
||||
tmp = tools_dir() / f".jre-{version}-{osname}-{arch}.extract"
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
_extract(archive, tmp)
|
||||
top = next(tmp.iterdir())
|
||||
top.rename(dest)
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
archive.unlink(missing_ok=True)
|
||||
exe = "java.exe" if sys.platform == "win32" else "java"
|
||||
exe = _java_exe()
|
||||
candidates = [dest / "bin" / exe, dest / "Contents/Home/bin" / exe]
|
||||
java = next(p for p in candidates if p.exists())
|
||||
if sys.platform != "win32":
|
||||
java.chmod(0o755)
|
||||
state = _state()
|
||||
state["java"] = {"version": version, "path": str(java)}
|
||||
state[_java_key()] = {"version": version, "path": str(java)}
|
||||
_save_state(state)
|
||||
return ToolStatus("java", True, version, java)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ The UI only calls `frameport.pipeline`, the targets and the toolchain; long work
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
@@ -20,6 +21,7 @@ import flet as ft
|
||||
from .. import pipeline
|
||||
from ..core import applog, library
|
||||
from ..errors import explain
|
||||
from ..frame import monitor as M
|
||||
from ..frame.connection import NOT_IN_LIBRARY, AgentFailed
|
||||
from ..i18n import fmt_size, tr, tr_n
|
||||
from ..recommend import catalog
|
||||
@@ -53,12 +55,16 @@ def _stop_link_watch() -> None:
|
||||
|
||||
|
||||
def _watch_links() -> None:
|
||||
"""Heartbeat for the link handler scripts (FramePort runs) + hand queued install links to the window."""
|
||||
"""Heartbeat for the link handler scripts (FramePort runs) + hand queued install links to the window; a second
|
||||
start of FramePort brings this window to the front (core/instance.py)."""
|
||||
from .. import urlhandler
|
||||
from ..core import instance
|
||||
|
||||
while not _link_state["closing"]:
|
||||
urlhandler.heartbeat()
|
||||
app = _link_state["owner"]
|
||||
if app is not None and instance.take_focus_request(): # FramePort was started again: show this window
|
||||
app.page.run_task(app.bring_to_front)
|
||||
if app is not None:
|
||||
for link in urlhandler.take_links():
|
||||
applog.log.info("install link received")
|
||||
@@ -156,6 +162,8 @@ class FramePortApp:
|
||||
for key, value in window_geometry(library.setting("ui.window"), T.SCALE).items():
|
||||
setattr(page.window, key, value)
|
||||
page.window.on_event = self._on_window_event
|
||||
if sys.platform == "win32" and (icon := G.window_icon(Path(assets_dir()))):
|
||||
page.window.icon = str(icon) # source runs: FramePort's icon instead of Flet's
|
||||
page.on_keyboard_event = self._on_key
|
||||
if page.web: # the library's right-click menu; otherwise the browser shows its own
|
||||
try:
|
||||
@@ -183,6 +191,13 @@ class FramePortApp:
|
||||
atexit.register(_stop_link_watch)
|
||||
self.run_bg(urlhandler.apply_setting) # framedrop:// + frameport:// → FramePort (Settings → General)
|
||||
|
||||
async def bring_to_front(self) -> None:
|
||||
win = self.page.window
|
||||
if win.minimized:
|
||||
win.minimized = False
|
||||
C.update(self.page)
|
||||
await win.to_front()
|
||||
|
||||
# ================================================================== shell
|
||||
def _build_shell(self) -> None:
|
||||
"""The window: sidebar (logo, navigation, update/activity/Frame cards), the view area and the activity panel.
|
||||
@@ -502,7 +517,7 @@ class FramePortApp:
|
||||
def _on_card_sample(self, s: dict) -> None:
|
||||
"""A sample for the card (the stream's thread): remember it, keep the fps history, redraw at most once a
|
||||
second (with the Monitor open the stream runs faster than the card's 5 s)."""
|
||||
game = (s.get("games") or [None])[0]
|
||||
game = M.main_game(s)
|
||||
pkg = game.get("package") if game else None
|
||||
if pkg != self._card_pkg:
|
||||
self._card_pkg = pkg
|
||||
@@ -516,7 +531,7 @@ class FramePortApp:
|
||||
self._card_art[pkg] = thumbs.url(pkg, CARD_ART, width=160, wait=False) if library.game(pkg) else None
|
||||
was = self._card_sample
|
||||
self._card_sample, self._card_state = s, "live"
|
||||
shown_game = bool(was and was.get("games"))
|
||||
shown_game = M.main_game(was) is not None
|
||||
now = time.monotonic()
|
||||
if now - self._card_shown < 1.0 and shown_game == bool(game):
|
||||
return
|
||||
@@ -935,7 +950,11 @@ class FramePortApp:
|
||||
from .views.library import display_title, twins
|
||||
|
||||
g = library.game(pkg)
|
||||
return display_title(g, twins(library.games())) if g else pkg
|
||||
if g:
|
||||
return display_title(g, twins(library.games()))
|
||||
# a game on the Frame that isn't in this library (Frame page → Installed): the name it has there
|
||||
on_frame = next((d for d in (self.frame_info or {}).get("installed", []) if d.get("package") == pkg), None)
|
||||
return (on_frame or {}).get("title") or pkg
|
||||
|
||||
def install_options(self, g: dict) -> list[tuple]:
|
||||
"""[(label, icon, on_click, disabled, tooltip)] — the first is the primary action."""
|
||||
@@ -1660,9 +1679,9 @@ class FramePortApp:
|
||||
delete_local, extra = self._delete_local_option(pkg)
|
||||
delete_data = None
|
||||
if to == "frame": # GitHub #130: what the game stored (saves, mods, downloads) used to survive every uninstall
|
||||
delete_data = ft.Checkbox(label=tr("Also delete its saves and everything it stored on the Frame (mods, "
|
||||
"downloaded content)"), value=False, active_color=T.ERROR)
|
||||
extra = delete_data if extra is None else ft.Column([delete_data, extra], spacing=T.px(8), tight=True)
|
||||
delete_data, row = C.checkbox(tr("Also delete its saves and everything it stored on the Frame (mods, "
|
||||
"downloaded content)"), value=False, active_color=T.ERROR)
|
||||
extra = row if extra is None else ft.Column([row, extra], spacing=T.px(8), tight=True)
|
||||
|
||||
def run(job: Job):
|
||||
self._target_for(to).uninstall(pkg, keep_data=not (delete_data and delete_data.value))
|
||||
@@ -1685,12 +1704,12 @@ class FramePortApp:
|
||||
return None, None
|
||||
size = sum((sum(f.stat().st_size for f in p.rglob("*") if f.is_file()) if p.is_dir() else p.stat().st_size)
|
||||
for p in files)
|
||||
box = ft.Checkbox(label=tr("Also delete this game's files on this PC ({size})").format(size=fmt_size(size)),
|
||||
value=False, active_color=T.ERROR)
|
||||
box, row = C.checkbox(tr("Also delete this game's files on this PC ({size})").format(size=fmt_size(size)),
|
||||
value=False, active_color=T.ERROR)
|
||||
from ..core import winhost
|
||||
|
||||
shown = [winhost.to_windows(p) if winhost.is_wsl() else str(p) for p in files]
|
||||
return box, ft.Column([box, *[C.body("• " + s, T.TEXT_3) for s in shown[:4]]], spacing=T.px(4), tight=True)
|
||||
return box, ft.Column([row, *[C.body("• " + s, T.TEXT_3) for s in shown[:4]]], spacing=T.px(4), tight=True)
|
||||
|
||||
def remove_from_library(self, pkg: str) -> None:
|
||||
title = self._title(pkg)
|
||||
@@ -1893,7 +1912,7 @@ class FramePortApp:
|
||||
else "works"))
|
||||
notes = C.field(label=tr("Notes (what you checked, known issues)"), multiline=True, min_lines=2,
|
||||
max_lines=6, width=T.DIALOG_M)
|
||||
played = ft.Checkbox(label=tr("I played it in the headset with this recipe"), value=False)
|
||||
played, played_row = C.checkbox(tr("I played it in the headset with this recipe"), value=False)
|
||||
send = C.primary(tr("Open GitHub issue"), ft.Icons.OPEN_IN_NEW_ROUNDED, on_click=None)
|
||||
|
||||
def sync(e=None):
|
||||
@@ -1921,7 +1940,7 @@ class FramePortApp:
|
||||
get=C.test_result(last.get("verdict"), last.get("milestone"))[0],
|
||||
value=C.test_result(last.get("verdict"), last.get("milestone"))[1]),
|
||||
T.TEXT_3)] if last else []),
|
||||
ft.Row([status, C.help_icon("share_config")]), notes, played,
|
||||
ft.Row([status, C.help_icon("share_config")]), notes, played_row,
|
||||
C.body(tr("Launch tests can't check the picture or controls — only you can."),
|
||||
T.TEXT_3),
|
||||
], tight=True, spacing=T.S3),
|
||||
@@ -2356,6 +2375,8 @@ class FramePortApp:
|
||||
self._sessions_busy, target = True, self.target
|
||||
|
||||
def work():
|
||||
found = [] # games whose session has something to try: one pop-up for all of them (a burst at the
|
||||
# first connection used to show one per game, and the Library flickered under them)
|
||||
try:
|
||||
for pkg, lp, fetch in due:
|
||||
if not fetch:
|
||||
@@ -2368,22 +2389,36 @@ class FramePortApp:
|
||||
if isinstance(exc, AgentFailed): # the agent answered: asking again won't help
|
||||
pipeline.mark_session_checked(pkg, lp["end"])
|
||||
continue
|
||||
self._session_triaged(pkg, summary)
|
||||
if self._session_triaged(pkg, summary):
|
||||
found.append(pkg)
|
||||
finally:
|
||||
self._sessions_busy = False
|
||||
self._announce_sessions(found)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _session_triaged(self, pkg: str, summary: dict) -> None:
|
||||
def _session_triaged(self, pkg: str, summary: dict) -> bool:
|
||||
"""True when the session has something to try (shown on the game's page; _announce_sessions tells the user)."""
|
||||
from ..validate.session import shown_findings
|
||||
|
||||
if not summary or not shown_findings(summary):
|
||||
return
|
||||
return False
|
||||
if self.route[:2] == ("game", pkg):
|
||||
self.refresh_view()
|
||||
else:
|
||||
return True
|
||||
|
||||
def _announce_sessions(self, packages: list[str]) -> None:
|
||||
"""One pop-up for the play sessions that have something to try (not for the game page already shown)."""
|
||||
packages = [p for p in packages if self.route[:2] != ("game", p)]
|
||||
if len(packages) == 1:
|
||||
pkg = packages[0]
|
||||
self.toast(tr("FramePort looked at your last session of {title} and has something to try.")
|
||||
.format(title=self._title(pkg)), action=tr("Open game"),
|
||||
on_action=lambda e: self.open_game(pkg))
|
||||
elif packages:
|
||||
self.toast(tr_n("FramePort looked at your last session of {n} game and has something to try.",
|
||||
"FramePort looked at the last sessions of {n} games and has something to try on their "
|
||||
"pages.", len(packages)), action=tr("Open {title}").format(title=self._title(packages[0])),
|
||||
on_action=lambda e: self.open_game(packages[0]))
|
||||
|
||||
def apply_session_fix(self, pkg: str, suggestions: list[str], finding: str | None = None) -> None:
|
||||
"""A fix from the game page's "Last session": FrameBridge settings are written on the Frame at once (no
|
||||
@@ -2724,12 +2759,11 @@ class FramePortApp:
|
||||
shown_dir = winhost.to_windows(backup_dir) if winhost.is_wsl() else str(backup_dir)
|
||||
keys_label = tr("Back up the signing keys ({len}) to {shown_dir} first").format(len=len(pl.keys),
|
||||
shown_dir=shown_dir)
|
||||
cb_keys = ft.Checkbox(label=keys_label,
|
||||
value=bool(pl.keys), active_color=T.ACCENT)
|
||||
cb_frame = ft.Checkbox(label=tr("Also remove FramePort's games and files from the Frame") +
|
||||
("" if connected else tr(" (connect the Frame first)")), value=connected,
|
||||
disabled=not connected, active_color=T.ACCENT)
|
||||
cb_saves = ft.Checkbox(label=tr("Keep game saves on the Frame"), value=True, active_color=T.ACCENT)
|
||||
cb_keys, keys_row = C.checkbox(keys_label, value=bool(pl.keys), active_color=T.ACCENT)
|
||||
cb_frame, frame_row = C.checkbox(tr("Also remove FramePort's games and files from the Frame") +
|
||||
("" if connected else tr(" (connect the Frame first)")), value=connected,
|
||||
disabled=not connected, active_color=T.ACCENT)
|
||||
cb_saves, saves_row = C.checkbox(tr("Keep game saves on the Frame"), value=True, active_color=T.ACCENT)
|
||||
frame_items = [i for i in pl.items if i.kind == "frame"]
|
||||
steam_items = [i for i in pl.items if i.kind == "steam"]
|
||||
other = [i for i in pl.items if i.kind == "pc"]
|
||||
@@ -2765,7 +2799,7 @@ class FramePortApp:
|
||||
self.page.show_dialog(C.dialog(
|
||||
tr("Uninstall FramePort?"),
|
||||
ft.Column([C.body(tr("This removes:"), T.TEXT), *lines, ft.Container(height=T.S2),
|
||||
cb_keys, cb_frame, cb_saves,
|
||||
keys_row, frame_row, saves_row,
|
||||
C.meta(tr("Keep the signing keys: without them, updating a game "
|
||||
"loses its saves."))],
|
||||
spacing=T.S2, tight=True, scroll=ft.ScrollMode.AUTO),
|
||||
@@ -2875,6 +2909,12 @@ def main(argv=None):
|
||||
if links and urlhandler.app_running():
|
||||
return # the open window takes it
|
||||
applog.setup("gui")
|
||||
from ..core import instance
|
||||
|
||||
if not instance.claim(urlhandler.app_running):
|
||||
applog.log.info("FramePort is already running: brought its window to the front")
|
||||
print("FramePort is already running.", file=sys.stderr)
|
||||
return
|
||||
serialize_flet_updates()
|
||||
from ..core import library
|
||||
from .updater import apply_pending_at_start
|
||||
@@ -2889,6 +2929,14 @@ def main(argv=None):
|
||||
if updates.install_kind() == "bundle": # a packaged app finds artwork by file path, not by URL (GitHub #16)
|
||||
thumbs.use_file_paths(True)
|
||||
ft.run(lambda page: FramePortApp(page), assets_dir=assets_dir())
|
||||
# the window is gone: end the process now. Background threads (re-analysis, session triage) would otherwise keep
|
||||
# it running without a window, holding the GUI lock; library writes are atomic and installs resume.
|
||||
_stop_link_watch()
|
||||
import logging
|
||||
import os
|
||||
|
||||
logging.shutdown()
|
||||
os._exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -213,6 +213,23 @@ def h2(text: str) -> ft.Text:
|
||||
return ft.Text(text, size=T.T_H2, weight=ft.FontWeight.W_600, color=T.TEXT)
|
||||
|
||||
|
||||
def checkbox(label: str, **kw) -> tuple[ft.Checkbox, ft.Control]:
|
||||
"""(the checkbox, the control to show): a checkbox whose label wraps (Material's own label stays on one line and
|
||||
runs past a dialog's edge); clicking the label toggles it too."""
|
||||
box = ft.Checkbox(**kw)
|
||||
|
||||
def click_label(e):
|
||||
if box.disabled:
|
||||
return
|
||||
box.value = not box.value
|
||||
update(box)
|
||||
if box.on_change:
|
||||
box.on_change(SimpleNamespace(control=box))
|
||||
return box, ft.Row([box, ft.Container(ft.Text(label, color=T.TEXT, size=T.px(14)), expand=True,
|
||||
on_click=click_label, ink=True, border_radius=T.RADIUS_XS)],
|
||||
spacing=T.S1, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
|
||||
|
||||
def switch(label: str = "", wrap: bool = True, **kw) -> ft.Control:
|
||||
"""A labelled switch in the theme's text colour (Material's default label is dark text). The label wraps (a
|
||||
Switch's own label is cut off when it's longer than the window); clicking it toggles the switch too.
|
||||
|
||||
@@ -24,10 +24,9 @@ def now_playing(sample: dict | None, fps_series: list | None = None) -> dict | N
|
||||
"""Display values for the card's "now playing" row, or None when no game runs: package, title, line ("playing ·
|
||||
27 min"), fps text ("72" / "–"), level (ok | warn | error by the fps against its presumed refresh rate) and the
|
||||
fps target (refresh rate) for the sparkline's guide."""
|
||||
games = (sample or {}).get("games") or []
|
||||
if not games:
|
||||
g = M.main_game(sample)
|
||||
if g is None:
|
||||
return None
|
||||
g = games[0]
|
||||
pkg = g.get("package") or ""
|
||||
elapsed = g.get("elapsed")
|
||||
line = tr("playing · {t}").format(t=M.fmt_duration(elapsed)) if elapsed is not None else tr("playing")
|
||||
|
||||
@@ -45,6 +45,27 @@ def install(assets: Path) -> Path:
|
||||
return target
|
||||
|
||||
|
||||
APP_ICON = Path(__file__).resolve().parents[2] / "assets" / "icon.png" # src/assets: the bundles' app icon
|
||||
|
||||
|
||||
def window_icon(assets: Path, source: Path = APP_ICON) -> Path | None:
|
||||
"""<assets>/ui-icons/app.ico made from the app icon, for the window and taskbar of source runs on Windows (they
|
||||
run Flet's prebuilt client, which shows Flet's logo; `flet build` bundles have the icon built in). None when the
|
||||
icon isn't there (bundles) or can't be converted."""
|
||||
dest = assets / FOLDER / "app.ico"
|
||||
try:
|
||||
if dest.is_file() and dest.stat().st_mtime >= source.stat().st_mtime:
|
||||
return dest
|
||||
from PIL import Image
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.open(source) as im:
|
||||
im.convert("RGBA").save(dest, sizes=[(n, n) for n in (16, 20, 24, 32, 40, 48, 64, 128, 256)])
|
||||
return dest
|
||||
except Exception: # noqa: BLE001 (Flet's icon then; never worth failing the start)
|
||||
return None
|
||||
|
||||
|
||||
PORTAL_REV = 2
|
||||
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ class Updater:
|
||||
tr("FramePort {version} is available").format(version=up.version),
|
||||
_changes_column(page, changes, intro), size="l", height=_changes_height(changes, 100),
|
||||
actions=[C.ghost(tr("Skip this version"), on_click=skip),
|
||||
C.ghost(tr("Release page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)),
|
||||
C.ghost(tr("Release page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: _open_url(page, up.page)),
|
||||
C.primary(tr("Update now"), ft.Icons.SYSTEM_UPDATE_ROUNDED, go)]))
|
||||
|
||||
def show_news(self) -> None:
|
||||
@@ -161,7 +161,7 @@ class Updater:
|
||||
page = self.app.page
|
||||
if not entries:
|
||||
self.app.toast(tr("Couldn't load the changelog — check the internet connection"), error=True,
|
||||
action=tr("Releases"), on_action=lambda e: page.launch_url(f"{REPO_URL}/releases"))
|
||||
action=tr("Releases"), on_action=lambda e: _open_url(page, f"{REPO_URL}/releases"))
|
||||
return
|
||||
self.app.page.run_thread(lambda: page.show_dialog(_changes_dialog(page, tr("FramePort changelog"),
|
||||
entries, current=__version__)))
|
||||
@@ -199,7 +199,7 @@ class Updater:
|
||||
tr("Install dev build {version}?").format(version=up.version),
|
||||
_changes_column(page, changes, warn), size="l", height=_changes_height(changes, 160),
|
||||
actions=[C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
C.ghost(tr("Build page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)),
|
||||
C.ghost(tr("Build page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: _open_url(page, up.page)),
|
||||
C.primary(tr("Install dev build"), G.TEST, go)]))
|
||||
|
||||
# ---------------------------------------------------------------- installing
|
||||
@@ -214,7 +214,7 @@ class Updater:
|
||||
if not target or not updates.can_replace(target):
|
||||
self.app.toast(tr("FramePort can't update itself in {value} (no permission) — opening the release "
|
||||
"page to download it").format(value=target or 'this folder'), error=True)
|
||||
page.launch_url(up.page)
|
||||
_open_url(page, up.page)
|
||||
return
|
||||
if kind == "source" and updates.source_is_dirty():
|
||||
self.app.toast(tr("This source checkout has uncommitted changes — commit or stash them, then update"),
|
||||
@@ -301,7 +301,7 @@ def _entry_section(page: ft.Page, entry: updates.ChangelogEntry, expanded: bool,
|
||||
notes = ft.Container(
|
||||
ft.Markdown(entry.body.strip() or tr("No release notes."), selectable=True,
|
||||
extension_set=ft.MarkdownExtensionSet.GITHUB_WEB, md_style_sheet=_notes_style(),
|
||||
on_tap_link=lambda e: page.launch_url(e.data)),
|
||||
on_tap_link=lambda e: _open_url(page, e.data)),
|
||||
padding=ft.Padding(T.S4, 0, T.S4, T.S3), visible=expanded)
|
||||
chevron = ft.Icon(ft.Icons.EXPAND_MORE_ROUNDED if expanded else ft.Icons.CHEVRON_RIGHT_ROUNDED, size=T.px(18),
|
||||
color=T.TEXT_3)
|
||||
@@ -338,12 +338,25 @@ def _changes_height(entries: list[updates.ChangelogEntry], extra: int) -> float:
|
||||
return T.px(min(480, extra + 26 * lines + 50 * len(entries)))
|
||||
|
||||
|
||||
def _open_url(page: ft.Page, url: str) -> None:
|
||||
"""Open a link in the browser. Flet 1.0 has no Page.launch_url (GitHub #194: "All releases" raised
|
||||
AttributeError); same path as FramePortApp.open_url."""
|
||||
from ..core import winhost
|
||||
|
||||
if winhost.is_wsl() and winhost.open_url(url): # the desktop client would open a Linux browser in WSL
|
||||
return
|
||||
try:
|
||||
page.run_task(ft.UrlLauncher().launch_url, url)
|
||||
except Exception: # noqa: BLE001
|
||||
winhost.open_url(url)
|
||||
|
||||
|
||||
def _changes_dialog(page: ft.Page, title: str, entries: list[updates.ChangelogEntry],
|
||||
current: str | None = None) -> ft.AlertDialog:
|
||||
return C.dialog(title, _changes_column(page, entries, None, current), size="l",
|
||||
height=_changes_height(entries, 20),
|
||||
actions=[C.ghost(tr("All releases"), ft.Icons.OPEN_IN_NEW_ROUNDED,
|
||||
lambda e: page.launch_url(f"{REPO_URL}/releases")),
|
||||
lambda e: _open_url(page, f"{REPO_URL}/releases")),
|
||||
C.primary(tr("Close"), on_click=lambda e: page.pop_dialog())])
|
||||
|
||||
|
||||
|
||||
@@ -518,7 +518,7 @@ class MonitorView:
|
||||
def _apply_game(self, s: dict) -> list:
|
||||
games = s.get("games") or []
|
||||
self.last_games = games
|
||||
game = games[0] if games else None
|
||||
game = M.main_game(s)
|
||||
was = self.game_card.visible
|
||||
self.game_card.visible = game is not None
|
||||
self.no_game.visible = game is None and self.filter.data == "game"
|
||||
@@ -534,7 +534,8 @@ class MonitorView:
|
||||
# once per game (not per tick): its artwork, by asset URL
|
||||
self.game_art.content = C.art_fill(meta["art"], radius=T.RADIUS_SM, width=T.px(96), height=T.px(96))
|
||||
self.page_btn.visible = meta["in_library"]
|
||||
kind = {"pcvr": tr("PC VR via Proton"), "linux": tr("Linux app")}.get(game.get("kind"), tr("Quest game"))
|
||||
kind = {"pcvr": tr("PC VR via Proton"), "linux": tr("Linux app"),
|
||||
"steam": tr("Steam game")}.get(game.get("kind"), tr("Quest game"))
|
||||
self.game_title.value = game.get("title") or pkg
|
||||
self.game_sub.value = " · ".join(x for x in (kind, tr("running for {t}").format(
|
||||
t=M.fmt_duration(game.get("elapsed"))) if game.get("elapsed") is not None else "") if x)
|
||||
|
||||
@@ -480,12 +480,27 @@ def _extract(archive: Path, dest: Path, platform: str) -> Path:
|
||||
return folder
|
||||
|
||||
|
||||
# set by the running app's Flutter host / embedded Python for *this* process (its server socket, its temporary
|
||||
# extraction folder, …): a relaunched FramePort that inherits them talks to the old, gone app and its window stays
|
||||
# black (GitHub #195)
|
||||
APP_ENV_PREFIXES = ("FLET_", "SERIOUS_PYTHON")
|
||||
APP_ENV_NAMES = ("PYTHONHOME", "PYTHONPATH", "PYTHONNOUSERSITE", "PYTHONINSPECT", "PYTHONDONTWRITEBYTECODE")
|
||||
|
||||
|
||||
def clean_env(env: dict | None = None) -> dict:
|
||||
"""The environment for the swap script and the FramePort it relaunches: without the running app's own Flet and
|
||||
embedded-Python variables."""
|
||||
env = dict(os.environ if env is None else env)
|
||||
return {k: v for k, v in env.items()
|
||||
if not k.upper().startswith(APP_ENV_PREFIXES) and k.upper() not in APP_ENV_NAMES}
|
||||
|
||||
|
||||
def _powershell() -> tuple[str, dict]:
|
||||
"""Windows PowerShell 5.1 (always installed) and an environment it can load its own modules in: started from
|
||||
PowerShell 7 (pwsh), PSModulePath points at pwsh's modules and 5.1 fails to load Microsoft.PowerShell.Security /
|
||||
.Management (Get-AuthenticodeSignature, Copy-Item). Without PSModulePath it rebuilds its default."""
|
||||
exe = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32/WindowsPowerShell/v1.0/powershell.exe"
|
||||
env = {k: v for k, v in os.environ.items() if k.upper() != "PSMODULEPATH"}
|
||||
env = {k: v for k, v in clean_env().items() if k.upper() != "PSMODULEPATH"}
|
||||
return (str(exe) if exe.exists() else "powershell"), env
|
||||
|
||||
|
||||
@@ -683,7 +698,8 @@ def apply(app: Path, target: Path | None = None, relaunch: bool = True, pid: int
|
||||
"Hidden", "-File", str(script)], env=env)
|
||||
else:
|
||||
proc = subprocess.Popen(["/bin/sh", str(script)], start_new_session=not wait, close_fds=True,
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
env=clean_env())
|
||||
if not wait: # make sure the script really runs before FramePort quits (it logs first thing)
|
||||
deadline = time.time() + START_TIMEOUT
|
||||
while time.time() < deadline:
|
||||
|
||||
@@ -406,8 +406,12 @@ def unregister(schemes=None) -> dict:
|
||||
def apply_setting() -> dict | None:
|
||||
"""At GUI start: register the enabled schemes (never taking one from another program) and remove FramePort's
|
||||
registration of the disabled ones. Errors are logged, never raised (links are a convenience)."""
|
||||
# an isolated data dir (FRAMEPORT_HOME: tests, screenshots) never takes over the user's link handler
|
||||
if os.environ.get("FRAMEPORT_NO_LINK_HANDLER") or os.environ.get("FRAMEPORT_HOME") or not platform_kind():
|
||||
# an isolated data dir (FRAMEPORT_HOME: tests, screenshots) never takes over the user's link handler, nor does a
|
||||
# WSL run on the Windows copy's data (it would point the Windows copy's links at WSL)
|
||||
from .core.paths import shares_windows_data
|
||||
|
||||
if (os.environ.get("FRAMEPORT_NO_LINK_HANDLER") or os.environ.get("FRAMEPORT_HOME") or shares_windows_data()
|
||||
or not platform_kind()):
|
||||
return None
|
||||
try:
|
||||
off = [s for s in SCHEMES if not enabled(s)]
|
||||
|
||||
@@ -16,8 +16,10 @@ def launch_test(frame: Frame, package: str, reporter: Reporter, seconds: int = 4
|
||||
return TriageResult("UNKNOWN", None), ""
|
||||
log = frame.get_text(res["log"]) if res.get("log_size") else ""
|
||||
crash = frame.get_text(res["crash_log"]) if res.get("crash_log") else "" # tombstones (backtraces), agent >= 23
|
||||
result = triage(log, res["state"], package, crash=crash[-256 * 1024:])
|
||||
reporter.check("Process", res["state"] == "RUNNING", f"{res['state']} after {res['elapsed']}s")
|
||||
result = triage(log, res["state"], package, crash=crash[-256 * 1024:], headless=True)
|
||||
reporter.check("Process", res["state"] == "RUNNING" or (None if result.headless_end else False),
|
||||
f"{res['state']} after {res['elapsed']}s"
|
||||
+ (f" (after {result.headless_end}: expected without the headset)" if result.headless_end else ""))
|
||||
for m in result.milestones:
|
||||
reporter.check(m, True)
|
||||
if result.fps:
|
||||
|
||||
@@ -30,11 +30,12 @@ class TriageResult:
|
||||
milestones: list[str] = field(default_factory=list)
|
||||
findings: list[Finding] = field(default_factory=list)
|
||||
fps: float | None = None
|
||||
headless_end: str | None = None # launch test: the session ended for lack of a worn headset (label), see triage()
|
||||
|
||||
@property
|
||||
def verdict(self) -> str:
|
||||
fatal = [f for f in self.findings if f.severity == "fatal"]
|
||||
if self.state == "RUNNING" and not fatal:
|
||||
if (self.state == "RUNNING" or self.headless_end) and not fatal:
|
||||
return "pass"
|
||||
return "fail" if fatal or self.state in ("EXITED", "NEVER_STARTED") else "unknown"
|
||||
|
||||
@@ -114,9 +115,10 @@ def split_suggestion(s: str) -> tuple[str, str | None]:
|
||||
|
||||
|
||||
def triage(log: str, state: str = "UNKNOWN", package: str | None = None, crash: str = "",
|
||||
kernel: str = "") -> TriageResult:
|
||||
kernel: str = "", headless: bool = False) -> TriageResult:
|
||||
"""`crash` = the container's crash logcat (tombstones come from crash_dump's pid, so it isn't pid-filtered);
|
||||
`kernel` = kernel log lines of a play session ("kernel: …", GPU hangs; agent session_log)."""
|
||||
`kernel` = kernel log lines of a play session ("kernel: …", GPU hangs; agent session_log); `headless` = a launch
|
||||
test (no headset worn): what happens after a `headless_end` line doesn't count against the game."""
|
||||
db = database()
|
||||
kind = "pcvr" if package and package.startswith("rift.") else "quest" # Proton/Revive logs vs Lepton logcat
|
||||
lines = game_lines(log, package) if kind == "quest" else [ANSI.sub("", ln) for ln in log.splitlines()]
|
||||
@@ -149,11 +151,14 @@ def triage(log: str, state: str = "UNKNOWN", package: str | None = None, crash:
|
||||
hidden = {h for sig in db["signatures"] if any(f.id == sig["id"] for f in res.findings)
|
||||
for h in sig.get("supersedes") or []}
|
||||
res.findings = [f for f in res.findings if f.id not in hidden]
|
||||
if headless:
|
||||
_forgive_headless_end(res, lines, db.get("headless_end") or [])
|
||||
fps = re.findall(r"pacing: ([0-9.]+) fps", text)
|
||||
if fps:
|
||||
res.fps = float(fps[-1])
|
||||
stopped = frames_stopped(lines)
|
||||
if stopped and state == "RUNNING" and not any(f.severity == "fatal" for f in res.findings):
|
||||
if (stopped and state == "RUNNING" and not res.headless_end
|
||||
and not any(f.severity == "fatal" for f in res.findings)):
|
||||
res.findings.append(Finding(
|
||||
"frames-stopped", "fatal",
|
||||
f"The game stopped sending frames {stopped:.0f} s before the test ended although its process kept "
|
||||
@@ -162,6 +167,40 @@ def triage(log: str, state: str = "UNKNOWN", package: str | None = None, crash:
|
||||
return res
|
||||
|
||||
|
||||
def _forgive_headless_end(res: TriageResult, lines: list[str], markers: list[dict]) -> None:
|
||||
"""A launch test whose game sent paced frames until a `headless_end` line (the VR session ended because nobody
|
||||
wears the headset, e.g. the VrApi bridge's tracking deadline): an exit or crash after it is an artifact of the
|
||||
test (BlazeRush: zink DEVICE LOST + SIGSEGV in the GPU driver thread as the renderer tore down, GitHub #57).
|
||||
Findings first seen after that line become one info finding; the verdict then passes (TriageResult.verdict)."""
|
||||
hits = ((m, next((i for i, ln in enumerate(lines) if re.search(m["pattern"], ln)), None)) for m in markers)
|
||||
marker, at = next(((m, i) for m, i in hits if i is not None), (None, None))
|
||||
if at is None or not any("FrameBridge: pacing:" in ln for ln in lines[:at]):
|
||||
return # no such line, or no frames before it: the game never really ran
|
||||
when = _secs(lines[at])
|
||||
|
||||
def after(f: Finding) -> bool:
|
||||
i = next((i for i, ln in enumerate(lines) if f.evidence and f.evidence in ln.strip()), None)
|
||||
if i is None:
|
||||
return False
|
||||
t = _secs(lines[i])
|
||||
return t >= when if t is not None and when is not None else i > at
|
||||
|
||||
late = [f for f in res.findings if after(f)]
|
||||
res.findings = [f for f in res.findings if f not in late]
|
||||
res.headless_end = marker["label"]
|
||||
what = ", ".join(f.id for f in late) or "the game ended"
|
||||
res.findings.append(Finding(
|
||||
"headless-end", "info",
|
||||
f"The game ran (paced frames) until the VR session ended for lack of a worn headset ({marker['label']}). What "
|
||||
f"came after ({what}) is a side effect of this headless test and doesn't happen in the headset.", [],
|
||||
lines[at].strip()[:300]))
|
||||
|
||||
|
||||
def _secs(line: str) -> float | None:
|
||||
m = LOGCAT_TIME.match(line)
|
||||
return int(m[1]) * 3600 + int(m[2]) * 60 + int(m[3]) + int(m[4]) / 1000 if m else None
|
||||
|
||||
|
||||
MISSING_OBB = ("The game keeps its content in a data file (.obb) and none was installed: FramePort found no .obb "
|
||||
"files next to its APK. Unreal games then stop right after start without an error (GitHub #85). Put "
|
||||
"the .obb files in a folder named like the package (or obb/) next to the APK, add the folder again "
|
||||
|
||||
@@ -9,6 +9,7 @@ import pytest
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
# modules that read settings at import time (UI) run during collection, before the per-test fixture below
|
||||
os.environ["FRAMEPORT_HOME"] = tempfile.mkdtemp(prefix="frameport-tests-")
|
||||
os.environ.pop("FRAMEPORT_WINDOWS_DATA", None) # a WSL dev shell may share the Windows data folder: never in tests
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Test fixture for patches/frame/ovr_stable_user.py: OVRPort's helper that makes up the logged-in user id
|
||||
// (same instructions as libovrplatformloader.so in OVRPort runtimes 3.4.3-23204ea and 3.4.3-aa54c3f).
|
||||
// Build: aarch64-linux-android29-clang -shared -fPIC -nostdlib fakestableuser.S -o ../libfakestableuser_arm64.so
|
||||
.text
|
||||
.globl random
|
||||
.type random, %function
|
||||
random:
|
||||
mov x0, #7
|
||||
ret
|
||||
.size random, .-random
|
||||
|
||||
.globl fake_user_id
|
||||
.type fake_user_id, %function
|
||||
fake_user_id:
|
||||
stp x29, x30, [sp, #-16]!
|
||||
mov x29, sp
|
||||
bl random
|
||||
mov x8, #0xa7640000
|
||||
mov x9, #-0x7ffffffffffffffd
|
||||
movk x8, #0xb6b3, lsl #32
|
||||
movk x9, #1, lsl #32
|
||||
movk x8, #0xde0, lsl #48
|
||||
mul x8, x0, x8
|
||||
smulh x9, x8, x9
|
||||
add x8, x9, x8
|
||||
asr x9, x8, #30
|
||||
add x8, x9, x8, lsr #63
|
||||
mov x9, #0xec580000
|
||||
movk x9, #0x4835, lsl #32
|
||||
movk x9, #0x5344, lsl #48
|
||||
add x0, x8, x9
|
||||
ldp x29, x30, [sp], #16
|
||||
ret
|
||||
.size fake_user_id, .-fake_user_id
|
||||
|
||||
.globl ovr_GetLoggedInUserID
|
||||
.type ovr_GetLoggedInUserID, %function
|
||||
ovr_GetLoggedInUserID:
|
||||
b fake_user_id
|
||||
.size ovr_GetLoggedInUserID, .-ovr_GetLoggedInUserID
|
||||
@@ -1710,3 +1710,39 @@ def test_two_revive_games_keep_their_own_shortcuts(monkeypatch, tmp_path):
|
||||
assert again == one
|
||||
names = sorted(v["appname"] for v in a.vdf_decode(vdf.read_bytes())["shortcuts"].values())
|
||||
assert names == ["Game One", "Game Two"]
|
||||
|
||||
|
||||
def test_game_threads_reports_stuck_threads(monkeypatch, tmp_path):
|
||||
"""GitHub #157: diagnostics saved while a game hangs show what each thread waits in."""
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
proc = tmp_path / "proc"
|
||||
|
||||
def task(pid, tid, comm, ticks, wchan, syscall):
|
||||
t = proc / str(pid) / "task" / str(tid)
|
||||
t.mkdir(parents=True)
|
||||
(t / "stat").write_text(f"{tid} ({comm}) S 1 1 1 0 -1 0 0 0 0 0 {ticks} 0 0 0 20 0 1 0")
|
||||
(t / "comm").write_text(comm + "\n")
|
||||
(t / "wchan").write_text(wchan)
|
||||
(t / "syscall").write_text(syscall)
|
||||
|
||||
(proc / "200").mkdir(parents=True)
|
||||
(proc / "200" / "cmdline").write_text("com.example.game\0")
|
||||
task(200, 200, "GameThread", 50, "futex_wait_queue", "98 0x1 0x2")
|
||||
task(200, 201, "RenderThread", 10, "do_epoll_wait", "22 0x1")
|
||||
(proc / "200" / "net").mkdir()
|
||||
(proc / "200" / "net" / "tcp").write_text(
|
||||
" sl local_address rem_address st\n"
|
||||
" 0: 0100007F:1F90 0B01A8C0:01BB 02 00000000:00000000\n")
|
||||
(proc / "300").mkdir()
|
||||
(proc / "300" / "cmdline").write_text("com.other\0")
|
||||
|
||||
def sleep(_): # GameThread uses 1 s of CPU between the two samples
|
||||
(proc / "200" / "task" / "200" / "stat").write_text(
|
||||
"200 (GameThread) R 1 1 1 0 -1 0 0 0 0 0 150 0 0 0 20 0 1 0")
|
||||
|
||||
out = a.game_threads("com.example.game", proc=str(proc), samples=2, gap=1, sleep=sleep)
|
||||
assert "com.example.game (pid 200, 2 threads)" in out
|
||||
game = next(ln for ln in out.splitlines() if "GameThread" in ln)
|
||||
assert "futex" in game and "futex_wait_queue" in game and f"{100 * 1000 // a.CLK_TCK}" in game
|
||||
assert "epoll_pwait" in out and "SYN_SENT" in out and "remote=local:443" in out
|
||||
assert "192.168" not in out and a.game_threads("com.missing", proc=str(proc)) is None
|
||||
@@ -324,3 +324,11 @@ def test_agent_collect_diag(monkeypatch, tmp_path):
|
||||
assert f["launch.log"].startswith("[... first 4000 bytes cut") and "files" not in json.loads(f["deployment.json"])
|
||||
assert res["listing"]["roots"]
|
||||
assert a.cmd_collect_diag({"package": "com.not.installed"})["installed"] is False
|
||||
|
||||
|
||||
def test_updater_never_uses_page_launch_url():
|
||||
"""GitHub #194: Flet 1.0's Page has no launch_url ("All releases" raised AttributeError)."""
|
||||
from pathlib import Path
|
||||
|
||||
src = Path(__file__).resolve().parents[1] / "src" / "frameport"
|
||||
assert not [p for p in src.rglob("*.py") if "page.launch_url(" in p.read_text(encoding="utf-8")]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""One FramePort window at a time (core/instance.py)."""
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
from frameport.core import instance
|
||||
|
||||
|
||||
def _hold_lock(path):
|
||||
"""A separate process holding the GUI lock (like a running FramePort)."""
|
||||
code = textwrap.dedent(f"""
|
||||
import sys, time
|
||||
sys.path.insert(0, {str(instance.__file__.rsplit('/frameport/', 1)[0])!r})
|
||||
from pathlib import Path
|
||||
from frameport.core import instance
|
||||
assert instance.acquire(Path({str(path)!r}))
|
||||
print("locked", flush=True)
|
||||
time.sleep(30)
|
||||
""")
|
||||
p = subprocess.Popen([sys.executable, "-c", code], stdout=subprocess.PIPE, text=True)
|
||||
assert p.stdout.readline().strip() == "locked"
|
||||
return p
|
||||
|
||||
|
||||
def test_second_start_is_refused_while_a_window_is_open(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FRAMEPORT_HOME", str(tmp_path))
|
||||
holder = _hold_lock(instance.lock_file())
|
||||
try:
|
||||
assert instance.holder() == holder.pid
|
||||
assert not instance.acquire()
|
||||
assert instance.claim(lambda: True) is False # a window answers: it's asked to come to the front
|
||||
assert instance.take_focus_request() and not instance.take_focus_request()
|
||||
# no window answers, but the holder isn't a FramePort GUI (or is still starting): it's left alone
|
||||
assert instance.claim(lambda: False, retries=1) is False
|
||||
assert holder.poll() is None
|
||||
finally:
|
||||
holder.kill()
|
||||
holder.wait()
|
||||
assert instance.acquire() # released by the OS when the holder ends
|
||||
|
||||
|
||||
def test_leftover_frameport_gui_is_ended(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FRAMEPORT_HOME", str(tmp_path))
|
||||
holder = _hold_lock(instance.lock_file())
|
||||
monkeypatch.setattr(instance, "_is_frameport_gui", lambda pid: pid == holder.pid)
|
||||
monkeypatch.setattr(instance, "STARTUP_GRACE", 0.0)
|
||||
try:
|
||||
assert instance.claim(lambda: False) is True # no window: the leftover process is ended, the lock taken
|
||||
assert holder.wait(5) is not None
|
||||
finally:
|
||||
if holder.poll() is None:
|
||||
holder.kill()
|
||||
@@ -108,6 +108,9 @@ def frame_tree(root, home):
|
||||
proc(root, 302, "lus4xvrplayerov", 301, cpu=50, fds={3: ("/dev/kgsl-3d0", 5000)},
|
||||
cgroup="/user.slice/libpod-abc.scope/container", cmdline=["cn.vr4p.oculus4xvrplayerov"])
|
||||
proc(root, 400, "bash", 1, cmdline=["bash", "-c", "sleep 9"])
|
||||
# the agent's shortcut worker during an uninstall names the game's launch.sh: not the game
|
||||
proc(root, 500, "python3", 1, cmdline=["python3", "{agent}", "_shortcuts_worker",
|
||||
json.dumps({"remove": [{"exe": f'"{launcher}"'}]})])
|
||||
return base
|
||||
|
||||
|
||||
@@ -119,6 +122,9 @@ def fa(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, home)
|
||||
root = tmp_path / "frame"
|
||||
frame_tree(root, home)
|
||||
agent_file = next(iter(a.AGENT_FILES))
|
||||
cmd = root / "proc" / "500" / "cmdline"
|
||||
cmd.write_text(cmd.read_text().replace("{agent}", agent_file))
|
||||
monkeypatch.setattr(a, "PROC", str(root / "proc"))
|
||||
monkeypatch.setattr(a, "SYS", str(root / "sys"))
|
||||
monkeypatch.setattr(a, "POWER_SUPPLY", str(root / "power_supply"))
|
||||
@@ -177,6 +183,7 @@ def test_processes_games_and_filters(fa):
|
||||
assert 3 not in names and 2 not in names # kernel threads
|
||||
assert {names[pid]["game"] for pid in (200, 300, 301, 302)} == {GAME}
|
||||
assert names[100]["group"] == "steam" and names[101]["group"] == "steamvr" and names[400]["group"] == "other"
|
||||
assert names[500]["game"] is None
|
||||
assert names[302]["name"] == "cn.vr4p.oculus4xvrplayerov" # the full name, not the truncated comm
|
||||
assert names[101]["critical"] and names[1]["locked"]
|
||||
# default filter: the game's processes + the busiest others as context
|
||||
@@ -356,3 +363,35 @@ def test_fast_ticks_scan_processes_every_2_s(fa):
|
||||
seen.append("procs" in m.sample(wall=2000.0 + clock["t"] - 100.0))
|
||||
assert seen == [False] * 7 + [True]
|
||||
assert 0.1 in fa.MON_INTERVALS and fa.MON_DEFAULT_INTERVAL == 0.5
|
||||
|
||||
|
||||
def test_steam_games_and_overlay_apps(fa):
|
||||
"""A Steam store game (Job Simulator in Lepton, started by Steam's reaper) counts as a game with its Steam name;
|
||||
an overlay app (Temporal Reality) is flagged and listed after it, and isn't the game in front."""
|
||||
from frameport.frame.monitor import main_game
|
||||
from frameport.ui.frame_card import now_playing
|
||||
|
||||
root, home = fa.ROOT, fa.ROOT.parent / "home"
|
||||
w(home / ".local/share/Steam/steamapps/appmanifest_448280.acf",
|
||||
'"AppState"\n{\n\t"appid"\t\t"448280"\n\t"name"\t\t"Job Simulator"\n\t"installdir"\t\t"Job Simulator"\n}\n')
|
||||
ov = home / "Applications/quest-frame/linux.overlay"
|
||||
w(ov / "deployment.json", json.dumps({"package": "linux.overlay", "appid": 1234, "title": "Temporal Reality",
|
||||
"kind": "linux", "overlay": {"registered": True}}))
|
||||
proc(root, 600, "bash", 1, start=50, cmdline=["bash", str(ov / "launch.sh")]) # started long before the game
|
||||
proc(root, 601, "TemporalReality", 600, start=50, cmdline=[str(ov / "app/TemporalReality")])
|
||||
proc(root, 700, "reaper", 100, start=150, cmdline=["reaper", "SteamLaunch", "AppId=448280", "--", "lepton"])
|
||||
proc(root, 701, "lepton", 700, start=150, cmdline=["/bin/bash", "lepton", "waitforexitandrun"])
|
||||
proc(root, 702, "reaper", 100, start=150, cmdline=["reaper", "SteamLaunch", f"AppId={fa.STEAMVR_APPID}"])
|
||||
m, _ = monitor(fa)
|
||||
s = m.sample(wall=2000.0)
|
||||
games = {g["package"]: g for g in s["games"]}
|
||||
steam = games["steam:448280"]
|
||||
assert steam["title"] == "Job Simulator" and steam["kind"] == "steam" and not steam["overlay"]
|
||||
assert games["linux.overlay"]["overlay"] and f"steam:{fa.STEAMVR_APPID}" not in games
|
||||
assert s["games"][-1]["package"] == "linux.overlay" # overlay apps last
|
||||
assert {p["pid"]: p["game"] for p in m.procs}[701] == "steam:448280"
|
||||
# the newest game first (the fixture's game started at 100, Job Simulator at 150)
|
||||
assert main_game(s)["package"] == "steam:448280" and now_playing(s)["title"] == "Job Simulator"
|
||||
# an overlay alone is nobody's game
|
||||
alone = {"games": [g for g in s["games"] if g["overlay"]]}
|
||||
assert main_game(alone) is None and now_playing(alone) is None
|
||||
@@ -0,0 +1,43 @@
|
||||
"""docs/PATCHES.md and the site's patch map are generated from the registry: they must be current."""
|
||||
import importlib.util
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _module():
|
||||
spec = importlib.util.spec_from_file_location("patch_docs", ROOT / "scripts" / "patch_docs.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def test_patch_docs_are_up_to_date():
|
||||
p = subprocess.run([sys.executable, str(ROOT / "scripts" / "patch_docs.py"), "--check"], capture_output=True,
|
||||
text=True)
|
||||
assert p.returncode == 0, p.stderr
|
||||
|
||||
|
||||
def test_every_patch_has_one_anchor_and_links_resolve():
|
||||
from frameport.patches import base
|
||||
|
||||
doc = (ROOT / "docs" / "PATCHES.md").read_text(encoding="utf-8")
|
||||
anchors = re.findall(r'<a id="([^"]+)"></a>', doc)
|
||||
assert len(anchors) == len(set(anchors))
|
||||
for p in base.all_patches():
|
||||
assert p.id.replace(".", "-") in anchors, p.id
|
||||
for target in re.findall(r"\]\(#([^)]+)\)", doc):
|
||||
assert target in anchors, target
|
||||
|
||||
|
||||
def test_suggestions_are_read_per_setting():
|
||||
from frameport.patches import base
|
||||
|
||||
mod = _module()
|
||||
reasons = [r for _, r, _ in mod.suggestions(base.get("adapter.controller_fix"))]
|
||||
assert reasons and all("Hand tracking" in r for r in reasons)
|
||||
assert mod.suggestions(base.get("adapter.swap_eyes")) == []
|
||||
assert mod.anchor("frame.unity_text_input") == "frame-unity_text_input"
|
||||
@@ -894,3 +894,35 @@ def test_bonelab_recipe_serves_both_builds():
|
||||
old, new = _analysis(engine="Unity", libs=libs), _analysis(engine="Unity", libs=libs + ["libSLZQuestNative.so"])
|
||||
assert presence.applies(old) and presence.applies(new)
|
||||
assert not hooks.applies(old) and hooks.applies(new)
|
||||
|
||||
|
||||
def test_ovr_stable_user_pins_the_made_up_user_id():
|
||||
"""GitHub #182: OVRPort makes up a new user id at every start (6e18 + random()); games lose saves filed under it."""
|
||||
from frameport.analysis import elf
|
||||
from frameport.patches.frame.ovr_stable_user import BASE_ID, find_helper, pin_user_id, stable_id
|
||||
|
||||
lib = _fixture("libfakestableuser_arm64.so")
|
||||
assert find_helper(lib) is not None
|
||||
uid = stable_id("com.example.game", b"keystore")
|
||||
assert BASE_ID <= uid < BASE_ID + 2**32
|
||||
assert uid == stable_id("com.example.game", b"keystore") != stable_id("com.example.other", b"keystore")
|
||||
assert uid != stable_id("com.example.game", b"another user's keystore")
|
||||
out = pin_user_id(lib, uid)
|
||||
assert out is not None and len(out) == len(lib)
|
||||
ins = [(m, o) for _, m, o in elf.text_instructions(out)]
|
||||
start = next(i for i, (m, o) in enumerate(ins) if m == "mov" and o.startswith("x0,") and ins[i + 1][0] == "movk")
|
||||
assert [m for m, _ in ins[start:start + 5]] == ["mov", "movk", "movk", "movk", "ret"]
|
||||
value = 0
|
||||
for i, (_, o) in enumerate(ins[start:start + 4]):
|
||||
imm = int(o.split("#")[1].split(",")[0], 0)
|
||||
value |= imm << (16 * i)
|
||||
assert value == uid
|
||||
assert not any(m == "bl" for m, _ in ins[start:start + 5]) # random() isn't called any more
|
||||
assert pin_user_id(out, uid) is None # idempotent
|
||||
assert pin_user_id(_fixture("libfakemicrophone_arm64.so"), uid) is None # not OVRPort's helper
|
||||
|
||||
|
||||
def test_ovr_stable_user_suggested_for_platform_games():
|
||||
p = base.get("frame.ovr_stable_user")
|
||||
assert p.detect(_analysis(engine="Unity", libs=["libovrplatformloader.so", "libunity.so"])).recommended
|
||||
assert p.detect(_analysis(engine="Unity", libs=["libunity.so"])) is None
|
||||
@@ -0,0 +1,73 @@
|
||||
"""A data folder shared by a Windows copy and WSL source runs (FRAMEPORT_WINDOWS_DATA)."""
|
||||
import json
|
||||
|
||||
from frameport.core import paths
|
||||
from frameport.tools import cpp2il, toolchain
|
||||
|
||||
|
||||
def test_local_path_on_windows(monkeypatch):
|
||||
monkeypatch.setattr(paths.sys, "platform", "win32")
|
||||
assert paths.local_path("/mnt/d/Games/X/a.apk") == "D:\\Games\\X\\a.apk"
|
||||
assert paths.local_path("/mnt/c") == "C:\\"
|
||||
assert paths.local_path("/home/steamos/Applications/x") == "/home/steamos/Applications/x" # Frame paths stay
|
||||
assert paths.local_path("D:\\Games") == "D:\\Games"
|
||||
|
||||
|
||||
def test_local_path_on_wsl(monkeypatch):
|
||||
monkeypatch.setattr(paths.sys, "platform", "linux")
|
||||
monkeypatch.setattr(paths, "shares_windows_data", lambda: True)
|
||||
assert paths.local_path("D:\\Games\\X\\a.apk") == "/mnt/d/Games/X/a.apk"
|
||||
assert paths.local_path("C:/Users/A") == "/mnt/c/Users/A"
|
||||
assert paths.local_path("/mnt/d/Games") == "/mnt/d/Games"
|
||||
monkeypatch.setattr(paths, "shares_windows_data", lambda: False)
|
||||
assert paths.local_path("D:\\Games") == "D:\\Games" # own data folder: left alone
|
||||
|
||||
|
||||
def test_localize_walks_json(monkeypatch):
|
||||
monkeypatch.setattr(paths.sys, "platform", "win32")
|
||||
data = {"games": {"a": {"apk": "/mnt/d/x.apk", "build": {"checks": [{"detail": "see /mnt/d/x"}]},
|
||||
"list": ["/mnt/e/y"], "n": 3, "title": "Cool"}}}
|
||||
paths.localize(data)
|
||||
g = data["games"]["a"]
|
||||
assert g["apk"] == "D:\\x.apk" and g["list"] == ["E:\\y"]
|
||||
assert g["build"]["checks"][0]["detail"] == "see /mnt/d/x" # only whole-string paths
|
||||
assert g["n"] == 3 and g["title"] == "Cool"
|
||||
|
||||
|
||||
def test_windows_data_only_when_asked(monkeypatch):
|
||||
monkeypatch.delenv("FRAMEPORT_WINDOWS_DATA", raising=False)
|
||||
assert not paths.shares_windows_data()
|
||||
monkeypatch.setenv("FRAMEPORT_WINDOWS_DATA", "1")
|
||||
assert not paths.shares_windows_data() # FRAMEPORT_HOME (set by the test fixtures) wins
|
||||
|
||||
|
||||
def test_java_entry_per_os(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(toolchain, "tools_dir", lambda: tmp_path)
|
||||
monkeypatch.delenv("FRAMEPORT_JAVA", raising=False)
|
||||
monkeypatch.setattr(toolchain.sys, "platform", "linux")
|
||||
linux_java = tmp_path / "jre-21" / "bin" / "java"
|
||||
linux_java.parent.mkdir(parents=True)
|
||||
linux_java.write_text("")
|
||||
# a legacy single entry written by Windows isn't this OS's java
|
||||
(tmp_path / "tools.json").write_text(json.dumps({"java": {"version": "21", "path": "C:\\\\x\\\\java.exe"}}))
|
||||
assert toolchain.java_path() is None
|
||||
(tmp_path / "tools.json").write_text(json.dumps({"java": {"version": "21", "path": str(linux_java)}}))
|
||||
assert toolchain.java_path() == linux_java
|
||||
(tmp_path / "tools.json").write_text(json.dumps({
|
||||
"java": {"version": "21", "path": "C:\\\\x\\\\java.exe"},
|
||||
toolchain._java_key(): {"version": "21", "path": str(linux_java)}}))
|
||||
assert toolchain.java_path() == linux_java
|
||||
|
||||
|
||||
def test_cpp2il_picks_this_os_exe(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(cpp2il, "tools_dir", lambda: tmp_path)
|
||||
monkeypatch.delenv("FRAMEPORT_CPP2IL", raising=False)
|
||||
d = tmp_path / "cpp2il-2022.1"
|
||||
d.mkdir()
|
||||
(d / "Cpp2IL.exe").write_text("")
|
||||
monkeypatch.setattr(cpp2il.sys, "platform", "linux")
|
||||
assert cpp2il.path() is None
|
||||
(d / "Cpp2IL").write_text("")
|
||||
assert cpp2il.path() == d / "Cpp2IL"
|
||||
monkeypatch.setattr(cpp2il.sys, "platform", "win32")
|
||||
assert cpp2il.path() == d / "Cpp2IL.exe"
|
||||
@@ -312,3 +312,52 @@ def test_unity_data_missing_fails_the_launch_test():
|
||||
"10-10 00:51:20.000 1 2 I FrameBridge: pacing: 72.0 fps\n")
|
||||
r = triage.triage(log, "RUNNING")
|
||||
assert "unity-data-missing" in [f.id for f in r.findings] and r.verdict == "fail"
|
||||
|
||||
|
||||
def test_webview_helper_exit_is_not_a_force_quit():
|
||||
"""GitHub #179: Android's WebView helper exits at every container boot; the game itself never started."""
|
||||
log = """
|
||||
10-11 05:09:26.499 220 249 I ActivityManager: Start proc 367:WebViewLoader-arm64-v8a/1037
|
||||
10-11 05:09:26.546 220 249 I ActivityManager: Start proc 397:com.android.networkstack.process/1073 for service
|
||||
10-11 05:09:26.579 367 367 I WebViewLoader-: System.exit called, status: 0
|
||||
LeptonProcessObserver: Target app (com.example.game) did not start.
|
||||
"""
|
||||
res = triage(log, "EXITED", package="com.example.game")
|
||||
ids = [f.id for f in res.findings]
|
||||
assert "force-quit" not in ids and "app-not-started" in ids
|
||||
assert "App process started" not in res.milestones
|
||||
# a real System.exit from the game still counts
|
||||
real = "09-28 17:39:01.000 1000 1000 I ActivityManager: Start proc 1150:com.example.game/u0a55 for activity\n" \
|
||||
"09-28 17:39:05.000 1150 1150 I GameActivity: System.exit called, status: 0\n"
|
||||
assert "force-quit" in [f.id for f in triage(real, "EXITED", package="com.example.game").findings]
|
||||
|
||||
|
||||
HEADLESS_END = """
|
||||
10-11 16:35:09.900 1000 1000 I ActivityManager: Start proc 1192:ru.targem.blazerush/u0a14 for activity
|
||||
10-11 16:35:12.282 1192 1222 I FrameBridge: xrCreateInstance result=0
|
||||
10-11 16:35:18.219 1192 1222 I FrameBridge: pacing: 68.7 fps, displayTime vs predicted: avg 0.00 ms, max 0.00 ms
|
||||
10-11 16:35:38.316 1192 1222 I FrameBridge: pacing: 72.2 fps, displayTime vs predicted: avg 0.00 ms, max 0.00 ms
|
||||
10-11 16:35:43.150 1192 1222 I OVRPortVrApi: EnterVrMode: no tracked head pose before deadline
|
||||
10-11 16:35:52.086 1192 1248 E MESA : zink: DEVICE LOST!
|
||||
10-11 16:35:52.087 1192 1248 F libc : Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x1a08
|
||||
"""
|
||||
|
||||
|
||||
def test_crash_after_the_headless_tracking_deadline_passes_a_launch_test():
|
||||
"""BlazeRush (GitHub #57): its launch test failed on the GPU crash that follows the VrApi bridge ending the session
|
||||
for lack of a worn headset; that never happens in the headset."""
|
||||
r = triage(HEADLESS_END, "EXITED", "ru.targem.blazerush", headless=True)
|
||||
assert r.verdict == "pass" and r.headless_end
|
||||
assert [f.id for f in r.findings] == ["headless-end"] and r.findings[0].severity == "info"
|
||||
assert "zink-device-lost" in r.findings[0].diagnosis
|
||||
# a play session (not headless) keeps the crash
|
||||
assert triage(HEADLESS_END, "EXITED", "ru.targem.blazerush").verdict == "fail"
|
||||
# a crash before the deadline still fails
|
||||
early = HEADLESS_END.replace("16:35:52.08", "16:35:40.08")
|
||||
early_lines = early.strip().splitlines()
|
||||
early = "\n".join(early_lines[:4] + early_lines[5:] + early_lines[4:5])
|
||||
r = triage(early, "EXITED", "ru.targem.blazerush", headless=True)
|
||||
assert r.verdict == "fail" and {"zink-device-lost", "native-crash"} <= {f.id for f in r.findings}
|
||||
# no frames before the deadline: the game never ran
|
||||
no_frames = "\n".join(ln for ln in HEADLESS_END.splitlines() if "pacing:" not in ln)
|
||||
assert triage(no_frames, "EXITED", "ru.targem.blazerush", headless=True).verdict == "fail"
|
||||
@@ -411,3 +411,13 @@ def test_pending_news_shows_once_and_waits_while_offline():
|
||||
library.set_setting("update.last_seen_version", None)
|
||||
library.set_setting("update.last_check", 1.0) # updated from an older FramePort
|
||||
assert [e.version for e in updates.pending_news("0.9.2", entries)] == ["0.9.2"]
|
||||
|
||||
|
||||
def test_relaunch_environment_drops_the_running_apps_own_variables():
|
||||
"""GitHub #195: the relaunched app inherited the old app's Flet/embedded-Python variables and stayed black."""
|
||||
from frameport import updates
|
||||
|
||||
env = updates.clean_env({"PATH": "/bin", "FLET_SERVER_UDS_PATH": "/tmp/old.sock", "flet_platform": "windows",
|
||||
"SERIOUS_PYTHON_SITE_PACKAGES": "x", "PYTHONHOME": "C:/Temp/serious_python_temp",
|
||||
"PYTHONPATH": "y", "USERPROFILE": "C:/Users/u", "PSModulePath": "z"})
|
||||
assert env == {"PATH": "/bin", "USERPROFILE": "C:/Users/u", "PSModulePath": "z"}
|
||||