Compare commits

...
7 Commits
Author SHA1 Message Date
spoopyghosty0 d2017194f3 Catalog recipes reach games already in the library: recipes remember the catalog entry they came from (catalog_rev) and are derived again when it changes (never the user's own edits); a maintained entry updated after the user's shared copy wins. Recipes: I Am Cat (issues: frame.unity_multipass), Lucky's Tale (issues: frame.unity_runtime_msaa_off), VR4 works with the no-ForceQuit build 2026-10-04 01:31:16 -04:00
spoopyghosty0 7a11706b3a FramePort 0.6.3: Unity patches frame.unity_runtime_msaa_off (OVRManager turns 4x MSAA on at runtime: Lucky's Tale restarted the headset, #9) and frame.unity_multipass (Oculus XR Plugin multiview: I Am Cat's grey right eye, #8), sharing one Cpp2IL run per build; triage signature for runtime MSAA; VR4 installs the no-ForceQuit build (#10) 2026-10-04 01:22:47 -04:00
spoopyghosty0 76d4390b19 Steam library entries (issues #4, #5: 'Game configuration unavailable', no shortcuts): agent v35 adds shortcuts for the signed-in Steam account (loginusers.vdf), else every account, instead of failing when the Frame has several; Play launches the shortcut's own id and says when a game isn't in the library, and the app then adds it and starts the game; an install whose library entry isn't confirmed says so. Triage: OVRPlugin's routine 'Unavailable OpenXR extension: XR_FB_scene' no longer suggests scene emulation 2026-10-04 01:10:52 -04:00
spoopyghosty0 2022d9986a Catalog: VR4 keeps its package title (com.Armature.VR4; the store name stays in source_hint for matching dump folders) 2026-10-04 00:54:16 -04:00
spoopyghosty0 9ff7edf93b Catalog: working configs from issues #1 (Resident Evil 4, package com.Armature.VR4: works with issues), #3 (Cook-Out), #6 (Beat Saber), #11 (Job Simulator), #12 (Dinosaur Island); games list regenerated. catalog_from_issue: 'device' is a checked list field, and FramePort patch ids must exist 2026-10-04 00:52:39 -04:00
spoopyghosty0 a5722b50a0 Windows games without VR (Reddit request): a game folder added with 'Add one game folder…' that has no VR runtime becomes a flat Windows game: only the Proton patches, a launcher without SteamGameId (Proton sets up no VR), a non-VR Steam shortcut (OpenVR=0, tag 'Windows game on Frame'), labels 'Windows game (Proton)'; no 'Play on this PC' 2026-10-03 19:38:23 -04:00
spoopyghosty0 0a13ca72a2 Frame crash detection: agent v34 reports the boot id, whether the previous boot shut down cleanly (journal) and the FramePort game launched last before it; the app compares with the last boot it saw and, after an unclean restart soon after a game started, says so with Report a problem. Diagnostics include the previous boot's errors and kernel warnings (GPU resets, OOM) 2026-10-03 19:33:37 -04:00
40 changed files with 857 additions and 92 deletions

No files matched your search

+12
View File
@@ -414,6 +414,18 @@ for steamos (Steam Input) → agent v33 `_keyboard` uinput keyboard ("Type on Fr
Installing a second APK into a container with `pm install` re-runs Lepton's post-install hook on the **main** app
(`lepton.active_app_id`; moves its files to /data/steam_app) and corrupts it on the next start (fix: touch the APK →
re-bake); `cmd_real package install` skips the hook.
**User reports (2026-10-04, issues #4-#10):** Steam shortcuts go to the signed-in account (`loginusers.vdf`
MostRecent) else every account (agent v35 `library_users`; it used to refuse with >1 account → Play gave Steam's "Game
configuration unavailable"); `launch` uses the shortcut's own appid and errors `NOT_IN_LIBRARY`, the app then adds it
and plays. Unity IL2CPP fixes share one Cpp2IL run (`unity_text_input.Il2cppReturnPatch`, `ALL_TARGETS`, cache keyed by
global-metadata.dat): `frame.unity_runtime_msaa_off` (OVRManager raises MSAA to 4x at runtime: "Switching to the
recommended level" → Lucky's Tale restarted the headset; `OVRDisplay.get_recommendedMSAALevel` → 0) and
`frame.unity_multipass` (Oculus XR Plugin multiview → MultiPass via `OculusSettings.GetStereoRenderingMode`; I Am Cat's
right eye grey). Both checked against Toy Master's Cpp2IL output, not yet in a headset. VR4 quits itself (System.exit
after the intro movie) → catalog `use_alt`. **Catalog changes reach existing games** without hardcoded migrations:
recipes store `catalog_rev` (= `CatalogEntry.rev()`), `library._follow_catalog` re-derives non-user recipes whose entry
changed; a maintained entry beats the user's shared copy when its `updated`/verified date is later (`catalog._newer`). Unity `boot.config` "vulkan" substring mislabels GLES games as Vulkan
(I Am Cat ran GLES); OVRPlugin's "Unavailable OpenXR extension: XR_FB_scene" is routine (no longer triaged).
**Lepton storage (2026-09-30):** each app's /sdcard (= /storage/emulated/0 → `<base>/lepton-data/external`) has `Movies`/`Download`/`Documents` symlinked to the Frame's `~/Videos`/`~/Downloads`/`~/Documents` (liblepton/mounting.sh, only if they exist at start); agent v24 `storage_targets` reads that mapping. Android's MediaProvider canonicalises paths to /home/steamos/... and rejects every file ("doesn't appear under [/system/media...]"), `sm list-volumes` is empty: the media index never works, apps must browse folders. Lepton installs with `adb install -g` (runtime permissions granted, MANAGE_EXTERNAL_STORAGE too). Files: `install/files.py`, `frameport frame send|storage`, GUI Files tab (formerly Frame → Send files).
**SteamVR per-app settings (2026-09-30):** editing steamvr.vrsettings while SteamVR runs is lost; the web API (127.0.0.1:27062 /app/setsettings) needs `x-steamvr-secret`. `native/vrsettings` = `fp_vrsettings.exe` (freestanding, OpenVR `FnTable:IVRSettings_003` as a Utility app, loads SteamVR's bin/win64/openvr_api.dll) sets them live and SteamVR persists them: section `steam.app.<shortcut appid>`, keys `preferredRefreshRate` (float) and `motionSmoothingOverride` (0 global, 1 on, 2 off, 3 always). Steam Link (vrlink) lists the Frame's rates 72/80/90/96/108/120/144 in vrserver.txt and follows the per-app preference ("host preferred N Hz"; whether the key is honoured is unverified in-headset yet). Judder metric: vrcompositor.txt session summary dropped + "Timed out. N total" (Stormland: 0 dropped but 313 timeouts in 2 min); fpsVR (`%LOCALAPPDATA%\fpsVR\*.json`, 0.1 ms histograms) gives p99 CPU/GPU ms. `pcvr.steamvr_tuning` (default on, PC only) applies on Play: highest rate whose budget ≥ p99×1.05, at least one step down, smoothing on.
+2 -1
View File
@@ -26,7 +26,8 @@ Install games that target the Meta Quest, Android, or general PCVR onto your **V
- **Per-game recipes:** a tested catalog plus detection rules; every patch explained in plain words.
- **FrameBridge:** FramePort's OpenXR adapter emulates what the Frame natively lacks (passthrough, room, controller models,
curved and 360° layers); game settings as simple switches.
- **Beyond Quest:** Android apps as windows, PC VR via Proton or Revive, a Files tab with drag and drop.
- **Beyond Quest:** Android apps as windows, PC VR via Proton or Revive, Windows (non-VR) games via Proton, a Files
tab with drag and drop.
- **Self-updating** releases, redacted diagnostics, one-click problem reports and working-config sharing.
## Quick start
+172 -43
View File
@@ -35,7 +35,7 @@ import sys
import time
import zlib
AGENT_VERSION = 33
AGENT_VERSION = 35
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -564,6 +564,48 @@ def steam_users():
if os.path.isdir(os.path.join(STEAM, "userdata")) else []
STEAMID64_BASE = 76561197960265728
def active_steam_user():
"""userdata folder (account id) of the most recent Steam login (config/loginusers.vdf), if it has one."""
try:
text = open(os.path.join(STEAM, "config/loginusers.vdf"), encoding="utf-8", errors="replace").read()
except OSError:
return None
users = steam_users()
for sid, body in re.findall(r'"(\d{17})"\s*\{([^}]*)\}', text):
if re.search(r'"MostRecent"\s+"1"', body) and str(int(sid) - STEAMID64_BASE) in users:
return str(int(sid) - STEAMID64_BASE)
return None
def library_users():
"""Steam accounts whose library gets FramePort's shortcuts: the signed-in one, else every account on the Frame
(several accounts used to stop the shortcut step: "found 2 Steam users", and Play then failed with Steam's "Game
configuration unavailable")."""
users = steam_users()
if not users:
raise AgentError("Steam has no signed-in account on this Frame yet; sign in to Steam on the Frame first")
active = active_steam_user()
return [active] if active else users
def shortcut_appid_for(exe):
"""appid of the shortcut whose Exe is `exe` in the signed-in account's library (any account if unknown); None
when it isn't in the library."""
for u in library_users():
vdf = os.path.join(STEAM, "userdata", u, "config/shortcuts.vdf")
try:
root = vdf_decode(open(vdf, "rb").read()) if os.path.exists(vdf) else {}
except (OSError, AgentError):
continue
for v in (root.get("shortcuts") or {}).values():
if isinstance(v, dict) and v.get("Exe") == exe and v.get("appid"):
return v["appid"] & 0xFFFFFFFF
return None
def container_running(appid):
p = run(["podman", "ps", "--format", "{{.Names}}"])
return f"lepton-steamlaunch-{appid}" in p.stdout.split()
@@ -637,6 +679,58 @@ def battery_state():
return battery
BOOT_STATE = os.path.join(HOME, ".cache/frameport-boot.json")
# what the last lines of a boot's journal say when it was shut down or rebooted on purpose (a crash, a GPU hang that
# reset the Frame or a pulled battery leaves none of these)
CLEAN_SHUTDOWN = ("Reached target System Power Off", "Reached target System Reboot", "Reached target Shutdown",
"System is powering down", "System is rebooting", "systemd-shutdown", "Power-Off", "Rebooting.")
def boot_state():
"""This boot and how the previous one ended: {"boot_id", "boot_time", "prev_clean" (True/False/None = unknown),
"last_launch" ({"package", "title", "time"}: the FramePort game started last before this boot)}. Worked out once
per boot (cached), so the app can say "your Frame restarted while <game> was running"."""
try:
boot_id = open("/proc/sys/kernel/random/boot_id").read().strip()
except OSError:
return None
try:
cached = json.load(open(BOOT_STATE))
if cached.get("boot_id") == boot_id:
return cached
except (OSError, ValueError):
pass
boot_time = None
try:
for line in open("/proc/stat"):
if line.startswith("btime "):
boot_time = int(line.split()[1])
except OSError:
pass
try:
tail = run(["journalctl", "-b", "-1", "-n", "120", "-q", "--no-pager", "-o", "cat"]).stdout
except OSError:
tail = ""
prev_clean = any(m in tail for m in CLEAN_SHUTDOWN) if tail.strip() else None
last = None
for d in cmd_list_installed({})["games"]:
log = os.path.join(d["base"], "launch.log")
try:
t = os.path.getmtime(log)
except OSError:
continue
if (boot_time is None or t < boot_time) and (last is None or t > last["time"]):
last = {"package": d["package"], "title": d.get("title") or d["package"], "time": t}
state = {"boot_id": boot_id, "boot_time": boot_time, "prev_clean": prev_clean, "last_launch": last}
try:
os.makedirs(os.path.dirname(BOOT_STATE), exist_ok=True)
with open(BOOT_STATE, "w") as f:
json.dump(state, f)
except OSError:
pass
return state
def cmd_battery(args):
return {"battery": battery_state()}
@@ -660,6 +754,7 @@ def cmd_info(args):
"steam_running": run(["pgrep", "-x", "steam"]).returncode == 0,
"host_fixes": ensure_host_fixes(), "kernel_keys": key_usage(),
"proton": cmd_proton_status({}) if args.get("proton", True) else None,
"boot": boot_state(),
"battery": battery_state(),
}
@@ -735,7 +830,8 @@ def vdf_encode(obj):
return bytes(out) + bytes([TYPE_END])
def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Frame", launch_options="", tags=None):
def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Frame", launch_options="", tags=None,
openvr=True):
"""Add/update a non-Steam shortcut (matched by Exe, so its appid never changes). `tags` (genres, the user's tags)
are merged with tags already on the shortcut, so ones set in Steam are kept."""
data = open(vdf_path, "rb").read() if os.path.exists(vdf_path) else b""
@@ -751,8 +847,8 @@ def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Fram
merged = list(dict.fromkeys(have + [t for t in [tag] + list(tags) if t]))
entry["tags"] = {str(i): t for i, t in enumerate(merged)}
entry.update(appname=title, Exe=exe, StartDir=start_dir, icon=icon or entry.get("icon", ""), ShortcutPath="",
LaunchOptions=launch_options, IsHidden=0, AllowDesktopConfig=1, AllowOverlay=1, OpenVR=1, Devkit=0,
DevkitGameID="", DevkitOverrideAppID=0, FlatpakAppID="")
LaunchOptions=launch_options, IsHidden=0, AllowDesktopConfig=1, AllowOverlay=1,
OpenVR=1 if openvr else 0, Devkit=0, DevkitGameID="", DevkitOverrideAppID=0, FlatpakAppID="")
if data:
backup_vdf(vdf_path)
os.makedirs(os.path.dirname(vdf_path), exist_ok=True)
@@ -875,42 +971,15 @@ def shortcuts_worker(payload):
args = json.loads(payload)
result = {"state": "done", "added": [], "errors": [], "finished": None}
try:
users = steam_users()
if len(users) != 1:
raise AgentError(f"found {len(users)} Steam users; not guessing which library to edit")
vdf = os.path.join(STEAM, "userdata", users[0], "config/shortcuts.vdf")
users = library_users()
try:
service = stop_steam()
except AgentError:
raise AgentError("Steam did not close; library not modified") from None
grid = os.path.join(os.path.dirname(vdf), "grid")
os.makedirs(grid, exist_ok=True)
for r in args.get("remove", []): # uninstalled games
try:
if remove_shortcut(vdf, r["exe"]):
result.setdefault("removed", []).append(r["exe"])
for art in grid_files(grid, r.get("appid") or ""):
os.remove(art)
except Exception as exc: # noqa: BLE001
result["errors"].append(f"{r.get('exe')}: {exc}")
for pkg in args["packages"]:
try:
anchor = os.path.join(ANCHORS, pkg)
dep = json.load(open(os.path.join(anchor, "deployment.json")))
icon = next(iter(glob.glob(os.path.join(anchor, "artwork/icon.*"))), "")
tag = "PC VR on Frame" if dep.get("kind") == "pcvr" else "Quest on Frame"
got = upsert_shortcut(vdf, f'"{anchor}/launch.sh"', dep["title"], anchor, icon, tag,
tags=dep.get("tags") or [])
for kind, suffix in (("portrait", "p"), ("landscape", ""), ("hero", "_hero"), ("logo", "_logo")):
img = next(iter(glob.glob(os.path.join(anchor, f"artwork/{kind}.*"))), None)
if not img:
continue
for old in glob.glob(os.path.join(grid, f"{got}{suffix}.*")):
os.remove(old)
shutil.copy(img, os.path.join(grid, f"{got}{suffix}{os.path.splitext(img)[1]}"))
result["added"].append({"package": pkg, "appid": got, "expected": dep["appid"]})
except Exception as exc: # noqa: BLE001
result["errors"].append(f"{pkg}: {exc}")
for user in steam_users(): # uninstalled games leave every account's library
remove_from_library(user, args.get("remove", []), result)
for user in users:
update_library(user, args["packages"], result)
if service or args.get("restart", True):
start_steam(service)
except Exception as exc: # noqa: BLE001
@@ -922,6 +991,48 @@ def shortcuts_worker(payload):
json.dump(result, f)
def remove_from_library(user, remove, result):
"""Remove uninstalled games' shortcuts + grid art from one Steam account (Steam is closed)."""
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
grid = os.path.join(os.path.dirname(vdf), "grid")
for r in remove:
try:
if remove_shortcut(vdf, r["exe"]):
result.setdefault("removed", []).append(r["exe"])
for art in grid_files(grid, r.get("appid") or ""):
os.remove(art)
except Exception as exc: # noqa: BLE001
result["errors"].append(f"{r.get('exe')}: {exc}")
def update_library(user, packages, result):
"""Add/update installed games' shortcuts + grid art in one Steam account (Steam is closed)."""
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
grid = os.path.join(os.path.dirname(vdf), "grid")
os.makedirs(grid, exist_ok=True)
for pkg in packages:
try:
anchor = os.path.join(ANCHORS, pkg)
dep = json.load(open(os.path.join(anchor, "deployment.json")))
icon = next(iter(glob.glob(os.path.join(anchor, "artwork/icon.*"))), "")
flat = dep.get("vr") is False
tag = ("Windows game on Frame" if flat else "PC VR on Frame") if dep.get("kind") == "pcvr" \
else "Quest on Frame"
got = upsert_shortcut(vdf, f'"{anchor}/launch.sh"', dep["title"], anchor, icon, tag,
tags=dep.get("tags") or [], openvr=not flat)
for kind, suffix in (("portrait", "p"), ("landscape", ""), ("hero", "_hero"), ("logo", "_logo")):
img = next(iter(glob.glob(os.path.join(anchor, f"artwork/{kind}.*"))), None)
if not img:
continue
for old in glob.glob(os.path.join(grid, f"{got}{suffix}.*")):
os.remove(old)
shutil.copy(img, os.path.join(grid, f"{got}{suffix}{os.path.splitext(img)[1]}"))
if not any(a["package"] == pkg for a in result["added"]):
result["added"].append({"package": pkg, "appid": got, "expected": dep["appid"]})
except Exception as exc: # noqa: BLE001
result["errors"].append(f"{pkg}: {exc}")
def cmd_shortcut_status(args):
try:
return json.load(open(STATUS_FILE))
@@ -965,6 +1076,9 @@ def steam_gameid(appid):
return (int(appid) << 32) | 0x02000000
NOT_IN_LIBRARY = "not in the Frame's Steam library"
def cmd_launch(args):
"""Start an installed game the way the headset's library does: ask the running Steam to launch its shortcut, so
it gets Steam's VR session, overlay and controller setup (unlike launch_test's direct, headless start)."""
@@ -974,7 +1088,10 @@ def cmd_launch(args):
raise AgentError(f"{pkg} is not installed")
if run(["pgrep", "-x", "steam"]).returncode != 0:
raise AgentError("Steam isn't running on the Frame")
gid = steam_gameid(dep["appid"])
appid = shortcut_appid_for(f'"{os.path.join(ANCHORS, pkg)}/launch.sh"')
if appid is None: # Steam would only say "Game configuration unavailable"
raise AgentError(f"{NOT_IN_LIBRARY}: {dep.get('title') or pkg}")
gid = steam_gameid(appid) # the shortcut's own id (it keeps its first one when the title changes)
# systemd-run: the launch request must outlive this SSH session
run(["systemd-run", "--user", "--collect", "--quiet", f"--unit=frameport-launch-{int(time.time())}",
"steam", "-ifrunning", f"steam://rungameid/{gid}"])
@@ -1665,8 +1782,7 @@ set -euo pipefail
base={base_q}
[[ -d "$base/game" ]] || {{ echo "Game files missing at $base (storage not mounted?)" >&2; exit 1; }}
export SteamAppId={appid}
export SteamGameId={appid}
export STEAM_COMPAT_APP_ID={appid}
{steam_game_id}export STEAM_COMPAT_APP_ID={appid}
export STEAM_COMPAT_DATA_PATH="$base/compatdata"
export STEAM_COMPAT_CLIENT_INSTALL_PATH={steam_q}
export STEAM_COMPAT_INSTALL_PATH="$base/game"
@@ -1733,7 +1849,7 @@ OCULUS_HMD_HELPER = "fp_oculushmd.exe"
def write_proton_launcher(anchor, base, pkg, title, appid, tool, exe_rel, revive, env, xr_layer=False, game_args=(),
oculus_hmd=False):
oculus_hmd=False, vr=True):
exe = os.path.join(base, "game", exe_rel)
prefix = compat_command(tool["dir"])
injector = os.path.join(base, "revive", "ReviveInjector.exe")
@@ -1754,6 +1870,8 @@ def write_proton_launcher(anchor, base, pkg, title, appid, tool, exe_rel, revive
text = LAUNCH_PROTON_SH.format(
title=title.replace("\n", " "), pkg=pkg, base_q=shlex.quote(base), appid=appid, steam_q=shlex.quote(STEAM),
tool=tool["name"], extra_env=extra, workdir=("/" + shlex.quote(workdir)) if workdir else "",
# Proton sets up VR (vrclient, wineopenxr) only when SteamGameId is set: a flat Windows game goes without
steam_game_id=f"export SteamGameId={appid}\n" if vr else "",
xr_layer=XR_LAYER_ENV if xr_layer else "",
command=" ".join(shlex.quote(a) for a in argv))
path = os.path.join(anchor, "launch.sh")
@@ -1863,8 +1981,9 @@ def cmd_finalize_pcvr(args):
raise AgentError(f"{OCULUS_HMD_HELPER} missing")
set_crash_reporter(base, enabled=not args.get("no_crash_reporter"))
set_libovr_redirect(base, exe_rel, enabled=bool(args.get("libovr_redirect")), bundled=not revive)
vr = args.get("vr", True) is not False # False: a flat Windows game (no VR at all)
write_proton_launcher(anchor, base, pkg, title, appid, tool, exe_rel, revive, args.get("env"), xr_layer,
args.get("game_args") or [], oculus_hmd)
args.get("game_args") or [], oculus_hmd, vr=vr)
art_in = os.path.join(base, "incoming-artwork")
if os.path.isdir(art_in):
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
@@ -1872,7 +1991,7 @@ def cmd_finalize_pcvr(args):
dep = {"package": pkg, "kind": "pcvr", "appid": int(appid), "base": base, "title": title, "exe": exe_rel,
"tags": args.get("tags") or [],
"sha256": args.get("exe_sha256"), "revive": revive, "revive_version": args.get("revive_version"),
"proton": tool["name"], "xr_layer": xr_layer, "oculus_hmd": oculus_hmd,
"proton": tool["name"], "xr_layer": xr_layer, "oculus_hmd": oculus_hmd, "vr": vr,
"libovr_redirect": bool(args.get("libovr_redirect")),
"recipe": args.get("recipe"),
"installed_by": "frameport",
@@ -1956,7 +2075,7 @@ def cmd_uninstall(args):
remove_tree(base)
anchor = os.path.join(ANCHORS, pkg)
removed_sc = False
if args.get("remove_shortcut") and len(steam_users()) == 1:
if args.get("remove_shortcut") and steam_users():
# Steam keeps its own copy of shortcuts.vdf and writes it back: change it only with Steam closed (worker)
removed_sc = cmd_shortcuts({"remove": [{"exe": f'"{anchor}/launch.sh"', "appid": dep.get("appid")}]})["started"]
if not keep_data or base != anchor:
@@ -2144,6 +2263,16 @@ def cmd_collect_diag(args):
"{{.Names}} {{.Status}}"]).stdout[-20000:]
host["steam_running"] = run(["pgrep", "-x", "steam"]).returncode == 0
host["uptime"] = _tail("/proc/uptime", 200)
host["boot"] = boot_state()
# how the previous boot ended: errors and kernel (GPU/msm/kgsl, OOM, panic) warnings before a crash or reset
for name, extra in (("previous-boot-errors.txt", ["-p", "err"]),
("previous-boot-kernel.txt", ["-k", "-p", "warning"])):
try:
text = run(["journalctl", "-b", "-1", *extra, "-n", "400", "-q", "--no-pager"]).stdout[-max_bytes:]
except OSError:
text = ""
if text.strip():
files[name] = text
host["installed"] = [{k: g.get(k) for k in ("package", "title", "kind", "appid", "version", "agent_version")}
for g in cmd_list_installed({})["games"]]
out = {"agent_version": AGENT_VERSION, "host": host, "files": files}
+22
View File
@@ -0,0 +1,22 @@
package: com.Armature.VR4
title: VR4
status: works
notes: Installs the no-ForceQuit build (the regular one quits after the opening cutscene).
details: The regular build quits itself when the first level loads after the opening cutscene (System.exit, GitHub
issue 10), so the no-ForceQuit build is installed.
tested_version: '2.3'
engine: Unreal
xr: VrApi
alt_overport:
- patch_remove_unreal_force_quit
use_alt: true
verified:
date: '2026-10-02'
known_good_sha256: 40ff74e37264b28ae8826923b69d0731d9620094c76462be34159643d6ae91b3
app: 0.3.4
overport_cli: 1.2.5
frame_build: '20260930.6234839'
agent: 26
issue: 1
updated: '2026-10-04'
source_hint: Resident Evil 4
@@ -0,0 +1,20 @@
package: com.NewFolderGames.IAmCat
title: I Am Cat
status: issues
notes: Renders each eye separately; with the default single-pass rendering the right eye showed only effects on grey.
details: Unity 2022.3 with the Oculus XR Plugin falls back to GLES (Zink) on the Frame, where its multiview pass drew
the right eye's slice wrong (GitHub issue 8). frame.unity_multipass switches the game to multi-pass rendering; not
yet confirmed in a headset.
tested_version: 1.4.0.0
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
- frame.unity_multipass
device:
- device.text_input_window
verified:
date: '2026-10-03'
issue: 8
updated: '2026-10-04'
source_hint: I Am Cat
@@ -0,0 +1,19 @@
package: com.beatgames.beatsaber
title: Beat Saber
status: works
tested_version: 1.40.8_7379
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-10-03'
known_good_sha256: ee720e040931f2547f7611e58ec5ad72d86e800dd4cf3135f576019683f2f359
app: 0.6.1
overport_cli: 1.2.5
frame_build: '20260930.6234839'
agent: 33
issue: 6
source_hint: Beat Saber
+19
View File
@@ -0,0 +1,19 @@
package: com.lakento.dinosquest
title: Dinosaur Island
status: works
tested_version: 1.7.5
engine: Unity
xr: VrApi
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-10-03'
known_good_sha256: 41ecbc728dc533eebcc3980e140fe1764fbd1958735d8a299c63fa9cda13a923
app: 0.6.2
overport_cli: 1.2.5
frame_build: '20260930.6234839'
agent: 33
issue: 12
source_hint: DINOSAUR ISLAND
@@ -0,0 +1,19 @@
package: com.owlchemylabs.jobsimulator
title: Job Simulator
status: works
tested_version: 1.4.0.6515
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-10-03'
known_good_sha256: 64638d7f93bb93cc61f422a43a08bb0d35b9bb4b463e90a42da7002147f03d1b
app: 0.6.2
overport_cli: 1.2.5
frame_build: '20260930.6234839'
agent: 33
issue: 11
source_hint: Job Simulator
+20
View File
@@ -0,0 +1,20 @@
package: com.playful.LuckysTale
title: Lucky's Tale
status: issues
notes: Keeps MSAA off; the game turned it on by itself and the whole Frame restarted in a menu.
details: Meta's OVRManager raises MSAA to 4x at runtime ("Switching to the recommended level"); multisampled
render-to-texture on GLES (Zink) hung the Frame's GPU (GitHub issue 9). frame.unity_runtime_msaa_off keeps it at 0;
not yet confirmed in a headset.
tested_version: 1.0.55
engine: Unity
xr: VrApi
frame:
- frame.unity_text_input
- frame.unity_runtime_msaa_off
device:
- device.text_input_window
verified:
date: '2026-10-03'
issue: 9
updated: '2026-10-04'
source_hint: Luckys Tale
@@ -0,0 +1,19 @@
package: com.resolutiongames.cookinggame
title: Cook-Out
status: works
tested_version: 1.20.0.10625-release
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-10-03'
known_good_sha256: 7217649c503adf76f3a959fc72858c446c2a42a9ce9e209286977b1950397915
app: 0.6.1
overport_cli: 1.2.5
frame_build: '20260930.6234839'
agent: 33
issue: 3
source_hint: com.resolutiongames.cookinggame
+9 -2
View File
@@ -44,7 +44,13 @@ signatures:
pattern: 'zink.*DEVICE[_ ]LOST|VK_ERROR_DEVICE_LOST'
severity: fatal
diagnosis: The GPU hung (Zink/Mesa). For GLES Unity games MSAA render-to-texture is the usual cause.
suggest: [frame.unity_no_msaa]
suggest: [frame.unity_no_msaa, frame.unity_runtime_msaa_off]
- id: unity-runtime-msaa
pattern: 'recommended MSAA level is [248]\. Switching to the recommended level'
severity: info
diagnosis: The game's OVRManager turns MSAA on at runtime (QualitySettings can't keep it off). On GLES this can
hang the Frame's GPU or restart the headset; if it does, keep it off in the game's code.
suggest: [frame.unity_runtime_msaa_off]
- id: gl-shader-failed
pattern: 'GLShim\s*: SHADER COMPILE FAILED|#extension directive is not allowed in the middle|could not implicitly convert operands'
severity: error
@@ -79,7 +85,8 @@ signatures:
agent's controller_models command).
suggest: []
- id: scene-missing
pattern: 'XR_FB_scene|XR_FB_spatial_entity.*(not supported|UNSUPPORTED)|xrQuerySpacesFB'
# not OVRPlugin's routine "Unavailable OpenXR extension: XR_FB_scene" (printed by every OVRPlugin game)
pattern: '(?<!Unavailable OpenXR extension: )XR_FB_scene|XR_FB_spatial_entity.*(not supported|UNSUPPORTED)|xrQuerySpacesFB'
severity: warning
diagnosis: The game uses the Meta scene (room) API; enable the adapter's guardian-based room emulation.
suggest: [adapter.scene_emul]
+7
View File
@@ -11,12 +11,16 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| BAM | Quest | ✅ Works | |
| BARTENDER VR SIMULATOR | Quest | ✅ Works | |
| Batman: Arkham Shadow | Quest | ✅ Works | |
| Beat Saber | Quest | ✅ Works | |
| Carve Snowboarding | Quest | ✅ Works | |
| Cook-Out | Quest | ✅ Works | |
| Demeter | Quest | ✅ Works | |
| Dinosaur Island | Quest | ✅ Works | |
| Espire 2 | Quest | ✅ Works | |
| Genotype | Quest | ✅ Works | |
| H.U.N.T | Quest | ✅ Works | |
| In Death: Unchained | Quest | ✅ Works | |
| Job Simulator | Quest | ✅ Works | |
| LEGO® Bricktales | Quest | ✅ Works | |
| Marvel's Deadpool VR | Quest | ✅ Works | |
| Medieval Dynasty New Settlement | Quest | ✅ Works | |
@@ -33,9 +37,12 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| The Climb 2 | Quest | ✅ Works | |
| Toy Master | Quest | ✅ Works | |
| Under Cover | Quest | ✅ Works | |
| VR4 | Quest | ✅ Works | |
| Wallace & Gromit in The Grand Getaway | Quest | ✅ Works | |
| Arcsmith | Quest | ⚠️ Works with issues | Right eye distorts during movement (unresolved; swap, tracking, Valve layers, depth and pacing ruled out). |
| Assassin's Creed Nexus | Quest | ⚠️ Works with issues | Some launch warning text is still upside down; the rest of the UI is fixed by flip emulation. |
| I Am Cat | Quest | ⚠️ Works with issues | Renders each eye separately; with the default single-pass rendering the right eye showed only effects on grey. |
| Lucky's Tale | Quest | ⚠️ Works with issues | Keeps MSAA off; the game turned it on by itself and the whole Frame restarted in a menu. |
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
| Time Stall | Quest | ⚠️ Works with issues | Both eyes distort during movement (unresolved). |
+5
View File
@@ -119,6 +119,11 @@ an installed Revive, or downloads a portable copy.
on the game page.
- Games that use the Oculus Platform SDK check the licence through the Oculus app, so they run on the PC only.
**Windows games without VR:** **Add games → Add one game folder…** with the game's folder (pick the program that
starts it if asked). FramePort installs it on the Frame and Proton runs it as a window, like Steam's own Windows games;
it shows up in the Frame's Steam library tagged "Windows game on Frame". Whether a game runs depends on Proton on ARM
(x86 games run through emulation).
## Files on the Frame (videos, documents, mods, saves)
The **Files** tab manages files on the Frame over the same connection as installs; no other transfer app is needed.
+2
View File
@@ -50,6 +50,8 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Quest app keeps recentering / snapping the view when you turn your head; log shows session state 5→4→3→4→5 within a second | the Frame briefly takes focus; the app recenters on focus changes | unfixed: hiding the dips (debounce) made AC Nexus stay black, so it was removed |
| Video player / app finds no local videos; MediaProvider "Requested path /home/steamos/... doesn't appear under ..." | Lepton's /sdcard is a symlink to a host path, so Android's media index rejects every file | upload in the Files tab (Videos = /sdcard/Movies) and browse folders in the app |
| Game (Unity, GLES) freezes, `zink: DEVICE LOST` | multisampled render-to-texture hangs the GPU | `frame.unity_no_msaa`; if it persists: unfixable → PC version |
| Unity game hangs or the whole Frame restarts (e.g. in a menu); log: `The current MSAA level is 0, but the recommended MSAA level is 4. Switching to the recommended level.` (e.g. Lucky's Tale) | Meta's OVRManager (`useRecommendedMSAALevel`) turns 4x MSAA on at runtime, past QualitySettings; multisampled render-to-texture on GLES/Zink hangs the GPU | `frame.unity_runtime_msaa_off` (Cpp2IL: `OVRDisplay.get_recommendedMSAALevel` → 0) |
| Unity game: one eye shows only effects or grey, the other is fine (e.g. I Am Cat, Oculus XR Plugin on GLES) | the game's single-pass multiview rendering goes wrong for array slice 1 (the adapter submits both slices correctly) | `frame.unity_multipass` (Cpp2IL: `OculusSettings.GetStereoRenderingMode` → MultiPass); `swap_eyes=1` tells whether the game or the adapter is at fault |
| Setup/intro loops every launch (Espire 2) | save folders created without write permission | launcher repairs permissions every 2 s (built in) |
| VrApi bridge: never enters VR | Frame reaches FOCUSED later than Quest; tracking only when worn | bridge waits 30 s; test in the headset |
+11 -1
View File
@@ -21,7 +21,10 @@ from frameport.recommend.catalog import CatalogEntry, to_yaml # noqa: E402
PKG_RE = re.compile(r"^(rift\.[a-z0-9_]{1,80}|[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+){1,8})$")
STATUSES = {"works", "issues", "unsupported", "unknown"}
LIST_FIELDS = {"overport_extra", "overport_remove", "alt_overport", "frame", "frame_remove", "pcvr", "pcvr_remove"}
LIST_FIELDS = {"overport_extra", "overport_remove", "alt_overport", "frame", "frame_remove", "pcvr", "pcvr_remove",
"device"}
# FramePort's own patch ids must exist (OVRPort's are listed by its CLI at runtime, so not checked here)
OWN_PATCH_FIELDS = ("frame", "frame_remove", "pcvr", "pcvr_remove", "device")
DICT_FIELDS = {"adapter", "device_files", "lepton_env", "proton_env", "verified"}
ID_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,80}$")
@@ -64,6 +67,13 @@ def validate(d: dict) -> CatalogEntry:
for k in LIST_FIELDS & set(d):
if not isinstance(d[k], list) or not all(isinstance(x, str) and ID_RE.match(x) for x in d[k]):
raise Invalid(f"{k} must be a list of patch ids")
from frameport.patches import base
base.load_all()
for k in OWN_PATCH_FIELDS:
unknown_ids = [x for x in d.get(k) or [] if x not in base.REGISTRY]
if unknown_ids:
raise Invalid(f"{k}: unknown patch id(s) {', '.join(unknown_ids)}")
for k in DICT_FIELDS & set(d):
if not isinstance(d[k], dict):
raise Invalid(f"{k} must be a mapping")
+1 -1
View File
@@ -1,2 +1,2 @@
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
__version__ = "0.6.2"
__version__ = "0.6.3"
+3
View File
@@ -193,6 +193,9 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
"split_apk": bool({"isSplitRequired", "requiredSplitTypes"} & set(manifest_strings)),
# Unity (IL2CPP) text fields: they close at once on the Frame (frame.unity_text_input)
"text_fields": unity_text_fields(il2cpp_meta) if il2cpp_meta else [],
# Meta's OVRManager raises MSAA at runtime (frame.unity_runtime_msaa_off); Oculus XR Plugin (multiview)
"ovr_runtime_msaa": bool(il2cpp_meta) and b"\0useRecommendedMSAALevel\0" in il2cpp_meta,
"oculus_xr_plugin": bool(il2cpp_meta) and b"\0m_StereoRenderingModeAndroid\0" in il2cpp_meta,
"unity_version": unity_version(ggm, lib_bytes.get("libunity.so")) if engine == "Unity" else None,
},
)
+7 -4
View File
@@ -2,7 +2,9 @@
in its "diffable C#" output with the attribute injector). Offset is the method's position in the libil2cpp.so FILE
(not the virtual address: those differ, e.g. by 0x4000 in Stremio VR's library), which is what a byte patch needs.
Results are cached per libil2cpp.so (sha256), so a rebuild of the same game doesn't run Cpp2IL again."""
Results are cached per game build (its global-metadata.dat), so a rebuild of the same game doesn't run Cpp2IL again,
and neither does the next patch of the same build (an earlier one has already changed libil2cpp.so's bytes, never its
layout)."""
from __future__ import annotations
import hashlib
@@ -39,8 +41,9 @@ def _plain_version(version: str) -> str:
return m.group(0) if m else version
def _cache_file(lib: bytes) -> Path:
return cache_dir() / "il2cpp" / (hashlib.sha256(lib).hexdigest()[:32] + ".json")
def _cache_file(lib: bytes, metadata: bytes) -> Path:
key = hashlib.sha256(metadata + len(lib).to_bytes(8, "little")).hexdigest()[:32]
return cache_dir() / "il2cpp" / (key + ".json")
def find_methods(lib: bytes, metadata: bytes, unity_version: str, wanted: dict[str, list[str]],
@@ -48,7 +51,7 @@ def find_methods(lib: bytes, metadata: bytes, unity_version: str, wanted: dict[s
"""wanted: {class file (relative to Cpp2IL's DiffableCs folder, e.g. "Unity.TextMeshPro/TMPro/TMP_InputField.cs"):
[method names]} -> {class file: {method: (file offset, length)}}; classes or methods not in the game are left
out."""
cache_file = _cache_file(lib)
cache_file = _cache_file(lib, metadata)
try:
cached = json.loads(cache_file.read_text())
if all(cls in cached.get("classes", {}) or cls in cached.get("missing", []) for cls in wanted):
+5 -1
View File
@@ -299,6 +299,8 @@ def original_platform(entry: dict) -> str:
"""What the game was made for (a Steam tag)."""
extra = (entry.get("analysis") or {}).get("extra") or {}
if entry.get("kind") == "rift":
if extra.get("flat"):
return "Windows"
return "Oculus Rift" if extra.get("needs_revive") else "PC VR"
kind = extra.get("vr_kind") or "quest"
return {"quest": "Meta Quest", "none": "Android"}.get(kind, "Android VR")
@@ -310,7 +312,9 @@ def steam_tags(entry: dict, where: str = "frame") -> list[str]:
from ..targets.pc_revive import TAG
kind = ((entry.get("analysis") or {}).get("extra") or {}).get("vr_kind") or "quest"
runs = TAG if where != "frame" else "PC VR on Frame" if entry.get("kind") == "rift" else \
flat = bool(((entry.get("analysis") or {}).get("extra") or {}).get("flat"))
runs = TAG if where != "frame" else ("Windows game on Frame" if flat else "PC VR on Frame") \
if entry.get("kind") == "rift" else \
"Quest on Frame" if kind == "quest" else "Android on Frame"
base = [runs, original_platform(entry)]
genres = ((entry.get("details") or {}).get("genres") or [])[:4]
+22 -1
View File
@@ -28,7 +28,7 @@ def load() -> dict:
data = json.loads(_path().read_text(encoding="utf-8"))
except (OSError, ValueError):
return {"games": {}, "settings": {}}
if _migrate(data):
if _migrate(data) | _follow_catalog(data):
save(data)
return data
@@ -42,6 +42,27 @@ def edit() -> Iterator[dict]:
save(data)
def _follow_catalog(data: dict) -> bool:
"""Recipes FramePort derived (not ones the user edited) follow their catalog entry: when an entry is new or
changed (e.g. a fix shipped with an update), the game's recipe is derived again; Update on Frame applies it."""
from ..recommend import catalog, engine
changed = False
for pkg, g in (data.get("games") or {}).items():
r, a = g.get("recipe"), g.get("analysis")
if not isinstance(r, dict) or not isinstance(a, dict) or r.get("source") == "user":
continue
entry = catalog.lookup(pkg)
if entry is None or r.get("catalog_rev") == entry.rev():
continue
try:
g["recipe"] = recipe_to_dict(engine.suggest(analysis_from_dict(a)))
except Exception: # noqa: BLE001 - a malformed entry keeps its recipe
continue
changed = True
return changed
def _migrate(data: dict) -> bool:
"""One-time updates of stored recipes when a new default is introduced (each runs once per library)."""
done = data.setdefault("settings", {}).setdefault("migrations", [])
+1
View File
@@ -82,6 +82,7 @@ class Recipe:
as_is: bool = False # install the game unchanged (already patched): no overport / Frame fixes / Revive
overport: bool = True # False: an ordinary Android app: no VR translation (overport) and no FrameBridge adapter
reasons: dict[str, str] = field(default_factory=dict) # patch id -> why it was suggested
catalog_rev: str = "" # CatalogEntry.rev() it was derived from: a changed catalog entry re-derives the recipe
def enabled(self, patch_id: str) -> bool:
return patch_id in self.patches
+3
View File
@@ -152,6 +152,9 @@ def bundled_agent_version() -> int | None:
RUN_TIMEOUT = 120 # seconds: default bound for one remote command's output
NOT_IN_LIBRARY = "not in the Frame's Steam library" # agent cmd_launch's error when the shortcut is missing
class AgentFailed(RuntimeError):
pass
+14 -1
View File
@@ -210,7 +210,7 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
env=launch_env(plan.recipe),
game_args=game_args(plan.recipe), no_crash_reporter="pcvr.no_crash_reporter" in plan.recipe.patches,
libovr_redirect="pcvr.libovr_redirect" in plan.recipe.patches,
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version,
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version, vr=not is_flat_windows(plan.package),
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source},
)
reporter.log(f"installed at {result['base']} (Proton {result['proton']}, Steam shortcut id {result['appid']})")
@@ -325,6 +325,14 @@ def _tags(package: str) -> list[str]:
return steam_tags(entry) if entry else []
def is_flat_windows(package: str) -> bool:
"""A Windows game without VR (added with "Add one game folder…"): Proton runs it as a window, no VR setup."""
from ..core import library
g = library.game(package) or {}
return bool(((g.get("analysis") or {}).get("extra") or {}).get("flat"))
def _flatscreen(package: str) -> bool:
"""An Android app without VR: Lepton must show it as a flat window (it never draws through OpenXR)."""
from ..core import library
@@ -384,8 +392,13 @@ def add_to_steam(frame: Frame, packages: list[str], reporter: Reporter, wait: fl
continue
if status.get("state") in ("done", "failed"):
break
added = {a["package"] for a in status.get("added", [])}
for a in status.get("added", []):
reporter.check(f"Steam library: {a['package']}", True, f"shortcut {a['appid']}")
for e in status.get("errors", []):
reporter.check("Steam library", False, e)
if not status.get("errors"):
for pkg in packages:
if pkg not in added: # no answer in time: say so (Play adds a missing shortcut itself)
reporter.check(f"Steam library: {pkg}", False, "the Frame didn't confirm the library entry in time")
return status
+14
View File
@@ -56,6 +56,7 @@
"A new FramePort version is ready to install": "",
"A recipe is the list of patches FramePort applies to a game before installing it. Known-good recipes were tested on a Frame; the others are suggested from the game's engine and VR API. Hover a patch for why it's there.": "",
"A VR app made for another Android headset. OpenXR apps are translated like Quest games; apps built on a headset maker's own SDK (Pico, HTC Wave) can't run on the Frame.": "",
"A Windows game without VR, added with \"Add one game folder…\". On the Frame, Proton runs it as a window (like Steam's own Windows games); it's in the Frame's Steam library.": "",
"A Windows PC VR game (OpenXR, SteamVR or Oculus). It runs on this PC with SteamVR, or on the Frame with Proton. Oculus-only games also need Revive (Oculus → OpenXR).": "",
"A zip with FramePort's logs, the game's recipe and analysis, launch-test logs and the Frame's runtime details — enough to debug without the game files. IP addresses, host and user names, home folders and Steam ids are replaced by placeholders. Attach it to a GitHub issue.": "",
"ABIs": "",
@@ -70,6 +71,7 @@
"Add games": "",
"Add one game folder…": "",
"Add this tag": "",
"Add to Steam library: {title}": "",
"Add videos": "",
"Add videos and files…": "",
"Add your games": "",
@@ -236,6 +238,7 @@
],
"Downloading…": "",
"Downloads": "",
"Draws each eye separately; try it when one eye shows a grey or broken picture.": "",
"Drop invalid layers": "",
"Drop layers whose swapchain failed or whose extension isn't enabled.": "",
"Drop to upload": "",
@@ -413,6 +416,7 @@
"Keep converted copies on this PC after installing (FramePort converts again for every install, so they're only needed for inspecting a build)": "",
"Keep game saves on the Frame": "",
"Keep menus in place": "",
"Keeps a smoothing setting off that the game turns on itself and that can freeze the Frame.": "",
"Keeps one label so the title is stable.": "",
"Keeps one name for the game, so it shows the same everywhere.": "",
"Keyboard connected to {label}.": "",
@@ -472,6 +476,7 @@
"Menus and screens": "",
"Meta Quest": "",
"Meta XR Audio: skip Quest telemetry class": "",
"Meta's OVRManager switches Unity to the headset's recommended MSAA level (4x) while the game runs, whatever its quality settings say (log: \"Switching to the recommended level\"). Multisampled render-to-texture on GLES can hang the Frame's GPU, up to a restart of the whole headset (e.g. Lucky's Tale in a menu). Rewrites OVRDisplay.recommendedMSAALevel -> 0 in libil2cpp.so (found with Cpp2IL), so MSAA stays off.": "",
"MiB": "",
"Mirrored": "",
"Mixed reality": "",
@@ -492,6 +497,7 @@
"New name": "",
"Next": "",
"No activity": "",
"no answer from the Frame": "",
"No artwork could be downloaded from that {source} result; the current artwork stays. Try another one.": "",
"No folders to rescan yet: add games with Scan a folder": "",
"No Frames found. New Frame? Use first-time setup (step 1). Otherwise make sure Developer Mode is on (Settings → System → Developer) and the Frame is on the same network.": "",
@@ -524,6 +530,7 @@
"Now {scale:.0%}; {new:.0%} after restarting FramePort.": "",
"Oculus game — needs Revive, which doesn't run on the Frame. Play it on this PC (SteamVR).": "",
"Oculus OS telemetry class stubs": "",
"Oculus XR Plugin games render both eyes in one pass (multiview) on Android. If one eye shows only effects or grey (e.g. I Am Cat), rendering each eye in its own pass can fix it, at some GPU cost. Rewrites OculusSettings.GetStereoRenderingMode -> MultiPass in libil2cpp.so (found with Cpp2IL). Try it when one eye is wrong.": "",
"off: {names}": "",
"Offline": "",
"Older workaround for upside-down quads: rotates them 180° (quads are single-sided; prefer flip_emul).": "",
@@ -885,6 +892,8 @@
"Uninstalled {title}": "",
"Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a frame later when there is none (Lepton has no on-screen keyboard; Meta's system keyboard does this on a Quest), so a selected text field only flashes a caret. Rewrites TouchScreenKeyboardShouldBeUsed -> false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -> true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with \"Show the app window\") or Type on Frame from the PC.": "",
"Unity: disable MSAA": "",
"Unity: keep MSAA off at runtime (OVRManager)": "",
"Unity: render each eye separately (no multiview)": "",
"Unreal builds of Meta XR Audio abort ('ClassNotFoundException com.oculus.os.AnalyticsEvent') on non-Quest devices. Patches that one JNI FindClass call so the library continues (e.g. NOPE Challenge).": "",
"Unreal games start CrashReportClient when they crash, which leaves a crash dialog instead of simply closing. This passes -nocrashreports to the game and, on the Frame, renames the game's copy of CrashReportClient.exe so it can't start (your game files on this PC aren't changed).": "",
"Untested": "",
@@ -948,6 +957,8 @@
"Where this game's recipe came from: catalog (tested on a Frame), heuristics (suggested by FramePort) or user (changed by you).": "",
"Which program starts {get}?": "",
"Which Proton build runs the game on the Frame (a Steam compat tool name from the Frame's ARM64 compat list, e.g. proton_11-arm64 or proton-experimental-arm64). Empty = newest installed.": "",
"Windows": "",
"Windows game (Proton)": "",
"Windows not detected": "",
"Windows treats this network as public and its firewall blocks the Frame. In Windows' network settings set this network to Private (or allow FramePort on public networks when Windows asks).": "",
"Windows' firewall for WSL blocks the Frame from reaching FramePort. Open the setup command again and allow the change when Windows asks (admin).": "",
@@ -972,6 +983,7 @@
"your Frame": "",
"Your Frame is back: continuing": "",
"Your Frame isn't set up for FramePort yet. Run the first-time setup once (step 1).": "",
"Your Frame restarted unexpectedly while {title} was running (or soon after). If that game crashed it, please report it.": "",
"Your headset, what it's ready for and what's installed": "",
"Your own game files aren't touched.": "",
"Your own tags (filled) can be anything you like. Outlined tags are added automatically from the engine, VR API and store genres. Filter the library by either.": "",
@@ -1034,7 +1046,9 @@
],
"{on} on your Frame": "",
"{remaining} more after this": "",
"{title} couldn't be added to the Frame's Steam library: {why}": "",
"{title} failed: {error}": "",
"{title} isn't in the Frame's Steam library yet: adding it (Steam restarts once)": "",
"{title}: analyzed again": "",
"{title}: details from {value}": "",
"{title}: done": "",
@@ -0,0 +1,63 @@
"""Unity XR settings the game's code chooses at runtime, fixed in libil2cpp.so (found per game with Cpp2IL, like
frame.unity_text_input).
- MSAA: Meta's OVRManager (useRecommendedMSAALevel) raises Unity's antiAliasing to the headset's recommended level
(4x) at runtime ("The current MSAA level is 0, but the recommended MSAA level is 4. Switching to the recommended
level."), whatever QualitySettings say, so frame.unity_no_msaa can't keep it off. Multisampled render-to-texture on
GLES through Zink hangs the Frame's GPU (Lucky's Tale: the whole headset restarted in a menu, 2026-10-03).
OVRDisplay.recommendedMSAALevel -> 0 keeps it off.
- Multiview: Oculus XR Plugin games render both eyes in one pass (Multiview) on Android. I Am Cat (Unity 2022.3, GLES
through Zink) drew only effects into the right eye's array slice; OculusSettings.GetStereoRenderingMode -> 0
(MultiPass) renders each eye in its own pass."""
from __future__ import annotations
from ..base import Suggestion, register
from .unity_text_input import RET_FALSE, Il2cppReturnPatch
RET_ZERO = RET_FALSE # mov w0, #0 ; ret
class UnityRuntimeMsaa(Il2cppReturnPatch):
id = "frame.unity_runtime_msaa_off"
title = "Unity: keep MSAA off at runtime (OVRManager)"
description = ("Meta's OVRManager switches Unity to the headset's recommended MSAA level (4x) while the game runs, "
"whatever its quality settings say (log: \"Switching to the recommended level\"). Multisampled "
"render-to-texture on GLES can hang the Frame's GPU, up to a restart of the whole headset (e.g. "
"Lucky's Tale in a menu). Rewrites OVRDisplay.recommendedMSAALevel -> 0 in libil2cpp.so (found with "
"Cpp2IL), so MSAA stays off.")
order = 47
targets = {"Oculus.VR/OVRDisplay.cs": {"get_recommendedMSAALevel": RET_ZERO},
"Assembly-CSharp/OVRDisplay.cs": {"get_recommendedMSAALevel": RET_ZERO}} # older Oculus Integration
check_name = "Unity MSAA"
def applies(self, a):
return a.engine == "Unity" and "libil2cpp.so" in a.libs and not a.only_32bit
def detect(self, a):
if self.applies(a) and (a.extra or {}).get("ovr_runtime_msaa") and "GLES" in a.graphics:
return Suggestion(True, "GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled "
"render-to-texture can hang the Frame's GPU.")
return None
class UnityMultiPass(Il2cppReturnPatch):
id = "frame.unity_multipass"
title = "Unity: render each eye separately (no multiview)"
description = ("Oculus XR Plugin games render both eyes in one pass (multiview) on Android. If one eye shows only "
"effects or grey (e.g. I Am Cat), rendering each eye in its own pass can fix it, at some GPU cost. "
"Rewrites OculusSettings.GetStereoRenderingMode -> MultiPass in libil2cpp.so (found with Cpp2IL). "
"Try it when one eye is wrong.")
order = 48
targets = {"Unity.XR.Oculus/Unity/XR/Oculus/OculusSettings.cs": {"GetStereoRenderingMode": RET_ZERO}}
check_name = "Unity stereo mode"
def applies(self, a):
return a.engine == "Unity" and "libil2cpp.so" in a.libs and not a.only_32bit \
and (a.extra or {}).get("oculus_xr_plugin", True) is not False
def detect(self, a):
return None # only for a game that shows the symptom (catalog recipe or the user's choice)
register(UnityRuntimeMsaa)
register(UnityMultiPass)
+48 -29
View File
@@ -29,12 +29,14 @@ def _executable(data: bytes) -> list[tuple[int, int]]:
if s["p_type"] == "PT_LOAD" and s["p_flags"] & 1]
def patch_methods(lib: bytes, found: dict[str, dict[str, tuple[int, int]]]) -> tuple[bytes | None, list[str]]:
"""Write the return values over the methods' first two instructions. found = il2cpp.find_methods' result.
def patch_methods(lib: bytes, found: dict[str, dict[str, tuple[int, int]]],
targets: dict[str, dict[str, bytes]] | None = None) -> tuple[bytes | None, list[str]]:
"""Write the return values over the methods' first two instructions. found = il2cpp.find_methods' result,
targets = {class file: {method: RET_*}} (default: the text field methods).
Returns (patched library or None when nothing changed, notes)."""
out, notes, changed = bytearray(lib), [], False
code = _executable(lib)
for cls, methods in TARGETS.items():
for cls, methods in (targets or TARGETS).items():
for method, new in methods.items():
off, length = found.get(cls, {}).get(method, (None, 0))
if off is None:
@@ -47,11 +49,50 @@ def patch_methods(lib: bytes, found: dict[str, dict[str, tuple[int, int]]]) -> t
continue
out[off:off + len(new)] = new
changed = True
notes.append(f"{name} -> {'true' if new == RET_TRUE else 'false'} (at {off:#x})")
notes.append(f"{name} -> {'true/1' if new == RET_TRUE else 'false/0'} (at {off:#x})")
return (bytes(out) if changed else None), notes
class UnityTextInput(Patch):
ALL_TARGETS: dict[str, list[str]] = {} # every IL2CPP patch's methods: one Cpp2IL run (and cache entry) serves all
class Il2cppReturnPatch(Patch):
"""Make IL2CPP methods return a constant (targets), found per game with Cpp2IL (analysis/il2cpp.py)."""
targets: dict[str, dict[str, bytes]] = {}
check_name = ""
def __init_subclass__(cls, **kw):
super().__init_subclass__(**kw)
for c, methods in cls.targets.items():
ALL_TARGETS.setdefault(c, [])
ALL_TARGETS[c] += [m for m in methods if m not in ALL_TARGETS[c]]
def apply(self, ctx: ApkContext) -> bool:
from ...analysis.il2cpp import find_methods
ws = ctx.ws
lib_name = ws.lib("libil2cpp.so")
if ws.abi != "arm64-v8a" or not ws.has(lib_name) or not ws.has(METADATA):
return False
version = (ctx.analysis.extra or {}).get("unity_version")
if not version:
ctx.reporter.check(self.check_name, False, "not fixed: unknown Unity version (rescan the game)")
return False
lib = ws.read(lib_name)
ctx.reporter.log(f"{self.check_name}: finding the code (Cpp2IL; the first time can take a minute)")
try:
found = find_methods(lib, ws.read(METADATA), version, ALL_TARGETS)
patched, notes = patch_methods(lib, found, self.targets)
except Exception as exc: # noqa: BLE001 - optional fix: the game still builds, unchanged
ctx.reporter.check(self.check_name, False, f"not fixed: {exc}")
return False
ctx.notes.extend(notes or [f"{self.check_name}: code not found in this game"])
if patched is not None:
ws.put(lib_name, patched)
return patched is not None
class UnityTextInput(Il2cppReturnPatch):
id = "frame.unity_text_input"
title = "Make Unity text fields work without a system keyboard"
description = ("Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a "
@@ -63,6 +104,8 @@ class UnityTextInput(Patch):
"Frame from the PC.")
order = 46
needs_vr = False
targets = TARGETS
check_name = "Unity text fields"
def applies(self, a):
return a.engine == "Unity" and "libil2cpp.so" in a.libs and bool((a.extra or {}).get("text_fields"))
@@ -74,29 +117,5 @@ class UnityTextInput(Patch):
"it has no system keyboard.")
return None
def apply(self, ctx: ApkContext) -> bool:
from ...analysis.il2cpp import find_methods
ws = ctx.ws
lib_name = ws.lib("libil2cpp.so")
if ws.abi != "arm64-v8a" or not ws.has(lib_name) or not ws.has(METADATA):
return False
version = (ctx.analysis.extra or {}).get("unity_version")
if not version:
ctx.reporter.check("Unity text fields", False, "not fixed: unknown Unity version (rescan the game)")
return False
lib = ws.read(lib_name)
ctx.reporter.log("finding the text field code (Cpp2IL; the first time can take a minute)")
try:
found = find_methods(lib, ws.read(METADATA), version, {c: list(m) for c, m in TARGETS.items()})
patched, notes = patch_methods(lib, found)
except Exception as exc: # noqa: BLE001 - optional fix: the game still builds, its text fields as before
ctx.reporter.check("Unity text fields", False, f"not fixed: {exc}")
return False
ctx.notes.extend(notes or ["no Unity text field code found"])
if patched is not None:
ws.put(lib_name, patched)
return patched is not None
register(UnityTextInput)
+3
View File
@@ -43,6 +43,9 @@ SUMMARIES = {
"frame.ovrplatformcompat": "Adds a small piece of Meta's platform library that some games need to start.",
"frame.ovrstubs": "Provides stand-ins for Meta store functions the game expects, so it doesn't crash at start.",
"frame.unity_no_msaa": "Turns off a smoothing setting that crashes some older Unity games on the Frame.",
"frame.unity_runtime_msaa_off": "Keeps a smoothing setting off that the game turns on itself and that can freeze "
"the Frame.",
"frame.unity_multipass": "Draws each eye separately; try it when one eye shows a grey or broken picture.",
"frame.swapchain_limit": "Allows very large pictures (8K video, theatres) instead of quitting.",
"frame.vrapi_bridge": "Translator for games built on Meta's oldest VR interface.",
"frame.gl_shim": "Fixes graphics code that the Frame's drivers reject (black screen with sound).",
+3 -1
View File
@@ -153,7 +153,9 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
if reporter:
reporter.log(f"analyzing {folder.name}")
a = rift.analyze(folder, exe=exe, tree=tree)
if not a.extra.get("vr_found") and not force and not old:
# no VR runtime in it: added on purpose ("Add one game folder…") = a flat Windows game run by Proton
a.extra["flat"] = not a.extra.get("vr_found")
if a.extra["flat"] and not force and not old:
if reporter:
reporter.log(f"skipped {folder.name}: no VR support (OpenXR, SteamVR or Oculus) found in {a.extra['exe']}")
return None
+20 -1
View File
@@ -49,6 +49,7 @@ class CatalogEntry:
proton_tool: str = ""
as_is: bool = False # Rift: the dump runs unchanged (e.g. a SteamVR build: no Revive)
verified: dict = field(default_factory=dict)
updated: str = "" # date of the last maintainer change without a new headset test (e.g. a fix from a report)
source_hint: str = ""
origin: str = "bundled"
@@ -57,6 +58,14 @@ class CatalogEntry:
known = {k: v for k, v in d.items() if k in cls.__dataclass_fields__}
return cls(**known, origin=origin)
def rev(self) -> str:
"""Fingerprint of the recipe content: library recipes derived from this entry follow it when it changes."""
import hashlib
import json
d = {k: v for k, v in self.to_dict().items() if k != "origin"}
return hashlib.sha256(json.dumps(d, sort_keys=True, default=str).encode()).hexdigest()[:16]
def to_dict(self) -> dict:
out = {}
for k in self.__dataclass_fields__:
@@ -114,11 +123,21 @@ def load(refresh: bool = False) -> dict[str, CatalogEntry]:
if _cache is None or refresh:
entries = _load_dir(catalog_dir() / "games", "bundled")
entries.update(_load_remote())
entries.update(_load_dir(user_catalog_dir(), "user"))
for pkg, e in _load_dir(user_catalog_dir(), "user").items():
if not _newer(entries.get(pkg), e): # the user's own recipe, unless a maintained one was verified later
entries[pkg] = e
_cache = entries
return _cache
def _newer(maintained: CatalogEntry | None, user: CatalogEntry) -> bool:
"""True when the bundled/remote entry was verified after the user's (e.g. a fix for a config the user shared)."""
if maintained is None:
return False
when = max(str(maintained.updated or ""), str(maintained.verified.get("date") or ""))
return when > str(user.verified.get("date") or "")
def lookup(package: str) -> CatalogEntry | None:
return load().get(package)
+8 -1
View File
@@ -9,10 +9,13 @@ from ..patches import base
from . import catalog
from .catalog import TOGGLED_DEVICE
# what a flat (non-VR) Windows game keeps of the PC VR patches
FLAT_WINDOWS_PATCHES = ("pcvr.proton_tool", "pcvr.proton_env", "pcvr.proton_log", "pcvr.no_crash_reporter")
def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
entry = catalog.lookup(analysis.package) if use_catalog else None
recipe = Recipe(analysis.package, title=analysis.label)
recipe = Recipe(analysis.package, title=analysis.label, catalog_rev=entry.rev() if entry else "")
# 1. heuristics / defaults from every patch module
rift = (analysis.extra or {}).get("kind") == "rift"
for patch in base.all_patches():
@@ -27,6 +30,10 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
# 2. known-good catalog recipe overrides heuristics
if rift:
_rift_recipe(analysis, recipe, entry)
if (analysis.extra or {}).get("flat"): # a Windows game without VR: Proton only, no VR parts
for pid in [p for p in recipe.patches if p not in FLAT_WINDOWS_PATCHES]:
recipe.patches.pop(pid)
recipe.reasons.pop(pid, None)
elif entry:
recipe.source = f"catalog ({entry.origin})"
recipe.status, recipe.notes, recipe.title = entry.status, entry.notes, entry.title or recipe.title
+52 -2
View File
@@ -19,6 +19,7 @@ import flet as ft
from .. import pipeline
from ..core import applog, library
from ..errors import explain
from ..frame.connection import NOT_IN_LIBRARY, AgentFailed
from ..i18n import fmt_size, tr, tr_n
from ..recommend import catalog
from . import components as C
@@ -499,7 +500,8 @@ class FramePortApp:
if self.frame_state == "connected" and C.install_state(g, self.frame_info) in ("installed", "outdated"):
out.append((tr("Play on Frame"), ft.Icons.PLAY_ARROW_ROUNDED, lambda e: self.play(pkg, "frame"), False,
tr("Starts the game through the Frame's Steam — put the headset on")))
if g.get("kind") == "rift" and pkg in self.pc_installs():
if g.get("kind") == "rift" and pkg in self.pc_installs() and \
not ((g.get("analysis") or {}).get("extra") or {}).get("flat"):
out.append((tr("Play on this PC"), ft.Icons.PLAY_ARROW_ROUNDED, lambda e: self.play(pkg, "pc"), False,
tr("Starts the game through Steam on this PC (SteamVR + Revive)")))
return out
@@ -508,7 +510,13 @@ class FramePortApp:
title = self._title(pkg)
def work():
res = self._target_for(to).launch(pkg) or {}
try:
res = self._target_for(to).launch(pkg) or {}
except AgentFailed as exc:
if to != "frame" or NOT_IN_LIBRARY not in str(exc):
raise
self._add_then_play(pkg, title) # e.g. the shortcut step failed during the install
return
library.upsert_game(pkg, last_played=time.time())
if to == "frame":
self.toast(tr("Starting {title} on the Frame — put the headset on").format(title=title))
@@ -520,6 +528,21 @@ class FramePortApp:
self.toast(tr("Starting SteamVR and {title} — put your headset on").format(title=title))
self.run_bg(work)
def _add_then_play(self, pkg: str, title: str) -> None:
def run(reporter):
target = self._target_for("frame")
status = target.add_to_library([pkg], reporter)
if not any(a.get("package") == pkg for a in status.get("added", [])):
raise RuntimeError(tr("{title} couldn't be added to the Frame's Steam library: {why}").format(
title=title, why="; ".join(status.get("errors") or []) or tr("no answer from the Frame")))
time.sleep(15) # Steam restarted: give it time to load the library before asking it to launch
target.launch(pkg)
library.upsert_game(pkg, last_played=time.time())
return tr("Starting {title} on the Frame — put the headset on").format(title=title)
self.toast(tr("{title} isn't in the Frame's Steam library yet: adding it (Steam restarts once)").format(
title=title))
self.submit(tr("Add to Steam library: {title}").format(title=title), run, pkg, "tool-frame")
def quick_action(self, g: dict) -> tuple[str | None, str | None]:
"""(tooltip, kind) for a library card's round button; kind: play | install | update | reinstall (None: no
button, e.g. while a job runs for the game or before a Frame is connected)."""
@@ -1340,6 +1363,30 @@ class FramePortApp:
except Exception: # noqa: BLE001
traceback.print_exc()
def _check_frame_restart(self, info: dict | None) -> None:
"""The Frame restarted without shutting down (a crash, a GPU reset) soon after a FramePort game started: say
so, with Report a problem (Reddit 2026-10-03: "Resident Evil 4 crashes the Frame after the opening cutscene").
Uses the last boot seen (kept in settings, so a crash while FramePort was closed counts too)."""
from . import restart
boot = (info or {}).get("boot") or {}
if not boot.get("boot_id"):
return
seen = library.setting("frame.boot_id")
if seen == boot["boot_id"]:
return
library.set_setting("frame.boot_id", boot["boot_id"])
game = restart.crashed_game(boot, seen)
if game is None:
return
applog.log.info("Frame restarted unexpectedly after %s was started: %s", game["package"], boot)
library.upsert_game(game["package"], frame_restart=boot.get("boot_time")) if library.game(game["package"]) \
else None
pkg = game["package"]
self.toast(tr("Your Frame restarted unexpectedly while {title} was running (or soon after). If that game "
"crashed it, please report it.").format(title=game.get("title") or pkg), error=True,
action=tr("Report a problem"), on_action=lambda e: self.report_problem_dialog(pkg))
def _battery_check(self, fetch: bool) -> None:
"""Battery level for the sidebar, and the queue on battery power: warn once, pause before the Frame would
switch itself off mid-upload, continue when it charges (ui/battery.py)."""
@@ -1389,6 +1436,7 @@ class FramePortApp:
self.toast(explain(exc), error=True)
return False
self.frame_info, self.frame_state = info, "connected"
self._check_frame_restart(info)
self._awake_at = 0.0 # the lock went away with the old connection's Frame session: take it again
self.jobs.resume()
self.toast(tr("Your Frame is back: continuing"))
@@ -1464,6 +1512,7 @@ class FramePortApp:
t.label = target.label
save_target(target)
old, self.target, self.frame_info, self.frame_state = self.target, t, info, "connected"
self._check_frame_restart(info)
if old is not None and old is not t and not self.jobs.current():
try: # the connection it replaces (a running job keeps using its own until it ends)
old.close()
@@ -1512,6 +1561,7 @@ class FramePortApp:
changed = info.get("installed") != (self.frame_info or {}).get("installed") or \
self.frame_state != "connected"
self.frame_info, self.frame_state = info, "connected"
self._check_frame_restart(info)
except Exception as exc: # noqa: BLE001
frame = getattr(target, "frame", None)
if frame is not None and frame.alive():
+2
View File
@@ -45,6 +45,8 @@ def platform(g: dict) -> tuple[str, str, str]:
"""(short label, long label, help key) for a game's platform: what it was made for, not how it runs here."""
if g.get("kind") == "rift":
extra = ((g.get("analysis") or {}).get("extra") or {})
if extra.get("flat"):
return tr("Windows"), tr("Windows game (Proton)"), "platform_windows"
oculus = extra.get("needs_revive") or extra.get("libovr")
return tr("PC VR"), tr("PC VR · Oculus") if oculus else tr("PC VR"), "platform_pcvr"
kind = (((g.get("analysis") or {}).get("extra") or {}).get("vr_kind")) or "quest"
+2
View File
@@ -28,6 +28,8 @@ HELP: dict[str, str] = _Translated({
"game (or right-click → Change executable) to pick it.",
"platform_quest": "A Meta Quest game (APK). It's rebuilt for the Frame and runs in Lepton, Valve's Android "
"container.",
"platform_windows": "A Windows game without VR, added with \"Add one game folder…\". On the Frame, Proton runs it "
"as a window (like Steam's own Windows games); it's in the Frame's Steam library.",
"platform_pcvr": "A Windows PC VR game (OpenXR, SteamVR or Oculus). It runs on this PC with SteamVR, or on the "
"Frame with Proton. Oculus-only games also need Revive (Oculus → OpenXR).",
"platform_android": "An ordinary Android app or game (no VR). It's installed unchanged and runs in Lepton, Valve's "
+16
View File
@@ -0,0 +1,16 @@
"""Did the Frame crash because of a game? (no Flet) From the agent's boot state: a new boot whose predecessor didn't
shut down cleanly, with a FramePort game started shortly before it."""
from __future__ import annotations
SOON = 3 * 3600 # a game started up to 3 h before the restart may have caused it
def crashed_game(boot: dict, previous_boot_id: str | None) -> dict | None:
"""The game to blame ({"package", "title", "time"}) or None. Only for a boot FramePort hasn't seen before whose
previous boot ended without a clean shutdown (a battery shutdown or a normal power-off is clean)."""
if not previous_boot_id or boot.get("boot_id") == previous_boot_id or boot.get("prev_clean") is not False:
return None
last, boot_time = boot.get("last_launch"), boot.get("boot_time")
if not last or not boot_time or not 0 <= boot_time - last.get("time", 0) <= SOON:
return None
return last
+67 -1
View File
@@ -369,11 +369,32 @@ def test_launch_uses_steam_shortcut(monkeypatch, tmp_path):
(anchor / "deployment.json").write_text(json.dumps(deployment))
calls = []
monkeypatch.setattr(a, "run", lambda cmd, **k: (calls.append(cmd), SimpleNamespace(returncode=0, stdout=""))[1])
cfg = tmp_path / ".local/share/Steam/userdata/42/config"
cfg.mkdir(parents=True)
with pytest.raises(a.AgentError, match="not in the Frame's Steam library"):
a.cmd_launch({"package": "com.x.y"})
exe = f'"{anchor}/launch.sh"'
a.upsert_shortcut(str(cfg / "shortcuts.vdf"), exe, "Old title", str(anchor)) # keeps its first id
r = a.cmd_launch({"package": "com.x.y"})
assert r["gameid"] == (2546384938 << 32) | 0x02000000
assert r["gameid"] == (a.shortcut_appid(exe, "Old title") << 32) | 0x02000000
assert calls[-1][-1] == f"steam://rungameid/{r['gameid']}" and calls[-1][0] == "systemd-run"
def test_library_users_prefers_the_signed_in_account(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
steam = tmp_path / ".local/share/Steam"
with pytest.raises(a.AgentError, match="signed-in"):
a.library_users()
for u in ("12345", "999"):
(steam / "userdata" / u / "config").mkdir(parents=True)
assert a.library_users() == ["12345", "999"] # unknown: every account (used to fail with 2 accounts)
(steam / "config").mkdir()
(steam / "config/loginusers.vdf").write_text(
'"users"\n{\n\t"76561197960278073"\n\t{\n\t\t"MostRecent"\t\t"1"\n\t}\n'
'\t"76561197960266727"\n\t{\n\t\t"MostRecent"\t\t"0"\n\t}\n}\n')
assert a.library_users() == ["12345"]
def test_uninstall_quest_keeping_saves_drops_the_install_record(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
anchor = tmp_path / "Applications/quest-frame/com.x.y"
@@ -793,3 +814,48 @@ def test_keyboard_session_reports_a_refused_device(monkeypatch, tmp_path):
out = io.StringIO()
assert a.keyboard_session(io.StringIO(""), out) == 1
assert json.loads(out.getvalue())["ready"] is False
def test_boot_state_finds_unclean_previous_boot_and_last_launch(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
base = tmp_path / "game"
base.mkdir()
(base / "launch.log").write_text("x")
os.utime(base / "launch.log", (1000, 1000))
monkeypatch.setattr(a, "cmd_list_installed", lambda args: {"games": [{"package": "com.x", "title": "X",
"base": str(base)}]})
real_open = open
def fake_open(path, *args, **kw):
if path == "/proc/sys/kernel/random/boot_id":
from io import StringIO
return StringIO("abc\n")
if path == "/proc/stat":
from io import StringIO
return StringIO("cpu 1 2 3\nbtime 2000\n")
return real_open(path, *args, **kw)
monkeypatch.setattr(a, "open", fake_open, raising=False)
monkeypatch.setattr(a, "run", lambda cmd, **kw: SimpleNamespace(stdout="kernel: msm_dpu ... fault\n"))
st = a.boot_state()
assert st == {"boot_id": "abc", "boot_time": 2000, "prev_clean": False,
"last_launch": {"package": "com.x", "title": "X", "time": 1000.0}}
monkeypatch.setattr(a, "run", lambda cmd, **kw: SimpleNamespace(stdout="Reached target System Power Off\n"))
assert a.boot_state()["prev_clean"] is False # cached for this boot: worked out once
def test_flat_windows_game_launcher_and_shortcut(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
monkeypatch.setattr(a, "compat_command", lambda d: ["/proton", "waitforexitandrun"])
anchor, base = tmp_path / "anchor", tmp_path / "base"
anchor.mkdir()
tool = {"dir": "/tools/proton", "name": "Proton 11"}
a.write_proton_launcher(str(anchor), str(base), "rift.game", "Game", 123, tool, "Game.exe", False, {}, vr=False)
flat = (anchor / "launch.sh").read_text()
a.write_proton_launcher(str(anchor), str(base), "rift.game", "Game", 123, tool, "Game.exe", False, {})
vr = (anchor / "launch.sh").read_text()
assert "SteamGameId" not in flat and "export SteamGameId=123" in vr # Proton sets up VR only with it
assert "STEAM_COMPAT_APP_ID=123" in flat
vdf = str(tmp_path / "shortcuts.vdf")
a.upsert_shortcut(vdf, '"/x/launch.sh"', "Game", "/x", tag="Windows game on Frame", openvr=False)
entry = a.vdf_decode(open(vdf, "rb").read())["shortcuts"]["0"]
assert entry["OpenVR"] == 0 and entry["tags"]["0"] == "Windows game on Frame"
+9 -2
View File
@@ -216,12 +216,17 @@ def test_catalog_from_issue(tmp_path):
assert s.main([str(_write(tmp_path, BODY)), "--issue", "7", "--out", str(tmp_path)]) == 0
d = yaml.safe_load((tmp_path / "com.example.game.yaml").read_text())
assert d["frame"] == ["frame.ovrstubs"] and d["verified"]["issue"] == 7
with_device = BODY.replace("- frame.ovrstubs", "- frame.ovrstubs\ndevice:\n- device.text_input_window")
assert s.main([str(_write(tmp_path, with_device)), "--out", str(tmp_path)]) == 0
assert yaml.safe_load((tmp_path / "com.example.game.yaml").read_text())["device"] == ["device.text_input_window"]
@pytest.mark.parametrize("bad", ["package: ../../etc/passwd\ntitle: x", "package: com.a.b\ntitle: x\nevil: 1",
"package: com.a.b\ntitle: x\nstatus: great",
"package: com.a.b\ntitle: x\nframe: [\"$(rm)\"]",
"package: com.a.b\ntitle: x\ndevice_files: {'../x': 1}", "- a list"])
"package: com.a.b\ntitle: x\ndevice_files: {'../x': 1}", "- a list",
"package: com.a.b\ntitle: x\nframe: [frame.no_such_patch]",
"package: com.a.b\ntitle: x\ndevice: device.text_input_window"])
def test_catalog_from_issue_rejects(tmp_path, bad):
s = _script()
body = BODY.split("```yaml")[0] + "```yaml\n" + bad + "\n```\n"
@@ -260,7 +265,9 @@ def test_agent_collect_diag(monkeypatch, tmp_path):
(xr / "XRService-12-31-50.log").write_text("xr")
host = a.cmd_collect_diag({})
assert host["agent_version"] == a.AGENT_VERSION and "openxr_layers" in host["host"]
assert host["files"] == {"XRService-12-31-50.log": "xr"}
# (plus the previous boot's journal excerpts when this machine has a journal)
assert {k: v for k, v in host["files"].items() if not k.startswith("previous-boot")} == \
{"XRService-12-31-50.log": "xr"}
res = a.cmd_collect_diag({"package": "com.x.y", "max_bytes": 1000})
f = res["files"]
assert res["installed"] and {"deployment.json", "launch.sh", "settings.conf", "launch.log", "logcat-main.log",
+20
View File
@@ -0,0 +1,20 @@
from frameport.ui.restart import crashed_game
LAUNCH = {"package": "com.x", "title": "X", "time": 1000.0}
def boot(**kw):
return {"boot_id": "b2", "boot_time": 2000, "prev_clean": False, "last_launch": LAUNCH, **kw}
def test_unclean_restart_soon_after_a_launch_blames_the_game():
assert crashed_game(boot(), "b1") == LAUNCH
def test_no_blame_for_clean_shutdowns_first_sight_old_launches_or_same_boot():
assert crashed_game(boot(prev_clean=True), "b1") is None # powered off / battery shutdown
assert crashed_game(boot(prev_clean=None), "b1") is None # journal unknown
assert crashed_game(boot(), None) is None # never seen this Frame boot before: nothing to compare
assert crashed_game(boot(), "b2") is None # same boot
assert crashed_game(boot(boot_time=1000 + 4 * 3600), "b1") is None # the game ran hours before
assert crashed_game(boot(last_launch=None), "b1") is None
+7
View File
@@ -60,3 +60,10 @@ def test_crash_logcat_signatures():
"(xrCreateSwapchain+424)\n")
r = triage(log.replace("11 (SIGSEGV)", "6 (SIGABRT)"), "EXITED", "com.example.game", crash=abort)
assert [(f.id, f.suggest) for f in r.findings] == [("swapchain-size-abort", ["frame.swapchain_limit"])]
def test_ovrplugin_extension_probe_is_not_a_scene_game():
probe = LOG_OK + "09-28 17:39:02.200 1147 1174 I OVRPlugin: Unavailable OpenXR extension: XR_FB_scene\n"
assert "scene-missing" not in {f.id for f in triage(probe, "RUNNING", "com.example.game").findings}
used = LOG_OK + "09-28 17:39:04.000 1147 1174 E Unity : xrQuerySpacesFB failed: -12\n"
assert "scene-missing" in {f.id for f in triage(used, "RUNNING", "com.example.game").findings}
+92
View File
@@ -0,0 +1,92 @@
"""Unity XR settings fixed in libil2cpp.so: OVRManager's runtime MSAA and the Oculus XR Plugin's multiview."""
from pathlib import Path
from test_patches import _analysis
from frameport.analysis import il2cpp
from frameport.patches import base
from frameport.patches.frame import unity_runtime as R
from frameport.patches.frame import unity_text_input as U
from frameport.validate.triage import triage
def _lib() -> bytes:
return sorted(Path(__file__).parent.joinpath("fixtures").glob("libfake*_arm64.so"))[0].read_bytes()
def test_all_il2cpp_patches_share_one_cpp2il_lookup():
base.load_all()
for p in (U.UnityTextInput, R.UnityRuntimeMsaa, R.UnityMultiPass):
for cls, methods in p.targets.items():
assert set(methods) <= set(U.ALL_TARGETS[cls])
# keyed by the build's metadata: an earlier patch's bytes in libil2cpp.so don't force another Cpp2IL run
assert il2cpp._cache_file(b"a" * 10, b"meta") == il2cpp._cache_file(b"b" * 10, b"meta")
assert il2cpp._cache_file(b"a" * 10, b"meta") != il2cpp._cache_file(b"a" * 10, b"other")
def test_runtime_patches_write_zero_returns():
lib = _lib()
lo, _hi = U._executable(lib)[0]
a, b = (lo + 0x40) & ~3, (lo + 0x80) & ~3
found = {"Assembly-CSharp/OVRDisplay.cs": {"get_recommendedMSAALevel": (a, 0x5C)},
"Unity.XR.Oculus/Unity/XR/Oculus/OculusSettings.cs": {"GetStereoRenderingMode": (b, 8)}}
msaa, notes = U.patch_methods(lib, found, R.UnityRuntimeMsaa.targets)
assert msaa[a:a + 8] == R.RET_ZERO and msaa[b:b + 8] == lib[b:b + 8] and "OVRDisplay" in notes[0]
multi, _ = U.patch_methods(lib, found, R.UnityMultiPass.targets)
assert multi[b:b + 8] == R.RET_ZERO and multi[a:a + 8] == lib[a:a + 8]
def test_runtime_msaa_suggested_for_gles_ovr_games_only():
base.load_all()
p = base.get("frame.unity_runtime_msaa_off")
gles = _analysis(libs=["libil2cpp.so", "libunity.so"], graphics="GLES or unknown (no Vulkan declaration)",
extra={"ovr_runtime_msaa": True})
assert p.detect(gles).recommended
assert p.detect(_analysis(libs=["libil2cpp.so"], extra={"ovr_runtime_msaa": True})) is None # Vulkan
gles.extra["ovr_runtime_msaa"] = False
assert p.detect(gles) is None and p.applies(gles)
assert base.get("frame.unity_multipass").detect(gles) is None # opt-in: only for the symptom
assert not base.get("frame.unity_multipass").applies(_analysis(libs=["libunity.so"])) # Mono: no Cpp2IL
def test_triage_points_runtime_msaa_to_the_patch():
log = ("09-28 17:39:01.000 1000 1000 I ActivityManager: Start proc 1147:com.example.game/u0a55 for activity\n"
"09-28 17:39:04.000 1147 1174 I Unity : The current MSAA level is 0, but the recommended MSAA level is "
"4. Switching to the recommended level.\n")
f = {x.id: x for x in triage(log, "RUNNING", "com.example.game").findings}
assert "frame.unity_runtime_msaa_off" in f["unity-runtime-msaa"].suggest
def test_library_recipes_follow_their_catalog_entry(tmp_path, monkeypatch):
import json
from frameport.core import library
from frameport.recommend import catalog, engine
monkeypatch.setattr(library, "_path", lambda: tmp_path / "library.json")
entry = catalog.CatalogEntry.from_dict({"package": "com.x.game", "title": "X", "status": "issues",
"frame": ["frame.unity_multipass"], "updated": "2026-10-04"}, "bundled")
monkeypatch.setattr(catalog, "lookup", lambda pkg: entry if pkg in ("com.x.game", "com.x.mine") else None)
an = {"package": "com.x.game", "engine": "Unity", "libs": ["libil2cpp.so", "libunity.so"], "abis": ["arm64-v8a"]}
games = {"com.x.game": {"analysis": an, "recipe": {"package": "com.x.game", "source": "heuristics"}},
"com.x.mine": {"analysis": dict(an, package="com.x.mine"),
"recipe": {"package": "com.x.mine", "source": "user", "patches": {"frame.adapter": {}}}}}
(tmp_path / "library.json").write_text(json.dumps({"games": games, "settings": {}}))
got = library.load()["games"]
r = got["com.x.game"]["recipe"]
assert "frame.unity_multipass" in r["patches"] and r["status"] == "issues" and r["catalog_rev"] == entry.rev()
mine = got["com.x.mine"]["recipe"] # the user's own choice stays (one-time migrations aside)
assert "frame.adapter" in mine["patches"] and "frame.unity_multipass" not in mine["patches"]
assert not library._follow_catalog(library.load()) # nothing changed since: no re-derive on every load
assert engine.suggest(library.analysis_from_dict(an)).catalog_rev == entry.rev()
def test_maintained_entry_verified_later_beats_the_users_shared_one():
from frameport.recommend import catalog
def e(origin, **kw):
return catalog.CatalogEntry.from_dict({"package": "p.q", "title": "P", **kw}, origin)
user = e("user", verified={"date": "2026-10-03"})
fixed, old = e("bundled", updated="2026-10-04"), e("bundled", verified={"date": "2026-09-28"})
assert catalog._newer(fixed, user) and not catalog._newer(old, user) and not catalog._newer(None, user)
+17
View File
@@ -130,3 +130,20 @@ def test_reanalyze_refreshes_the_suggestion_and_keeps_a_users_recipe(monkeypatch
g = library.game("com.x")
assert "frame.unity_text_input" not in g["recipe"]["patches"] # the user's own choices stay
assert "frame.unity_text_input" in g["suggested"]["patches"]
def test_flat_windows_game_keeps_only_proton_patches():
from test_patches import _analysis
from frameport.patches import base
from frameport.recommend import engine
base.load_all()
a = _analysis(package="rift.somegame", engine="Unity", xr="?", libs=[],
extra={"kind": "rift", "flat": True, "vr_found": False, "exe": "Game.exe"})
vr = engine.suggest(_analysis(package="rift.somegame", engine="Unity", xr="OpenXR", libs=[],
extra={"kind": "rift", "vr_found": True, "openxr": True, "exe": "Game.exe"}),
use_catalog=False)
r = engine.suggest(a, use_catalog=False)
assert set(r.patches) <= set(engine.FLAT_WINDOWS_PATCHES)
assert set(vr.patches) - set(engine.FLAT_WINDOWS_PATCHES) # the same game with VR gets VR patches