mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-23 13:01:53 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2017194f3 | ||
|
|
7a11706b3a | ||
|
|
76d4390b19 | ||
|
|
2022d9986a | ||
|
|
9ff7edf93b | ||
|
|
a5722b50a0 | ||
|
|
0a13ca72a2 |
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user