Compare commits

...
21 Commits
Author SHA1 Message Date
spoopyghosty0 dbabb37343 FramePort 0.7.0 2026-10-04 17:08:34 -04:00
spoopyghosty0 7afee4bf59 FramePort on the Frame: Play in Desktop Mode says the game will be in the Steam library back in Gaming Mode (was an error: the waiting library update read as 'no answer'); a second request for a game whose library update already waits doesn't start another (agent v39) 2026-10-04 16:53:47 -04:00
spoopyghosty0 7367a35176 Library rescans find new downloads: a folder with a stray APK next to game folders counted as one game (the scan stopped there), and games more than two folders down (a download manager's <library>/data/downloads/<game>/) weren't searched; now 5 levels, collections vs game folders told apart, PC program folders and code checkouts skipped 2026-10-04 16:50:33 -04:00
spoopyghosty0 9948f946c5 Remove from library offers the same 'also delete this game's files on this PC' option (off by default) 2026-10-04 16:39:35 -04:00
spoopyghosty0 3870500972 Uninstall asks whether to also delete the game's files on this PC (off by default): the APK and data folder (or a PC VR game's folder) and FramePort's converted copies, listed with their size; never signing keys or files another library entry uses; the game then leaves the library 2026-10-04 16:36:17 -04:00
spoopyghosty0 a7b02c00ee On the Frame itself the app keeps its data in ~/.local/share/frameport-app: ~/.local/share/frameport is the agent's folder, which its purge removes (with the game signing keys) 2026-10-04 16:31:54 -04:00
spoopyghosty0 09c7bb6ea7 SDL / LÖVE apps start on the Frame (GitHub #24): Lepton's Android has no clipboard service and SDLClipboardHandler's constructor crashed on it; frame.sdl_clipboard skips that one call with an in-place classes.dex edit (apk/dex.py), verified with LÖVE for Android 11.5 (crash without, runs with). Android apps without VR keep every suggested non-VR patch 2026-10-04 16:29:45 -04:00
spoopyghosty0 3761dfd62d FramePort on the Frame itself: on SteamOS ARM64 it remembers 'This Frame' (127.0.0.1) and authorizes its own key (no pairing; Developer Mode for sshd, explained if off). Steam library changes wait while Desktop Mode is open (a Steam restart would end it, and FramePort with it; agent v38). Recipe: Beat Saber co-existence build (#29, without the repack's branding) 2026-10-04 16:17:24 -04:00
spoopyghosty0 f86d9021f9 Steam library fixes (GitHub #4, #21, #27): shortcuts go to every Steam account, the agent waits for Steam's helpers before writing and checks the entries survived Steam's restart (writes once more, else reports it; agent v38); Play's add-to-library repair no longer crashes (TypeError since 0.6.3). Uploads resume after a transient OSError (Windows Errno 22). Recipes: Riven (#26), SUPERHOT VR Rift version (#28) 2026-10-04 16:03:38 -04:00
spoopyghosty0 83e901a682 Accounting+: works with issues (VR starts with frame.unity_oculus_check + the frame-wait shim; stuck at 'press any button' although its OVRInput gets clean input). The shim's input wrappers (logging, input focus, stuck-button release) stay behind INPUT_PROBE (off); per-hand interaction profiles and input counters in layer_debug 2026-10-04 15:50:00 -04:00
spoopyghosty0 246e5fc5ee Accounting+ runs: with the frame-loop shim Unity 2017's built-in Oculus support submits frames (~71 fps in the launch test, was a frozen dashboard); frame.unity_oculus_check is suggested again 2026-10-04 15:03:49 -04:00
spoopyghosty0 17403779cb Accounting+ / Unity 2017 built-in Oculus: native/ovrpshim (libfp_ovrp.so) adds the frame wait Unity's legacy loop never makes (fpov_Update2 = ovrp_WaitToBeginFrame once per frame, then ovrp_Update2); frame.unity_oculus_check renames the lookup in libunity.so and links the shim into libOVRPlugin.so. Still opt-in until a launch test shows frames 2026-10-04 14:58:01 -04:00
spoopyghosty0 0e03e061a4 Recipes FramePort derived (not the user's own) are derived again once after every app update, so new automatic fixes reach games already in a library (e.g. the Vulkan shim for own-engine games); a changed recipe shows Update on Frame 2026-10-04 14:52:26 -04:00
spoopyghosty0 d8cf19fb20 Owner's verdicts: Blade & Sorcery works, BattleGlide works, Myst and I Am Cat work with issues, Roblox unsupported; Myst keeps application space warp (turning it off didn't help). PLAYBOOK: symptom rows and debugging techniques from these sessions (headset-only failures, linker log via LEPTON_GFXRECON_FP_PROPS, Unity's own tombstone, wear-sensor focus losses in vrserver.txt, inlined getters, catalog YAML) 2026-10-04 14:43:49 -04:00
spoopyghosty0 7cd9a38eaf Headset round 3: Blade & Sorcery's freezes are the headset's wear sensor flickering off while worn (SteamVR 'HMD off' 0.5-2 s), which only this game pauses on: new adapter setting focus_hold_ms (default 1000; 2500 in its recipe). Installs no longer restart Steam when the game's shortcut is unchanged (a reinstall ended a game being played), and wait while a game runs when Steam has to restart (agent v37). frame.vk_sanitize also covers own-engine libraries that load libvulkan.so by name (Roblox's crash is on Fossilize's recording thread). Myst: app space warp off (object glitches). frame.unity_oculus_check is opt-in (Unity 2017's frame loop never waits: dashboard freeze). focus_hold: 1 s focused / 1 s dips by default 2026-10-04 14:15:50 -04:00
spoopyghosty0 4f3593192f Recipes from the second headset round: BattleGlide works (focus_hold), Myst works with minor glitches (sync_guard confirmed), Blade & Sorcery still jumps/freezes now and then. GAMES.md is generated from the repo's recipes only (a local user catalog hid Myst), and a test parses every catalog file (a broken one was skipped silently) 2026-10-04 12:03:53 -04:00
spoopyghosty0 974426d1d0 Headset round (2026-10-04): frame.unity_oculus_check (Unity 2017-2018 built-in Oculus starts VR only with Meta's com.oculus.systemactivities; Accounting+ now creates its OpenXR instance instead of running as a hidden 2D app); adapter sync_guard (the Frame runtime's xrSyncActions race right after focus, Myst); recipes: Myst, BattleGlide and Blade & Sorcery: Nomad (focus_hold for the Frame's brief focus dips) as works with issues, Roblox and BONELAB unsupported with what was found; triage + PLAYBOOK rows 2026-10-04 11:45:52 -04:00
spoopyghosty0 b7a0e0cb5b Notes: device results for the requested games (BONELAB, Roblox, Accounting+ findings) 2026-10-04 11:14:57 -04:00
spoopyghosty0 38edc26092 Cpp2IL: a build it can't read (e.g. Unity 2017) is cached as such, so each Unity patch doesn't run it again, and the error names Cpp2IL's exception instead of a stack trace; the controller-profile triage line no longer suggests profile_remap (it's on by default) 2026-10-04 11:14:43 -04:00
spoopyghosty0 e96e25bade Controller profiles (0.7.0 D): the adapter suggests bindings for Meta's newer Touch profiles (Touch Plus/Pro), which the Frame's runtime rejects, again as oculus/touch_controller (setting profile_remap, default on; triage info signature). unity_no_msaa: games with several MSAA quality levels failed ('bytes not found in place'), and pre-2019 Unity names the field blendWeights 2026-10-04 10:27:53 -04:00
spoopyghosty0 67c991ca4f Fixes from user reports, round 2: VR4 campaign hang (one shader reads an uninitialized loop counter; the Vulkan shim's new vk_shader_fix setting inserts the zeroing stores, matched by size + SHA-256, from #10's capture); I Am Cat multipass (the getter is inlined: the field read in OculusLoader.Initialize is rewritten, verified on a Frame); builds remember their recipe (recipe_fp, Patch.revision) so changed recipes show Update on Frame; a Frame that got a new address is found again by its SSH host key; packaged apps show library artwork (#16); diagnostics include this boot's kernel log; the Game settings dialog keeps text settings; recipes from #13, #14, #15, #17 2026-10-04 10:21:30 -04:00
65 changed files with 2202 additions and 122 deletions

No files matched your search

+52 -1
View File
@@ -424,7 +424,58 @@ recommended level" → Lucky's Tale restarted the headset; `OVRDisplay.get_recom
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
changed; a maintained entry beats the user's shared copy when its `updated`/verified date is later (`catalog._newer`).
**Round 2 of reports (2026-10-04):** VR4's campaign hang = one fragment shader reading an uninitialized loop counter
(reporter's capture): vkshim `vk_shader_fix` setting (`<size>:<sha256>:<offset>:<words>`, from the recipe's `adapter:`;
matched by size + SHA-256, words inserted into a copy) — on the device the module is fixed at load ("fixed shader
module (6488 -> 6512 bytes)"); campaign itself not yet seen in a headset by us. Text settings (kind "str") stay out of
the Game settings dialog (saving it dropped them). I Am Cat: `GetStereoRenderingMode` is inlined (no call sites) →
`Il2cppReturnPatch.field_loads` rewrites the field read in `OculusLoader.Initialize` (Cpp2IL gives the field offset,
`field:<name>`); verified with Toy Master: eye swapchains array=1 ×2 instead of array=2. Builds record `recipe_fp`
(`patches/base.recipe_fingerprint`, `Patch.revision`): a changed recipe or revised patch shows "Update on Frame".
Remembered Frames are found again after an address change (`Frame.relocate`: scan, same SSH host key, before
login). Packaged apps show artwork by file path (`thumbs.use_file_paths`, #16). Diagnostics: this boot's kernel log.
Device tests of requested games (2026-10-04, headless): I Am Cat, Myst 3.3.0, BattleGlide, Blade & Sorcery: Nomad
start (RUNNING, frames). Roblox: SIGSEGV in je_free from libroblox.so ~3 s in (likely its anti-tamper vs the re-signed
APK). BONELAB (Unity 2021.3 Vulkan, OVRPlugin 1.94): SIGSEGV with pc == fault addr in vkCreateInstance, called from
libSLZQuestNative.so's Vulkan hooks into an unmapped (unloaded) library right after OVRPlugin's pre-init
xrDestroyInstance; pinning openxr/vrclient libraries (RTLD_NODELETE) did NOT help — find the unloaded library with the
linker's dlopen/dlclose logging next. Accounting+ (Unity 2017.4 built-in Oculus, libOVRPlugin + libvrapi): Unity
never starts VR on the Frame (no OVRPlugin/OpenXR lines; runs as a 2D app), which is why its "controller setup"
screen can't be passed — Unity 2017's Oculus device check, not the controller profiles. Cpp2IL can't read Unity 2017.
The Frame's Android runtime (vrclient.so) knows oculus/touch_controller but not Meta's Touch Plus/Pro profiles
(OVRPlugin suggests those too, routinely) → adapter `profile_remap`.
Headset round 2 (2026-10-04): the Frame drops focus often (ms to 7 s; only some are presence/standby) → games pause
or recentre (B&S Player.OnVRPresence → Teleport, BattleGlide/Unreal pauses); focus_hold now 1 s focused / 1 s max dip
(longer must still pause). Myst crash = vrclient xrSyncActions race after FOCUSED → `sync_guard` (confirmed); its
object glitches: app space warp suspected (FB_space_warp is advertised and used) → recipe `patch_disable_space_warp`.
I Am Cat multipass: poses/times consistent, but every frame is submitted after the next xrWaitFrame (one period late;
Zink + doubled draws) and the game clamps its physics step to 10 ms → judder while still; testing scale 0.8. Roblox's
newest crash is on Fossilize's recording thread → vk_sanitize extended to own-engine libs (engine "Other").
Accounting+: unity_oculus_check + native/ovrpshim (libfp_ovrp.so waits via ovrp_WaitToBeginFrame before Unity
2017's ovrp_Update2) → VR in the headset at ~72 fps (2026-10-04), but stuck at "press any button" although input
reaches its OVRInput cleanly (probe: both Touch connected 0x63, individual buttons, input focus 1) → works with issues.
"outside of frame bounds" warnings stay (~2/frame) and are harmless.
Steam library (GitHub #4/#21/#27, agent v38): shortcuts go to every Steam account (signed-in first; MostRecent isn't
always the account on the Frame); stop_steam also waits for Steam's helpers; after the restart the agent re-reads
shortcuts.vdf and writes once more if Steam put its old copy back (`shortcuts_lost`), else reports it. 0.6.3's Play
auto-repair (`_add_then_play`) crashed (its job got a Job, not a reporter). Uploads resume on a transient OSError.
Lepton's Android 11 has no clipboard service (134 services; checked with `podman exec … service check clipboard`):
SDL/LÖVE apps crashed at start → `frame.sdl_clipboard` (`apk/dex.py`: in-place dex edit, nops one invoke, fixes
the header checksum/signature; verified with LÖVE for Android 11.5, GitHub #24 Dramatic Shape). 2D apps (vr_kind none)
keep every suggested patch that isn't about VR (`needs_vr = False`), not a fixed list. FramePort on the Frame:
`frame/local.py` (127.0.0.1, own key authorized; "This Frame (experimental)"; app data in
~/.local/share/frameport-app because ~/.local/share/frameport is the agent's) and Steam library changes wait while
Desktop Mode is open. Verified on the device 2026-10-04 (install + launch test from the Frame). Library updates on the
Frame run one at a time (agent v40 flock): two quick uninstalls used to bring a removed shortcut back (Roblox).
Headset round 3 (owner's verdicts, 2026-10-04): Blade & Sorcery works — its freezes were the headset's wear sensor
flickering off while worn (vrserver.txt "HMD off/on" 0.5-2 s) → per-game `focus_hold_ms=2500` (only this game reacts;
not a default). BattleGlide works (focus_hold). Myst works with issues (object glitches; space warp ruled out, its
recipe keeps it on). I Am Cat works with issues (judder; scale 0.8 no help). Roblox unsupported (je_free in
libroblox, also with the Vulkan shim). Installs skip the Steam restart when the shortcut is unchanged and wait while a
game runs (agent v37). New default fixes in FramePort's code reach games already in a library: after every app update
`library._follow_catalog` re-derives each non-user recipe once (setting `recipes.app_version`; tests switch it off via
`library.REFRESH_ON_UPDATE`); analysis fields added later still need a re-analysis. Troubleshooting techniques: docs/PLAYBOOK.md "Debugging techniques". 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.
+132 -19
View File
@@ -35,7 +35,7 @@ import sys
import time
import zlib
AGENT_VERSION = 35
AGENT_VERSION = 40
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -537,6 +537,11 @@ def stop_steam():
time.sleep(1)
if run(["pgrep", "-x", "steam"]).returncode == 0:
raise AgentError("Steam did not close")
for _ in range(15): # Steam's helpers can still be writing its config (shortcuts.vdf) for a moment
if run(["pgrep", "-f", "steamwebhelper|steam.sh|reaper SteamLaunch"]).returncode:
break
time.sleep(1)
time.sleep(2)
return service
@@ -581,14 +586,14 @@ def active_steam_user():
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")."""
"""Steam accounts whose library gets FramePort's shortcuts: every account on the Frame, the signed-in one first
(loginusers.vdf's MostRecent isn't always the account signed in on the Frame: GitHub #4/#21 got shortcuts in the
other account; an extra shortcut in an unused account does no harm)."""
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
return [active] + [u for u in users if u != active] if active else users
def shortcut_appid_for(exe):
@@ -831,9 +836,10 @@ def vdf_encode(obj):
def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Frame", launch_options="", tags=None,
openvr=True):
openvr=True, write=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."""
are merged with tags already on the shortcut, so ones set in Steam are kept. write=False: change nothing, return
(appid, whether shortcuts.vdf would change)."""
data = open(vdf_path, "rb").read() if os.path.exists(vdf_path) else b""
root = vdf_decode(data) if data else {"shortcuts": {}}
shortcuts = root.setdefault("shortcuts", {})
@@ -849,6 +855,8 @@ def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Fram
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 if openvr else 0, Devkit=0, DevkitGameID="", DevkitOverrideAppID=0, FlatpakAppID="")
if not write:
return ident, vdf_encode(root) != data
if data:
backup_vdf(vdf_path)
os.makedirs(os.path.dirname(vdf_path), exist_ok=True)
@@ -957,6 +965,11 @@ def cmd_shortcuts(args):
if isinstance(r, dict) and str(r.get("exe", "")).startswith('"' + ANCHORS)]
if not packages and not remove:
return {"started": False}
current = cmd_shortcut_status({})
alive = time.time() - float(current.get("started") or 0) < 60 # a waiting worker rewrites its status every 10 s
if current.get("state") == "waiting" and alive and set(packages) <= set(current.get("packages") or []) \
and not remove:
return {"started": False, "waiting": True} # the update for these games already waits (Gaming Mode / game)
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
with open(STATUS_FILE, "w") as f:
json.dump({"state": "running", "packages": packages, "started": time.time()}, f)
@@ -967,11 +980,101 @@ def cmd_shortcuts(args):
return {"started": True, "unit": unit}
def shortcut_args(pkg):
"""(exe, title, start dir, icon, tag, tags, openvr) of an installed game's shortcut."""
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"
return f'"{anchor}/launch.sh"', dep["title"], anchor, icon, tag, dep.get("tags") or [], not flat
def library_changes(users, packages):
"""Whether shortcuts.vdf would change for any of these games (a reinstall usually changes nothing)."""
for user in users:
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
for pkg in packages:
exe, title, start, icon, tag, tags, openvr = shortcut_args(pkg)
if upsert_shortcut(vdf, exe, title, start, icon, tag, tags=tags, openvr=openvr, write=False)[1]:
return True
return False
def shortcuts_lost(users, packages, wait=25):
"""Packages whose shortcut isn't in any account's shortcuts.vdf once Steam has started again (Steam saving its own
copy over ours on the way out looked like a successful install with no game in the library, GitHub #27)."""
for _ in range(wait): # until Steam runs again (it rewrites shortcuts.vdf while starting, too)
if run(["pgrep", "-x", "steam"]).returncode == 0:
break
time.sleep(1)
time.sleep(8)
lost = []
for pkg in packages:
exe = shortcut_args(pkg)[0]
found = False
for user in users:
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
try:
root = vdf_decode(open(vdf, "rb").read()) if os.path.exists(vdf) else {}
except (OSError, AgentError):
continue
entries = (root.get("shortcuts") or {}).values()
found = found or any(isinstance(v, dict) and v.get("Exe") == exe for v in entries)
if not found:
lost.append(pkg)
return lost
def desktop_mode():
"""Desktop Mode is open: it runs inside Steam's session, so a Steam restart would end it (and FramePort itself when
it runs on the Frame); library changes wait until the user is back in Gaming Mode."""
return run(["pgrep", "-x", "plasmashell"]).returncode == 0
def game_running():
"""A game is being played on the Frame (any Lepton game container, or a FramePort PC VR game)."""
names = run(["podman", "ps", "--format", "{{.Names}}"]).stdout.split()
if any(n.startswith("lepton-steamlaunch-") for n in names):
return True
return any(pcvr_pids(d["base"]) for d in cmd_list_installed({})["games"] if d.get("kind") == "pcvr")
def shortcuts_worker(payload):
"""One library update at a time: two at once each read shortcuts.vdf, changed it and wrote it back, so the second
write brought back a shortcut the first had removed (uninstalling two games quickly left Roblox in Steam)."""
import fcntl
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
with open(STATUS_FILE + ".lock", "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
_shortcuts_worker(payload)
def _shortcuts_worker(payload):
args = json.loads(payload)
result = {"state": "done", "added": [], "errors": [], "finished": None}
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
try:
users = library_users()
if not args.get("remove") and not library_changes(users, args["packages"]):
# nothing to change in shortcuts.vdf (e.g. a reinstall): only the artwork, no Steam restart
for user in users:
update_library(user, args["packages"], result, shortcuts=False)
result["unchanged"] = True
result["finished"] = time.time()
with open(STATUS_FILE, "w") as f:
json.dump(result, f)
return
deadline = time.time() + 12 * 3600
while time.time() < deadline: # restarting Steam would end the game being played, or Desktop Mode
reason = "game" if game_running() else "desktop" if desktop_mode() else None
if not reason:
break
with open(STATUS_FILE, "w") as f:
json.dump({"state": "waiting", "reason": reason, "packages": args["packages"],
"started": time.time()}, f)
time.sleep(10)
try:
service = stop_steam()
except AgentError:
@@ -982,6 +1085,16 @@ def shortcuts_worker(payload):
update_library(user, args["packages"], result)
if service or args.get("restart", True):
start_steam(service)
lost = shortcuts_lost(users, args["packages"])
if lost: # Steam wrote its old copy back over ours (it was still saving): once more, then report
result["retried"] = lost
service = stop_steam()
for user in users:
update_library(user, lost, {"added": [], "errors": result["errors"]})
start_steam(service)
for pkg in shortcuts_lost(users, lost):
result["added"] = [a for a in result["added"] if a["package"] != pkg]
result["errors"].append(f"{pkg}: Steam removed the new library entry again after restarting")
except Exception as exc: # noqa: BLE001
result["state"] = "failed"
result["errors"].append(str(exc))
@@ -1005,8 +1118,9 @@ def remove_from_library(user, remove, result):
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)."""
def update_library(user, packages, result, shortcuts=True):
"""Add/update installed games' shortcuts + grid art in one Steam account (Steam is closed; shortcuts=False: only
the grid art of shortcuts that are already right, while Steam runs)."""
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)
@@ -1014,12 +1128,9 @@ def update_library(user, packages, result):
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)
exe, title, start, icon, tag, tags, openvr = shortcut_args(pkg)
got = upsert_shortcut(vdf, exe, title, start, icon, tag, tags=tags, openvr=openvr, write=shortcuts)
got = got[0] if not shortcuts else got
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:
@@ -2264,11 +2375,13 @@ def cmd_collect_diag(args):
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"])):
# how the previous boot ended: errors and kernel (GPU/msm/kgsl, OOM, panic) warnings before a crash or reset; and
# this boot's kernel warnings (a GPU hang the Frame recovered from only ends the game: no reboot, no tombstone)
for name, boot, extra in (("previous-boot-errors.txt", "-1", ["-p", "err"]),
("previous-boot-kernel.txt", "-1", ["-k", "-p", "warning"]),
("this-boot-kernel.txt", "0", ["-k", "-p", "warning"])):
try:
text = run(["journalctl", "-b", "-1", *extra, "-n", "400", "-q", "--no-pager"]).stdout[-max_bytes:]
text = run(["journalctl", "-b", boot, *extra, "-n", "400", "-q", "--no-pager"]).stdout[-max_bytes:]
except OSError:
text = ""
if text.strip():
+4 -3
View File
@@ -1,10 +1,11 @@
0f681f54b59cfad2efc8883aee5a0971c2fba09d9ba2dca15a136b0ac74a59b8 ./arm64-v8a/libfp_vk.so
a5b03bb7ae5d953d7ded2bf117b0a19c57fae604f16f54310847f8e89593dde6 ./arm64-v8a/libfp_ovrp.so
6d2b9f8cea2f2cf33b8ff30d29a2f3d9c4d55549260a867168a24e44abada021 ./arm64-v8a/libfp_vk.so
8e00552d1ab90f82749db3caad4f773351fa8a28edcc5ea5cbd1b31c434ff32a ./arm64-v8a/libframe_xrshim.so
3dfcb0f2bec47857bcecc7261c58e076d872d4649718c40850f29e10c75477dd ./arm64-v8a/libglshim.so
dc62857b4f7a23b194060599339dfe72b38c16cdf4a9eb1b1f3b4d1fb9071a6d ./arm64-v8a/libopenxr_loader_generic.so
f326df1a5bd600033661f53509f076b9b4ecbb6226e9050460b1d0cfdd70fb9d ./arm64-v8a/libopenxr_loader_generic.so
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
32525cd0a9ee3d9993d8871cd6905bdcf22cde4f4ba28f5db6434ce7d6554758 ./arm64-v8a/libvrapi.so
a79c598744b98b9bf581664a80bcee1a0d11a62fdcdb99f05a0f6f381b6498cd ./armeabi-v7a/libopenxr_loader_generic.so
ddfd8b6c319bfd0ea8aada3b5e941981288ac10aa6443a79843a7b8c288f82fe ./armeabi-v7a/libopenxr_loader_generic.so
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json
28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.so
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,20 @@
package: com.Appnori.AllInOneSports
title: AllInOneSports
status: works
tested_version: 1.9.0
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
- frame.oculusos
device:
- device.text_input_window
verified:
date: '2026-10-04'
known_good_sha256: a1f9535ac569068bba25f137f131db9d4c3e997c072f3e790beac8ffef701fe3
app: 0.6.3
overport_cli: 1.2.5
frame_build: '20260930.6234839'
agent: 35
issue: 13
source_hint: All In One Sports
+9 -3
View File
@@ -1,15 +1,21 @@
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.
notes: Installs the no-ForceQuit build and fixes a campaign shader that hung the GPU after the opening cutscene.
details: Entering the campaign gave a black screen, slowing audio and a crash (Mercenaries loaded). One fragment shader
reads a loop counter before setting it, which hangs the GPU; the Vulkan shim zeroes it (vk_shader_fix). The
no-ForceQuit build is kept. Diagnosed and confirmed on a Frame by the reporter of GitHub issue 10.
tested_version: '2.3'
engine: Unreal
xr: VrApi
alt_overport:
- patch_remove_unreal_force_quit
use_alt: true
adapter:
# one campaign shader reads an uninitialized loop counter and hangs the GPU (black screen, slowing audio, crash);
# the Vulkan shim inserts two OpStores that zero it and its accumulator (from the reporter's capture, GitHub
# issue 10)
vk_shader_fix: 6488:5dcd842db8d21e3fdd13ec916d2e34dd0cfdf12a92fffaf8a27115072ef55c59:2624:0x0003003e,138,18,0x0003003e,150,42
verified:
date: '2026-10-02'
known_good_sha256: 40ff74e37264b28ae8826923b69d0731d9620094c76462be34159643d6ae91b3
+19
View File
@@ -0,0 +1,19 @@
package: com.CyanWorlds.Myst
title: Myst
status: issues
notes: Minor graphical glitches on some objects.
details: The crash is in the Frame's SteamVR runtime (vrclient.so CVRInputLatest::UpdateActionStateInternal via
xrSyncActions) on the first input sync after focus, in 2 of 4 headset sessions. sync_guard serialises input syncs
with event polling and pauses them for 250 ms after focus returns (confirmed in the headset, no crash). The object
glitches are not application space warp (turning it off didn't change them and ran slightly worse).
tested_version: 3.3.0
engine: Unreal
xr: OpenXR
alt_overport:
- patch_remove_unreal_force_quit
adapter:
sync_guard: 1
verified:
date: '2026-10-04'
updated: '2026-10-04'
source_hint: Myst
+20
View File
@@ -0,0 +1,20 @@
package: com.CyanWorlds.Riven
title: Riven
status: works
notes: Plays as expected.
tested_version: 1.9.0
engine: Unreal
xr: OpenXR
alt_overport:
- patch_remove_unreal_force_quit
adapter:
equirect_emul: 1
verified:
date: '2026-10-04'
known_good_sha256: 7c211588f39c21d67998ac6a6aeb44a8ad66c6551a1b0feb1bcf44e2cef98bdf
app: 0.6.3
overport_cli: 1.2.5
frame_build: '20260930.6234839'
agent: 35
issue: 26
source_hint: Riven
@@ -0,0 +1,16 @@
package: com.GhostlyBirdGames.BattleGlide
title: BattleGlide
status: works
notes: Freezes from the headset's brief focus losses are hidden (focus_hold).
details: The Frame drops the session out of focus for ~0.5 s every few seconds (11 times in 80 s of play) and Unreal
pauses each time; focus_hold hides dips up to 1 s once the game has been focused for 1 s. Longer dips (real
interruptions) still pause it.
tested_version: 0.4.1 (BETA)
engine: Unreal
xr: OpenXR
adapter:
focus_hold: 1
verified:
date: '2026-10-04'
updated: '2026-10-04'
source_hint: BattleGlide
+7 -4
View File
@@ -1,10 +1,13 @@
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.
notes: The view vibrates slightly, even when holding still.
details: Unity 2022.3 with the Oculus XR Plugin falls back to GLES (Zink) on the Frame, where its single-pass
(multiview) rendering drew the right eye wrong (GitHub issue 8); frame.unity_multipass renders each eye in its own
pass (OculusSettings.GetStereoRenderingMode + its inlined read in OculusLoader.Initialize). Poses and frame times
are consistent, but every frame reaches the runtime one display period late (two passes on Zink) and the game
clamps its physics step to 10 ms, so the view judders; scale 0.8 didn't change it. Possible next steps are fixing the
multiview path instead or the game's fixed timestep.
tested_version: 1.4.0.0
engine: Unity
xr: OpenXR
@@ -0,0 +1,15 @@
package: com.StressLevelZero.BONELAB
title: BONELAB
status: unsupported
notes: Crashes while starting its graphics (Vulkan); no fix yet.
details: SIGSEGV in vkCreateInstance, called from the game's own Vulkan hooks (libSLZQuestNative.so) into code that no
longer exists, right after OVRPlugin's pre-initialization creates and destroys an OpenXR instance. Keeping the
runtime (vrclient.so) and Steam's Fossilize layer loaded didn't help, so the stale pointer is to memory freed with
that first instance.
tested_version: 1.2974.57485
engine: Unity
xr: OpenXR
verified:
date: '2026-10-04'
updated: '2026-10-04'
source_hint: BONELAB
@@ -0,0 +1,22 @@
package: com.Warpfrog.BladeAndSorcery
title: 'Blade & Sorcery: Nomad'
status: works
notes: The headset's brief focus losses are hidden (focus_hold, up to 2.5 s).
details: The Frame's runtime drops focus now and then (mostly 1.5-7 s, some only milliseconds); the game pauses
("System menu opened", the world freezes) and re-aligns the player when focus returns (Player.OnVRPresence ->
Teleport, the sideways jump). They match the headset's wear sensor flickering off while worn (SteamVR "HMD off" 0.5-2
s), which only this game reacts to; focus_hold hides dips up to 2.5 s here. Rendering keeps 72 fps throughout.
tested_version: 1.3.1
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
adapter:
focus_hold: 1
focus_hold_ms: 2500
verified:
date: '2026-10-04'
updated: '2026-10-04'
source_hint: Blade & Sorcery Nomad
@@ -0,0 +1,23 @@
package: com.beatgames.beatpaber
title: Beat Saber (co-existence build)
status: works
notes: A Beat Saber build with its own package name, so a modded and an unmodded Beat Saber can be installed side by
side.
details: The submitted build's app label was very long and the game didn't appear in the Frame's Steam library (GitHub
issue 27, a shortcut Steam dropped again; fixed in 0.7.0); the recipe's title names it.
tested_version: 1.45.1_27839
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-10-05'
known_good_sha256: 9965b01d5d1a3707bce1762f60d5419ce3e89bc095c63586f6940bb69696e9fd
app: 0.6.3
overport_cli: 1.2.5
frame_build: '20260930.6234839'
agent: 35
issue: 29
source_hint: Beat Saber
@@ -0,0 +1,17 @@
package: com.crowscrowscrows.AccountingPlus
title: Accounting+
status: issues
notes: Starts in VR, but doesn't get past its "press any button" screen.
details: Unity 2017.4 built-in Oculus support. frame.unity_oculus_check gets VR started (Meta's system-app check and
the frame wait its legacy loop never makes, libfp_ovrp.so), ~72 fps. Controller input reaches the game's OVRInput
cleanly (both Touch controllers connected, individual button bits, input focus 1 while focused) but the screen
doesn't continue; Cpp2IL can't read this Unity version to see what it waits for.
tested_version: 1.24.35
engine: Unity
xr: VrApi
frame:
- frame.unity_oculus_check
verified:
date: '2026-10-04'
updated: '2026-10-04'
source_hint: Accounting+
@@ -0,0 +1,20 @@
package: com.iillusions.spacepiratetrainerquest
title: Space Pirate Trainer Quest
status: works
tested_version: '1.97'
engine: Unity
xr: VrApi
frame:
- frame.unity_text_input
- frame.unity_runtime_msaa_off
device:
- device.text_input_window
verified:
date: '2026-10-04'
known_good_sha256: 711d87e6c1fa14fdcdc8bab5792657523d0281b5d904f7f1abafd98f3189d2c0
app: 0.6.3
overport_cli: 1.2.5
frame_build: '20260930.6234839'
agent: 35
issue: 14
source_hint: Space Pirate Trainer
+20
View File
@@ -0,0 +1,20 @@
package: com.parkline.wander
title: Wander
status: works
tested_version: '0.3569'
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
- frame.unity_runtime_msaa_off
device:
- device.text_input_window
verified:
date: '2026-10-04'
known_good_sha256: c0c9de22b3646f35009a53796757d26f8d4e95259713f26a18fae2be995b23aa
app: 0.6.3
overport_cli: 1.2.5
frame_build: '20260930.6234839'
agent: 35
issue: 17
source_hint: Wander
+14
View File
@@ -0,0 +1,14 @@
package: com.roblox.client
title: Roblox
status: unsupported
notes: Crashes on its first VR frame on the Frame.
details: SIGSEGV on Roblox's render thread right after it re-creates its eye swapchains (format 37, which the Frame
doesn't offer, retried as sRGB 43) — in its own code or inside the runtime's xrBeginFrame. No integrity or login
check involved; also needs an online login once it runs.
tested_version: 2.741.1051
engine: Other
xr: OpenXR
verified:
date: '2026-10-04'
updated: '2026-10-04'
source_hint: Roblox
@@ -0,0 +1,19 @@
package: com.steelcrategames.keeptalkingandnobodyexplodes
title: Keep Talking and Nobody Explodes
status: works
tested_version: 1.10.15
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-10-04'
known_good_sha256: 1a6f18e6d7b077e2662c7e633d473018067f4c871f5c45c4632c58233feaeac5
app: 0.6.3
overport_cli: 1.2.5
frame_build: '20260930.6234839'
agent: 35
issue: 15
source_hint: Keep Talking and Nobody Explodes
+20
View File
@@ -0,0 +1,20 @@
package: rift.superhot_vr
title: SUPERHOT VR
status: works
notes: 'Supports SteamVR/OpenXR itself: runs directly, without Revive.'
engine: Unity
xr: OpenXR
kind: rift
pcvr:
- pcvr.xr_timefix
- pcvr.steamvr_tuning
pcvr_remove:
- pcvr.revive
as_is: true
verified:
date: '2026-10-05'
known_good_sha256: 143dd3c6a93a45819a98e3cca8fd1535b461cdf860c927d955ca54815faf5d7b
app: 0.6.3
overport_cli: 1.2.5
issue: 28
source_hint: SUPERHOT VR
+30
View File
@@ -51,6 +51,36 @@ signatures:
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: runtime-input-crash
pattern: 'vrclient\.so[^\n]*(UpdateActionStateInternal|sxr_xrSyncActions)'
severity: fatal
diagnosis: The Frame's runtime crashed in its controller-input code (xrSyncActions), typically right after the game
got focus. sync_guard serialises input syncs and pauses them briefly after focus returns.
suggest: [adapter.sync_guard]
- id: focus-dip-teleport
pattern: 'OnVRPresence[^\n]*Teleport'
severity: warning
diagnosis: The game moves the player when the headset reports presence again, which the Frame's brief focus dips
trigger (the view jumps). focus_hold hides dips under 600 ms.
suggest: [adapter.focus_hold]
- id: unity-vr-device-none
pattern: 'NewtonVR.*(not setup properly|no headset found)|Loaded VR device: None|XR: Oculus could not be loaded'
severity: fatal
diagnosis: Unity didn't start its Oculus VR device (older Unity checks for Meta's system apps first), so the game
runs as a hidden 2D app.
suggest: [frame.unity_oculus_check]
- id: sdl-no-clipboard
pattern: 'ClipboardManager\.addPrimaryClipChangedListener|SDLClipboardHandler\.<init>'
severity: fatal
diagnosis: An SDL app (SDL2 / LÖVE) crashed at start because Lepton's Android has no clipboard service.
suggest: [frame.sdl_clipboard]
- id: controller-profile-rejected
pattern: 'PATH_UNSUPPORTED.*xrSuggestInteractionProfileBindings|xrSuggestInteractionProfileBindings.*(-48|PATH_UNSUPPORTED)'
severity: info
diagnosis: The runtime rejected controller bindings for a profile it doesn't know (Meta's Touch Plus/Pro). Usually
harmless (Meta's OVRPlugin suggests plain Touch too); a game that suggests only these gets them again as Touch
(profile_remap, on by default; log "controller profile ... -> oculus/touch_controller").
suggest: []
- 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
+14 -1
View File
@@ -7,11 +7,15 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Game | Platform | Status | Notes |
|---|---|---|---|
| 4XVR Video Player | Quest | ✅ Works | |
| AllInOneSports | Quest | ✅ Works | |
| Asgard's Wrath 2 | Quest | ✅ Works | |
| BAM | Quest | ✅ Works | |
| BARTENDER VR SIMULATOR | Quest | ✅ Works | |
| Batman: Arkham Shadow | Quest | ✅ Works | |
| BattleGlide | Quest | ✅ Works | |
| Beat Saber | Quest | ✅ Works | |
| Beat Saber (co-existence build) | Quest | ✅ Works | |
| Blade & Sorcery: Nomad | Quest | ✅ Works | |
| Carve Snowboarding | Quest | ✅ Works | |
| Cook-Out | Quest | ✅ Works | |
| Demeter | Quest | ✅ Works | |
@@ -21,6 +25,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| H.U.N.T | Quest | ✅ Works | |
| In Death: Unchained | Quest | ✅ Works | |
| Job Simulator | Quest | ✅ Works | |
| Keep Talking and Nobody Explodes | Quest | ✅ Works | |
| LEGO® Bricktales | Quest | ✅ Works | |
| Marvel's Deadpool VR | Quest | ✅ Works | |
| Medieval Dynasty New Settlement | Quest | ✅ Works | |
@@ -31,24 +36,32 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Path of the Warrior | Quest | ✅ Works | |
| PowerWash Simulator VR | Quest | ✅ Works | |
| Rick and Morty: Virtual Rick-ality | PC VR | ✅ Works | |
| Riven | Quest | ✅ Works | |
| Robo Recall | Quest | ✅ Works | |
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
| Space Pirate Trainer Quest | Quest | ✅ Works | |
| Stremio | Quest | ✅ Works | |
| SUPERHOT VR | PC VR | ✅ Works | |
| The Climb 2 | Quest | ✅ Works | |
| Toy Master | Quest | ✅ Works | |
| Under Cover | Quest | ✅ Works | |
| VR4 | Quest | ✅ Works | |
| Wallace & Gromit in The Grand Getaway | Quest | ✅ Works | |
| Wander | Quest | ✅ Works | |
| Accounting+ | Quest | ⚠️ Works with issues | Starts in VR, but doesn't get past its "press any button" screen. |
| 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. |
| I Am Cat | Quest | ⚠️ Works with issues | The view vibrates slightly, even when holding still. |
| 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. |
| Myst | Quest | ⚠️ Works with issues | Minor graphical glitches on some objects. |
| 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). |
| BONELAB | Quest | ❌ Doesn't run | Crashes while starting its graphics (Vulkan); no fix yet. |
| Espire 1: VR Operative (Quest Edition) | Quest | ❌ Doesn't run | Mesa GL driver crash during texture upload. |
| HITMAN 3 VR: Reloaded | Quest | ❌ Doesn't run | Vulkan driver crash (freedreno), even without Valve layers. |
| Journey of the Gods | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
| Roblox | Quest | ❌ Doesn't run | Crashes on its first VR frame on the Frame. |
| Shadow Point | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
| Sniper Elite VR | Quest | ❌ Doesn't run | GPU hang (zink: DEVICE LOST) even with MSAA off. |
| Sports Scramble (Santa Cruz) | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
+8
View File
@@ -54,6 +54,14 @@ Lepton from the Steam Frame page afterwards if it's missing.
If the setup command only says "timed out", a firewall on your computer blocks the Frame; the setup page
says which after about 45 seconds. Details per system: [FRAME_SETUP.md](FRAME_SETUP.md#network-and-firewalls).
## Running FramePort on the Frame (experimental)
FramePort can run on the Steam Frame itself, without a PC: in **Desktop Mode**, download
`FramePort-linux-arm64.tar.gz`, unpack it (`tar xzf FramePort-linux-arm64.tar.gz`) and start `FramePort/FramePort`.
Turn on **Developer Mode** first (Steam → Settings → System); FramePort then manages "This Frame" directly, with
no pairing. Games are added to the Steam library when you go back to **Gaming Mode** (Steam has to restart for it,
which would end Desktop Mode). This is new: please report anything odd with **Report a problem**.
## Installing games
- **Install on Frame** on a game's page (or select several in the Library and install them together). Installs run
+32
View File
@@ -50,6 +50,12 @@ 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 |
| Only the Android home screen shows; the log has Unity's VR device as `None` (e.g. Accounting+, Unity 2017) | Unity 2017–2018 built-in Oculus support starts VR only when `com.oculus.systemactivities` is installed; Lepton has no Meta system apps, so Unity falls back to non-VR | `frame.unity_oculus_check`: that package name in libunity.so → `android`, plus `native/ovrpshim` (libfp_ovrp.so): Unity 2017's legacy frame loop (`ovrp_Update2`/`ovrp_BeginFrame`) never calls `ovrp_WaitToBeginFrame`, so without the shim no `xrWaitFrame` happens, `CompositorOpenXR::Update … outside of frame bounds` floods the log and the dashboard freezes. With it: log `ovrp frame loop shim: waited for frame N`, ~71 fps. Accounting+ then stops at "press any button" although its OVRInput gets clean input (`INPUT_PROBE` in the patch logs connected controllers/buttons/input focus per call) — unresolved |
| Own-engine game crashes in `libVkLayer_fossilize.so` (Fossilize's recording thread, e.g. Roblox) | same as Deadpool VR: uninitialised pointers in what Fossilize records | `frame.vk_sanitize` now also covers own-engine libraries that load `libvulkan.so` by name |
| Crash right after the game gets focus, in `vrclient.so … UpdateActionStateInternal` / `sxr_xrSyncActions` (e.g. Myst) | a race in the Frame runtime's input code on the first sync after focus (not every launch) | adapter `sync_guard` (input syncs one at a time with event polling, paused 250 ms after FOCUSED) |
| View jumps sideways / game pauses or freezes for a moment every few seconds (e.g. Blade & Sorcery, BattleGlide) | the Frame drops focus for ~0.5 s (FOCUSED→VISIBLE→SYNCHRONIZED); games pause or re-align the player (`OnVRPresence … Teleport`) | adapter `focus_hold` |
| SDL / LÖVE app crashes at start: `NullPointerException … ClipboardManager.addPrimaryClipChangedListener` in `SDLClipboardHandler.<init>` (e.g. Dramatic Shape) | Lepton's Android has no clipboard service (`service check clipboard`: not found) | `frame.sdl_clipboard` (that call → nops in classes.dex, in place, `apk/dex.py`); reproduced and verified with LÖVE for Android 11.5 |
| Controllers do nothing (e.g. stuck on a setup screen); log: `xrSuggestInteractionProfileBindings … XR_ERROR_PATH_UNSUPPORTED` | the game only suggests bindings for Meta's newer profiles (Touch Plus / Touch Pro); the Frame's Android runtime knows `oculus/touch_controller`, not those | adapter `profile_remap` (default on): the bindings are suggested again for Touch (log: `controller profile … -> oculus/touch_controller`) |
| 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) |
@@ -71,6 +77,12 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| MR game stuck waiting for room data (Demeter) | no Meta scene API | adapter `scene_emul=1` (+ `frame.meta_permissions`) |
| Hand-tracking game janky (Silhouette) | Frame synthesizes hands from controllers | `controller_fix=0` passes hands through; not really fixable |
| Eye distortion while moving (Arcsmith, Time Stall) | unknown (not eye swap, tracking, Valve layers, depth or pacing) | unresolved |
| One eye grey / only effects (I Am Cat, Unity Oculus XR Plugin on GLES) | the game's multiview pass draws array slice 1 wrong on Zink | `frame.unity_multipass` (also rewrites the getter's inlined read in `OculusLoader.Initialize`; check for two `array=1` eye swapchains) |
| View vibrates even when holding still after `frame.unity_multipass` (I Am Cat) | poses/times are consistent, but two passes on Zink make every frame one period late, and the game clamps its physics step to 10 ms | not fixed (scale 0.8 didn't help); next ideas: fix multiview instead, or the game's fixed timestep |
| Game freezes / pauses for 1-2 s now and then, no flicker; only some games (Blade & Sorcery) | the headset's wear sensor flickers off while worn (SteamVR vrserver.txt `HMD off, stopping eye tracking` … `HMD on`), the Frame turns it into a focus loss and Quest games that pause on focus loss pause | adapter `focus_hold=1` + `focus_hold_ms=2500` per game; native games don't react, so it's per game |
| Minor glitches on some objects (Myst, Unreal Vulkan) | unknown; not application space warp (turning it off changed nothing and ran worse) | unresolved |
| Graphics crash in vkCreateInstance with pc == fault address, from the game's own Vulkan hooks (BONELAB `libSLZQuestNative`) | a pointer into memory freed with OVRPlugin's pre-init OpenXR instance; not the runtime (vrclient.so) or Fossilize being unloaded (keeping both loaded with RTLD_NODELETE didn't help) | unresolved |
| Own-engine game crashes in `je_free` from its own library on the first frames (Roblox) | the game frees memory it doesn't own; not the Vulkan shim or the format fallback | unsupported |
## Debugging techniques that worked
- A user's problem report: `frameport diag inspect <FramePort-diag-*.zip>` re-triages its launch log with the current
@@ -80,6 +92,26 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
- For GLES/GLAD engines, wrap `eglGetProcAddress` to see shader compile errors (build the shim with `-DGLSHIM_TRACE`
for per-FBO draw counts and draw-call errors; the bridge has `-DOVP_GL_DIAG` for eye-image readback).
- Anything visual needs one headset session per iteration — batch hypotheses into each build.
- Headless launch tests never get focus: they prove startup, not play. Crashes right after FOCUSED (Myst), focus
dips and judder only show up in a headset session: ask for one, then `frameport diag collect <pkg>` (it contains
the headset sessions too, plus this boot's kernel log).
- Which library a stale pointer belonged to: turn on the Android linker's log for one run by adding
`debug.ld.app.<pkg>=dlopen` as an extra line of `LEPTON_GFXRECON_FP_PROPS` (first line `0`) in the game's
launch.sh on the Frame (back it up, restore it after) and look for `dlclose: unloading "…"`.
- Unity catches native crashes itself: no debuggerd backtrace in Lepton's logcat-crash.log, but a
`tombstone_00` in the game's `Android/data/<pkg>/files/` with the memory map (map pc/lr/registers to libraries).
- Focus losses: the game sees FOCUSED→VISIBLE→SYNCHRONIZED (OVRPlugin `[XR_SESSION]`, FrameBridge `focus_hold:`
lines); the cause is on the host: `~/.local/share/Steam/logs/vrserver.txt` (`HMD off/on`, `entering standby`,
`SystemButtonDown` in vrclient_vrcompositor.txt) and XRService (`[UserPresence]`, `IMUFallback`). Compare the times
(Lepton logs UTC, host logs local time).
- A Unity getter patch that changes nothing: the compiler may have inlined it (no `BL` to its address in
libil2cpp.so) — patch the field read in the caller (`Il2cppReturnPatch.field_loads`).
- Fixes found by users count: a reporter's shader capture (VR4) became the `vk_shader_fix` recipe data (match by size
+ SHA-256, insert words); verify on the device that the log shows the module being fixed.
- Don't install or test on the Frame while the owner is playing: adding a library entry restarts Steam (FramePort
now skips the restart when the shortcut is unchanged and waits while a game runs), and a launch test starts a game.
- Catalog YAML: a `: ` inside an unquoted value breaks the file and the loader skipped it silently (the game vanished
from the list); `tests/test_catalog_files.py` now catches it.
- Keep the known-good APK and roll back if a change regresses (`PATCHED/_known-good-*`).
## When it can't run on the Frame
+34 -1
View File
@@ -76,9 +76,12 @@ static int no_equirect, no_equirect2, no_cylinder, no_cube;
static int runtime_has_fb_passthrough, emulate_passthrough, alpha_blend_failed;
static int controller_models; // serve Frame controller models via XR_FB_render_model (render_model.c)
// Per-game settings, all off by default (session_fixes.c, layer_emul_gl.c).
static int sync_guard; // xrSyncActions one at a time with xrPollEvent, paused briefly after focus returns
static int profile_remap = 1; // Meta's newer controller profiles (rejected by the Frame) -> oculus/touch_controller
static int layer_debug; // diagnostics: layers, swapchains, session states, spaces, aim/grip, refresh rates
static int stable_local; // keep every LOCAL space the app creates on the session-start origin
static int focus_hold; // hide brief focus dips once the session has been focused for a while
static float focus_hold_ms = 1000; // longest focus dip focus_hold hides
static float aim_pitch, aim_yaw, aim_forward; // aim pose correction (degrees, degrees, metres)
static float refresh_rate; // requested display refresh rate (Hz), 0 = the app's choice
static int equirect_emul; // show 360 layers as cube faces (GLES)
@@ -115,8 +118,11 @@ static void read_settings(const char *path) {
if (sscanf(line, "scene_depth=%f", &value) == 1 && value > 0.5f && value < 20.0f) scene_depth = value;
if (sscanf(line, "controller_models=%f", &value) == 1) controller_models = value != 0;
if (sscanf(line, "layer_debug=%f", &value) == 1) layer_debug = value != 0;
if (sscanf(line, "profile_remap=%f", &value) == 1) profile_remap = value != 0;
if (sscanf(line, "sync_guard=%f", &value) == 1) sync_guard = value != 0;
if (sscanf(line, "stable_local=%f", &value) == 1) stable_local = value != 0;
if (sscanf(line, "focus_hold=%f", &value) == 1) focus_hold = value != 0;
if (sscanf(line, "focus_hold_ms=%f", &value) == 1 && value >= 100 && value <= 5000) focus_hold_ms = value;
if (sscanf(line, "aim_pitch=%f", &value) == 1 && fabsf(value) <= 90) aim_pitch = value;
if (sscanf(line, "aim_yaw=%f", &value) == 1 && fabsf(value) <= 90) aim_yaw = value;
if (sscanf(line, "aim_forward=%f", &value) == 1 && fabsf(value) <= 0.5f) aim_forward = value;
@@ -728,7 +734,13 @@ XRAPI_ATTR XrResult XRAPI_CALL xrPollEvent(XrInstance instance, XrEventDataBuffe
pthread_mutex_unlock(&scene_lock);
if (got) return XR_SUCCESS;
}
if (sync_guard) pthread_mutex_lock(&sync_lock);
XrResult result = focus_hold && event ? focus_hold_poll(fn, instance, event) : fn(instance, event);
if (sync_guard) {
if (result == XR_SUCCESS && event && session_state_of(event) == XR_SESSION_STATE_FOCUSED)
sync_resume_at = monotonic_ns() + SYNC_GUARD_PAUSE_NS;
pthread_mutex_unlock(&sync_lock);
}
if (result == XR_SUCCESS && event && (layer_debug || stable_local)) {
if (event->type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED && layer_debug)
LOG("layer_debug: session state %d at %.3f", ((const XrEventDataSessionStateChanged *)event)->state,
@@ -867,6 +879,12 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
if (elapsed >= 5.0) {
LOG("pacing: %.1f fps, displayTime vs predicted: avg %.2f ms, max %.2f ms", frames / elapsed,
drift_sum / (double)frames / 1e6, drift_max / 1e6);
if (layer_debug)
LOG("layer_debug: input: %d xrSyncActions (%d ok, last %d), %d bool reads, %d pressed",
__atomic_exchange_n(&input_syncs, 0, __ATOMIC_RELAXED),
__atomic_exchange_n(&input_sync_ok, 0, __ATOMIC_RELAXED), input_last_sync_result,
__atomic_exchange_n(&input_bool_reads, 0, __ATOMIC_RELAXED),
__atomic_exchange_n(&input_bool_true, 0, __ATOMIC_RELAXED));
frames = 0; drift_sum = drift_max = 0;
}
}
@@ -1079,6 +1097,18 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetCurrentInteractionProfile(XrSession session,
(PFN_xrGetCurrentInteractionProfile)lookup(active_instance, "xrGetCurrentInteractionProfile");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
XrResult result = fn(session, user, state);
if (layer_debug && active_instance) { // which device each hand has, as the runtime reports it
PFN_xrPathToString str = (PFN_xrPathToString)lookup(active_instance, "xrPathToString");
char who[XR_MAX_PATH_LENGTH] = "?", what[XR_MAX_PATH_LENGTH] = "(none)";
uint32_t n = 0;
if (str) {
str(active_instance, user, sizeof(who), &n, who);
if (XR_SUCCEEDED(result) && state && state->interactionProfile)
str(active_instance, state->interactionProfile, sizeof(what), &n, what);
}
static int logged;
if (logged++ < 40) LOG("layer_debug: interaction profile of %s: %s (result %d)", who, what, result);
}
if (!controller_fix || XR_FAILED(result) || !state || !state->interactionProfile || !active_instance)
return result;
PFN_xrPathToString to_string = (PFN_xrPathToString)lookup(active_instance, "xrPathToString");
@@ -1211,8 +1241,11 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
if (layer_debug || refresh_rate > 0) {
HOOK_AS(xrRequestDisplayRefreshRateFB, hook_request_refresh_rate)
}
if (layer_debug || aim_correction_on()) {
if (sync_guard || layer_debug) HOOK_AS(xrSyncActions, hook_xrSyncActions)
if (layer_debug) HOOK_AS(xrGetActionStateBoolean, hook_xrGetActionStateBoolean)
if (layer_debug || aim_correction_on() || profile_remap)
HOOK_AS(xrSuggestInteractionProfileBindings, hook_xrSuggestInteractionProfileBindings)
if (layer_debug || aim_correction_on()) {
HOOK_AS(xrCreateActionSpace, hook_xrCreateActionSpace)
}
#undef HOOK_AS
+131 -5
View File
@@ -95,9 +95,55 @@ static void debug_reference_space(XrSession session, const XrReferenceSpaceCreat
LOG("layer_debug: that LOCAL space can't be located yet (no tracking)");
}
// ---------------------------------------------------------------- sync_guard
// The Frame's runtime crashed in xrSyncActions right after focus returned (SIGSEGV in vrclient.so
// CVRInputLatest::UpdateActionStateInternal, Myst; a race inside the runtime, not every launch). sync_guard runs
// xrSyncActions one at a time with xrPollEvent and skips it for a moment after FOCUSED arrives (XR_SESSION_NOT_FOCUSED
// is a success code every app handles: no input for those few frames).
#define SYNC_GUARD_PAUSE_NS 250000000ll
static pthread_mutex_t sync_lock = PTHREAD_MUTEX_INITIALIZER;
static int64_t sync_resume_at;
// layer_debug input counters, logged with the pacing line: were actions synced, did any button read as pressed?
static int input_syncs, input_sync_ok, input_bool_reads, input_bool_true, input_last_sync_result;
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrSyncActions(XrSession session, const XrActionsSyncInfo *info) {
PFN_xrSyncActions fn = (PFN_xrSyncActions)lookup(active_instance, "xrSyncActions");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
if (!sync_guard) { // installed for layer_debug only: count
XrResult r = fn(session, info);
__atomic_add_fetch(&input_syncs, 1, __ATOMIC_RELAXED);
if (r == XR_SUCCESS) __atomic_add_fetch(&input_sync_ok, 1, __ATOMIC_RELAXED);
input_last_sync_result = r;
return r;
}
pthread_mutex_lock(&sync_lock);
int64_t wait = sync_resume_at - monotonic_ns();
XrResult r = wait > 0 ? XR_SESSION_NOT_FOCUSED : fn(session, info);
pthread_mutex_unlock(&sync_lock);
static int logged;
if (wait > 0 && logged++ < 5) LOG("sync_guard: input paused for %.0f ms after focus returned", wait / 1e6);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrGetActionStateBoolean(XrSession session, const XrActionStateGetInfo *info,
XrActionStateBoolean *state) {
PFN_xrGetActionStateBoolean fn = (PFN_xrGetActionStateBoolean)lookup(active_instance, "xrGetActionStateBoolean");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
XrResult r = fn(session, info, state);
__atomic_add_fetch(&input_bool_reads, 1, __ATOMIC_RELAXED);
if (r == XR_SUCCESS && state && state->isActive && state->currentState)
__atomic_add_fetch(&input_bool_true, 1, __ATOMIC_RELAXED);
return r;
}
// ---------------------------------------------------------------- focus_hold
#define FOCUS_HOLD_MIN_FOCUSED_NS 3000000000ll // only after this long in FOCUSED (start-up transitions untouched)
#define FOCUS_HOLD_MAX_DIP_NS 600000000ll // dips longer than this are delivered (late, in order)
#define FOCUS_HOLD_MIN_FOCUSED_NS 1000000000ll // only after this long in FOCUSED (start-up transitions untouched;
// 3 s let a 27 ms dip through in Blade & Sorcery)
// dips longer than focus_hold_ms (setting, default 1000) are delivered (late, in order): taking the headset off or the
// system menu must still pause the game. A longer limit is for headsets whose wear sensor flickers ("HMD off" for
// 0.5-2 s while worn; Blade & Sorcery pauses on each)
#define FOCUS_HOLD_MAX_DIP_NS ((int64_t)(focus_hold_ms * 1000000.0f))
static struct {
XrEventDataBuffer events[6];
int count, holding, delivering;
@@ -131,7 +177,7 @@ static XrResult focus_hold_poll(PFN_xrPollEvent fn, XrInstance instance, XrEvent
if (result != XR_SUCCESS) {
if (held.holding && now - held.since > FOCUS_HOLD_MAX_DIP_NS) {
LOG("focus_hold: focus lost for > %lld ms, delivering %d state change(s)",
FOCUS_HOLD_MAX_DIP_NS / 1000000, held.count);
(long long)(FOCUS_HOLD_MAX_DIP_NS / 1000000), held.count);
held.holding = 0; held.delivering = 1;
continue;
}
@@ -195,20 +241,100 @@ static int pose_action_kind(XrAction action) { // 1 aim, 0 grip, -1 unknown
return kind;
}
// Meta's newer controller profiles, which the Frame's runtime doesn't know (it has oculus/touch_controller). An app
// that suggests bindings only for these gets XR_ERROR_PATH_UNSUPPORTED and no input (e.g. a setup screen that can't be
// passed); its bindings are suggested again for Touch, keeping the components Touch controllers have.
static const char *const NEWER_TOUCH[] = {"/interaction_profiles/meta/touch_controller_plus",
"/interaction_profiles/meta/touch_plus_controller",
"/interaction_profiles/facebook/touch_controller_pro",
"/interaction_profiles/meta/touch_pro_controller",
"/interaction_profiles/meta/touch_controller_rift_cv1",
"/interaction_profiles/meta/touch_controller_quest_1_rift_s",
"/interaction_profiles/meta/touch_controller_quest_2"};
static const char *const TOUCH_PROFILE = "/interaction_profiles/oculus/touch_controller";
// oculus/touch_controller components (OpenXR spec), after /user/hand/<left|right>/
static const char *const TOUCH_COMMON[] = {"input/squeeze/value", "input/trigger/value", "input/trigger/touch",
"input/thumbstick", "input/thumbstick/x", "input/thumbstick/y", "input/thumbstick/click",
"input/thumbstick/touch", "input/thumbrest/touch", "input/grip/pose", "input/aim/pose", "output/haptic"};
static const char *const TOUCH_LEFT[] = {"input/x/click", "input/x/touch", "input/y/click", "input/y/touch",
"input/menu/click"};
static const char *const TOUCH_RIGHT[] = {"input/a/click", "input/a/touch", "input/b/click", "input/b/touch",
"input/system/click"};
static int app_suggested_touch;
static int in_list(const char *s, const char *const *list, size_t n) {
for (size_t i = 0; i < n; ++i)
if (!strcmp(s, list[i])) return 1;
return 0;
}
#define IN(s, list) in_list(s, list, sizeof(list) / sizeof(*list))
// 1 if the binding path exists on oculus/touch_controller
static int touch_has(const char *path) {
const char *left = "/user/hand/left/", *right = "/user/hand/right/";
if (!strncmp(path, left, strlen(left))) {
path += strlen(left);
return IN(path, TOUCH_COMMON) || IN(path, TOUCH_LEFT);
}
if (!strncmp(path, right, strlen(right))) {
path += strlen(right);
return IN(path, TOUCH_COMMON) || IN(path, TOUCH_RIGHT);
}
return 0;
}
static XrResult remap_to_touch(XrInstance instance, PFN_xrSuggestInteractionProfileBindings fn,
PFN_xrPathToString to_string, const XrInteractionProfileSuggestedBinding *suggested,
const char *profile) {
PFN_xrStringToPath to_path = (PFN_xrStringToPath)lookup(instance, "xrStringToPath");
XrPath touch;
if (!to_path || XR_FAILED(to_path(instance, TOUCH_PROFILE, &touch))) return XR_ERROR_PATH_UNSUPPORTED;
uint32_t n = suggested->countSuggestedBindings, kept = 0;
XrActionSuggestedBinding *b = calloc(n ? n : 1, sizeof(*b));
if (!b) return XR_ERROR_OUT_OF_MEMORY;
for (uint32_t i = 0; i < n; ++i) {
char path[XR_MAX_PATH_LENGTH];
uint32_t size = 0;
if (XR_SUCCEEDED(to_string(instance, suggested->suggestedBindings[i].binding, sizeof(path), &size, path))
&& touch_has(path))
b[kept++] = suggested->suggestedBindings[i];
}
XrInteractionProfileSuggestedBinding copy = *suggested;
copy.interactionProfile = touch;
copy.countSuggestedBindings = kept;
copy.suggestedBindings = b;
XrResult r = kept ? fn(instance, &copy) : XR_ERROR_PATH_UNSUPPORTED;
free(b);
LOG("controller profile %s -> oculus/touch_controller (%u bindings, %u dropped): %d", profile + 22, kept,
n - kept, r);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrSuggestInteractionProfileBindings(XrInstance instance,
const XrInteractionProfileSuggestedBinding *suggested) {
PFN_xrSuggestInteractionProfileBindings fn =
(PFN_xrSuggestInteractionProfileBindings)lookup(instance, "xrSuggestInteractionProfileBindings");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
PFN_xrPathToString to_string = (PFN_xrPathToString)lookup(instance, "xrPathToString");
if (suggested && to_string)
if (suggested && to_string && (layer_debug || aim_correction_on()))
for (uint32_t i = 0; i < suggested->countSuggestedBindings; ++i) {
char path[XR_MAX_PATH_LENGTH];
uint32_t size = 0;
if (XR_SUCCEEDED(to_string(instance, suggested->suggestedBindings[i].binding, sizeof(path), &size, path)))
note_pose_binding(suggested->suggestedBindings[i].action, path);
}
return fn(instance, suggested);
XrResult r = fn(instance, suggested);
if (!profile_remap || !suggested || !to_string) return r;
char profile[XR_MAX_PATH_LENGTH] = {0};
uint32_t size = 0;
if (XR_FAILED(to_string(instance, suggested->interactionProfile, sizeof(profile), &size, profile))) return r;
if (!strcmp(profile, TOUCH_PROFILE)) {
if (XR_SUCCEEDED(r)) app_suggested_touch = 1; // the app's own Touch bindings win over a remap
return r;
}
if (r != XR_ERROR_PATH_UNSUPPORTED || app_suggested_touch || !IN(profile, NEWER_TOUCH)) return r;
XrResult remapped = remap_to_touch(instance, fn, to_string, suggested, profile);
return XR_SUCCEEDED(remapped) ? XR_SUCCESS : r;
}
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrCreateActionSpace(XrSession session, const XrActionSpaceCreateInfo *info,
+12 -3
View File
@@ -10,7 +10,7 @@ timefix OpenXR layer for Proton games (linux-arm64, glibc; the NDK's clang build
OculusHMDConnected helper for Rift games under Proton (win-x64 PE; the NDK's clang + lld-link, no Windows SDK), and
rewrites artifacts/SHA256SUMS. Run `frameport parity` afterwards to see which games change.
python native/build.py [--only adapter,bridge,compat,glshim,dex,xrlayer,oculushmd,xrshim,vkshim,vrsettings] [--ndk PATH]
python native/build.py [--only adapter,bridge,compat,glshim,dex,xrlayer,oculushmd,xrshim,vkshim,ovrpshim,vrsettings] [--ndk PATH]
"""
from __future__ import annotations
@@ -247,6 +247,14 @@ def build_vkshim(tc: Path):
cwd=src)
def build_ovrpshim(tc: Path):
"""OVRPlugin frame-loop shim (see ovrpshim/ovrpshim.c) for Unity 2017-2018 built-in Oculus support."""
src = HERE / "ovrpshim"
run([exe(tc, "aarch64-linux-android29-clang"), "-shared", "-fPIC", "-O2", "-Wall", "-Wextra", "-Werror",
"-fvisibility=hidden", "-Wl,-soname,libfp_ovrp.so", "-Wl,-z,max-page-size=16384", "ovrpshim.c", "-ldl",
"-llog", "-o", ART / "arm64-v8a/libfp_ovrp.so"], cwd=src)
def build_dex():
javac = shutil.which("javac")
if not javac:
@@ -291,7 +299,7 @@ def write_sums():
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--only", default="adapter,bridge,compat,glshim,dex,xrlayer,oculushmd,xrshim,vkshim,vrsettings")
ap.add_argument("--only", default="adapter,bridge,compat,glshim,dex,xrlayer,oculushmd,xrshim,vkshim,ovrpshim,vrsettings")
ap.add_argument("--ndk")
args = ap.parse_args()
parts = set(args.only.split(","))
@@ -301,8 +309,9 @@ def main():
steps = {"adapter": lambda: build_adapter(tc), "bridge": lambda: build_bridge(tc), "compat": lambda: build_compat(tc),
"glshim": lambda: build_glshim(tc), "xrshim": lambda: build_xrshim(tc), "dex": build_dex, "xrlayer": lambda: build_xrlayer(tc),
"oculushmd": lambda: build_oculushmd(tc), "vkshim": lambda: build_vkshim(tc),
"ovrpshim": lambda: build_ovrpshim(tc),
"vrsettings": lambda: build_vrsettings(tc)}
for name in ("adapter", "bridge", "compat", "glshim", "xrshim", "vkshim", "dex", "xrlayer", "oculushmd", "vrsettings"):
for name in ("adapter", "bridge", "compat", "glshim", "xrshim", "vkshim", "ovrpshim", "dex", "xrlayer", "oculushmd", "vrsettings"):
if name in parts:
log(f"build {name}")
steps[name]()
+195
View File
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: GPL-3.0-only
// FramePort OVRPlugin frame-loop shim for Unity 2017-2018 built-in Oculus support.
//
// That Unity drives OVRPlugin with the legacy frame loop: ovrp_Update2(render step, frame index, ...) on the main
// thread, then ovrp_BeginFrame / ovrp_EndFrame on the render thread. It never calls ovrp_WaitToBeginFrame, which
// newer OVRPlugin builds (OVRPort's OpenXR OVRPlugin) need to call xrWaitFrame: no frame ever begins, OVRPlugin logs
// "CompositorOpenXR::Update called for frame N outside of frame bounds" thousands of times a second and the Frame's
// dashboard freezes (Accounting+, Unity 2017.4).
//
// FramePort renames the "ovrp_Update2" lookup in libunity.so to "fpov_Update2" (same length) and adds this library to
// libOVRPlugin.so's DT_NEEDED, so Unity's dlsym on the plugin finds this function: it waits for the frame first (once
// per frame index, render step only), then calls the real ovrp_Update2.
#include <android/log.h>
#include <dlfcn.h>
#include <pthread.h>
#define TAG "FrameBridge"
#define EXPORT __attribute__((visibility("default")))
#define LOG(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
typedef int (*PFN_Update2)(int step, int frame_index, double prediction_seconds);
typedef int (*PFN_WaitToBeginFrame)(int frame_index);
static PFN_Update2 real_update2;
static PFN_WaitToBeginFrame real_wait;
static pthread_once_t once = PTHREAD_ONCE_INIT;
static void init(void) {
void *ovrp = dlopen("libOVRPlugin.so", RTLD_NOW | RTLD_NOLOAD);
if (!ovrp) ovrp = dlopen("libOVRPlugin.so", RTLD_NOW);
if (ovrp) {
real_update2 = (PFN_Update2)dlsym(ovrp, "ovrp_Update2");
real_wait = (PFN_WaitToBeginFrame)dlsym(ovrp, "ovrp_WaitToBeginFrame");
}
LOG("ovrp frame loop shim: ovrp_Update2 %s, ovrp_WaitToBeginFrame %s", real_update2 ? "OK" : "MISSING",
real_wait ? "OK" : "MISSING");
}
#define STEP_RENDER (-1) // ovrpStep_Render
EXPORT int fpov_Update2(int step, int frame_index, double prediction_seconds) {
pthread_once(&once, init);
static int last_waited = -1, logged;
if (step == STEP_RENDER && real_wait && frame_index != last_waited) {
last_waited = frame_index;
int r = real_wait(frame_index);
if (logged++ < 3) LOG("ovrp frame loop shim: waited for frame %d: %d", frame_index, r);
}
return real_update2 ? real_update2(step, frame_index, prediction_seconds) : -1000; // ovrpFailure
}
// ---------------------------------------------------------------- input diagnostics (pass-through)
// The game's C# input (OVRInput) asks OVRPlugin which controllers are connected and for their button state. These
// wrappers return exactly what OVRPlugin returns and log the connected mask and every change of the button bits, to
// see whether presses reach the game (Accounting+ stuck at "press any button" while the runtime reports presses).
typedef struct { unsigned int words[3]; } State; // the common start: ConnectedControllers, Buttons, Touches
// ovrpControllerState2 is returned by value (through x8): the type must have its exact size, 64 bytes (4 uint32,
// IndexTrigger[2], HandTrigger[2], Thumbstick[2] and Touchpad[2] as float pairs)
typedef struct { unsigned int words[16]; } State2;
_Static_assert(sizeof(State2) == 64, "ovrpControllerState2 is 64 bytes");
typedef unsigned int (*PFN_GetConnected)(void);
typedef int (*PFN_GetState4)(unsigned int mask, State *state); // (written by OVRPlugin; only the start is read)
typedef State2 (*PFN_GetState2)(unsigned int mask);
static PFN_GetConnected real_connected;
static PFN_GetState4 real_state4;
static PFN_GetState2 real_state2;
static pthread_once_t input_once = PTHREAD_ONCE_INIT;
static void input_init(void) {
void *ovrp = dlopen("libOVRPlugin.so", RTLD_NOW | RTLD_NOLOAD);
if (!ovrp) return;
real_connected = (PFN_GetConnected)dlsym(ovrp, "ovrp_GetConnectedControllers");
real_state4 = (PFN_GetState4)dlsym(ovrp, "ovrp_GetControllerState4");
real_state2 = (PFN_GetState2)dlsym(ovrp, "ovrp_GetControllerState2");
}
// per requested controller mask (the game may ask for Touch, Remote, Gamepad... separately)
static struct { unsigned int mask, connected, buttons; int seen; } masks[8];
static int changes;
// Face buttons on the Frame's controllers can read as clicked while only touched (Accounting+: buttons == touches ==
// A|B|X|Y held for seconds). A button held for more than STUCK_NS is reported released until it really lets go, so
// real presses (short) still register as a change.
#include <time.h>
#define STUCK_NS 2000000000ll
static long long held_since[32];
static unsigned int suppressed;
static long long now_ns(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return t.tv_sec * 1000000000ll + t.tv_nsec;
}
static unsigned int unstick(unsigned int buttons) {
long long now = now_ns();
for (int b = 0; b < 32; b++) {
unsigned int bit = 1u << b;
if (!(buttons & bit)) {
held_since[b] = 0;
suppressed &= ~bit;
continue;
}
if (!held_since[b]) held_since[b] = now;
if (now - held_since[b] > STUCK_NS && !(suppressed & bit)) {
suppressed |= bit;
static int logged;
if (logged++ < 20) LOG("ovrp input: button 0x%x held for > 2 s: reported released until let go", bit);
}
}
return buttons & ~suppressed;
}
static void note_state(const char *which, unsigned int mask, const State *s) {
int i = 0;
while (i < 8 && masks[i].seen && masks[i].mask != mask) i++;
if (i == 8) return;
if (!masks[i].seen || masks[i].buttons != s->words[1] || masks[i].connected != s->words[0]) {
if (changes++ < 120)
LOG("ovrp input: %s(mask 0x%x): connected 0x%x buttons 0x%x touches 0x%x%s", which, mask, s->words[0],
s->words[1], s->words[2], masks[i].seen ? "" : " (first call with this mask)");
masks[i].seen = 1;
masks[i].mask = mask;
masks[i].connected = s->words[0];
masks[i].buttons = s->words[1];
}
}
EXPORT unsigned int fpov_GetConnectedControllers(void) {
pthread_once(&input_once, input_init);
unsigned int c = real_connected ? real_connected() : 0;
static unsigned int last = 0xffffffffu;
static int logged;
if (c != last && logged++ < 20) LOG("ovrp input: connected controllers 0x%x", c);
last = c;
return c;
}
EXPORT int fpov_GetControllerState4(unsigned int mask, State *state) {
pthread_once(&input_once, input_init);
int r = real_state4 ? real_state4(mask, state) : -1000;
if (state) {
note_state("State4", mask, state);
state->words[1] = unstick(state->words[1]);
}
return r;
}
EXPORT State2 fpov_GetControllerState2(unsigned int mask) {
pthread_once(&input_once, input_init);
State2 s = {{0}};
if (real_state2) s = real_state2(mask);
note_state("State2", mask, (const State *)&s);
s.words[1] = unstick(s.words[1]);
return s;
}
// Unity's own input (libunity.so: Input.GetKey/anyKey on the Oculus device) reads ovrp_GetControllerState (v1,
// returned by value, 48 bytes: 4 uint32, IndexTrigger[2], HandTrigger[2], Thumbstick[2]) and ovrp_GetControllerState2
typedef struct { unsigned int words[12]; } State1;
_Static_assert(sizeof(State1) == 48, "ovrpControllerState is 48 bytes");
typedef State1 (*PFN_GetState1)(unsigned int mask);
static PFN_GetState1 real_state1;
EXPORT State1 fpov_GetControllerState(unsigned int mask) {
pthread_once(&input_once, input_init);
if (!real_state1) {
void *ovrp = dlopen("libOVRPlugin.so", RTLD_NOW | RTLD_NOLOAD);
if (ovrp) real_state1 = (PFN_GetState1)dlsym(ovrp, "ovrp_GetControllerState");
}
State1 s = {{0}};
if (real_state1) s = real_state1(mask);
note_state("State1(unity)", mask, (const State *)&s);
return s;
}
// Oculus Utilities (1.3x) ignores every button unless OVRPlugin reports input focus; with Unity 2017's legacy frame
// loop on OVRPort's OVRPlugin that may never be true. Report it as true (the session's own focus still pauses the game)
// and log what OVRPlugin said.
typedef int (*PFN_GetInputFocus)(int *has_focus);
static PFN_GetInputFocus real_input_focus;
EXPORT int fpov_GetAppHasInputFocus(int *has_focus) {
pthread_once(&input_once, input_init);
if (!real_input_focus) {
void *ovrp = dlopen("libOVRPlugin.so", RTLD_NOW | RTLD_NOLOAD);
if (ovrp) real_input_focus = (PFN_GetInputFocus)dlsym(ovrp, "ovrp_GetAppHasInputFocus");
}
int value = 0, r = real_input_focus ? real_input_focus(&value) : -1000;
static int last = -2, logged;
if ((r < 0 ? -1 : value) != last && logged++ < 20) LOG("ovrp input: OVRPlugin input focus %d (result %d)", value, r);
last = r < 0 ? -1 : value;
if (has_focus) *has_focus = 1;
return 0; // ovrpSuccess
}
+201 -10
View File
@@ -8,6 +8,11 @@
// at this shim (patches/frame/vk_sanitize.py). dlsym on the shim finds the functions below first and every other
// symbol in its dependency, the real libvulkan.so. Valid pointers are kept; a pointer is dropped only when it isn't
// readable memory or doesn't point at a structure type the parent may chain.
//
// Shader fixes (adapter setting vk_shader_fix, from a game's recipe): a SPIR-V module whose size and SHA-256 match is
// copied with extra words inserted at a byte offset, e.g. stores that initialize locals a shader reads before writing
// (an undefined loop counter hung the GPU in VR4's campaign). Every other module passes through unchanged.
// Format: vk_shader_fix=<size>:<sha256 hex>:<byte offset>:<word>,<word>,...[;<next fix>]
#define _GNU_SOURCE
#include <vulkan/vulkan.h>
#include <android/log.h>
@@ -27,14 +32,162 @@ static void *real_vk;
static PFN_vkGetInstanceProcAddr real_gipa;
static PFN_vkGetDeviceProcAddr real_gdpa;
static PFN_vkCreateRenderPass2 real_rp2_trampoline;
static PFN_vkCreateShaderModule real_csm_trampoline;
static pthread_once_t once = PTHREAD_ONCE_INIT;
// ---------------------------------------------------------------- shader fixes (settings)
#define MAX_FIXES 16
#define MAX_WORDS 64
static struct {
size_t size, offset;
uint8_t sha[32];
uint32_t words[MAX_WORDS];
int nwords;
} fixes[MAX_FIXES];
static int nfixes;
static int hexval(char c) {
return c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : c >= 'A' && c <= 'F' ? c - 'A' + 10 : -1;
}
static void parse_fixes(char *v) {
for (char *save = NULL, *item = strtok_r(v, ";", &save); item && nfixes < MAX_FIXES; item = strtok_r(NULL, ";", &save)) {
char *f[4] = {0}, *save2 = NULL;
int n = 0;
for (char *t = strtok_r(item, ":", &save2); t && n < 4; t = strtok_r(NULL, ":", &save2)) f[n++] = t;
if (n != 4 || strlen(f[1]) != 64) continue;
int ok = 1;
for (int i = 0; i < 32 && ok; i++) {
int hi = hexval(f[1][2 * i]), lo = hexval(f[1][2 * i + 1]);
if (hi < 0 || lo < 0) ok = 0; else fixes[nfixes].sha[i] = (uint8_t)(hi << 4 | lo);
}
fixes[nfixes].size = strtoul(f[0], NULL, 0);
fixes[nfixes].offset = strtoul(f[2], NULL, 0);
int nw = 0;
for (char *save3 = NULL, *w = strtok_r(f[3], ",", &save3); w && nw < MAX_WORDS; w = strtok_r(NULL, ",", &save3))
fixes[nfixes].words[nw++] = (uint32_t)strtoul(w, NULL, 0);
fixes[nfixes].nwords = nw;
if (ok && nw && fixes[nfixes].size % 4 == 0 && fixes[nfixes].offset % 4 == 0 &&
fixes[nfixes].offset >= 20 && fixes[nfixes].offset <= fixes[nfixes].size)
nfixes++;
}
}
static void read_settings(const char *path) {
FILE *f = fopen(path, "r");
if (!f) return;
char line[4096];
while (fgets(line, sizeof line, f)) {
line[strcspn(line, "\r\n")] = 0;
if (!strncmp(line, "vk_shader_fix=", 14)) {
nfixes = 0; // later sources override earlier ones (as for the adapter's settings)
parse_fixes(line + 14);
}
}
fclose(f);
}
static void read_all_settings(void) {
char path[600];
Dl_info info;
if (dladdr((void *)read_all_settings, &info) && info.dli_fname) {
const char *slash = strrchr(info.dli_fname, '/');
if (slash && slash - info.dli_fname < 500) {
snprintf(path, sizeof path, "%.*s/libframe_settings.so", (int)(slash - info.dli_fname), info.dli_fname);
read_settings(path);
}
}
char pkg[256] = {0};
FILE *f = fopen("/proc/self/cmdline", "r");
if (f) {
size_t n = fread(pkg, 1, sizeof pkg - 1, f);
pkg[n] = 0;
fclose(f);
}
char *colon = strchr(pkg, ':');
if (colon) *colon = 0;
if (*pkg && !strchr(pkg, '/')) {
snprintf(path, sizeof path, "/sdcard/Android/data/%s/files/framebridge.conf", pkg);
read_settings(path);
}
const char *env = getenv("FRAMEBRIDGE_CONFIG");
if (env && *env) read_settings(env);
}
// SHA-256 (FIPS 180-4), only run for modules whose size matches a fix
static const uint32_t K[64] = {
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01,
0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08,
0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
#define ROR(x, n) (((x) >> (n)) | ((x) << (32 - (n))))
static void sha256_block(uint32_t h[8], const uint8_t *p) {
uint32_t w[64];
for (int i = 0; i < 16; i++) w[i] = (uint32_t)p[4 * i] << 24 | (uint32_t)p[4 * i + 1] << 16 | (uint32_t)p[4 * i + 2] << 8 | p[4 * i + 3];
for (int i = 16; i < 64; i++) {
uint32_t s0 = ROR(w[i - 15], 7) ^ ROR(w[i - 15], 18) ^ (w[i - 15] >> 3);
uint32_t s1 = ROR(w[i - 2], 17) ^ ROR(w[i - 2], 19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
}
uint32_t a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7];
for (int i = 0; i < 64; i++) {
uint32_t t1 = hh + (ROR(e, 6) ^ ROR(e, 11) ^ ROR(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i];
uint32_t t2 = (ROR(a, 2) ^ ROR(a, 13) ^ ROR(a, 22)) + ((a & b) ^ (a & c) ^ (b & c));
hh = g; g = f; f = e; e = d + t1; d = c; c = b; b = a; a = t1 + t2;
}
h[0] += a; h[1] += b; h[2] += c; h[3] += d; h[4] += e; h[5] += f; h[6] += g; h[7] += hh;
}
static void sha256(const uint8_t *data, size_t len, uint8_t out[32]) {
uint32_t h[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
size_t i = 0;
for (; i + 64 <= len; i += 64) sha256_block(h, data + i);
uint8_t tail[128] = {0};
size_t rest = len - i;
memcpy(tail, data + i, rest);
tail[rest] = 0x80;
size_t blocks = rest + 9 > 64 ? 2 : 1;
uint64_t bits = (uint64_t)len * 8;
for (int b = 0; b < 8; b++) tail[blocks * 64 - 1 - b] = (uint8_t)(bits >> (8 * b));
for (size_t b = 0; b < blocks; b++) sha256_block(h, tail + 64 * b);
for (int b = 0; b < 8; b++) {
out[4 * b] = h[b] >> 24; out[4 * b + 1] = h[b] >> 16; out[4 * b + 2] = h[b] >> 8; out[4 * b + 3] = h[b];
}
}
// The fixed copy of `code` (caller frees) and its size, or NULL when no fix matches.
static uint32_t *fixed_shader(const uint32_t *code, size_t size, size_t *out_size) {
for (int i = 0; i < nfixes; i++) {
if (fixes[i].size != size) continue;
uint8_t digest[32];
sha256((const uint8_t *)code, size, digest);
if (memcmp(digest, fixes[i].sha, 32)) continue;
size_t extra = (size_t)fixes[i].nwords * 4;
uint32_t *copy = malloc(size + extra);
if (!copy) return NULL;
memcpy(copy, code, fixes[i].offset);
memcpy((uint8_t *)copy + fixes[i].offset, fixes[i].words, extra);
memcpy((uint8_t *)copy + fixes[i].offset + extra, (const uint8_t *)code + fixes[i].offset, size - fixes[i].offset);
*out_size = size + extra;
return copy;
}
return NULL;
}
static void init(void) {
read_all_settings();
if (nfixes) LOG("vk shim: %d shader fix(es) configured", nfixes);
real_vk = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL);
if (real_vk) {
real_gipa = (PFN_vkGetInstanceProcAddr)dlsym(real_vk, "vkGetInstanceProcAddr");
real_gdpa = (PFN_vkGetDeviceProcAddr)dlsym(real_vk, "vkGetDeviceProcAddr");
real_rp2_trampoline = (PFN_vkCreateRenderPass2)dlsym(real_vk, "vkCreateRenderPass2");
real_csm_trampoline = (PFN_vkCreateShaderModule)dlsym(real_vk, "vkCreateShaderModule");
}
LOG("vk shim: libvulkan.so %s", real_gipa && real_gdpa ? "OK" : "MISSING");
}
@@ -203,17 +356,26 @@ static VkRenderPassCreateInfo2 clean_create_info(Arena *a, const VkRenderPassCre
// ---------------------------------------------------------------- entry points
#define MAX_DEVICES 8
static struct { VkDevice device; PFN_vkCreateRenderPass2 rp2, rp2khr; } devices[MAX_DEVICES];
enum { FN_RP2, FN_RP2KHR, FN_CSM, FN_COUNT };
static const char *const FN_NAMES[FN_COUNT] = {"vkCreateRenderPass2", "vkCreateRenderPass2KHR", "vkCreateShaderModule"};
static struct { VkDevice device; PFN_vkVoidFunction fn[FN_COUNT]; } devices[MAX_DEVICES];
static PFN_vkCreateRenderPass2 fallback_rp2khr; // from vkGetInstanceProcAddr
static pthread_mutex_t dev_lock = PTHREAD_MUTEX_INITIALIZER;
static PFN_vkCreateRenderPass2 real_for(VkDevice device, int khr) {
PFN_vkCreateRenderPass2 fn = NULL;
// The next implementation of a wrapped device function: the one vkGetDeviceProcAddr returned for this device, else
// a fresh lookup, else the loader's trampoline.
static PFN_vkVoidFunction device_fn(VkDevice device, int which) {
PFN_vkVoidFunction fn = NULL;
pthread_mutex_lock(&dev_lock);
for (int i = 0; i < MAX_DEVICES; i++)
if (devices[i].device == device) { fn = khr ? devices[i].rp2khr : devices[i].rp2; break; }
if (devices[i].device == device) { fn = devices[i].fn[which]; break; }
pthread_mutex_unlock(&dev_lock);
if (!fn && real_gdpa) fn = (PFN_vkCreateRenderPass2)real_gdpa(device, khr ? "vkCreateRenderPass2KHR" : "vkCreateRenderPass2");
if (!fn && real_gdpa) fn = real_gdpa(device, FN_NAMES[which]);
return fn;
}
static PFN_vkCreateRenderPass2 real_for(VkDevice device, int khr) {
PFN_vkCreateRenderPass2 fn = (PFN_vkCreateRenderPass2)device_fn(device, khr ? FN_RP2KHR : FN_RP2);
if (!fn) fn = khr ? fallback_rp2khr : real_rp2_trampoline;
return fn;
}
@@ -246,10 +408,39 @@ static VKAPI_ATTR VkResult VKAPI_CALL create_render_pass2_khr(VkDevice device, c
return create_render_pass2(device, ci, alloc, rp, 1);
}
static int fixes_applied;
EXPORT VKAPI_ATTR VkResult VKAPI_CALL vkCreateShaderModule(VkDevice device, const VkShaderModuleCreateInfo *ci,
const VkAllocationCallbacks *alloc, VkShaderModule *module) {
pthread_once(&once, init);
PFN_vkCreateShaderModule real = (PFN_vkCreateShaderModule)device_fn(device, FN_CSM);
if (!real) real = real_csm_trampoline;
if (!real) return VK_ERROR_INITIALIZATION_FAILED;
size_t size = 0;
uint32_t *code = nfixes && ci && ci->pCode ? fixed_shader(ci->pCode, ci->codeSize, &size) : NULL;
if (!code) return real(device, ci, alloc, module);
VkShaderModuleCreateInfo copy = *ci; // the caller's create info and code stay untouched
copy.codeSize = size;
copy.pCode = code;
VkResult r = real(device, &copy, alloc, module);
free(code);
if (fixes_applied++ < 20) LOG("vk shim: fixed shader module (%zu -> %zu bytes): %d", ci->codeSize, size, r);
return r;
}
static int wrapped_index(const char *name) {
for (int i = 0; i < FN_COUNT; i++)
if (!strcmp(name, FN_NAMES[i])) return i;
return -1;
}
static PFN_vkVoidFunction wrap(const char *name) {
if (!strcmp(name, "vkCreateRenderPass2")) return (PFN_vkVoidFunction)vkCreateRenderPass2;
if (!strcmp(name, "vkCreateRenderPass2KHR")) return (PFN_vkVoidFunction)create_render_pass2_khr;
return NULL;
switch (wrapped_index(name)) {
case FN_RP2: return (PFN_vkVoidFunction)vkCreateRenderPass2;
case FN_RP2KHR: return (PFN_vkVoidFunction)create_render_pass2_khr;
case FN_CSM: return nfixes ? (PFN_vkVoidFunction)vkCreateShaderModule : NULL; // nothing to fix: no detour
default: return NULL;
}
}
EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL vkGetDeviceProcAddr(VkDevice device, const char *name) {
@@ -258,7 +449,7 @@ EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL vkGetDeviceProcAddr(VkDevice dev
PFN_vkVoidFunction fn = real_gdpa(device, name);
PFN_vkVoidFunction w = fn ? wrap(name) : NULL;
if (!w) return fn;
int khr = name[strlen(name) - 1] == 'R';
int which = wrapped_index(name);
pthread_mutex_lock(&dev_lock);
int slot = -1;
for (int i = 0; i < MAX_DEVICES; i++) {
@@ -267,7 +458,7 @@ EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL vkGetDeviceProcAddr(VkDevice dev
}
if (slot >= 0) {
devices[slot].device = device;
if (khr) devices[slot].rp2khr = (PFN_vkCreateRenderPass2)fn; else devices[slot].rp2 = (PFN_vkCreateRenderPass2)fn;
devices[slot].fn[which] = fn;
}
pthread_mutex_unlock(&dev_lock);
return w;
+3 -1
View File
@@ -24,7 +24,9 @@ def short(note: str) -> str:
def render() -> str:
entries = [e for e in catalog.load().values() if e.origin == "bundled" and e.status in STATUS]
# the repo's own recipes only (a local user catalog would replace or hide entries)
bundled = catalog._load_dir(catalog.catalog_dir() / "games", "bundled")
entries = [e for e in bundled.values() if e.status in STATUS]
entries.sort(key=lambda e: (ORDER[e.status], e.title.lower()))
rows = ["| Game | Platform | Status | Notes |", "|---|---|---|---|"]
for e in entries:
+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.3"
__version__ = "0.7.0"
+6
View File
@@ -93,6 +93,9 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
abi = next((a for a in ("arm64-v8a", "armeabi-v7a") if a in abis), None)
prefix = f"lib/{abi}/" if abi else None
libs = sorted(n[len(prefix):] for n in names if prefix and n.startswith(prefix) and n.endswith(".so"))
# SDL's Java side (SDL2 / LÖVE apps): crashes in Lepton without a clipboard service (frame.sdl_clipboard)
sdl_java = any(b"Lorg/libsdl/app/SDLClipboardHandler;" in z.read(n) for n in names
if n.startswith("classes") and n.endswith(".dex"))
manifest = z.read("AndroidManifest.xml")
lib_bytes = {}
if deep and prefix:
@@ -195,7 +198,10 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
"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,
"sdl_java": sdl_java,
"oculus_xr_plugin": bool(il2cpp_meta) and b"\0m_StereoRenderingModeAndroid\0" in il2cpp_meta,
# Unity's built-in Oculus support checks for Meta's system apps before VR (frame.unity_oculus_check)
"unity_oculus_check": b"\0com.oculus.systemactivities\0" in lib_bytes.get("libunity.so", b""),
"unity_version": unity_version(ggm, lib_bytes.get("libunity.so")) if engine == "Unity" else None,
},
)
+20 -2
View File
@@ -20,9 +20,15 @@ _ADDRESS = re.compile(r'Offset = "0x([0-9A-Fa-f]+)", Length = "0x([0-9A-Fa-f]+)"
def parse_methods(cs_text: str, names: list[str]) -> dict[str, tuple[int, int]]:
"""{method name: (file offset, length)} for the named methods of one Cpp2IL diffable-C# class file."""
"""{method name: (file offset, length)} for the named methods of one Cpp2IL diffable-C# class file. A name
"field:<f>" gives the instance field's offset instead: (offset, 0)."""
lines = cs_text.splitlines()
found: dict[str, tuple[int, int]] = {}
for name in [n for n in names if n.startswith("field:")]:
m = re.search(rf"\s{re.escape(name[6:])};\s*//Field offset: 0x([0-9A-Fa-f]+)", cs_text)
if m:
found[name] = (int(m.group(1), 16), 0)
names = [n for n in names if not n.startswith("field:")]
for i, line in enumerate(lines):
for name in names:
if name in found or not re.search(rf"\s{re.escape(name)}\s*\(", line) or "Token" in line:
@@ -35,6 +41,13 @@ def parse_methods(cs_text: str, names: list[str]) -> dict[str, tuple[int, int]]:
return found
def _last_error(output: str) -> str:
"""The exception line of Cpp2IL's output (its stack trace isn't useful to a user)."""
lines = [ln.strip() for ln in output.splitlines() if ln.strip()]
msg = next((ln for ln in reversed(lines) if "Exception" in ln and not ln.startswith("at ")), None)
return (msg or (lines[-1] if lines else "no output"))[:300]
def _plain_version(version: str) -> str:
"""6000.2.7f2 -> 6000.2.7 (what Cpp2IL's --force-unity-version takes)."""
m = re.match(r"\d+\.\d+\.\d+", version)
@@ -54,6 +67,8 @@ def find_methods(lib: bytes, metadata: bytes, unity_version: str, wanted: dict[s
cache_file = _cache_file(lib, metadata)
try:
cached = json.loads(cache_file.read_text())
if cached.get("error"): # Cpp2IL can't read this build (e.g. an old Unity): don't run it again per patch
raise RuntimeError(cached["error"])
if all(cls in cached.get("classes", {}) or cls in cached.get("missing", []) for cls in wanted):
return {cls: {m: tuple(v) for m, v in cached["classes"][cls].items() if m in names}
for cls, names in wanted.items() if cls in cached.get("classes", {})}
@@ -75,7 +90,10 @@ def find_methods(lib: bytes, metadata: bytes, unity_version: str, wanted: dict[s
timeout=timeout, cwd=tmp)
root = t / "out" / "DiffableCs"
if not root.is_dir():
raise RuntimeError("Cpp2IL couldn't read this game's code: " + (proc.stdout + proc.stderr)[-400:].strip())
error = "Cpp2IL couldn't read this game's code: " + _last_error(proc.stdout + proc.stderr)
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps({"unity_version": unity_version, "error": error}))
raise RuntimeError(error)
out, missing = {}, []
for cls, names in wanted.items():
f = root / cls
+93
View File
@@ -0,0 +1,93 @@
"""Minimal in-place dex edits: find one method's bytecode and turn a call in it into no-ops (same length), then fix the
header's SHA-1 signature and Adler-32 checksum. Nothing moves, so the rest of classes.dex stays byte-identical (no
smali round trip)."""
from __future__ import annotations
import hashlib
import struct
import zlib
INVOKE_VIRTUAL, INVOKE_VIRTUAL_RANGE = 0x6E, 0x74 # 35c / 3rc: 3 code units, method index in the second
def _uleb(data: bytes, pos: int) -> tuple[int, int]:
value = shift = 0
while True:
b = data[pos]
pos += 1
value |= (b & 0x7F) << shift
if not b & 0x80:
return value, pos
shift += 7
class Dex:
def __init__(self, data: bytes):
if data[:4] != b"dex\n":
raise ValueError("not a dex file")
self.data = bytearray(data)
h = struct.unpack_from("<8I", data, 56) # string_ids size/off, type_ids, proto_ids, field_ids
self.strings_n, self.strings_off, self.types_n, self.types_off = h[0], h[1], h[2], h[3]
self.methods_n, self.methods_off, self.classes_n, self.classes_off = struct.unpack_from("<4I", data, 88)
def string(self, idx: int) -> str:
off = struct.unpack_from("<I", self.data, self.strings_off + 4 * idx)[0]
_n, pos = _uleb(self.data, off)
end = self.data.index(0, pos)
return bytes(self.data[pos:end]).decode("utf-8", "replace")
def type_name(self, idx: int) -> str:
return self.string(struct.unpack_from("<I", self.data, self.types_off + 4 * idx)[0])
def method(self, idx: int) -> tuple[str, str]:
cls, _proto, name = struct.unpack_from("<HHI", self.data, self.methods_off + 8 * idx)
return self.type_name(cls), self.string(name)
def method_index(self, cls: str, name: str) -> list[int]:
return [i for i in range(self.methods_n) if self.method(i) == (cls, name)]
def code_items(self, cls: str, name: str) -> list[int]:
"""Offsets of the code items of the methods `name` in class `cls` (direct and virtual)."""
out = []
for c in range(self.classes_n):
base = self.classes_off + 32 * c
if self.type_name(struct.unpack_from("<I", self.data, base)[0]) != cls:
continue
class_data = struct.unpack_from("<I", self.data, base + 24)[0]
if not class_data:
continue
pos = class_data
sf, pos = _uleb(self.data, pos)
inf, pos = _uleb(self.data, pos)
dm, pos = _uleb(self.data, pos)
vm, pos = _uleb(self.data, pos)
for _ in range(sf + inf):
_, pos = _uleb(self.data, pos)
_, pos = _uleb(self.data, pos)
for count in (dm, vm):
idx = 0
for _ in range(count):
diff, pos = _uleb(self.data, pos)
_, pos = _uleb(self.data, pos)
code, pos = _uleb(self.data, pos)
idx += diff
if code and self.method(idx)[1] == name:
out.append(code)
return out
def nop_calls(self, code_off: int, targets: set[int]) -> int:
"""Replace invoke-virtual(/range) calls of any method index in `targets` inside one code item with nops."""
insns_n = struct.unpack_from("<I", self.data, code_off + 12)[0]
start = code_off + 16
units = struct.unpack_from(f"<{insns_n}H", self.data, start)
done = 0
for i in range(insns_n - 2):
if units[i] & 0xFF in (INVOKE_VIRTUAL, INVOKE_VIRTUAL_RANGE) and units[i + 1] in targets:
struct.pack_into("<3H", self.data, start + 2 * i, 0, 0, 0)
done += 1
return done
def finish(self) -> bytes:
self.data[12:32] = hashlib.sha1(bytes(self.data[32:])).digest()
struct.pack_into("<I", self.data, 8, zlib.adler32(bytes(self.data[12:])) & 0xFFFFFFFF)
return bytes(self.data)
+13
View File
@@ -86,7 +86,20 @@ def ready_thumb(path: Path, width: int, kind: str | None = None) -> Path | None:
return out if out.exists() else None
_FILE_PATHS = False
def use_file_paths(on: bool = True) -> None:
"""Images by absolute file path: a packaged app (flet build) serves relative image paths from its own bundled
assets, not from assets_dir, so artwork by URL stayed blank there (GitHub issue 16; the reporter confirmed file
paths work in the Windows app). Source runs and the web view (ui_smoke) keep URLs."""
global _FILE_PATHS
_FILE_PATHS = on
def asset_url(path: Path) -> str:
if _FILE_PATHS:
return str(path.resolve())
return "/" + path.relative_to(user_data_dir()).as_posix()
+12 -4
View File
@@ -42,18 +42,26 @@ def edit() -> Iterator[dict]:
save(data)
REFRESH_ON_UPDATE = True # (tests switch it off: their hand-made libraries have no version marker)
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."""
"""Recipes FramePort derived (not ones the user edited) stay current: when their catalog entry is new or changed,
and once after every app update (new automatic fixes and suggestions in FramePort's code), the game's recipe is
derived again; a changed recipe shows Update on Frame."""
from .. import __version__
from ..recommend import catalog, engine
changed = False
settings = data.setdefault("settings", {})
app_updated = REFRESH_ON_UPDATE and settings.get("recipes.app_version") != __version__
changed = app_updated
settings["recipes.app_version"] = __version__
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():
if not app_updated and (entry is None or r.get("catalog_rev") == entry.rev()):
continue
try:
g["recipe"] = recipe_to_dict(engine.suggest(analysis_from_dict(a)))
+10 -1
View File
@@ -39,6 +39,12 @@ def bootstrap_dir() -> Path:
return DATA_ROOT / "bootstrap"
def _on_frame() -> bool:
from ..frame.local import on_frame
return on_frame()
def user_data_dir() -> Path:
"""Tools, keystores, work dirs and settings. Override with FRAMEPORT_HOME."""
if os.environ.get("FRAMEPORT_HOME"):
@@ -48,7 +54,10 @@ def user_data_dir() -> Path:
elif sys.platform == "darwin":
base = Path.home() / "Library/Application Support/FramePort"
else:
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "frameport"
# on the Frame itself ~/.local/share/frameport is the agent's folder (and its purge removes it): the app's own
# data (library, tools, game signing keys) lives next to it
name = "frameport-app" if _on_frame() else "frameport"
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / name
if not _removed.is_set():
base.mkdir(parents=True, exist_ok=True)
return base
+128 -5
View File
@@ -9,11 +9,13 @@ from __future__ import annotations
import contextlib
import hashlib
import json
import logging
import os
import posixpath
import re
import stat
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
@@ -21,6 +23,8 @@ import paramiko
from ..core.paths import agent_file, ssh_dir, user_data_dir, write_atomic
log = logging.getLogger(__name__)
REMOTE_AGENT_DIR = ".local/share/frameport/agent"
@@ -112,7 +116,14 @@ _saved_cache: tuple[float, list] = (-1.0, [])
def saved_targets() -> list[FrameTarget]:
"""The remembered Frames (read again only when frames.json changed: the sidebar asks several times a second)."""
"""The remembered Frames (read again only when frames.json changed: the sidebar asks several times a second).
FramePort running on a Frame remembers that Frame itself (127.0.0.1) the first time."""
if not (user_data_dir() / "frames.json").exists():
return _local_frame()
return _read_saved()
def _read_saved() -> list[FrameTarget]:
global _saved_cache
path = user_data_dir() / "frames.json"
try:
@@ -130,8 +141,62 @@ def saved_targets() -> list[FrameTarget]:
return []
def is_unreachable(exc: BaseException) -> bool:
"""The Frame isn't there at all (asleep, off the network): retrying an upload at once won't help."""
return isinstance(exc, (ConnectionRefusedError, TimeoutError, paramiko.ssh_exception.NoValidConnectionsError))
def _local_frame() -> list[FrameTarget]:
"""On a Frame with nothing remembered yet: this Frame, with FramePort's key authorized for the local user."""
from . import local
if not local.on_frame():
return []
try:
local.authorize_self(app_public_key())
except OSError:
pass
target = FrameTarget("127.0.0.1", local.local_user(), 22, local.LOCAL_NAME)
save_target(target)
return [target]
def replace_target(old_host: str, target: FrameTarget) -> None:
"""The remembered Frame at old_host is now at target.host (same device: its SSH host key matched)."""
items = [FrameTarget(target.host, t.user, t.port, t.name) if t.host == old_host else t for t in _read_saved()]
if target.host not in [t.host for t in items]:
items.insert(0, target)
seen, out = set(), []
for t in items:
if t.host not in seen:
seen.add(t.host)
out.append(t)
write_atomic(user_data_dir() / "frames.json", json.dumps([t.__dict__ for t in out], indent=2))
def server_key(host: str, port: int = 22, timeout: float = 5) -> paramiko.PKey | None:
"""The SSH host key a device presents (no login)."""
import socket
try:
sock = socket.create_connection((host, port), timeout=timeout)
except OSError:
return None
transport = paramiko.Transport(sock)
try:
transport.start_client(timeout=timeout)
return transport.get_remote_server_key()
except (OSError, paramiko.SSHException, EOFError):
return None
finally:
transport.close()
_last_relocate = -1e9
def save_target(target: FrameTarget) -> None:
items = [t for t in saved_targets() if t.host != target.host]
items = [t for t in _read_saved() if t.host != target.host]
items.insert(0, target)
write_atomic(user_data_dir() / "frames.json", json.dumps([t.__dict__ for t in items], indent=2))
@@ -282,6 +347,48 @@ class Frame:
# ------------------------------------------------------------------ connect
def connect(self, timeout: float = 10) -> Frame:
try:
return self._connect(timeout)
except OSError:
if self.target.host in ("127.0.0.1", "localhost"): # FramePort on the Frame itself: sshd isn't running
raise ConnectionError("FramePort runs on this Frame but can't reach it over SSH: turn on Developer "
"Mode (Steam → Settings → System → Developer Mode), then try again") from None
moved = self.relocate() # the router gave the Frame a new address (it's remembered by address)
if not moved:
raise
log.info("Frame moved from %s to %s", self.target.host, moved)
old, self.target.host = self.target.host, moved
self._connect(timeout)
replace_target(old, self.target)
return self
def relocate(self) -> str | None:
"""The Frame's new address when it no longer answers at the remembered one: a device on the network whose
SSH host key is the one this PC saw at the old address (checked before logging in). At most once a minute (the
GUI retries every few seconds while a Frame sleeps)."""
global _last_relocate
if time.monotonic() - _last_relocate < 60 or self.target.host not in [t.host for t in saved_targets()]:
return None
_last_relocate = time.monotonic()
hk = paramiko.HostKeys()
try:
hk.load(str(ssh_dir() / "known_hosts"))
except OSError:
return None
known = hk.lookup(self.target.host if self.target.port == 22 else f"[{self.target.host}]:{self.target.port}")
if not known:
return None
from .discovery import browse
for f in browse(seconds=3.0):
if f.host == self.target.host or f.port != self.target.port:
continue
key = server_key(f.host, f.port)
if key is not None and known.get(key.get_name()) == key:
return f.host
return None
def _connect(self, timeout: float) -> Frame:
known = ssh_dir() / "known_hosts"
known.touch(exist_ok=True)
kwargs = dict(hostname=self.target.host, port=self.target.port, username=self.target.user, timeout=timeout,
@@ -335,6 +442,8 @@ class Frame:
presents the same SSH host key as this connection. Returns (frame, link name); (self, "") if none."""
import socket
if self.target.host in ("127.0.0.1", "localhost"): # FramePort on the Frame itself: nothing is faster
return self, ""
key = self.client.get_transport().get_remote_server_key()
for iface in FAST_LINKS:
for name, ip in self.addresses():
@@ -435,10 +544,24 @@ class Frame:
if dirs:
self.run("xargs -0 mkdir -p", stdin="\0".join(dirs))
def put(self, local: Path, remote: str, progress=None, resume: bool = True, mkdir: bool = True) -> None:
def put(self, local: Path, remote: str, progress=None, resume: bool = True, mkdir: bool = True,
attempts: int = 3) -> None:
"""Upload with resume: data goes to <remote>.part, appended from the existing size, then renamed. progress may
raise (e.g. Cancelled): the .part stays and the next put continues from there."""
local = Path(local)
raise (e.g. Cancelled): the .part stays and the next put continues from there. A failed transfer is resumed
up to `attempts` times (Windows: an occasional OSError [Errno 22] mid-upload that the next try got past)."""
for attempt in range(attempts):
try:
return self._put(Path(local), remote, progress, resume, mkdir)
except OSError as exc:
if attempt == attempts - 1 or is_unreachable(exc):
raise
log.warning("upload of %s failed (%s), resuming", Path(local).name, exc)
time.sleep(2)
if not self.alive():
self.close()
self.connect()
def _put(self, local: Path, remote: str, progress, resume: bool, mkdir: bool) -> None:
size = local.stat().st_size
part = remote + ".part"
if mkdir:
+45
View File
@@ -0,0 +1,45 @@
"""FramePort running on the Steam Frame itself (Desktop Mode, the Linux ARM64 bundle): it manages the same device over
SSH to 127.0.0.1, so everything else (agent, installs, launch tests) works unchanged. Developer Mode has to be on for
sshd; FramePort authorizes its own key for the local user, no pairing needed."""
from __future__ import annotations
import functools
import getpass
import os
import platform
from pathlib import Path
LOCAL_NAME = "This Frame (experimental)" # FramePort running on the Frame itself
@functools.lru_cache(maxsize=1)
def on_frame() -> bool:
"""SteamOS on ARM64 (the Steam Frame)."""
if platform.machine().lower() not in ("aarch64", "arm64"):
return False
try:
text = Path("/etc/os-release").read_text(encoding="utf-8", errors="replace")
except OSError:
return False
return any(line.strip().lower() in ("id=steamos", 'id="steamos"') for line in text.splitlines())
def authorize_self(public_key: str) -> bool:
"""Add FramePort's public key to this user's ~/.ssh/authorized_keys (once). True if it was added."""
ssh = Path.home() / ".ssh"
keys = ssh / "authorized_keys"
ssh.mkdir(mode=0o700, exist_ok=True)
have = keys.read_text(encoding="utf-8", errors="replace") if keys.exists() else ""
if public_key.strip() in have:
return False
with open(keys, "a", encoding="utf-8") as f:
f.write(("" if not have or have.endswith("\n") else "\n") + public_key.strip() + " frameport-local\n")
os.chmod(keys, 0o600)
return True
def local_user() -> str:
try:
return getpass.getuser()
except Exception: # noqa: BLE001
return "steamos"
+6
View File
@@ -392,6 +392,12 @@ def add_to_steam(frame: Frame, packages: list[str], reporter: Reporter, wait: fl
continue
if status.get("state") in ("done", "failed"):
break
if status.get("state") == "waiting": # Steam restarts later: after the game, or back in Gaming Mode
reporter.check("Steam library", None,
"added when you're back in Gaming Mode (Steam has to restart for it)"
if status.get("reason") == "desktop" else
"added when the game that's running now is closed (Steam has to restart for it)")
return status
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']}")
+25 -3
View File
@@ -100,6 +100,7 @@
"Already patched: don't change the game's files": "",
"Already patched: install as is (skip patching)": "",
"Already set up: on your network": "",
"Also delete this game's files on this PC ({size})": "",
"Also in your library: {value} version": "",
"Also remove FramePort's games and files from the Frame": "",
"An ordinary Android app or game (no VR). It's installed unchanged and runs in Lepton, Valve's Android container.": "",
@@ -114,6 +115,7 @@
"APK": "",
"Appearance": "",
"Apply the suggested patches and reinstall": "",
"Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead (components Touch lacks are dropped). Without it such apps get no controller input.": "",
"Artwork for {get}": "",
"Artwork updated ({src}). The Frame's Steam library still shows the old art.": "",
"Artwork updated for {get}": "",
@@ -284,11 +286,14 @@
"Fix upside-down menus": "",
"Fixes graphics code that the Frame's drivers reject (black screen with sound).": "",
"Flat": "",
"focus_hold hides focus dips up to this long (100-5000). Longer for a headset whose wear sensor flickers (\"HMD off\" for 0.5-2 s while worn, e.g. Blade & Sorcery pausing); taking the headset off or the system menu pauses the game only after this long.": "",
"Folder": "",
"folder and everything in it": "",
"Folder name": "",
"Folder to upload to the Frame": "",
"Folder with VR games (Android APKs or PC VR games)": "",
"For apps that only know Quest 3/Pro controllers: without it they get no buttons.": "",
"For games that crash right after you return to them (e.g. after the Steam menu).": "",
"For games that pause or recenter when the headset briefly loses focus.": "",
"For games that show the headset's own controllers. Takes effect after a reinstall.": "",
"For games whose menus or screens jump to wherever you look.": "",
@@ -363,7 +368,7 @@
"Hide Quest foveation": "",
"Hides Android's back/home/recents buttons, which cover the app's own buttons.": "",
"Hides Quest foveation extensions the Frame runtime lacks.": "",
"Hides the Frame's brief focus dips (well under a second) once the game has been focused for a few seconds. For games that recentre or pause every time focus returns.": "",
"Hides the Frame's brief focus dips (up to a second) once the game has been focused for a second. For games that recentre or pause every time focus returns.": "",
"High": "",
"Higher is sharper but needs more power.": "",
"Higher keeps more detail but uses more memory.": "",
@@ -376,6 +381,7 @@
"If the pointer doesn't hit what you aim at, tilt it up or down.": "",
"If Windows asked whether FramePort may use the network, allow it (Private networks).": "",
"If you know the Frame's address.": "",
"Ignore focus dips up to": "",
"In your Steam library": "",
"In your Steam library · launch settings changed — update it": "",
"Install": "",
@@ -441,9 +447,12 @@
"Lepton is Valve's Android container on the Frame. Quest games run inside it, one container per game. It needs Developer Mode.": "",
"Lepton only launches an activity with category LAUNCHER (Quest apps often use INFO). Symptom when missing: launch.log says 'APP_ACTIVITY is empty' and nothing starts.": "",
"Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as \"Gamescope\" (the Frame's compositor). Pairs with \"Make Unity text fields work without a system keyboard\".": "",
"Lepton's Android has no clipboard service, and SDL's Java code (SDL2 and LÖVE games, e.g. Dramatic Shape) registers a clipboard listener at start without checking, so the app crashes with a NullPointerException in SDLClipboardHandler. That call is skipped (an in-place edit of classes.dex).": "",
"Lets games made for Oculus PCs run on SteamVR.": "",
"Lets games written for newer OpenXR versions run on the Frame.": "",
"Lets mixed-reality games start even though they insist on a camera view of your room.": "",
"Lets older Unity games start VR without Meta's system apps (they'd stay on a 2D screen).": "",
"Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboard).": "",
"Lets typing reach this app: Steam's on-screen keyboard or your computer's keyboard (VR is unaffected).": "",
"Lets Unity games accept a headset that isn't a Quest.": "",
"Lets Unreal games accept a headset that isn't a Quest.": "",
@@ -456,6 +465,7 @@
"Loads libglshim.so ahead of libEGL in every engine library that fetches GL through eglGetProcAddress. Fixes Quest-only leniency that Mesa/Zink rejects: #pragma before #extension, implicit int/float conversions (enables GL_EXT_shader_implicit_conversions), and multiview shaders used on single-view framebuffers (hides GL_OVR_multiview; gl_hide_multiview=0 turns that off). Logs every failed shader with its source lines (logcat tag GLShim). Symptom: black screen with working audio in a GLES game.": "",
"Locations": "",
"Logs details for debugging: composition layers and swapchains, focus changes, play-space creation, pointer vs grip poses, refresh rates. No effect on the game.": "",
"Longer for headsets that briefly think they're off your head.": "",
"Looking for Frames on your network…": "",
"Looking for your Frame…": "",
"Looking up the store description and screenshots…": "",
@@ -530,7 +540,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.": "",
"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 and its inlined read in OculusLoader.Initialize -> 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).": "",
@@ -542,7 +552,6 @@
"On this PC, runs Revive through SteamVR (works with more headsets).": "",
"on: {names}": "",
"One PC VR game folder": "",
"Only FramePort's entry is removed. Your game files and anything installed stay.": "",
"Only if 360° pictures show wrongly.": "",
"Only if 360° pictures show wrongly: 1 = upside down, 2 = mirrored, 4 = turned around (add values to combine).": "",
"Open": "",
@@ -655,10 +664,12 @@
"Remove {title} from the library?": "",
"Removed the converted copies ({size})": "",
"Removed {title}": "",
"Removed {title} and deleted its files on this PC ({size})": "",
"Removes a controller position adjustment, for games whose hands look misplaced.": "",
"Removes Android's back/home/recents buttons from a 2D app's window, where they cover the app's own controls. Turn it off for an app that needs the on-screen back button.": "",
"Removes debugging leftovers some game files contain.": "",
"Removes everything FramePort created: its data and tools on this PC, the Steam entries it added, and (optionally) its games and files on the Frame. Your game dumps aren't touched.": "",
"Removes FramePort's entry. Anything installed on the Frame stays.": "",
"Removes leftovers from dumped/modded APKs.": "",
"Removes Meta's old VR library when the game doesn't need it.": "",
"Removes OVRPort's controller pose offset if controllers look misplaced.": "",
@@ -699,6 +710,7 @@
"Runs Oculus PC games on OpenXR": "",
"Runs OVRPort and apksigner": "",
"Runs the game directly": "",
"Runs xrSyncActions one at a time with xrPollEvent and skips it for 250 ms after focus returns. For games that crash in the runtime's input code right after focus comes back (SIGSEGV in vrclient.so UpdateActionStateInternal / xrSyncActions, e.g. Myst).": "",
"Save": "",
"Save as known-good recipe": "",
"Saved as a known-good recipe": "",
@@ -712,6 +724,7 @@
"Scan a folder…": "",
"Scan {name}": "",
"Scans the folders you added games from again and adds games that appeared since. Games already in the library keep their recipe, tags and art; unchanged ones aren't analyzed again.": "",
"SDL apps: start without a clipboard service": "",
"Search": "",
"Search again": "",
"Search games and tags": "",
@@ -777,6 +790,7 @@
"Starts the game with options, e.g. to choose SteamVR or OpenXR.": "",
"Starts the game with the launcher that came with your copy.": "",
"Status": "",
"Steady controller input after focus": "",
"Steam": "",
"Steam for Windows not found": "",
"Steam Frame": "",
@@ -860,6 +874,7 @@
"Tools": "",
"Tools are up to date": "",
"Translator for games built on Meta's oldest VR interface.": "",
"Treat newer Quest controllers as Touch": "",
"Tried it in the headset? Tell us how it runs: with your recipe it can join FramePort's built-in list, so it works out of the box for everyone.": "",
"Troubleshooting": "",
"Try again": "",
@@ -890,6 +905,9 @@
"Uninstall {title}?": "",
"Uninstall. ": "",
"Uninstalled {title}": "",
"Uninstalled {title} and deleted its files on this PC ({size})": "",
"Unity 2017: start VR without Meta's system apps": "",
"Unity's older built-in Oculus support (Unity 2017–2018) only starts VR when Android has Meta's com.oculus.systemactivities package; without it the game runs as a hidden 2D app (you see the Android home screen, e.g. Accounting+). Points that package name in libunity.so at \"android\", which always exists, and adds the frame wait its legacy frame loop never makes (libfp_ovrp.so calls ovrp_WaitToBeginFrame before ovrp_Update2; without it no frame starts and the dashboard freezes).": "",
"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)": "",
@@ -942,6 +960,8 @@
"View screenshot": "",
"VrApi → OpenXR adapter (OVRPort)": "",
"VrApi → OpenXR bridge": "",
"Vulkan shader fixes": "",
"Vulkan shim (frame.vk_sanitize): SPIR-V modules matching size + SHA-256 get words inserted at a byte offset, e.g. stores that initialize locals a shader reads before writing (an undefined loop counter hung the GPU in VR4's campaign). Format: <size>:<sha256>:<byte offset>:<word>,<word>,...; several separated by ';'. Comes from a game's recipe.": "",
"Vulkan: drop invalid render-pass pointers": "",
"v{remote} (updates on the next action)": "",
"Waiting": "",
@@ -1049,6 +1069,8 @@
"{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} will be in the Frame's Steam library when the game that's running is closed": "",
"{title} will be in the Frame's Steam library when you're back in Gaming Mode: start it from there": "",
"{title}: analyzed again": "",
"{title}: details from {value}": "",
"{title}: done": "",
+22
View File
@@ -73,6 +73,7 @@ class Patch:
default_on: bool = False # part of the always-recommended baseline
experimental: bool = False
needs_vr: bool = True # only matters for VR apps (hidden for Android apps without VR)
revision: int = 1 # bump when apply() writes something different: installed builds then show "Update on Frame"
@property
def summary(self) -> str:
@@ -168,3 +169,24 @@ def _import_modules() -> None:
for mod in pkgutil.iter_modules(frame.__path__):
importlib.import_module(f"{frame.__name__}.{mod.name}")
def recipe_fingerprint(recipe: dict) -> str:
"""What a build of this recipe contains: the patch selection with parameters, the build choices and each enabled
patch's revision. A build whose fingerprint differs from the recipe's now is outdated (a catalog fix, a revised
patch or a changed option), even when nothing was rebuilt yet."""
import hashlib
import json
load_all()
patches = recipe.get("patches") or {}
d = {"patches": patches, "use_alt": bool(recipe.get("use_alt")), "alt": sorted(recipe.get("alt_patches") or []),
"as_is": bool(recipe.get("as_is")), "overport": recipe.get("overport", True),
"rev": {p: REGISTRY[p].revision for p in sorted(patches) if p in REGISTRY and REGISTRY[p].revision != 1}}
return hashlib.sha256(json.dumps(d, sort_keys=True, default=str).encode()).hexdigest()[:16]
def revised_since_unrecorded(recipe: dict) -> bool:
"""For builds made before fingerprints were recorded: an enabled patch has been revised since (revision > 1)."""
load_all()
return any(p in REGISTRY and REGISTRY[p].revision > 1 for p in recipe.get("patches") or {})
@@ -0,0 +1,48 @@
"""SDL apps (SDL2 / LÖVE games, e.g. Dramatic Shape) crash at start in Lepton: its Android has no clipboard service
(`service check clipboard`: not found), getSystemService(CLIPBOARD_SERVICE) returns null and SDLClipboardHandler's
constructor calls addPrimaryClipChangedListener on it (NullPointerException in SDLActivity.onCreate). That one call is
turned into no-ops in classes.dex (in place; other clipboard calls only happen when the app uses the clipboard)."""
from __future__ import annotations
from ...apk.dex import Dex
from ..base import ApkContext, Patch, Suggestion, register
HANDLER = "Lorg/libsdl/app/SDLClipboardHandler;"
LISTENER = ("Landroid/content/ClipboardManager;", "addPrimaryClipChangedListener")
class SdlClipboard(Patch):
id = "frame.sdl_clipboard"
title = "SDL apps: start without a clipboard service"
description = ("Lepton's Android has no clipboard service, and SDL's Java code (SDL2 and LÖVE games, e.g. "
"Dramatic Shape) registers a clipboard listener at start without checking, so the app crashes "
"with a NullPointerException in SDLClipboardHandler. That call is skipped (an in-place edit of "
"classes.dex).")
order = 44
needs_vr = False
def applies(self, a):
return bool((a.extra or {}).get("sdl_java"))
def detect(self, a):
if self.applies(a):
return Suggestion(True, "SDL app: it crashes at start in Lepton, which has no clipboard service.")
return None
def apply(self, ctx: ApkContext) -> bool:
changed = False
for name in sorted(n for n in ctx.ws.names() if n.startswith("classes") and n.endswith(".dex")):
data = ctx.ws.read(name)
if HANDLER.encode() not in data:
continue
dex = Dex(data)
targets = set(dex.method_index(*LISTENER))
done = sum(dex.nop_calls(code, targets) for code in dex.code_items(HANDLER, "<init>")) if targets else 0
if done:
ctx.ws.put(name, dex.finish())
ctx.notes.append(f"{name}: SDLClipboardHandler no longer needs a clipboard service ({done} call)")
changed = True
return changed
register(SdlClipboard)
+7 -4
View File
@@ -26,8 +26,8 @@ def count_msaa_levels(ggm_bytes: bytes) -> int:
def disable_msaa(ggm_bytes: bytes) -> bytes | None:
"""Set every quality level's antiAliasing to 0 by patching the int in place (re-serializing with UnityPy breaks
scene loading). The field is found through the 4-int run skinWeights, textureQuality, anisotropicTextures,
antiAliasing."""
scene loading). The field is found through the 4-int run skinWeights (blendWeights before Unity 2019),
textureQuality, anisotropicTextures, antiAliasing."""
data = bytearray(ggm_bytes)
patched = 0
for obj, raw, level in _levels(ggm_bytes):
@@ -35,9 +35,12 @@ def disable_msaa(ggm_bytes: bytes) -> bytes | None:
if aa <= 1:
continue
base = obj.byte_start
if bytes(data[base:base + len(raw)]) != raw:
if ggm_bytes[base:base + len(raw)] != raw: # (not `data`: an earlier level of the same object changed it)
raise RuntimeError("QualitySettings object bytes not found in place")
key = struct.pack("<4i", level["skinWeights"], level["textureQuality"], level["anisotropicTextures"], aa)
weights = level.get("skinWeights", level.get("blendWeights"))
if weights is None:
raise RuntimeError(f"quality level {level.get('name')}: unknown QualitySettings layout")
key = struct.pack("<4i", weights, level["textureQuality"], level["anisotropicTextures"], aa)
at = raw.find(key)
if at < 0:
raise RuntimeError(f"antiAliasing field not found for quality level {level['name']}")
@@ -0,0 +1,86 @@
"""Unity 2017-era built-in Oculus support starts its VR device only on a Quest/Go: libunity.so asks Android's package
manager for com.oculus.systemactivities (Meta's system UI) and, when the lookup fails, quietly falls back to its "None"
VR device. Lepton's Android has no such package, so the game ran as a plain 2D app that Lepton never shows (only the
Android home screen, e.g. Accounting+, Unity 2017.4). The package name in libunity.so is pointed at "android" (always
installed): a same-length, in-place string edit; everything else about the check stays as it is."""
from __future__ import annotations
from ...analysis import elf
from ..base import ApkContext, Patch, Suggestion, register
from . import artifact
PACKAGE = "com.oculus.systemactivities"
ALWAYS_THERE = "android"
# Unity's legacy frame loop never calls ovrp_WaitToBeginFrame: its ovrp_Update2 lookup goes to native/ovrpshim
SHIM = "libfp_ovrp.so"
UPDATE, SHIM_UPDATE = "ovrp_Update2", "fpov_Update2"
# input diagnostics (off): the C# P/Invoke names in libil2cpp.so pointed at the shim's wrappers, which log what they
# return, report input focus as true and release buttons held > 2 s. Accounting+ still didn't pass "press any button"
# with them (input reached the game cleanly), so they stay off; switch on to investigate another game.
INPUT_PROBE = False
INPUT_CALLS = ("ovrp_GetConnectedControllers", "ovrp_GetControllerState4", "ovrp_GetControllerState2",
"ovrp_GetAppHasInputFocus")
UNITY_INPUT_CALLS = ("ovrp_GetControllerState", "ovrp_GetControllerState2")
class UnityOculusCheck(Patch):
id = "frame.unity_oculus_check"
title = "Unity 2017: start VR without Meta's system apps"
description = ("Unity's older built-in Oculus support (Unity 2017–2018) only starts VR when Android has Meta's "
"com.oculus.systemactivities package; without it the game runs as a hidden 2D app (you see the "
"Android home screen, e.g. Accounting+). Points that package name in libunity.so at \"android\", "
"which always exists, and adds the frame wait its legacy frame loop never makes (libfp_ovrp.so "
"calls ovrp_WaitToBeginFrame before ovrp_Update2; without it no frame starts and the dashboard "
"freezes).")
order = 45
def applies(self, a):
x = a.extra or {}
major = int(str(x.get("unity_version") or "0").split(".")[0] or 0)
# Unity 2019+ games run without it (e.g. Lucky's Tale 2019.4): their builds stay as they are
return a.engine == "Unity" and "libOVRPlugin.so" in a.libs and bool(x.get("unity_oculus_check")) \
and 0 < major < 2019
def detect(self, a):
if self.applies(a):
return Suggestion(True, "Older Unity with built-in Oculus support: it checks for Meta's system apps "
"before starting VR, and its frame loop never waits for the next frame "
"(e.g. Accounting+).")
return None
def apply(self, ctx: ApkContext) -> bool:
ws = ctx.ws
name = ws.lib("libunity.so")
if not ws.has(name):
return False
data, count = elf.replace_rodata_string(ws.read(name), PACKAGE, ALWAYS_THERE)
if not count:
return False
data, loops = elf.replace_rodata_string(data, UPDATE, SHIM_UPDATE)
plugin = ws.lib("libOVRPlugin.so")
if loops and ws.abi == "arm64-v8a" and ws.has(plugin):
ovrp = ws.read(plugin)
if SHIM.encode() not in ovrp:
ws.put(plugin, elf.add_needed(ovrp, SHIM))
ws.put(ws.lib(SHIM), artifact(ws.abi, SHIM))
ctx.notes.append(f"libunity.so: {UPDATE} -> {SHIM_UPDATE} ({SHIM} waits for each frame)")
il2cpp = ws.lib("libil2cpp.so")
if loops and INPUT_PROBE and ws.has(il2cpp): # diagnostics: the game's C# input calls go through the shim
code, probes = ws.read(il2cpp), 0
for real in INPUT_CALLS:
code, n = elf.replace_rodata_string(code, real, "fpov_" + real[5:])
probes += n
if probes:
ws.put(il2cpp, code)
ctx.notes.append(f"libil2cpp.so: {probes} input calls logged by {SHIM}")
if loops and INPUT_PROBE: # Unity's own input reads too
for real in UNITY_INPUT_CALLS:
data, n = elf.replace_rodata_string(data, real, "fpov_" + real[5:])
if n:
ctx.notes.append(f"libunity.so: {real} logged by {SHIM}")
ws.put(name, data)
ctx.notes.append(f"libunity.so: {PACKAGE} -> {ALWAYS_THERE} ({count}x)")
return True
register(UnityOculusCheck)
+9 -3
View File
@@ -45,10 +45,16 @@ class UnityMultiPass(Il2cppReturnPatch):
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.")
"Rewrites OculusSettings.GetStereoRenderingMode and its inlined read in OculusLoader.Initialize -> "
"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}}
revision = 2 # 0.6.3 only patched the getter, which is inlined: also the field read in OculusLoader.Initialize
SETTINGS = "Unity.XR.Oculus/Unity/XR/Oculus/OculusSettings.cs"
targets = {SETTINGS: {"GetStereoRenderingMode": RET_ZERO}}
# the one-line getter is inlined into OculusLoader.Initialize (no call sites in I Am Cat / Toy Master): the field
# read there is what reaches the native plugin
field_loads = {"Unity.XR.Oculus/Unity/XR/Oculus/OculusLoader.cs":
{"Initialize": (SETTINGS, "m_StereoRenderingModeAndroid", 0)}}
check_name = "Unity stereo mode"
def applies(self, a):
@@ -53,19 +53,64 @@ def patch_methods(lib: bytes, found: dict[str, dict[str, tuple[int, int]]],
return (bytes(out) if changed else None), notes
def patch_field_loads(lib: bytes, method: tuple[int, int], field_offset: int, value: int, name: str
) -> tuple[bytes | None, list[str]]:
"""Inside one method (file offset, length), turn every load of the instance field at field_offset (ldr/ldrh/ldrb
w<t>, [x<n>, #field_offset]) into mov w<t>, #value: for a getter the compiler inlined into its caller, patching the
getter alone changes nothing. Refuses when there's no such load (unless already patched) or more than 2."""
start, length = method
if start % 4 or not any(a <= start and start + length <= b for a, b in _executable(lib)):
raise RuntimeError(f"{name}: offset {start:#x} isn't code in libil2cpp.so (Cpp2IL mismatch)")
kinds = [(0xB9400000, 4), (0x79400000, 2), (0x39400000, 1)] # ldr w (32-bit), ldrh, ldrb (unsigned offset)
mov = 0x52800000 | (value & 0xFFFF) << 5 # movz w<t>, #value
out, hits, already = bytearray(lib), [], 0
for off in range(start, start + length, 4):
w = int.from_bytes(lib[off:off + 4], "little")
if w & 0xFFFFFFE0 == mov:
already += 1
for base, size in kinds:
if field_offset % size == 0 and field_offset // size < 4096 \
and w & 0xFFFFFC00 == base | (field_offset // size) << 10:
hits.append(off)
out[off:off + 4] = (mov | w & 31).to_bytes(4, "little")
if not hits:
if already:
return None, [f"{name} already patched"]
raise RuntimeError(f"{name}: no load of the field at {field_offset:#x} in this method")
if len(hits) > 2:
raise RuntimeError(f"{name}: {len(hits)} loads of offset {field_offset:#x}; not guessing which one")
return bytes(out), [f"{name} -> {value} (at {', '.join(hex(h) for h in hits)})"]
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]] = {}
# loads of a field inlined into a method: {method's class file: {method: (field's class file, field, value)}}
field_loads: dict[str, dict[str, tuple[str, str, int]]] = {}
check_name = ""
def __init_subclass__(cls, **kw):
super().__init_subclass__(**kw)
for c, methods in cls.targets.items():
wanted = [(c, m) for c, methods in cls.targets.items() for m in methods]
for c, methods in cls.field_loads.items():
for m, (fc, field, _v) in methods.items():
wanted += [(c, m), (fc, f"field:{field}")]
for c, m in wanted:
ALL_TARGETS.setdefault(c, [])
ALL_TARGETS[c] += [m for m in methods if m not in ALL_TARGETS[c]]
if m not in ALL_TARGETS[c]:
ALL_TARGETS[c].append(m)
@staticmethod
def _unity_version(ws) -> str | None:
"""For library entries analyzed before the version was recorded: read it from the APK itself."""
from ...analysis.detect import UNITY_GGM, unity_version
ggm = ws.read(UNITY_GGM) if ws.has(UNITY_GGM) else None
libunity = ws.read_lib("libunity.so") if ws.has(ws.lib("libunity.so")) else None
return unity_version(ggm, libunity) if ggm or libunity else None
def apply(self, ctx: ApkContext) -> bool:
from ...analysis.il2cpp import find_methods
@@ -74,7 +119,7 @@ class Il2cppReturnPatch(Patch):
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")
version = (ctx.analysis.extra or {}).get("unity_version") or self._unity_version(ws)
if not version:
ctx.reporter.check(self.check_name, False, "not fixed: unknown Unity version (rescan the game)")
return False
@@ -83,6 +128,14 @@ class Il2cppReturnPatch(Patch):
try:
found = find_methods(lib, ws.read(METADATA), version, ALL_TARGETS)
patched, notes = patch_methods(lib, found, self.targets)
for c, methods in self.field_loads.items():
for m, (fc, field, value) in methods.items():
method, offset = found.get(c, {}).get(m), found.get(fc, {}).get(f"field:{field}")
if not method or not offset:
continue
name = f"{c.rsplit('/', 1)[-1][:-3]}.{m}: {field}"
again, more = patch_field_loads(patched or lib, method, offset[0], value, name)
patched, notes = again or patched, notes + more
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
+26 -5
View File
@@ -8,6 +8,24 @@ from . import artifact
SHIM = "libfp_vk.so" # not longer than "libvulkan.so": the engine's dlopen string is rewritten in place
VULKAN = "libvulkan.so"
ENGINE_LIBS = ("libUE4.so", "libUnreal.so")
# not the engine: XR/Meta/FramePort libraries that may name libvulkan.so too
NOT_ENGINE = ("libopenxr", "libOVR", "libovr", "libvrapi", "libfp_", "libframe", "libVkLayer", "libc++", "libunity")
def engine_libs(ws, analysis) -> list[str]:
"""The game's libraries that load Vulkan by name: Unreal's engine library, or for other engines (own engines,
e.g. Roblox) every library of the game naming libvulkan.so. Unity loads Vulkan differently and isn't touched."""
if analysis.engine == "Unreal":
return [lib for lib in ENGINE_LIBS if ws.has(ws.lib(lib))]
if analysis.engine != "Other":
return []
out = []
for lib in analysis.libs:
if lib.startswith(NOT_ENGINE) or not ws.has(ws.lib(lib)):
continue
if b"\0" + VULKAN.encode() + b"\0" in ws.read_lib(lib):
out.append(lib)
return out
class VulkanSanitize(Patch):
@@ -22,6 +40,9 @@ class VulkanSanitize(Patch):
default_on = True
def detect(self, a):
if a.engine == "Other": # changes nothing unless one of its libraries loads Vulkan by name
return Suggestion(True, "Own-engine game: applied when one of its libraries loads Vulkan by name (e.g. "
"Roblox); keeps Lepton's Fossilize layer from crashing on uninitialized pointers.")
if a.engine == "Unreal":
return Suggestion(True, "Unreal game: applied automatically when the engine loads Vulkan by name; keeps "
"Lepton's Fossilize layer from crashing on uninitialized pointers "
@@ -29,16 +50,14 @@ class VulkanSanitize(Patch):
return None
def applies(self, a):
return a.engine == "Unreal"
return a.engine in ("Unreal", "Other")
def apply(self, ctx: ApkContext) -> bool:
ws = ctx.ws
if ws.abi != "arm64-v8a":
return False
changed = False
for lib in ENGINE_LIBS:
if not ws.has(ws.lib(lib)):
continue
for lib in engine_libs(ws, ctx.analysis):
data, count = elf.replace_rodata_string(ws.read_lib(lib), VULKAN, SHIM)
if count:
ws.put(ws.lib(lib), data)
@@ -52,7 +71,9 @@ class VulkanSanitize(Patch):
def validate(self, ctx: ApkContext):
ws = ctx.ws
return [("Vulkan shim present", ws.has(ws.lib(SHIM)), SHIM)]
uses = [lib for lib in ctx.analysis.libs if not lib.startswith(NOT_ENGINE) and ws.has(ws.lib(lib))
and b"\0" + SHIM.encode() + b"\0" in ws.read_lib(lib)] if ctx.analysis.engine == "Other" else [1]
return [("Vulkan shim present", ws.has(ws.lib(SHIM)), SHIM)] if uses else []
register(VulkanSanitize)
+32 -1
View File
@@ -53,8 +53,20 @@ SETTINGS = [
"Every 'local' play space the game creates lines up with the one at the start. For games whose menus or screens "
"jump to where you look on the Frame."),
("focus_hold", "int", 0, "Ignore brief focus dips",
"Hides the Frame's brief focus dips (well under a second) once the game has been focused for a few seconds. For "
"Hides the Frame's brief focus dips (up to a second) once the game has been focused for a second. For "
"games that recentre or pause every time focus returns."),
("focus_hold_ms", "float", 1000.0, "Longest focus dip to ignore (ms)",
"focus_hold hides focus dips up to this long (100-5000). Longer for a headset whose wear sensor flickers "
"(\"HMD off\" for 0.5-2 s while worn, e.g. Blade & Sorcery pausing); taking the headset off or the system menu "
"pauses the game only after this long."),
("sync_guard", "int", 0, "Guard controller input after focus",
"Runs xrSyncActions one at a time with xrPollEvent and skips it for 250 ms after focus returns. For games that "
"crash in the runtime's input code right after focus comes back (SIGSEGV in vrclient.so UpdateActionStateInternal "
"/ xrSyncActions, e.g. Myst)."),
("profile_remap", "int", 1, "Newer Touch profiles as Touch",
"Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's "
"runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead "
"(components Touch lacks are dropped). Without it such apps get no controller input."),
("aim_pitch", "float", 0.0, "Pointer tilt (degrees)",
"Tilts the controllers' pointing ray up (+) or down (−), for games whose pointer doesn't hit what you aim at."),
("aim_yaw", "float", 0.0, "Pointer turn (degrees)", "Turns the controllers' pointing ray left (+) or right (−)."),
@@ -85,12 +97,20 @@ SETTINGS = [
("respace_kick", "int", 0, "Re-create reference space", "Recreate spaces after the first frames (diagnostic)."),
("flip_quads", "int", 0, "Rotate quads 180°",
"Older workaround for upside-down quads: rotates them 180° (quads are single-sided; prefer flip_emul)."),
("vk_shader_fix", "str", "", "Vulkan shader fixes",
"Vulkan shim (frame.vk_sanitize): SPIR-V modules matching size + SHA-256 get words inserted at a byte offset, "
"e.g. stores that initialize locals a shader reads before writing (an undefined loop counter hung the GPU in "
"VR4's campaign). Format: <size>:<sha256>:<byte offset>:<word>,<word>,...; several separated by ';'. Comes from "
"a game's recipe."),
("gl_hide_multiview", "int", 1, "GL shim: hide multiview",
"GL shim only: hide GL_OVR_multiview so all passes use single-view shaders. For GLES games whose multiview "
"shaders fail on single-view render targets (e.g. Path of the Warrior)."),
]
# settings that need a new build, not only new settings files: the Vulkan shim learned vk_shader_fix in 0.6.4
REVISIONS = {"vk_shader_fix": 2}
# How the "Game settings" dialog shows each setting to non-technical users: group, level (common settings are always
# shown; advanced ones only under "Show advanced settings"), a plain label and one-line help, the control, and the
# setting it depends on (only shown while that one is on). The technical title/description above stay for the CLI.
@@ -107,6 +127,15 @@ UI: dict[str, dict] = {
"controller_fix": dict(group="controllers", level="common", label="Use controllers",
help="Turn off only for games you play with your hands instead of controllers.",
control=("switch",)),
"focus_hold_ms": dict(group="screens", level="advanced", label="Ignore focus dips up to", depends="focus_hold",
help="Longer for headsets that briefly think they're off your head.",
control=("slider", 500.0, 3000.0, 100.0, "{:.0f} ms")),
"sync_guard": dict(group="troubleshooting", level="advanced", label="Steady controller input after focus",
help="For games that crash right after you return to them (e.g. after the Steam menu).",
control=("switch",)),
"profile_remap": dict(group="controllers", level="advanced", label="Treat newer Quest controllers as Touch",
help="For apps that only know Quest 3/Pro controllers: without it they get no buttons.",
control=("switch",)),
"aim_pitch": dict(group="controllers", level="common", label="Pointer angle",
help="If the pointer doesn't hit what you aim at, tilt it up or down.",
control=("slider", -30.0, 30.0, 1.0, "{:+.0f}°")),
@@ -177,6 +206,7 @@ class AdapterSetting(Patch):
self.description = description
self.params = [Param("value", kind, default, description)]
self.default = default
self.revision = REVISIONS.get(key, 1)
def detect(self, a):
from .applicability import needs_scene, uses_equirect_layers, uses_render_models
@@ -210,6 +240,7 @@ class AdapterSetting(Patch):
"mutable_fix": ap.is_vulkan,
"swapchain_fix": ap.is_gles,
"gl_hide_multiview": lambda a: a.direct_vrapi and ap.is_gles(a),
"vk_shader_fix": lambda a: a.engine == "Unreal", # read by the Vulkan shim, which only Unreal games get
"controller_models": ap.may_use_render_models,
**{k: ap.is_gles for k in ("equirect_emul", "equirect_face", "equirect_res", "equirect_flip",
"equirect_fps", "equirect_stereo")},
+3
View File
@@ -45,6 +45,9 @@ SUMMARIES = {
"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.sdl_clipboard": "Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboard).",
"frame.unity_oculus_check": "Lets older Unity games start VR without Meta's system apps (they'd stay on a 2D "
"screen).",
"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.",
+57 -3
View File
@@ -351,7 +351,8 @@ def prepare_rift(package: str, reporter: Reporter) -> dict:
check("Revive", True, f"{revive.installed_version()} ({rdir})")
art, store_title = artwork.fetch(package, lookup=entry.get("quest_package"))
info = {"sha256": sha256(exe) if exe.is_file() else None, "checks": checks,
"ok": all(c["ok"] is not False for c in checks), "revive": revive.installed_version(), "kind": "rift"}
"ok": all(c["ok"] is not False for c in checks), "revive": revive.installed_version(), "kind": "rift",
"recipe_fp": recipe_fingerprint(entry["recipe"])}
library.upsert_game(package, build=info)
return info
@@ -386,11 +387,18 @@ def prepare_as_is(package: str, reporter: Reporter) -> dict:
check("Manifest", None, str(exc))
art, store_title = artwork.fetch(package, apk)
info = {"apk": str(apk), "alt_apk": None, "sha256": sha256(apk), "alt_sha256": None, "applied": [],
"checks": checks, "ok": all(c["ok"] is not False for c in checks), "as_is": True}
"checks": checks, "ok": all(c["ok"] is not False for c in checks), "as_is": True,
"recipe_fp": recipe_fingerprint(entry["recipe"])}
library.upsert_game(package, build=info, title=entry.get("title") or store_title)
return info
def recipe_fingerprint(recipe: dict) -> str:
from .patches.base import recipe_fingerprint as fp
return fp(recipe)
def build_game(package: str, reporter: Reporter, outdir: Path | None = None) -> dict:
entry = library.game(package)
if is_rift(entry):
@@ -405,7 +413,7 @@ def build_game(package: str, reporter: Reporter, outdir: Path | None = None) ->
art, store_title = artwork.fetch(package, res.apk)
build_info = {"apk": str(res.apk), "alt_apk": str(res.alt_apk) if res.alt_apk else None, "sha256": res.sha256,
"alt_sha256": res.alt_sha256, "applied": res.applied, "checks": res.checks, "ok": res.ok,
"overport": res.meta.get("overport")}
"overport": res.meta.get("overport"), "recipe_fp": recipe_fingerprint(entry["recipe"])}
library.upsert_game(package, build=build_info, title=entry.get("title") or store_title)
return build_info
@@ -448,6 +456,52 @@ def remove_all_converted_copies() -> int:
return freed
def local_game_files(package: str) -> list[Path]:
"""The game's own files on this PC that "Uninstall → also delete the files on this PC" removes: a Quest game's
APK and data (OBB) folder, a PC VR game's folder, and FramePort's converted copies of it. Never the signing keys,
and never a path another library entry still uses (e.g. a shared folder)."""
g = library.game(package) or {}
paths: list[Path] = []
if g.get("kind") == "rift":
paths += [Path(g["game_dir"])] if g.get("game_dir") else []
else:
paths += [Path(p) for p in (g.get("apk"), g.get("data_dir")) if p]
b = g.get("build") or {}
out = output_dir().resolve()
paths += [Path(b[k]) for k in ("apk", "alt_apk") if b.get(k) and out in Path(b[k]).resolve().parents]
others = [o for o in library.games() if o.get("package") != package]
used = [Path(p).resolve() for o in others for p in (o.get("apk"), o.get("data_dir"), o.get("game_dir")) if p]
def shared(p: Path) -> bool: # the same path, a path inside it, or a folder around it belongs to another game
rp = p.resolve()
return any(u == rp or rp in u.parents or u in rp.parents for u in used)
return [p for p in dict.fromkeys(paths) if p.exists() and not shared(p)]
def delete_local_files(package: str) -> tuple[list[str], int]:
"""Delete local_game_files(package), then the game's folder if that left it empty, and drop the library entry (the
game can't be rebuilt without its files). Returns (deleted paths, bytes freed)."""
import shutil
files = local_game_files(package)
freed, done = 0, []
parents = {p.parent for p in files}
for p in files:
size = sum(f.stat().st_size for f in p.rglob("*") if f.is_file()) if p.is_dir() else p.stat().st_size
shutil.rmtree(p) if p.is_dir() else p.unlink()
freed += size
done.append(str(p))
for d in parents: # e.g. "Game v1.2/" that held only the APK and its OBB folder
try:
if d.is_dir() and not any(d.iterdir()) and d != output_dir():
d.rmdir()
done.append(str(d))
except OSError:
pass
library.remove_game(package)
return done, freed
def remove_converted_copies(package: str) -> int:
"""The converted APKs are only needed until they're on the Frame (every install converts again): remove them
unless the user keeps them (setting build.keep_copies). Only files in FramePort's own output folder, never the
+4 -1
View File
@@ -183,7 +183,10 @@ def _non_quest(analysis: Analysis, recipe: Recipe) -> None:
if kind == "none":
# an ordinary (2D) Android app: no OpenXR to translate. Install it unchanged, or with only the launcher fix
recipe.overport = False
needed = {"device.hide_navbar"} | ({"frame.launcher"} if analysis.has_info_category else set()) # LAUNCHER
# fixes that aren't about VR stay (navigation bar, SDL's clipboard, Unity text fields...), and the launcher
# entry when the app only has an INFO activity
needed = {pid for pid in recipe.patches if not base.get(pid).needs_vr and pid != "frame.launcher"}
needed |= {"frame.launcher"} if analysis.has_info_category else set()
recipe.patches = {pid: v for pid, v in recipe.patches.items() if pid in needed}
recipe.reasons = {pid: v for pid, v in recipe.reasons.items() if pid in recipe.patches}
recipe.alt_patches = []
+50 -23
View File
@@ -61,28 +61,55 @@ def from_path(path: Path) -> SourceGame | None:
return SourceGame(display_name(path.name), apk, data, path, [a for a in apks if a != apk])
def scan(root: Path, depth: int = 2) -> list[SourceGame]:
"""Find games under root (the root itself, or folders up to `depth` levels down)."""
root = Path(root)
found = []
game = from_path(root)
if game:
return [game]
frontier = [root]
for _ in range(depth):
nxt = []
for d in frontier:
try:
children = sorted(p for p in d.iterdir() if p.is_dir() and not p.name.startswith(("_", ".")))
except OSError:
def _no_quest_games_below(d: Path) -> bool:
"""Folders not worth searching for APKs: a PC program (exe/dll files), a git checkout, a Python environment."""
try:
names = [p.name.lower() for p in d.iterdir()]
except OSError:
return True
return any(n.endswith((".exe", ".dll")) for n in names) or ".git" in names or "pyvenv.cfg" in names
def _other_folders(d: Path, game: SourceGame) -> list[Path]:
"""Subfolders of a folder with an APK that aren't that game's own data (OBB) folder."""
try:
subs = [p for p in d.iterdir() if p.is_dir() and not p.name.startswith(("_", "."))]
except OSError:
return []
own = {game.data_dir.resolve()} if game.data_dir else set()
return [p for p in subs if p.resolve() not in own and p.name.lower() not in ("obb", "android")]
def scan(root: Path, depth: int = 5) -> list[SourceGame]:
"""Find games under root, up to `depth` folder levels down (e.g. a download manager's
"<library>/data/downloads/<game>/game.apk"). A folder with an APK and nothing but that game's data folder is one
game; a folder that also has other folders is a collection: its loose APKs are games and its folders are searched
(a "VR" folder with a stray APK next to the game folders used to count as one game). PC program folders and code
checkouts aren't searched."""
found: list[SourceGame] = []
def walk(d: Path, level: int) -> None:
game = from_path(d)
if game and (level >= depth or not _other_folders(d, game)):
found.append(game)
return
if game: # a collection with loose APKs: each is a game of its own
found.extend(g for g in (from_path(p) for p in sorted(d.glob("*.apk")) if _is_apk(p)) if g)
if level >= depth:
return
try:
children = sorted(p for p in d.iterdir() if p.is_dir() and not p.name.startswith(("_", ".")))
except OSError:
return
for child in children:
if game and game.data_dir and child.resolve() == game.data_dir.resolve():
continue
for child in children:
g = from_path(child)
if g:
found.append(g)
else:
nxt.append(child)
frontier = nxt
loose = [p for p in root.glob("*.apk") if _is_apk(p)]
found += [g for g in (from_path(p) for p in loose) if g]
if from_path(child) or not _no_quest_games_below(child):
walk(child, level + 1)
root = Path(root)
if root.is_file():
g = from_path(root)
return [g] if g else []
walk(root, 0)
return found
+50 -6
View File
@@ -529,9 +529,14 @@ class FramePortApp:
self.run_bg(work)
def _add_then_play(self, pkg: str, title: str) -> None:
def run(reporter):
def run(job: Job): # (jobs get the Job, not a reporter: this raised TypeError in 0.6.3)
target = self._target_for("frame")
status = target.add_to_library([pkg], reporter)
status = target.add_to_library([pkg], job.reporter)
if status.get("state") == "waiting": # Desktop Mode is open (FramePort on the Frame) or a game runs
return (tr("{title} will be in the Frame's Steam library when you're back in Gaming Mode: start it "
"from there").format(title=title) if status.get("reason") == "desktop" else
tr("{title} will be in the Frame's Steam library when the game that's running is closed")
.format(title=title))
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")))
@@ -1043,19 +1048,53 @@ class FramePortApp:
tr("Removes {title} from this PC's Steam library (Steam restarts once). "
"The game folder isn't touched.").format(title=title)
delete_local, extra = self._delete_local_option(pkg)
def run(job: Job):
self._target_for(to).uninstall(pkg, keep_data=True)
self._pc_cache = None
if delete_local and delete_local.value:
done, freed = pipeline.delete_local_files(pkg)
job.reporter.log(f"deleted {len(done)} item(s) on this PC, {fmt_size(freed)}")
self.go("library")
return tr("Uninstalled {title} and deleted its files on this PC ({size})").format(
title=title, size=fmt_size(freed))
return tr("Uninstalled {title}").format(title=title)
C.confirm(self.page, tr("Uninstall {title}?").format(title=title), text, tr("Uninstall"),
lambda: self.submit(tr("Uninstall {title}").format(title=title), run, pkg, "uninstall"), danger=True)
lambda: self.submit(tr("Uninstall {title}").format(title=title), run, pkg, "uninstall"), danger=True,
extra=extra)
def _delete_local_option(self, pkg: str) -> tuple[ft.Checkbox | None, ft.Control | None]:
"""The 'Also delete this game's files on this PC' checkbox (off by default) with the paths it would delete."""
files = pipeline.local_game_files(pkg)
if not files:
return None, None
size = sum((sum(f.stat().st_size for f in p.rglob("*") if f.is_file()) if p.is_dir() else p.stat().st_size)
for p in files)
box = ft.Checkbox(label=tr("Also delete this game's files on this PC ({size})").format(size=fmt_size(size)),
value=False, active_color=T.ERROR)
from ..core import winhost
shown = [winhost.to_windows(p) if winhost.is_wsl() else str(p) for p in files]
return box, ft.Column([box, *[C.body("• " + s, T.TEXT_3) for s in shown[:4]]], spacing=T.px(4), tight=True)
def remove_from_library(self, pkg: str) -> None:
title = self._title(pkg)
delete_local, extra = self._delete_local_option(pkg)
def remove():
if delete_local and delete_local.value:
_done, freed = pipeline.delete_local_files(pkg) # (removes the library entry too)
message = tr("Removed {title} and deleted its files on this PC ({size})").format(
title=title, size=fmt_size(freed))
else:
library.remove_game(pkg)
message = tr("Removed {title}").format(title=title)
self.go("library")
self.toast(message)
C.confirm(self.page, tr("Remove {title} from the library?").format(title=title),
tr("Only FramePort's entry is removed. Your game files and anything installed stay."), tr("Remove"),
lambda: (library.remove_game(pkg), self.go("library"),
self.toast(tr("Removed {title}").format(title=title))), danger=True)
tr("Removes FramePort's entry. Anything installed on the Frame stays."), tr("Remove"), remove,
danger=True, extra=extra)
def _target_for(self, to: str):
if to == "pc":
@@ -1796,6 +1835,11 @@ def main(argv=None):
return
T.set_scale(T.scale_from_setting(library.setting("ui.scale", "auto"))) # before any view is built
applog.log.info("ui scale %.2f", T.SCALE)
from .. import updates
from ..artwork import thumbs
if updates.install_kind() == "bundle": # a packaged app finds artwork by file path, not by URL (GitHub #16)
thumbs.use_file_paths(True)
ft.run(lambda page: FramePortApp(page), assets_dir=assets_dir())
+17 -2
View File
@@ -70,9 +70,22 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
built = {b.get("sha256"), b.get("alt_sha256")} - {None}
if built and dep.get("sha256") and dep["sha256"] not in built:
return "outdated"
if built and dep.get("sha256") in built and recipe_changed(game):
return "outdated" # the recipe changed since this build (e.g. a catalog fix): it needs a new build
return "outdated" if settings_diff(game, frame_info) else "installed"
def recipe_changed(game: dict) -> bool:
from ..patches.base import recipe_fingerprint, revised_since_unrecorded
recipe, b = game.get("recipe") or {}, game.get("build") or {}
if not recipe:
return False
if b.get("recipe_fp"):
return b["recipe_fp"] != recipe_fingerprint(recipe)
return revised_since_unrecorded(recipe)
def pc_outdated(g: dict, dep: dict) -> bool:
"""A PC install whose launch settings (pcvr patches + their parameters) differ from the game's recipe now."""
installed = set((dep.get("recipe") or {}).get("patches") or [])
@@ -330,12 +343,14 @@ def bottom_fade(height: int | None = None, strength: float = 0.85) -> ft.Contain
def confirm(page: ft.Page, heading: str, text: str, ok_label: str, on_ok: Callable[[], None],
danger: bool = False) -> None:
danger: bool = False, extra: ft.Control | None = None) -> None:
"""A yes/no dialog; `extra` (e.g. an option checkbox) goes below the text and is read by on_ok."""
def go(e):
page.pop_dialog()
on_ok()
content = body(text) if extra is None else ft.Column([body(text), extra], spacing=T.px(12), tight=True)
page.show_dialog(ft.AlertDialog(
title=ft.Text(heading, weight=ft.FontWeight.W_600), content=ft.Container(body(text), width=T.px(420)),
title=ft.Text(heading, weight=ft.FontWeight.W_600), content=ft.Container(content, width=T.px(420)),
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
actions=[ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
ft.FilledButton(ok_label, on_click=go, style=ft.ButtonStyle(
+3 -1
View File
@@ -18,7 +18,9 @@ from .. import theme as T
if TYPE_CHECKING:
from ..app import FramePortApp
SPECS = {key: (kind, default) for key, kind, default, _, _ in SETTINGS}
# the dialog's settings: numbers and switches (text settings such as vk_shader_fix are recipe data: saving the dialog
# keeps them as they are)
SPECS = {key: (kind, default) for key, kind, default, _, _ in SETTINGS if kind in ("int", "float")}
def default(key: str):
+2 -1
View File
@@ -14,9 +14,10 @@ os.environ["FRAMEPORT_HOME"] = tempfile.mkdtemp(prefix="frameport-tests-")
@pytest.fixture(autouse=True)
def isolated_home(tmp_path, monkeypatch):
"""Never touch the real user data dir (tools, keystores, library) from tests."""
from frameport.core import paths
from frameport.core import library, paths
monkeypatch.setenv("FRAMEPORT_HOME", str(tmp_path / "home"))
monkeypatch.setattr(library, "REFRESH_ON_UPDATE", False) # hand-made test libraries have no version marker
yield
paths._removed.clear() # an uninstall test marks the data folder as removed for the rest of the process
+38 -1
View File
@@ -392,7 +392,7 @@ def test_library_users_prefers_the_signed_in_account(monkeypatch, tmp_path):
(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"]
assert a.library_users() == ["12345", "999"] # every account, the signed-in one first
def test_uninstall_quest_keeping_saves_drops_the_install_record(monkeypatch, tmp_path):
@@ -859,3 +859,40 @@ def test_flat_windows_game_launcher_and_shortcut(monkeypatch, tmp_path):
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"
def test_reinstall_with_unchanged_shortcut_does_not_restart_steam(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
anchor = tmp_path / "Applications/quest-frame/com.x.y"
(anchor / "artwork").mkdir(parents=True)
(anchor / "artwork/portrait.jpg").write_bytes(b"art")
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 1, "title": "X", "base": "b"}))
cfg = tmp_path / ".local/share/Steam/userdata/42/config"
cfg.mkdir(parents=True)
exe, title, start, icon, tag, tags, openvr = a.shortcut_args("com.x.y")
assert a.library_changes(["42"], ["com.x.y"]) # not in the library yet
a.upsert_shortcut(str(cfg / "shortcuts.vdf"), exe, title, start, icon, tag, tags=tags, openvr=openvr)
assert not a.library_changes(["42"], ["com.x.y"]) # a reinstall: nothing to change
stops = []
monkeypatch.setattr(a, "stop_steam", lambda: stops.append(1) or True)
monkeypatch.setattr(a, "start_steam", lambda s: stops.append(2))
a.shortcuts_worker(json.dumps({"packages": ["com.x.y"], "remove": []}))
status = json.load(open(a.STATUS_FILE))
assert not stops and status["state"] == "done" and status["unchanged"]
assert status["added"][0]["package"] == "com.x.y"
assert (cfg / "grid").is_dir() and any(p.name.endswith("p.jpg") for p in (cfg / "grid").iterdir())
def test_shortcuts_lost_after_steam_restart(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
anchor = tmp_path / "Applications/quest-frame/com.x.y"
anchor.mkdir(parents=True)
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 1, "title": "X", "base": "b"}))
cfg = tmp_path / ".local/share/Steam/userdata/42/config"
cfg.mkdir(parents=True)
monkeypatch.setattr(a, "run", lambda cmd, **k: SimpleNamespace(returncode=0, stdout=""))
monkeypatch.setattr(a.time, "sleep", lambda s: None)
assert a.shortcuts_lost(["42"], ["com.x.y"]) == ["com.x.y"] # Steam put its old shortcuts.vdf back
exe, title, start, icon, tag, tags, openvr = a.shortcut_args("com.x.y")
a.upsert_shortcut(str(cfg / "shortcuts.vdf"), exe, title, start, icon, tag, tags=tags, openvr=openvr)
assert a.shortcuts_lost(["42"], ["com.x.y"]) == []
+15
View File
@@ -0,0 +1,15 @@
"""Every bundled catalog file parses (the loader skips broken files silently, so a typo would hide a game)."""
from pathlib import Path
import yaml
from frameport.recommend.catalog import CatalogEntry, catalog_dir
def test_every_catalog_file_parses():
files = sorted((catalog_dir() / "games").glob("*.yaml"))
assert files
for f in files:
d = yaml.safe_load(f.read_text(encoding="utf-8"))
e = CatalogEntry.from_dict(d, "bundled")
assert e.package == Path(f).stem, f
+43
View File
@@ -69,3 +69,46 @@ def test_network_errors_still_end_the_attempts(fake_ssh):
with pytest.raises(paramiko.SSHException, match="banner"):
C.Frame(C.FrameTarget("10.0.0.2"), password="x").connect()
assert fake_ssh.tried == ["app_key"]
def test_upload_resumes_after_a_transient_error(tmp_path, monkeypatch):
from frameport.frame.connection import Frame, FrameTarget
f = Frame(FrameTarget("h"))
calls = []
def flaky(local, remote, progress, resume, mkdir):
calls.append(1)
if len(calls) == 1:
raise OSError(22, "Invalid argument")
monkeypatch.setattr(f, "_put", flaky)
monkeypatch.setattr(f, "alive", lambda: True)
monkeypatch.setattr("frameport.frame.connection.time.sleep", lambda s: None)
f.put(tmp_path / "x.bin", "/r/x.bin")
assert len(calls) == 2
def test_framport_on_the_frame_targets_itself(tmp_path, monkeypatch):
from frameport.frame import connection, local
monkeypatch.setattr(local, "on_frame", lambda: True)
monkeypatch.setattr(local.Path, "home", lambda: tmp_path)
targets = connection.saved_targets()
assert targets and targets[0].host == "127.0.0.1" and targets[0].name == local.LOCAL_NAME
keys = (tmp_path / ".ssh/authorized_keys").read_text()
assert connection.app_public_key().split()[1] in keys
assert not local.authorize_self(connection.app_public_key()) # once
monkeypatch.setattr(local, "on_frame", lambda: False)
assert connection.saved_targets()[0].host == "127.0.0.1" # remembered like any Frame
def test_app_data_on_the_frame_stays_out_of_the_agents_folder(monkeypatch, tmp_path):
from frameport.core import paths
from frameport.frame import local
monkeypatch.delenv("FRAMEPORT_HOME", raising=False)
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setattr(local, "on_frame", lambda: True)
assert paths.user_data_dir() == tmp_path / "frameport-app" # the agent owns (and purges) .../frameport
monkeypatch.setattr(local, "on_frame", lambda: False)
assert paths.user_data_dir() == tmp_path / "frameport"
+6 -1
View File
@@ -370,7 +370,7 @@ def test_game_settings_dialog_shows_relevant_settings_and_saves_only_changes():
from frameport.patches.settings import SETTINGS, UI
from frameport.ui.views import adapter_dialog as ad
assert set(UI) == {k for k, *_ in SETTINGS} # every setting has a plain label/control
assert set(UI) == {k for k, kind, *_ in SETTINGS if kind != "str"} # every number/switch has a label/control
vulkan = _analysis()
gles = _analysis(graphics="GLES (declared in manifest)")
assert ad.relevant(vulkan, "flip_emul", 1) and not ad.relevant(gles, "flip_emul", 1) # Vulkan-only
@@ -386,6 +386,11 @@ def test_game_settings_dialog_shows_relevant_settings_and_saves_only_changes():
adapter = {k: v for k, v in saved["patches"].items() if k.startswith("adapter.")}
assert adapter == {"adapter.refresh_rate": {"value": 90.0}, "adapter.aim_pitch": {"value": -5.0}} # no defaults
assert "frame.launcher" in saved["patches"] # other patches untouched
r = library.recipe_from_dict(saved)
r.patches["adapter.vk_shader_fix"] = {"value": "8:" + "0" * 64 + ":4:1"}
library.upsert_game("com.x", recipe=asdict(r))
ad.save_to_recipe("com.x", ad.recipe_values(library.game("com.x")))
assert library.game("com.x")["recipe"]["patches"]["adapter.vk_shader_fix"]["value"].startswith("8:") # kept
assert saved["source"] == "user" and saved["reasons"]["adapter.aim_pitch"] == "Set by you."
+8
View File
@@ -67,3 +67,11 @@ def test_ovrplugin_extension_probe_is_not_a_scene_game():
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}
def test_rejected_controller_profile_is_information_only():
log = LOG_OK + ("09-28 17:39:02.300 1147 1174 E OVRPlugin: OpenXR error: XR_ERROR_PATH_UNSUPPORTED, cmd "
"xrSuggestInteractionProfileBindings(m_xrInstance, &plusSuggestedBindings)\n")
f = {x.id: x for x in triage(log, "RUNNING", "com.example.game").findings}
assert f["controller-profile-rejected"].severity == "info"
assert triage(log, "RUNNING", "com.example.game").verdict == "pass"
+152
View File
@@ -90,3 +90,155 @@ def test_maintained_entry_verified_later_beats_the_users_shared_one():
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)
def test_inlined_getter_field_read_is_rewritten():
lib = bytearray(_lib())
lo, _hi = U._executable(bytes(lib))[0]
m = (lo + 0x40) & ~3
lib[m:m + 4] = (0xB9401C00 | 20 << 5 | 22).to_bytes(4, "little") # ldr w22, [x20, #0x1c]
lib[m + 4:m + 8] = (0x39408000 | 20 << 5 | 8).to_bytes(4, "little") # ldrb w8, [x20, #0x20]: another field
out, notes = U.patch_field_loads(bytes(lib), (m, 16), 0x1C, 0, "Initialize")
assert out[m:m + 4] == (0x52800000 | 22).to_bytes(4, "little") and out[m + 4:m + 8] == lib[m + 4:m + 8]
assert U.patch_field_loads(out, (m, 16), 0x1C, 0, "Initialize")[0] is None # idempotent
import pytest
with pytest.raises(RuntimeError, match="no load"):
U.patch_field_loads(bytes(_lib()), (m, 16), 0x1C, 0, "Initialize")
def test_cpp2il_field_offsets_are_parsed():
cs = ("public class OculusSettings : ScriptableObject\n{\n\t[SerializeField]\n"
"\tpublic StereoRenderingModeAndroid m_StereoRenderingModeAndroid; //Field offset: 0x1C\n}\n")
assert il2cpp.parse_methods(cs, ["field:m_StereoRenderingModeAndroid", "Missing"]) == {
"field:m_StereoRenderingModeAndroid": (0x1C, 0)}
assert "field:m_StereoRenderingModeAndroid" in U.ALL_TARGETS[R.UnityMultiPass.SETTINGS]
def test_recipe_changes_and_revised_patches_mark_builds_outdated():
from frameport.patches.base import recipe_fingerprint, revised_since_unrecorded
from frameport.ui.components import recipe_changed
base.load_all()
r = {"patches": {"frame.unity_text_input": {}}, "use_alt": False}
game = {"recipe": r, "build": {"sha256": "a", "recipe_fp": recipe_fingerprint(r)}}
assert not recipe_changed(game)
r["use_alt"] = True # e.g. a catalog fix switched to the alternate build
assert recipe_changed(game)
assert not revised_since_unrecorded({"patches": {"frame.unity_text_input": {}}})
assert revised_since_unrecorded({"patches": {"frame.unity_multipass": {}}}) # fixed after 0.6.3
assert revised_since_unrecorded({"patches": {"adapter.vk_shader_fix": {"value": "x"}}}) # needs the new shim
def test_packaged_app_shows_artwork_by_file_path(tmp_path, monkeypatch):
from frameport.artwork import thumbs
monkeypatch.setattr(thumbs, "user_data_dir", lambda: tmp_path)
art = tmp_path / "artwork" / "p.q" / "cover.jpg"
assert thumbs.asset_url(art) == "/artwork/p.q/cover.jpg" # web view / source run: URL under assets_dir
thumbs.use_file_paths(True)
try:
assert thumbs.asset_url(art) == str(art.resolve())
finally:
thumbs.use_file_paths(False)
def test_unity_oculus_check_only_for_old_unity_with_the_check():
base.load_all()
p = base.get("frame.unity_oculus_check")
old = _analysis(libs=["libunity.so", "libOVRPlugin.so"],
extra={"unity_version": "2017.4.23f1", "unity_oculus_check": True})
assert p.applies(old) and p.detect(old).recommended
new = _analysis(libs=["libunity.so", "libOVRPlugin.so"],
extra={"unity_version": "2019.4.35f1", "unity_oculus_check": True})
assert not p.applies(new) and p.detect(new) is None # 2019+ runs without it: builds stay byte-identical
log = "10-04 15:08:01.000 1213 1235 I Unity : [NewtonVR] Critical Error: Oculus / SteamVR not setup properly"
assert "frame.unity_oculus_check" in {s for f in triage(log, "RUNNING", None).findings for s in f.suggest}
def test_app_update_refreshes_derived_recipes_once(tmp_path, monkeypatch):
import json
from frameport.core import library
monkeypatch.setattr(library, "_path", lambda: tmp_path / "library.json")
monkeypatch.setattr(library, "REFRESH_ON_UPDATE", True)
an = {"package": "com.own.engine", "engine": "Other", "libs": ["libgame.so"], "abis": ["arm64-v8a"]}
custom = {"frame.adapter": {}, "adapter.scale": {"value": 1.5}}
games = {"com.own.engine": {"analysis": an, "recipe": {"package": "com.own.engine", "source": "heuristics",
"patches": dict(custom)}},
"com.mine": {"analysis": dict(an, package="com.mine"),
"recipe": {"package": "com.mine", "source": "user", "patches": dict(custom)}}}
(tmp_path / "library.json").write_text(json.dumps({"games": games, "settings": {"recipes.app_version": "0.0.1"}}))
got = library.load()["games"]
assert "frame.vk_sanitize" in got["com.own.engine"]["recipe"]["patches"] # a newer automatic fix reaches it
assert "adapter.scale" not in got["com.own.engine"]["recipe"]["patches"] # derived again from scratch
assert got["com.mine"]["recipe"]["patches"]["adapter.scale"] == {"value": 1.5} # the user's own recipe stays
assert not library._follow_catalog(library.load()) # once per app version
def test_sdl_clipboard_patch_for_2d_sdl_apps():
from frameport.recommend import engine
base.load_all()
a = _analysis(package="org.love2d.android", engine="Other", libs=["liblove.so"], is_overport_output=False,
extra={"sdl_java": True, "vr_kind": "none"})
r = engine.suggest(a)
assert "frame.sdl_clipboard" in r.patches and not r.as_is # an APK edit: not installed unchanged
a.extra["sdl_java"] = False
assert "frame.sdl_clipboard" not in engine.suggest(a).patches
def test_uninstall_can_delete_the_games_files_on_this_pc(tmp_path):
from frameport import pipeline
from frameport.core import library
from frameport.core.paths import output_dir, user_data_dir
game = tmp_path / "Game v1.2"
(game / "com.x.game").mkdir(parents=True)
(game / "com.x.game.apk").write_bytes(b"PK" * 10)
(game / "com.x.game/main.obb").write_bytes(b"o" * 30)
shared = tmp_path / "Shared"
(shared / "data").mkdir(parents=True)
(shared / "b.apk").write_bytes(b"PK")
conv = output_dir() / "Game v1.2"
conv.mkdir(parents=True)
(conv / "com.x.game.apk").write_bytes(b"c")
key = user_data_dir() / "overport-workspace/signatures/com.x.game.keystore"
key.parent.mkdir(parents=True)
key.write_bytes(b"k")
library.upsert_game("com.x.game", apk=str(game / "com.x.game.apk"), data_dir=str(game / "com.x.game"),
build={"apk": str(conv / "com.x.game.apk")})
library.upsert_game("com.y.other", apk=str(shared / "b.apk"), data_dir=str(shared / "data"))
library.upsert_game("com.z.third", apk=str(shared / "c.apk"), data_dir=str(shared / "data"))
assert set(pipeline.local_game_files("com.x.game")) == {game / "com.x.game.apk", game / "com.x.game",
conv / "com.x.game.apk"}
assert pipeline.local_game_files("com.y.other") == [shared / "b.apk"] # its data folder is another game's too
done, freed = pipeline.delete_local_files("com.x.game")
assert not game.exists() and freed == 51 and library.game("com.x.game") is None
assert key.exists() and shared.exists() # signing keys and other games' files stay
def test_scan_finds_games_in_download_manager_layouts(tmp_path, monkeypatch):
import zipfile
from frameport.sources import quest_dump
monkeypatch.setattr(quest_dump, "_package_of", lambda apk: Path(apk).stem)
def apk(path):
path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(path, "w") as z:
z.writestr("AndroidManifest.xml", b"x")
root = tmp_path / "VR"
apk(root / "stray.app.apk") # a loose APK next to game folders: not "one game"
apk(root / "Manager/Game A v1/com.a.game.apk")
(root / "Manager/Game A v1/com.a.game").mkdir()
(root / "Manager/Game A v1/com.a.game/main.obb").write_bytes(b"o")
apk(root / "Manager/data/downloads/Game B v2/com.b.game.apk") # 4 levels down
(root / "PC Game/Binaries").mkdir(parents=True)
(root / "PC Game/game.exe").write_bytes(b"MZ")
apk(root / "PC Game/Binaries/never.apk") # PC program folders aren't searched
found = {g.apk.name: g for g in quest_dump.scan(root)}
assert set(found) == {"stray.app.apk", "com.a.game.apk", "com.b.game.apk"}
assert found["com.a.game.apk"].data_dir == root / "Manager/Game A v1/com.a.game"