mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-17 20:01:23 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbabb37343 | ||
|
|
7afee4bf59 | ||
|
|
7367a35176 | ||
|
|
9948f946c5 | ||
|
|
3870500972 | ||
|
|
a7b02c00ee | ||
|
|
09c7bb6ea7 | ||
|
|
3761dfd62d | ||
|
|
f86d9021f9 | ||
|
|
83e901a682 | ||
|
|
246e5fc5ee | ||
|
|
17403779cb | ||
|
|
0e03e061a4 | ||
|
|
d8cf19fb20 | ||
|
|
7cd9a38eaf | ||
|
|
4f3593192f | ||
|
|
974426d1d0 | ||
|
|
b7a0e0cb5b | ||
|
|
38edc26092 | ||
|
|
e96e25bade | ||
|
|
67c991ca4f |
No files matched your search
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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. |
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ©) : 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
@@ -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]()
|
||||
|
||||
@@ -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
@@ -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, ©, 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;
|
||||
|
||||
@@ -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,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"
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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']}")
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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")},
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]) == []
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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."
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user