Merge main into aarch32testing; maintainer fixes for the 32-bit bridge

Merged main (agent 90, ANALYSIS_VERSION 17/18 after main's 15/16) and rebuilt the bridge, foveation and a32
libraries from source (deterministic, no local paths).

Fixes on top:
- a32 loader: apply DT_ANDROID_RELR relocations (Journey of the Gods crashed at start)
- VrApi bridge: vrapi_Get/SetTrackingTransform (floor-level tracking; Shadow Point's player was below the floor)
- a32: never submit an untracked default head pose (hold up to 100 ms, repair or drop); pose consistency per display
  time; submit/tracking counters
- VrApi bridge: wait again when xrWaitFrame gives a display time in the refresh just submitted (the runtime rejected
  the second frame with AlreadySubmitted; Journey of the Gods ran at ~120 fps on a 90 Hz display and the scene slid
  and snapped back); pacing counters
- VrApi bridge: GLES re-entry polls events before reusing the kept session (a lost or exiting session is replaced, a
  stopped one waits for READY)
- VrApi bridge: STAGE is the runtime's STAGE space (play-area centre and orientation, unchanged by recentering);
  LOCAL_FLOOR keeps the head-based origin
- signer certificate: pick the certificate the SignerInfo names, not the first in the PKCS#7 block
- flat 32-bit apps keep the bridge (it isn't a VR-only fix)
- Frida gadget loads are stripped before translation
- launcher restart test fixed; native/.cache is no longer committed
- catalog: Shadow Point and Journey of the Gods work (headset, 2026-10-14; min_app 1.1.2)
This commit is contained in:
spoopyghosty0 committed 2026-10-14 20:10:10 -04:00
commit 927dae5ff8
127 files changed
+6351 -508

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ parity-report.md
*.keystore
*.apk
.pytest_cache/
native/.cache/
native/.cache
native/**/build/
# per-machine Claude Code settings (contain home paths)
.claude/settings.local.json
+13 -2
View File
@@ -703,7 +703,9 @@ reaches its OVRInput cleanly (probe: both Touch connected 0x63, individual butto
"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
shortcuts.vdf and writes once more if Steam put its old copy back (`shortcuts_lost`); lost again (agent v89): Steam
ignores FramePort's entries on that Frame, so `add_devkit_entry` adds the devkit entry instead (`added` gets `via:
devkit`) and only reports an error if that fails too. 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
@@ -801,13 +803,22 @@ own context and saved as `files/fb_snap_0-7.ppm` (quarter size) — headless lau
shows what the game draws. Vader: Lucasfilm logo (an OBB mp4: video works), then its loading card (portrait + segmented
bar) that never advances; the async loader thread sleeps and OBB reads stop (~168 MB of a 2.7 GB pak).
**SteamVR overlay apps (agent v75, 2026-10-10):** host OpenVR overlays (VRApplication_Overlay) are composited over Lepton (Quest) games on the Frame (probe checker seen over 4XVR in the headset view, `native/vroverlay_probe`); Windows ones register through Proton's vrclient too (Temporal Reality's Windows build created its overlays; fpsVR never reaches OpenVR under Proton). Detection `analysis/vroverlay.py` (bundled `.vrmanifest` with `is_dashboard_overlay`, else OpenVR + IVROverlay + self-registration markers, engine Other; Rift fingerprint `RIFT_ANALYSIS`), patch `pcvr.vr_overlay` (param autostart), Linux entry fields `vr_overlay`/`vr_overlay_autostart` (game page switch); agent `register_vr_overlay`/`unregister_vr_overlay`/`launch_vr_overlay` (IVRApplications via ctypes, live; manifest `<anchor>/frameport-overlay.vrmanifest`, the app's own key, binary = launch.sh). **SteamVR on the Frame reads only `binary_path_linux_arm`** (binary_path_linux alone is skipped). Overlay apps: no launch test, no Linux Steam-parent watchdog (`FRAMEPORT_OVERLAY`), not a "running game". A Steam overlay-app shortcut and a game shortcut run together. SteamVR's SetApplicationAutoLaunch didn't persist on the dev Frame (the watch never started by itself) → **agent v76: FramePort's own autostart**: the deployment's `overlay.autostart` is the source of truth; `ensure_host_fixes` (`ensure_overlay_service`) keeps the user service `frameport-vr-overlays.service` (`_vr_overlay_watch`, 5 s polls of the known vrserver's /proc stat) only while an overlay app has autostart on (register/unregister, uninstall, purge and the kill switch `~/.local/share/frameport/vr-overlays.disabled` / `FRAMEPORT_NO_OVERLAY_AUTOSTART` update it); a new vrserver (pid + start time, kept in `~/.cache/frameport-vr-overlays.json` across agent updates) → child `_vr_overlay_round`: wait for IVRApplications, 15 s grace, `launch_vr_overlay` for apps not running (folder process or GetApplicationProcessId); log `~/.local/share/frameport/vr-overlays.log`. Dev Frame: adopted the running vrserver, skipped the already running watch; a start after a real SteamVR start/boot is not yet seen. Details: docs/FRAME_RUNTIME.md "Overlay apps".
**Unreal's self-restart (agent v86, 2026-10-13):** UE 4.21+ restarts the app once after its first-run shader
**Vader's leak solved / AC Nexus flips (2026-10-14):** Vader Immortal leaves GL errors pending on its context; SteamVR's GL interop (`openxr_graphics_gl_openvr.cpp`, `xrclient_<pkg>.txt` in Steam's logs: `SXR_GL_CHECK: error:0x502 ... glGenTextures`) takes the stale error as its own failure once, and from then on `CVRCompositorSharedTextures::BeginCommandBuffer` allocates a command buffer every frame (perf page-fault stacks; ~430 renderD128 mappings + 22 MB/s). Adapter `gl_error_clear` (GLES, catalog on for Vader ep1-3) clears them before xrCreateSwapchain/xrBeginFrame/xrEndFrame: flat memory, no SXR_GL_CHECK (headless). The per-game `xrclient_<pkg>.txt` logs are worth reading for runtime errors. The Frame's runtime caps swapchains per session (XR_ERROR_LIMIT_REACHED from ~15; AC Nexus asks for more): flip emulation now turns quads upright in place (scratch VkImage, once per acquire; transfer usage added to single-layer colour swapchains) instead of a shadow swapchain per quad, which ran into the cap (#138). Headless sessions don't draw AC Nexus's panels (adapter `flip_dump` saves before/after images of flipped quads, for headset runs).
**Unreal's self-restart (agent v90, 2026-10-13):** UE 4.21+ restarts the app once after its first-run shader
precompile ("optimizing" screen; `r.ProgramBinaryCache.RestartAndroidAfterPrecompile`): it starts its own LAUNCHER
activity and calls System.exit(0); Lepton ends the container with the first process (the restarted one with it).
Skipping `AndroidThunkJava_RestartApplication` or the cvar = 0 (accepted via UE4CommandLine.txt `-ini:`) left Sports
Scramble on that level for good. launch.sh (RESTART_BLOCK, `upgrade_launchers` adds it) starts Lepton once more when
`_restart_requested` sees the app's own LAUNCHER start (system log, uid >= 10000) <= 5 s before its exit; first run
kept as `launch-before-restart.log`; `FRAMEPORT_NO_RESTART=1` off. Owner-verified with Sports Scramble's first start (2026-10-13).
**xrWaitFrame runs ahead (Journey of the Gods, 2026-10-14, owner-verified):** with real frames (headset on) the
Frame's runtime sometimes returns from xrWaitFrame at once with a display time less than one refresh after the frame
just submitted, and then rejects the second submission for that refresh (`VRCompositorError_AlreadySubmitted` in
Steam's `logs/xrclient_<pkg>.txt`; FrameBridge pacing above the display rate). VrApi games that render faster than
the display ran ahead (~120 submits/s at 90 Hz) and the scene slid and snapped back. The VrApi bridge re-waits when
the new display time is under half a period after the last submitted one (`pacing 2s:` counters). Headless runs only
submit empty frames and never show it. Lepton's logcat mirror also missed the whole verifying session (launch.log =
Lepton's own lines only), so the keeper doesn't catch every failure.
**Lepton's logcat mirror dies (agent v67, 2026-10-09):** occasionally launch.log ends with `logcat: Unexpected EOF!` right after the game starts (about 1 launch in 50, Lepton 2.8.14 and 3.0.5): no dashboard auto-hide, no launch-test result. launch.sh's `_logcat_keeper` then reads `podman exec lepton-steamlaunch-<appid> logcat` itself into launch.log (up to 5 restarts while the game runs).
**Double launch (agent v57, 2026-10-06):** a second Play while Lepton still boots (~10 s with nothing to see) made
the second Lepton stop the first one's container ("Waiting for steamlaunch-<appid> (PID …) to exit", exit 137
+1
View File
@@ -5,6 +5,7 @@ FramePort is GPL-3.0-only (see `LICENSE`). It includes or downloads the followin
## Included in this repository / the release bundles
| Component | Where | License |
|---|---|---|
| LuaJIT (Lua engine for verified xLua pointer compatibility, github.com/LuaJIT/LuaJIT) | `artifacts/arm64-v8a/libfp_luajit.so`; pinned build in `native/luajit/build.py` | MIT (`native/luajit/COPYRIGHT`, included in bundles as `artifacts/licenses/LuaJIT.txt`) |
| OVRPort VrApi → OpenXR adapter (`native/vrapi`, github.com/Android-XR-Bridge/OVRPort), with FramePort's changes | `native/vrapi-bridge/`, `artifacts/arm64-v8a/libvrapi.so` | GPL-3.0 (`native/vrapi-bridge/LICENSE.upstream`) |
| OVRPort platform compatibility library (`native/platform`) | `native/platformcompat/`, `artifacts/arm64-v8a/libovrplatformcompat.so` | GPL-3.0; Meta Platform SDK headers under the Meta Platform Technologies SDK License (`native/platformcompat/licenses/OCULUS-PLATFORM-SDK.txt`) |
| Khronos OpenXR SDK headers | used to build the native layers | Apache-2.0 / MIT (`native/vrapi-bridge/licenses/OPENXR-SDK.txt`) |
+203 -28
View File
@@ -44,7 +44,7 @@ try:
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
fcntl = None
AGENT_VERSION = 86
AGENT_VERSION = 90
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -902,6 +902,32 @@ def shortcut_appid(exe, title):
return zlib.crc32((exe + title).encode("utf-8")) | 0x80000000
class VdfMap(dict):
"""Steam's KeyValues names are case-insensitive; keep their spelling when writing back (#263)."""
def _key(self, key):
return next((k for k in self if k.casefold() == key.casefold()), key)
def __contains__(self, key):
return super().__contains__(self._key(key))
def __getitem__(self, key):
return super().__getitem__(self._key(key))
def __setitem__(self, key, value):
super().__setitem__(self._key(key), value)
def get(self, key, default=None):
return super().get(self._key(key), default)
def setdefault(self, key, default=None):
return super().setdefault(self._key(key), default)
def update(self, *args, **kwargs):
for key, value in dict(*args, **kwargs).items():
self[key] = value
def vdf_decode(data):
pos = 0
@@ -914,7 +940,7 @@ def vdf_decode(data):
def node():
nonlocal pos
out = {}
out = VdfMap()
while True:
kind = data[pos]
pos += 1
@@ -1230,7 +1256,7 @@ def _shortcuts_worker(payload):
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
if lost: # Steam wrote its old copy back over ours (it was still saving): once more, then the devkit entry
result["retried"] = lost
service = stop_steam()
for user in users:
@@ -1238,7 +1264,7 @@ def _shortcuts_worker(payload):
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")
add_devkit_entry(pkg, result)
except Exception as exc: # noqa: BLE001
result["state"] = "failed"
result["errors"].append(str(exc))
@@ -1248,6 +1274,19 @@ def _shortcuts_worker(payload):
json.dump(result, f)
def add_devkit_entry(pkg, result):
"""Steam dropped the game's shortcuts.vdf entry twice: this Frame's Steam ignores FramePort's entries (GitHub #41),
so add it the way Valve's devkit tool does (Steam is running again). That entry is the one Steam keeps."""
try:
appid = devkit_register(pkg)
except Exception as exc: # noqa: BLE001
result["errors"].append(f"{pkg}: Steam removed the new library entry again after restarting, and the devkit "
f"entry failed too: {exc}")
return
result["added"].append({"package": pkg, "appid": appid, "expected": (deployment(pkg) or {}).get("appid"),
"via": "devkit"})
def remove_from_library(user, remove, result):
"""Remove uninstalled games' shortcuts + grid art from one Steam account (Steam is closed)."""
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
@@ -1500,10 +1539,17 @@ def is_overlay(dep):
return bool(isinstance(dep, dict) and dep.get("overlay"))
def deployment_summary(dep):
"""Keep routine connection/monitor samples small; detailed data manifests are fetched only for an update."""
return {key: value for key, value in dep.items()
if key not in ("data_manifest", "configuration", "managed_config_files")}
def cmd_list_installed(args):
games = []
table = mounts()
for dep_path in sorted(glob.glob(os.path.join(ANCHORS, "*/deployment.json"))):
selected = check_pkg(args["package"]) if args.get("package") else "*"
for dep_path in sorted(glob.glob(os.path.join(ANCHORS, selected, "deployment.json"))):
try:
dep = json.load(open(dep_path))
except (OSError, ValueError):
@@ -1540,7 +1586,7 @@ def cmd_list_installed(args):
dep["apk_present"] = os.path.exists(apk)
dep["apk_size"] = os.path.getsize(apk) if dep["apk_present"] else 0
dep["last_play"] = last_play(dep["anchor"]) # agent v70: the PC triages a finished play session's log
games.append(dep)
games.append(dep if args.get("details") else deployment_summary(dep))
return {"games": games}
@@ -3876,7 +3922,7 @@ def cmd_finalize(args):
pkg = check_pkg(args["package"])
title = args["title"]
prep = cmd_prepare({"package": pkg, "title": title, "dest": args.get("dest")})
base, anchor, appid, incoming = prep["base"], prep["anchor"], prep["appid"], prep["incoming"]
base, anchor, incoming = prep["base"], prep["anchor"], prep["incoming"]
lepton = prep["lepton"]
if not lepton:
raise AgentError("Lepton is not installed (Developer Mode → install/launch 'Lepton Development' once)")
@@ -3897,6 +3943,10 @@ def cmd_finalize(args):
bad = [k for k, v in expected.items() if have.get(k) != v]
if bad:
raise AgentError(f"{len(bad)} data file(s) missing or incomplete, e.g. {bad[0]}")
if args.get("existing_only") and os.path.exists(new_apk):
raise AgentError("an interrupted APK upload is pending; finish that install before updating data")
if args.get("existing_only"):
verify_installed_apk(deployment(pkg), args.get("apk_sha256"))
if os.path.exists(new_apk):
if args.get("apk_sha256") and sha256_file(new_apk) != args["apk_sha256"]:
raise AgentError("uploaded APK checksum mismatch (transfer corrupted?)")
@@ -3907,16 +3957,67 @@ def cmd_finalize(args):
elif not os.path.exists(os.path.join(app, "game.apk")):
raise AgentError("no APK uploaded and none installed")
moved = 0
for root, _, files in os.walk(inc_obb):
for name in files:
if name.endswith(".part"):
continue
src = os.path.join(root, name)
dst = os.path.join(app, "obb", os.path.relpath(src, inc_obb))
os.makedirs(os.path.dirname(dst), exist_ok=True)
os.replace(src, dst)
moved += 1
shutil.rmtree(incoming, ignore_errors=True)
if not args.get("preserve_data"):
for root, _, files in os.walk(inc_obb):
for name in files:
if name.endswith(".part"):
continue
src = os.path.join(root, name)
rel = os.path.relpath(src, inc_obb)
if expected is not None and rel not in expected:
continue # an older interrupted upload is not part of this install
dst = os.path.join(app, "obb", rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
os.replace(src, dst)
moved += 1
shutil.rmtree(incoming, ignore_errors=True)
return configure_install({**args, "consume_artwork": args.get("consume_artwork", True)}, prep, moved)
def verify_installed_apk(dep, expected):
apk = os.path.join(dep["base"], "lepton-app", "game.apk")
if not expected or dep.get("sha256") != expected or not os.path.isfile(apk):
raise AgentError("installed APK is missing or changed; refresh and reinstall it")
st = os.stat(apk)
identity = {"size": st.st_size, "mtime_ns": st.st_mtime_ns}
if identity != dep.get("apk_stat") and sha256_file(apk) != expected:
raise AgentError("installed APK content changed; reinstall it")
def cmd_configure(args):
"""Apply runtime configuration to an existing, matching APK without staging/replacing game files."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep or dep.get("kind", "quest") != "quest":
raise AgentError(f"{pkg} is not an installed Quest/Android app")
base, _ = new_base(args, pkg, dep) # validates a removable drive; never silently falls back
verify_installed_apk(dep, args.get("apk_sha256"))
if container_running(dep["appid"]):
raise AgentError("Close the game before updating its configuration")
lepton, _ = lepton_path()
if not lepton:
raise AgentError("Lepton is not installed")
return configure_install({**args, "title": dep["title"]},
{"base": base, "anchor": os.path.join(ANCHORS, pkg),
"appid": dep["appid"], "lepton": lepton}, 0)
def configure_install(args, prep, moved):
pkg, title = check_pkg(args["package"]), args["title"]
base, anchor, appid, lepton = prep["base"], prep["anchor"], prep["appid"], prep["lepton"]
app = os.path.join(base, "lepton-app")
previous = deployment(pkg) or {}
files_dir = data_files_dir(base, pkg)
for rel in set(args.get("files") or {}) | set(previous.get("managed_config_files") or {}):
target = os.path.realpath(os.path.join(files_dir, rel))
if not _inside(target, os.path.realpath(files_dir)) or target == os.path.realpath(files_dir):
raise AgentError(f"refusing to write outside the game's files dir: {rel}")
backup = os.path.join(files_dir, rel) + ".frameport-config-backup"
if os.path.islink(backup):
raise AgentError(f"refusing a linked configuration backup: {rel}")
for key, value in (args.get("settings") or {}).items():
if not re.fullmatch(r"[a-z_]+", key) or any(c in str(value) for c in "\r\n\x00"):
raise AgentError(f"bad setting {key}")
if "flatscreen" in args: # older clients don't send it: leave the marker as it is
set_flatscreen(app, args["flatscreen"])
# settings: settings.conf (read by the adapter via LEPTON_ENV_FRAMEBRIDGE_CONFIG) + framebridge.conf copy
@@ -3933,13 +4034,37 @@ def cmd_finalize(args):
f.write(text)
with open(os.path.join(files_dir, "framebridge.conf"), "w") as f:
f.write(text)
managed = previous.get("managed_config_files") or {}
wanted = args.get("files") or {}
for rel, record in managed.items():
if rel in wanted:
continue
target = os.path.realpath(os.path.join(files_dir, rel))
if not _inside(target, os.path.realpath(files_dir)):
raise AgentError(f"invalid previously managed path: {rel}")
if os.path.isfile(target) and sha256_file(target) == record["sha256"]:
backup = target + ".frameport-config-backup"
if record.get("backup") and os.path.isfile(backup):
os.replace(backup, target)
else:
os.unlink(target)
new_managed = {}
for rel, content in (args.get("files") or {}).items():
target = os.path.normpath(os.path.join(files_dir, rel))
if not target.startswith(files_dir + os.sep):
raise AgentError(f"refusing to write outside the game's files dir: {rel}")
os.makedirs(os.path.dirname(target), exist_ok=True)
record = managed.get(rel)
backup = target + ".frameport-config-backup"
# no record: an existing file is the user's, unless it already has exactly this content (written by
# FramePort before agent v86 kept records; backing it up would bring it back when the recipe drops it)
had_original = bool(record.get("backup")) if record else os.path.isfile(target) and \
open(target, "rb").read() != content.encode()
if not record and had_original:
shutil.copy2(target, backup)
with open(target, "w") as f:
f.write(content)
new_managed[rel] = {"sha256": sha256_file(target), "backup": had_original}
models = install_controller_models(files_dir, str(settings.get("controller_models", 0)) not in ("0", "0.0"))
recipe = args.get("recipe") if isinstance(args.get("recipe"), dict) else {}
hw_video = HW_VIDEO_PATCH in (recipe.get("patches") or []) # the shared codec (install_video_codec)
@@ -3949,22 +4074,40 @@ def cmd_finalize(args):
config_sync = args.get("config_sync") if isinstance(args.get("config_sync"), dict) else {}
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"), hw_video, gamepad, bool(config_sync))
art_in = os.path.join(base, "incoming-artwork")
if os.path.isdir(art_in):
art_moved = False
if args.get("consume_artwork") and os.path.isdir(art_in):
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
shutil.move(art_in, os.path.join(anchor, "artwork"))
previous = deployment(pkg) or {}
dep = {"package": pkg, "appid": int(appid), "base": base, "title": title, "tags": args.get("tags") or [],
"apk": args.get("apk_name", "game.apk"),
"sha256": args.get("apk_sha256"), "recipe": args.get("recipe"), "installed_by": "frameport",
art_moved = True
dep = {**previous, "package": pkg, "appid": int(appid), "base": base, "title": title,
"tags": args.get("tags", previous.get("tags")) or [],
"apk": args.get("apk_name", previous.get("apk", "game.apk")),
"sha256": args.get("apk_sha256") or previous.get("sha256"),
"recipe": args.get("recipe"), "installed_by": "frameport",
"hw_video_decode": hw_video, "steam_gamepad": gamepad, "config_sync": config_sync,
"agent_version": AGENT_VERSION, "time": time.time()}
if previous.get("eye_base"): # the runtime's eye size learned from an earlier start (device.config_sync)
dep["eye_base"] = previous["eye_base"]
with open(os.path.join(anchor, "deployment.json"), "w") as f:
json.dump(dep, f, indent=2)
dep["managed_config_files"] = new_managed
# An app before agent v86 records none of these: what it installed is unknown, so a newer PC mustn't skip work
# on the strength of an older record. Art the agent didn't replace keeps its record.
art_replaced = bool(args.get("consume_artwork")) and art_moved
for key in ("configuration", "configuration_fp", "apk_recipe_fp", "source_version", "artwork_manifest"):
if key in args:
dep[key] = args[key]
elif key != "artwork_manifest" or art_replaced:
dep.pop(key, None)
if args.get("obb_manifest") is not None:
dep["data_manifest"] = args["obb_manifest"]
if "data_manifest" in dep:
dep["data_fp"] = hashlib.sha256(json.dumps(dep["data_manifest"], sort_keys=True).encode()).hexdigest()[:32]
st = os.stat(os.path.join(app, "game.apk"))
dep["apk_size"] = st.st_size
dep["apk_stat"] = {"size": st.st_size, "mtime_ns": st.st_mtime_ns}
synced = sync_game_config(pkg, dep)
write_deployment(pkg, dep)
return {"ok": True, "base": base, "appid": appid, "moved_data_files": moved, "controller_models": models,
"config_sync": synced}
"config_sync": synced, "deployment": deployment_summary(dep)}
# --------------------------------------------------------------- Steam Frame controller models (XR_FB_render_model)
@@ -5223,8 +5366,8 @@ def cmd_set_settings(args):
if k:
current[k] = v
for k, v in (args.get("settings") or {}).items():
if not re.fullmatch(r"[a-z_]+", k) or not re.fullmatch(r"-?[0-9.]+", str(v)):
raise AgentError(f"bad setting {k}={v}")
if not re.fullmatch(r"[a-z_]+", k) or not re.fullmatch(r"-?(?:[0-9]+\.?[0-9]*|\.[0-9]+)", str(v)):
raise AgentError(f"bad setting {k}={v}") # checked before anything is written
current[k] = str(v)
text = "".join(f"{k}={v}\n" for k, v in current.items())
with open(conf, "w") as f:
@@ -5234,7 +5377,19 @@ def cmd_set_settings(args):
with open(os.path.join(files_dir, "framebridge.conf"), "w") as f:
f.write(text)
models = install_controller_models(files_dir, current.get("controller_models", "0") not in ("0", "0.0"))
return {"settings": current, "controller_models": models, "config_sync": sync_game_config(pkg, dep)}
synced = sync_game_config(pkg, dep)
if isinstance(dep.get("configuration"), dict):
applied = dep["configuration"]
settings = applied.get("settings") or {}
for key, value in (args.get("settings") or {}).items():
if key in settings:
number = float(value)
# keep the recorded type (the PC's fingerprint uses it); an int setting given 1.5 stays 1.5
settings[key] = number if isinstance(settings[key], float) or not number.is_integer() \
else int(number)
dep["configuration_fp"] = hashlib.sha256(json.dumps(applied, sort_keys=True).encode()).hexdigest()[:32]
write_deployment(pkg, dep)
return {"settings": current, "controller_models": models, "config_sync": synced}
TITLE_MAX = 128
@@ -5248,6 +5403,26 @@ def clean_title(title):
return text[:TITLE_MAX]
def cmd_record_artwork(args):
"""Record artwork only after verifying the files actually uploaded to the game's anchor."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
manifest = args.get("manifest") or {}
root = os.path.join(ANCHORS, pkg, "artwork")
for name, digest in manifest.items():
if (not re.fullmatch(r"(?:portrait|landscape|hero|logo|icon)\.[a-zA-Z0-9]+|\.icon-source", name)
or not re.fullmatch(r"[a-f0-9]{64}", str(digest))):
raise AgentError("invalid artwork manifest")
path = os.path.join(root, name)
if not os.path.isfile(path) or sha256_file(path) != digest:
raise AgentError(f"artwork missing or checksum mismatch: {name}")
dep["artwork_manifest"] = manifest
write_deployment(pkg, dep)
return {"verified": True}
def cmd_rename(args):
"""Rename an installed game (agent v77): deployment.json's title is what its Steam shortcut shows. The shortcut
keeps its appid (upsert_shortcut matches by Exe, deployment.json keeps the appid), so Play, its grid art, launch
+9 -7
View File
@@ -1,9 +1,10 @@
1a4dce5c4a6d0dfc5f20e55ac29e70c73cc2cc124c21ca6a7e1b316e6afb1c79 ./arm64-v8a/libVkLayer_fdm_darken.so
5becb96ee86fbda0ce98c0e0fd064f8ba092314aceaa400e3dcc785705dc3c23 ./arm64-v8a/libVkLayer_fp_shaderfix.so
4aba526e2fb58311cbaed8520601f17924d63922324ecdf2ae90a9c315b00654 ./arm64-v8a/liba32bridge.so
4d555f18f7838212ba409a72d41a7b94f465f40a70ae5b761bdd01e79b178f75 ./arm64-v8a/liba32rt.so
fbc3b0238462d976d234fee6740db92b873c85d6bb22a6b01428e64e9058ed83 ./arm64-v8a/liba32bridge.so
c9c3da5cac224c38e2210b781bd12b7f97e02bd6b0ebb0ef85543e4ea4b76671 ./arm64-v8a/liba32rt.so
18d8d917e83a6d87008f41908eb478dff635e798844e125abd1e8a5cf31805e2 ./arm64-v8a/liba32stub.so
e7554c6343ee2989b0a273ee6230e65c25bfe6499eefd00de19f5f0fee58754f ./arm64-v8a/libfp_langpack.so
d6337034fb0bdcca7ef9b7063d85b7586b8508b63b435959e8fffaba0691091c ./arm64-v8a/libfp_langpack.so
69102b128e00fa1809489a7308e09c1505f563bf7091bbdd3fe5a58e4cc96c2c ./arm64-v8a/libfp_luajit.so
321da502e0f8f46f0880aad39fe6b84bdef88f925164568814cf704a1b31a577 ./arm64-v8a/libfp_ovrp.so
40defdaddcda53bd2649eb48076fae1622bfc2bfc88e9e03c94fc8b45af2a4f2 ./arm64-v8a/libfp_ovrtrace.so
0167c2fde5dfde62897f9d85afc4fc8e97784e988b5b8df6cea8c407240b3424 ./arm64-v8a/libfp_tkeys.so
@@ -12,17 +13,18 @@ e8c0965c188665a4e565e4a3af983ef1866d1cf9225665cc90d8df286bc96ef3 ./arm64-v8a/li
5432674a59d02f411cd853a5dc57e3fa547d1ac518f25bf433fc54c43677177a ./arm64-v8a/libfpglmv.so
8a8f6b1da8952cb3933a5b53558424a5fc56043b2527a16bb6dd62a00d2de01a ./arm64-v8a/libframe_xrshim.so
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
e44ded778758de33ad87bb31398281d7ec94c1ecd43dcf55b1dc99311f011859 ./arm64-v8a/libopenxr_loader_generic.so
51f5e0c5b3d3556baab27ece2936257782d6b590183ae59601cc6d8158c96ee2 ./arm64-v8a/libopenxr_loader_generic.so
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
6d766fd87488fc487d465dcb79f7b462f0c863934414e416746b7c1841fb4130 ./arm64-v8a/libvrapi.so
6d766fd87488fc487d465dcb79f7b462f0c863934414e416746b7c1841fb4130 ./arm64-v8a/libvrapi_foveation.so
d2361ee8b6ff62d2ff6b3f59f7b15730b95bdbe0e9dcee469ffa1117411e7288 ./armeabi-v7a/libopenxr_loader_generic.so
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
6927f100536283a8c26b784af73ee400140dd910e9294e4a9d55f855547ffaf2 ./armeabi-v7a/libopenxr_loader_generic.so
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
4516971bbb2b635e19b14d2d37b9353962876f7de02043e7457327052c1fcbc0 ./hevc/manifest.json
46c3dcad2c43cc30bea3b3680b362ed84a99c15d60714bbbbb422c80ecf90405 ./hevc/media_codecs_frameport.xml
529ea35d5d82474afe9e09ad1216d269317f56dd24a05fb100fced52b7b4dbca ./hevc/podman.py.txt
6cda3076a8c09b31bb1f90da614ea89e7ee194589626eeac613e522849604e47 ./licenses/LuaJIT.txt
1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json
28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.so
b10b3a5c10c3339fc63fd9f6cb4d01ea29be11a863d0ebad1389ea69fd471940 ./linux-arm64-bin/fp_venc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+56
View File
@@ -0,0 +1,56 @@
===============================================================================
LuaJIT -- a Just-In-Time Compiler for Lua. https://luajit.org/
Copyright (C) 2005-2026 Mike Pall. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
[ MIT license: https://www.opensource.org/licenses/mit-license.php ]
===============================================================================
[ LuaJIT includes code from Lua 5.1/5.2, which has this license statement: ]
Copyright (C) 1994-2012 Lua.org, PUC-Rio.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
===============================================================================
[ LuaJIT includes code from dlmalloc, which has this license statement: ]
This is a version (aka dlmalloc) of malloc/free/realloc written by
Doug Lea and released to the public domain, as explained at
https://creativecommons.org/licenses/publicdomain
===============================================================================
@@ -13,8 +13,9 @@ details: Unreal Engine 4 (GLES). Found and verified in gameplay on a Frame by Kl
the captured modules are from Lepton 2.8.14 and stop matching if a Frame update changes the driver's output -
the layer logs that). OVRPlugin asks for poses at its own clock (pose_time_fix). Unreal's Oculus input animates the
thumbs from near-touch, which the Frame never reports (frame.unreal_thumb_touch reads the touches instead; binding
the proximity to the touch inputs in FrameBridge didn't reach the game). The Frame's SteamVR runtime
leaks about 20 MB a second while the game runs (reported to Valve).
the proximity to the touch inputs in FrameBridge didn't reach the game). The game leaves GL errors
pending; the runtime's GL check took one as its own failure and then allocated a command buffer every frame
(~20 MB a second until the frame rate collapsed). gl_error_clear clears them first (GitHub #285).
tested_version: 1.1.1+667256.cl.387770
engine: Unreal
xr: VrApi
@@ -27,10 +28,11 @@ frame:
- frame.zink_shader_fix
adapter:
pose_time_fix: 1
gl_error_clear: 1
zink_shader_fix: 12016:b2919629761ad268e0b21c72ac8520f81b76e72c3adc26897f97eae9a3e70a66:3092:0x0003003e,166,82,0x0003003e,177,82,0x0003003e,183,82,0x0003003e,189,82,0x0003003e,256,82,0x0003003e,266,82,0x0003003e,272,82,0x0003003e,278,82,0x0003003e,318,82,0x0003003e,328,82,0x0003003e,334,82,0x0003003e,340,82;11436:6f18be49f2aaa2452f962eb16f7af744c5c22a7b25f37ce681e0d6399e35a869:2932:0x0003003e,164,80,0x0003003e,175,80,0x0003003e,181,80,0x0003003e,187,80,0x0003003e,254,80,0x0003003e,264,80,0x0003003e,270,80,0x0003003e,276,80,0x0003003e,316,80,0x0003003e,326,80,0x0003003e,332,80,0x0003003e,338,80
verified:
date: '2026-10-09'
issue: 49
updated: '2026-10-09'
updated: '2026-10-14'
min_app: 0.12.1
source_hint: Vader Immortal- Episode I
@@ -18,7 +18,9 @@ frame:
- frame.unreal_thumb_touch
adapter:
pose_time_fix: 1
gl_error_clear: 1
verified:
date: '2026-10-09'
updated: '2026-10-14'
min_app: 0.12.1
source_hint: Vader Immortal- Episode II
@@ -18,7 +18,9 @@ frame:
- frame.unreal_thumb_touch
adapter:
pose_time_fix: 1
gl_error_clear: 1
verified:
date: '2026-10-09'
updated: '2026-10-14'
min_app: 0.12.1
source_hint: Vader Immortal- Episode III
@@ -0,0 +1,19 @@
package: com.NiVision.RacketPinball
title: RacketPinball
status: works
tested_version: '2.06'
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-13'
known_good_sha256: 80e850f81c24112e8c35a560a333846163a3e783da391290db7663b9015ca58f
app: 1.0.4
overport_cli: 1.2.5
issue: 262
source_hint: RacketPinball
+6 -3
View File
@@ -4,9 +4,9 @@ tested_version: MAIN.450412.207706.final
source_hint: Assassins Creed Nexus VR
engine: Unity
xr: OpenXR
status: issues
notes: Some launch warning text is still upside down; the rest of the UI is fixed by flip emulation. Ubisoft login
may not work.
status: works
notes: FrameBridge turns its upside-down UI panels upright (flip emulation, in place since FramePort 1.1.2). Ubisoft
login may not work.
details: Ubisoft online login may not work. Data folder holds raw assets (start0..2, .climb, bundles), not .obb
files; install to obb as-is.
overport_extra:
@@ -17,3 +17,6 @@ verified:
overport_runtime: 3.4.3-23204ea
known_good_sha256: 89644a12bf8df956fa3d3a9a40540ba9e1fb62f63494714a05c9f05984383ac8
no_appsw_90: '2026-10-02, OVRPort 1.2.5 (owner: 90 Hz felt better than 72 Hz; same known issues)'
flip_in_place: '2026-10-14, owner in the headset: launch warnings and tutorial pop-ups upright (GitHub #138)'
updated: '2026-10-14'
min_app: 1.1.2
+25
View File
@@ -0,0 +1,25 @@
package: com.arkio.arkio
title: Arkio
status: works
notes: The quest version allows for passthrough mode
tested_version: 2.3.1
engine: Unity
xr: OpenXR
overport_extra:
- patch_force_passthrough
frame:
- frame.meta_permissions
- frame.unity_text_input
device:
- device.text_input_window
adapter:
scene_emul: 1
verified:
date: '2026-10-13'
known_good_sha256: e17f9b2dc56cb49b4469d81f2e075ca5953f73152804ff1a00fba0931048e103
app: 1.1.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 85
issue: 266
source_hint: com.arkio.arkio
+3 -2
View File
@@ -16,11 +16,12 @@ frame:
adapter:
sync_guard: 1
surface_native: 1
fade_quad: 1
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: 796d1cab08f614d72c50d9702974c719c0908f5de91183cfe02d1fd5eb09551a
updated: '2026-10-10'
min_app: 0.12.1
updated: '2026-10-13'
min_app: 1.1.0
source_hint: Batman- Arkham Shadow
+4
View File
@@ -8,6 +8,8 @@ frame:
- frame.unity_text_input
device:
- device.text_input_window
adapter:
fade_quad: 1
verified:
date: '2026-10-05'
known_good_sha256: 41e0ab90f622afd50accb0b6e2553520d30a6cc82e5ab4cbb2341b48f7316a71
@@ -15,3 +17,5 @@ verified:
overport_cli: 1.2.5
issue: 47
source_hint: Marvel's Iron Man VR
updated: '2026-10-13'
min_app: 1.1.0
+11 -8
View File
@@ -4,13 +4,16 @@ tested_version: '1.4'
source_hint: Shadow Point
engine: Unity
xr: VrApi
status: unsupported
notes: 32-bit only; the Frame has no AArch32.
details: 32-bit (armeabi-v7a) only; the Frame has no AArch32. 64-bit conversion was researched and dropped (IL2CPP
+ FMOD + custom rasterizer).
status: works
notes: 32-bit only (armeabi-v7a), runs through FramePort's 32-bit bridge (experimental). Plays in the headset.
details: 'Unity with OVRPlugin + VrApi, GLES. Needed the bridge''s vrapi_GetTrackingTransform/SetTrackingTransform
(the game sets its floor level through them; without them the player stood below the floor). Plays in the headset
(owner, 2026-10-14).'
frame:
- frame.a32_bridge
device:
- device.text_input_window
pcvr_alternative: Rift version via Revive + SteamVR.
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: ea0c0d09d2c3d8da04d165732ead64875fef7573a5739608de0de9c93654007e
date: '2026-10-14'
min_app: 1.1.2
+20
View File
@@ -0,0 +1,20 @@
package: com.edde746.plezy
title: Plezy
status: issues
notes: Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding.
Otherwise works.
tested_version: 2.22.0
engine: Other
xr: '?'
frame:
- frame.hw_video_decode
as_is: true
verified:
date: '2026-10-13'
known_good_sha256: 893ae7c01d3d7688b27cbf4e7fc1260fb617614baeaf378fe5ca0af41febc92f
app: 1.1.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 85
issue: 270
source_hint: plezy
@@ -0,0 +1,20 @@
package: com.fulldivegames.majorasmaskvr
title: Majora's Mask VR
status: issues
notes: Audio on the file select screen sounds crunchy. Controls may need rebinding in the game. Quest build from https://github.com/fulldivegames/Majoras-Mask-VR.
tested_version: '0.36'
engine: Other
xr: OpenXR
frame:
- frame.sdl_clipboard
adapter:
refresh_rate: 144.0
verified:
date: '2026-10-13'
known_good_sha256: 220096c70fb515ec7b8db1cc7a9075836b4d09f7df63026908af3c30e9fdb7e5
app: 1.1.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 85
issue: 268
source_hint: MMVR-Quest-0.36
@@ -0,0 +1,26 @@
package: com.nDreams.Ghostbusters
title: 'Ghostbusters: Rise of the Ghost Lord'
status: works
notes: 'Needs the game''s offline mode: it is built for online play, and without a profile the player name is a random
number. The flat loading screen floats in front of you instead of following your head, so assets can be seen loading
in behind it.'
tested_version: 1.325635.prod.shipping
engine: Unreal
xr: OpenXR
alt_overport:
- patch_remove_unreal_force_quit
frame:
- frame.unreal_ovrp_entrypoints
- frame.hw_video_decode
adapter:
haptic_fix: 1
scene_emul: 1
verified:
date: '2026-10-14'
known_good_sha256: 7556a78c8772aed70c38fc4781677c0900e9dfd4be085a79c421a31323af7888
app: 1.1.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 85
issue: 279
source_hint: GBRotgl
@@ -0,0 +1,23 @@
package: com.notdead.COMPOUNDQUEST
title: COMPOUND
status: works
tested_version: 1.0.12
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
- frame.unity_runtime_msaa_off
- frame.unity_gl_shim
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-13'
known_good_sha256: 5da7aac9863412daec7ba909dcfd2d5cfa6cb5d078150011c006165058cd76bd
app: 1.1.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 85
issue: 260
source_hint: COMPOUND
+22
View File
@@ -0,0 +1,22 @@
package: com.polyarc.Moss2
title: 'Moss: Book II'
status: works
notes: Just played the very begining but controlls worked. Did nothing other than install it.
tested_version: 1.0.3.149619
engine: Unreal
xr: VrApi
alt_overport:
- patch_remove_unreal_force_quit
frame:
- frame.hw_video_decode
adapter:
haptic_fix: 1
verified:
date: '2026-10-12'
known_good_sha256: 686db536ced97d8a151c82955174ec01d67987c466ecd326d3deb557d8c4fd1d
app: 1.0.4
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 79
issue: 248
source_hint: Moss- Book II
+10 -7
View File
@@ -4,14 +4,17 @@ tested_version: 1.0.479431
source_hint: Journey of the Gods
engine: Unreal
xr: VrApi
status: unsupported
notes: 32-bit only; the Frame has no AArch32.
details: 32-bit (armeabi-v7a) only; the Frame has no AArch32. UE4 cannot be rebuilt for arm64.
status: works
notes: 32-bit only (armeabi-v7a), runs through FramePort's 32-bit bridge (experimental). Plays in the headset.
details: 'Unreal Engine 4 NativeActivity with OVRPlugin + VrApi, GLES. Needed DT_ANDROID_RELR relocations in the 32-bit
loader (it crashed at start) and the VrApi bridge''s frame pacing: the Frame''s runtime can return from
xrWaitFrame early, and this light game then ran at ~120 fps on a 90 Hz display, so the scene slid and snapped
back. Plays in the headset (owner, 2026-10-14).'
alt_overport:
- patch_remove_unreal_force_quit
frame:
- frame.a32_bridge
pcvr_alternative: Rift (Meta PC) version via Revive + SteamVR.
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: e85141e3e9a8a3226f653d8ffbd21b44268101f3a207f56e657454a3639b3201
date: '2026-10-14'
min_app: 1.1.2
+24
View File
@@ -0,0 +1,24 @@
package: pl.idreams.zombirds
title: Shoot The Zombirds VR
status: works
tested_version: 1.0.2
engine: Unity
xr: VrApi
frame:
- frame.unity_oculus_check
- frame.unity_text_input
- frame.unity_runtime_msaa_off
- frame.unity_gl_shim
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-13'
known_good_sha256: 2d06d1b6aac83d232e9f559c1cd3188642e7a34ef515605763ee7afa04730156
app: 1.1.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 85
issue: 259
source_hint: Shoot The Zombirds
+11 -6
View File
@@ -1,11 +1,16 @@
package: quest.eleven.forfunlabs
title: Eleven Table Tennis
status: unsupported
notes: Stops at the splash screen. The game needs Meta's online services after start, which aren't available on
the Frame.
status: works
notes: Works great for me, tried the tutorial and an online match. Quest 2 apk.
tested_version: 0.436.37_2026.9.4.18.35.33.415
engine: Unity
xr: OpenXR
pcvr_alternative: The PC VR (Rift) version works on the Frame through FramePort.
adapter:
haptic_fix: 1
verified:
date: '2026-10-10'
issue: 160
date: '2026-10-13'
known_good_sha256: f4d996db1c913639f8b981bf87834e053ee2d9267383da3c1a8372ba755e5ac8
app: 1.1.0
overport_cli: 1.2.5
issue: 267
source_hint: quest.eleven.forfunlabs
+30 -4
View File
@@ -6,6 +6,23 @@
# with diagnostics. Launch tests and real play sessions (agent session_log, `frameport session`) are both matched.
# Sources: every failure we hit porting 34 games to the Steam Frame (see docs/PLAYBOOK.md).
signatures:
- id: meta-asset-status-error
pattern: 'Asset status error\s+\(code 100\)'
severity: error
diagnosis: A DLC manager reports a Meta asset-file status error after VR starts (e.g. PixelRipped 1995). The asset-file check failed; the log does not identify the native call or asset ID. Try the experimental asset-file bypass (empty list, installed status with no file path); this does not supply DLC and has not been verified in the game.
suggest: [frame.asset_files_stub]
- id: lua-lightuserdata-pointer
pattern: 'bad light userdata pointer'
severity: fatal
diagnosis: The Lua runtime rejected a pointer outside its supported address range. In the verified older xLua build, valid wider Frame pointers trigger this failure; a scoped compatible engine is available. For other builds, inspect the rejected pointer before assuming this cause.
suggest: [frame.luajit_pointers]
supersedes: [native-crash]
- id: lua-startup-exit
pattern: '(?s)\A(?=.*libxlua\.so[^\r\n]*lj_err_throw)(?=.*(?:__cxa_finalize|exit\+\d+))(?=.*(?:Fatal signal 6|FORTIFY:))'
severity: error
diagnosis: An uncaught Lua error triggered process exit, followed by a crash during Unity cleanup. The mutex failure is secondary; capture the original Lua error before choosing a fix. This stack alone does not identify a pointer-range problem.
suggest: []
supersedes: [native-crash]
- id: vrapi-frame-rejected
pattern: 'OVRPortVrApi:\s+Unsupported frame flags or swap interval'
severity: fatal
@@ -364,10 +381,19 @@ signatures:
The game stays on Unreal's OBB check screen (DownloaderActivity), which the Frame doesn't show, and never
starts VR (for example Contractors, GitHub #159). Unreal checks that the data file (.obb) has the name and size
this APK expects, and with bVerifyOBBOnStartUp it also reads the whole file to check it; a failed check waits
on an error screen. "Unreal - start without the OBB check" skips the content check. If the game still stops
here, the OBB doesn't match this APK version - copy the APK and the OBB from the same install, add the game
again and reinstall.
suggest: [frame.unreal_skip_obb_check]
on an error screen. "Unreal - start without the OBB check" skips the content check. Copy the APK and OBB
from the same install, add the game again and reinstall.
If the downloader still appears with bVerifyOBBOnStartUp=false (the skip patch already applied), try
"Unreal: skip the OBB delivery check", an experimental bypass of the separate Java name and size gate
(e.g. Into The Radius, GitHub #283). An external-storage path or visibility failure can reject an uploaded
OBB; the native engine still needs complete, matching, readable data.
suggest: [frame.unreal_skip_obb_check, frame.unreal_skip_obb_delivery]
- id: tbxr-fdm-shutdown-crash
pattern: '(?s)\A(?=.*Fatal signal 11)(?=.*fdm_injection_xrDestroySwapchain)(?=.*TBXR_LeaveVR)'
severity: error
diagnosis: The game crashed while leaving VR, inside Valve's foveation layer when it destroys a swapchain (for example Lambda1VR). This can happen after manually quitting; it is not a startup crash and does not explain distorted geometry seen before quitting.
suggest: []
supersedes: [native-crash]
- id: native-crash
pattern: 'Fatal signal (\d+) \(SIG[A-Z]+\)'
severity: fatal
+9
View File
@@ -23,6 +23,9 @@ manifest.json versions (app, agent, tools, SteamOS build, Proton)
app/app.log the app's log (<data>/logs/app.log, rotated 2 MB × 3)
app/jobs/*.log saved GUI job logs (<data>/logs/jobs, last 30): build, install, test, ...
app/settings.json library settings, catalog sources
app/display.json Flet/Flutter versions and last window state; on native Windows, display layout,
resolution, refresh rate, GPU driver metadata and up to 60 recent display/device
System event records (last 30 minutes; timestamps, providers and IDs only)
frame/info.json agent `info` (SteamOS build, Lepton, Proton, installed games, kernel keys)
frame/host.json OpenXR runtime + explicit layers, podman containers, containers.conf, uptime
frame/XRService-*.log the Frame's OpenXR runtime (SteamVR XRService) log, newest
@@ -57,6 +60,12 @@ after the logs were cut); OpenGL ES (no index): the newest modules (by time writ
the kernel's `hangcheck detected gpu lockup` line (this-boot-kernel.txt). All modules stay in the game's storage on
the Frame (`Android/data/<pkg>/files/fp_vk_shaders/`, Files tab → the game's storage).
Display queries run only while collecting logs. The Windows event query has a five-second timeout and runs without
a console window; the result distinguishes no matching events from a failed or timed-out query. Window visibility,
focus and completed move/resize events are recorded in `app.log`, with UTC timestamps for comparison with Windows
events. There is no display polling loop or change to display settings. Empty System event results do not rule out
a rendering or monitor-link problem.
## Redaction
`diag/redact.py` runs on every file and on the issue text.
- **Exact values:** the data dir → `<data>`, home dirs → `<home>`, user names → `<user>`, PC and Frame host names and
+19
View File
@@ -84,7 +84,24 @@
- Display 72 Hz by default in tests. Head pose is only tracked while the headset is worn; otherwise flags 0x3 and
the session stays below FOCUSED.
## Lua native pointer compatibility
Frame's Android processes can map libraries above the lowest 2^47 bytes of address space. The verified xLua API 105
engine in BattleTalent 4.7 rejects those otherwise valid pointers in `lua_pushlightuserdata`. This was isolated using
the unchanged library from the source APK: a real allocation at `0x10000000` round-trips, but its own native `xlua_tag`
pointer above 47 bits raises `bad light userdata pointer` and exits. With `frame.luajit_pointers`, both round-trip
exactly. No game scripts or account checks are changed. This validates an address-layout compatibility requirement;
the APK was not run on an actual Quest during this investigation.
The patch is restricted to SHA-256 `6d5860e4a4f1cc2b05c600c63297ed467369739666edca79413fff4374ff2684`.
It redirects only the Lua engine's public API to a pinned full-pointer LuaJIT, keeping the original xLua bindings,
LuaSocket and game helpers. The patched build survives a 60 s headless launch and creates OpenXR swapchains.
Early frame-rectangle errors and backend login errors remain in the capture; this does not verify rendering or play.
An arbitrary `lj_err_throw` followed by Unity destructor failures does not prove this pointer issue: first capture the
Lua error text. `Unknown Platform` also appears after this crash is fixed, so it was not the fatal cause here.
## Graphics stack
- Explicit foveation injection probe (SteamOS 0.4.5, 20261007.6125817, Lepton 3.0.5, 2026-10-11):
Batman's normal Steam environment requested the FDM Vulkan layer but supplied no `FDM_DEBUG` flag.
Appending `debug` while preserving inherited flags showed `fdm_injection_CreateInstance: skipped`.
@@ -172,6 +189,8 @@
## Steam integration
- Non-Steam shortcut: binary `userdata/<id>/config/shortcuts.vdf`; appid = crc32(exe+title) | 0x80000000;
artwork in `config/grid/<appid>p.*` (portrait), `<appid>.*` (landscape), `<appid>_hero.*`, `<appid>_logo.*`.
KeyValues names are case-insensitive: Steam may save `Shortcuts` / `AppName` after a devkit registration
(GitHub #263). Agent v86 reads and updates keys case-insensitively, keeping Steam's spelling and existing appids.
Steam reads the file only at start → stop steam.service, edit, start (FramePort does it once per batch).
- Processes started from Steam (Konsole, SSH sessions?) share steam.service's cgroup: use `systemd-run --user`.
- SSH: `sshd` must be enabled (`sudo systemctl enable --now sshd`), which needs a user password (`passwd`).
+10 -2
View File
@@ -10,7 +10,9 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Accounting+ | Quest | ✅ Works | |
| AgeOfJoy | Quest | ✅ Works | |
| AllInOneSports | Quest | ✅ Works | |
| Arkio | Quest | ✅ Works | |
| Asgard's Wrath 2 | Quest | ✅ Works | |
| Assassin's Creed Nexus | Quest | ✅ Works | |
| Audica | Quest | ✅ Works | |
| BAM | Quest | ✅ Works | |
| BARTENDER VR SIMULATOR | Quest | ✅ Works | |
@@ -28,6 +30,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Carve Snowboarding | Quest | ✅ Works | |
| Caves | Quest | ✅ Works | |
| Clockwork | Quest | ✅ Works | |
| COMPOUND | Quest | ✅ Works | |
| Cook-Out | Quest | ✅ Works | |
| Cosmodread | Quest | ✅ Works | |
| Creed | Quest | ✅ Works | |
@@ -40,12 +43,14 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Down the Rabbit Hole | Quest | ✅ Works | |
| Drunkn Bar Fight | Quest | ✅ Works | |
| Drunkn Bar Fight 2 - Early Access | Quest | ✅ Works | |
| Eleven Table Tennis | Quest | ✅ Works | |
| Eleven: Table Tennis VR | PC VR | ✅ Works | |
| Espire 2 | Quest | ✅ Works | |
| FrenzyVR | Quest | ✅ Works | |
| Fruit Ninja | Quest | ✅ Works | |
| Fruit Salon | Quest | ✅ Works | |
| Genotype | Quest | ✅ Works | |
| Ghostbusters: Rise of the Ghost Lord | Quest | ✅ Works | |
| GORN2 | Quest | ✅ Works | |
| Gravity Lab | Quest | ✅ Works | |
| Gunjack | Quest | ✅ Works | |
@@ -69,6 +74,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Medieval Dynasty New Settlement | Quest | ✅ Works | |
| Metro Awakening | Quest | ✅ Works | |
| Mobile Suit Gundam: Silver Phantom | Quest | ✅ Works | |
| Moss: Book II | Quest | ✅ Works | |
| Munkr Funkr (The Legacy Days) | Quest | ✅ Works | |
| Nano | Quest | ✅ Works | |
| NEX Player | Quest | ✅ Works | |
@@ -82,6 +88,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| PowerWash Simulator VR | Quest | ✅ Works | |
| QuestCraft | Quest | ✅ Works | |
| Racket: Nx | Quest | ✅ Works | |
| RacketPinball | Quest | ✅ Works | |
| RC Pilot Trainer | Quest | ✅ Works | |
| Retronika | Quest | ✅ Works | |
| Retropolis | Quest | ✅ Works | |
@@ -90,6 +97,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Riven | Quest | ✅ Works | |
| Robo Recall | Quest | ✅ Works | |
| RUINSMAGUS | Quest | ✅ Works | |
| Shoot The Zombirds VR | Quest | ✅ Works | |
| Shores of Loci | Quest | ✅ Works | |
| SilentSlayer | Quest | ✅ Works | |
| Sniper Elite VR | Quest | ✅ Works | |
@@ -130,7 +138,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| AFFECTED: The Manor | Quest | ⚠️ Works with issues | Gear VR game (32-bit only), runs through FramePort's 32-bit bridge (experimental). |
| Ancient_Dungeon | Quest | ⚠️ Works with issues | Multiplayer isn't available (offline). |
| 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. |
| Does it Stack? | Quest | ⚠️ Works with issues | Mixed reality mode does not work (works on Demeo), everything else seems to be OK |
| Dungeons of Eternity | Quest | ⚠️ Works with issues | It can not connect to servers |
| End Space | Quest | ⚠️ Works with issues | Controls stop responding after the first mission loads. |
@@ -138,9 +145,11 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Fisherman | Quest | ⚠️ Works with issues | Loading screens have corruption, but everything works otherwise. |
| Freedom | Quest | ⚠️ Works with issues | Volume can't be changed in the game. |
| MacacoVR | Quest | ⚠️ Works with issues | 2d loading screens are glitchy, otherwise main gameplay is fine |
| Majora's Mask VR | Quest | ⚠️ Works with issues | Audio on the file select screen sounds crunchy. |
| 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). |
| Pinball FX VR | Quest | ⚠️ Works with issues | Plays; mixed reality mode not working yet. |
| Plezy | Quest | ⚠️ Works with issues | Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding. |
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
| Spunky Dungeon | Quest | ⚠️ Works with issues | Gear VR / Oculus Go game (32-bit only), runs through FramePort's 32-bit bridge (experimental). |
| Swarm 2 | Quest | ⚠️ Works with issues | MSAA creates visible seams around edge geometry and also the skybox edge |
@@ -149,7 +158,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Time Stall | Quest | ⚠️ Works with issues | Both eyes distort during movement (unresolved). |
| Vermillion | Quest | ⚠️ Works with issues | Runs. |
| WiiCompiled VR | Quest | ⚠️ Works with issues | To add a game: in FramePort's Files tab, upload your .wcgame file to this game's storage, folder Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR… |
| Eleven Table Tennis | Quest | ❌ Doesn't run | Stops at the splash screen. |
| Espire 1: VR Operative (Quest Edition) | Quest | ❌ Doesn't run | Mesa GL driver crash during texture upload. |
| GOLF+ | Quest | ❌ Doesn't run | Black screen. |
| HITMAN 3 VR: Reloaded | Quest | ❌ Doesn't run | Vulkan driver crash (freedreno), even without Valve layers. |
+3
View File
@@ -19,6 +19,9 @@ On first start FramePort downloads the tools it uses (Settings → Tools shows t
## First launch
Starting FramePort again brings its existing window to the front. If a previous instance was left running after
its window closed, FramePort recovers it automatically, including on Windows where the GUI lock cannot be read.
FramePort isn't signed with a paid certificate, so the first start shows a warning:
- **Windows:** "Windows protected your PC" → **More info** → **Run anyway**.
+113 -39
View File
@@ -2,7 +2,7 @@
# Patches
Every patch and game setting FramePort knows: 164 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
Every patch and game setting FramePort knows: 169 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
Each entry says what the patch does, whether it changes the APK or only the install, when FramePort suggests it, its options, the catalog games whose recipe names it and the launch-test signatures that suggest it. Patches that are on by default are on for every game, so the catalog rarely names them.
@@ -11,8 +11,8 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
## Groups
- [Patches from OVRPort](#group-overport) (25)
- [Steam Frame patches](#group-frame) (52)
- [Game settings](#group-adapter) (66)
- [Steam Frame patches](#group-frame) (55)
- [Game settings](#group-adapter) (68)
- [Files and settings on the Frame](#group-device) (8)
- [PC VR (Proton and Revive)](#group-pcvr) (13)
@@ -30,7 +30,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Disable application space warp if used](#patch_disable_space_warp) | `patch_disable_space_warp` | Patches from OVRPort | 3 |
| [Fix minimal Android SDK](#patch_fix_min_android_sdk) | `patch_fix_min_android_sdk` | Patches from OVRPort | default |
| [Fix UE4 crash with Unity stub](#patch_fix_unreal_crash) | `patch_fix_unreal_crash` | Patches from OVRPort | default |
| [Force enable passthrough](#patch_force_passthrough) | `patch_force_passthrough` | Patches from OVRPort | 2 |
| [Force enable passthrough](#patch_force_passthrough) | `patch_force_passthrough` | Patches from OVRPort | 3 |
| [Generate OVRPort config](#patch_generate_config) | `patch_generate_config` | Patches from OVRPort | default |
| [Fix launcher icon entry](#patch_launcher_entry) | `patch_launcher_entry` | Patches from OVRPort | default |
| [Mark application to allow backup](#patch_mark_allow_backup) | `patch_mark_allow_backup` | Patches from OVRPort | default |
@@ -39,15 +39,16 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Patch Oculus detection for Unity](#patch_oculus_unity) | `patch_oculus_unity` | Patches from OVRPort | default |
| [Patch Oculus detection for Unreal](#patch_oculus_unreal) | `patch_oculus_unreal` | Patches from OVRPort | default |
| [Remove localized app names](#patch_remove_localized_names) | `patch_remove_localized_names` | Patches from OVRPort | default |
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 33 |
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 35 |
| [Remove uses-library](#patch_remove_uses_library) | `patch_remove_uses_library` | Patches from OVRPort | default |
| [Remove VrApi library](#patch_remove_vrapi) | `patch_remove_vrapi` | Patches from OVRPort | |
| [Replace application label and icon](#patch_replace_icon_label) | `patch_replace_icon_label` | Patches from OVRPort | default |
| [Pico/YVR/Quest metadata](#patch_vr_metadata) | `patch_vr_metadata` | Patches from OVRPort | default |
| [VrApi → OpenXR adapter (OVRPort)](#patch_vrapi_openxr) | `patch_vrapi_openxr` | Patches from OVRPort | |
| [32-bit ARM bridge (Gear VR / Go games)](#frame-a32_bridge) | `frame.a32_bridge` | Steam Frame patches | 4 |
| [32-bit ARM bridge (Gear VR / Go games)](#frame-a32_bridge) | `frame.a32_bridge` | Steam Frame patches | 6 |
| [FrameBridge OpenXR adapter](#frame-adapter) | `frame.adapter` | Steam Frame patches | default |
| [Content files from the game's data](#frame-asset_files) | `frame.asset_files` | Steam Frame patches | 1 |
| [Asset-file service bypass](#frame-asset_files_stub) | `frame.asset_files_stub` | Steam Frame patches | |
| [Skip Meta avatars](#frame-avatar_stub) | `frame.avatar_stub` | Steam Frame patches | 1 |
| [bHaptics: skip the Bluetooth permission request](#frame-bhaptics_permission) | `frame.bhaptics_permission` | Steam Frame patches | |
| [BlazeRush: controllers without Meta avatars, own shader cache](#frame-blazerush) | `frame.blazerush` | Steam Frame patches | 1 |
@@ -55,11 +56,12 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [OpenGL ES: multiview shaders on flat panels (HUD, menus)](#frame-gl_multiview_fbo) | `frame.gl_multiview_fbo` | Steam Frame patches | 1 |
| [GL shim (Mesa GLSL compatibility)](#frame-gl_shim) | `frame.gl_shim` | Steam Frame patches | 2 |
| [Godot apps: start without a clipboard service](#frame-godot_clipboard) | `frame.godot_clipboard` | Steam Frame patches | |
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 6 |
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 9 |
| [Language packs from the game's files](#frame-langpacks) | `frame.langpacks` | Steam Frame patches | |
| [Launcher category (INFO → LAUNCHER)](#frame-launcher) | `frame.launcher` | Steam Frame patches | default |
| [GL wrapper: OpenGL ES depth formats (Minecraft)](#frame-ltw_depth) | `frame.ltw_depth` | Steam Frame patches | default |
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 1 |
| [Fix Lua startup crashes on Frame](#frame-luajit_pointers) | `frame.luajit_pointers` | Steam Frame patches | |
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 2 |
| [Meta XR Audio: skip Quest telemetry class](#frame-metaxr_telemetry) | `frame.metaxr_telemetry` | Steam Frame patches | default |
| [Clear android:debuggable](#frame-nodebug) | `frame.nodebug` | Steam Frame patches | 3 |
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 15 |
@@ -69,26 +71,27 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Platform compat (ovrMessageType_ToString)](#frame-ovrplatformcompat) | `frame.ovrplatformcompat` | Steam Frame patches | default |
| [Stub missing Meta platform functions](#frame-ovrstubs) | `frame.ovrstubs` | Steam Frame patches | default |
| [Unpaired pointer-authentication checks](#frame-pac_hints) | `frame.pac_hints` | Steam Frame patches | 1 |
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 3 |
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 4 |
| [Stress Level Zero: let Unity start Vulkan itself](#frame-slz_vulkan_hooks) | `frame.slz_vulkan_hooks` | Steam Frame patches | 1 |
| [Start straight in VR (skip the 2D launcher)](#frame-start_activity) | `frame.start_activity` | Steam Frame patches | |
| [Allow swapchains larger than 4096 px](#frame-swapchain_limit) | `frame.swapchain_limit` | Steam Frame patches | default |
| [Team Beef ports: treat the Frame as a Meta headset](#frame-tbxr_vendor) | `frame.tbxr_vendor` | Steam Frame patches | 1 |
| [GL shim for Unity (no multisampled render-to-texture)](#frame-unity_gl_shim) | `frame.unity_gl_shim` | Steam Frame patches | 17 |
| [GL shim for Unity (no multisampled render-to-texture)](#frame-unity_gl_shim) | `frame.unity_gl_shim` | Steam Frame patches | 19 |
| [Unity: render each eye separately (no multiview)](#frame-unity_multipass) | `frame.unity_multipass` | Steam Frame patches | 1 |
| [Unity: disable MSAA](#frame-unity_no_msaa) | `frame.unity_no_msaa` | Steam Frame patches | 7 |
| [Unity: skip OVROverlay layers (fades, splash screens)](#frame-unity_no_overlay_copy) | `frame.unity_no_overlay_copy` | Steam Frame patches | 2 |
| [Unity: start VR without Meta's system apps](#frame-unity_oculus_check) | `frame.unity_oculus_check` | Steam Frame patches | 9 |
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 22 |
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 69 |
| [Unity: start VR without Meta's system apps](#frame-unity_oculus_check) | `frame.unity_oculus_check` | Steam Frame patches | 10 |
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 24 |
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 73 |
| [Unity: share thread keys (games that run out of them)](#frame-unity_tls_key_pool) | `frame.unity_tls_key_pool` | Steam Frame patches | 1 |
| [Unity: headset always counts as worn](#frame-unity_user_presence) | `frame.unity_user_presence` | Steam Frame patches | 1 |
| [GL shim for Unreal (no multisampled render-to-texture)](#frame-unreal_gl_shim) | `frame.unreal_gl_shim` | Steam Frame patches | 1 |
| [Unreal: match multiview depth to the eye size](#frame-unreal_multiview_depth) | `frame.unreal_multiview_depth` | Steam Frame patches | 1 |
| [Unreal: stand-ins for OVRPlugin functions OVRPort lacks](#frame-unreal_ovrp_entrypoints) | `frame.unreal_ovrp_entrypoints` | Steam Frame patches | 1 |
| [Unreal: stand-ins for OVRPlugin functions OVRPort lacks](#frame-unreal_ovrp_entrypoints) | `frame.unreal_ovrp_entrypoints` | Steam Frame patches | 2 |
| [Unreal: Quest controller bindings (RPOC key map)](#frame-unreal_quest_keymap) | `frame.unreal_quest_keymap` | Steam Frame patches | 3 |
| [Unreal: don't wait for the Quest shader precompile](#frame-unreal_quest_precompile) | `frame.unreal_quest_precompile` | Steam Frame patches | 3 |
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 3 |
| [Unreal: skip the OBB delivery check](#frame-unreal_skip_obb_delivery) | `frame.unreal_skip_obb_delivery` | Steam Frame patches | |
| [Unreal: bypass the stalled loading splash](#frame-unreal_splash_bypass) | `frame.unreal_splash_bypass` | Steam Frame patches | 1 |
| [Unreal: thumb follows the capacitive touches](#frame-unreal_thumb_touch) | `frame.unreal_thumb_touch` | Steam Frame patches | 7 |
| [Vermillion: paint canvases in the scene](#frame-vermillion_scene_canvas) | `frame.vermillion_scene_canvas` | Steam Frame patches | 1 |
@@ -111,18 +114,20 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [360° sharpness](#adapter-equirect_res) | `adapter.equirect_res` | Game settings | |
| [360° 3D](#adapter-equirect_stereo) | `adapter.equirect_stereo` | Game settings | |
| [Per-eye diagnostics (log)](#adapter-eye_debug) | `adapter.eye_debug` | Game settings | |
| [Draw fades over the picture](#adapter-fade_quad) | `adapter.fade_quad` | Game settings | |
| [Draw fades over the picture](#adapter-fade_quad) | `adapter.fade_quad` | Game settings | 2 |
| [Upside-down panel images (diagnostics)](#adapter-flip_dump) | `adapter.flip_dump` | Game settings | |
| [Fix upside-down menus](#adapter-flip_emul) | `adapter.flip_emul` | Game settings | |
| [Rotate panels 180°](#adapter-flip_quads) | `adapter.flip_quads` | Game settings | |
| [Don't pause on short interruptions](#adapter-focus_hold) | `adapter.focus_hold` | Game settings | 4 |
| [Ignore focus dips up to](#adapter-focus_hold_ms) | `adapter.focus_hold_ms` | Game settings | 2 |
| [Hide Quest foveation](#adapter-foveation_fix) | `adapter.foveation_fix` | Game settings | |
| [Clear the game's GL errors (GLES)](#adapter-gl_error_clear) | `adapter.gl_error_clear` | Game settings | 3 |
| [Hide fast anti-aliasing (OpenGL ES)](#adapter-gl_hide_msrtt) | `adapter.gl_hide_msrtt` | Game settings | 1 |
| [Draw each eye separately (OpenGL ES)](#adapter-gl_hide_multiview) | `adapter.gl_hide_multiview` | Game settings | 1 |
| [GL shim: high-precision vertex shaders](#adapter-gl_highp_markers) | `adapter.gl_highp_markers` | Game settings | 1 |
| [GL shim: integer vertex inputs](#adapter-gl_int_attribs) | `adapter.gl_int_attribs` | Game settings | 1 |
| [Flat panel diagnostics (log)](#adapter-gl_mv_debug) | `adapter.gl_mv_debug` | Game settings | |
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 60 |
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 66 |
| [Vibration strength](#adapter-haptic_scale) | `adapter.haptic_scale` | Game settings | |
| [Turn off space warp](#adapter-hide_space_warp) | `adapter.hide_space_warp` | Game settings | 4 |
| [Controller input diagnostics (log)](#adapter-input_diag) | `adapter.input_diag` | Game settings | |
@@ -138,12 +143,12 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Treat newer Quest controllers as Touch](#adapter-profile_remap) | `adapter.profile_remap` | Game settings | |
| [Animate fingers from touch](#adapter-proximity_emul) | `adapter.proximity_emul` | Game settings | |
| [Keep pictures in bounds](#adapter-rect_clamp) | `adapter.rect_clamp` | Game settings | |
| [Refresh rate](#adapter-refresh_rate) | `adapter.refresh_rate` | Game settings | 2 |
| [Refresh rate](#adapter-refresh_rate) | `adapter.refresh_rate` | Game settings | 3 |
| [Wait for the game's GPU](#adapter-release_wait) | `adapter.release_wait` | Game settings | |
| [Re-create play space](#adapter-respace_kick) | `adapter.respace_kick` | Game settings | |
| [Sharpness](#adapter-scale) | `adapter.scale` | Game settings | 4 |
| [Room depth](#adapter-scene_depth) | `adapter.scene_depth` | Game settings | |
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 2 |
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 4 |
| [Room height](#adapter-scene_height) | `adapter.scene_height` | Game settings | |
| [Room width](#adapter-scene_width) | `adapter.scene_width` | Game settings | |
| [Eye-image snapshots (diagnostics)](#adapter-snapshot) | `adapter.snapshot` | Game settings | |
@@ -170,7 +175,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Hide Android's navigation bar](#device-hide_navbar) | `device.hide_navbar` | Files and settings on the Frame | |
| [Extra Lepton settings](#device-lepton_env) | `device.lepton_env` | Files and settings on the Frame | 3 |
| [Controllers as a gamepad (Steam Input)](#device-steam_gamepad) | `device.steam_gamepad` | Files and settings on the Frame | |
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 71 |
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 76 |
| [Launch options (SteamVR or OpenXR)](#pcvr-launch_args) | `pcvr.launch_args` | PC VR (Proton and Revive) | 2 |
| [Provide the Oculus runtime from Revive (Frame)](#pcvr-libovr_redirect) | `pcvr.libovr_redirect` | PC VR (Proton and Revive) | |
| [No Unreal crash reporter](#pcvr-no_crash_reporter) | `pcvr.no_crash_reporter` | PC VR (Proton and Revive) | |
@@ -313,7 +318,7 @@ Lets mixed-reality games start even though they insist on a camera view of your
- `patch_force_passthrough`
- **Changes:** Changes the APK (during the OVRPort conversion).
- **Suggested (on):** Mixed-reality-only game (passthrough required, no guardian): force passthrough on.
- **Catalog games (2):** BAM, Demeter.
- **Catalog games (3):** Arkio, BAM, Demeter.
- **Suggested by launch-test signatures:**
- `passthrough-missing` (error): The game needs Meta passthrough (the camera view), which the Frame lacks; the adapter emulates it.
- **Source:** [patches/overport.py](../src/frameport/patches/overport.py)
@@ -415,7 +420,7 @@ Stops some Unreal games from closing themselves right after starting.
- `patch_remove_unreal_force_quit`
- **Changes:** Changes the APK (during the OVRPort conversion).
- **Offered (off):** Built as the alternate APK for Unreal games (use it if the game quits itself).
- **Catalog games (33):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2, ExploreVR and 25 more.
- **Catalog games (35):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2, ExploreVR and 27 more.
- **Suggested by launch-test signatures:**
- `force-quit` (error): The game quit itself right after starting.
- **Source:** [patches/overport.py](../src/frameport/patches/overport.py)
@@ -487,10 +492,10 @@ FramePort's own changes to a converted Quest game, for what the Steam Frame lack
Runs a 32-bit game (Gear VR, Oculus Go, early Quest) on the Frame, which only runs 64-bit code. Experimental.
- `frame.a32_bridge` · experimental
- `frame.a32_bridge` · experimental · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** 32-bit-only game: the Frame can't run 32-bit ARM code, the bridge translates it.
- **Catalog games (4):** AFFECTED: The Manor, Gunjack, Sports Scramble (Santa Cruz), Spunky Dungeon.
- **Catalog games (6):** AFFECTED: The Manor, Gunjack, Journey of the Gods, Shadow Point, Sports Scramble (Santa Cruz), Spunky Dungeon.
- **Suggested by launch-test signatures:**
- `no-arm64` (fatal): 32-bit-only APK (e.g. a Gear VR or Oculus Go game).
- **Source:** [patches/frame/a32_bridge.py](../src/frameport/patches/frame/a32_bridge.py)
@@ -525,6 +530,19 @@ Lets the game find extra content it keeps next to its data (for example Star War
> Some games keep content in separate files next to their OBB (for example Star Wars: Tales from the Galaxy's Edge: its seasons and sound banks) and ask Meta's platform for them as asset files. OVRPort's platform loader doesn't know them, so the game waits for content that never arrives (black screen after loading). Lists the content files shipped with the game's data (*.pak) as installed and answers their download request at once with the file's path; nothing is copied or downloaded. Shares the language-pack library.
### <a id="frame-asset_files_stub"></a>Asset-file service bypass
Experimental bypass for a failed Meta asset-file check at startup. Reports an empty asset list and installed status without a file path; does not provide DLC. Experimental.
- `frame.asset_files_stub` · experimental
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
- **Suggested by launch-test signatures:**
- `meta-asset-status-error` (error): A DLC manager reports a Meta asset-file status error after VR starts (e.g.
- **Source:** [patches/frame/langpack.py](../src/frameport/patches/frame/langpack.py)
> Experimental bypass for a DLC manager blocked on Meta asset-file service errors at startup (e.g. PixelRipped 1995: Asset status error (code 100)). Returns an empty successful asset list and reports requested asset IDs as installed, with an empty file path. Does not supply DLC or change download requests. Enable only to test this startup failure; disable with FRAMEPORT_ASSET_FILES_STUB=0. Shares the language-pack library and needs no language packs.
### <a id="frame-avatar_stub"></a>Skip Meta avatars
Lets games start that would stop at Meta's avatars (for example BlazeRush). No Meta avatars.
@@ -625,7 +643,7 @@ Plays the game's videos on the Frame's video hardware, so 4K and 8K video runs s
- `frame.hw_video_decode` · also for 2D apps
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
- **Suggested (on):** The game plays video through Android's decoders (MediaCodec, ExoPlayer or VLC): decode it on the Frame's hardware.
- **Catalog games (6):** 4XVR Video Player, Batman: Arkham Shadow, Dance Central, Fruit Ninja, Kill It With Fire VR, MacacoVR.
- **Catalog games (9):** 4XVR Video Player, Batman: Arkham Shadow, Dance Central, Fruit Ninja, Ghostbusters: Rise of the Ghost Lord, Kill It With Fire VR, MacacoVR, Moss: Book II and 1 more.
- **Source:** [patches/frame/hw_video_decode.py](../src/frameport/patches/frame/hw_video_decode.py)
> Decodes the game's videos on the Frame's hardware video decoder instead of Android's software decoders, which stutter or fall behind with 4K/8K video (for example video players, Batman: Arkham Shadow's cutscenes). Adds FramePort's codec plugin (OMX.frameport.avc/hevc/vp9.decoder) to this game's container only; the APK isn't changed. When the hardware is busy (for example Steam's own hardware video decoding is on) the video is decoded in software as before.
@@ -666,6 +684,19 @@ Stops a black picture in Minecraft launchers (for example QuestCraft).
> Minecraft asks for desktop OpenGL's GL_DEPTH_COMPONENT32 depth textures. Some builds of LTW, the OpenGL-to-OpenGL ES wrapper in Minecraft launchers such as QuestCraft, pass that on unchanged; Quest's driver accepts it, the Frame's Mesa rejects it (GL_INVALID_OPERATION in glTexImage2D), so the depth buffer never exists and the picture stays black. LTW then loads OpenGL ES through a small shim that asks for GL_DEPTH_COMPONENT32F (or 24-bit) instead.
### <a id="frame-luajit_pointers"></a>Fix Lua startup crashes on Frame
Lets games with a verified older Lua engine get past a startup crash on the Frame.
- `frame.luajit_pointers`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** The bundled Lua engine rejects Frame's wider native pointers; use the compatible full-pointer engine while retaining the game's bindings.
- **Suggested by launch-test signatures:**
- `lua-lightuserdata-pointer` (fatal): The Lua runtime rejected a pointer outside its supported address range.
- **Source:** [patches/frame/luajit_pointers.py](../src/frameport/patches/frame/luajit_pointers.py)
> Older xLua/LuaJIT engines reject native pointers outside their 47-bit range, causing a startup error and sometimes a second crash during Unity shutdown (e.g. BattleTalent). Uses a Lua engine that preserves full pointer values while keeping the game's original xLua bindings and native helpers. Applied only to the verified compatible library build.
### <a id="frame-meta_permissions"></a>Declare Meta-only permissions
Declares Meta's permissions some mixed-reality games ask for, so they don't stop.
@@ -673,7 +704,7 @@ Declares Meta's permissions some mixed-reality games ask for, so they don't stop
- `frame.meta_permissions`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Mixed-reality game that needs the room model: its 'use spatial data' permission must be granted (for example Demeter).
- **Catalog games (1):** Demeter.
- **Catalog games (2):** Arkio, Demeter.
- **Source:** [patches/frame/manifest_fixes.py](../src/frameport/patches/frame/manifest_fixes.py)
> Declares com.oculus.permission.* / horizonos.permission.* that the app uses, so Android grants them at install (for example the 'use spatial data' permission of mixed-reality games like Demeter).
@@ -805,7 +836,7 @@ Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboar
- `frame.sdl_clipboard` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** SDL app: it crashes at start in Lepton, which has no clipboard service.
- **Catalog games (3):** Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
- **Catalog games (4):** Majora's Mask VR, Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
- **Suggested by launch-test signatures:**
- `sdl-no-clipboard` (fatal): The app crashed at start because the Frame's Android has no clipboard.
- **Source:** [patches/frame/sdl_clipboard.py](../src/frameport/patches/frame/sdl_clipboard.py)
@@ -856,13 +887,13 @@ Allows very large pictures (8K video, theaters) instead of quitting.
Lets Team Beef ports (for example Lambda1VR) start on the Frame.
- `frame.tbxr_vendor`
- `frame.tbxr_vendor` · revision 2
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Team Beef port: it picks its OpenXR setup by headset maker, which the Frame reports as "valve".
- **Catalog games (1):** Lambda1VR.
- **Source:** [patches/frame/tbxr_vendor.py](../src/frameport/patches/frame/tbxr_vendor.py)
> Team Beef's ports (for example Lambda1VR, RTCWQuest) load an OpenXR library named after the headset maker and pick their VR setup by maker; for "valve" they stop at start (libopenxr_loader_valve.so not found) or take the Pico path. Adds that library and lets them take the Meta path.
> Team Beef's ports (for example Lambda1VR, RTCWQuest) load an OpenXR library named after the headset maker and pick their VR setup by maker; for "valve" they stop at start (libopenxr_loader_valve.so not found) or take the Pico path. Adds that library and lets them take the Meta path. Keeps GL4ES in charge of its desktop OpenGL draw calls so animated models don't turn into stretched triangles.
### <a id="frame-unity_gl_shim"></a>GL shim for Unity (no multisampled render-to-texture)
@@ -871,7 +902,7 @@ Stops a gray or frozen screen in Unity games that turn on anti-aliasing while ru
- `frame.unity_gl_shim`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unity GLES game that may switch to MSAA at runtime: hide multisampled render-to-texture, which crashes the Frame's GL driver (for example The Room VR).
- **Catalog games (17):** Audica, BattleSisters, Beat Saber, Clockwork, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 9 more.
- **Catalog games (19):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 11 more.
- **Suggested by launch-test signatures:**
- `unreal-msrtt-crash` (fatal): The game's graphics crashed a few seconds after start.
- **Source:** [patches/frame/vrapi_bridge.py](../src/frameport/patches/frame/vrapi_bridge.py)
@@ -926,7 +957,7 @@ Lets older Unity games start VR without Meta's system apps (they'd stay on a 2D
- `frame.unity_oculus_check` · revision 5
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unity with built-in Oculus support: it checks for Meta's system apps before starting VR, else it runs as a 2D app (for example Accounting+, BattleSisters).
- **Catalog games (9):** Accounting+, Audica, BattleSisters, Cosmodread, End Space, Jurassic World Aftermath Collection, Sniper Elite VR, SUPERHOT VR and 1 more.
- **Catalog games (10):** Accounting+, Audica, BattleSisters, Cosmodread, End Space, Jurassic World Aftermath Collection, Shoot The Zombirds VR, Sniper Elite VR and 2 more.
- **Suggested by launch-test signatures:**
- `unity-vr-device-none` (fatal): Unity didn't start VR, so the game runs as a hidden 2D app.
- **Source:** [patches/frame/unity_oculus_check.py](../src/frameport/patches/frame/unity_oculus_check.py)
@@ -940,7 +971,7 @@ Keeps a smoothing setting off that the game turns on itself and that can freeze
- `frame.unity_runtime_msaa_off` · revision 2
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled render-to-texture can hang the Frame's GPU.
- **Catalog games (22):** Audica, BattleSisters, Beat Saber, Clockwork, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 14 more.
- **Catalog games (24):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 16 more.
- **Suggested by launch-test signatures:**
- `unity-render-crash` (fatal): The game's graphics crashed, so the picture freezes or stays gray.
- `unity-runtime-msaa` (info): The game turns MSAA on at runtime, which can hang the Frame's GPU.
@@ -956,7 +987,7 @@ Lets you type into this app's text fields on the Frame (they'd close at once).
- `frame.unity_text_input` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unity app with text fields (…): they close at once on the Frame because it has no system keyboard.
- **Catalog games (69):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Audica, BattleSisters, Beat Saber, Beat Saber (co-existence build), Blade & Sorcery: Nomad and 61 more.
- **Catalog games (73):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, BattleSisters, Beat Saber, Beat Saber (co-existence build) and 65 more.
- **Source:** [patches/frame/unity_text_input.py](../src/frameport/patches/frame/unity_text_input.py)
> 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 -&gt; false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -&gt; 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.
@@ -1022,7 +1053,7 @@ Lets older Unreal games start VR on the Frame (for example Star Wars Pinball VR)
- `frame.unreal_ovrp_entrypoints`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unreal looks up OVRPlugin function(s) OVRPort's OVRPlugin lacks: ……. Without them Unreal starts no VR.
- **Catalog games (1):** Star Wars Pinball VR.
- **Catalog games (2):** Ghostbusters: Rise of the Ghost Lord, Star Wars Pinball VR.
- **Source:** [patches/frame/unreal_ovrp.py](../src/frameport/patches/frame/unreal_ovrp.py)
> Unreal's Oculus module looks up every OVRPlugin function it was built against and starts no VR when one is missing; OVRPort's OpenXR OVRPlugin lacks some old ones (for example ovrp_GetPTWNear, which Unreal 4.25 builds like Star Wars Pinball VR look up). The game then never starts an OpenXR session and crashes a few seconds in. Adds a generated library to libOVRPlugin.so with a stand-in for each missing function that reports failure (ovrpFailure), so Unreal's Oculus support starts. The functions OVRPort has are untouched.
@@ -1065,6 +1096,19 @@ Starts Unreal games without re-checking their data file (they'd seem to hang on
> Unreal games can check their whole data file (.obb) before starting (bVerifyOBBOnStartUp). On the Frame that check's progress and error screens are invisible (Lepton runs VR games without an Android window), so the game seems to hang on its DownloaderActivity, for example Contractors. This turns the check off in the manifest; Unreal still checks that the OBB is there with the right size, so a missing or wrong-version OBB still stops the game.
### <a id="frame-unreal_skip_obb_delivery"></a>Unreal: skip the OBB delivery check
Skips a separate data-file check when an Unreal game still hangs at startup with the content check off. Experimental; the game still needs its data.
- `frame.unreal_skip_obb_delivery` · experimental · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
- **Suggested by launch-test signatures:**
- `unreal-obb-check-stuck` (fatal): The game stays on Unreal's OBB check screen (DownloaderActivity), which the Frame doesn't show, and never starts VR (for example Contractors, GitHub #159).
- **Source:** [patches/frame/unreal_obb_check.py](../src/frameport/patches/frame/unreal_obb_check.py)
> Unreal's Java OBB delivery check can leave a game on an invisible DownloaderActivity screen even with the manifest CRC check off (e.g. Into The Radius). Makes known Unreal expansionFilesDelivered methods return true, bypassing the Java name and size gate. Experimental: the native engine still needs complete, matching, readable OBB data.
### <a id="frame-unreal_splash_bypass"></a>Unreal: bypass the stalled loading splash
Lets the game finish loading when its separate loading splash stalls the render thread.
@@ -1393,10 +1437,25 @@ For games whose fades to black don't show, so you see the level loading (for exa
- **Options:**
- Value: int, default `0`.
- Game settings → Picture → "Draw fades over the picture" (advanced).
- **Catalog games (2):** Batman: Arkham Shadow, Marvel's Iron Man VR.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Games that fade to black by dimming their picture with a layer color scale (XR_KHR_composition_layer_color_scale_bias, for example Batman: Arkham Shadow's loading screens) can show the level loading instead of black on the Frame. FrameBridge then draws the fade itself: a black quad in front of the eyes whose opacity follows the game's dimming. 2 also removes the game's own dimming (if both show, the fade is doubled).
### <a id="adapter-flip_dump"></a>Upside-down panel images (diagnostics)
Saves a few UI panels before and after FrameBridge turns them upright, for testing without the headset.
- `adapter.flip_dump`
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
- **Options:**
- Value: int, default `0`.
- Game settings → Troubleshooting → "Upside-down panel images (diagnostics)" (advanced).
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Saves the first N UI panels that FrameBridge turns upright (Vulkan games whose panels are stored upside down), before and after the flip, as fb_flip_&lt;n&gt;_raw.ppm / fb_flip_&lt;n&gt;_upright.ppm in the game's files folder on the Frame. Shows what the runtime gets without anyone wearing the headset.
### <a id="adapter-flip_emul"></a>Fix upside-down menus
Turns menus and text the right way up.
@@ -1471,6 +1530,21 @@ Hides a Quest-only way of saving GPU power that the Frame lacks. Leave it on.
> Hides Quest foveation extensions the Frame runtime lacks.
### <a id="adapter-gl_error_clear"></a>Clear the game's GL errors (GLES)
For OpenGL ES games whose memory use keeps growing (e.g. Vader Immortal).
- `adapter.gl_error_clear`
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
- **Options:**
- Value: int, default `0`.
- Game settings → Troubleshooting → "Clear the game's GL errors (GLES)" (advanced).
- **Catalog games (3):** Vader Immortal: Episode I, Vader Immortal: Episode II, Vader Immortal: Episode III.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Clears OpenGL errors the game left pending before FrameBridge calls the Frame's runtime. The runtime's GL code fails on any pending error; after one such failure at the start it allocated GPU memory every frame without freeing it (e.g. Vader Immortal, ~20 MB a second).
### <a id="adapter-gl_hide_msrtt"></a>Hide fast anti-aliasing (OpenGL ES)
For Unity games that crash when they turn on anti-aliasing (for example The Room VR).
@@ -1555,7 +1629,7 @@ For games that freeze the Frame when a controller vibrates (for example Lucky's
- **Options:**
- Value: int, default `0`.
- Game settings → Controllers → "Fix freezes when controllers vibrate" (advanced).
- **Catalog games (60):** AgeOfJoy, Ancient_Dungeon, Audica, BattleSisters, Beat Saber, Caves, Clockwork, Cosmodread and 52 more.
- **Catalog games (66):** AgeOfJoy, Ancient_Dungeon, Audica, BattleSisters, Beat Saber, Caves, Clockwork, COMPOUND and 58 more.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the Frame when the game vibrates a controller (for example Lucky's Tale's save slots, GitHub #9). Needs a rebuild.
@@ -1790,7 +1864,7 @@ How often the picture updates. Higher is smoother if the game can keep up.
- **Options:**
- Value: float, default `0.0`.
- Game settings → Picture → "Refresh rate": Game's choice (`0`), 72 Hz (`72`), 80 Hz (`80`), 90 Hz (`90`), 96 Hz (`96`), 108 Hz (`108`), 120 Hz (`120`), 144 Hz (`144`).
- **Catalog games (2):** Does it Stack?, Vacation Simulator.
- **Catalog games (3):** Does it Stack?, Majora's Mask VR, Vacation Simulator.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Display refresh rate for this game (72, 80, 90, 96, 108, 120 or 144; 0 = the game's choice). A video's frame rate that divides the refresh rate plays smoothest (for example 30 fps at 90 Hz, 24 fps at 72 Hz).
@@ -1863,7 +1937,7 @@ Mixed-reality games that build their level from your room get a simple room with
- **Options:**
- Value: int, default `0`.
- Game settings → Mixed reality → "Pretend room layout".
- **Catalog games (2):** Demeter, Vermillion.
- **Catalog games (4):** Arkio, Demeter, Ghostbusters: Rise of the Ghost Lord, Vermillion.
- **Suggested by launch-test signatures:**
- `scene-missing` (warning): The game wants Meta's room data (scene API), which the Frame lacks.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
@@ -1929,7 +2003,7 @@ For games whose menus or screens jump to wherever you look.
### <a id="adapter-strip_color_bias"></a>Drop layer color fades
For games whose memory use keeps growing (for example Vader Immortal). Some fades no longer show.
Diagnostics: removes layer color fades. Some fades no longer show.
- `adapter.strip_color_bias`
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
@@ -1939,7 +2013,7 @@ For games whose memory use keeps growing (for example Vader Immortal). Some fade
- Game settings → Troubleshooting → "Drop layer color fades" (advanced).
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Removes the color scale/bias (XR_KHR_composition_layer_color_scale_bias) from the game's layers, so the runtime doesn't apply it. The Frame's runtime allocates GPU memory for it every frame without freeing it, for example Vader Immortal leaked ~20 MB/s on its loading screen. Fades done this way no longer show.
> Removes the color scale/bias (XR_KHR_composition_layer_color_scale_bias) from the game's layers, so the runtime doesn't apply it (diagnostics). Fades done this way no longer show.
### <a id="adapter-strip_depth"></a>Strip depth layers
@@ -2256,7 +2330,7 @@ Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard
- `device.text_input_window`
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
- **Suggested (on):** App with text fields: key presses only reach a shown Android window.
- **Catalog games (71):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Audica, Beat Saber, Beat Saber (co-existence build), Blade & Sorcery: Nomad and 63 more.
- **Catalog games (76):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, Beat Saber, Beat Saber (co-existence build) and 68 more.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> 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".
+12 -2
View File
@@ -13,12 +13,18 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
## Startup failures (visible in launch.log)
| Symptom | Cause | Fix |
|---|---|---|
| Startup exits through `libxlua.so (lj_err_throw)`, then Unity aborts in mutex cleanup; captured Lua error is `bad light userdata pointer` (GitHub #257, BattleTalent 4.7) | This older LuaJIT encodes lightuserdata in 47 bits; Frame supplies valid wider native pointers. Controlled tests with the unchanged library pass a real low-address allocation and fail its own high-address static pointer | `frame.luajit_pointers` automatically selects a full-pointer Lua engine only for the verified library SHA-256, retaining xLua bindings and custom helpers. The scoped patch reaches OpenXR startup for 60 s headless; rendering and gameplay remain unverified. A `lj_err_throw` cleanup stack without the original error message is insufficient to infer this cause |
| A GL4ES Team Beef game draws stretched triangles or spikes across characters and the scene (GitHub #191, Lambda1VR) | The newer GL shim exports GLES draw functions that intercept GL4ES's desktop OpenGL calls, bypassing its client-array and immediate-mode translation | `frame.tbxr_vendor` keeps the shim's five draw symbols private when the APK bundles `libgl4es.so`. GL queries and single-sampled framebuffer fixes remain active; other games retain the draw hooks. A/B eye captures on SteamOS 0.4.5: old shim normal, current shim spikes, current shim with private draw symbols normal at ~72 fps. Worn-headset gameplay still needs confirmation |
| A Team Beef game reports a crash after manually quitting: `fdm_injection_xrDestroySwapchain` → `TBXR_LeaveVR` (GitHub #191, Lambda1VR) | Valve's foveation layer dereferences a null pointer during swapchain cleanup | Triage `tbxr-fdm-shutdown-crash` identifies this as a teardown crash, separate from startup and distorted geometry. No verified cleanup fix yet |
| Conversion fails with `NoSuchElementException: Sequence is empty` in `PatcherContext.appName` (GitHub #269, Accounting+) | OVRPort assumes an application label exists; some APKs only label their activity | FramePort retries that exact failure once using a temporary APK whose application inherits the activity's label; existing labels and the original APK are kept |
| Updating a flat Android app after saving its recipe fails with `no OVRPort patches selected` (GitHub #271, Plezy) | older FramePort loads the saved recipe without restoring its unchanged-install setting or applying the flat-app build rules, then tries an empty VR conversion | update FramePort: saved recipes keep `as_is`, and flat apps still skip OVRPort; existing catalog-derived recipes refresh at the next app update. Until then, enable "Install as is" for an app whose APK needs no fixes |
| A launch test suggests a patch (for example triage `sdl-no-clipboard` → `frame.sdl_clipboard`) but the game page doesn't show it / lists it as not applicable; or a game added long ago lacks warnings newer ones get (Android version, web wrapper, missing OBB) (GitHub #104, Dramatic Shape) | the library entry was analyzed by an older FramePort: the analysis field the patch reads (`sdl_java`, `min_sdk`, `web_wrapper`, `expects_obb`, …) is missing, so `applies()` is false | entries whose `analysis.extra.analysis_version` is below `detect.ANALYSIS_VERSION` are analyzed again at the GUI's start (background) and before a build (`pipeline.refresh_analyses`); by hand: game menu → "Analyze again", then Update on Frame. An unreadable APK is marked `analysis_failed` (retried after the next version bump). Adding an analysis field: bump `ANALYSIS_VERSION` |
| FDM layer loads but diagnostic logging reports `fdm_injection_CreateInstance: skipped` | Layer loading alone does not enable injection on the tested SteamOS 0.4.5 build; a skipped helper instance alone also does not establish game-wide inactivity | Opt-in `device.foveation` mode `tracked`: loads Valve's layer and sets `FDM_DEBUG=enable`. Batman probe reached foveated render passes with changing per-eye offsets. Validate graphics and matched gameplay GPU time; do not enable globally. Use `debug` only for brief diagnostics, preserving existing flags; never benchmark with debug spam. See FRAME_RUNTIME.md. |
| One eye (usually the right) shimmers or jitters in the 3D view while menus and panels look steady; pacing is clean (GitHub #69, e.g. RTCWQuest, Moss) | Valve's eye-tracked foveation layer (`VALVE_fdm_injection`) moves its low-density regions with each eye's gaze, and the right eye's gaze filter restarts often (`CEyePoseUKF R: Large dt … Bootstrapped` in `~/.local/share/Steam/logs/eyetracking.txt`) | Customize → "Eye-tracked foveation (Valve)" (`device.foveation`): **Fixed** (`FDM_DEBUG=disable_offsets`; verify injection remains active on the installed runtime) or **Off** (`VK_INSTANCE_LAYERS=""`); catalog field `foveation:` |
| A Unity OpenXR game closes right after the OpenXR instance is created: `F VrApiLoader: vrapi_SetPropertyInt was called before vrapi_Initialize()!`, SIGABRT on UnityMain (GitHub #62, Jurassic World Aftermath) | the APK still ships Meta's VrApi loader; OVRPlugin (1.89, on OpenXR) calls it without starting VrApi and Meta's loader aborts. The VrApi bridge can't replace it (39 of the loader's 114 functions: OVRPlugin wouldn't link) | `frame.vrapi_stub` (same exports, each returns 0); triage `vrapi-before-init` |
| A VrApi game exits at once: `UnsatisfiedLinkError … dlopen failed: cannot locate symbol "vrapi_PollEvent"` (GitHub #57, BlazeRush) | the engine imports a VrApi function the VrApi bridge didn't implement | the bridge has `vrapi_PollEvent`, `RecenterPose`, `SetDisplayRefreshRate`, `GetSystemPropertyFloatArray` (rebuild the game); the static check "VrApi functions resolvable" names any other missing one; triage `vrapi-symbol-missing` |
| A Team Beef port (Lambda1VR, RTCWQuest, …) exits at once: `UnsatisfiedLinkError: dlopen failed: library "libopenxr_loader_valve.so" not found` (GitHub #59) | TBXR loads `openxr_loader_<Build.MANUFACTURER>` (Lepton: valve) and takes the Meta path only when `strstr(OPENXR_HMD, "meta")` matches, else Pico's | `frame.tbxr_vendor` (empty `libopenxr_loader_valve.so` + the "meta" literal → "alve"). Game data (`xash/`, …) goes to /sdcard: upload it with the Files tab to the game's storage. Black eyes with `TBXR: Incomplete frame buffer object: GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE`: TBXR always uses multisampled render-to-texture (even `--msaa 1`); `frame.tbxr_vendor` links the GL shim, which hands it single-sampled versions (log `GL shim: multisampled render-to-texture … drawn single-sampled`) |
| Black screen after VR starts; `Asset status error (code 100)` in `DLC_Manager:AssetStatusCallback` (GitHub #203, e.g. PixelRipped 1995) | Meta asset-file status fails after entitlement succeeds; the log does not identify the native call or asset ID | Try `frame.asset_files_stub` (opt-in, triage-only): native/langpack answers GetList with an empty successful list and StatusById with installed status, an empty path and the requested ID. No DLC files are supplied; download requests retain their existing behavior. Disable with `FRAMEPORT_ASSET_FILES_STUB=0`. Host-tested only; whether this fixes startup needs a game/device test |
| Missing sound or extra content in a game whose OBB folder has separate `*.pak` files (GitHub #61, Star Wars: Tales from the Galaxy's Edge: seasons + Wwise banks) | the game asks Meta's platform for these asset files (`ovr_AssetFile_GetList` / `DownloadById`); OVRPort's loader doesn't list them | copy every content/sound-bank PAK and use `frame.asset_files` (native/langpack lists the data's `*.pak` as installed `default` assets, IAP `free`, and answers DownloadById at once with the path; off: env FRAMEPORT_ASSET_FILES=0). Tales also needs the splash and depth-size fixes below |
| A SideQuest backup has `apk/` and a sibling `obb/` with content/sound-bank PAKs, but the Frame only has the main OBB (GitHub #61, Tales from the Galaxy's Edge) | older scans treated that layout as loose expansion files and selected only the named OBBs; the asset-file patch then found no PAKs | the scanner chooses the OBB folder by version first, then keeps the whole sibling data folder when all its expansion files are selected for this APK; app updates repair affected library entries and refresh asset/language-pack detection. Reinstall to copy the missing files, or scan the backup again first. Folders with other packages or unselected OBB versions still send only the selected OBBs |
| Tales from the Galaxy's Edge stops during map loading (GitHub #61): game thread in `FlushRenderingCommands` / `UEngine::LoadMap`, render thread in `FSplash::RenderFrame_RenderThread` / `ovrp_WaitToBeginFrame` / SteamVR `WaitFrame` | the separate Oculus loading splash waits for an unbegun frame while map travel waits for rendering | `frame.unreal_splash_bypass`: returns from the splash renderer, with a checked symbol, size and prologue. The last image stays visible during loading; the normal renderer and menu layers continue. Headset test with the depth-size fix: gameplay, both eyes, controller input and sound work |
@@ -39,7 +45,8 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Launch test EXITED, launch.log has `Target app (<pkg>) did not start` and no `Start proc …:<pkg>` (GitHub #179) | Lepton occasionally doesn't start the app on the first start after an APK change; Android's `WebViewLoader-: System.exit` at boot is not the game (triage `app-not-started`) | start it again |
| A game keeps no progress between starts although it saves (its storage gets a new `Profiles/Oculus/<id>/`-style folder per start; Walkabout Mini Golf, GitHub #182) | OVRPort's platform loader answers `ovr_GetLoggedInUserID` with a made-up id (6e18 + `random()`), new at every start; games file saves under the user id | `frame.ovr_stable_user` (default for games with the platform library): a fixed id per game and keystore. Old progress: copy the newest old profile into the new id's folder |
| A Unity app's text field shows a caret for a moment and loses focus; no keyboard appears (for example Stremio VR login) | Unity's TMP_InputField/InputField wait for Android's on-screen keyboard and close themselves without one (Lepton has none); headless VR apps also have no focused Android window, so no key press reaches them | `frame.unity_text_input` (Cpp2IL finds `TouchScreenKeyboardShouldBeUsed`/`isKeyboardUsingEvents`, rewritten to false/true) + `device.text_input_window` (lepton-show-flatscreen: Steam's keyboard and Type on Frame work) |
| Play gives Steam's "Game configuration unavailable"; Steam's console_log: `GameAction [AppID <id>] … RequestingLicense → UpdatingAppInfo → LaunchApp failed with AppError_9` | this Frame's Steam never loaded FramePort's shortcuts.vdf entry (it treats the id as a store app); cause unknown (GitHub #21/#30) | automatic since agent v43: Play registers the game through Steam's devkit interface ("Devkit Game: …", `devkit_register`) and starts that; uninstall removes it. Agent v58: a devkit entry Steam forgot after a restart is added again live on Play (no NOT_IN_LIBRARY → no Steam restart), and installs/art updates of devkit games only copy art (no Steam restart, GitHub #41) |
| FramePort's games disappear after Steam restarts, especially after a devkit registration (GitHub #263) | Steam saves `Shortcuts` / `AppName` with capital letters; older FramePort reads them case-sensitively and writes a second shortcut map that Steam ignores | agent v86 reads and updates keys case-insensitively, keeping Steam's spelling, appids and tags; update FramePort and add the missing games to the library again |
| Play gives Steam's "Game configuration unavailable"; Steam's console_log: `GameAction [AppID <id>] … RequestingLicense → UpdatingAppInfo → LaunchApp failed with AppError_9` | this Frame's Steam never loaded FramePort's shortcuts.vdf entry (it treats the id as a store app); see the casing fix above, other causes remain unconfirmed (GitHub #21/#30) | automatic since agent v43: Play registers the game through Steam's devkit interface ("Devkit Game: …", `devkit_register`) and starts that; uninstall removes it. Agent v58: a devkit entry Steam forgot after a restart is added again live on Play (no NOT_IN_LIBRARY → no Steam restart), and installs/art updates of devkit games only copy art (no Steam restart, GitHub #41) |
| Steam's Exit game leaves the game running (container `lepton-steamlaunch-<appid>` still up) | Steam stopped only its `reaper`; launch.sh and Lepton (setsid) never got a signal (GitHub #36) | agent v44: launch.sh's 2 s loop ends the game when its parent is gone (`upgrade_launchers` adds it to existing launchers); by hand: `podman kill lepton-steamlaunch-<appid>` |
| Black screen at start while audio/ExoPlayer runs; log `xrEndFrame: dropped N unusable layer(s)`, a quad layer with swapchain 0x0 (for example I Am Monkey's intro video) | The game plays video into an Android-surface swapchain (XR_KHR_android_surface_swapchain); the Frame's runtime lists the extension but returns FUNCTION_UNSUPPORTED | adapter `surface_emul` (default on): FrameBridge gives the player a SurfaceTexture-backed Surface and copies each frame into an ordinary swapchain (log `surface_emul: …`) |
| The game quits right after start: `Something failed to initialize. Quitting!` / `We don't have write permission to …/files/cloud/data` (for example SUPERHOT); or stays at loading with `UnauthorizedAccessException` in `StartupLoading.Start` (GitHub #239, Jurassic World Aftermath) | The game created a cloud-save folder with mode 1700; inside Lepton the app writes through the folder's group. Its startup routine can fail before the permission watchdog repairs the newly created folder | launch.sh `fix_perms` adds owner + group write to every folder in the game's storage (agent 41). Close and start the game again without uninstalling: the folders are now repaired (Aftermath confirmed in the headset). Agent 84 creates `files/cloud/data` before `fix_perms` and startup, including in upgraded launchers, to prevent the first-start race. Triage `startup-save-permission` fails the launch test despite rendering frames |
@@ -54,7 +61,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Oculus Rift game won't run on the Frame | Oculus/LibOVR game: needs Revive, whose hooks don't work under Proton-arm64, and FramePort won't defeat the Oculus runtime signature check | Play it on this PC (SteamVR + Revive). Only OpenVR/OpenXR-native Rift games run on the Frame |
| Un-cracked Oculus PC VR game: "Initializing OVR session" then exits / signature check on the runtime | Revive's hooks don't install on Proton-arm64; the Oculus shim rejects the unsigned Revive runtime | Not runnable on the Frame without the repack's crack extracted (not done by FramePort); use the PC version |
| PC VR game: `Failed to initialize Oculus API (-3001)` / `Unable to load LibOVRRT DLL` | Revive's LoadLibrary hook doesn't work under Proton arm64 (ARM64EC kernelbase); the game's LibOVR shim finds no runtime DLL | Symlink `LibOVRRT64_1.dll` → Revive's DLL in the prefix's system32 (manual so far) → then -3021 (runtime signature check; open) |
| Uploads to the Frame are slow (~15 MB/s) | PC and Frame both on Wi-Fi through the home router | Connect the PC to the Frame's own hotspot (or a USB cable): FramePort uses the direct link automatically (~80-100 MB/s); job log line "Transfer link" |
| Uploads to the Frame are slow | The link, storage or transfer backend can limit throughput | Check the job's "Transfer link"; try the Frame's hotspot or USB cable. Large install files prefer system OpenSSH SFTP, retaining paired-host checks and `.part` resume; unavailable or failed native uploads fall back to Paramiko. The log names the backend. Settings → Installing → Parallel uploads accepts 1–8 (default 3; saved preferences are preserved). More is not always faster. Measure the production path with `scripts/upload_bench.py --methods app-files-3 --check-resume` (three repetitions, checksum verification) |
| PC VR game exits right away: `Unhandled Exception: 0xc06d007e` (after `FOnlineSubsystemOculus::InitWithWindowsPlatform`) | Delay-loaded `LibOVRPlatform64_1.dll` (Oculus Platform SDK, installed with the Oculus app) is missing | None on the Frame (entitlement check; FramePort doesn't replace it). PC mode with the Oculus app |
| Unity PC VR game (Oculus + SteamVR build: `OVRPlugin.dll` and `openvr_api.dll` in `<Name>_Data/Plugins`) opens a window and quits after ~20 s; no VR Vulkan instance after the game's DXVK device in launch.log; Unity log (`unity log … Player.log`, agent v67): `OpenVR failed initialization` / `Initialization of device … failed` | Unity's built-in VR tries its SDKs in list order (Oculus first) unless `-vrmode` picks one; a catalog recipe verified with another build of the game (for example SUPERHOT VR's OpenXR build, GitHub #105) used to drop the build's own arguments | `pcvr.launch_args` = `-vrmode OpenVR` (detected for such builds; kept when the catalog's `xr` differs from the build's). A game's Electron launcher next to it (SHVR.exe) is ranked below the game |
| Unreal PC VR game opens the crash reporter instead of closing | UE starts CrashReportClient.exe on a crash | `pcvr.no_crash_reporter` (default for Unreal Rift games): `-nocrashreports` + CrashReportClient.exe renamed `.disabled` in the Frame copy |
@@ -66,7 +73,9 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| The Climb 666.0: audio with no headset picture, `OVRPortVrApi: Unsupported frame flags or swap interval`; then SIGSEGV in `vulkan.freedreno.so` during `vkCmdEndRenderPass` once frames are accepted (GitHub #238) | The game submits the obsolete Quest texture-density debug flag (`0x100`), which the bridge rejected. It also binds the bridge's full-rate texture as a fragment-density attachment even though foveation support is reported off; Turnip reads density maps on the CPU, but an ordinary sampled image has no mapped backing memory | Updated `frame.vrapi_bridge` accepts the compositor debug/volume flags and creates the compatibility texture with `VK_IMAGE_USAGE_FRAGMENT_DENSITY_MAP_BIT_EXT`. Do not gate that usage on the advertised format features (the Frame reports R8G8_UNORM without the density bit). `device.files` selects `r_vr_foveated = 0` when the engine contains that cvar; The Climb 2 keeps `r_variable_rate_shading = 0`. Triage `vrapi-frame-rejected` marks rejected frames as fatal despite pacing logs. Picture confirmed on the headset. With the corrected config, an uncapped launch stayed running for over four minutes at a median 72 fps with no native crash or GPU fault. The user confirmed working menus and gameplay on 2026-10-12 |
| Unreal game: `JNI_OnLoad` (OVRPlugin), then silence: no OpenXR instance, no crash, no frames (GitHub #85 TRIANGLE STRATEGY; launch-test finding `missing-obb`) | Installed without its OBB: the manifest says `com.epicgames.ue4.GameActivity.bHasOBBFiles` = true (UE5: `com.epicgames.unreal.…`) but the library has no data folder (`data_dir` null, `data_bytes` 0) | Put the .obb files in a `<package>` (or `obb/`) folder next to the APK, add the folder again, reinstall. Analysis `extra.expects_obb`; the game page, the install question and `frameport scan`/`install` warn; launch tests that sent no frames get the `missing-obb` finding from the library (the game logs nothing). Unity split-binary builds count too (`extra.unity_split`, `analysis/unity_split.py`): an XR-plugin build (Oculus XR Plugin / Unity OpenXR library) without `assets/bin/Data/UnitySubsystems/` in the APK, or BuildSettings listing more scenes than the APK has `levelN` files (globalgamemanagers loose or read from the head of data.unity3d); exact on the 48 Unity APKs of the dumps (13 split, 17 full games; OBBs of full builds are asset bundles or sound banks) |
| Unreal game: launch.log ends with `ActivityTaskManager: START u0 {… cmp=<pkg>/.DownloaderActivity}` and `Displayed <pkg>/.DownloaderActivity`, then nothing: no OpenXR instance, no crash (GitHub #159 Contractors; triage `unreal-obb-check-stuck`, also when the OBB is on the Frame) | Epic's DownloaderActivity (started by GameActivity.onResume before the engine's main init) checks every OBB's name + size compiled into the APK (OBBData); with manifest meta-data `com.epicgames.ue4.GameActivity.bVerifyOBBOnStartUp=true` it then reads the whole OBB and compares each entry's CRC32 (cached in `files/cacheFile.txt` by the OBB's mtime). Its progress and error screens are invisible in Lepton (headless VR); a CRC mismatch waits for a button forever. A missing / wrong-size OBB starts Google Play's downloader (or a "No Google Play Store Key" dialog) and also waits | `frame.unreal_skip_obb_check` (suggested by analysis `unreal_verify_obb`): sets that meta-data to false (one boolean in the manifest, `axml.set_meta_data_bool`), which skips only the CRC pass. Still stuck: the OBB doesn't match the APK version (name `main.<versionCode>.<pkg>.obb` and its size) - copy APK + OBB from the same install |
| Unreal game still stays on `DownloaderActivity` with CRC checking off (`bVerifyOBBOnStartUp=false`) and a fully uploaded matching OBB (GitHub #283, Into The Radius) | The separate Java `expansionFilesDelivered()` name and size gate can reject an OBB through Android external storage in Lepton; disabling CRC does not bypass it | Try `frame.unreal_skip_obb_delivery` (opt-in, experimental, triage-only). It returns true from known Unreal/package delivery predicates in every dex, preserving normal downloader success and main init. Analysis `unreal_obb_delivery` (version 16) controls applicability. The native engine still needs complete, matching, readable OBB data; unverified on a device |
| `INSTALL_FAILED_NO_MATCHING_ABIS` | 32-bit-only APK (Gear VR / Go / early Quest); Frame has no AArch32 | `frame.a32_bridge` (suggested for every 32-bit-only APK, experimental): the a32 translator runs the game's 32-bit libraries inside the 64-bit app (native/a32translator/bridge) |
| a32 bridge faults at a low Thumb PC while loading a library, with the same unrelocated pointer in `.init_array` (e.g. Journey of the Gods: `0x1259e9` in OVRPlugin) | The guest loader ignored the older `DT_ANDROID_RELR` / `DT_ANDROID_RELRSZ` tags, leaving relative pointers at their link addresses | Fixed in the guest loader: both Android and standard RELR tags use the existing RELR decoder. Rebuild the a32 runtime and APK; host regression tests cover both tag families, but game startup still needs a Frame run |
| 32-bit game on the bridge: framerate falls over minutes, purple/garbled geometry, then `Could not allocate memory: System out of memory!` (Spunky Dungeon) | The bridge's guest copies of mapped GL buffers were tracked per buffer target: a second buffer mapped on the same target lost the first one's copy (never written back: garbage vertices; never freed: ~10 MB/s until the guest's 4 GiB were full) | Fixed in the bridge (gl.c: mappings per buffer object). Check: the guest's `[anon:scudo:*]` maps below 4 GiB in /proc/<pid>/maps must stay flat |
| 32-bit game on the bridge loads, leaves VR and its engine threads end, no crash, no finish() (Gunjack) | The game checks its own APK signature (UE4 `AndroidThunkJava_GetSignature` = Base64 of the signing certificate's SHA-1, compared in AndroidMain): every FramePort build is re-signed | `frame.a32_bridge` stores the original APK's value in `assets/fp_a32_signature.txt` (Unreal games) and the bridge answers the guest's call with it (log `AndroidThunkJava_GetSignature: original signature returned`). Find other such checks with `A32_BREAK` on the game's exit path (vrapi_LeaveVrMode logs its caller) |
| 32-bit NativeActivity game on the bridge ignores every controller (Gunjack, UE 4.10 Gear VR: no vrapi input functions, it reads the Gear VR touchpad/back button as Android input events from its AInputQueue) | The bridge sent its controller events through `Activity.dispatchKeyEvent`, which a NativeActivity never passes to native code | input.c puts synthetic touchpad (motion, touchscreen source) and back-key events in front of the game's AInputQueue: `ALooper_pollAll/pollOnce` return the queue's ident while one waits, `AInputQueue_getEvent` hands it out, the AInputEvent/AKeyEvent/AMotionEvent imports answer for it. Right trigger = hold the touchpad, A = tap, right stick = swipe (up/down = swipe forward/back along the touchpad's X axis: Gunjack's `OnFlick` takes flick angles <45°/>315° as reload, 135-225° as special weapon), B/left menu = back (log `controllers: the game reads its AInputQueue`) |
@@ -147,6 +156,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Unreal game (ILMxLAB, for example Vader Immortal): controllers track but grip/trigger do nothing, grabbing never works (Unreal's own input values move, the game's stay 0) | `URPOCKeyMapManagerComponent` picks the Quest or the Gear VR key set by `IsRunningOnSantaCruz()`; on the Frame it takes Gear VR, empty in a Quest build | `frame.unreal_quest_keymap` (the Oculus case keeps the Quest set, for action and axis mappings; found by Klownicle, GitHub #49) |
| The hands' thumbs never move when you touch the thumbstick or buttons (Unreal `ThumbUp` stays 1, for example Vader Immortal) | OVRPlugin reports thumb proximity from XR_FB_touch_controller_proximity, which the Frame's runtime lacks; OVRPort's loader offers it anyway and the proximity actions never get a binding | adapter `proximity_emul=1` (thumb proximity from the touch inputs: thumbstick, face buttons, thumb rest; 2 = also the index finger from trigger touch); log `proximity_emul: finger proximity bound to touch`. **For UE4 Oculus games this didn't animate the thumbs** (Vader, GitHub #49): use `frame.unreal_thumb_touch` (found by Klownicle): `OculusInput::FOculusInput::SendControllerEvents` computes ThumbUp from NearTouches (masks 0x2/0x8); the patch makes it read Touches (the slot 4 bytes before) with masks 0x0f00 (X/Y/stick/thumb rest) / 0x000f (A/B/stick/thumb rest), keeping the inversion; three instructions, matched exactly (build note `OculusInput ThumbUp: from the capacitive touches`). Matches Vader Immortal Ep. I, Robo Recall, Phantom: Covert Ops, Time Stall, Star Wars: Tales from the Galaxy's Edge; newer UE4 builds (for example Asgard's Wrath 2, RE4, In Death) compile it differently (shared mask registers) and aren't matched |
| Vulkan game (Unreal / own engine with the Vulkan shim): the GPU hangs at one effect or cutscene (kernel `hangcheck detected gpu lockup`, freeze/exit; for example a VR4 cutscene, GitHub #140) | a SPIR-V shader reads a loop counter/accumulator before setting it (VR4's campaign shader, GitHub #10) | adapter `vk_shader_dump=1` (`frame.vk_sanitize`'s shim; log `vk shim: dumping SPIR-V modules to …`) writes every distinct module once to `files/fp_vk_shaders/<size>_<sha256>.spv` + `index.txt` (`<seq> <ms> <unix ms> <name> new\|known\|again`); play to the hang, collect diagnostics (`target/shaders/fp_vk_shaders/`, newest 4 MB) and take the modules first created (`new`) shortly before the kernel's hangcheck time; disassemble (`spirv-dis`), find the variable read before its first store, write a `vk_shader_fix` (`<size>:<sha256>:<byte offset>:<words>` = inserted OpStores; log `vk shim: fixed shader module`); switch the dump off again |
| OpenGL ES game: memory and GPU mappings grow every frame (~20 MB/s, `/dev/dri/renderD128` mappings ~6 per frame) until the frame rate collapses; the game's `xrclient_<package>.txt` (Steam logs) has one `SXR_GL_CHECK: error:0x502 ... glGenTextures` before the session starts (GitHub #285, Vader Immortal) | The game leaves GL errors pending on its context; the runtime's GL interop reads glGetError after its own calls and takes the stale error as its own failure. From then on `CVRCompositorSharedTextures::BeginCommandBuffer` allocates a new command buffer every frame instead of reusing one (perf page-fault stacks) | Adapter `gl_error_clear=1` (GLES only) clears pending GL errors before xrCreateSwapchain/xrBeginFrame/xrEndFrame; Vader's catalog recipes set it. Verified headless 2026-10-14: 191 GPU mappings and ~1.07 GB flat over 100 s (before: +430 mappings/s, +22 MB/s), no SXR_GL_CHECK |
| OpenGL ES game: the GPU hangs at one effect (freeze/exit, compositor watchdog, for example Vader Immortal's lightspeed jump) | a shader (as Zink compiles it) reads loop counters/accumulators before setting them | capture the SPIR-V (adapter `zink_shader_dump=1` writes every module to `files/fp_spirv/`, or a GPU capture), then a `zink_shader_fix` entry (same format as `vk_shader_fix`) + `frame.zink_shader_fix` (Vulkan layer under Zink, activated through GraphicsEnv's debug layer list); log `shader fix layer: fixed shader module N`; triage `zink-shader-fix-mismatch` when a Frame update changed the driver's output |
| 2D Android app: Steam says it's running but nothing shows | Lepton runs apps headless unless the app folder has `lepton-show-flatscreen` | FramePort adds it for apps without VR (agent v28+); reinstall apps installed before |
| A phone/tablet app shows nothing in the headset, or a VR app opens as a flat window | FramePort's VR/2D guess (`vr_kind`) is wrong for this app | patch `device.display_mode` → **Flat window** or **VR** (Customize → "Show as VR or as a flat window"), then Update on Frame |
+195
View File
@@ -0,0 +1,195 @@
# Transfer measurements and incremental installs
## Production OpenSSH follow-up
OpenSSH is now the preferred installer backend for files >=8 MiB. The new default is three workers (range 1–8),
with saved preferences preserved. Fresh files use `put`; existing partial files use `reput`. Each worker keeps
one SFTP batch session. Progress is polled through a separate bounded SFTP channel. Native failures resume via
Paramiko; cancellation stops the owned SFTP/SSH process trees and does not trigger fallback.
Windows matched production comparison: eight 64 MiB files, 512 MiB total, three shuffled repetitions per backend,
three workers, same home Wi-Fi/internal storage. OpenSSH median **15.51 MB/s**, Paramiko median **11.08 MB/s**
(about 40% faster). All six checksums passed. OpenSSH trials ranged 9.5–16.8 MB/s, so link variability still
matters; it was slower than Paramiko in one pass. Native credential/progress setup took 0.12–0.13 seconds.
The separate Windows 128 MiB production test also passed every checksum. Native cancellation left a partial file
of 74,752,000 bytes; resuming with OpenSSH produced the expected SHA-256. The log confirmed no fallback for the
resumed production upload. Reports:
`/mnt/c/Users/Austin/AppData/Local/Temp/frameport-upload-native-production-comparison-windows.json` and
`/mnt/c/Users/Austin/AppData/Local/Temp/frameport-upload-native-production-windows.json`.
The earlier sections below preserve measurements made before the production backend switch.
The WSL production validation (128 MiB, three workers, three repetitions) measured 14.1 / 19.1 / 15.0 MB/s,
with every checksum passing and native backend logs recorded. Production cancellation left a 72,330,240-byte
partial file; OpenSSH resumed it in 4.23 seconds with the expected SHA-256 and no Paramiko fallback. Report:
`/tmp/frameport-upload-native-production-wsl.json`.
## Measurements (2026-10-14)
Both runs used the same paired Frame, the home-router connection and internal ext4 storage under the paired user's
home. Each large-file trial sent the same 128 MiB random payload; each small-file trial sent 512 files of 64 KiB
(32 MiB total). Methods were shuffled for each of three repetitions. Windows and WSL runs were sequential, not
concurrent. Every completed transfer passed SHA-256 verification. Rates below include connection/setup and transfer
completion, but exclude checksum verification and cleanup; MB/s uses decimal bytes.
| Method | WSL2 median MB/s | Windows 11 native median MB/s |
|---|---:|---:|
| FramePort pipelined SFTP | 10.4 | 6.1 |
| Paramiko SFTP, 64 MiB window | 17.1 | 4.5 |
| OpenSSH SFTP | 16.8 | 15.4 |
| OpenSSH SCP (default protocol) | 11.1 | 10.1 |
| OpenSSH `ssh` + streamed file | 9.9 | 8.1 |
| FramePort tar stream | 14.9 | 6.8 |
| OpenSSH tar stream | 15.0 | 7.2 |
The network varied considerably: WSL's application trials ranged from 9.6–12.8 MB/s, native SFTP from
16.2–17.3 MB/s, and some other methods dropped below 5 MB/s. Windows application trials ranged from 5.6–6.4 MB/s;
native SFTP ranged from 9.1–16.5 MB/s. These are useful end-to-end comparisons on this setup, not a measurement of
the maximum throughput each transport can achieve. Window tuning did not help the Windows run.
Cancellation/resume checks interrupted the application upload at 64 MiB, then independently resumed that partial
file with the application and with OpenSSH SFTP `reput`. All four resumed files passed SHA-256 verification. This
checks application cancellation and both resume paths; native SFTP cancellation itself remains unmeasured.
The Frame's USB and hotspot interfaces were up, but neither direct endpoint was reachable from the PC. No removable
storage was mounted. USB, hotspot and microSD performance remain **unmeasured** in this round. The Frame's router
connection used 5 GHz / 80 MHz Wi-Fi, with roughly -56 dBm signal during a read-only probe. This does not reproduce
the 6 GHz direct-hotspot setup in [discussion #242](https://github.com/spoopyghosty0/frameport/discussions/242), where
the reporter measured about 114 MB/s with OpenSSH versus 23 MB/s with Paramiko.
**Recommendation:** prototype an optional OpenSSH SFTP backend for large files, retaining `.part`/`reput`,
cancellation, retries, strict paired-host verification and Paramiko fallback. Validate it on a fast direct link
before making it the default. On this Windows run SFTP was about 2.5 times faster than the current application,
and it beat SCP. The small-file tar measurements do not justify selecting a new tar transport yet. Production
transport selection remains Paramiko pending that decision. The bounded parallel-file setting described below
uses independent Paramiko connections.
## Parallel-file uploads
### Matched native OpenSSH comparison
The later comparison tested both backends sequentially on Windows and WSL, with three shuffled repetitions of
the same eight 16 MiB files per worker count. Native `sftp-files-N` balances files across N persistent OpenSSH
SFTP batch processes. Setup and completion are included; checksum verification is excluded. All 60 transfers
passed SHA-256 checks. This is benchmark code; the production uploader still uses Paramiko.
| Workers | Windows Paramiko MB/s | Windows OpenSSH MB/s | WSL Paramiko MB/s | WSL OpenSSH MB/s |
|---|---:|---:|---:|---:|
| 1 | 6.22 | 14.96 | 6.17 | 14.64 |
| 2 | 10.61 | 17.73 | 14.41 | 14.45 |
| 3 | 8.90 | 18.89 | 13.65 | 17.75 |
| 4 | 14.90 | 16.80 | 15.67 | 16.63 |
| 8 | 13.50 | 20.16 | 17.03 | 16.82 |
Three is a reasonable default candidate for a future OpenSSH backend: versus two it improved median throughput
by 6.5% on Windows and 22.8% on WSL. Eight was faster on Windows but slower on WSL. The production Paramiko
default remains two because three regressed by 16.1% on Windows and 5.3% on WSL in this matched comparison.
Link variability remains substantial, and no fast hotspot/USB result is claimed.
Reports: `/mnt/c/Users/Austin/AppData/Local/Temp/frameport-upload-native-parallel-windows.json` and
`/tmp/frameport-upload-native-parallel-wsl.json`.
Settings → Installing → **Parallel uploads** accepts **1–8**, default **2**. It controls large files within one
install; the FIFO still runs one install job at a time. A single APK is not split into chunks. Large files are
scheduled largest first, with at most one authenticated SSH connection per active worker and never more workers
than files. Small files still use tar batches after the large-file uploads. Choose 1 on a busy network; a higher
count can consume all available bandwidth and is not automatically faster.
The parallel comparison uses the same eight 16 MiB OBB-shaped files (128 MiB total) for every worker count, with
three shuffled repetitions. The production uploader is used by `app-files-N`, including its aggregate progress,
resume baselines, cancellation checks and connection cleanup. A failed or cancelled cohort stops queued work and
closes only its worker transports; completed files and interrupted `.part` files remain resumable.
| Workers | WSL2 production median MB/s | Windows 11 production median MB/s |
|---|---:|---:|
| 1 | 9.6 | 5.6 |
| 2 (default) | 9.1 | 10.9 |
| 3 | 6.5 | Not measured in the final production run |
| 4 | 12.4 | 14.8 |
| 8 | 11.2 | 17.3 |
On Windows, the production uploader improved from 5.6 to 10.9 MB/s with 2 workers (**1.95 times faster**), and
17.3 MB/s with 8 (**3.08 times faster**). All twelve Windows production transfers passed SHA-256 verification.
The preceding Windows prototype also tested 3 workers (12.2 MB/s median), before aggregate progress/cancellation
were integrated; that is not included as a production measurement in the table.
WSL was noisy: four workers improved its median by about 29%, but 2 did not improve its median, and 3 was slower.
There is no evidence that a particular worker count is universally fastest. The option is retained because it
produced a repeatable benefit on Windows and higher counts improved the WSL median. Default 2 limits competing
transfers while retaining a measured Windows benefit; users can measure higher counts or select 1 in their own
environment. Production code, including cleanup and progress checks, was used for both final comparisons.
```sh
uv run python scripts/upload_bench.py --mib 128 --repeat 3 --methods app-files-1,app-files-2,app-files-3,app-files-4,app-files-8 --json parallel.json
```
`install.upload_threads` persists the setting. Malformed values use 2 and numeric values are clamped to 1–8.
The serial setting reuses the existing control connection; parallel workers verify that the new transport reaches
the same paired headset before sending any files.
## Reproducing the measurements
Run from the checkout using the Python environment for the platform being measured:
```sh
uv run python scripts/upload_bench.py --mib 512 --repeat 3 --check-resume --json uploads.json
uv run python scripts/upload_bench.py --fast-link --probe
uv run python scripts/upload_bench.py --fast-link --remote-dir /path/on/card --mib 512 --repeat 3 --json card.json
```
`--host` selects a specific paired endpoint; `--fast-link` uses the same authenticated direct-link discovery as an
install. Inspect the reported endpoint and link before comparing methods. Join the Frame's hotspot or connect its
USB cable before testing those paths; the benchmark does not change PC networking. Keep the headset awake and avoid
other network traffic. A 512 MiB payload is preferred for follow-up tests on fast links; the available-link runs
above used 128 MiB.
The script creates a unique scratch directory on the selected filesystem, checks content outside the timed upload,
and removes its own temporary remote files and temporary key copy. It never modifies the pairing key's ACL. Windows
OpenSSH rejects an `OWNER RIGHTS` ACL entry; the temporary copy grants only the current user, without Python's
Windows `chmod` behaviour that can introduce that entry. Native clients use the application's key and known-hosts
file with strict host verification.
JSON rows contain bytes, total seconds, separately measured setup seconds where available, payload and total rates,
verification duration and checksums. SCP/SFTP's setup is included in total time because their command-line clients
do not expose its completion separately. On POSIX, CPU includes the Python parent and child processes; on Windows,
`os.times()` measures only the parent, so native subprocess CPU numbers must not be compared with Paramiko's CPU.
An error is recorded rather than counted as a successful trial. `--methods` can restrict the run; `--small-files`
controls the tar corpus. Cipher comparisons are available as `ssh-gcm` and `ssh-chacha`.
## Incremental install behaviour
`game_update.plan_update` is the shared, I/O-free Quest/Android classifier used by badges and installation. Recipe
source, notes and catalog revision do not affect it. A newer tested catalog recipe remains a separate suggestion
when the user customized their recipe.
- Build records keep an APK recipe fingerprint, source hash/stat, selected native-asset identities, converter
identity and the existing validated APK hashes. Reusable local APKs are checksum-checked before installation.
- Deployment records keep applied runtime configuration and its fingerprint, APK stat/hash, source version,
data manifest/digest and artwork hashes. Routine device samples carry only the digest, and updates fetch a
detailed manifest only when necessary. Live numeric settings writes update only the applied runtime configuration,
leaving APK build identity intact. Desired data manifests are cached locally for badges, without render-path I/O.
- Runtime-only updates use agent v87 `configure`: no conversion, signing, APK transfer, artwork, Steam restart or
automatic launch test. They still require the installed APK identity and an available destination, and refuse
changes while the game is running. Defaults explicitly reset overrides embedded in an older APK. Generated
configuration files are restored or removed only when they still match FramePort's last write; user edits survive.
- Build support dependencies remain conservative: controller models/native surfaces/haptic support, Zink-layer
selection, mixed foveation patches and revised adapter support can require a new APK. A recorded alternate
variant can be reused when switching variants.
- Data-only updates transfer missing/different-size data using the resumable uploader. Recipe/APK updates skip
data synchronization when the source data manifest is unchanged, preserving Frame-side mods; explicit forced
reinstalls still check/repair data. Artwork-only
updates send changed slots; unchanged artwork avoids a Steam refresh. APK/data changes retain the configured
automatic launch test. Settings/artwork changes skip it.
- APK-only updates preserve installed OBBs, including mods, and do not apply old staged OBBs or artwork from an
interrupted upload. Ordinary data comparison still uses sizes, matching existing behaviour; equal-size content
replacement is not detected automatically. Explicit rebuild/reinstall remains available (`install --force`).
- Legacy build identities take the full path once because their original build inputs cannot be established
safely. Converted-copy cleanup and `build.keep_copies` remain in effect; a configuration/data update does not
require the deleted converted APK.
Install results record `stage_seconds` in the library's install history. This includes planning, conversion,
validation, transfer and finalization stages reached by that operation; the GUI's subsequent optional launch test
is separate. Use these timings to distinguish build cost, network transfer and device/Steam work when reviewing a
slow install. Configuration success is recorded after writing the configuration and completing its sync; a failed
operation leaves the prior applied fingerprint so it remains retryable.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 103 KiB

+1
View File
@@ -11,6 +11,7 @@ embeds debug paths (`-g`) and the dex depends on the d8 version, so those differ
| Dir | Artifact | What / why |
|---|---|---|
| `luajit/` | `arm64-v8a/libfp_luajit.so` | Pinned upstream LuaJIT with full-width lightuserdata (`frame.luajit_pointers`). Only the verified old Lua engine's shared public functions become imports; the original xLua API 105 bindings, LuaSocket helpers and `luaopen_perflib` remain in the game. No game library is bundled. Build: `python native/build.py --only luajit`; source revision + archive SHA-256 in `luajit/build.py`, MIT notices in `luajit/COPYRIGHT` and `artifacts/licenses/LuaJIT.txt`. `runtime.c` checks GC64 and the retained `xlua_gl` global-reference offset. `smoke.c` compares a real low-address allocation with the original library's own native static pointer; the unmodified library fails only the high-address case, the patched one round-trips both exactly. Startup verified on Frame; gameplay unverified. |
| `adapter/` | `{arm64-v8a,armeabi-v7a}/libopenxr_loader_generic.so` | **FrameBridge**: replaces overport's generic loader (which is renamed `libopenxr_loader_original.so`) and fixes Steam Frame runtime gaps. Hooks xrCreateInstance, swapchains, xrEndFrame, spaces, xrPollEvent, … everything else is forwarded by `gen_forwarders.py`-generated tail calls. `scene_emu.c` = Meta scene/spatial-entity emulation; `flip_vk.c` = Vulkan blit for VERTICAL_FLIP quads. `session_fixes.c` = per-game session fixes, all off by default and hooked only when on (`layer_debug` diagnostics, `stable_local`, `focus_hold`, aim correction `aim_pitch/aim_yaw/aim_forward`, `refresh_rate`, `proximity_emul` = finger proximity (XR_FB_touch_controller_proximity, which the Frame lacks) from the capacitive touch inputs: OVRPlugin's `hand_thumb_proximity`/`hand_trigger_proximity` actions get extra Touch bindings); `input_diag.c` = controller-input diagnostics (setting `input_diag`, off by default and hooked only when on; logs suggested interaction profiles and the runtime's answers, each hand's current profile, functions the runtime lacks and failing input/haptics/perf calls, each line once; tested on the host by `tests/test_input_diag.py`); `layer_emul_gl.c` = `equirect_emul` (GLES sessions only): 360° equirect/equirect2 layers become one adapter projection layer, drawn by a worker thread with its own EGL context shared with the app's: each 360° image is converted to a cube map only when it changes, and the view is redrawn only after a 4° head turn (GL/EGL resolved with dlopen, so Vulkan games never load them); `layer_math.h` = their pose/face math, tested on the host by `tests/test_layer_emul.py`. `render_model.c` = XR_FB_render_model emulation (setting `controller_models`, off by default): serves `files/framebridge/controller_{left,right}.glb`, which the agent converts on the Frame from SteamVR's own Frame controller render models (never shipped by FramePort); tested on the host by `tests/test_render_model.py`. Settings: `libframe_settings.so` in the APK + `settings.conf`/`framebridge.conf` on the Frame. |
| `vrapi-bridge/` | `arm64-v8a/libvrapi.so` | VrApi → OpenXR bridge from [Android-XR-Bridge/OVRPort](https://github.com/Android-XR-Bridge/OVRPort) `native/vrapi` at 5e7df52 (GPL-3.0, `LICENSE.upstream`; unchanged upstream as of OVRPort v1.2.5, which ships the unpatched code as its opt-in `patch_vrapi_openxr` in experimental CLI builds), with our changes in `upstream-patches/`: GLES sessions + GL texture swapchains, cylinder→quad layers, GL vertical flip, UNORM↔sRGB format twins, 30 s VR-mode deadline, VALID-only recenter, loading-icon layers skipped, `vrapi_PollEvent`/`RecenterPose`/`SetDisplayRefreshRate`/`GetSystemPropertyFloatArray` (BlazeRush imports them, GitHub #57), legacy compositor debug/volume frame flags accepted, full-rate Vulkan foveation images allocated as density attachments (The Climb, GitHub #238), diagnostics behind `-DOVP_GL_DIAG`. `upstream-patches/` reproduces these sources from the fork's `native/vrapi` (checked 2026-10-06 against its latest commit); upstream PRs Android-XR-Bridge/OVRPort#3-#8, tracked in GitHub #75. |
| `platformcompat/` | `arm64-v8a/libovrplatformcompat.so` | Real `ovrMessageType_ToString` (same fork, `native/platform`). |
+2 -1
View File
@@ -165,7 +165,8 @@ Tests:
with a fake JVM (`fakejni.c`) for three guest libraries: RegisterNatives and `Java_*` exports, 12-argument
natives (stack arguments on both sides), floats/doubles/bytes, strings, arrays below and above 4 GiB, every
Call*Method style, NewObject, nested calls, guest threads that attach, several host threads, exceptions, a
NativeActivity with a DT_NEEDED library, and C++ exceptions in a guest library. Runs under qemu-aarch64 or
NativeActivity with a DT_NEEDED library, and C++ exceptions in guest libraries using ordinary REL,
standard RELR and Android's older RELR tags (with APS2). Runs under qemu-aarch64 or
natively (passes on the Frame with JIT and interpreter).
- `uv run python bridge/test/make_apk.py`: `org.frameport.a32test`, a NativeActivity APK without Java code whose
library is 32-bit (`bridge/test/libapktest.c`). Installed with FramePort (`frameport scan`, then `frameport
+15 -2
View File
@@ -50,7 +50,12 @@ def main():
OUT.mkdir(parents=True, exist_ok=True)
subprocess.run([sys.executable, str(HERE / "gen_stubs.py"), "--ndk", a.ndk], check=True)
opt = ["-O0", "-g"] if a.debug else ["-O2", "-g"]
prefix = [f"-ffile-prefix-map={ROOT}=a32translator", f"-ffile-prefix-map={Path(a.ndk).resolve()}=ndk"]
# The cache can be symlinked into a worktree. Clang records both the spelling
# used on the command line and the resolved NDK path, plus the compilation cwd.
prefix = [f"-ffile-prefix-map={ROOT.parent.parent}=frameport",
f"-ffile-prefix-map={ROOT}=a32translator",
f"-ffile-prefix-map={Path(a.ndk).absolute()}=ndk",
f"-ffile-prefix-map={Path(a.ndk).resolve()}=ndk"]
# guest runtime: everything a guest library may link against comes from the static archives (exports.S
# references each symbol, so the linker keeps them)
@@ -76,7 +81,7 @@ def main():
to.mkdir(exist_ok=True)
# the harness is static (runs under qemu-aarch64 or on any arm64 Linux): high image base keeps the
# low 4 GiB free, like a32run's
run([cc64, *common, "-DA32B_STDERR_LOG", "-static", "-Wl,--image-base=0x7000000000", "-I", h,
run([cc64, *common, "-DA32B_STDERR_LOG", "-DA32B_TEST", "-static", "-Wl,--image-base=0x7000000000", "-I", h,
t / "harness.c", t / "fakejni.c", *[src / s for s in TRANSLATOR], *[h / s for s in HOST], "-lm", "-ldl",
"-o", to / "harness"])
run([cc32, *opt, *prefix, *WARN, "-mthumb", "-mfpu=neon-vfpv4", "-shared", "-fPIC", t / "libbtest.c",
@@ -85,6 +90,14 @@ def main():
to / "libbtest.a32.so", "-landroid", "-llog", "-o", to / "libbtest2.a32.so"])
run([bin_ / "armv7a-linux-androideabi30-clang++", *opt, *prefix, *WARN, "-mthumb", "-shared", "-fPIC",
"-static-libstdc++", t / "libbtest3.cpp", "-o", to / "libbtest3.a32.so"])
# Exercise both RELR tag families through the real guest loader. C++ constructors,
# pointer tables and exceptions need relative relocations; Android's older tags
# occur in Quest libraries (e.g. Journey of the Gods' OVRPlugin).
for tags in ("android", "standard"):
run([bin_ / "armv7a-linux-androideabi30-clang++", *opt, *prefix, *WARN, "-mthumb", "-shared", "-fPIC",
"-static-libstdc++", t / "libbtest3.cpp", "-Wl,--pack-dyn-relocs=android+relr",
"-Wl,--use-android-relr-tags" if tags == "android" else "-Wl,--no-use-android-relr-tags",
"-o", to / f"libbtest3_{tags}.a32.so"])
shutil.copy(OUT / "liba32rt.so", to / "liba32rt.so")
@@ -21,6 +21,10 @@
#endif
#define DT_ANDROID_REL 0x6000000f
#define DT_ANDROID_RELSZ 0x60000010
/* Android used these OS-specific tags before RELR received standard ELF tags.
* The table encoding is identical (including ARM32's 31-bit bitmaps). */
#define DT_ANDROID_RELR 0x6fffe000
#define DT_ANDROID_RELRSZ 0x6fffe001
#define DT_RELR_ 36
#define DT_RELRSZ_ 35
#define R_ARM_NONE 0
@@ -528,7 +532,9 @@ static lib *load_lib(const char *name)
case DT_PLTRELSZ: jmprelsz = v; break;
case DT_ANDROID_REL: arel = v; break;
case DT_ANDROID_RELSZ: arelsz = v; break;
case DT_ANDROID_RELR:
case DT_RELR_: relr = v; break;
case DT_ANDROID_RELRSZ:
case DT_RELRSZ_: relrsz = v; break;
case DT_NEEDED: nneeded++; break;
case DT_RELA: set_error("%s: RELA relocations on ARM32?", l->name); return NULL;
+297 -17
View File
@@ -23,6 +23,7 @@ typedef struct {
uint32_t Padding;
double TimeInSeconds, PredictionInSeconds;
} pose64; /* ovrRigidBodyPosef: the same in both ABIs */
typedef struct { float Orientation[4], Position[3]; } posef64;
typedef struct {
uint32_t Status, Padding;
pose64 HeadPose;
@@ -97,6 +98,8 @@ static struct {
double (*GetPredictedDisplayTime)(void *, int64_t);
tracking2 (*GetPredictedTracking2)(void *, double);
void (*RecenterPose)(void *);
posef64 (*GetTrackingTransform)(void *, int);
void (*SetTrackingTransform)(void *, posef64);
void *(*CreateTextureSwapChain2)(int32_t, int32_t, int, int, int, int);
void (*DestroyTextureSwapChain)(void *);
int (*GetTextureSwapChainLength)(void *);
@@ -134,7 +137,7 @@ static void bridge_init(void)
L(GetTextureSwapChainLength); L(GetTextureSwapChainHandle); L(SubmitFrame2); L(SetPropertyInt);
L(EnumerateInputDevices); L(GetInputDeviceCapabilities); L(GetCurrentInputState); L(GetInputTrackingState);
L(CreateTextureSwapChain3); L(SetDisplayRefreshRate); L(PollEvent); L(GetSystemPropertyFloatArray);
L(SetTrackingSpace); L(SetHapticVibrationSimple);
L(SetTrackingSpace); L(SetHapticVibrationSimple); L(GetTrackingTransform); L(SetTrackingTransform);
#undef L
bridge_ok = B.Initialize && B.EnterVrMode && B.SubmitFrame2 && B.GetPredictedTracking2 && B.GetPredictedDisplayTime &&
B.LeaveVrMode && B.CreateTextureSwapChain2 && B.DestroyTextureSwapChain && B.GetTextureSwapChainLength &&
@@ -327,6 +330,226 @@ static void stat(int k)
memset(stats, 0, sizeof stats);
}
/* Only the a32 path: one fully tracked answer per (mobile, requested time), shared by both tracking APIs.
* Render/update/submit must agree on the head for a display time. xrLocateSpace bypasses FrameBridge's
* xrLocateViews consistency cache. Invalidate on session/origin changes, never cache untracked startup poses. */
/* OVRPlugin keeps 64 frame slots, with update/render queries at different times. */
#define TRACK_CACHE 128
static struct { void *mobile; double time, sample_time; tracking2 value; } track_cache[TRACK_CACHE];
static pthread_mutex_t track_lock = PTHREAD_MUTEX_INITIALIZER;
static unsigned track_next, track_hits;
static struct { void *mobile; int64_t index; double time; } display_queries[32];
static unsigned display_next;
static void display_record(void *m, int64_t index, double time)
{
pthread_mutex_lock(&track_lock);
unsigned i = display_next++ % 32;
display_queries[i].mobile = m; display_queries[i].index = index; display_queries[i].time = time;
pthread_mutex_unlock(&track_lock);
}
static void tracking_reset(void)
{
pthread_mutex_lock(&track_lock);
memset(track_cache, 0, sizeof track_cache);
memset(display_queries, 0, sizeof display_queries);
pthread_mutex_unlock(&track_lock);
}
/* The runtime can successfully locate an untracked identity in LOCAL. In floor space that
* becomes (0, 1.6, 0), not a usable render pose. A tracked identity is legitimate. */
static int default_pose(const pose64 *p)
{
return p && fabsf(p->Orientation[0]) < 1e-5f && fabsf(p->Orientation[1]) < 1e-5f &&
fabsf(p->Orientation[2]) < 1e-5f && fabsf(fabsf(p->Orientation[3])-1) < 1e-5f &&
fabsf(p->Position[0]) < 1e-5f && fabsf(p->Position[2]) < 1e-5f &&
(fabsf(p->Position[1]) < 1e-5f || fabsf(p->Position[1]-1.6f) < 1e-5f);
}
/* track_lock held. Bounded hold, never reuse a pose across an origin/session reset. */
static int recent_tracking(void *m, double t, tracking2 *out, double *sample_time)
{
double best = 0.1;
int found = 0;
for (int i = 0; i < TRACK_CACHE; ++i) {
double age = fabs(track_cache[i].sample_time-t);
if (track_cache[i].mobile == m && age < best) {
*out = track_cache[i].value; *sample_time = track_cache[i].sample_time; best = age; found = 1;
}
}
return found;
}
static tracking2 tracking_of(void *m, double t)
{
tracking2 tr = {0};
tr.HeadPose.Orientation[3] = 1;
if (!bridge() || !m) return tr;
pthread_mutex_lock(&track_lock);
for (int i = 0; i < TRACK_CACHE; ++i) {
if (track_cache[i].mobile == m && track_cache[i].time == t) {
tr = track_cache[i].value;
if (!track_hits++) bridge_log(ANDROID_LOG_INFO, "a32 pose_consistency: repeated time uses first tracked head and eyes");
pthread_mutex_unlock(&track_lock);
return tr;
}
}
tr = B.GetPredictedTracking2(m, t);
static double source_next;
static unsigned source_bad, source_default, source_held, source_details;
struct timespec source_ts;
clock_gettime(CLOCK_MONOTONIC, &source_ts);
double source_now = source_ts.tv_sec + source_ts.tv_nsec * 1e-9;
int untracked = (tr.Status & 15) != 15;
int identity = default_pose(&tr.HeadPose);
source_bad += untracked; source_default += identity;
if ((untracked || identity) && source_details++ < 3)
bridge_log(ANDROID_LOG_WARN, "a32 tracking anomaly: requested=%.6f returned=%.6f status=0x%x default=%d pos=%.6f,%.6f,%.6f quat=%.6f,%.6f,%.6f,%.6f",
t, tr.HeadPose.TimeInSeconds, tr.Status, identity,
tr.HeadPose.Position[0], tr.HeadPose.Position[1], tr.HeadPose.Position[2],
tr.HeadPose.Orientation[0], tr.HeadPose.Orientation[1], tr.HeadPose.Orientation[2], tr.HeadPose.Orientation[3]);
tracking2 held;
double sample_time = t;
if (untracked && isfinite(t) && t > 0 && recent_tracking(m, t, &held, &sample_time)) {
tr = held;
/* Hold head+eyes together, retaining the original sample time so caching
* this consistent answer cannot renew the hold during a tracking outage. */
tr.HeadPose.TimeInSeconds = t;
tr.HeadPose.PredictionInSeconds = 0;
++source_held;
}
if (source_now >= source_next) {
bridge_log(ANDROID_LOG_INFO, "a32 tracking window: untracked=%u default=%u held=%u", source_bad, source_default, source_held);
source_bad = source_default = source_held = source_details = 0;
source_next = source_now + 2;
}
/* HeadPose comes from xrLocateSpace; Eye.View comes from a separate xrLocateViews. Log the positional
* disagreement without another locate (which would itself change the runtime's pose sample). */
static double diag_next;
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
double now = ts.tv_sec + ts.tv_nsec * 1e-9;
if (now >= diag_next && (tr.Status & 15) == 15) {
diag_next = now + 2;
float center[3] = {0}, sum = 0;
for (int e = 0; e < 2; ++e) {
const float *v = tr.Eye[e].View;
for (int j = 0; j < 3; ++j)
center[j] -= 0.5f*(v[j]*v[3] + v[4+j]*v[7] + v[8+j]*v[11]);
}
for (int j = 0; j < 3; ++j) { float d = center[j]-tr.HeadPose.Position[j]; sum += d*d; }
bridge_log(ANDROID_LOG_INFO, "a32 tracking: requested=%.6f returned=%.6f status=0x%x head_y=%.3f head_eye_mm=%.3f",
t, tr.HeadPose.TimeInSeconds, tr.Status, tr.HeadPose.Position[1], sqrtf(sum)*1000);
}
if (isfinite(t) && t > 0 && (tr.Status & 15) == 15) {
unsigned i = track_next++ % TRACK_CACHE;
track_cache[i].mobile = m;
track_cache[i].time = t;
track_cache[i].sample_time = sample_time;
track_cache[i].value = tr;
}
pthread_mutex_unlock(&track_lock);
return tr;
}
/* Rate-limited evidence: submitted render pose versus the exact-time tracking answer, not a fresh locate.
* Count all frames before rate limiting; a missing match alone never changes a render pose. */
static void submit_diag(void *m, const submit2 *d, const pose64 *pose, int incoming_match, int had_default, int repaired, int dropped)
{
static pthread_mutex_t diag_lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&diag_lock);
static double next, previous_time;
static uint64_t previous_index;
static unsigned repeats, backwards, total, missing, defaults, repairs, drops, details;
if (d->DisplayTime == previous_time) ++repeats;
if (d->FrameIndex < previous_index) ++backwards;
previous_time = d->DisplayTime;
previous_index = d->FrameIndex;
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
double now = ts.tv_sec + ts.tv_nsec * 1e-9;
float dp = -1, dq = -1;
double queried_time = 0;
pthread_mutex_lock(&track_lock);
for (unsigned j = 0; j < 32; ++j) {
unsigned i = (display_next - 1 - j) % 32;
if (display_queries[i].mobile == m && display_queries[i].index == (int64_t)d->FrameIndex) {
queried_time = display_queries[i].time;
break;
}
}
if (pose) for (int i = 0; i < TRACK_CACHE; ++i) {
if (track_cache[i].mobile != m || fabs(track_cache[i].time - pose->TimeInSeconds) > 1e-6) continue;
const pose64 *q = &track_cache[i].value.HeadPose;
float sum = 0, dot = 0;
for (int j = 0; j < 3; ++j) { float v = pose->Position[j] - q->Position[j]; sum += v*v; }
for (int j = 0; j < 4; ++j) dot += pose->Orientation[j]*q->Orientation[j];
dp = sqrtf(sum)*1000;
dq = 2*acosf(fminf(1, fabsf(dot)))*57.29578f;
break;
}
++total; missing += pose && !incoming_match; defaults += had_default;
repairs += repaired; drops += dropped;
if (pose && (!incoming_match || default_pose(pose)) && details++ < 3)
bridge_log(ANDROID_LOG_WARN, "a32 submit anomaly: index=%llu display=%.6f render=%.6f cached=%d default=%d repaired=%d dropped=%d pos=%.6f,%.6f,%.6f quat=%.6f,%.6f,%.6f,%.6f",
(unsigned long long)d->FrameIndex, d->DisplayTime, pose->TimeInSeconds, incoming_match, default_pose(pose), repaired, dropped,
pose->Position[0], pose->Position[1], pose->Position[2], pose->Orientation[0], pose->Orientation[1], pose->Orientation[2], pose->Orientation[3]);
if (now < next) { pthread_mutex_unlock(&track_lock); pthread_mutex_unlock(&diag_lock); return; }
next = now + 2;
bridge_log(ANDROID_LOG_INFO, "a32 submit window: submits=%u no_cached_match=%u default=%u repaired=%u dropped=%u", total, missing, defaults, repairs, drops);
total = missing = defaults = repairs = drops = details = 0;
bridge_log(ANDROID_LOG_INFO, "a32 submit: index=%llu predicted_index=%lld display=%.6f queried=%.6f render=%.6f age_ms=%.2f head_y=%.3f delta_mm=%.3f delta_deg=%.3f cache_hits=%u repeated_display=%u backwards_index=%u",
(unsigned long long)d->FrameIndex, (long long)last_frame_index, d->DisplayTime, queried_time,
pose ? pose->TimeInSeconds : 0, pose ? (d->DisplayTime-pose->TimeInSeconds)*1000 : 0,
pose ? pose->Position[1] : 0, dp, dq, track_hits, repeats, backwards);
pthread_mutex_unlock(&track_lock);
repeats = backwards = 0;
pthread_mutex_unlock(&diag_lock);
}
/* Repair only a default without matching tracked provenance on a world projection, never an arbitrary render pose,
* head-locked layer or cylinder placement. If no tracked answer exists, end an empty frame
* rather than reproject an image around the origin. No additional WaitFrame/time advance. */
static void submit_prepare(void *m, submit2 *d)
{
pose64 original = {0};
int have_pose = 0, incoming_match = 1, had_default = 0, repaired = 0, dropped = 0;
/* Inspect all incoming world projections before any locate adds a cache entry. */
for (uint32_t k = 0; k < d->LayerCount; ++k) {
if (d->Layers[k]->Type != 1 || (d->Layers[k]->Flags & (1u << 2))) continue;
const projection2 *p = (const projection2 *)d->Layers[k];
int matched = 0;
pthread_mutex_lock(&track_lock);
for (int i = 0; i < TRACK_CACHE; ++i)
if (track_cache[i].mobile == m && fabs(track_cache[i].time-p->HeadPose.TimeInSeconds) <= 1e-6) matched = 1;
pthread_mutex_unlock(&track_lock);
if (!have_pose || (!had_default && default_pose(&p->HeadPose))) original = p->HeadPose;
have_pose = 1;
incoming_match &= matched;
had_default |= default_pose(&p->HeadPose);
}
for (uint32_t k = 0; k < d->LayerCount; ++k) {
if (d->Layers[k]->Type != 1 || (d->Layers[k]->Flags & (1u << 2))) continue;
projection2 *p = (projection2 *)d->Layers[k];
int matched = 0;
tracking2 tr = {0};
pthread_mutex_lock(&track_lock);
for (int i = 0; i < TRACK_CACHE; ++i)
if (track_cache[i].mobile == m && fabs(track_cache[i].time-p->HeadPose.TimeInSeconds) <= 1e-6) { matched = 1; tr = track_cache[i].value; }
pthread_mutex_unlock(&track_lock);
if (!default_pose(&p->HeadPose) || (matched && default_pose(&tr.HeadPose))) continue; /* a real tracked identity */
if (!matched) tr = tracking_of(m, d->DisplayTime);
/* A late render thread may submit a time outside the runtime's locatable
* window. Zero requests the already-begun frame's prediction, without
* another WaitFrame or changing the application's display time. */
if ((tr.Status & 15) != 15) tr = tracking_of(m, 0);
if ((tr.Status & 15) == 15) {
p->HeadPose = tr.HeadPose; repaired = 1;
} else { dropped = 1; break; }
}
/* Record the incoming pose, not the repaired pose. */
submit_diag(m, d, have_pose ? &original : NULL, incoming_match, had_default, repaired, dropped);
if (dropped) d->LayerCount = 0;
}
/* ---------------------------------------------------------------- the functions */
/* One VrApi instance per process, as on a Gear VR/Go (the system driver kept it): OVRPlugin 1.46 initializes in
@@ -479,6 +702,7 @@ void imp_vrapi_EnterVrMode(a32_cpu *c, argreader *R)
bridge_log(ANDROID_LOG_INFO, "EnterVrMode: eglQueryContext(CONFIG_ID) ok %u id %d error 0x%x; current %p on %p", ok,
id, error ? error() : 0, current ? current() : NULL, current_dpy ? current_dpy() : NULL);
}
tracking_reset();
void *mobile = B.EnterVrMode(&m);
bridge_log(ANDROID_LOG_INFO, "vrapi_EnterVrMode(flags 0x%x) -> %p", m.Flags, mobile);
if (mobile && quest_touch) bridge_log(ANDROID_LOG_INFO, "controllers: no touchpad/back events (the game reads Touch)");
@@ -490,6 +714,7 @@ void imp_vrapi_LeaveVrMode(a32_cpu *c, argreader *R)
{
void *m = h2ptr(rd32(R));
guest_backtrace(c, "vrapi_LeaveVrMode"); /* games that leave VR by themselves: where from */
tracking_reset();
input_stop();
if (bridge() && m) B.LeaveVrMode(m);
}
@@ -501,6 +726,7 @@ void imp_vrapi_GetPredictedDisplayTime(a32_cpu *c, argreader *R)
stat(S_DISPLAY_TIME);
last_frame_index = index;
last_display_time = bridge() && m ? B.GetPredictedDisplayTime(m, index) : 0.0;
display_record(m, index, last_display_time);
retd(c, last_display_time);
}
@@ -511,10 +737,7 @@ void imp_vrapi_GetPredictedTracking(a32_cpu *c, argreader *R)
void *m = h2ptr(rd32(R));
double t = rdd(R);
stat(S_TRACK);
tracking2 tr;
memset(&tr, 0, sizeof tr);
tr.HeadPose.Orientation[3] = 1;
if (bridge() && m) tr = B.GetPredictedTracking2(m, t);
tracking2 tr = tracking_of(m, t);
if (out) memcpy(g2p(out), &tr, 104);
ret32(c, out);
}
@@ -522,6 +745,7 @@ void imp_vrapi_GetPredictedTracking(a32_cpu *c, argreader *R)
void imp_vrapi_RecenterPose(a32_cpu *c, argreader *R)
{
void *m = h2ptr(rd32(R));
tracking_reset();
if (bridge() && m && B.RecenterPose) B.RecenterPose(m);
}
@@ -604,7 +828,7 @@ void imp_vrapi_SubmitFrame(a32_cpu *c, argreader *R)
if (bad++ < 5) bridge_log(ANDROID_LOG_WARN, "vrapi_SubmitFrame(ovr 0x%x -> %p, parms 0x%x) skipped", mh, m, fp);
return;
}
static projection2 layers[FP_MAX_LAYERS];
projection2 layers[FP_MAX_LAYERS];
const header2 *list[FP_MAX_LAYERS];
const fp_layout *F = ld32(fp) == 3 ? &FP_15 : &FP_104;
static int announced;
@@ -669,6 +893,7 @@ void imp_vrapi_SubmitFrame(a32_cpu *c, argreader *R)
d.DisplayTime = B.GetPredictedDisplayTime(m, (int64_t)d.FrameIndex);
d.LayerCount = n;
d.Layers = list;
submit_prepare(m, &d);
int r = B.SubmitFrame2(m, &d);
static int fails;
if (r && fails++ < 10) bridge_log(ANDROID_LOG_WARN, "vrapi_SubmitFrame: frame %llu, %u layers -> %d",
@@ -991,7 +1216,7 @@ void imp_vrapi_SubmitFrame2(a32_cpu *c, argreader *R)
ret32(c, (uint32_t)-1005);
return;
}
static union { header2 h; projection2 p; cylinder2 cy; } layers[MAX_LAYERS2];
union { header2 h; projection2 p; cylinder2 cy; } layers[MAX_LAYERS2];
const header2 *list[MAX_LAYERS2];
uint32_t count = ld32(d32 + 32), arr = ld32(d32 + 36), n = 0;
for (uint32_t k = 0; k < count && n < MAX_LAYERS2 && arr; k++) {
@@ -1047,6 +1272,7 @@ void imp_vrapi_SubmitFrame2(a32_cpu *c, argreader *R)
d.LayerCount = n;
d.Layers = list;
if (!n) stat(S_SUBMIT_EMPTY);
submit_prepare(m, &d);
int r = B.SubmitFrame2(m, &d);
static int fails;
if (r && fails++ < 10) bridge_log(ANDROID_LOG_WARN, "vrapi_SubmitFrame2: frame %llu, %u layers -> %d",
@@ -1078,10 +1304,7 @@ void imp_vrapi_GetPredictedTracking2(a32_cpu *c, argreader *R)
void *m = h2ptr(rd32(R));
double t = rdd(R);
stat(S_TRACK);
tracking2 tr;
memset(&tr, 0, sizeof tr);
tr.HeadPose.Orientation[3] = 1;
if (bridge() && m) tr = B.GetPredictedTracking2(m, t);
tracking2 tr = tracking_of(m, t);
if (out) memcpy(g2p(out), &tr, sizeof tr);
ret32(c, out);
}
@@ -1094,6 +1317,7 @@ void imp_vrapi_WaitFrame(a32_cpu *c, argreader *R)
stat(S_WAIT);
last_frame_index = index;
if (bridge() && m) last_display_time = B.GetPredictedDisplayTime(m, index);
display_record(m, index, last_display_time);
ret32(c, 0);
}
@@ -1128,25 +1352,77 @@ void imp_vrapi_GetSystemPropertyFloatArray(a32_cpu *c, argreader *R)
? (uint32_t)B.GetSystemPropertyFloatArray(&j, prop, g2p(values), count) : 0);
}
static int tracking_space = 1; /* VRAPI_TRACKING_SPACE_LOCAL */
/* VrApi's spaces: 0 LOCAL (eye level), 1 LOCAL_FLOOR, 2 LOCAL_TILTED, 3 STAGE. The 64-bit bridge starts in LOCAL:
* reporting 1 here told Quest builds they already had a floor-level origin, so they never asked for one and put the
* player's eyes on the floor (Shadow Point). */
static int tracking_space = 0; /* VRAPI_TRACKING_SPACE_LOCAL */
static int floor_space(int space) { return space == 1 || space == 3; }
void imp_vrapi_SetTrackingSpace(a32_cpu *c, argreader *R)
{
void *m = h2ptr(rd32(R));
int space = (int)rd32(R);
int r = bridge() && m && B.SetTrackingSpace ? B.SetTrackingSpace(m, space) : 0;
if (!r) tracking_space = space;
if (!r && tracking_space != space) { tracking_space = space; tracking_reset(); }
bridge_log(ANDROID_LOG_INFO, "a32 SetTrackingSpace(%d) -> %d", space, r);
ret32(c, (uint32_t)r);
}
void imp_vrapi_GetTrackingSpace(a32_cpu *c, argreader *R) { ret32(c, (uint32_t)tracking_space); }
/* Older OVRPlugin (Shadow Point 1.16) selects its origin with these APIs, not SetTrackingSpace.
* Get returns ovrPosef via hidden r0. Set passes its 28-byte aggregate in r1-r3 then four stack words;
* it has 4-byte alignment (reading doublewords would incorrectly skip r1). */
void imp_vrapi_GetTrackingTransform(a32_cpu *c, argreader *R)
{
uint32_t out = rd32(R);
void *m = h2ptr(rd32(R));
int which = (int)rd32(R);
posef64 pose = {{0, 0, 0, 1}, {0, 0, 0}};
if (bridge() && m && B.GetTrackingTransform) pose = B.GetTrackingTransform(m, which);
static unsigned logged[4];
if (which >= 0 && which < 4 && logged[which]++ < 4)
bridge_log(ANDROID_LOG_INFO, "a32 GetTrackingTransform(%d) -> q=%.3f,%.3f,%.3f,%.3f p=%.3f,%.3f,%.3f",
which, pose.Orientation[0], pose.Orientation[1], pose.Orientation[2], pose.Orientation[3],
pose.Position[0], pose.Position[1], pose.Position[2]);
if (out) memcpy(g2p(out), &pose, sizeof pose);
ret32(c, out);
}
void imp_vrapi_SetTrackingTransform(a32_cpu *c, argreader *R)
{
void *m = h2ptr(rd32(R));
posef64 pose;
for (int i = 0; i < 4; ++i) pose.Orientation[i] = rdf(R);
for (int i = 0; i < 3; ++i) pose.Position[i] = rdf(R);
tracking_reset();
if (bridge() && m && B.SetTrackingTransform) B.SetTrackingTransform(m, pose);
bridge_log(ANDROID_LOG_INFO, "a32 SetTrackingTransform: q=%.3f,%.3f,%.3f,%.3f p=%.3f,%.3f,%.3f",
pose.Orientation[0], pose.Orientation[1], pose.Orientation[2], pose.Orientation[3],
pose.Position[0], pose.Position[1], pose.Position[2]);
}
/* ovrPosef (28 bytes) through a hidden result pointer: the identity (spaces share their origin here) */
void imp_vrapi_GetTrackingSpace(a32_cpu *c, argreader *R)
{
static unsigned logged;
if (logged++ < 4) bridge_log(ANDROID_LOG_INFO, "a32 GetTrackingSpace -> %d", tracking_space);
ret32(c, (uint32_t)tracking_space);
}
/* ovrPosef (28 bytes) through a hidden result pointer: the transform between the current space and `target`. The
* eye-level (LOCAL) and floor (LOCAL_FLOOR / STAGE) origins differ by the standing eye height the 64-bit bridge also
* uses when the runtime reports no stage floor (1.6 m); otherwise the spaces share their origin. Sign from the
* headset (Shadow Point's floor-level camera rig): the identity put the eyes on the floor, -1.6 m put them 1.6 m
* below it, so games apply this as the offset that lifts the floor-relative rig to eye level. */
void imp_vrapi_LocateTrackingSpace(a32_cpu *c, argreader *R)
{
uint32_t out = rd32(R);
static const float identity[7] = {0, 0, 0, 1, 0, 0, 0};
if (out) memcpy(g2p(out), identity, sizeof identity);
rd32(R); /* ovrMobile* */
int target = (int)rd32(R);
float pose[7] = {0, 0, 0, 1, 0, 0, 0}; /* orientation x,y,z,w; position x,y,z */
if (floor_space(target) && !floor_space(tracking_space)) pose[5] = 1.6f;
else if (!floor_space(target) && floor_space(tracking_space)) pose[5] = -1.6f;
static unsigned logged;
if (logged++ < 4) bridge_log(ANDROID_LOG_INFO, "a32 LocateTrackingSpace(current=%d target=%d) -> y=%.3f",
tracking_space, target, pose[5]);
if (out) memcpy(g2p(out), pose, sizeof pose);
ret32(c, out);
}
@@ -1190,3 +1466,7 @@ void imp_vrapi_GetInstanceExtensionsVulkan(a32_cpu *c, argreader *R) { ret32(c,
void imp_vrapi_GetDeviceExtensionsVulkan(a32_cpu *c, argreader *R) { ret32(c, (uint32_t)-1050); }
void imp_vrapi_GetTextureSwapChainBufferVulkan(a32_cpu *c, argreader *R) { ret32(c, 0); }
void imp_vrapi_GetTextureSwapChainBufferFoveationVulkan(a32_cpu *c, argreader *R) { ret32(c, (uint32_t)-1050); }
#ifdef A32B_TEST
#include "../test/vrapi_test.c"
#endif
+2
View File
@@ -98,6 +98,8 @@ f ASensor_getResolution(H)
! I vrapi_SetTrackingSpace(HI)
! I vrapi_GetTrackingSpace(H)
! v vrapi_LocateTrackingSpace(PHI)
! v vrapi_GetTrackingTransform(PHI)
! v vrapi_SetTrackingTransform(Hfffffff)
! I vrapi_GetBoundaryGeometry(HIPP)
! I vrapi_GetBoundaryOrientedBoundingBox(HPP)
! I vrapi_TestPointIsInBoundary(HPPP)
+8 -4
View File
@@ -263,15 +263,15 @@ static void test_native_activity(const char *dir)
free(state);
}
static void test_cxx(const char *dir)
static void test_cxx(const char *dir, const char *name)
{
char stub[512];
snprintf(stub, sizeof stub, "%s/libbtest3.so", dir);
snprintf(stub, sizeof stub, "%s/%s.so", dir, name);
uint32_t h = a32b_load(stub);
uint32_t fn = h ? a32b_sym(h, "cxx_test") : 0;
uint32_t w = 6;
int r = fn ? (int)a32b_call(fn, &w, 1) : -1;
CHECK(r == 303, "C++ exceptions and static constructors in a guest library (%d)", r);
CHECK(r == 303, "%s: C++ exceptions and static constructors in a guest library (%d)", name, r);
}
int main(int argc, char **argv)
@@ -283,9 +283,13 @@ int main(int argc, char **argv)
/* the host process owns memory in the low 4 GiB, as ART's heap and boot image do: the guest must avoid it */
void *taken = mmap((void *)0x400000, 4 << 20, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0);
if (taken == MAP_FAILED) perror("mmap 0x400000");
extern int a32_vrapi_selftest(void);
CHECK(a32_vrapi_selftest() == 0, "VrApi ABI, render poses, tracking consistency and floor transforms");
test_jni(argv[1]);
test_native_activity(argv[1]);
test_cxx(argv[1]);
test_cxx(argv[1], "libbtest3");
test_cxx(argv[1], "libbtest3_android");
test_cxx(argv[1], "libbtest3_standard");
CHECK(mprotect(taken, 4 << 20, PROT_NONE) == 0, "the host's low mapping is still there");
if (failures) {
printf("%d FAILED\n", failures);
@@ -0,0 +1,171 @@
/* Linked only into the qemu host harness. Exercise the real AAPCS readers and thunks with a fake 64-bit API. */
#include <sys/mman.h>
static int test_tracking_calls, test_submit_calls;
static int test_tracked = 1, test_identity, test_current_only;
static posef64 test_set_pose;
static tracking2 test_tracking(void *m, double t)
{
tracking2 tr = {0};
tr.Status = test_tracked && (!test_current_only || t == 0) ? 143 : 0;
tr.HeadPose.Orientation[3] = 1;
++test_tracking_calls;
tr.HeadPose.Position[1] = (!tr.Status || test_identity) ? 1.6f : (float)test_tracking_calls;
tr.HeadPose.TimeInSeconds = t == 0 ? 42.75 : t;
return tr;
}
static posef64 test_transform(void *m, int which)
{
posef64 pose = {{0,0,0,1}, {0, which == 3 ? -1.6f : 0, 0}};
return pose;
}
static void test_set_transform(void *m, posef64 pose) { test_set_pose = pose; }
static double test_display(void *m, int64_t index) { return 42.25; }
static pose64 test_submit_pose;
static uint64_t test_submit_index;
static unsigned test_submit_layers;
static double test_submit_time;
static int test_submit(void *m, const submit2 *d)
{
++test_submit_calls;
test_submit_layers = d->LayerCount;
test_submit_index = d->FrameIndex;
test_submit_time = d->DisplayTime;
if (d->LayerCount == 1) test_submit_pose = ((const projection2 *)d->Layers[0])->HeadPose;
return 0;
}
int a32_vrapi_selftest(void)
{
int failed = 0;
#define VT(x) do { if (!(x)) { fprintf(stderr, "FAIL vrapi: %s\n", #x); ++failed; } } while (0)
uint8_t *buf = mmap((void *)0x30000000, 8192, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED_NOREPLACE, -1, 0);
if (buf == MAP_FAILED) return 1;
uint32_t g = p2g(buf);
a32_cpu cpu = {0};
argreader r;
cpu.r[0] = 123; cpu.r[1] = 0xdeadbeef; cpu.r[2] = 0x89abcdef; cpu.r[3] = 0x01234567;
ar_init(&r, &cpu, 0, g);
VT(rd32(&r) == 123); VT(rd64(&r) == UINT64_C(0x0123456789abcdef));
bridge(); /* finish pthread_once (the static harness has no real bridge DSO) */
__typeof__(B) saved = B;
int saved_ok = bridge_ok;
B.GetPredictedTracking2 = test_tracking;
B.GetTrackingTransform = test_transform; B.SetTrackingTransform = test_set_transform;
B.GetPredictedDisplayTime = test_display; B.SubmitFrame2 = test_submit;
bridge_ok = 1;
void *m = &test_set_pose;
uint32_t mh = ptr2h(m);
tracking_reset();
tracking2 a = tracking_of(m, 42.25), b = tracking_of(m, 42.25);
VT(!memcmp(&a, &b, sizeof a)); VT(test_tracking_calls == 1);
tracking_of(m, 42.5); VT(test_tracking_calls == 2);
cpu.r[0]=g; cpu.r[1]=mh; double requested=42.25; memcpy(&cpu.r[2],&requested,8);
ar_init(&r,&cpu,0,g+4096); imp_vrapi_GetPredictedTracking(&cpu,&r);
VT(!memcmp(buf,&a,104)); VT(cpu.r[0] == g);
cpu.r[0]=g; cpu.r[1]=mh;
ar_init(&r,&cpu,0,g+4096); imp_vrapi_GetPredictedTracking2(&cpu,&r);
VT(!memcmp(buf,&a,sizeof a)); VT(test_tracking_calls == 2);
tracking_reset(); test_tracked = 0;
tracking_of(m, 42.25); tracking_of(m, 42.25); VT(test_tracking_calls == 4);
test_tracked = 1;
cpu.r[0] = g; cpu.r[1] = mh; cpu.r[2] = 3;
ar_init(&r, &cpu, 0, g+4096); imp_vrapi_GetTrackingTransform(&cpu, &r);
posef64 floor; memcpy(&floor, buf, sizeof floor);
VT(floor.Orientation[3] == 1 && floor.Position[1] == -1.6f); VT(cpu.r[0] == g);
posef64 wanted = {{0.1f,0.2f,0.3f,0.9f}, {2,-1.6f,4}};
cpu.r[0] = mh;
memcpy(&cpu.r[1], &wanted, 12); memcpy(buf+4096, (uint8_t *)&wanted+12, 16);
ar_init(&r, &cpu, 0, g+4096); imp_vrapi_SetTrackingTransform(&cpu, &r);
VT(!memcmp(&wanted, &test_set_pose, sizeof wanted));
tracking_of(m, 42.25); VT(test_tracking_calls == 5); /* transform invalidated the cache */
/* Projection2: pointer widening and 88 -> 96 byte texture stride; HeadPose is ABI-identical. */
memset(buf, 0, 4096);
uint32_t layer = g+128, array = g+1024;
uint32_t chain_object = 0; uint32_t ch = ptr2h(&chain_object);
pose64 render = {0}; render.Orientation[3] = 1; render.Position[1] = 1.75f;
render.AngularVelocity[1] = 0.25f; render.LinearAcceleration[2] = 0.75f;
render.TimeInSeconds = 42.25; render.PredictionInSeconds = 0.0125;
st32(g+32,1); st32(g+36,array); st32(array,layer);
st32(layer,1); memcpy(g2p(layer+40), &render, sizeof render);
st32(layer+136,ch); st32(layer+224,ch);
uint64_t index = UINT64_C(0x123456789);
memcpy(buf+8,&index,8); double time=42.25; memcpy(buf+16,&time,8);
cpu.r[0] = mh; cpu.r[1] = g;
ar_init(&r,&cpu,0,g+4096); imp_vrapi_SubmitFrame2(&cpu,&r);
VT(test_submit_calls == 1); VT(test_submit_index == index && test_submit_time == time);
VT(!memcmp(&render,&test_submit_pose,sizeof render));
/* Legacy VrApi 1.5: 200-byte eye textures and 432-byte layers. */
memset(buf,0,4096); st32(g,3); st32(g+1736,1);
st32(g+8,ch); st32(g+208,ch); memcpy(buf+8+96,&render,sizeof render);
memcpy(buf+1744,&index,8);
cpu.r[0]=mh; cpu.r[1]=g;
ar_init(&r,&cpu,0,g+4096); imp_vrapi_SubmitFrame(&cpu,&r);
VT(test_submit_calls == 2); VT(test_submit_index == index && test_submit_time == time);
VT(!memcmp(&render,&test_submit_pose,sizeof render));
/* The early loader's 140-byte prefix shifts the otherwise identical legacy layout. */
memset(buf,0,4096); st32(g,0x10000001); st32(g+1876,1);
st32(g+148,ch); st32(g+348,ch); memcpy(buf+148+96,&render,sizeof render);
memcpy(buf+1884,&index,8);
cpu.r[0]=mh; cpu.r[1]=g;
ar_init(&r,&cpu,0,g+4096); imp_vrapi_SubmitFrame(&cpu,&r);
VT(test_submit_calls == 3); VT(test_submit_index == index && test_submit_time == time);
VT(!memcmp(&render,&test_submit_pose,sizeof render));
/* Runtime tracking loss: hold head and eyes together for <=100 ms, while
* keeping repeated answers consistent without renewing the original sample age. */
tracking2 good = tracking_of(m, 42.25);
test_tracked = 0;
int calls = test_tracking_calls;
tracking2 held = tracking_of(m, 42.26);
VT(held.Status == good.Status && held.HeadPose.Position[1] == good.HeadPose.Position[1]);
VT(held.HeadPose.TimeInSeconds == 42.26);
VT(!memcmp(held.Eye, good.Eye, sizeof held.Eye));
tracking_of(m, 42.26); VT(test_tracking_calls == calls+1);
VT(tracking_of(m, 42.36).Status == 0); /* held .26 did not renew .25 */
VT(tracking_of(m, 42.5).Status == 0);
test_tracked = 1;
/* Default submitted without a matching tracking request: repair at display time.
* Both conversion paths use this gate; preserve valid arbitrary render poses. */
projection2 projection = {0}; projection.Header.Type = 1;
projection.HeadPose.Orientation[3] = 1; projection.HeadPose.Position[1] = 1.6f;
projection.HeadPose.TimeInSeconds = 42.26;
const header2 *headers[] = {&projection.Header};
submit2 desc = {0}; desc.DisplayTime = 42.25; desc.LayerCount = 1; desc.Layers = headers;
submit_prepare(m, &desc);
VT(desc.LayerCount == 1 && projection.HeadPose.Position[1] == good.HeadPose.Position[1]);
VT(projection.HeadPose.TimeInSeconds == 42.26); /* exact render-time held answer */
/* Genuine tracked identity and head-locked identity must remain unchanged. */
tracking_reset(); test_identity = 1;
tracking2 identity = tracking_of(m, 42.25);
projection.HeadPose = identity.HeadPose; submit_prepare(m, &desc);
VT(!memcmp(&projection.HeadPose, &identity.HeadPose, sizeof(pose64)));
tracking_reset(); test_tracked = 0;
projection.Header.Flags = 4; submit_prepare(m, &desc); VT(desc.LayerCount == 1);
projection.Header.Flags = 0; submit_prepare(m, &desc); VT(desc.LayerCount == 0);
/* Check the real SubmitFrame2 thunk ends an empty frame with no tracked fallback. */
memset(buf, 0, 4096);
st32(g+32,1); st32(g+36,array); st32(array,layer); st32(layer,1);
st32(layer+136,ch); st32(layer+224,ch);
memcpy(g2p(layer+40), &identity.HeadPose, sizeof(pose64)); memcpy(buf+16,&time,8);
cpu.r[0]=mh; cpu.r[1]=g;
ar_init(&r,&cpu,0,g+4096); imp_vrapi_SubmitFrame2(&cpu,&r);
VT(test_submit_calls == 4 && test_submit_layers == 0);
test_tracked = 1; test_identity = 0; test_current_only = 1;
desc.LayerCount = 1; projection.HeadPose = identity.HeadPose;
submit_prepare(m, &desc);
VT(desc.LayerCount == 1 && projection.HeadPose.TimeInSeconds == 42.75);
VT(desc.DisplayTime == 42.25); /* no extra WaitFrame or display-time advance */
test_current_only = 0;
/* Keep an update/render pair across more than the old 16 cache entries. */
tracking_reset();
good = tracking_of(m, 42.25);
for (int j = 1; j <= 40; ++j) tracking_of(m, 42.25+j*0.001);
calls = test_tracking_calls;
held = tracking_of(m, 42.25);
VT(test_tracking_calls == calls && !memcmp(&held, &good, sizeof held));
test_tracked = 1; test_identity = 0;
tracking_reset(); B = saved; bridge_ok = saved_ok;
munmap(buf,8192);
fprintf(stderr, "vrapi ABI/cache/transform tests: %s\n", failed ? "FAILED" : "OK");
#undef VT
return failed;
}
+218 -89
View File
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-only
// Included by frame_adapter.c. Emulates XR_FB_composition_layer_image_layout's VERTICAL_FLIP for quad
// layers of Vulkan apps when the runtime lacks the extension (Frame): the app's image is blitted
// upside-down into a runtime-owned shadow swapchain, which is submitted instead.
// layers of Vulkan apps when the runtime lacks the extension (Frame): the quad's rectangle is turned upside down in
// the app's own image (through a scratch image of ours), once per acquired image. An extra runtime swapchain per
// flipped quad (the first version) ran into the Frame's swapchain limit: AC Nexus has more UI panels than it allows,
// so some stayed upside down and the game's own later panels failed with XR_ERROR_LIMIT_REACHED (GitHub #138).
#define VK_NO_PROTOTYPES
#include <vulkan/vulkan.h>
#define XR_USE_GRAPHICS_API_VULKAN
@@ -40,6 +42,25 @@ static struct {
PFN_vkCreateFence CreateFence;
PFN_vkWaitForFences WaitForFences;
PFN_vkResetFences ResetFences;
PFN_vkCreateImage CreateImage;
PFN_vkDestroyImage DestroyImage;
PFN_vkGetImageMemoryRequirements GetImageMemoryRequirements;
PFN_vkAllocateMemory AllocateMemory;
PFN_vkFreeMemory FreeMemory;
PFN_vkBindImageMemory BindImageMemory;
PFN_vkCreateBuffer CreateBuffer; // flip_dump
PFN_vkDestroyBuffer DestroyBuffer;
PFN_vkGetBufferMemoryRequirements GetBufferMemoryRequirements;
PFN_vkBindBufferMemory BindBufferMemory;
PFN_vkMapMemory MapMemory;
PFN_vkUnmapMemory UnmapMemory;
PFN_vkCmdCopyImageToBuffer CmdCopyImageToBuffer;
VkPhysicalDeviceMemoryProperties memory;
// scratch image for in-place flips: grown to the largest rectangle, one format at a time
VkImage scratch;
VkDeviceMemory scratch_memory;
VkFormat scratch_format;
uint32_t scratch_w, scratch_h;
} vk;
typedef struct {
@@ -49,10 +70,12 @@ typedef struct {
uint32_t image_count;
uint32_t last_acquired;
uint32_t acquired[MAX_IMAGES], acquired_count, waited_count;
XrSwapchain shadow;
XrRect2Di shadow_rect;
VkImage shadow_images[MAX_IMAGES];
uint32_t shadow_count;
// rectangles already turned upside down in each image since the app last acquired it (static quads are
// submitted again without a new acquire: flipping them again would undo it)
struct { XrRect2Di rect; uint32_t layer; } flipped[MAX_IMAGES][4];
uint32_t flipped_count[MAX_IMAGES];
int dumped; // flip_dump saved this swapchain's quad
int logged; // its first in-place flip was logged
} tracked_swapchain;
static pthread_mutex_t flip_lock = PTHREAD_MUTEX_INITIALIZER;
@@ -81,6 +104,9 @@ static void flip_on_create_session(const XrSessionCreateInfo *info) {
vk.family = b->queueFamilyIndex;
vk.index = b->queueIndex;
vk.state = 0;
vk.scratch = VK_NULL_HANDLE; // belonged to the previous session's device
vk.scratch_memory = VK_NULL_HANDLE;
vk.scratch_w = vk.scratch_h = 0;
LOG("flip: Vulkan session (queue family %u index %u)", vk.family, vk.index);
}
}
@@ -109,22 +135,26 @@ static void flip_on_acquire(XrSwapchain handle, uint32_t index) {
tracked_swapchain *t = find_tracked(handle, 0);
if (t) {
t->last_acquired = index;
if (index < MAX_IMAGES) t->flipped_count[index] = 0; // new content: upside down again
if(t->acquired_count<MAX_IMAGES)t->acquired[t->acquired_count++]=index;
}
pthread_mutex_unlock(&flip_lock);
}
static void flip_on_destroy(XrSwapchain handle) {
PFN_xrDestroySwapchain destroy = (PFN_xrDestroySwapchain)lookup(active_instance, "xrDestroySwapchain");
pthread_mutex_lock(&flip_lock);
tracked_swapchain *t = find_tracked(handle, 0);
if (t) {
if (t->shadow && destroy) destroy(t->shadow);
memset(t, 0, sizeof(*t));
}
if (t) memset(t, 0, sizeof(*t));
pthread_mutex_unlock(&flip_lock);
}
// xrCreateSwapchain: single-image colour swapchains of a Vulkan session (quad candidates) also get transfer usage,
// so the in-place flip may blit from and back into them.
static int flip_wants_transfer(const XrSwapchainCreateInfo *info) {
return flip_emul && vk.device && !runtime_has_image_layout && info->arraySize == 1 && info->faceCount == 1 &&
info->sampleCount == 1 && (info->usageFlags & XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT);
}
static int vk_ready(void) {
if (vk.state) return vk.state > 0;
vk.state = -1;
@@ -137,8 +167,15 @@ static int vk_ready(void) {
#define DEV(name) vk.name = (PFN_vk##name)vk.GetDeviceProcAddr(vk.device, "vk" #name); if (!vk.name) return 0;
DEV(GetDeviceQueue) DEV(CreateCommandPool) DEV(AllocateCommandBuffers) DEV(BeginCommandBuffer)
DEV(EndCommandBuffer) DEV(ResetCommandBuffer) DEV(CmdPipelineBarrier) DEV(CmdBlitImage) DEV(CmdClearColorImage) DEV(QueueSubmit)
DEV(CreateFence) DEV(WaitForFences) DEV(ResetFences)
DEV(CreateFence) DEV(WaitForFences) DEV(ResetFences) DEV(CreateImage) DEV(DestroyImage)
DEV(GetImageMemoryRequirements) DEV(AllocateMemory) DEV(FreeMemory) DEV(BindImageMemory)
DEV(CreateBuffer) DEV(DestroyBuffer) DEV(GetBufferMemoryRequirements) DEV(BindBufferMemory) DEV(MapMemory)
DEV(UnmapMemory) DEV(CmdCopyImageToBuffer)
#undef DEV
PFN_vkGetPhysicalDeviceMemoryProperties memory_properties =
(PFN_vkGetPhysicalDeviceMemoryProperties)gipa(vk.instance, "vkGetPhysicalDeviceMemoryProperties");
if (!memory_properties) return 0;
memory_properties(vk.physical, &vk.memory);
vk.GetDeviceQueue(vk.device, vk.family, vk.index, &vk.queue);
VkCommandPoolCreateInfo pool = {VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, NULL,
VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, vk.family};
@@ -169,103 +206,195 @@ static int wants_vertical_flip(const XrCompositionLayerQuad *q) {
return 0;
}
// Returns a replacement quad referencing a flipped copy, or NULL to keep the original.
static const XrCompositionLayerQuad *flip_quad(XrSession session, const XrCompositionLayerQuad *q,
XrCompositionLayerQuad *out) {
if (!flip_emul || runtime_has_image_layout || !wants_vertical_flip(q)) return NULL;
if (!vk_ready()) { static int warned; if (!warned++) LOG("flip: needed but Vulkan blitter unavailable (GLES app?)"); return NULL; }
PFN_xrCreateSwapchain create = (PFN_xrCreateSwapchain)lookup(active_instance, "xrCreateSwapchain");
PFN_xrEnumerateSwapchainImages enumerate =
(PFN_xrEnumerateSwapchainImages)lookup(active_instance, "xrEnumerateSwapchainImages");
PFN_xrAcquireSwapchainImage acquire = (PFN_xrAcquireSwapchainImage)lookup(active_instance, "xrAcquireSwapchainImage");
PFN_xrWaitSwapchainImage wait = (PFN_xrWaitSwapchainImage)lookup(active_instance, "xrWaitSwapchainImage");
PFN_xrReleaseSwapchainImage release = (PFN_xrReleaseSwapchainImage)lookup(active_instance, "xrReleaseSwapchainImage");
if (!create || !enumerate || !acquire || !wait || !release) return NULL;
pthread_mutex_lock(&flip_lock);
tracked_swapchain *t = find_tracked(q->subImage.swapchain, 0);
const XrCompositionLayerQuad *result = NULL;
const XrRect2Di rect = q->subImage.imageRect;
if (!t || !t->image_count || t->last_acquired >= t->image_count || rect.extent.width <= 0 || rect.extent.height <= 0) {
static int skipped;
if (skipped++ < 10)
LOG("flip: skipped quad swapchain=%p tracked=%d images=%u last=%u rect=%dx%d", (void *)(uintptr_t)q->subImage.swapchain,
t != NULL, t ? t->image_count : 0, t ? t->last_acquired : 0, rect.extent.width, rect.extent.height);
goto done;
// A scratch image at least w x h in this format (device-local, transfer source + destination).
static int scratch_fit(VkFormat format, uint32_t w, uint32_t h) {
if (vk.scratch && vk.scratch_format == format && vk.scratch_w >= w && vk.scratch_h >= h) return 1;
if (vk.scratch) {
vk.DestroyImage(vk.device, vk.scratch, NULL);
vk.FreeMemory(vk.device, vk.scratch_memory, NULL);
vk.scratch = VK_NULL_HANDLE;
vk.scratch_memory = VK_NULL_HANDLE;
}
if (t->shadow && (t->shadow_rect.extent.width != rect.extent.width || t->shadow_rect.extent.height != rect.extent.height)) {
// The app changed the sub-rectangle it shows: rebuild the shadow at the new size.
PFN_xrDestroySwapchain destroy = (PFN_xrDestroySwapchain)lookup(active_instance, "xrDestroySwapchain");
forget_swapchain(t->shadow);
if (destroy) destroy(t->shadow);
t->shadow = XR_NULL_HANDLE;
if (vk.scratch_format == format) { // grow, don't shrink: panels of one game come in several sizes
if (vk.scratch_w > w) w = vk.scratch_w;
if (vk.scratch_h > h) h = vk.scratch_h;
}
if (!t->shadow) {
XrSwapchainCreateInfo ci = t->info;
ci.createFlags = 0;
ci.usageFlags |= XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT | XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT;
ci.width = (uint32_t)rect.extent.width;
ci.height = (uint32_t)rect.extent.height;
ci.arraySize = 1;
ci.mipCount = 1;
ci.faceCount = 1;
if (XR_FAILED(create(session, &ci, &t->shadow))) { t->shadow = XR_NULL_HANDLE; goto done; }
remember_swapchain(t->shadow);
XrSwapchainImageVulkanKHR images[MAX_IMAGES];
for (int i = 0; i < MAX_IMAGES; ++i) images[i] = (XrSwapchainImageVulkanKHR){XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR, NULL, VK_NULL_HANDLE};
uint32_t n = 0;
if (XR_FAILED(enumerate(t->shadow, MAX_IMAGES, &n, (XrSwapchainImageBaseHeader *)images))) goto done;
t->shadow_count = n;
for (uint32_t i = 0; i < n; ++i) t->shadow_images[i] = images[i].image;
t->shadow_rect = rect;
LOG("flip: created %dx%d shadow swapchain for upside-down quad", rect.extent.width, rect.extent.height);
VkImageCreateInfo ci = {VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, NULL, 0, VK_IMAGE_TYPE_2D, format, {w, h, 1}, 1, 1,
VK_SAMPLE_COUNT_1_BIT, VK_IMAGE_TILING_OPTIMAL,
VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT,
VK_SHARING_MODE_EXCLUSIVE, 0, NULL, VK_IMAGE_LAYOUT_UNDEFINED};
if (vk.CreateImage(vk.device, &ci, NULL, &vk.scratch) != VK_SUCCESS) { vk.scratch = VK_NULL_HANDLE; return 0; }
VkMemoryRequirements req;
vk.GetImageMemoryRequirements(vk.device, vk.scratch, &req);
uint32_t type = UINT32_MAX;
for (uint32_t i = 0; i < vk.memory.memoryTypeCount && type == UINT32_MAX; ++i)
if ((req.memoryTypeBits & (1u << i)) &&
(vk.memory.memoryTypes[i].propertyFlags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT)) type = i;
for (uint32_t i = 0; i < vk.memory.memoryTypeCount && type == UINT32_MAX; ++i)
if (req.memoryTypeBits & (1u << i)) type = i;
VkMemoryAllocateInfo ai = {VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, NULL, req.size, type};
if (type == UINT32_MAX || vk.AllocateMemory(vk.device, &ai, NULL, &vk.scratch_memory) != VK_SUCCESS ||
vk.BindImageMemory(vk.device, vk.scratch, vk.scratch_memory, 0) != VK_SUCCESS) {
if (vk.scratch_memory) vk.FreeMemory(vk.device, vk.scratch_memory, NULL);
vk.DestroyImage(vk.device, vk.scratch, NULL);
vk.scratch = VK_NULL_HANDLE;
vk.scratch_memory = VK_NULL_HANDLE;
return 0;
}
vk.scratch_format = format;
vk.scratch_w = w;
vk.scratch_h = h;
LOG("flip: scratch image %ux%u format %d", w, h, (int)format);
return 1;
}
uint32_t index = 0;
XrSwapchainImageAcquireInfo ai = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO, NULL};
XrSwapchainImageWaitInfo wi = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO, NULL, 100000000};
XrSwapchainImageReleaseInfo ri = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO, NULL};
if (XR_FAILED(acquire(t->shadow, &ai, &index)) || index >= t->shadow_count) goto done;
if (XR_FAILED(wait(t->shadow, &wi))) { release(t->shadow, &ri); goto done; }
VkImage src = t->images[t->last_acquired], dst = t->shadow_images[index];
uint32_t layer = q->subImage.imageArrayIndex;
// flip_dump=N (diagnostics): the quad's rectangle as the app drew it ("raw") and as submitted after the flip
// ("upright"), saved as files/fb_flip_<n>_<raw|upright>.ppm (8-bit RGBA formats only).
static int dump_rect(VkImage app, uint32_t layer, XrRect2Di rect, int64_t format, int n, const char *what) {
int written = 0;
if (!*snap_dir || (format != VK_FORMAT_R8G8B8A8_SRGB && format != VK_FORMAT_R8G8B8A8_UNORM &&
format != VK_FORMAT_B8G8R8A8_SRGB && format != VK_FORMAT_B8G8R8A8_UNORM)) return 0;
uint32_t w = (uint32_t)rect.extent.width, h = (uint32_t)rect.extent.height;
VkDeviceSize size = (VkDeviceSize)w * h * 4;
VkBufferCreateInfo bi = {VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, NULL, 0, size, VK_BUFFER_USAGE_TRANSFER_DST_BIT,
VK_SHARING_MODE_EXCLUSIVE, 0, NULL};
VkBuffer buf = VK_NULL_HANDLE;
VkDeviceMemory mem = VK_NULL_HANDLE;
if (vk.CreateBuffer(vk.device, &bi, NULL, &buf) != VK_SUCCESS) return 0;
VkMemoryRequirements req;
vk.GetBufferMemoryRequirements(vk.device, buf, &req);
uint32_t type = UINT32_MAX;
for (uint32_t i = 0; i < vk.memory.memoryTypeCount && type == UINT32_MAX; ++i)
if ((req.memoryTypeBits & (1u << i)) && (vk.memory.memoryTypes[i].propertyFlags &
(VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) ==
(VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) type = i;
VkMemoryAllocateInfo ai = {VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, NULL, req.size, type};
if (type == UINT32_MAX || vk.AllocateMemory(vk.device, &ai, NULL, &mem) != VK_SUCCESS ||
vk.BindBufferMemory(vk.device, buf, mem, 0) != VK_SUCCESS) goto out;
VkCommandBufferBeginInfo begin = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, NULL,
VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, NULL};
vk.ResetCommandBuffer(vk.cmd, 0);
vk.BeginCommandBuffer(vk.cmd, &begin);
barrier(src, layer, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
barrier(app, layer, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT);
VkBufferImageCopy region = {0, 0, 0, {VK_IMAGE_ASPECT_COLOR_BIT, 0, layer, 1},
{rect.offset.x, rect.offset.y, 0}, {w, h, 1}};
vk.CmdCopyImageToBuffer(vk.cmd, app, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, buf, 1, &region);
barrier(app, layer, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT);
vk.EndCommandBuffer(vk.cmd);
VkSubmitInfo submit = {VK_STRUCTURE_TYPE_SUBMIT_INFO, NULL, 0, NULL, NULL, 1, &vk.cmd, 0, NULL};
int ok = vk.QueueSubmit(vk.queue, 1, &submit, vk.fence) == VK_SUCCESS &&
vk.WaitForFences(vk.device, 1, &vk.fence, VK_TRUE, 1000000000ull) == VK_SUCCESS;
vk.ResetFences(vk.device, 1, &vk.fence);
void *mapped = NULL;
if (!ok || vk.MapMemory(vk.device, mem, 0, size, 0, &mapped) != VK_SUCCESS) goto out;
const unsigned char *bytes = mapped;
VkDeviceSize nonzero = 0;
for (VkDeviceSize i = 0; i < size && !nonzero; ++i) nonzero = bytes[i];
char path[320];
snprintf(path, sizeof(path), "%s/fb_flip_%d_%s.ppm", snap_dir, n, what);
FILE *f = nonzero ? fopen(path, "wb") : NULL; // (an empty panel, e.g. before its first content: not counted)
if (f) {
int bgra = format == VK_FORMAT_B8G8R8A8_SRGB || format == VK_FORMAT_B8G8R8A8_UNORM;
fprintf(f, "P6\n%u %u\n255\n", w, h);
const unsigned char *px = mapped;
for (VkDeviceSize i = 0; i < (VkDeviceSize)w * h; ++i) {
unsigned char rgb[3] = {px[i * 4 + (bgra ? 2 : 0)], px[i * 4 + 1], px[i * 4 + (bgra ? 0 : 2)]};
fwrite(rgb, 1, 3, f);
}
fclose(f);
written = 1;
LOG("flip_dump: wrote %s (%ux%u)", path, w, h);
}
vk.UnmapMemory(vk.device, mem);
out:
if (mem) vk.FreeMemory(vk.device, mem, NULL);
vk.DestroyBuffer(vk.device, buf, NULL);
return written;
}
// Turns the quad's rectangle upside down in the app's own image, once per acquire of that image. Always returns
// NULL: the original quad (same swapchain, same rectangle) is submitted and now shows upright.
static const XrCompositionLayerQuad *flip_quad(XrSession session, const XrCompositionLayerQuad *q,
XrCompositionLayerQuad *out) {
(void)session; (void)out;
if (!flip_emul || runtime_has_image_layout || !wants_vertical_flip(q)) return NULL;
if (!vk_ready()) { static int warned; if (!warned++) LOG("flip: needed but Vulkan blitter unavailable (GLES app?)"); return NULL; }
pthread_mutex_lock(&flip_lock);
tracked_swapchain *t = find_tracked(q->subImage.swapchain, 0);
const XrRect2Di rect = q->subImage.imageRect;
uint32_t layer = q->subImage.imageArrayIndex, index = t ? t->last_acquired : 0;
if (!t || !t->image_count || index >= t->image_count || rect.extent.width <= 0 || rect.extent.height <= 0) {
static int skipped;
if (skipped++ < 10)
LOG("flip: skipped quad swapchain=%p tracked=%d images=%u last=%u rect=%dx%d", (void *)(uintptr_t)q->subImage.swapchain,
t != NULL, t ? t->image_count : 0, index, rect.extent.width, rect.extent.height);
goto done;
}
for (uint32_t i = 0; i < t->flipped_count[index]; ++i)
if (t->flipped[index][i].layer == layer && !memcmp(&t->flipped[index][i].rect, &rect, sizeof rect)) goto done;
if (t->flipped_count[index] >= 4) goto done; // never seen: more than 4 quads on one image
if (!(t->info.usageFlags & XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT)) { // created before the Vulkan session was known
static int warned;
if (warned++ < 5) LOG("flip: quad swapchain=%p has no transfer usage, left as it is", (void *)(uintptr_t)q->subImage.swapchain);
goto done;
}
uint32_t w = (uint32_t)rect.extent.width, h = (uint32_t)rect.extent.height;
if (!scratch_fit((VkFormat)t->info.format, w, h)) {
static int failed;
if (failed++ < 5) LOG("flip: no scratch image for %ux%u format %lld", w, h, (long long)t->info.format);
goto done;
}
VkImage app = t->images[index];
static int dumps; // one non-empty image per quad swapchain, at most flip_dump of them
int dump = 0;
if (dumps < flip_dump && !t->dumped && dump_rect(app, layer, rect, t->info.format, dumps + 1, "raw")) {
dump = ++dumps;
t->dumped = 1;
}
VkCommandBufferBeginInfo begin = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, NULL,
VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, NULL};
vk.ResetCommandBuffer(vk.cmd, 0);
vk.BeginCommandBuffer(vk.cmd, &begin);
barrier(app, layer, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT);
barrier(dst, 0, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, VK_ACCESS_TRANSFER_WRITE_BIT);
VkImageBlit blit = {
barrier(vk.scratch, 0, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, VK_ACCESS_TRANSFER_WRITE_BIT);
VkImageBlit down = { // the rectangle into the scratch image, rows reversed
{VK_IMAGE_ASPECT_COLOR_BIT, 0, layer, 1},
{{rect.offset.x, rect.offset.y, 0}, {rect.offset.x + rect.extent.width, rect.offset.y + rect.extent.height, 1}},
{VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1},
{{0, rect.extent.height, 0}, {rect.extent.width, 0, 1}}, // rows reversed
{{0, (int32_t)h, 0}, {(int32_t)w, 0, 1}},
};
vk.CmdBlitImage(vk.cmd, src, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1,
&blit, VK_FILTER_NEAREST);
barrier(src, layer, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT);
barrier(dst, 0, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
vk.CmdBlitImage(vk.cmd, app, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, vk.scratch, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
1, &down, VK_FILTER_NEAREST);
barrier(vk.scratch, 0, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT);
barrier(app, layer, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT);
VkImageBlit back = { // and back into the app's image as it is
{VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1},
{{0, 0, 0}, {(int32_t)w, (int32_t)h, 1}},
{VK_IMAGE_ASPECT_COLOR_BIT, 0, layer, 1},
{{rect.offset.x, rect.offset.y, 0}, {rect.offset.x + rect.extent.width, rect.offset.y + rect.extent.height, 1}},
};
vk.CmdBlitImage(vk.cmd, vk.scratch, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, app, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
1, &back, VK_FILTER_NEAREST);
barrier(app, layer, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT);
vk.EndCommandBuffer(vk.cmd);
VkSubmitInfo submit = {VK_STRUCTURE_TYPE_SUBMIT_INFO, NULL, 0, NULL, NULL, 1, &vk.cmd, 0, NULL};
int ok = vk.QueueSubmit(vk.queue, 1, &submit, vk.fence) == VK_SUCCESS &&
vk.WaitForFences(vk.device, 1, &vk.fence, VK_TRUE, 100000000ull) == VK_SUCCESS;
vk.ResetFences(vk.device, 1, &vk.fence);
release(t->shadow, &ri);
if (!ok) goto done;
*out = *q;
out->subImage.swapchain = t->shadow;
out->subImage.imageArrayIndex = 0;
out->subImage.imageRect = (XrRect2Di){{0, 0}, rect.extent};
result = out;
static int logged;
if (!logged++) LOG("flip: presenting vertically flipped quad");
if (dump) dump_rect(app, layer, rect, t->info.format, dump, "upright");
t->flipped[index][t->flipped_count[index]].rect = rect;
t->flipped[index][t->flipped_count[index]++].layer = layer;
if (!t->logged++) // once per quad swapchain
LOG("flip: turning quad swapchain=%p (%ux%u) upright in place", (void *)(uintptr_t)q->subImage.swapchain, w, h);
done:
pthread_mutex_unlock(&flip_lock);
return result;
return NULL;
}
+46 -1
View File
@@ -104,6 +104,9 @@ static int fade_quad = 0;
static uint32_t runtime_max_layers = 16; // XrSystemGraphicsProperties::maxLayerCount (the Frame: 16)
static int snapshot = 0; // seconds between eye-image snapshots (snapshot_gl.c; 0 = off)
static char snap_dir[256];
static int gl_error_clear; // GLES: clear GL errors the app left pending before calling the runtime (Vader)
static int gles_session;
static int flip_dump; // diagnostics: save the first N flipped quads before/after the flip (flip_vk.c)
static void snap_on_create(XrSwapchain handle, const XrSwapchainCreateInfo *info);
static void snap_on_destroy(XrSwapchain handle);
static void snap_on_enumerate(XrSwapchain handle, uint32_t count, const XrSwapchainImageBaseHeader *images);
@@ -176,6 +179,8 @@ static void read_settings(const char *path) {
if (sscanf(line, "fade_quad=%f", &value) == 1) fade_quad = value > 0 ? (int)value : 0;
if (sscanf(line, "frame_balance=%f", &value) == 1) frame_balance = value != 0;
if (sscanf(line, "snapshot=%f", &value) == 1) snapshot = value > 0 ? (int)value : 0;
if (sscanf(line, "flip_dump=%f", &value) == 1) flip_dump = value > 0 ? (int)value : 0;
if (sscanf(line, "gl_error_clear=%f", &value) == 1) gl_error_clear = value != 0;
if (sscanf(line, "controller_fix=%f", &value) == 1) controller_fix = value != 0;
if (sscanf(line, "swapchain_fix=%f", &value) == 1) swapchain_fix = value != 0;
if (sscanf(line, "cube_standin=%f", &value) == 1) cube_standin = value != 0;
@@ -256,7 +261,7 @@ static void initialize(void) {
}
const char *env = getenv("FRAMEBRIDGE_CONFIG");
if (env && *env) read_settings(env);
if (snapshot && *process && !strchr(process, '/')) {
if ((snapshot || flip_dump) && *process && !strchr(process, '/')) {
snprintf(snap_dir, sizeof(snap_dir), "/sdcard/Android/data/%s/files", process);
LOG("per-game: snapshot every %d s to %s/fb_snap_N.ppm", snapshot, snap_dir);
}
@@ -554,6 +559,8 @@ static void forget_swapchain(XrSwapchain handle) {
}
static void flip_on_create_swapchain(XrSwapchain handle, const XrSwapchainCreateInfo *info);
static int flip_wants_transfer(const XrSwapchainCreateInfo *info);
static void clear_gl_errors(const char *where);
static void flip_on_destroy(XrSwapchain handle);
static void emul_on_create_swapchain(XrSwapchain handle, const XrSwapchainCreateInfo *info);
static void emul_on_destroy_swapchain(XrSwapchain handle);
@@ -636,12 +643,16 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
if(surface_native && fixed.arraySize==2 && fixed.sampleCount==1 &&
(fixed.usageFlags&XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT))
fixed.usageFlags |= XR_SWAPCHAIN_USAGE_TRANSFER_SRC_BIT;
// in-place flips of upside-down quads (flip_vk.c) blit from and back into these images
if (flip_wants_transfer(&fixed))
fixed.usageFlags |= XR_SWAPCHAIN_USAGE_TRANSFER_SRC_BIT | XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT;
if (equirect_emul && swapchain && emul_virtual_create(session, &fixed, swapchain)) {
remember_swapchain(*swapchain);
emul_on_create_swapchain(*swapchain, &fixed);
snap_on_create(*swapchain, &fixed);
return XR_SUCCESS;
}
clear_gl_errors("xrCreateSwapchain");
XrResult result = fn(session, &fixed, swapchain);
LOG("xrCreateSwapchain %ux%u format=%lld samples=%u array=%u faces=%u usage=0x%llx flags=0x%llx result=%d",
fixed.width, fixed.height, (long long)fixed.format, fixed.sampleCount, fixed.arraySize, fixed.faceCount,
@@ -1096,6 +1107,35 @@ static int ask_graphics_requirements(XrInstance instance, const XrSessionCreateI
return 0;
}
// gl_error_clear: the runtime's GL interop checks glGetError after its own GL calls and fails on any error, also one
// the app left pending on the same context. Vader Immortal leaves GL_INVALID_OPERATION pending before its first
// frame; the runtime then logs "SXR_GL_CHECK: error:0x502 ... glGenTextures", and from that moment allocates a new
// command buffer every frame (CVRCompositorSharedTextures::BeginCommandBuffer, ~20 MB/s, GitHub #285).
static void clear_gl_errors(const char *where) {
if (!gl_error_clear || !gles_session) return;
static unsigned (*get_error)(void);
static void *(*current_context)(void);
static int resolved;
if (!resolved) {
resolved = 1;
get_error = (unsigned (*)(void))dlsym(RTLD_DEFAULT, "glGetError");
current_context = (void *(*)(void))dlsym(RTLD_DEFAULT, "eglGetCurrentContext");
if (!get_error || !current_context) {
void *gl = dlopen("libGLESv2.so", RTLD_NOW | RTLD_NOLOAD), *egl = dlopen("libEGL.so", RTLD_NOW | RTLD_NOLOAD);
if (!get_error && gl) get_error = (unsigned (*)(void))dlsym(gl, "glGetError");
if (!current_context && egl) current_context = (void *(*)(void))dlsym(egl, "eglGetCurrentContext");
}
LOG("gl_error_clear: %s", get_error && current_context ? "on" : "glGetError/eglGetCurrentContext not found");
}
if (!get_error || !current_context || !current_context()) return;
for (int i = 0; i < 16; ++i) {
unsigned err = get_error();
if (!err) break;
static int logged;
if (logged++ < 20) LOG("gl_error_clear: cleared the app's pending GL error 0x%x before %s", err, where);
}
}
XRAPI_ATTR XrResult XRAPI_CALL xrCreateSession(XrInstance instance, const XrSessionCreateInfo *info, XrSession *session) {
PFN_xrCreateSession fn = (PFN_xrCreateSession)lookup(instance, "xrCreateSession");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
@@ -1103,6 +1143,9 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSession(XrInstance instance, const XrSess
if (result == XR_ERROR_GRAPHICS_REQUIREMENTS_CALL_MISSING && ask_graphics_requirements(instance, info))
result = fn(instance, info, session);
if (XR_SUCCEEDED(result)) {
gles_session = 0;
for (const XrBaseInStructure *b = info ? (const XrBaseInStructure *)info->next : NULL; b; b = b->next)
gles_session |= b->type == (XrStructureType)1000024001; // XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR
foveation_eye_supported = 0;
if (foveation_eye_enabled) {
PFN_xrGetSystemProperties properties = (PFN_xrGetSystemProperties)lookup(instance, "xrGetSystemProperties");
@@ -1496,6 +1539,7 @@ static XrTime last_begin_display_time;
XRAPI_ATTR XrResult XRAPI_CALL xrBeginFrame(XrSession session, const XrFrameBeginInfo *info) {
PFN_xrBeginFrame fn = (PFN_xrBeginFrame)lookup(active_instance, "xrBeginFrame");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
clear_gl_errors("xrBeginFrame");
if (frame_open && frame_balance) {
PFN_xrEndFrame end = (PFN_xrEndFrame)lookup(active_instance, "xrEndFrame");
XrFrameEndInfo empty = {XR_TYPE_FRAME_END_INFO, NULL, last_begin_display_time ? last_begin_display_time
@@ -1520,6 +1564,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
PFN_xrEndFrame fn = (PFN_xrEndFrame)lookup(active_instance, "xrEndFrame");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
frame_open = 0;
clear_gl_errors("xrEndFrame");
__atomic_add_fetch(&frame_ends, 1, __ATOMIC_RELAXED);
snap_end_frame(info);
if(surface_native)++sc_serial;
+8 -2
View File
@@ -380,6 +380,11 @@ def build_tlskeys(tc: Path):
"-Wl,-z,max-page-size=16384", "tlskeys.c", "-llog", "-o", ART / "arm64-v8a/libfp_tkeys.so"], cwd=src)
def build_luajit(tc: Path):
run([sys.executable, HERE / "luajit/build.py", "--clang-dir", tc / "bin",
"--out", ART / "arm64-v8a/libfp_luajit.so"])
def build_a32(tc: Path):
"""The 32-bit ARM bridge (a32translator/bridge, patch frame.a32_bridge): its own build script, then the three
files every bridged game ships (liba32rt.so is 32-bit ARM code: it runs inside the translator)."""
@@ -433,7 +438,7 @@ def write_sums():
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--only", default="adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,foveation,ovrpshim,tlskeys,vrsettings,venc,a32")
ap.add_argument("--only", default="adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,foveation,ovrpshim,tlskeys,luajit,vrsettings,venc,a32")
ap.add_argument("--ndk")
args = ap.parse_args()
parts = set(args.only.split(","))
@@ -447,9 +452,10 @@ def main():
"zinkfix": lambda: build_zinkfix(tc),
"foveation": lambda: build_foveation(tc),
"ovrpshim": lambda: build_ovrpshim(tc), "tlskeys": lambda: build_tlskeys(tc),
"luajit": lambda: build_luajit(tc),
"vrsettings": lambda: build_vrsettings(tc), "venc": lambda: build_venc(tc),
"vroverlay_probe": lambda: build_vroverlay_probe(tc), "a32": lambda: build_a32(tc)}
for name in ("adapter", "bridge", "compat", "langpack", "glshim", "eglfmt", "glmv", "ovrtrace", "xrshim", "vkshim", "zinkfix", "foveation", "ovrpshim", "tlskeys", "dex", "xrlayer", "oculushmd", "vrsettings", "venc",
for name in ("adapter", "bridge", "compat", "langpack", "glshim", "eglfmt", "glmv", "ovrtrace", "xrshim", "vkshim", "zinkfix", "foveation", "ovrpshim", "tlskeys", "luajit", "dex", "xrlayer", "oculushmd", "vrsettings", "venc",
"vroverlay_probe", "a32"):
if name in parts:
log(f"build {name}")
+31
View File
@@ -300,6 +300,16 @@ static int assets_enabled(void) {
return ((const volatile char *)g_assets_slot)[sizeof ASSETS_MARK - 1] == '1';
}
// Triage-only bypass for games blocked on Meta's unavailable asset-file service. No file paths are invented.
#define STUB_MARK "@FPASSETSTUB@"
__attribute__((used)) static char g_stub_slot[sizeof STUB_MARK + 1] = STUB_MARK "0";
static int stub_enabled(void) {
const char *env = getenv("FRAMEPORT_ASSET_FILES_STUB");
if (env && *env) return *env == '1';
return ((const volatile char *)g_stub_slot)[sizeof STUB_MARK - 1] == '1';
}
static u64 hash_name(const char *name) {
u64 h = 1469598103934665603ULL ^ 0x66; // FNV-1a, seeded apart from the tags
for (const char *p = name; *p; p++) {
@@ -754,6 +764,14 @@ EXPORT u64 ovr_LanguagePack_SetCurrent(const char *tag) {
}
EXPORT u64 ovr_AssetFile_GetList(void) {
if (stub_enabled()) {
msg_t *m = new_msg(MSG_ASSETFILE_GETLIST, 0);
if (!m) return 0;
u64 req = m->req;
fplog("AssetFile_GetList: stub, empty list");
post(m);
return req;
}
u64 req = o_AssetFile_GetList();
fplog("AssetFile_GetList (dispatcher request %llu)", (unsigned long long)req);
if (req) {
@@ -772,6 +790,19 @@ EXPORT u64 ovr_AssetFile_GetList(void) {
}
EXPORT u64 ovr_AssetFile_StatusById(u64 id) {
if (stub_enabled()) {
msg_t *m = new_msg(MSG_ASSETFILE_STATUSBYID, 0);
if (!m) return 0;
u64 req = m->req;
m->has_det = 1;
m->det.id = id;
snprintf(m->det.type, sizeof m->det.type, "default");
snprintf(m->det.status, sizeof m->det.status, "installed");
snprintf(m->det.iap, sizeof m->det.iap, "free");
fplog("AssetFile_StatusById(%llu): stub, installed without a file", (unsigned long long)id);
post(m);
return req;
}
fplog("AssetFile_StatusById(%llu)%s", (unsigned long long)id, (id >> 48) == 0x4650 ? " (ours)" : "");
if ((id >> 48) == 0x4650) {
pack_t packs[MAXP];
+56
View File
@@ -0,0 +1,56 @@
===============================================================================
LuaJIT -- a Just-In-Time Compiler for Lua. https://luajit.org/
Copyright (C) 2005-2026 Mike Pall. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
[ MIT license: https://www.opensource.org/licenses/mit-license.php ]
===============================================================================
[ LuaJIT includes code from Lua 5.1/5.2, which has this license statement: ]
Copyright (C) 1994-2012 Lua.org, PUC-Rio.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
===============================================================================
[ LuaJIT includes code from dlmalloc, which has this license statement: ]
This is a version (aka dlmalloc) of malloc/free/realloc written by
Doug Lea and released to the public domain, as explained at
https://creativecommons.org/licenses/publicdomain
===============================================================================
+63
View File
@@ -0,0 +1,63 @@
"""Build the pinned LuaJIT engine; the game's xLua bindings are retained, never bundled here."""
from __future__ import annotations
import argparse
import hashlib
import shlex
import shutil
import subprocess
import tarfile
import urllib.request
from pathlib import Path
REVISION = "634daf2453559fd5e0ec282f3b7aedaa09e9f344"
ARCHIVE_SHA256 = "a66bc46efe5417c949fe08e224fd5362a5d395c7d96ff5acbd428580fc6b3448"
HERE = Path(__file__).resolve().parent
CACHE = HERE.parent / ".cache" / "luajit"
def sources() -> Path:
CACHE.mkdir(parents=True, exist_ok=True)
archive = CACHE / f"{REVISION}.tar.gz"
if not archive.exists():
url = f"https://codeload.github.com/LuaJIT/LuaJIT/tar.gz/{REVISION}"
with urllib.request.urlopen(url) as response:
archive.write_bytes(response.read())
if hashlib.sha256(archive.read_bytes()).hexdigest() != ARCHIVE_SHA256:
raise RuntimeError("LuaJIT source archive checksum mismatch")
root = CACHE / f"LuaJIT-{REVISION}"
if not root.exists():
with tarfile.open(archive) as tar:
tar.extractall(CACHE, filter="data")
return root / "src"
def build(tc: Path, output: Path) -> None:
src = sources()
clang = tc / "aarch64-linux-android21-clang"
flags = "-fPIC -DLUAJIT_UNWIND_INTERNAL " + shlex.quote(f"-ffile-prefix-map={src}=LuaJIT/src")
# Quotes are part of Make's variable value: the toolchain can live in a path with spaces.
settings = ["HOST_CC=cc", f"TARGET_CC={shlex.quote(str(clang))}",
f"TARGET_LD={shlex.quote(str(clang))}",
f"TARGET_AR={shlex.quote(str(tc / 'llvm-ar'))} rcus",
f"TARGET_STRIP={shlex.quote(str(tc / 'llvm-strip'))}",
"TARGET_SYS=Linux", f"TARGET_FLAGS={flags}", "BUILDMODE=static"]
subprocess.run(["make", "-C", str(src), "clean", *settings], check=True)
subprocess.run(["make", "-C", str(src), "-j4", "libluajit.a", *settings], check=True)
output.parent.mkdir(parents=True, exist_ok=True)
subprocess.run([str(clang), "-shared", "-fPIC", "-O2",
"-DLUAJIT_UNWIND_INTERNAL", "-I", str(src), "runtime.c", "-Wl,--whole-archive",
str(src / "libluajit.a"), "-Wl,--no-whole-archive", "-lm", "-ldl", "-llog",
"-Wl,-soname,libfp_luajit.so", "-Wl,-z,max-page-size=16384", "-o", str(output)],
cwd=HERE, check=True)
notices = output.parent.parent / "licenses"
notices.mkdir(exist_ok=True)
shutil.copyfile(HERE / "COPYRIGHT", notices / "LuaJIT.txt")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--clang-dir", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
build(args.clang_dir.resolve(), args.out.resolve())
+12
View File
@@ -0,0 +1,12 @@
/* FramePort's startup marker and the layout contract used by retained xLua bindings. */
#include <stddef.h>
#include <android/log.h>
#include "lj_obj.h"
_Static_assert(LJ_GC64, "Retained xLua bindings require GC64");
_Static_assert(offsetof(lua_State, glref) == 16, "xLua's xlua_gl must keep its global-state reference");
__attribute__((constructor)) static void ready(void)
{
__android_log_print(ANDROID_LOG_INFO, "FrameBridge", "lua: full-width lightuserdata runtime enabled");
}
+38
View File
@@ -0,0 +1,38 @@
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
#include <sys/mman.h>
#include "lua.h"
#include "lauxlib.h"
static const char *(*tolstring)(lua_State *,int,size_t *);
static int panic(lua_State *L) {
fprintf(stderr,"PANIC: %s\n",tolstring(L,-1,NULL)); fflush(stderr); return 0;
}
#define RESOLVE(name) __typeof__(&name) f_##name=(__typeof__(&name))dlsym(h,#name); if(!f_##name){fprintf(stderr,"missing %s: %s\n",#name,dlerror());return 4;}
int main(int argc,char **argv) {
if(argc!=3)return 2;
void*h=dlopen(argv[1],RTLD_NOW|RTLD_LOCAL); if(!h){fprintf(stderr,"dlopen: %s\n",dlerror());return 3;}
RESOLVE(luaL_newstate); RESOLVE(lua_pushlightuserdata); RESOLVE(lua_touserdata); RESOLVE(lua_atpanic); RESOLVE(lua_close);
tolstring=(void*)dlsym(h,"lua_tolstring");
void*(*tag)(void)=dlsym(h,"xlua_tag");if(!tag)return 4;
lua_State*L=f_luaL_newstate();if(!L)return 5;f_lua_atpanic(L,panic);
void*p=tag();
if(argv[2][0]=='l') {p=mmap((void*)0x10000000,4096,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,-1,0);if(p==MAP_FAILED)return 6;}
printf("native pointer=%p, above47=%u, LuaState=%p\n",p,(unsigned)((uintptr_t)p>>47!=0),L);fflush(stdout);
f_lua_pushlightuserdata(L,p);
void*back=f_lua_touserdata(L,-1);
printf("roundtrip=%p exact=%u\n",back,back==p);fflush(stdout);
if(argv[2][0]=='h' && argv[2][1]=='e') {
RESOLVE(lua_getfield); RESOLVE(lua_type); RESOLVE(lua_settop);
int(*open_perf)(lua_State*)=dlsym(h,"luaopen_perflib");
void*(*global)(lua_State*)=dlsym(h,"xlua_gl");
if(!open_perf || !global)return 8;
if(global(L)!=*(void**)((char*)L+16))return 9;
open_perf(L);f_lua_getfield(L,LUA_GLOBALSINDEX,"perf");
if(f_lua_type(L,-1)!=LUA_TTABLE)return 10;
printf("retained xLua global reference and perf helper: PASS\n");fflush(stdout);
f_lua_settop(L,0);
}
f_lua_close(L);return back==p?0:7;
}
+3 -1
View File
@@ -39,8 +39,10 @@ inline int format(char* buffer, size_t size, const float fov[2]) {
return std::snprintf(buffer, size, "fov_h=%.3f\nfov_v=%.3f\n", fov[0], fov[1]);
}
// worth rewriting the saved value: none saved yet, or a change of more than 0.01 degrees on an axis
// (the located FOV wobbles by a few hundredths of a degree from frame to frame: at 0.01 degrees the file was
// rewritten about 50 times a second in Shadow Point, on the game's thread)
inline bool changed(bool haveSaved, const float saved[2], const float now[2]) {
return !haveSaved || std::fabs(saved[0] - now[0]) > 0.01f || std::fabs(saved[1] - now[1]) > 0.01f;
return !haveSaved || std::fabs(saved[0] - now[0]) > 0.25f || std::fabs(saved[1] - now[1]) > 0.25f;
}
// the external files folder's file for the package in /proc/self/cmdline ("pkg" or "pkg:process"); false if unknown
inline bool path(const char* cmdline, char* buffer, size_t size) {
+115 -24
View File
@@ -105,10 +105,26 @@ static bool referenceSpace(XrReferenceSpaceType type, const XrPosef& pose, XrSpa
info.poseInReferenceSpace = pose;
return xrOk(s.xr.xrCreateReferenceSpace(s.session, &info, &space), "xrCreateReferenceSpace");
}
// The app space for the eye-level origin `eye` (LOCAL coordinates): VrApi's LOCAL_FLOOR and STAGE spaces put their
// origin on the floor below it (the runtime's STAGE floor, else a standing eye height of 1.6 m)
// The app space for the eye-level origin `eye` (LOCAL coordinates). VrApi's STAGE is the play area itself (its centre
// and orientation, unchanged by recentering): the runtime's STAGE space when it can be located. LOCAL_FLOOR (and STAGE
// without a located play area) puts the origin on the floor below `eye` (the runtime's STAGE floor, else a standing
// eye height of 1.6 m).
static bool appSpaceFor(const XrPosef& eye) {
auto& s = runtime();
if (s.trackingSpace == 3 && s.stageSpace && s.frame.predictedDisplayTime) {
constexpr XrSpaceLocationFlags valid = XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | XR_SPACE_LOCATION_POSITION_VALID_BIT;
XrSpaceLocation stage{XR_TYPE_SPACE_LOCATION};
XrSpace next = XR_NULL_HANDLE;
if (XR_SUCCEEDED(s.xr.xrLocateSpace(s.stageSpace, s.localSpace, s.frame.predictedDisplayTime, &stage)) &&
(stage.locationFlags & valid) == valid &&
referenceSpace(XR_REFERENCE_SPACE_TYPE_STAGE, XrPosef{{0, 0, 0, 1}, {0, 0, 0}}, next)) {
if (s.appSpace) s.xr.xrDestroySpace(s.appSpace);
s.appSpace = next;
s.eyeOrigin = eye;
s.centerEyeTransform = s.trackingTransform = fromXrPose(stage.pose);
return true;
}
}
XrPosef origin = eye;
if (s.trackingSpace == 1 || s.trackingSpace == 3) {
origin.position.y -= 1.6f;
@@ -146,6 +162,7 @@ static void destroySession() {
s.running = s.inVr = s.frameBegun = false;
s.frameIndex = -1;
s.frame = {XR_TYPE_FRAME_STATE};
s.lastEndedDisplayTime = 0;
s.timeMapped = false;
s.locatedFovValid = false;
s.initialRecenterPending = false;
@@ -336,16 +353,69 @@ bool ensureSession(VkQueue preferredQueue) {
s.initialRecenterPending = true;
return true;
}
// Pacing diagnostics, logged every 2 s while frames run: what xrWaitFrame returned (time blocked, spacing of the
// predicted display times, period), re-waits, discarded begins, and ends with/without layers or for the same refresh.
static struct {
double next = 0, waitSum = 0, waitMax = 0;
unsigned waits = 0, rewaits = 0, discarded = 0, ends = 0, empty = 0, sameRefresh = 0, failed = 0;
XrTime lastPredicted = 0, deltaMin = 0, deltaMax = 0, period = 0;
unsigned shortDeltas = 0;
} gPacing;
static void pacingWaited(double blocked, const XrFrameState& frame) {
auto& g = gPacing;
++g.waits;
g.waitSum += blocked;
g.waitMax = std::max(g.waitMax, blocked);
g.period = frame.predictedDisplayPeriod;
if (g.lastPredicted) {
const XrTime delta = frame.predictedDisplayTime - g.lastPredicted;
if (!g.deltaMin || delta < g.deltaMin) g.deltaMin = delta;
if (delta > g.deltaMax) g.deltaMax = delta;
if (frame.predictedDisplayPeriod > 0 && delta < frame.predictedDisplayPeriod / 2) ++g.shortDeltas;
}
g.lastPredicted = frame.predictedDisplayTime;
const double now = secondsNow();
if (!g.next) g.next = now + 2;
if (now < g.next) return;
OVP_LOG("pacing 2s: waits=%u (blocked avg %.2f max %.2f ms) display time step min %.2f max %.2f ms, %u under half "
"a period (%.2f ms); re-waits=%u discarded=%u; ends=%u (empty %u, same refresh %u, failed %u)",
g.waits, g.waits ? g.waitSum / g.waits * 1e3 : 0, g.waitMax * 1e3, g.deltaMin / 1e6, g.deltaMax / 1e6,
g.shortDeltas, g.period / 1e6, g.rewaits, g.discarded, g.ends, g.empty, g.sameRefresh, g.failed);
const XrTime keep = g.lastPredicted;
g = {};
g.lastPredicted = keep;
g.next = now + 2;
}
bool beginFrame(int64_t index) {
auto& s = runtime();
if (!s.session || !pollEvents()) return false;
if (!s.running) return false;
if (s.frameBegun) return true;
XrFrameWaitInfo wait{XR_TYPE_FRAME_WAIT_INFO};
s.frame = {XR_TYPE_FRAME_STATE};
if (!xrOk(s.xr.xrWaitFrame(s.session, &wait, &s.frame), "xrWaitFrame")) return false;
XrFrameBeginInfo begin{XR_TYPE_FRAME_BEGIN_INFO};
if (!xrOk(s.xr.xrBeginFrame(s.session, &begin), "xrBeginFrame")) return false;
// The Frame's runtime sometimes answers xrWaitFrame at once with a display time less than a refresh after the
// frame just submitted, and then rejects the second submission for that refresh (VRCompositorError_AlreadySubmitted
// in xrclient_<pkg>.txt). VrApi blocks in vrapi_SubmitFrame, so a game that renders faster than the display ran
// ahead (Journey of the Gods: ~120 submits a second at 90 Hz, ~30 of them rejected, the scene stepped unevenly).
// Wait again for a later display time: an xrBeginFrame after a begun frame discards that frame
// (XR_FRAME_DISCARDED), nothing is submitted for it.
for (int attempt = 0;; ++attempt) {
const double waitStart = secondsNow();
s.frame = {XR_TYPE_FRAME_STATE};
if (!xrOk(s.xr.xrWaitFrame(s.session, &wait, &s.frame), "xrWaitFrame")) return false;
pacingWaited(secondsNow() - waitStart, s.frame);
const XrResult began = s.xr.xrBeginFrame(s.session, &begin);
if (!xrOk(began, "xrBeginFrame")) return false;
if (began == XR_FRAME_DISCARDED) ++gPacing.discarded;
const XrTime half = s.frame.predictedDisplayPeriod > 0 ? s.frame.predictedDisplayPeriod / 2 : 5000000;
if (attempt >= 3 || !s.lastEndedDisplayTime || s.frame.predictedDisplayTime >= s.lastEndedDisplayTime + half)
break;
++gPacing.rewaits;
if (s.repeatedWaits++ % 500 == 0)
OVP_LOG("xrWaitFrame gave a display time %.2f ms after the last submitted one (period %.2f ms): waiting for "
"the next refresh (%u times)", (s.frame.predictedDisplayTime - s.lastEndedDisplayTime) / 1e6,
s.frame.predictedDisplayPeriod / 1e6, s.repeatedWaits);
}
s.frameBegun = true;
s.frameIndex = index;
if (!s.timeMapped) {
@@ -467,6 +537,13 @@ XrResult endFrame(const XrCompositionLayerBaseHeader* const* layers, uint32_t co
info.layers = layers;
XrResult result = s.xr.xrEndFrame(s.session, &info);
s.frameBegun = false;
++gPacing.ends;
if (!count) ++gPacing.empty;
if (XR_FAILED(result)) ++gPacing.failed;
if (s.lastEndedDisplayTime && s.frame.predictedDisplayPeriod > 0 &&
info.displayTime < s.lastEndedDisplayTime + s.frame.predictedDisplayPeriod / 2)
++gPacing.sameRefresh;
if (XR_SUCCEEDED(result)) s.lastEndedDisplayTime = info.displayTime;
xrOk(result, "xrEndFrame");
return result;
}
@@ -706,29 +783,35 @@ VRAPI ovrMobile* vrapi_EnterVrMode(const ovrModeParms* parms) {
EGLContext context = reinterpret_cast<EGLContext>(parms->ShareContext);
if (display == EGL_NO_DISPLAY) display = eglGetCurrentDisplay();
if (context == EGL_NO_CONTEXT) context = eglGetCurrentContext();
if (s.session && s.running && display == s.eglDisplay && context == s.eglContext) {
// re-entry with the context of the session vrapi_LeaveVrMode kept (see there)
// re-entry with the context of the session vrapi_LeaveVrMode kept (see there). Events that arrived while the
// game was outside VR come first: a lost or exiting session is replaced, a stopped one waits for READY below.
const bool kept = s.session && display == s.eglDisplay && context == s.eglContext && pollEvents();
if (kept && s.running) {
++s.mobile.generation;
s.inVr = true;
OVP_LOG("Entered VR mode again (kept GLES session)");
return &s.mobile;
}
if (s.session) destroySession(); // another context: a new session
s.eglDisplay = display;
s.eglContext = context;
EGLint configId = 0, count = 0;
eglQueryContext(s.eglDisplay, s.eglContext, EGL_CONFIG_ID, &configId);
// EGL_KHR_no_config_context (Unity 2019): the context has no config (id 0); use the window's, else RGBA8 ES3
EGLSurface drawn = eglGetCurrentContext() == s.eglContext ? eglGetCurrentSurface(EGL_DRAW) : EGL_NO_SURFACE;
if (!configId && drawn != EGL_NO_SURFACE) eglQuerySurface(s.eglDisplay, drawn, EGL_CONFIG_ID, &configId);
const EGLint byId[] = {EGL_CONFIG_ID, configId, EGL_NONE};
const EGLint rgba8[] = {EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8,
EGL_RENDERABLE_TYPE, 0x40 /* EGL_OPENGL_ES3_BIT */, EGL_NONE};
if (!eglChooseConfig(s.eglDisplay, configId ? byId : rgba8, &s.eglConfig, 1, &count) || count != 1) {
OVP_ERROR("EnterVrMode(GLES): could not resolve the context's EGLConfig");
return nullptr;
if (kept) {
OVP_LOG("EnterVrMode: the kept GLES session isn't running (state %d), waiting for it", s.sessionState);
} else {
if (s.session) destroySession(); // another context, or the kept session was lost: a new session
s.eglDisplay = display;
s.eglContext = context;
EGLint configId = 0, count = 0;
eglQueryContext(s.eglDisplay, s.eglContext, EGL_CONFIG_ID, &configId);
// EGL_KHR_no_config_context (Unity 2019): the context has no config (id 0); use the window's, else RGBA8 ES3
EGLSurface drawn = eglGetCurrentContext() == s.eglContext ? eglGetCurrentSurface(EGL_DRAW) : EGL_NO_SURFACE;
if (!configId && drawn != EGL_NO_SURFACE) eglQuerySurface(s.eglDisplay, drawn, EGL_CONFIG_ID, &configId);
const EGLint byId[] = {EGL_CONFIG_ID, configId, EGL_NONE};
const EGLint rgba8[] = {EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8,
EGL_RENDERABLE_TYPE, 0x40 /* EGL_OPENGL_ES3_BIT */, EGL_NONE};
if (!eglChooseConfig(s.eglDisplay, configId ? byId : rgba8, &s.eglConfig, 1, &count) || count != 1) {
OVP_ERROR("EnterVrMode(GLES): could not resolve the context's EGLConfig");
return nullptr;
}
OVP_LOG("EnterVrMode(GLES): display=%p context=%p config id=%d", s.eglDisplay, s.eglContext, configId);
}
OVP_LOG("EnterVrMode(GLES): display=%p context=%p config id=%d", s.eglDisplay, s.eglContext, configId);
}
if (!ensureSession(queue)) return nullptr;
// Runtime READY can arrive asynchronously. Wait only for a bounded lifecycle transition.
@@ -795,10 +878,18 @@ VRAPI ovrPosef vrapi_GetTrackingTransform(ovrMobile* mobile, int which) {
if (!validMobile(mobile)) return result;
if (which == 1) return s.trackingTransform;
if (which == 2) return s.centerEyeTransform;
if (which == 3 && s.stageSpace) {
if (which == 3) {
if (!s.frameBegun && !beginFrame(s.frameIndex + 1)) return result;
// Same floor origin as appSpaceFor: a runtime STAGE floor, else 1.6 m below the eye origin.
// Older OVRPlugin changes its origin via GetTrackingTransform(3)/SetTrackingTransform,
// so an identity fallback here silently puts a floor-level camera at eye level.
result = fromXrPose(s.eyeOrigin);
result.Position.y -= 1.6f;
XrSpaceLocation location{XR_TYPE_SPACE_LOCATION};
if (xrOk(s.xr.xrLocateSpace(s.stageSpace, s.localSpace, s.frame.predictedDisplayTime, &location), "floor transform") && (location.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT)) return fromXrPose(location.pose);
if (s.stageSpace && xrOk(s.xr.xrLocateSpace(s.stageSpace, s.localSpace, s.frame.predictedDisplayTime, &location), "floor transform") &&
(location.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT)) {
return fromXrPose(location.pose);
}
}
return result;
}
+2
View File
@@ -56,6 +56,8 @@ struct Runtime {
bool inVr = false, running = false, frameBegun = false;
int64_t frameIndex = -1;
XrFrameState frame{XR_TYPE_FRAME_STATE};
XrTime lastEndedDisplayTime = 0; // display time of the last xrEndFrame
uint32_t repeatedWaits = 0; // xrWaitFrame answers that repeated an already submitted display time
std::array<XrViewConfigurationView, 2> viewConfig{};
std::array<XrViewConfigurationViewFovEPIC, 2> viewConfigFov{};
std::array<float, 2> recommendedFovDegrees{};
@@ -404,7 +404,28 @@
}
VRAPI double vrapi_GetPredictedDisplayTime(ovrMobile* mobile, int64_t index) {
LOCK_STATE;
@@ -607,7 +829,12 @@
@@ -573,10 +795,18 @@
if (!validMobile(mobile)) return result;
if (which == 1) return s.trackingTransform;
if (which == 2) return s.centerEyeTransform;
- if (which == 3 && s.stageSpace) {
+ if (which == 3) {
if (!s.frameBegun && !beginFrame(s.frameIndex + 1)) return result;
+ // Same floor origin as appSpaceFor: a runtime STAGE floor, else 1.6 m below the eye origin.
+ // Older OVRPlugin changes its origin via GetTrackingTransform(3)/SetTrackingTransform,
+ // so an identity fallback here silently puts a floor-level camera at eye level.
+ result = fromXrPose(s.eyeOrigin);
+ result.Position.y -= 1.6f;
XrSpaceLocation location{XR_TYPE_SPACE_LOCATION};
- if (xrOk(s.xr.xrLocateSpace(s.stageSpace, s.localSpace, s.frame.predictedDisplayTime, &location), "floor transform") && (location.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT)) return fromXrPose(location.pose);
+ if (s.stageSpace && xrOk(s.xr.xrLocateSpace(s.stageSpace, s.localSpace, s.frame.predictedDisplayTime, &location), "floor transform") &&
+ (location.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT)) {
+ return fromXrPose(location.pose);
+ }
}
return result;
}
@@ -607,7 +837,12 @@
case 128: return 1; // Stereo array texture path.
case 129: return 1; // sRGB VkFormats retain their color encoding.
case 130: return 0; // No foveation image implementation is advertised.
@@ -418,7 +439,7 @@
}
}
VRAPI float vrapi_GetSystemPropertyFloat(const ovrJava*, int property) {
@@ -615,10 +842,20 @@
@@ -615,10 +850,20 @@
if (property == 4) return queryDisplayRefreshRate();
if (property == 7 || property == 8) {
const size_t axis = static_cast<size_t>(property - 7);
@@ -443,7 +464,7 @@
}
OVP_LOG("Unsupported float system property %d", property);
return 0;
@@ -626,7 +863,9 @@
@@ -626,7 +871,9 @@
VRAPI int vrapi_GetSystemStatusInt(const ovrJava*, int status) {
LOCK_STATE;
if (status == 0) return s.instance != XR_NULL_HANDLE;
@@ -454,7 +475,7 @@
if (status == 13) return static_cast<int>(s.recenterCount);
if (status == 14) return s.running && s.sessionState != XR_SESSION_STATE_FOCUSED;
if (status == 130) return 1;
@@ -665,3 +904,52 @@
@@ -665,3 +912,52 @@
if (!s.setColorSpace) return color->ColorSpace == 0 ? Success : Unsupported;
return xrOk(s.setColorSpace(s.session, static_cast<XrColorSpaceFB>(color->ColorSpace)), "xrSetColorSpaceFB") ? Success : InvalidParameter;
}
+378 -98
View File
@@ -1,135 +1,415 @@
"""Upload speed to the Frame by method (GitHub discussion #242: paramiko capped at ~23 MB/s on a reporter's PC).
"""Compare bulk transports on one paired Frame endpoint and one destination filesystem.
uv run python scripts/upload_bench.py [--mib 512] [--host <addr>] [--methods app,paramiko-tuned,ssh,ssh-gcm,sftp]
uv run python scripts/upload_bench.py --mib 512 --repeat 3 --json /tmp/uploads.json
uv run python scripts/upload_bench.py --fast-link --remote-dir /path/on/sdcard --check-resume
Methods, each sending the same random file to ~/frameport-bench on the Frame (removed afterwards):
- app FramePort's own upload (Frame.put: pipelined SFTP, 1 MiB writes)
- paramiko-tuned the same through a paramiko transport/SFTP with a 64 MiB window
- ssh the system's OpenSSH: `ssh … "cat > file"` (default cipher)
- ssh-gcm the same with aes128-gcm@openssh.com
- ssh-chacha the same with chacha20-poly1305@openssh.com
- sftp OpenSSH's sftp in batch mode
Prints MB/s, wall time and the CPU time spent on this PC (this process + children). Dev tool, not part of the app.
The script uses a unique scratch folder, verifies SHA-256 outside the timed
transfer, and removes its own files afterwards. Native SCP/SFTP timings include startup; SSH streaming reports
handshake separately. Windows-native Python and WSL must be measured separately.
Use --methods app-files-1,app-files-2,sftp-files-1,sftp-files-2 to compare parallel file uploads.
Both file methods accept worker counts 1, 2, 3, 4 and 8, using the same eight-file payload.
app-files-N uses the production backend (OpenSSH preferred, Paramiko fallback); its log records the actual choice.
"""
from __future__ import annotations
import argparse
import contextlib
import csv
import hashlib
import io
import json
import os
import platform
import random
import shutil
import socket
import stat
import subprocess
import sys
import tarfile
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
import paramiko # noqa: E402
from frameport.frame.connection import Frame, app_key, saved_targets, ssh_dir # noqa: E402
REMOTE = "frameport-bench"
from frameport.frame.connection import Frame, saved_targets, sh_quote, ssh_dir # noqa: E402
def cpu() -> float:
t = os.times()
return t.user + t.system + t.children_user + t.children_system
def private_key() -> str:
"""OpenSSH refuses a key file others can read (the Windows data folder under /mnt/c shows 0777): a 0600 copy."""
@contextlib.contextmanager
def private_key():
"""Temporary private copy; never alter the user's pairing key/ACL."""
key = ssh_dir() / "id_ed25519"
if os.name == "nt" or stat.S_IMODE(key.stat().st_mode) & 0o077 == 0:
return str(key)
tmp = Path(tempfile.mkdtemp()) / "id_ed25519"
shutil.copy(key, tmp)
tmp.chmod(0o600)
return str(tmp)
if os.name != "nt" and stat.S_IMODE(key.stat().st_mode) & 0o077 == 0:
yield str(key)
return
with tempfile.TemporaryDirectory(prefix="frameport-bench-key-") as directory:
copy = Path(directory) / "id_ed25519"
if os.name == "nt":
row = next(csv.reader(io.StringIO(subprocess.check_output(
["whoami", "/user", "/fo", "csv", "/nh"], text=True))))
# Restrict the directory before writing the key, including under inherited Windows ACLs.
subprocess.run(["icacls", directory, "/inheritance:r", "/grant:r", f"*{row[-1]}:(OI)(CI)F"],
check=True, stdout=subprocess.DEVNULL)
shutil.copyfile(key, copy)
if os.name == "nt":
# Python 3.13's chmod can add an OWNER RIGHTS ACE that Windows OpenSSH refuses.
subprocess.run(["icacls", str(copy), "/inheritance:r", "/remove:g", "*S-1-3-4",
"/grant:r", f"*{row[-1]}:F"], check=True, stdout=subprocess.DEVNULL)
else:
copy.chmod(0o600)
yield str(copy)
def ssh_base(frame: Frame, key: str, port_flag: str = "-p") -> list[str]:
return ["-i", key, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
def ssh_base(frame, key, port_flag="-p"):
return ["-i", key, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-o", "ConnectTimeout=10",
"-o", f"UserKnownHostsFile={ssh_dir() / 'known_hosts'}", "-o", "StrictHostKeyChecking=yes",
port_flag, str(frame.target.port)]
def m_app(frame, local, remote, key):
frame.put(local, remote, resume=False)
def cpu():
t = os.times()
return t.user + t.system + t.children_user + t.children_system
def m_paramiko_tuned(frame, local, remote, key):
sock = socket.create_connection((frame.target.host, frame.target.port), timeout=10)
t = paramiko.Transport(sock, default_window_size=64 << 20, default_max_packet_size=32768)
def digest(path):
with open(path, "rb") as source:
return hashlib.file_digest(source, "sha256").hexdigest()
def sftp_quote(path):
"""OpenSSH batch syntax, distinct from shell quoting."""
value = str(path)
if any(c in value for c in "\n\r\x00"):
raise ValueError("SFTP paths cannot contain newlines or NUL")
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def native(frame, key, tool="ssh"):
return [tool, *ssh_base(frame, key, "-p" if tool == "ssh" else "-P"),
f"{frame.target.user}@{frame.target.host}"]
def stream_ssh(frame, key, command, writer, cipher=None):
args = native(frame, key)
if cipher:
args[1:1] = ["-c", cipher]
t0 = time.monotonic()
# Drain stderr to a file: no pipe deadlock for long-running transfers or an authentication failure.
with tempfile.TemporaryFile() as errors:
process = subprocess.Popen([*args, f"printf 'READY\\n'; {command}"], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=errors)
try:
if process.stdout.readline() != b"READY\n":
process.wait(timeout=15)
errors.seek(0)
raise RuntimeError(errors.read().decode(errors="replace")[-1000:])
setup = time.monotonic() - t0
writer(process.stdin)
process.stdin.close()
if process.wait(timeout=60):
errors.seek(0)
raise RuntimeError(errors.read().decode(errors="replace")[-1000:])
return setup
finally:
if process.poll() is None:
process.kill()
process.wait()
process.stdout.close()
if not process.stdin.closed:
process.stdin.close()
def send(method, frame, local, remote, key, small, large=None, logs=None):
"""Returns measured setup seconds (None when the native tool cannot expose handshake completion)."""
t0 = time.monotonic()
if method.startswith(("app-files-", "paramiko-files-")):
from frameport.core.events import Reporter
from frameport.install.installer import upload_files
workers = int(method.rsplit("-", 1)[1])
with contextlib.closing(Frame(frame.target, frame.password).connect()) as transfer:
items = [(path, path.name, path.stat().st_size) for path in large]
def record(event):
if event.kind == "log":
if logs is not None:
logs.append(event.message)
print(f" {event.message}", flush=True)
reporter = Reporter([record])
upload_files(transfer, items, remote, reporter, sum(size for _, _, size in items), workers=workers,
prefer_native=not method.startswith("paramiko-files-"))
return None # worker handshakes overlap transfers; measure the production end-to-end operation
if method.startswith("sftp-files-"):
workers = min(int(method.rsplit("-", 1)[1]), len(large))
frame.run(f"mkdir -p {sh_quote(remote)}")
groups = [[] for _ in range(workers)]
for path in sorted(large, key=lambda p: p.stat().st_size, reverse=True):
group = min(groups, key=lambda group: sum(p.stat().st_size for p in group))
group.append(path)
def upload(group):
args = native(frame, key, "sftp")
args[-1:-1] = ["-b", "-"]
commands = "".join(f"put {sftp_quote(path)} {sftp_quote(remote + '/' + path.name)}\n"
for path in group)
subprocess.run(args, input=commands.encode(), check=True, stdout=subprocess.DEVNULL, timeout=1800)
with ThreadPoolExecutor(max_workers=workers) as pool:
list(pool.map(upload, groups))
return None # each worker keeps one native SFTP session for its entire batch
if method in ("app", "app-tar"):
with contextlib.closing(Frame(frame.target).connect()) as transfer:
setup = time.monotonic() - t0
if method == "app":
transfer.put(local, remote)
else:
transfer.put_tar([(p, p.name) for p in small], remote)
return setup
if method == "paramiko-tuned":
# Authenticate the same paired host key, rather than opening an unchecked raw Transport.
with contextlib.closing(Frame(frame.target).connect()) as transfer:
transport = transfer.client.get_transport()
with paramiko.SFTPClient.from_transport(transport, window_size=64 << 20) as client:
setup = time.monotonic() - t0
with open(local, "rb") as source, client.open(remote, "wb") as target:
target.set_pipelined(True)
shutil.copyfileobj(source, target, 1 << 20)
return setup
if method.startswith("ssh"):
cipher = {"ssh-gcm": "aes128-gcm@openssh.com", "ssh-chacha": "chacha20-poly1305@openssh.com"}.get(method)
if method == "ssh-tar":
def writer(pipe):
with tarfile.open(fileobj=pipe, mode="w|", format=tarfile.PAX_FORMAT) as archive:
for path in small:
archive.add(path, arcname=path.name, recursive=False)
command = f"mkdir -p {sh_quote(remote)} && tar -xf - -C {sh_quote(remote)}"
else:
def writer(pipe):
with open(local, "rb") as source:
shutil.copyfileobj(source, pipe, 1 << 20)
command = f"cat > {sh_quote(remote)}"
return stream_ssh(frame, key, command, writer, cipher)
if method == "sftp":
args = native(frame, key, "sftp")
args[-1:-1] = ["-b", "-"]
subprocess.run(args, input=f"put {sftp_quote(local)} {sftp_quote(remote)}\n".encode(),
check=True, stdout=subprocess.DEVNULL, timeout=1800)
elif method == "scp":
args = native(frame, key, "scp")
destination = args.pop()
subprocess.run([*args, str(local), f"{destination}:{remote}"], check=True, timeout=1800)
else:
raise ValueError(f"unknown method {method}")
return None
METHODS = ("app", "paramiko-tuned", "ssh", "ssh-gcm", "ssh-chacha", "sftp", "scp", "app-tar", "ssh-tar",
"app-files-1", "app-files-2", "app-files-3", "app-files-4", "app-files-8",
"paramiko-files-1", "paramiko-files-2", "paramiko-files-3", "paramiko-files-4", "paramiko-files-8",
"sftp-files-1", "sftp-files-2", "sftp-files-3", "sftp-files-4", "sftp-files-8")
def check_resume(frame, local, remote, key):
"""Interrupt the app mid-upload, then resume exactly that .part with app and native SFTP reput."""
class Interrupted(Exception):
pass
results = []
for method in ("app", "sftp"):
if method == "sftp" and not shutil.which("sftp"):
results.append({"method": method, "skipped": "sftp unavailable"})
continue
path = remote + "-" + method
def interrupt(done, size):
if done >= size // 2:
raise Interrupted()
try:
frame.put(local, path, interrupt)
except Interrupted:
pass
offset = frame.sftp.stat(path + ".part").st_size
if not 0 < offset < local.stat().st_size:
raise RuntimeError("interrupted upload did not leave a partial file")
started = time.monotonic()
if method == "app":
frame.put(local, path)
else:
args = native(frame, key, "sftp")
args[-1:-1] = ["-b", "-"]
part = path + ".part"
commands = f"reput {sftp_quote(local)} {sftp_quote(part)}\nrename {sftp_quote(part)} {sftp_quote(path)}\n"
subprocess.run(args, input=commands.encode(), check=True, timeout=1800, stdout=subprocess.DEVNULL)
code, out, err = frame.run(f"sha256sum {sh_quote(path)}")
if code or out.split()[0] != digest(local):
raise RuntimeError(f"resume checksum mismatch: {err}")
results.append({"method": method, "offset": offset, "seconds": time.monotonic() - started,
"sha256_ok": True, "cancelled_app": True})
from frameport.core.events import Cancelled, Reporter
from frameport.install.installer import upload_files
root = remote + "-production"
items = [(local, "resume.obb", local.stat().st_size)]
report = Reporter()
report.subscribe(lambda event: report.cancelled.set()
if event.kind == "progress" and event.fraction >= 0.5 else None)
try:
t.connect(username=frame.target.user, pkey=app_key())
sftp = paramiko.SFTPClient.from_transport(t, window_size=64 << 20, max_packet_size=32768)
with open(local, "rb") as src, sftp.open(remote, "wb") as dst:
dst.set_pipelined(True)
while chunk := src.read(4 << 20):
dst.write(chunk)
finally:
t.close()
upload_files(frame, items, root, report, local.stat().st_size, workers=1)
except Cancelled:
pass
offset = frame.sftp.stat(root + "/resume.obb.part").st_size
if not 0 < offset < local.stat().st_size:
raise RuntimeError("production cancellation did not leave a partial file")
started = time.monotonic()
logs = []
upload_files(frame, items, root, Reporter([lambda event: logs.append(event.message)
if event.kind == "log" else None]), local.stat().st_size, workers=1)
code, out, err = frame.run(f"sha256sum {sh_quote(root + '/resume.obb')}")
if code or out.split()[0] != digest(local):
raise RuntimeError(f"production resume checksum mismatch: {err}")
results.append({"method": "production", "offset": offset, "seconds": time.monotonic() - started,
"sha256_ok": True, "cancelled_production": True, "logs": logs})
return results
def _ssh_cat(cipher):
def run(frame, local, remote, key):
extra = ["-c", cipher] if cipher else []
with open(local, "rb") as src:
subprocess.run(["ssh", *ssh_base(frame, key), *extra, f"{frame.target.user}@{frame.target.host}",
f"cat > {remote}"], stdin=src, check=True)
return run
def m_sftp(frame, local, remote, key):
subprocess.run(["sftp", *ssh_base(frame, key, "-P"), "-b", "-", f"{frame.target.user}@{frame.target.host}"],
input=f'put "{local}" "{remote}"\n'.encode(), check=True, stdout=subprocess.DEVNULL)
METHODS = {"app": m_app, "paramiko-tuned": m_paramiko_tuned, "ssh": _ssh_cat(None),
"ssh-gcm": _ssh_cat("aes128-gcm@openssh.com"), "ssh-chacha": _ssh_cat("chacha20-poly1305@openssh.com"),
"sftp": m_sftp}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--mib", type=int, default=512)
ap.add_argument("--host", help="the Frame's address (default: the remembered Frame)")
ap.add_argument("--methods", default="app,paramiko-tuned,ssh,ssh-gcm,ssh-chacha,sftp")
a = ap.parse_args()
target = saved_targets()[0]
if a.host:
target.host = a.host
frame = Frame(target).connect()
remote = f"{frame.home}/{REMOTE}"
key = private_key()
with tempfile.NamedTemporaryFile(delete=False) as f:
block = os.urandom(1 << 20)
for _ in range(a.mib):
f.write(block)
local = f.name
size = a.mib << 20
print(f"{a.mib} MiB to {target.host} ({sys.platform}, paramiko {paramiko.__version__})")
try:
for name in a.methods.split(","):
if name.startswith(("ssh", "sftp")) and not shutil.which(name.split("-")[0]):
print(f"{name:15} skipped: no {name.split('-')[0]} on PATH")
continue
c0, t0 = cpu(), time.monotonic()
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--mib", type=int, default=512)
parser.add_argument("--repeat", type=int, default=3)
parser.add_argument("--small-files", type=int, default=1024)
parser.add_argument("--host")
parser.add_argument("--fast-link", action="store_true")
parser.add_argument("--remote-dir", help="destination filesystem, default: paired user's home")
parser.add_argument("--methods", default=",".join(METHODS[:9]))
parser.add_argument("--check-resume", action="store_true")
parser.add_argument("--json", type=Path)
parser.add_argument("--probe", action="store_true", help="show reachable links and storage without transfers")
args = parser.parse_args()
methods = args.methods.split(",")
if args.mib < 1 or args.repeat < 1 or not 1 <= args.small_files <= 10000 or set(methods) - set(METHODS):
parser.error("positive size/repeat and 1–10000 small files required; methods: " + ",".join(METHODS))
targets = saved_targets()
if not targets:
parser.error("pair a Frame first")
target = targets[0]
if args.host:
target.host = args.host
report = {"platform": platform.platform(), "python": sys.version, "paramiko": paramiko.__version__,
"wsl": "microsoft" in platform.release().lower(), "methods": [], "resume": [],
"cpu_scope": "Python parent only on Windows; parent + child processes on POSIX"}
with contextlib.ExitStack() as stack:
control = stack.enter_context(contextlib.closing(Frame(target).connect()))
frame, label = control.fast_link() if args.fast_link else (control, "explicit/current endpoint")
if frame is not control:
stack.callback(frame.close)
report.update(host=frame.target.host, port=frame.target.port, link=label or "home/current endpoint",
addresses=control.addresses())
_, inventory, _ = frame.run("lsblk -J -o NAME,TYPE,FSTYPE,MOUNTPOINTS")
_, wireless, _ = frame.run("iw dev wlan0 link 2>/dev/null")
report.update(storage_inventory=inventory, wireless=wireless)
if args.probe:
print(json.dumps(report, indent=2))
return 0
# One unique directory on the chosen filesystem; safely quote user-supplied locations.
parent = args.remote_dir or frame.home
code, scratch, err = frame.run(f"mktemp -d -p {sh_quote(parent)} frameport-bench.XXXXXXXX")
if code:
raise RuntimeError(err)
scratch = scratch.strip()
if not scratch or not scratch.startswith(parent.rstrip("/") + "/frameport-bench."):
raise RuntimeError("unexpected scratch directory")
stack.callback(frame.run, f"rm -rf -- {sh_quote(scratch)}")
_, storage, _ = frame.run(f"df -T {sh_quote(scratch)}")
report.update(destination=parent, storage=storage.strip())
key = stack.enter_context(private_key())
folder = Path(stack.enter_context(tempfile.TemporaryDirectory(prefix="frameport-upload-bench-")))
local = folder / "payload.bin"
# No compression-friendly repeated block: each method sends exactly the same random payload.
with local.open("wb") as output:
for _ in range(args.mib):
output.write(os.urandom(1 << 20))
expected = digest(local)
small_dir = folder / "small"
small_dir.mkdir()
small = []
with local.open("rb") as source:
for i in range(args.small_files):
path = small_dir / f"item-{i:05}.bin"
source.seek((i * 65536) % local.stat().st_size)
path.write_bytes(source.read(65536))
small.append(path)
small_hashes = {p.name: digest(p) for p in small}
large_dir = folder / "large"
large_dir.mkdir()
large = []
# Equal total bytes to the single-file trial; all worker counts send exactly these eight files.
with local.open("rb") as source:
for i in range(8):
path = large_dir / f"main-{i:02}.obb"
count = local.stat().st_size // 8 if i < 7 else local.stat().st_size - source.tell()
with path.open("wb") as output:
remaining = count
while remaining:
block = source.read(min(remaining, 1 << 20))
output.write(block)
remaining -= len(block)
large.append(path)
large_hashes = {p.name: digest(p) for p in large}
print(f"{platform.system()} {'WSL' if report['wsl'] else 'native'} "
f"to {frame.target.host}, {parent}", flush=True)
rng = random.Random(0)
for repeat in range(args.repeat):
order = list(methods)
rng.shuffle(order)
for method in order:
row = {"method": method, "repeat": repeat + 1}
tool = ("ssh" if method.startswith("ssh") else "sftp" if method.startswith("sftp-files-")
else method if method in ("scp", "sftp") else None)
if tool and not shutil.which(tool):
row["skipped"] = f"no {tool} on PATH"
report["methods"].append(row)
continue
remote = f"{scratch}/{method}-{repeat}"
size = sum(p.stat().st_size for p in small) if method.endswith("-tar") else local.stat().st_size
c0, t0 = cpu(), time.monotonic()
try:
row["logs"] = []
setup = send(method, frame, local, remote, key, small, large, row["logs"])
elapsed, used = time.monotonic() - t0, cpu() - c0
row.update(bytes=size, total_s=elapsed, setup_s=setup, cpu_s=used,
total_MB_s=size / elapsed / 1e6,
payload_MB_s=size / (elapsed - setup) / 1e6 if setup is not None else None)
verify_start = time.monotonic()
large_trial = method.startswith(("app-files-", "paramiko-files-", "sftp-files-"))
if method.endswith("-tar") or large_trial:
pattern = "*.obb" if large_trial else "*.bin"
_, out, _ = frame.run(f"cd {sh_quote(remote)} && sha256sum {pattern}")
actual = {line.split(maxsplit=1)[1].strip(): line.split()[0] for line in out.splitlines()}
verified = actual == (large_hashes if large_trial else small_hashes)
else:
code, out, _ = frame.run(f"sha256sum {sh_quote(remote)}")
verified = code == 0 and bool(out.split()) and out.split()[0] == expected
row.update(verify_s=time.monotonic() - verify_start, sha256_ok=verified)
if not verified:
raise RuntimeError("checksum mismatch")
print(f"{repeat + 1} {method:15} {size / elapsed / 1e6:7.1f} MB/s total "
f"{elapsed:6.2f}s setup={setup if setup is not None else 'included'} "
f"CPU={used:.2f}s checksum=OK",
flush=True)
except Exception as exc:
row["error"] = str(exc)
print(f"{method}: {exc}", flush=True)
report["methods"].append(row)
# Exclude deletion and hash verification from transfer timings and retain no previous trial files.
frame.run(f"rm -rf -- {sh_quote(remote)} {sh_quote(remote + '.part')}")
if args.check_resume:
try:
METHODS[name](frame, local, remote, key)
except Exception as exc: # noqa: BLE001
print(f"{name:15} failed: {exc}")
continue
wall, used = time.monotonic() - t0, cpu() - c0
got = int(frame.run(f"stat -c %s {remote}")[1].strip() or 0)
frame.run(f"rm -f {remote}")
ok = "" if got == size else f" (size {got} != {size})"
print(f"{name:15} {size / wall / 1e6:7.1f} MB/s {wall:6.1f} s CPU here {used:5.1f} s{ok}")
finally:
os.remove(local)
return 0
report["resume"] = check_resume(frame, local, f"{scratch}/resume", key)
except Exception as exc:
report["resume"] = [{"error": str(exc)}]
print(f"resume check: {exc}", flush=True)
if args.json:
args.json.write_text(json.dumps(report, indent=2) + "\n")
return int(any(r.get("error") for r in [*report["methods"], *report["resume"]]))
if __name__ == "__main__":
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="96" viewBox="0 0 72 96"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2D7FF9"/><stop offset=".55" stop-color="#7A4BD8"/><stop offset="1" stop-color="#FF8A3D"/></linearGradient><radialGradient id="sun" cx=".4" cy=".35" r=".75"><stop offset="0" stop-color="#FFE48A"/><stop offset="1" stop-color="#FFB42E"/></radialGradient></defs><rect width="72" height="96" fill="url(#bg)"/><path d="M0 74 18 60 30 68 46 54 72 72V96H0z" fill="#101218" fill-opacity=".35"/><circle cx="36" cy="40" r="20" fill="url(#sun)" stroke="#101218" stroke-width="2.4"/><path d="M17.5 35.5h37M20 35.5h13l-1.4 6.2a3.4 3.4 0 0 1-3.3 2.6h-4.6A3.4 3.4 0 0 1 20.4 42zM39 35.5h13l-.4 6.5a3.4 3.4 0 0 1-3.3 2.3h-4.6a3.4 3.4 0 0 1-3.3-2.6z" fill="#101218" stroke="#101218" stroke-width="2" stroke-linejoin="round"/><path d="M23.5 37.5h4" stroke="#FFFFFF" stroke-opacity=".7" stroke-width="1.4" stroke-linecap="round"/><path d="M29 50.5c4 3.2 10 3.2 14 0" fill="none" stroke="#101218" stroke-width="2.4" stroke-linecap="round"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="96" viewBox="0 0 72 96"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2D7FF9"/><stop offset=".55" stop-color="#7A4BD8"/><stop offset="1" stop-color="#FF8A3D"/></linearGradient><radialGradient id="sun" cx=".4" cy=".35" r=".75"><stop offset="0" stop-color="#FFE48A"/><stop offset="1" stop-color="#FFB42E"/></radialGradient></defs><rect width="72" height="96" fill="url(#bg)"/><path d="M0 74 18 60 30 68 46 54 72 72V96H0z" fill="#101218" fill-opacity=".35"/><circle cx="36" cy="40" r="20" fill="url(#sun)" stroke="#101218" stroke-width="2.4"/><path d="M17.5 35.5h37M20 35.5h13l-1.4 6.2q-.6 2.6-3.3 2.6h-4.6q-2.9 0-3.3-2.6zM39 35.5h13l-.4 6.2q-.4 2.6-3.3 2.6h-4.6q-2.7 0-3.3-2.6z" fill="#101218" stroke="#101218" stroke-width="2" stroke-linejoin="round"/><path d="M23.5 37.5h4" stroke="#FFFFFF" stroke-opacity=".7" stroke-width="1.4" stroke-linecap="round"/><path d="M29 50.5c4 3.2 10 3.2 14 0" fill="none" stroke="#101218" stroke-width="2.4" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+150 -24
View File
@@ -1,14 +1,14 @@
{
"updated": "2026-10-13",
"updated": "2026-10-14",
"people": [
{
"login": "spoopyghosty0",
"avatar": "https://avatars.githubusercontent.com/u/336754034?v=4",
"url": "https://github.com/spoopyghosty0",
"commits": 578,
"commits": 603,
"issues": 5,
"prs": 3,
"total": 608
"total": 633
},
{
"login": "Klownicle",
@@ -46,6 +46,15 @@
"prs": 0,
"total": 33
},
{
"login": "UnicronNL",
"avatar": "https://avatars.githubusercontent.com/u/1267745?v=4",
"url": "https://github.com/UnicronNL",
"commits": 5,
"issues": 6,
"prs": 2,
"total": 33
},
{
"login": "Lucas-Mathieu",
"avatar": "https://avatars.githubusercontent.com/u/184943223?v=4",
@@ -55,15 +64,6 @@
"prs": 4,
"total": 29
},
{
"login": "UnicronNL",
"avatar": "https://avatars.githubusercontent.com/u/1267745?v=4",
"url": "https://github.com/UnicronNL",
"commits": 5,
"issues": 6,
"prs": 1,
"total": 28
},
{
"login": "inseend",
"avatar": "https://avatars.githubusercontent.com/u/11666993?v=4",
@@ -145,6 +145,15 @@
"prs": 0,
"total": 15
},
{
"login": "coah80",
"avatar": "https://avatars.githubusercontent.com/u/100182500?v=4",
"url": "https://github.com/coah80",
"commits": 0,
"issues": 1,
"prs": 2,
"total": 13
},
{
"login": "nickwilde77555",
"avatar": "https://avatars.githubusercontent.com/u/180547421?v=4",
@@ -226,6 +235,15 @@
"prs": 0,
"total": 9
},
{
"login": "ghost",
"avatar": "https://avatars.githubusercontent.com/u/10137?v=4",
"url": "https://github.com/ghost",
"commits": 0,
"issues": 3,
"prs": 0,
"total": 9
},
{
"login": "greggray24",
"avatar": "https://avatars.githubusercontent.com/u/255593097?v=4",
@@ -262,6 +280,24 @@
"prs": 0,
"total": 9
},
{
"login": "puppet-head",
"avatar": "https://avatars.githubusercontent.com/u/8073775?v=4",
"url": "https://github.com/puppet-head",
"commits": 0,
"issues": 3,
"prs": 0,
"total": 9
},
{
"login": "Tazzash",
"avatar": "https://avatars.githubusercontent.com/u/42785850?v=4",
"url": "https://github.com/Tazzash",
"commits": 0,
"issues": 3,
"prs": 0,
"total": 9
},
{
"login": "DJ3thenew23",
"avatar": "https://avatars.githubusercontent.com/u/3744521?v=4",
@@ -289,6 +325,15 @@
"prs": 1,
"total": 6
},
{
"login": "jeliastam",
"avatar": "https://avatars.githubusercontent.com/u/2113009?v=4",
"url": "https://github.com/jeliastam",
"commits": 0,
"issues": 2,
"prs": 0,
"total": 6
},
{
"login": "Loafmeister",
"avatar": "https://avatars.githubusercontent.com/u/60628625?v=4",
@@ -298,6 +343,15 @@
"prs": 0,
"total": 6
},
{
"login": "marcoszaballos",
"avatar": "https://avatars.githubusercontent.com/u/44778619?v=4",
"url": "https://github.com/marcoszaballos",
"commits": 0,
"issues": 2,
"prs": 0,
"total": 6
},
{
"login": "maxfvd",
"avatar": "https://avatars.githubusercontent.com/u/337777776?v=4",
@@ -317,9 +371,9 @@
"total": 6
},
{
"login": "puppet-head",
"avatar": "https://avatars.githubusercontent.com/u/8073775?v=4",
"url": "https://github.com/puppet-head",
"login": "peetzweg",
"avatar": "https://avatars.githubusercontent.com/u/839848?v=4",
"url": "https://github.com/peetzweg",
"commits": 0,
"issues": 2,
"prs": 0,
@@ -343,6 +397,15 @@
"prs": 0,
"total": 6
},
{
"login": "wanwanwanwano",
"avatar": "https://avatars.githubusercontent.com/u/341975109?v=4",
"url": "https://github.com/wanwanwanwano",
"commits": 0,
"issues": 2,
"prs": 0,
"total": 6
},
{
"login": "yzfsoldier",
"avatar": "https://avatars.githubusercontent.com/u/35410319?v=4",
@@ -361,6 +424,15 @@
"prs": 1,
"total": 5
},
{
"login": "Agent-Gato",
"avatar": "https://avatars.githubusercontent.com/u/141082538?v=4",
"url": "https://github.com/Agent-Gato",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "Akimya49",
"avatar": "https://avatars.githubusercontent.com/u/46827872?v=4",
@@ -406,6 +478,15 @@
"prs": 0,
"total": 3
},
{
"login": "bobosand77",
"avatar": "https://avatars.githubusercontent.com/u/342317426?v=4",
"url": "https://github.com/bobosand77",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "chaseflow",
"avatar": "https://avatars.githubusercontent.com/u/235484785?v=4",
@@ -416,9 +497,9 @@
"total": 3
},
{
"login": "coah80",
"avatar": "https://avatars.githubusercontent.com/u/100182500?v=4",
"url": "https://github.com/coah80",
"login": "clockhart192",
"avatar": "https://avatars.githubusercontent.com/u/16326214?v=4",
"url": "https://github.com/clockhart192",
"commits": 0,
"issues": 1,
"prs": 0,
@@ -478,6 +559,15 @@
"prs": 0,
"total": 3
},
{
"login": "fazejohny",
"avatar": "https://avatars.githubusercontent.com/u/12040179?v=4",
"url": "https://github.com/fazejohny",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "felis-lynx",
"avatar": "https://avatars.githubusercontent.com/u/106830854?v=4",
@@ -541,6 +631,15 @@
"prs": 0,
"total": 3
},
{
"login": "LeaPhant",
"avatar": "https://avatars.githubusercontent.com/u/14080165?v=4",
"url": "https://github.com/LeaPhant",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "leoloopfiets",
"avatar": "https://avatars.githubusercontent.com/u/215600281?v=4",
@@ -550,6 +649,15 @@
"prs": 0,
"total": 3
},
{
"login": "LynkWyland",
"avatar": "https://avatars.githubusercontent.com/u/54166762?v=4",
"url": "https://github.com/LynkWyland",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "Mitchlett",
"avatar": "https://avatars.githubusercontent.com/u/46537377?v=4",
@@ -568,6 +676,15 @@
"prs": 0,
"total": 3
},
{
"login": "Natouille04",
"avatar": "https://avatars.githubusercontent.com/u/60348431?v=4",
"url": "https://github.com/Natouille04",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "njorbie",
"avatar": "https://avatars.githubusercontent.com/u/340724129?v=4",
@@ -605,18 +722,18 @@
"total": 3
},
{
"login": "TainakaRitsu",
"avatar": "https://avatars.githubusercontent.com/u/12560774?v=4",
"url": "https://github.com/TainakaRitsu",
"login": "SylanaZero",
"avatar": "https://avatars.githubusercontent.com/u/9733124?v=4",
"url": "https://github.com/SylanaZero",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "Tazzash",
"avatar": "https://avatars.githubusercontent.com/u/42785850?v=4",
"url": "https://github.com/Tazzash",
"login": "TainakaRitsu",
"avatar": "https://avatars.githubusercontent.com/u/12560774?v=4",
"url": "https://github.com/TainakaRitsu",
"commits": 0,
"issues": 1,
"prs": 0,
@@ -666,6 +783,15 @@
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "zoomiti",
"avatar": "https://avatars.githubusercontent.com/u/34012653?v=4",
"url": "https://github.com/zoomiti",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
}
]
}
+25
View File
@@ -74,6 +74,11 @@
"title": "Draw fades over the picture",
"group": "Game settings"
},
"adapter.flip_dump": {
"anchor": "adapter-flip_dump",
"title": "Upside-down panel images (diagnostics)",
"group": "Game settings"
},
"adapter.flip_emul": {
"anchor": "adapter-flip_emul",
"title": "Fix upside-down menus",
@@ -99,6 +104,11 @@
"title": "Hide Quest foveation",
"group": "Game settings"
},
"adapter.gl_error_clear": {
"anchor": "adapter-gl_error_clear",
"title": "Clear the game's GL errors (GLES)",
"group": "Game settings"
},
"adapter.gl_hide_msrtt": {
"anchor": "adapter-gl_hide_msrtt",
"title": "Hide fast anti-aliasing (OpenGL ES)",
@@ -384,6 +394,11 @@
"title": "Content files from the game's data",
"group": "Steam Frame patches"
},
"frame.asset_files_stub": {
"anchor": "frame-asset_files_stub",
"title": "Asset-file service bypass",
"group": "Steam Frame patches"
},
"frame.avatar_stub": {
"anchor": "frame-avatar_stub",
"title": "Skip Meta avatars",
@@ -439,6 +454,11 @@
"title": "GL wrapper: OpenGL ES depth formats (Minecraft)",
"group": "Steam Frame patches"
},
"frame.luajit_pointers": {
"anchor": "frame-luajit_pointers",
"title": "Fix Lua startup crashes on Frame",
"group": "Steam Frame patches"
},
"frame.meta_permissions": {
"anchor": "frame-meta_permissions",
"title": "Declare Meta-only permissions",
@@ -589,6 +609,11 @@
"title": "Unreal: start without the OBB check",
"group": "Steam Frame patches"
},
"frame.unreal_skip_obb_delivery": {
"anchor": "frame-unreal_skip_obb_delivery",
"title": "Unreal: skip the OBB delivery check",
"group": "Steam Frame patches"
},
"frame.unreal_splash_bypass": {
"anchor": "frame-unreal_splash_bypass",
"title": "Unreal: bypass the stalled loading splash",
+1 -1
View File
@@ -1,2 +1,2 @@
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
__version__ = "1.1.0"
__version__ = "1.1.1"
+13 -4
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from ..apk import axml
from ..core.models import Analysis
from . import elf
from .luajit import xlua_47bit, xlua_sha256
from .unity_split import split_build as unity_split_build
logging.getLogger("pyaxmlparser").setLevel(logging.ERROR)
@@ -37,10 +38,12 @@ IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
# libAndroidLauncher.so + a .cryproject (Climb 1 has no libCrySystem.so, GitHub #238) (2026-10)
# 13: validate monolithic CryEngine projects/config, OBB filename formats and foveation cvar (The Climb, GitHub #238)
# 14: bhaptics_permission (bHaptics asks for Android 12's BLUETOOTH_CONNECT: frame.bhaptics_permission, GitHub #235)
# 15: headset (the Meta headset a VR app was made for: Gear VR / Go games run through frame.a32_bridge) (2026-10)
# 16: headset "quest" also for 32-bit apps that require 6DoF head tracking (Santa Cruz builds, e.g. Sports
# 15: verified xLua/LuaJIT 47-bit lightuserdata engine (frame.luajit_pointers, GitHub #257)
# 16: unreal_obb_delivery (Java expansionFilesDelivered gate, GitHub #283) (2026-10)
# 17: headset (the Meta headset a VR app was made for: Gear VR / Go games run through frame.a32_bridge) (2026-10)
# 18: headset "quest" also for 32-bit apps that require 6DoF head tracking (Santa Cruz builds, e.g. Sports
# Scramble: Touch controllers on the a32 bridge) (2026-10)
ANALYSIS_VERSION = 16
ANALYSIS_VERSION = 18
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
@@ -225,6 +228,7 @@ LAYER_EXTENSIONS = ("XR_KHR_composition_layer_cylinder", "XR_KHR_composition_lay
def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Analysis:
path = Path(path)
package, version, label, activity, apk_info = _read_manifest_info(path)
with zipfile.ZipFile(path) as z:
names = z.namelist()
abis = sorted({n.split("/")[1] for n in names if n.startswith("lib/") and n.count("/") >= 2})
@@ -245,6 +249,9 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
for d in dexes)
# video through Android's decoders (frame.hw_video_decode): Java MediaCodec, ExoPlayer/Media3, or libVLC
media_codec = any(m in d for d in dexes for m in MEDIA_CODEC_MARKERS) or "libvlc.so" in libs
from ..patches.frame.unreal_obb_check import has_delivery_check
unreal_obb_delivery = any(has_delivery_check(d, package) for d in dexes)
del dexes
manifest = z.read("AndroidManifest.xml")
lib_bytes = {}
@@ -267,7 +274,6 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
system_cfg = (z.read("assets/system.cfg").decode("utf-8", "replace")
if "assets/system.cfg" in names else "")
package, version, label, activity, apk_info = _read_manifest_info(path)
libset = set(libs)
engine = ("Unreal" if libset & {"libUE4.so", "libUnreal.so"} else "Unity" if "libunity.so" in libset
else "CryEngine" if "libCrySystem.so" in libset or cry_project else "Other")
@@ -337,6 +343,8 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
debuggable=bool(axml.Axml(manifest).get_bool("application", "debuggable")),
extra={
"analysis_version": ANALYSIS_VERSION,
"xlua_47bit": xlua_47bit(lib_bytes.get("libxlua.so", b"")),
"xlua_sha256": xlua_sha256(lib_bytes.get("libxlua.so", b"")),
"frame_patched": "libframe_settings.so" in libset, # already has FramePort's FrameBridge adapter
# Team Beef's TBXR ports pick their OpenXR path by headset maker (frame.tbxr_vendor): their libraries
# the game asks Meta's platform for asset files (content shipped as separate files, frame.asset_files)
@@ -400,6 +408,7 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
"unity_split": unity_split,
# Unreal CRC-checks the OBB before the game starts (frame.unreal_skip_obb_check, GitHub #159)
"unreal_verify_obb": unreal_verify_obb(meta),
"unreal_obb_delivery": unreal_obb_delivery,
# Unreal's Oculus module needs every OVRPlugin function it looks up (frame.unreal_ovrp_entrypoints)
"unreal_ovrp_lookups": (ovrp_lookups(lib_bytes.get(engine_lib.rsplit("/", 1)[1], b""))
if engine_lib and "libOVRPlugin.so" in libset else []),
+25
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import io
import struct
from functools import lru_cache
from elftools.elf.dynamic import DynamicSection
@@ -161,6 +162,30 @@ def hide_exports(data: bytes, names) -> tuple[bytes, list[str]]:
return bytes(buf), sorted(hidden)
def redirect_exports(data: bytes, names) -> tuple[bytes, list[str]]:
"""Turn definitions into imports, so both dlsym and this ELF's relocations use a dependency's implementation.
Unlike hide_exports, this also redirects self-references through the PLT. Code and symbol indices stay put;
callers using direct branch instructions are NOT redirected and must be reviewed before using this operation.
ELF64, little endian only. Returns (data, definitions actually redirected).
"""
if not is_elf(data) or not is_64bit(data) or data[5] != 1:
return data, []
sec = _elf(data).get_section_by_name(".dynsym")
if not isinstance(sec, SymbolTableSection):
return data, []
wanted = set(names)
buf, redirected = bytearray(data), []
for i, sym in enumerate(sec.iter_symbols()):
if (sym.name in wanted and sym["st_shndx"] != "SHN_UNDEF"
and sym["st_info"]["bind"] in ("STB_GLOBAL", "STB_WEAK")
and sym["st_info"]["type"] == "STT_FUNC"):
at = sec["sh_offset"] + i * (sec["sh_entsize"] or 24)
struct.pack_into("<HQQ", buf, at + 6, 0, 0, 0) # shndx, value, size; keep GLOBAL and the name
redirected.append(sym.name)
return bytes(buf), sorted(redirected)
def rename_imports(data: bytes, renames: dict[str, str]) -> tuple[bytes, list[str]]:
"""Rename imported (undefined) dynamic symbols in place: `.dynstr` bytes of each import's name are overwritten
with the new name (not longer; NUL-padded), so its relocations bind to whatever library exports the new name
+5
View File
@@ -12,6 +12,7 @@ import zipfile
from pathlib import Path
from ..core.paths import user_data_dir
from ..i18n import tr
EM_AARCH64, EM_X86_64 = 183, 62
ARCHIVES = (".zip", ".tar.gz", ".tgz", ".tar.xz", ".txz", ".tar.bz2", ".tar")
@@ -234,6 +235,10 @@ def inspect(path: Path) -> dict:
"vr_overlay": False, "vr_overlay_app": None, "vr_overlay_from": ""}
programs = rank_programs(root)
if not programs:
if any(p.is_file() and p.name.lower() in ("install.sh", "setup.sh")
for pattern in ("*", "*/*") for p in root.glob(pattern)):
raise ValueError(tr("This folder contains an installer without a launchable Linux program. "
"FramePort runs programs, not installers. Use the project's own install instructions."))
raise ValueError(f"no Linux program found in {path.name}")
# an arm64 build wins; x86_64 programs only when there is nothing else (they run through FEX on the Frame)
for want in (EM_AARCH64, EM_X86_64):
+20
View File
@@ -0,0 +1,20 @@
"""Verified xLua/LuaJIT engines whose lightuserdata is limited to 47-bit pointers."""
from __future__ import annotations
import hashlib
# xLua API 105, LuaJIT 2.1 beta3, GC64. The bindings use the public Lua API;
# xlua_gl reads lua_State.glref at offset 16, unchanged in the replacement.
# Match the complete original library: custom builds need their own ABI review.
XLUA_47BIT_SHA256 = frozenset({
"6d5860e4a4f1cc2b05c600c63297ed467369739666edca79413fff4374ff2684",
})
def xlua_sha256(data: bytes) -> str | None:
"""Diagnostics can identify an unreviewed engine without sharing the library itself."""
return hashlib.sha256(data).hexdigest() if data else None
def xlua_47bit(data: bytes) -> bool:
return xlua_sha256(data) in XLUA_47BIT_SHA256
+49
View File
@@ -202,6 +202,55 @@ class Axml:
# ---------------------------------------------------------------------- high-level queries/fixes
def copy_activity_label_to_application(manifest: bytes) -> bytes | None:
"""Fill a missing application label from an activity, retaining its resource/string value (#269).
OVRPort's appName() assumes the application has a label; Android permits activity-only labels. Existing
application labels and the activity itself are untouched. No strings or resource ids are added.
"""
x = Axml(manifest)
names = x.strings()
def label(el):
return next((a for a in el.attrs if a.ns < len(names) and a.name < len(names) and names[a.ns] == ANDROID_NS
and names[a.name] == "label"), None)
elements = list(x.elements())
app = next((el for el in elements if el.name == "application"), None)
if app is None or label(app) is not None:
return None
source = next((a for el in elements if el.name in ("activity", "activity-alias")
and (a := label(el)) is not None), None)
if source is None:
return None
# Attributes are sorted by resource id in Android's binary XML. Reuse the existing resource map.
ids = {}
pos = x.pool_off + x.pool_size
while pos < app.offset:
typ, hsz, size = struct.unpack_from("<HHI", x.data, pos)
if size < hsz or size == 0:
raise ValueError("invalid binary XML chunk")
if typ == 0x0180: # RES_XML_RESOURCE_MAP
ids = dict(enumerate(struct.unpack_from(f"<{(size - hsz) // 4}I", x.data, pos + hsz)))
break
pos += size
ext = app.offset + struct.unpack_from("<H", x.data, app.offset + 2)[0]
start, stride, count = struct.unpack_from("<HHH", x.data, ext + 8)
index = next((i for i, a in enumerate(app.attrs)
if ids.get(a.name, 0xFFFFFFFF) > ids.get(source.name, 0xFFFFFFFF)), count)
at = ext + start + index * stride
record = x.data[source.offset:source.offset + 20] + bytes(stride - 20)
data = x.data[:at] + record + x.data[at:]
struct.pack_into("<I", data, app.offset + 4, app.size + stride)
struct.pack_into("<H", data, ext + 12, count + 1)
for offset in (14, 16, 18): # one-based id/class/style attribute indices
value = struct.unpack_from("<H", data, ext + offset)[0]
if value > index:
struct.pack_into("<H", data, ext + offset, value + 1)
struct.pack_into("<I", data, 4, len(data))
return bytes(data)
def used_and_declared_permissions(manifest: bytes) -> tuple[list[str], set[str]]:
x = Axml(manifest)
used, declared = [], set()
+6 -5
View File
@@ -152,17 +152,18 @@ class Dex:
"""Make a void method return at once (see return_early)."""
return self.return_early(code_off, "V")
def return_early(self, code_off: int, ret: str) -> bool:
def return_early(self, code_off: int, ret: str, *, boolean_value: bool = False) -> bool:
"""Make a method return at once: its first instruction(s) become `return-void` (ret "V"), `const/4 v0, 0;
return v0` (ret Z/B/S/C/I: false / 0) or `const-string v0, ""; return-object v0` (ret Ljava/lang/String;: the
empty string, only when the dex has it), padded with nops to the next instruction boundary. The rest of the
code stays as it was (unreachable). False if the method already starts that way, the return type isn't
supported (wide, other objects) or its first instructions can't be decoded."""
empty string, only when the dex has it). Z can return true with boolean_value=True. Padded with nops to the
next instruction boundary. The rest of the code stays as it was (unreachable). False if the method already
starts that way, the return type isn't supported (wide, other objects) or its first instructions can't be
decoded."""
regs = struct.unpack_from("<H", self.data, code_off)[0] # registers_size: v0 must exist for a value
if ret == "V":
new = [RETURN_VOID]
elif ret in ("Z", "B", "S", "C", "I") and regs:
new = [CONST4_V0_0, RETURN_V0]
new = [0x1012 if ret == "Z" and boolean_value else CONST4_V0_0, RETURN_V0]
elif ret == "Ljava/lang/String;" and regs and self.strings_n and self.string(0) == "":
new = [CONST_STRING_V0, 0, RETURN_OBJECT_V0] # string ids are sorted: "" is always the first
else:
+8 -3
View File
@@ -465,6 +465,7 @@ def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Op
frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
password: Optional[str] = typer.Option(None, help="the Frame's password (first connection only)"),
apk_only: bool = typer.Option(False, help="reuse game data already on the Frame"),
force: bool = typer.Option(False, help="rebuild and reinstall even if the Frame is already current"),
no_library: bool = typer.Option(False, help="don't add to the Steam library now"),
to: str = typer.Option("frame", help="frame, or pc (PC VR games only: install on this PC)"),
apk: Optional[Path] = typer.Option(None, help="install this APK instead of the last build (one game; "
@@ -479,12 +480,16 @@ def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Op
pkgs = _pkgs(package, all_)
if apk and len(pkgs) != 1:
raise typer.BadParameter("--apk needs exactly one game")
shortcuts = []
for pkg in pkgs:
if not apk_only and pipeline.missing_obb(library.game(pkg) or {}):
typer.echo(f"{pkg}: warning: " + pipeline.MISSING_OBB_NOTE.format(package=pkg), err=True)
pipeline.install_game(pkg, target, printing_reporter(False), apk_only, add_to_library=False, apk=apk)
if not no_library:
target.add_to_library(pkgs, printing_reporter(False))
result = pipeline.install_game(pkg, target, printing_reporter(False), apk_only,
add_to_library=False, apk=apk, force=force)
if result.get("shortcut_required", True):
shortcuts.append(pkg)
if not no_library and shortcuts:
target.add_to_library(shortcuts, printing_reporter(False))
@app.command()
+17 -2
View File
@@ -25,6 +25,8 @@ class Reporter:
self._sinks = list(sinks or [])
self._lock = threading.Lock()
self.stage_name = ""
self._stage_started = time.monotonic()
self._stage_seconds: dict[str, float] = {}
self.cancelled = threading.Event()
def subscribe(self, sink: Callable[[Event], None]) -> None:
@@ -44,8 +46,21 @@ class Reporter:
self.emit(Event("log", message, self.stage_name, data=data))
def stage(self, name: str, message: str = "") -> None:
self.stage_name = name
self.emit(Event("stage", message or name, name))
now = time.monotonic()
previous = self.stage_name
elapsed = now - self._stage_started
if previous:
self._stage_seconds[previous] = self._stage_seconds.get(previous, 0.0) + elapsed
self.stage_name, self._stage_started = name, now
self.emit(Event("stage", message or name, name,
data={"previous_stage": previous, "previous_stage_seconds": elapsed} if previous else {}))
def stage_timings(self) -> dict[str, float]:
"""Completed stages plus the current stage, for install diagnostics and benchmark comparisons."""
result = dict(self._stage_seconds)
if self.stage_name:
result[self.stage_name] = result.get(self.stage_name, 0.0) + time.monotonic() - self._stage_started
return result
def progress(self, fraction: float, message: str = "", **data) -> None:
"""`data` may carry `speed` (e.g. "42.1 MB/s · ~3 min left") for transfers."""
+54 -5
View File
@@ -9,12 +9,13 @@ play sessions (the Library flickered under a stream of pop-ups). The GUI holds a
"""
from __future__ import annotations
import json
import os
import sys
import time
from pathlib import Path
from .paths import user_data_dir
from .paths import user_data_dir, write_atomic
_held = None # the open lock file (kept for the process lifetime)
@@ -27,6 +28,10 @@ def focus_file() -> Path:
return user_data_dir() / "gui.focus"
def _owner_file(path: Path) -> Path:
return path.with_suffix(".pid")
def _try_lock(f) -> bool:
try:
if sys.platform == "win32":
@@ -57,6 +62,13 @@ def acquire(path: Path | None = None) -> bool:
f.write(str(os.getpid()))
f.flush()
_held = f
try:
import psutil
# Windows denies reading the locked byte, so another launch cannot read the PID from gui.lock itself.
write_atomic(_owner_file(path), json.dumps({"pid": os.getpid(), "created": psutil.Process().create_time()}))
except Exception: # noqa: BLE001 the lock is ours; a missing owner file only costs a later launch's recovery
pass
return True
@@ -67,6 +79,11 @@ def release() -> None:
f, _held = _held, None
if f is None:
return
path = Path(f.name)
try:
_owner_file(path).unlink(missing_ok=True)
except OSError:
pass
try:
if sys.platform == "win32":
import msvcrt
@@ -75,7 +92,6 @@ def release() -> None:
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
except OSError:
pass
path = Path(f.name)
f.close()
try:
path.unlink(missing_ok=True)
@@ -84,8 +100,18 @@ def release() -> None:
def holder(path: Path | None = None) -> int | None:
path = path or lock_file()
try:
return int((path or lock_file()).read_text().strip() or 0) or None
import psutil
owner = json.loads(_owner_file(path).read_text())
pid = int(owner["pid"])
if abs(psutil.Process(pid).create_time() - float(owner["created"])) < 0.01:
return pid # creation time guards against stale metadata and recycled PIDs
except (OSError, ValueError, KeyError, TypeError, psutil.Error):
pass
try:
return int(path.read_text().strip() or 0) or None
except (OSError, ValueError):
return None
@@ -94,10 +120,30 @@ def _is_frameport_gui(pid: int) -> bool:
try:
import psutil
cmd = " ".join(psutil.Process(pid).cmdline()).lower()
cmd = psutil.Process(pid).cmdline()
except Exception: # noqa: BLE001 (gone, or not ours to inspect)
return False
return "frameport" in cmd
if any(a == "-m" and b == "frameport.ui.app" for a, b in zip(cmd, cmd[1:], strict=False)):
return True
return any(Path(arg).name.lower() in ("frameport", "frameport.exe", "frameport-gui", "frameport-gui.exe")
for arg in cmd[:2])
def _legacy_holders(path: Path) -> list[int]:
"""Older Windows GUIs have no readable PID record. Only recover processes holding this exact lock."""
import psutil
found = []
wanted = os.path.normcase(str(path.resolve()))
for process in psutil.process_iter(["pid"]):
if process.pid == os.getpid() or not _is_frameport_gui(process.pid):
continue
try:
if any(os.path.normcase(f.path) == wanted for f in process.open_files()):
found.append(process.pid)
except (OSError, psutil.Error):
pass
return found
STARTUP_GRACE = 30.0 # a GUI this young may still be opening its window (no heartbeat yet): never end it
@@ -153,6 +199,9 @@ def claim(window_open, retries: int = 20) -> bool:
pid = holder()
if pid:
end_leftover(pid)
elif sys.platform == "win32":
for pid in _legacy_holders(lock_file()):
end_leftover(pid)
for _ in range(retries): # the OS releases the lock when the process is gone
if acquire():
return True
+7
View File
@@ -34,6 +34,7 @@ Created by FramePort {version} on {created}. Personal data was replaced by place
| manifest.json | app / agent / tool versions, OS, which games are included, warnings |
| app/app.log, app/jobs/*.log | the app's log and recent GUI job logs (build, install, launch test, ...) |
| app/settings.json | library settings and catalog sources |
| app/display.json | GUI versions, window state; Windows display modes, GPU drivers and display/device event metadata |
| frame/info.json, frame/host.json | the Frame: SteamOS build, Lepton/Proton, OpenXR runtime + layers, podman, kernel"""
""" keys |
| games/<pkg>/entry.json | the library entry: analysis, recipe, build checks, installs, last launch test |
@@ -372,6 +373,12 @@ def collect(packages: list[str] | None, target=None, reporter: Reporter | None =
red.add("source", d)
w = _Writer(red)
# app-wide
try:
from . import display
w.json("app/display.json", display.collect())
except Exception as exc: # noqa: BLE001
w.warnings.append(f"display diagnostics: {exc}")
for p in sorted(applog.logs_dir().glob("app.log*"))[:2]:
w.text("app/" + p.name, _read_tail(p, 2 << 20))
jobs = sorted(applog.jobs_dir().glob("*.log"))
+193
View File
@@ -0,0 +1,193 @@
"""Small, read-only desktop diagnostics. Native Windows display queries run only when collecting a bundle."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import threading
from datetime import UTC, datetime
EVENT_TIMEOUT = 5
EVENT_LIMIT = 60
_lock = threading.Lock()
_window: dict = {}
def _now() -> str:
return datetime.now(UTC).isoformat(timespec="milliseconds")
def _is_windows() -> bool:
return sys.platform == "win32"
def record_window(event: str, window, *, theme: str, reduce_motion: bool) -> None:
"""Cache and log existing window events; no display queries, timers or extra UI updates."""
if not _is_windows():
return
state = {"event": event, "time_utc": _now(), "size": [window.width, window.height],
"position": [window.left, window.top], "minimized": window.minimized,
"maximized": window.maximized, "full_screen": window.full_screen,
"theme": theme, "reduce_motion": reduce_motion}
with _lock:
state["focused"] = (event == "focus") if event in ("focus", "blur") else _window.get("focused")
if event in ("minimize", "restore"):
state["minimized"] = event == "minimize"
if event in ("maximize", "unmaximize"):
state["maximized"] = event == "maximize"
if event in ("enter-full-screen", "leave-full-screen"):
state["full_screen"] = event == "enter-full-screen"
_window.clear()
_window.update(state)
from ..core import applog
applog.log.info("desktop window: %s", json.dumps(state, separators=(",", ":")))
def _runtime() -> dict:
# Flet's constants are available in bundles even when wheel metadata is absent. Do not import the GUI here.
version = sys.modules.get("flet.version")
return {"flet": getattr(version, "flet_version", None),
"flutter": getattr(version, "flutter_version", None),
"executable": os.path.basename(sys.executable),
"engine_switches": [os.environ[f"FLUTTER_ENGINE_SWITCH_{i}"][:256] for i in range(1, 17)
if f"FLUTTER_ENGINE_SWITCH_{i}" in os.environ]}
def _driver(device_key: str) -> dict:
"""Read driver metadata only, without device IDs, registry paths or monitor serials."""
import winreg
prefix = "\\Registry\\Machine\\"
if not device_key.startswith(prefix):
return {"status": "unavailable"}
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, device_key[len(prefix):]) as key:
out = {}
for value, field in (("DriverVersion", "version"), ("DriverDate", "date"),
("ProviderName", "provider")):
try:
out[field] = str(winreg.QueryValueEx(key, value)[0])
except OSError:
pass
return {"status": "ok" if out else "unavailable", **out}
except OSError as exc:
return {"status": "error", "error": str(exc)}
def _windows_displays() -> dict:
import ctypes as C
WORD, DWORD, LONG = C.c_uint16, C.c_uint32, C.c_int32
class Device(C.Structure):
_fields_ = [("cb", DWORD), ("name", C.c_wchar * 32), ("description", C.c_wchar * 128),
("flags", DWORD), ("device_id", C.c_wchar * 128), ("key", C.c_wchar * 128)]
class Mode(C.Structure):
_fields_ = [("device", C.c_wchar * 32), ("spec", WORD), ("driver", WORD), ("size", WORD),
("extra", WORD), ("fields", DWORD), ("x", LONG), ("y", LONG),
("orientation", DWORD), ("fixed_output", DWORD), ("color", WORD), ("duplex", WORD),
("y_resolution", WORD), ("tt_option", WORD), ("collate", WORD),
("form", C.c_wchar * 32), ("log_pixels", WORD), ("bits", DWORD), ("width", DWORD),
("height", DWORD), ("flags", DWORD), ("frequency", DWORD), ("icm_method", DWORD),
("icm_intent", DWORD), ("media_type", DWORD), ("dither", DWORD),
("reserved1", DWORD), ("reserved2", DWORD), ("panning_width", DWORD),
("panning_height", DWORD)]
user = C.WinDLL("user32", use_last_error=True)
enum = user.EnumDisplayDevicesW
enum.argtypes, enum.restype = [C.c_wchar_p, DWORD, C.POINTER(Device), DWORD], C.c_int
settings = user.EnumDisplaySettingsW
settings.argtypes, settings.restype = [C.c_wchar_p, DWORD, C.POINTER(Mode)], C.c_int
adapters = []
for i in range(16):
device = Device(cb=C.sizeof(Device))
if not enum(None, i, C.byref(device), 0):
break
adapter = {"name": device.name, "description": device.description,
"attached": bool(device.flags & 1), "primary": bool(device.flags & 4),
"driver": _driver(device.key)}
mode = Mode(size=C.sizeof(Mode))
if settings(device.name, 0xFFFFFFFF, C.byref(mode)): # ENUM_CURRENT_SETTINGS
adapter["mode"] = {"size": [mode.width, mode.height], "position": [mode.x, mode.y],
"refresh_hz": mode.frequency, "bits_per_pixel": mode.bits,
"orientation": mode.orientation}
else:
adapter["mode"] = {"status": "unavailable"}
monitors = []
for j in range(16):
monitor = Device(cb=C.sizeof(Device))
if not enum(device.name, j, C.byref(monitor), 0):
break
monitors.append({"name": monitor.name, "description": monitor.description,
"active": bool(monitor.flags & 1)})
adapter["monitors"] = monitors
adapters.append(adapter)
return {"status": "ok", "adapters": adapters, "limit_reached": len(adapters) == 16}
# Filter on the event-log service, not by scanning every System event. Metadata is enough to correlate resets
# and device changes; event messages can contain unrelated device IDs and personal data.
_EVENT_SCRIPT = r"""
$ErrorActionPreference = 'Stop'
$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$providers = "Provider[@Name='Display'] or Provider[@Name='nvlddmkm'] or " +
"Provider[@Name='Microsoft-Windows-Kernel-PnP'] or Provider[@Name='Microsoft-Windows-Dwm-Core'] or " +
"Provider[@Name='Microsoft-Windows-DxgKrnl']"
$xpath = "*[System[TimeCreated[timediff(@SystemTime) <= 1800000] and ($providers)]]"
try {
$events = @(Get-WinEvent -LogName System -FilterXPath $xpath -MaxEvents 60 -ErrorAction Stop | ForEach-Object {
@{time_utc=$_.TimeCreated.ToUniversalTime().ToString('o'); provider=$_.ProviderName;
id=$_.Id; level=$_.Level; record_id=$_.RecordId}
})
@{status='ok'; events=$events} | ConvertTo-Json -Depth 4 -Compress
} catch {
if ($_.FullyQualifiedErrorId -like 'NoMatchingEventsFound*') {
@{status='ok'; events=@()} | ConvertTo-Json -Compress
} else {
@{status='error'; error=$_.FullyQualifiedErrorId; events=@()} | ConvertTo-Json -Compress
}
}
"""
def _recent_events() -> dict:
from ..updates import _powershell
try:
# Windows PowerShell 5.1 without pwsh's PSModulePath: started from pwsh 7 it can't load Get-WinEvent's module
exe, env = _powershell()
result = subprocess.run([exe, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
_EVENT_SCRIPT], capture_output=True, encoding="utf-8", errors="replace",
timeout=EVENT_TIMEOUT, creationflags=0x08000000, env=env)
if result.returncode:
return {"status": "error", "error": (result.stderr or "PowerShell query failed")[-512:]}
data = json.loads(result.stdout.lstrip("\ufeff"))
if not isinstance(data, dict) or data.get("status") not in ("ok", "error"):
raise ValueError("unexpected event query result")
if not isinstance(data.get("events"), list):
raise ValueError("unexpected event list")
data["events"] = data["events"][:EVENT_LIMIT]
return data
except subprocess.TimeoutExpired:
return {"status": "timeout", "timeout_seconds": EVENT_TIMEOUT}
except (OSError, ValueError, TypeError) as exc:
return {"status": "error", "error": str(exc)[:512]}
def collect() -> dict:
"""One on-demand snapshot. Unsupported platforms and query failures are explicit, never empty successes."""
with _lock:
window = dict(_window)
out = {"collected_at_utc": _now(), "platform": sys.platform, "runtime": _runtime(), "window": window}
if not _is_windows():
out["windows"] = {"status": "not_applicable"}
return out
try:
displays = _windows_displays()
except Exception as exc: # noqa: BLE001 diagnostics must not prevent collecting the rest of the bundle
displays = {"status": "error", "error": str(exc)[:512]}
out["windows"] = {"displays": displays, "system_events": {"lookback_minutes": 30, **_recent_events()}}
return out
+17
View File
@@ -7,11 +7,18 @@ install queue then waits for it instead of failing every remaining job).
from __future__ import annotations
import errno
import ssl
import zipfile
from .i18n import tr
class MissingGameSource(ValueError):
def __init__(self, path, kind="APK"):
self.path, self.kind = path, kind
super().__init__(f"missing game {kind}: {path}")
def _paramiko():
try:
import paramiko
@@ -46,6 +53,16 @@ def explain(exc: BaseException) -> str:
paramiko, _ = _paramiko()
msg = " ".join(str(exc).split()).removeprefix("[Errno None] ")
low = msg.lower()
if isinstance(exc, MissingGameSource):
kind = {"APK": tr("APK"), "data folder": tr("data folder"), "data file": tr("data file")}.get(exc.kind, exc.kind)
return tr("The game's {kind} isn't at {path} any more. Add the game again from the folder that has it.") \
.format(kind=kind, path=exc.path)
if isinstance(exc, ssl.SSLCertVerificationError) or any(
marker in low for marker in ("certificate_verify_failed", "certificate verify failed",
"self-signed certificate", "self signed certificate")):
return tr("The site's HTTPS certificate couldn't be verified. Something on the network, such as antivirus "
"HTTPS scanning, a proxy or a school/work network, may have replaced it. Try another network or "
"turn off HTTPS scanning.")
if type(exc).__name__ == "HostKeyChanged":
return tr("This Frame's SSH key changed since FramePort last connected (SteamOS was reset, or another "
"device now has its address). If it's your Frame, choose Trust the new key on the Steam Frame "
+20 -1
View File
@@ -541,6 +541,17 @@ class Frame:
self._agent_digest = ""
self._video_codec_digest = ""
def upload_session(self) -> Frame:
"""An independent bulk-upload transport to the same authenticated headset."""
from dataclasses import replace
other = Frame(replace(self.target), self.password).connect()
if (other.client.get_transport().get_remote_server_key() !=
self.client.get_transport().get_remote_server_key()):
other.close()
raise paramiko.SSHException("Upload worker reached a different headset; refusing transfer")
return other
def alive(self) -> bool:
"""The SSH connection itself still works (a failed agent command doesn't mean the Frame is gone)."""
transport = self.client.get_transport() if self.client else None
@@ -688,18 +699,24 @@ class Frame:
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,
attempts: int = 3) -> None:
attempts: int = 3, check_cancel=None) -> 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. 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):
if check_cancel:
check_cancel()
try:
return self._put(Path(local), remote, progress, resume, mkdir)
except OSError as exc:
if check_cancel:
check_cancel()
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 check_cancel:
check_cancel()
if not self.alive():
self.close()
self.connect()
@@ -721,6 +738,8 @@ class Frame:
dst.set_pipelined(True)
src.seek(offset)
done = offset
if progress:
progress(done, size) # gives parallel progress an exact resume baseline
while True:
chunk = src.read(1 << 20)
if not chunk:
+52
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import itertools
import json
import math
import posixpath
import threading
from collections import deque
@@ -185,6 +186,57 @@ def main_game(sample: dict | None) -> dict | None:
return next((g for g in (sample or {}).get("games") or [] if not g.get("overlay")), None)
SMOOTH_TAU = 1.5 # seconds: the shown numbers follow a change within ~3 s instead of jumping with every tick
class Smoother:
"""Exponential moving average per value, by time (the same feel at 0.1 s or 2 s intervals): the big numbers
stopped flickering between neighbours (71/72 fps, 18/21 %) at fast intervals. A gap of several `tau` (paused,
reconnected) or a missing value starts that value afresh."""
def __init__(self, tau: float = SMOOTH_TAU):
self.tau = tau
self.state: dict[str, tuple[float, float]] = {} # key -> (value, time)
def value(self, key: str, v: float | None, t: float | None) -> float | None:
if v is None:
self.state.pop(key, None)
return None
prev = self.state.get(key)
if prev is None or t is None or not 0 < t - prev[1] < 4 * self.tau:
out = float(v)
else:
alpha = 1 - math.exp(-(t - prev[1]) / self.tau)
out = prev[0] + alpha * (v - prev[0])
self.state[key] = (out, t if t is not None else 0.0)
return out
def sample(self, s: dict) -> dict:
"""A copy of the sample with the shown numbers smoothed (tiles + each game's fps/CPU/GPU, by package)."""
t = s.get("t")
out = dict(s)
def part(name, keys=None):
d = s.get(name)
if isinstance(d, dict):
out[name] = {**d, **{k: self.value(f"{name}.{k}", d.get(k), t) for k in (keys or d)
if isinstance(d.get(k), (int, float)) and not isinstance(d.get(k), bool)}}
part("cpu", ("total",))
part("gpu", ("busy",))
part("mem", ("avail",))
part("temps")
part("power")
if s.get("games"):
out["games"] = [{**g, **{k: self.value(f"game:{g.get('package')}.{k}", g.get(k), t)
for k in ("fps", "cpu", "gpu") if g.get(k) is not None}}
for g in s["games"]]
return out
def clear(self) -> None:
self.state.clear()
def series_of(sample: dict) -> dict[str, float | None]:
"""The values one sample adds to the History."""
mem = sample.get("mem") or {}
+183
View File
@@ -0,0 +1,183 @@
"""System OpenSSH for bulk files; the established Frame connection owns trust and progress polling."""
from __future__ import annotations
import contextlib
import csv
import io
import os
import posixpath
import shutil
import signal
import subprocess
import tempfile
import time
from pathlib import Path
from .connection import sh_quote, ssh_dir
def quote(value) -> str:
value = str(value)
if any(c in value for c in "\r\n\x00"):
raise ValueError("SFTP paths cannot contain newlines or NUL")
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
for char in "*?[":
escaped = escaped.replace(char, "\\" + char)
return '"' + escaped + '"'
def size_of(sftp, path):
try:
return sftp.stat(path).st_size
except FileNotFoundError:
return 0
@contextlib.contextmanager
def credentials(frame):
"""Private temporary copies avoid modifying pairing keys, including their Windows ACLs."""
key_paths = [ssh_dir() / name for name in ("id_ed25519", "id_rsa") if (ssh_dir() / name).exists()]
if not key_paths:
raise OSError("no FramePort key available for OpenSSH")
key = frame.client.get_transport().get_remote_server_key()
with tempfile.TemporaryDirectory(prefix="frameport-upload-") as directory:
directory = Path(directory)
sid = None
if os.name == "nt":
row = next(csv.reader(io.StringIO(subprocess.check_output(
["whoami", "/user", "/fo", "csv", "/nh"], text=True, **hidden()))))
sid = row[-1]
subprocess.run(["icacls", str(directory), "/inheritance:r", "/grant:r", f"*{sid}:(OI)(CI)F"],
check=True, stdout=subprocess.DEVNULL, **hidden())
else:
directory.chmod(0o700)
copies = []
for path in key_paths:
copy = directory / path.name
shutil.copyfile(path, copy)
if sid:
subprocess.run(["icacls", str(copy), "/inheritance:r", "/remove:g", "*S-1-3-4",
"/grant:r", f"*{sid}:F"], check=True, stdout=subprocess.DEVNULL, **hidden())
else:
copy.chmod(0o600)
copies.extend(["-i", str(copy)])
known = directory / "known_hosts"
host = frame.target.host if frame.target.port == 22 else f"[{frame.target.host}]:{frame.target.port}"
known.write_text(f"{host} {key.get_name()} {key.get_base64()}\n")
yield ["-F", os.devnull, *copies, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=yes", "-o", f'UserKnownHostsFile="{known.as_posix()}"',
"-o", f"GlobalKnownHostsFile={os.devnull}", "-o", "ConnectTimeout=10",
"-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3",
"-P", str(frame.target.port)]
def hidden():
return {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {}
def stop_process(process):
"""SFTP starts an ssh child: stop the entire owned process group before resuming with another backend."""
if process.poll() is None:
if os.name == "nt":
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, **hidden())
if process.poll() is None:
process.kill()
else:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
def upload(frame, items, remote_root, reporter, total, sent, workers):
"""One persistent SFTP batch per worker. A failure leaves .part files for Paramiko to resume.
Only successful batches are promoted, after every process has exited successfully. All control operations
happen in the supervising thread, so Paramiko's SFTP client is never shared across threads.
"""
executable = shutil.which("sftp")
if not executable:
raise OSError("system OpenSSH SFTP is unavailable")
from ..install.installer import Speed
reporter.check_cancel()
if not items:
return sent
groups = [[] for _ in range(min(workers, len(items)))]
sizes, offsets, progress = {}, {}, {}
for path, rel, size in sorted(items, key=lambda item: item[2], reverse=True):
remote = posixpath.join(remote_root, rel)
quote(path)
quote(remote)
offset = size_of(frame.sftp, remote + ".part")
if offset > size:
frame.sftp.remove(remote + ".part")
offset = 0
offsets[rel] = progress[rel] = offset
sizes[rel] = size
if offset == size:
continue # complete from an earlier attempt (sftp's reput refuses a target of the same size): rename only
group = min(groups, key=lambda group: sum(item[2] for item in group))
group.append((path, rel, size))
groups = [group for group in groups if group]
speed = Speed(total - sent - sum(offsets.values()))
reporter.log(f"uploading large files with OpenSSH SFTP ({len(groups)} workers)")
setup_started = time.monotonic()
with credentials(frame) as args, contextlib.ExitStack() as stack:
monitor = stack.enter_context(frame.client.open_sftp())
monitor.get_channel().settimeout(2)
reporter.log(f"OpenSSH credentials and progress connection ready in {time.monotonic() - setup_started:.2f}s")
processes = []
try:
for group in groups:
reporter.check_cancel()
batch = stack.enter_context(tempfile.TemporaryFile())
for path, rel, _ in group:
remote = posixpath.join(remote_root, rel) + ".part"
command = "reput" if offsets[rel] else "put"
batch.write(f"{command} {quote(path)} {quote(remote)}\n".encode())
batch.seek(0)
errors = stack.enter_context(tempfile.TemporaryFile())
process = subprocess.Popen([executable, *args, "-b", "-",
f"{frame.target.user}@{frame.target.host}"], stdin=batch,
stdout=subprocess.DEVNULL, stderr=errors,
**(hidden() if os.name == "nt" else {"start_new_session": True}))
processes.append((process, errors))
while True:
reporter.check_cancel()
for process, errors in processes:
if process.poll() not in (None, 0):
errors.seek(0)
detail = errors.read().decode(errors="replace")[-1500:]
raise OSError(f"OpenSSH SFTP failed ({process.returncode}): {detail}")
for rel, size in sizes.items():
reporter.check_cancel()
progress[rel] = max(progress[rel], min(size, size_of(
monitor, posixpath.join(remote_root, rel) + ".part")))
done = sum(progress.values())
reporter.progress((sent + done) / max(total, 1),
f"{len(items)} files · {done / 2**20:.0f}/{sum(sizes.values()) / 2**20:.0f} MiB",
speed=speed.text(done - sum(offsets.values())))
if all(process.poll() == 0 for process, _ in processes):
break
time.sleep(0.2)
for _, rel, size in items:
reporter.check_cancel()
remote = posixpath.join(remote_root, rel)
if size_of(monitor, remote + ".part") != size:
raise OSError(f"OpenSSH upload has an unexpected size: {rel}")
for start in range(0, len(items), 100):
reporter.check_cancel()
commands = []
for _, rel, _ in items[start:start + 100]:
remote = posixpath.join(remote_root, rel)
commands.append(f"mv -f -- {sh_quote(remote + '.part')} {sh_quote(remote)}")
code, _, error = frame.run(" && ".join(commands))
if code:
raise OSError(f"cannot finalize OpenSSH upload: {error}")
finally:
for process, _ in processes:
stop_process(process)
return sent + sum(size for _, _, size in items)
+106
View File
@@ -0,0 +1,106 @@
"""Pure Quest update decisions shared by the pipeline and UI (no filesystem or network I/O)."""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from .patches import base, upstream
def fingerprint(value) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest()[:32]
# These settings select APK support as well as runtime behaviour. Keep their revisions too: a newer shim can
# be needed even though the value did not change. Foveation remains an APK patch, including all its parameters.
APK_SETTINGS = {"adapter.haptic_fix", "adapter.controller_models", "adapter.surface_native",
"adapter.zink_shader_fix", "adapter.zink_shader_dump"}
def apk_recipe_fingerprint(recipe: dict, package: str = "", superseded=None) -> str:
base.load_all()
patches = upstream.without_superseded(recipe.get("patches") or {}, superseded)
selected = {}
for pid, params in patches.items():
patch = base.REGISTRY.get(pid)
if patch is None or patch.stage != "install" or pid in APK_SETTINGS:
selected[pid] = params
elif patch.category == "adapter" and patch.revision_for(package) != 1:
# Adapter revisions also track changes to the native code implementing a setting.
selected[pid] = {"revision": patch.revision_for(package)}
if recipe.get("as_is"):
return base.recipe_fingerprint({"as_is": True, "overport": False}, package)
return base.recipe_fingerprint({**recipe, "patches": selected, "use_alt": False}, package)
def configuration(recipe: dict, package: str = "", superseded=None) -> dict:
from .patches.settings import BASE_SETTINGS, SETTINGS, adapter_settings
patches = upstream.without_superseded(recipe.get("patches") or {}, superseded)
# Explicit defaults override settings embedded in an older APK when a recipe removes an override.
settings = {key: default for key, _, default, *_ in SETTINGS}
settings.update(BASE_SETTINGS)
settings.update(adapter_settings(patches))
install = {pid: {"params": params or {}, "revision": base.get(pid).revision_for(package)}
for pid, params in patches.items() if base.get(pid).stage == "install" and
base.get(pid).category != "adapter"}
return {"settings": settings, "patches": install}
def configuration_fingerprint(recipe: dict, package: str = "", superseded=None) -> str:
return fingerprint(configuration(recipe, package, superseded))
@dataclass(frozen=True)
class UpdatePlan:
rebuild: bool = False
apk: bool = False
data: bool = False
configure: bool = False
artwork: bool = False
shortcut: bool = False
reasons: tuple[str, ...] = ()
@property
def changed(self) -> bool:
return any((self.rebuild, self.apk, self.data, self.configure, self.artwork, self.shortcut))
@property
def launch_test(self) -> bool:
return self.rebuild or self.apk or self.data
def plan_update(game: dict, deployment: dict | None, *, apk_only: bool = False,
data_manifest: dict | None = None) -> UpdatePlan:
"""Unknown legacy identities use the full path once. Catalog metadata never affects these identities."""
recipe, build = game.get("recipe") or {}, game.get("build") or {}
package = game.get("package", "")
dep = deployment or {}
missing = not dep or dep.get("apk_present") is False
current = apk_recipe_fingerprint(recipe, package, build.get("superseded"))
rebuild = not build.get("ok", True) or not build.get("apk_recipe_fp") or build["apk_recipe_fp"] != current
if build.get("source_version") != (game.get("analysis") or {}).get("version"):
rebuild = True
if game.get("source_sha256") and build.get("source_sha256") != game["source_sha256"]:
rebuild = True
if recipe.get("use_alt") and not build.get("alt_sha256"):
rebuild = True
sha = build.get("alt_sha256") if recipe.get("use_alt") else build.get("sha256")
apk = missing or rebuild or not sha or dep.get("sha256") != sha
configure = apk or dep.get("configuration_fp") != configuration_fingerprint(
recipe, package, build.get("superseded"))
if data_manifest is None:
data_manifest = game.get("data_manifest")
data = False
if not apk_only and data_manifest:
if "data_manifest" in dep:
data = any(dep["data_manifest"].get(k) != v for k, v in data_manifest.items())
else:
data = fingerprint(data_manifest) != dep.get("data_fp")
artwork = missing or bool(game.get("steam_art_stale"))
shortcut = missing or bool(game.get("steam_name_stale")) or artwork
reasons = tuple(name for name, needed in (("APK build inputs changed or unrecorded", rebuild),
("APK differs", apk), ("Game data differs", data), ("Runtime configuration differs", configure),
("Artwork differs", artwork), ("Steam entry differs", shortcut)) if needed)
return UpdatePlan(rebuild, apk, data, configure, artwork, shortcut, reasons)
+244 -25
View File
@@ -3,13 +3,15 @@ from __future__ import annotations
import contextlib
import posixpath
import threading
import time
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from dataclasses import dataclass
from pathlib import Path
from ..artwork import fetch as artwork
from ..build import sha256
from ..core.events import Reporter
from ..core.events import Cancelled, Reporter
from ..core.models import Recipe, data_manifest
from ..frame.connection import Frame, sh_quote
from ..patches import base
@@ -39,6 +41,56 @@ def install_context(recipe: Recipe) -> base.InstallContext:
return ctx
def configuration_args(entry: dict, recipe: Recipe) -> dict:
"""One complete configuration for both an APK install and the configuration-only path."""
from ..game_update import configuration, configuration_fingerprint
ctx = install_context(recipe)
original = entry["recipe"]
build = entry.get("build") or {}
applied = configuration(original, recipe.package, build.get("superseded"))
settings = applied["settings"]
return dict(settings=settings, files={k: v.decode() if isinstance(v, bytes) else v
for k, v in ctx.files.items()}, env=ctx.env,
flatscreen=show_window(ctx, _flatscreen(recipe.package)), config_sync=ctx.config_sync,
recipe={"patches": sorted(recipe.patches), "source": recipe.source, "alt": recipe.use_alt},
configuration=applied,
configuration_fp=configuration_fingerprint(original, recipe.package, build.get("superseded")),
apk_recipe_fp=build.get("apk_recipe_fp"), source_version=build.get("source_version"))
def update_existing(frame: Frame, entry: dict, deployment: dict, plan, recipe: Recipe,
reporter: Reporter, manifest: dict | None = None) -> dict:
"""No local converted APK is needed for runtime/data updates to a matching deployed build."""
args = configuration_args(entry, recipe)
# the library's current Steam tags (user tags, genres): the shortcut check after this update writes them
args.update(package=entry["package"], title=entry.get("title") or entry["package"],
apk_sha256=deployment["sha256"], tags=_tags(entry["package"]))
if plan.data:
reporter.stage("Check game data")
prep = frame.agent("prepare", package=entry["package"], title=args["title"])
manifest = manifest or {}
pending = [(Path(entry["data_dir"]) / rel, f"obb/{rel}", size)
for rel, size in manifest.items() if prep["existing_obb"].get(rel) != size]
need = sum(size for _, _, size in pending)
if need > prep["free_bytes"] - (1 << 30):
raise RuntimeError("not enough space on the Frame for the changed game data + 1 GiB headroom")
with transfer_link(frame, reporter, need) as xfer:
if pending:
reporter.stage("Upload changed game data")
upload_files(xfer, pending, prep["incoming"], reporter, max(need, 1))
args.update(obb_manifest=manifest, existing_only=True, consume_artwork=False,
apk_name=deployment.get("apk", "game.apk"))
reporter.stage("Finalize game data")
return frame.agent("finalize", **args)
reporter.stage("Apply game configuration")
result = frame.agent("configure", **args)
if result.get("deployment", {}).get("configuration_fp") != args["configuration_fp"]:
raise RuntimeError("the Frame did not confirm the applied configuration")
reporter.check("Game configuration", True, "applied; used from the game's next start")
return result
def local_data_manifest(data_dir: Path | None, files: list[str] | None = None) -> dict[str, int]:
return data_manifest(data_dir, files)
@@ -66,6 +118,9 @@ class Speed:
def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
from ..core import library
entry = library.game(plan.package)
reporter.stage("Prepare Frame")
apk_sha = sha256(plan.apk)
prep = frame.agent("prepare", package=plan.package, title=plan.title, dest=plan.dest,
@@ -93,7 +148,10 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
reporter.check_cancel()
reporter.progress(done / total, f"APK {done / 2**20:.0f}/{size / 2**20:.0f} MiB",
speed=speed.text(done))
xfer.put(plan.apk, posixpath.join(incoming, "game.apk"), apk_cb)
if plan.apk.stat().st_size >= BIG_FILE:
upload_files(xfer, [(plan.apk, "game.apk", plan.apk.stat().st_size)], incoming, reporter, total)
else:
xfer.put(plan.apk, posixpath.join(incoming, "game.apk"), apk_cb)
sent += plan.apk.stat().st_size
if to_send:
reporter.stage(f"Upload data ({len(to_send)} files)")
@@ -102,19 +160,25 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
elif manifest:
reporter.log("game data already on the Frame; not re-sending")
artwork.fetch(plan.package, plan.apk)
upload_steam_art(frame, plan.package, prep["base"], reporter)
artwork_manifest = None
if not prep.get("installed") or (entry or {}).get("steam_art_stale"):
artwork.fetch(plan.package, plan.apk)
artwork_manifest = upload_steam_art(frame, plan.package, prep["base"], reporter)
reporter.stage("Finalize install")
ctx = install_context(plan.recipe)
config = configuration_args(entry, plan.recipe) if entry else dict(
settings=ctx.adapter_settings, files={k: v.decode() if isinstance(v, bytes) else v
for k, v in ctx.files.items()}, env=ctx.env,
flatscreen=show_window(ctx, _flatscreen(plan.package)), config_sync=ctx.config_sync or None,
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source, "alt": plan.recipe.use_alt})
if artwork_manifest is not None:
config["artwork_manifest"] = artwork_manifest
result = frame.agent(
"finalize", package=plan.package, title=plan.title, dest=plan.dest, apk_sha256=apk_sha,
tags=_tags(plan.package),
apk_name=plan.apk.name, settings=ctx.adapter_settings,
files={k: v.decode() if isinstance(v, bytes) else v for k, v in ctx.files.items()}, env=ctx.env,
obb_manifest=manifest or None, flatscreen=show_window(ctx, _flatscreen(plan.package)),
config_sync=ctx.config_sync or None,
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source, "alt": plan.recipe.use_alt},
apk_name=plan.apk.name, obb_manifest=manifest if not plan.apk_only else None,
preserve_data=plan.apk_only, consume_artwork=artwork_manifest is not None, **config,
)
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
log_controller_models(result.get("controller_models"), reporter)
@@ -356,10 +420,102 @@ def ensure_proton(frame: Frame, reporter: Reporter, tool: str | None = None, tim
raise RuntimeError(f"{name} didn't finish installing on the Frame in time; check Steam's downloads on the Frame")
BIG_FILE = 8 << 20 # bigger files go one by one (resumable); smaller ones are streamed in batches
BIG_FILE = 8 << 20 # bigger files use resumable SFTP; smaller ones are streamed in batches
BATCH_BYTES, BATCH_FILES = 256 << 20, 2000
def upload_threads(value=None) -> int:
"""Bound the saved preference; the one-job FIFO still bounds concurrent installs."""
from ..core import library
if value is None:
value = library.setting("install.upload_threads", 3)
try:
return max(1, min(8, int(value)))
except (TypeError, ValueError, OverflowError):
return 3
def _upload_parallel(frame, items, remote_root, reporter, total, sent, workers):
"""Each worker owns one transport; failures stop the cohort and leave resumable .part files."""
lock, stop, local = threading.Lock(), threading.Event(), threading.local()
sessions, errors, progress, offsets = [], [], {}, {}
speed = Speed(total - sent)
def upload(item):
path, rel, size = item
try:
reporter.check_cancel()
if stop.is_set():
raise Cancelled()
if not hasattr(local, "frame"):
session = frame.upload_session()
with lock:
sessions.append(session)
if stop.is_set():
session.close()
raise Cancelled()
local.frame = session
def show(done, size):
reporter.check_cancel()
if stop.is_set():
raise Cancelled()
with lock:
offsets.setdefault(rel, done)
progress[rel] = max(progress.get(rel, 0), done)
completed = sum(progress.values())
resumed = sum(offsets.values())
speed.total = total - sent - resumed
reporter.progress((sent + completed) / max(total, 1),
f"{rel} {done / 2**20:.0f}/{size / 2**20:.0f} MiB",
speed=speed.text(completed - resumed))
def check_cancel():
reporter.check_cancel()
if stop.is_set():
raise Cancelled()
local.frame.put(path, posixpath.join(remote_root, rel), show, mkdir=False, check_cancel=check_cancel)
except Exception as exc:
with lock:
if not errors:
errors.append(exc)
stop.set()
raise
try:
with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="frameport-upload") as pool:
pending = {pool.submit(upload, item) for item in items}
interrupted = False
while pending:
if reporter.cancelled.is_set():
with lock:
if not errors:
errors.append(Cancelled())
stop.set()
done, pending = wait(pending, timeout=0.2, return_when=FIRST_COMPLETED)
for future in done:
if not future.cancelled():
try:
future.result()
except Exception:
stop.set()
if stop.is_set() and not interrupted:
interrupted = True
for future in pending:
future.cancel()
pending = {future for future in pending if not future.cancelled()}
with lock:
active = list(sessions)
for session in active:
session.close() # interrupt blocked transfers; never close the control connection
if errors:
raise errors[0]
finally:
for session in sessions:
session.close()
return sent + sum(size for _, _, size in items)
FAST_LINK_MIN = 64 << 20 # below this a second connection isn't worth it
@@ -384,12 +540,14 @@ def transfer_link(frame: Frame, reporter: Reporter, need: int):
def upload_files(frame: Frame, items: list[tuple[Path, str, int]], remote_root: str, reporter: Reporter,
total: int, sent: int = 0, speed: Speed | None = None) -> int:
total: int, sent: int = 0, speed: Speed | None = None, workers: int | None = None,
prefer_native: bool = True) -> int:
"""Upload (local, relative path, size) items under remote_root. Interruptible (Cancel, dropped connection) and
resumable: finished files are skipped next time (the agent counts files already in incoming/), a cut-off big file
continues from its .part. Returns bytes sent."""
big = [i for i in items if i[2] >= BIG_FILE]
big = sorted((i for i in items if i[2] >= BIG_FILE), key=lambda item: item[2], reverse=True)
small = [i for i in items if i[2] < BIG_FILE]
reporter.check_cancel()
frame.mkdirs(posixpath.dirname(posixpath.join(remote_root, rel)) for _, rel, _ in big)
state = {"sent": sent}
speed = speed or Speed(total)
@@ -398,12 +556,45 @@ def upload_files(frame: Frame, items: list[tuple[Path, str, int]], remote_root:
reporter.check_cancel()
done = state["sent"] + extra
reporter.progress(done / total, label, speed=speed.text(done))
for local, rel, size in big:
reporter.check_cancel()
frame.put(local, posixpath.join(remote_root, rel),
lambda done, size, rel=rel: show(done, f"{rel} {done / 2**20:.0f}/{size / 2**20:.0f} MiB"),
mkdir=False)
state["sent"] += size
threads = min(upload_threads(workers), len(big))
native_done = False
if big and prefer_native and isinstance(frame, Frame):
from ..frame import native_upload
original = reporter
class ProgressReporter:
last = 0.0
def __getattr__(self, name):
return getattr(original, name)
def progress(self, fraction, label, **kwargs):
self.last = max(self.last, fraction)
original.progress(self.last, label, **kwargs)
reporter = ProgressReporter() # fallback resumes in a different order; never move the progress bar backward
try:
state["sent"] = native_upload.upload(frame, big, remote_root, reporter, total, sent, max(threads, 1))
native_done = True
except Cancelled:
raise
except Exception as exc: # native startup, authentication or transfer failure: retain the portable path
reporter.check_cancel()
reporter.log(f"OpenSSH unavailable or failed; resuming with Paramiko: {exc}")
speed.samples.clear()
if native_done:
pass
elif threads > 1 and hasattr(frame, "upload_session"):
reporter.log(f"uploading large files with {threads} workers")
state["sent"] = _upload_parallel(frame, big, remote_root, reporter, total, sent, threads)
speed.samples.clear()
else:
for local, rel, size in big:
reporter.check_cancel()
frame.put(local, posixpath.join(remote_root, rel),
lambda done, size, rel=rel: show(done, f"{rel} {done / 2**20:.0f}/{size / 2**20:.0f} MiB"),
mkdir=False)
state["sent"] += size
batch: list[tuple[Path, str]] = []
batch_bytes = 0
@@ -455,7 +646,16 @@ def _flatscreen(package: str) -> bool:
return bool(g.get("analysis")) and library.analysis_from_dict(g["analysis"]).vr_kind == "none"
def upload_steam_art(frame: Frame, package: str, base: str, reporter: Reporter) -> None:
def art_manifest(package: str, art: dict) -> dict[str, str]:
import hashlib
from ..artwork.sources import icon_source
return {**{f"{kind}{path.suffix}": sha256(path) for kind, path in art.items()},
".icon-source": hashlib.sha256(icon_source(package).encode()).hexdigest()}
def upload_steam_art(frame: Frame, package: str, base: str, reporter: Reporter) -> dict[str, str]:
"""The complete Steam art set (portrait, wide, hero, logo, icon — composed where the store has no such shape)."""
from ..artwork.steam import steam_set_for
@@ -467,6 +667,7 @@ def upload_steam_art(frame: Frame, package: str, base: str, reporter: Reporter)
frame.put(f, posixpath.join(remote_art, f"{kind}{f.suffix}"), resume=False)
send_icon_source(frame, package, remote_art)
reporter.check("Steam artwork", bool(art) or None, ", ".join(sorted(art)) or "none found")
return art_manifest(package, art)
def send_icon_source(frame: Frame, package: str, remote_art: str) -> None:
@@ -509,7 +710,8 @@ def update_steam_art(frame: Frame, package: str, reporter: Reporter, title: str
"""Replace an installed game's Steam library art with the current artwork (after the user picked new art):
the art set goes to the game's anchor, then the shortcut is rewritten (Steam restarts once). `title`: the game's
name in the library; a different name on the Frame is renamed first (agent v77 `rename`: the shortcut keeps its
appid, so Play, grid art and saves stay). Placeholder art shows the name, so it's sent again too."""
appid, so Play, grid art and saves stay). Placeholder art shows the name, so it's sent again too.
The result has `library: True` when the shortcut was rewritten too (else the caller still checks it)."""
from ..artwork.steam import steam_set_for
dep = next((d for d in frame.agent("list_installed")["games"] if d.get("package") == package), None)
@@ -526,16 +728,33 @@ def update_steam_art(frame: Frame, package: str, reporter: Reporter, title: str
art = steam_set_for(package)
if not art:
if renamed:
return add_to_steam(frame, [package], reporter)
return {**(add_to_steam(frame, [package], reporter) or {}), "library": True}
raise RuntimeError("no artwork to send")
manifest = art_manifest(package, art)
previous = dep.get("artwork_manifest") or {}
if manifest == previous:
reporter.log("Steam artwork already current; not re-sending")
return {**(add_to_steam(frame, [package], reporter) or {}), "library": True} if renamed else {"unchanged": True}
reporter.stage("Artwork for the Steam library")
remote = posixpath.join(dep["anchor"], "artwork")
frame.run(f"rm -rf {sh_quote(remote)} && mkdir -p {sh_quote(remote)}")
if not previous:
# Unrecorded art (installs before agent v86): what's in the folder is unknown, and an old portrait.jpg next
# to a new portrait.png would be picked at random for the Steam grid. Start clean as before; all is sent.
frame.run(f"rm -rf -- {sh_quote(remote)}")
frame.run(f"mkdir -p {sh_quote(remote)}")
for name in previous.keys() - manifest.keys():
# Deployment records are data, not shell paths. Never act on an unexpected name.
if "/" not in name and name not in (".", ".."):
frame.run(f"rm -f -- {sh_quote(posixpath.join(remote, name))}")
for kind, f in art.items():
frame.put(f, posixpath.join(remote, f"{kind}{f.suffix}"), resume=False)
send_icon_source(frame, package, remote)
name = f"{kind}{f.suffix}"
if previous.get(name) != manifest[name]:
frame.put(f, posixpath.join(remote, name), resume=False)
if previous.get(".icon-source") != manifest[".icon-source"]:
send_icon_source(frame, package, remote)
frame.agent("record_artwork", package=package, manifest=manifest)
reporter.check("Steam artwork", True, ", ".join(sorted(art)))
return add_to_steam(frame, [package], reporter)
return {**(add_to_steam(frame, [package], reporter) or {}), "library": True}
def add_to_steam(frame: Frame, packages: list[str], reporter: Reporter, wait: float = 120) -> dict:
+26 -4
View File
@@ -186,6 +186,7 @@
"Assassin's Creed Nexus (build 207706 only): turns off application space warp and runs at 90 Hz.": "",
"Assassin's Creed Nexus: smoother picture at 72 Hz (turns off Meta's frame-rate trick).": "",
"Assassin's Creed Nexus: smoother picture at 90 Hz (turns off Meta's frame-rate trick).": "",
"Asset-file service bypass": "",
"At most this many redraws per second of a 360° layer (0 = no limit). Video players need at least the video's frame rate.": "",
"Automatic": "",
"Automatic ({auto:.0%})": "",
@@ -256,6 +257,8 @@
"Clear search": "",
"Clear selection": "",
"Clear tags": "",
"Clear the game's GL errors (GLES)": "",
"Clears OpenGL errors the game left pending before FrameBridge calls the Frame's runtime. The runtime's GL code fails on any pending error; after one such failure at the start it allocated GPU memory every frame without freeing it (e.g. Vader Immortal, ~20 MB a second).": "",
"Click here, then type. Every key goes to the Frame.": "",
"Click to open · right-click for quick actions": "",
"Click to open · right-click for quick actions · drag across cards to select several": "",
@@ -340,6 +343,8 @@
"Cylinder layers (curved menus and movie screens, for example 4XVR), which the Frame's runtime lacks, are shown as a few flat strips along the curve. 0 = drop them. 360° (equirect) layers can't be shown on the Frame.": "",
"Darken lower-resolution regions (test)": "",
"Data": "",
"data file": "",
"data folder": "",
"Data folder": "",
"Decide later": "",
"Declare Meta-only permissions": "",
@@ -373,6 +378,7 @@
"Dev builds are early, less tested versions and may have bugs. You still get the next release as a normal update (you have {version}).": "",
"Developer Mode lets FramePort find and connect to the Frame; the setup turns it on for you. With it on, you can also open Settings → Developer → Pair new host on the Frame and approve FramePort there.": "",
"Diagnostics for frame.gl_multiview_fbo: checks for GL errors after every single-view draw of a multiview shader and logs counters every 5 seconds (tag GLMV in launch.log).": "",
"Diagnostics: removes layer color fades. Some fades no longer show.": "",
"Diagnostics: the Vulkan shim (frame.vk_sanitize) adds Khronos' validation layer to the game's instance; its findings go to launch.log. The layer library (libVkLayer_khronos_validation.so) must be in the APK.": "",
"Disable application space warp if used": "",
"Disable controller tracking offset": "",
@@ -451,6 +457,8 @@
"Everything is up to date": "",
"Experimental": "",
"experimental": "",
"Experimental bypass for a DLC manager blocked on Meta asset-file service errors at startup (e.g. PixelRipped 1995: Asset status error (code 100)). Returns an empty successful asset list and reports requested asset IDs as installed, with an empty file path. Does not supply DLC or change download requests. Enable only to test this startup failure; disable with FRAMEPORT_ASSET_FILES_STUB=0. Shares the language-pack library and needs no language packs.": "",
"Experimental bypass for a failed Meta asset-file check at startup. Reports an empty asset list and installed status without a file path; does not provide DLC. Experimental.": "",
"Experimental Vulkan mutable-format workaround.": "",
"Experimental. Leave it off unless asked to test it.": "",
"Experimental: shows HUDs, menus and other flat panels that stay black on the Frame (for example Doom3Quest's HUD and PDA).": "",
@@ -483,6 +491,7 @@
"Fix freezes when controllers vibrate": "",
"Fix hands lagging behind controllers": "",
"Fix launcher icon entry": "",
"Fix Lua startup crashes on Frame": "",
"Fix minimal Android SDK": "",
"Fix UE4 crash with Unity stub": "",
"Fix upside-down menus": "",
@@ -509,7 +518,6 @@
"For games whose fades to black don't show, so you see the level loading (for example Batman: Arkham Shadow).": "",
"For games whose hands trail the controllers when you move them (for example BattleSisters).": "",
"For games whose hands' thumbs never move when you touch the buttons or thumbstick (for example Vader Immortal).": "",
"For games whose memory use keeps growing (for example Vader Immortal). Some fades no longer show.": "",
"For games whose menus or screens jump to wherever you look.": "",
"For games whose picture flickers or smears (for example Into The Radius 2).": "",
"For heavy games that use application space warp: it causes artifacts or hangs on non-Quest runtimes (for example Asgard's Wrath 2, Batman: Arkham Shadow).": "",
@@ -518,6 +526,7 @@
"For older Unity games that freeze at the first scene change (for example Sniper Elite VR). Can make loading screens stutter.": "",
"For older Unity games whose hands trail the controllers (for example Sniper Elite VR). In others the hands lag.": "",
"For one eye looking wrong (for example a jittering right eye): logs each eye's submitted pose against a fresh one, the eye-to-eye relation and when each eye's image was released, also into framebridge.log in the game's storage (some games stop Android's log early). No effect on the game.": "",
"For OpenGL ES games whose memory use keeps growing (e.g. Vader Immortal).": "",
"For overlay apps (fpsVR, wrist watches and other SteamVR add-ons that draw over games instead of being a game): FramePort registers the app with the Frame's SteamVR, which draws its overlays over whatever you play there, Quest and PC VR games alike. With 'start with SteamVR' on, SteamVR starts it by itself (also after a restart); otherwise start it from the library. It isn't launch-tested (it shows nothing without a game). Ignored on this PC.": "",
"For troubleshooting: records the game's requests to Meta's online services.": "",
"For Unity games that crash when they turn on anti-aliasing (for example The Room VR).": "",
@@ -784,6 +793,7 @@
"KiB": "",
"Language": "",
"Language packs from the game's files": "",
"Large files upload together using OpenSSH when available. Default: 3. Lower this on a busy network.": "",
"Last build": "",
"Last launch test's log": "",
"Last launch test: passed": "",
@@ -812,6 +822,7 @@
"Lets games built on Meta's oldest VR interface run on the Frame.": "",
"Lets games made for Oculus PCs run on SteamVR.": "",
"Lets games start that would stop at Meta's avatars (for example BlazeRush). No Meta avatars.": "",
"Lets games with a verified older Lua engine get past a startup crash on the Frame.": "",
"Lets games with Vivox voice chat start on the Frame (they'd crash on an Android 12 audio call).": "",
"Lets games written for newer OpenXR versions run on the Frame.": "",
"Lets Godot games start on the Frame (they'd crash looking for a clipboard).": "",
@@ -1000,6 +1011,7 @@
"OK": "",
"Older way to turn upside-down panels; prefer Fix upside-down menus.": "",
"Older workaround for upside-down quads: rotates them 180° (quads are single-sided; prefer flip_emul).": "",
"Older xLua/LuaJIT engines reject native pointers outside their 47-bit range, causing a startup error and sometimes a second crash during Unity shutdown (e.g. BattleTalent). Uses a Lua engine that preserves full pointer values while keeping the game's original xLua bindings and native helpers. Applied only to the verified compatible library build.": "",
"ON AIR": "",
"On Frame": "",
"On the desktop": "",
@@ -1055,6 +1067,7 @@
"OVRPort's own VrApi→OpenXR adapter for engines that call libvrapi.so directly: the same upstream code as FramePort's 'VrApi → OpenXR bridge' without its Frame-specific changes. Only in OVRPort's experimental CLI builds (the stable CLI lists it but can't apply it).": "",
"OVRPort's platform loader answers the Meta Platform SDK's language-pack calls (ovr_LanguagePack_GetCurrent/SetCurrent) with 'no request', so a game that asks for its language pack never gets an answer. Adds a small library that serves language packs (<tag>.lang, for example de.lang) found in the game's own data folders (OBB and app files) through the same calls, the asset-file list and the status call, without any download. The game then finds the file path itself. The pack it applies with SetCurrent wins; otherwise the environment variable FRAMEPORT_LANGPACK (a tag), otherwise the only pack there is. Everything else goes to the loader unchanged. Off by default: turn it on for a game whose data has such files (the game page lists the patch only then).": "",
"Package": "",
"Parallel uploads": "",
"passed": "",
"Password (first time only)": "",
"Paste an install link: an install button's address, a manifest (.json) or a direct link to an APK, .zip or .exe.": "",
@@ -1216,7 +1229,7 @@
"Removes Meta's old VR library when the game doesn't need it.": "",
"Removes OVRPort's controller pose offset if controllers look misplaced.": "",
"Removes requirements for Quest-only system parts.": "",
"Removes the color scale/bias (XR_KHR_composition_layer_color_scale_bias) from the game's layers, so the runtime doesn't apply it. The Frame's runtime allocates GPU memory for it every frame without freeing it, for example Vader Immortal leaked ~20 MB/s on its loading screen. Fades done this way no longer show.": "",
"Removes the color scale/bias (XR_KHR_composition_layer_color_scale_bias) from the game's layers, so the runtime doesn't apply it (diagnostics). Fades done this way no longer show.": "",
"Removes the game from the Frame. Saves are kept, so a reinstall picks up where you left off.": "",
"Removes {title} from Steam on this PC (Steam restarts once). The game folder stays.": "",
"Removes {title}'s game files from the Frame. Its saves and own files stay unless you tick the box below. Its Steam entry goes away after Steam's next restart.": "",
@@ -1291,7 +1304,9 @@
"Saved {name} — drag it into the GitHub issue that opened": "",
"Saved. Restart the game to use the new settings.": "",
"Saved. They apply when the game is installed.": "",
"Saves a few UI panels before and after FrameBridge turns them upright, for testing without the headset.": "",
"saves and settings": "",
"Saves the first N UI panels that FrameBridge turns upright (Vulkan games whose panels are stored upside down), before and after the flip, as fb_flip_<n>_raw.ppm / fb_flip_<n>_upright.ppm in the game's files folder on the Frame. Shows what the runtime gets without anyone wearing the headset.": "",
"Saves the game's shaders to capture one for a fix. No effect on the game.": "",
"Saves the game's shaders to find one that hangs the GPU. No effect on the game.": "",
"Saves what the game draws every few seconds, for testing without the headset.": "",
@@ -1373,6 +1388,7 @@
"Skip Meta avatars": "",
"Skip this version": "",
"Skips a Quest-only reporting step in Meta's audio library that crashes some games.": "",
"Skips a separate data-file check when an Unreal game still hangs at startup with the content check off. Experimental; the game still needs its data.": "",
"Skips Meta XR Audio's telemetry under x86_64 ARM translation (emulators). Not needed on the Frame, which runs games natively.": "",
"Skips picture layers the Frame would refuse. Leave it on.": "",
"Sleep": "",
@@ -1489,7 +1505,7 @@
"Take screenshot": "",
"tall, 600 × 900": "",
"Team Beef ports: treat the Frame as a Meta headset": "",
"Team Beef's ports (for example Lambda1VR, RTCWQuest) load an OpenXR library named after the headset maker and pick their VR setup by maker; for \"valve\" they stop at start (libopenxr_loader_valve.so not found) or take the Pico path. Adds that library and lets them take the Meta path.": "",
"Team Beef's ports (for example Lambda1VR, RTCWQuest) load an OpenXR library named after the headset maker and pick their VR setup by maker; for \"valve\" they stop at start (libopenxr_loader_valve.so not found) or take the Pico path. Adds that library and lets them take the Meta path. Keeps GL4ES in charge of its desktop OpenGL draw calls so animated models don't turn into stretched triangles.": "",
"Temperature": "",
"Temperatures and fan": "",
"Test": "",
@@ -1534,6 +1550,8 @@
"The Frame's Steam library still shows the old artwork.": "",
"The Frame's Steam library still shows the old name.": "",
"the game failed its checks (see the list above)": "",
"The game's files or settings changed. Update applies only what's needed; saves are kept.": "",
"The game's {kind} isn't at {path} any more. Add the game again from the folder that has it.": "",
"The headset is asleep: put it on, then take the screenshot.": "",
"The headset's view and sound open in an mpv window. Open it in your browser any time.": "",
"The headset's view and sound open in your web browser. Stop the stream when you're done.": "",
@@ -1552,8 +1570,8 @@
"The PC VR counterpart of OVRPort's 'Patch Oculus detection for Unreal'. Unreal's Oculus plugin only starts when the Oculus service announces a headset (the Windows event 'OculusHMDConnected'); without it the game runs as a flat window. On the Frame the launcher runs the game through FramePort's small helper (fp_oculushmd.exe) that provides that event while the game runs, instead of relying only on Revive's hook of the check. Ignored on this PC (the Oculus app or Revive handle it there).": "",
"the previous version, kept as a backup": "",
"The processor types the game has code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games (Gear VR, Oculus Go) run through FramePort's 32-bit bridge (experimental).": "",
"The recipe or your game files changed since the install. Update to install the new build; saves are kept.": "",
"The setup turns on Developer Mode, lets FramePort in and installs Lepton if needed. The Frame's desktop closes during it, then FramePort connects by itself. No password needed.": "",
"The site's HTTPS certificate couldn't be verified. Something on the network, such as antivirus HTTPS scanning, a proxy or a school/work network, may have replaced it. Try another network or turn off HTTPS scanning.": "",
"The store's name": "",
"The time the Frame spends collecting these numbers.": "",
"The upload to the Frame was damaged. Try again, ideally over a cable or the Frame's hotspot.": "",
@@ -1572,6 +1590,7 @@
"They're deleted on the Frame for good. Steam's own list may show them until Steam restarts.": "",
"They're part of SteamOS. Ending them may restart Steam and stop the running game.": "",
"This deletes the {what} on the Frame. It can't be undone.": "",
"This folder contains an installer without a launchable Linux program. FramePort runs programs, not installers. Use the project's own install instructions.": "",
"This folder is empty. Upload files with the buttons above.": "",
"This Frame isn't set up for FramePort yet. Go to Steam Frame → Start setup.": "",
"This Frame isn't set up for FramePort yet. Run the setup once.": "",
@@ -1661,11 +1680,13 @@
"Unreal builds of Meta XR Audio abort ('ClassNotFoundException com.oculus.os.AnalyticsEvent') on non-Quest devices. Patches that one JNI FindClass call so the library continues (for example NOPE Challenge).": "",
"Unreal games can check their whole data file (.obb) before starting (bVerifyOBBOnStartUp). On the Frame that check's progress and error screens are invisible (Lepton runs VR games without an Android window), so the game seems to hang on its DownloaderActivity, for example Contractors. This turns the check off in the manifest; Unreal still checks that the OBB is there with the right size, so a missing or wrong-version OBB still stops the game.": "",
"Unreal games start CrashReportClient when they crash, which leaves a crash dialog instead of simply closing. This passes -nocrashreports to the game and, on the Frame, renames the game's copy of CrashReportClient.exe so it can't start (your game files on this PC aren't changed).": "",
"Unreal's Java OBB delivery check can leave a game on an invisible DownloaderActivity screen even with the manifest CRC check off (e.g. Into The Radius). Makes known Unreal expansionFilesDelivered methods return true, bypassing the Java name and size gate. Experimental: the native engine still needs complete, matching, readable OBB data.": "",
"Unreal's Oculus module looks up every OVRPlugin function it was built against and starts no VR when one is missing; OVRPort's OpenXR OVRPlugin lacks some old ones (for example ovrp_GetPTWNear, which Unreal 4.25 builds like Star Wars Pinball VR look up). The game then never starts an OpenXR session and crashes a few seconds in. Adds a generated library to libOVRPlugin.so with a stand-in for each missing function that reports failure (ovrpFailure), so Unreal's Oculus support starts. The functions OVRPort has are untouched.": "",
"Unreal: bypass the stalled loading splash": "",
"Unreal: don't wait for the Quest shader precompile": "",
"Unreal: match multiview depth to the eye size": "",
"Unreal: Quest controller bindings (RPOC key map)": "",
"Unreal: skip the OBB delivery check": "",
"Unreal: stand-ins for OVRPlugin functions OVRPort lacks": "",
"Unreal: start without the OBB check": "",
"Unreal: thumb follows the capacitive touches": "",
@@ -1708,6 +1729,7 @@
],
"Upside down": "",
"Upside down and mirrored": "",
"Upside-down panel images (diagnostics)": "",
"Use controllers": "",
"Use FramePort": "",
"Use FramePort for these links": "",
+47 -1
View File
@@ -52,11 +52,50 @@ def original_signature(ws) -> bytes | None:
except ValueError:
continue
if certs:
der = certs[0].public_bytes(Encoding.DER)
# the signer's own certificate: the one that issued none of the others (a block may carry its CA
# chain, in any order)
leaves = [c for c in certs if not any(o is not c and o.issuer == c.subject for o in certs)] or certs
der = leaves[0].public_bytes(Encoding.DER)
return base64.b64encode(hashlib.sha1(der).digest()) + b"\n"
return None
# Repacks carry a Frida gadget (libfrda.so + its config and script, loaded from Java with System.loadLibrary("frda"))
# that rewrites Meta's entitlement and platform checks. OVRPort's patch_clean_up_frida removes it from 64-bit games;
# OVRPort doesn't run on this path, so the conversion does the same (in the translator the gadget also crashed while
# scanning the process's memory: Shadow Point).
FRIDA_LIBS = ("libfrda.so", "libfrda.config.so", "libscript.so")
def strip_frida_load(data: bytes) -> bytes | None:
"""The dex with every `System.loadLibrary("frda")` call turned into nops; None if it has none."""
import struct
from ...apk.dex import Dex
if b"frda" not in data:
return None
dex = Dex(data)
frda = [i for i in range(dex.strings_n) if dex.string(i) == "frda"]
loads = set(dex.method_index("Ljava/lang/System;", "loadLibrary"))
if not frda or not loads:
return None
done = 0
for cls in dex.class_names():
for _idx, code in dex.class_methods(cls):
consts = {}
for pos, unit in list(dex._insns(code)):
op = unit[0] & 0xFF
if op == 0x1A: # const-string vAA, string@BBBB
consts[unit[0] >> 8] = unit[1]
elif op == 0x1B: # const-string/jumbo vAA, string@BBBBBBBB
consts[unit[0] >> 8] = unit[1] | unit[2] << 16
elif op == 0x71 and unit[1] in loads and unit[0] >> 12 == 1 and consts.get(unit[2] & 0xF) in frda:
struct.pack_into("<3H", dex.data, code + 16 + 2 * pos, 0, 0, 0) # invoke-static: 3 nops
done += 1
return dex.finish() if done else None
def unity_oculus_check(data: bytes) -> bytes:
"""Unity's built-in Oculus support starts VR only when com.oculus.systemactivities is installed: point the lookup
at "android" (as frame.unity_oculus_check does for 64-bit Unity)."""
@@ -92,6 +131,12 @@ def convert(ws, prebuilt: Callable[[str], bytes], ovrport: Callable[[str, str],
if any(n.startswith(f"lib/{HOST}/") for n in names):
raise RuntimeError("the APK has 64-bit libraries already")
libs = sorted(n[len(f"lib/{abi}/"):] for n in names if n.startswith(f"lib/{abi}/") and n.endswith(".so"))
if "libfrda.so" in libs: # a repack's Frida gadget: not shipped, and its load is removed (FRIDA_LIBS)
for name in [n for n in names if n.startswith("classes") and n.endswith(".dex")]:
fixed = strip_frida_load(ws.read(name))
if fixed:
ws.put(name, fixed)
libs = [b for b in libs if b not in FRIDA_LIBS]
signature = original_signature(ws)
if signature and any(n.startswith(f"lib/{abi}/libUE4.so") for n in names):
ws.put(SIGNATURE_ASSET, signature)
@@ -158,6 +203,7 @@ class A32Bridge(Patch):
"run slower than native ones.")
order = 5 # before frame.adapter, which wraps the OpenXR loader this patch adds
experimental = True
needs_vr = False # translates the game's native code: a flat 32-bit app needs it as much as a VR game
def detect(self, a):
if self.applies(a):
+46 -2
View File
@@ -34,6 +34,18 @@ def with_assets(lib: bytes) -> bytes:
at += len(ASSETS_MARK)
return lib[:at] + b"1" + lib[at + 1:]
STUB_MARK = b"@FPASSETSTUB@"
def with_stub(lib: bytes) -> bytes:
"""Enable the empty asset-list / installed-status bypass without listing local files."""
at = lib.find(STUB_MARK + b"0")
if at < 0:
return lib
at += len(STUB_MARK)
return lib[:at] + b"1" + lib[at + 1:]
# Every function native/langpack/langpack.c defines. The loader's own exports of these are hidden (see
# elf.hide_exports) so lookups reach LIB first; tests/test_langpack.py keeps this list equal to the C source.
EXPORTS = (
@@ -112,6 +124,7 @@ class AssetFiles(Patch):
"installed and answers their download request at once with the file's path; nothing is copied or "
"downloaded. Shares the language-pack library.")
order = 33
flag_library = staticmethod(with_assets)
def applies(self, a):
extra = a.extra or {}
@@ -137,11 +150,11 @@ class AssetFiles(Patch):
ws.put(ws.lib(LOADER), elf.add_needed(patched, LIB))
ws.put(ws.lib(LIB), with_metadata(artifact(ws.abi, LIB), (ctx.analysis.version or "").strip()))
lib = ws.read_lib(LIB)
flagged = with_assets(lib)
flagged = self.flag_library(lib)
if flagged == lib:
return False
ws.put(ws.lib(LIB), flagged)
ctx.notes.append(f"content files listed as installed asset files by {LIB}")
ctx.notes.append(f"{self.title} enabled by {LIB}")
return True
def validate(self, ctx: ApkContext):
@@ -151,5 +164,36 @@ class AssetFiles(Patch):
return [("Content files listed (asset-file library)", ASSETS_MARK + b"1" in ws.read_lib(LIB), LIB)]
class AssetFilesStub(AssetFiles):
id = "frame.asset_files_stub"
title = "Asset-file service bypass"
description = ("Experimental bypass for a DLC manager blocked on Meta asset-file service errors "
"at startup (e.g. PixelRipped 1995: Asset status error (code 100)). Returns an empty successful asset list and "
"reports requested asset IDs as installed, with an empty file path. Does not supply DLC or "
"change download requests. Enable only to test this startup failure; disable with "
"FRAMEPORT_ASSET_FILES_STUB=0. Shares the language-pack library and needs no language packs.")
order = 34
experimental = True
flag_library = staticmethod(with_stub)
@property
def summary(self):
return ("Experimental bypass for a failed Meta asset-file check at startup. Reports an empty asset list and "
"installed status without a file path; does not provide DLC. Experimental.")
def applies(self, a):
return LOADER in a.libs and "arm64-v8a" in a.abis
def detect(self, a):
return None
def validate(self, ctx: ApkContext):
ws = ctx.ws
linked = ws.has(ws.lib(LOADER)) and LIB in elf.needed(ws.read_lib(LOADER))
enabled = ws.has(ws.lib(LIB)) and STUB_MARK + b"1" in ws.read_lib(LIB)
return [("Asset-file bypass linked and enabled", linked and enabled, LIB)]
register(AssetFilesStub)
register(LanguagePacks)
register(AssetFiles)
@@ -0,0 +1,66 @@
"""Use a full-pointer Lua engine while retaining the game's xLua bindings and custom helpers."""
from __future__ import annotations
from ...analysis import elf
from ...analysis.luajit import xlua_47bit
from ..base import ApkContext, Patch, Suggestion, register
from . import artifact
LOADER = "libxlua.so"
RUNTIME = "libfp_luajit.so"
class LuaJitPointers(Patch):
id = "frame.luajit_pointers"
title = "Fix Lua startup crashes on Frame"
description = ("Older xLua/LuaJIT engines reject native pointers outside their 47-bit range, causing a startup "
"error and sometimes a second crash during Unity shutdown (e.g. BattleTalent). Uses a Lua engine "
"that preserves full pointer values while keeping the game's original xLua bindings and native "
"helpers. Applied only to the verified compatible library build.")
order = 48
def applies(self, a):
return "arm64-v8a" in a.abis and bool((a.extra or {}).get("xlua_47bit"))
def detect(self, a):
if self.applies(a):
return Suggestion(True, "The bundled Lua engine rejects Frame's wider native pointers; use the "
"compatible full-pointer engine while retaining the game's bindings.")
return None
def apply(self, ctx: ApkContext) -> bool:
ws = ctx.ws
if ws.abi != "arm64-v8a" or not ws.has(ws.lib(LOADER)):
return False
original = ws.read_lib(LOADER)
runtime = artifact(ws.abi, RUNTIME)
if RUNTIME in elf.needed(original):
if ws.has(ws.lib(RUNTIME)) and ws.read_lib(RUNTIME) == runtime:
return False
ws.put(ws.lib(RUNTIME), runtime)
return True
if not xlua_47bit(original):
raise ValueError("Lua pointer fix refused: this xLua library has not been verified compatible")
names = {name for name in elf.dyn_symbols(original, True) & elf.dyn_symbols(runtime, True)
if name.startswith(("lua_", "luaL_", "luaJIT_", "luaopen_"))}
patched, redirected = elf.redirect_exports(original, names)
if not redirected:
raise ValueError("Lua pointer fix could not redirect the verified engine's API")
ws.put(ws.lib(LOADER), elf.add_needed(patched, RUNTIME))
ws.put(ws.lib(RUNTIME), runtime)
ctx.notes.append(f"{LOADER}: {len(redirected)} Lua engine functions redirected; original bindings retained")
return True
def validate(self, ctx: ApkContext):
ws = ctx.ws
if not ws.has(ws.lib(LOADER)):
return []
data = ws.read_lib(LOADER)
exported = elf.dyn_symbols(data, True)
ready = (ws.has(ws.lib(RUNTIME)) and RUNTIME in elf.needed(data)
and {"lua_pushlightuserdata", "lua_touserdata"} <= elf.dyn_symbols(data, False)
and {"xlua_get_lib_version", "xlua_gl", "luaopen_perflib"} <= exported)
return [("Full-pointer Lua engine with original xLua bindings", ready, RUNTIME)]
register(LuaJitPointers)
+24 -2
View File
@@ -11,6 +11,10 @@ TBXR also draws through multisampled render-to-texture (glFramebufferTexture2DMu
glRenderbufferStorageMultisampleEXT) whatever its --msaa setting; on the Frame that framebuffer is incomplete
(GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE, black eyes). The GL shim (libglshim.so, linked into the TBXR library) hands
it single-sampled versions of both.
GL4ES implements desktop GL draw calls, including client arrays and immediate-mode conversion. The shim's GLES
draw exports must not take those calls away from GL4ES: doing so submits its untranslated arrays and stretches
animated models into spikes (#191). Keep those five symbols private only in APKs that bundle GL4ES.
"""
from __future__ import annotations
@@ -21,6 +25,8 @@ from . import artifact
from .vrapi_bridge import SHIM
VENDOR_LOADER = "libopenxr_loader_valve.so"
GL4ES_DRAW_EXPORTS = ("glDrawArrays", "glDrawElements", "glDrawArraysInstanced",
"glDrawElementsInstanced", "glDrawRangeElements")
class TbxrVendor(Patch):
@@ -29,8 +35,10 @@ class TbxrVendor(Patch):
description = ("Team Beef's ports (for example Lambda1VR, RTCWQuest) load an OpenXR library named after the "
"headset maker and pick their VR setup by maker; for \"valve\" they stop at start "
"(libopenxr_loader_valve.so not found) or take the Pico path. Adds that library and lets them take "
"the Meta path.")
"the Meta path. Keeps GL4ES in charge of its desktop OpenGL draw calls so animated models "
"don't turn into stretched triangles.")
order = 47
revision = 2 # 2: GL4ES keeps its draw calls (#191). Ports without GL4ES rebuild to the same APK (nothing re-sent)
def applies(self, a):
return bool((a.extra or {}).get("tbxr_libs")) and "arm64-v8a" in a.abis
@@ -60,11 +68,25 @@ class TbxrVendor(Patch):
ctx.notes.append(f"GL shim loaded by {lib} (single-sampled render-to-texture)")
changed = True
if changed or ws.has(ws.lib(SHIM)):
ws.put(ws.lib(SHIM), artifact(ws.abi, SHIM))
shim = artifact(ws.abi, SHIM)
if ws.has(ws.lib("libgl4es.so")):
shim, hidden = elf.hide_exports(shim, GL4ES_DRAW_EXPORTS)
if hidden:
ctx.notes.append("GL4ES keeps its desktop GL draw calls (shim draw exports made private)")
if not ws.has(ws.lib(SHIM)) or ws.read_lib(SHIM) != shim:
ws.put(ws.lib(SHIM), shim)
changed = True
if not ws.has(ws.lib(VENDOR_LOADER)):
ws.put(ws.lib(VENDOR_LOADER), build_stub_library([], soname=VENDOR_LOADER))
changed = True
return changed
def validate(self, ctx: ApkContext):
ws = ctx.ws
if ws.abi != "arm64-v8a" or not ws.has(ws.lib("libgl4es.so")) or not ws.has(ws.lib(SHIM)):
return []
_, public = elf.hide_exports(ws.read_lib(SHIM), GL4ES_DRAW_EXPORTS)
return [("GL4ES handles desktop GL draw calls", not public, ", ".join(public))]
register(TbxrVendor)
@@ -8,13 +8,36 @@ the downloader (GitHub #159, Contractors). Setting the meta-data to false skips
check stays (a missing or wrong-size OBB still stops at the downloader). One 4-byte manifest value changes."""
from __future__ import annotations
import struct
from ...analysis.detect import UE_VERIFY_OBB_KEY
from ...apk import axml
from ...apk.dex import Dex
from ..base import ApkContext, Patch, Suggestion, register
MANIFEST = "AndroidManifest.xml"
def delivery_methods(dex: Dex, package: str) -> list[tuple[int, int]]:
classes = {f"L{package.replace('.', '/')}/{name};" for name in ("DownloaderActivity", "OBBData")}
classes.update(f"Lcom/epicgames/{engine}/{name};" for engine in ("ue4", "unreal")
for name in ("DownloadShim", "GameActivity"))
return [(idx, code) for cls in dex.class_names() if cls in classes
for idx, code in dex.class_methods(cls)
if dex.method(idx)[1] == "expansionFilesDelivered" and dex.return_type(idx) == "Z"]
def has_delivery_check(data: bytes, package: str) -> bool:
if b"expansionFilesDelivered" not in data:
return False
return bool(delivery_methods(Dex(data), package))
def patch_dex(dex: Dex, package: str) -> list[str]:
return [dex.method(idx)[0] for idx, code in delivery_methods(dex, package)
if dex.return_early(code, "Z", boolean_value=True)]
class UnrealSkipObbCheck(Patch):
id = "frame.unreal_skip_obb_check"
title = "Unreal: start without the OBB check"
@@ -49,3 +72,52 @@ class UnrealSkipObbCheck(Patch):
register(UnrealSkipObbCheck)
class UnrealSkipObbDelivery(Patch):
id = "frame.unreal_skip_obb_delivery"
title = "Unreal: skip the OBB delivery check"
description = ("Unreal's Java OBB delivery check can leave a game on an invisible DownloaderActivity screen "
"even with the manifest CRC check off (e.g. Into The Radius). Makes known Unreal "
"expansionFilesDelivered methods return true, bypassing the Java name and size gate. "
"Experimental: the native engine still needs complete, matching, readable OBB data.")
order = 24
needs_vr = False
experimental = True
def applies(self, a):
return bool((a.extra or {}).get("unreal_obb_delivery"))
def detect(self, a):
return None # triage-only: most games pass the delivery check
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 not has_delivery_check(data, ctx.analysis.package):
continue
dex = Dex(data)
done = patch_dex(dex, ctx.analysis.package)
if done:
ctx.ws.put(name, dex.finish())
ctx.notes.append(f"{name}: expansionFilesDelivered=true in {', '.join(sorted(done))}")
changed = True
return changed
def validate(self, ctx):
checks = []
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 not has_delivery_check(data, ctx.analysis.package):
continue
dex = Dex(data)
for idx, code in delivery_methods(dex, ctx.analysis.package):
ready = (struct.unpack_from("<H", data, code)[0] > 0
and struct.unpack_from("<I", data, code + 12)[0] >= 2
and struct.unpack_from("<2H", data, code + 16) == (0x1012, 0x000F))
checks.append(("OBB delivery check bypassed", ready, f"{name}: {dex.method(idx)[0]}"))
return checks
register(UnrealSkipObbDelivery)
+15 -4
View File
@@ -22,10 +22,17 @@ SETTINGS = [
"Every N seconds, saves the left-eye image the game submits (a quarter of its size) as fb_snap_0-7.ppm in the "
"game's files folder on the Frame. Launch tests run without anyone wearing the headset, so the headset view stays "
"black; this shows what the game itself draws. OpenGL ES games only."),
("gl_error_clear", "int", 0, "Clear the game's GL errors (GLES)",
"Clears OpenGL errors the game left pending before FrameBridge calls the Frame's runtime. The runtime's GL code "
"fails on any pending error; after one such failure at the start it allocated GPU memory every frame without "
"freeing it (e.g. Vader Immortal, ~20 MB a second)."),
("flip_dump", "int", 0, "Upside-down panel images (diagnostics)",
"Saves the first N UI panels that FrameBridge turns upright (Vulkan games whose panels are stored upside down), "
"before and after the flip, as fb_flip_<n>_raw.ppm / fb_flip_<n>_upright.ppm in the game's files folder on the "
"Frame. Shows what the runtime gets without anyone wearing the headset."),
("strip_color_bias", "int", 0, "Drop layer color fades",
"Removes the color scale/bias (XR_KHR_composition_layer_color_scale_bias) from the game's layers, so the runtime "
"doesn't apply it. The Frame's runtime allocates GPU memory for it every frame without freeing it, for example "
"Vader Immortal leaked ~20 MB/s on its loading screen. Fades done this way no longer show."),
"doesn't apply it (diagnostics). Fades done this way no longer show."),
("fade_quad", "int", 0, "Draw fades over the picture",
"Games that fade to black by dimming their picture with a layer color scale (XR_KHR_composition_layer_color_"
"scale_bias, for example Batman: Arkham Shadow's loading screens) can show the level loading instead of black on "
@@ -392,11 +399,15 @@ UI: dict[str, dict] = {
help="For Unity games that crash when they turn on anti-aliasing (for example The Room "
"VR).", control=("switch",)),
"strip_color_bias": dict(group="troubleshooting", level="advanced", label="Drop layer color fades",
help="For games whose memory use keeps growing (for example Vader Immortal). Some fades no"
" longer show.", control=("switch",)),
help="Diagnostics: removes layer color fades. Some fades no longer show.", control=("switch",)),
"snapshot": dict(group="troubleshooting", level="advanced", label="Eye-image snapshots (diagnostics)",
help="Saves what the game draws every few seconds, for testing without the headset.",
control=("switch",)),
"gl_error_clear": dict(group="troubleshooting", level="advanced", label="Clear the game's GL errors (GLES)",
help="For OpenGL ES games whose memory use keeps growing (e.g. Vader Immortal).", control=("switch",)),
"flip_dump": dict(group="troubleshooting", level="advanced", label="Upside-down panel images (diagnostics)",
help="Saves a few UI panels before and after FrameBridge turns them upright, for testing without the "
"headset.", control=("switch",)),
"strip_depth": dict(group="troubleshooting", level="advanced", label="Strip depth layers",
help="Removes depth information from the game's layers. For testing.", control=("switch",)),
"respace_kick": dict(group="troubleshooting", level="advanced", label="Re-create play space",
+3
View File
@@ -72,6 +72,8 @@ SUMMARIES = {
"the Frame.",
"frame.sdl_clipboard": "Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboard).",
"frame.godot_clipboard": "Lets Godot games start on the Frame (they'd crash looking for a clipboard).",
"frame.unreal_skip_obb_delivery": "Skips a separate data-file check when an Unreal game still hangs at startup "
"with the content check off. Experimental; the game still needs its data.",
"frame.unreal_skip_obb_check": "Starts Unreal games without re-checking their data file (they'd seem to hang "
"on an invisible check screen).",
"frame.bhaptics_permission": "Stops bHaptics from asking for a Bluetooth permission the Frame doesn't have (the "
@@ -97,6 +99,7 @@ SUMMARIES = {
"seasons).",
"frame.ovr_microphone": "Stops a crash a few seconds after the logo in games with voice chat.",
"frame.ovr_stable_user": "Keeps your progress in games that file their saves under your Meta user.",
"frame.luajit_pointers": "Lets games with a verified older Lua engine get past a startup crash on the Frame.",
"frame.avatar_stub": "Lets games start that would stop at Meta's avatars (for example BlazeRush). No Meta "
"avatars.",
"frame.vrapi_bridge": "Lets games built on Meta's oldest VR interface run on the Frame.",
+181 -13
View File
@@ -598,7 +598,10 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
if reporter:
reporter.log(f"analyzing {src.apk.name}")
a = analyze(src.apk, data_bytes=src.data_bytes())
from .core.models import data_manifest
manifest = data_manifest(src.data_dir, src.data_files)
a = analyze(src.apk, data_bytes=sum(manifest.values()))
a.extra["lang_packs"] = langpacks.find_tags(_data_folder(src))
a.extra["asset_files"] = langpacks.find_content_files(_data_folder(src))
recipe = engine.suggest(a)
@@ -607,7 +610,8 @@ def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
names = {"auto_title": recipe.title or a.label} if old.get("title_locked") else {"title": recipe.title or a.label}
return library.upsert_game(
a.package, **names, name=src.name, apk=str(src.apk),
data_dir=str(src.data_dir) if src.data_dir else None, data_files=src.data_files, data_bytes=src.data_bytes(),
data_dir=str(src.data_dir) if src.data_dir else None, data_files=src.data_files,
data_bytes=sum(manifest.values()), data_manifest=manifest, source_sha256=builder.sha256(src.apk),
origin=str(src.origin),
analysis=a.to_dict(), recipe=library.recipe_to_dict(recipe), suggested=library.recipe_to_dict(recipe),
status=recipe.status,
@@ -759,6 +763,23 @@ def source_of(entry: dict) -> SourceGame:
Path(entry["origin"]) if entry.get("origin") else None, data_files=entry.get("data_files"))
def check_source(entry: dict) -> SourceGame:
from .errors import MissingGameSource
src = source_of(entry)
if not src.apk.is_file():
raise MissingGameSource(src.apk)
if src.data_dir is not None:
if not src.data_dir.is_dir():
raise MissingGameSource(src.data_dir, "data folder")
# only files the user picked: the folder's other files can change (newer OBBs) and are listed again at build
for name in src.data_files or ():
path = src.data_dir / name
if not path.is_file():
raise MissingGameSource(path, "data file")
return src
def set_recipe(package: str, recipe: Recipe) -> None:
"""Save a game's recipe. A 32-bit game with the bridge turned on (also by hand) loses what the bridge replaces
(OVRPort, the 64-bit library fixes): `recipe` is changed in place, callers redraw from it."""
@@ -834,7 +855,7 @@ def prepare_as_is(package: str, reporter: Reporter) -> dict:
from .build import sha256
entry = library.game(package)
apk = Path(entry["apk"])
apk = check_source(entry).apk
a = entry["analysis"]
reporter.stage("Checking the game (installing it as it is)")
checks = []
@@ -859,8 +880,10 @@ def prepare_as_is(package: str, reporter: Reporter) -> dict:
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,
"recipe_fp": recipe_fingerprint(entry["recipe"], package), "source_version": _source_version(entry)}
library.upsert_game(package, build=info, title=entry.get("title") or store_title)
"recipe_fp": recipe_fingerprint(entry["recipe"], package), "source_version": _source_version(entry),
**_build_record(entry)}
library.upsert_game(package, build=info, title=entry.get("title") or store_title,
source_sha256=info["source_sha256"])
return info
@@ -884,7 +907,82 @@ def _build_lock(package: str) -> threading.Lock:
return _build_locks.setdefault(package, threading.Lock())
def _build_inputs(entry: dict, native_names: list[str] | None = None) -> dict:
"""Only the native assets carried by this APK and the local converter; no downloads or Java invocations."""
from .core.paths import artifacts_dir
from .tools import toolchain
build = entry.get("build") or {}
previous = build.get("build_inputs") or {}
recipe = entry.get("recipe") or {}
native_names = native_names if native_names is not None else build.get("native_names") or []
native = previous.get("native") or {}
manifest = artifacts_dir() / "SHA256SUMS"
if manifest.is_file():
checksums = {line.split(maxsplit=1)[1].strip().removeprefix("./"): line.split()[0]
for line in manifest.read_text().splitlines() if len(line.split()) == 2}
native = {name: checksums.get(name) for name in native_names}
jar = toolchain.overport_jar() if recipe.get("overport", True) and not recipe.get("as_is") else None
converter = previous.get("overport")
if jar and jar.is_file():
st = jar.stat()
stat = {"path": str(jar), "size": st.st_size, "mtime_ns": st.st_mtime_ns}
if stat != build.get("overport_stat"):
converter = builder.sha256(jar)
return {"native": native, "overport": converter}
def _source_stat(entry: dict) -> dict | None:
source = Path(entry["apk"])
try:
st = source.stat()
except OSError:
return None
return {"path": str(source.resolve()), "size": st.st_size, "mtime_ns": st.st_mtime_ns}
def _build_record(entry: dict, apk: Path | None = None, superseded=None) -> dict:
import zipfile
from .core.paths import artifacts_dir
from .game_update import apk_recipe_fingerprint
from .tools import toolchain
native_names = []
if apk and not entry["recipe"].get("as_is"):
with zipfile.ZipFile(apk) as archive:
native_names = [name.removeprefix("lib/") for name in archive.namelist()
if name.startswith("lib/") and (artifacts_dir() / name.removeprefix("lib/")).is_file()]
if "frame.oculusos" in (entry["recipe"].get("patches") or {}):
native_names.append("dex/oculusos-stubs.dex")
jar = toolchain.overport_jar()
converter_stat = None
if jar and jar.is_file():
st = jar.stat()
converter_stat = {"path": str(jar), "size": st.st_size, "mtime_ns": st.st_mtime_ns}
return {"apk_recipe_fp": apk_recipe_fingerprint(entry["recipe"], entry["package"], superseded),
"source_stat": _source_stat(entry), "source_sha256": builder.sha256(Path(entry["apk"])),
"build_inputs": _build_inputs(entry, native_names), "native_names": native_names,
"overport_stat": converter_stat}
def _build_inputs_changed(entry: dict) -> bool:
build = entry.get("build") or {}
if not build.get("build_inputs") or build["build_inputs"] != _build_inputs(entry):
return True
stat = _source_stat(entry)
if stat is None:
# An unavailable dump does not prevent configuration updates to a known installed APK.
return False
if stat != build.get("source_stat"):
return builder.sha256(Path(entry["apk"])) != build.get("source_sha256")
return False
def build_game(package: str, reporter: Reporter, outdir: Path | None = None) -> dict:
entry = library.game(package)
if not is_linux(entry) and not is_rift(entry):
check_source(entry) # fail before reanalysis or tool setup when the original files have gone
if analysis_outdated(library.game(package) or {}): # analysed by an older FramePort: new fields first
refresh_analyses([package], reporter)
entry = library.game(package)
@@ -906,8 +1004,10 @@ def build_game(package: str, reporter: Reporter, outdir: Path | None = None) ->
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"), "recipe_fp": recipe_fingerprint(entry["recipe"], package),
"superseded": res.meta.get("superseded") or {}, "source_version": _source_version(entry)}
library.upsert_game(package, build=build_info, title=entry.get("title") or store_title)
"superseded": res.meta.get("superseded") or {}, "source_version": _source_version(entry),
**_build_record(entry, res.apk, res.meta.get("superseded"))}
library.upsert_game(package, build=build_info, title=entry.get("title") or store_title,
source_sha256=build_info["source_sha256"])
return build_info
@@ -927,33 +1027,101 @@ def _find_late_data(entry: dict, reporter: Reporter | None = None) -> dict:
def install_game(package: str, target: Target, reporter: Reporter, apk_only: bool = False,
add_to_library: bool = True, apk: Path | None = None) -> dict:
add_to_library: bool = True, apk: Path | None = None, force: bool = False) -> dict:
"""Install the game's last build (or `apk`, e.g. a test build of the same package signed with the same key)."""
reporter = reporter or Reporter()
entry = library.game(package)
if is_linux(entry):
return install_linux(package, target, reporter, add_to_library)
if is_rift(entry):
return install_rift(package, target, reporter, add_to_library)
from .game_update import plan_update
test_build = apk is not None
optimized = not test_build and hasattr(target, "update_existing")
deployment, update, manifest = None, None, None
if optimized:
reporter.stage("Check update requirements")
deployment = next((d for d in target.installed() if d.get("package") == package), None)
if not apk_only:
entry = _find_late_data(entry, reporter) if not entry.get("data_dir") and entry.get("apk") else entry
from .core.models import data_manifest
manifest = data_manifest(Path(entry["data_dir"]) if entry.get("data_dir") else None,
entry.get("data_files"))
# Cache desired data for badges without doing filesystem I/O in render paths.
library.update_game(package, lambda game: game.update(data_manifest=manifest))
entry["data_manifest"] = manifest
update = plan_update(entry, deployment, apk_only=apk_only, data_manifest=manifest)
if update.data and not update.apk and hasattr(target, "deployment"):
deployment = target.deployment(package)
update = plan_update(entry, deployment, apk_only=apk_only, data_manifest=manifest)
if force or _build_inputs_changed(entry):
update = dataclasses.replace(update, rebuild=True, apk=True, configure=True)
for reason in update.reasons:
reporter.log(reason)
if update.rebuild:
info = build_game(package, reporter)
if not info["ok"]:
raise RuntimeError("the game failed its checks (see the list above)")
entry = library.game(package)
b = entry.get("build") or {}
recipe = library.recipe_from_dict(entry["recipe"])
if b.get("superseded"):
recipe = dataclasses.replace(recipe, patches=upstream.without_superseded(recipe.patches, b["superseded"]))
if deployment and not update.apk:
if update.configure or update.data:
result = target.update_existing(entry, deployment, update, recipe, reporter, manifest)
else:
reporter.log("APK, game data and runtime configuration already current")
result = {"base": deployment["base"], "appid": deployment["appid"]}
art = None
if update.artwork or update.shortcut:
art = target.update_steam_art(package, reporter, title=steam_title(entry))
library.update_game(package, lambda game: game.pop("steam_art_stale", None))
# The deployment can't tell whether Steam still has the shortcut (a connection lost during "Add to
# Steam", or Steam putting its old shortcuts.vdf back, GitHub #21/#41): check it every time. The agent
# restarts Steam only when the library entry really changes.
check_shortcut = not (art or {}).get("library")
if add_to_library and check_shortcut:
target.add_to_library([package], reporter)
result["launch_test_required"] = update.launch_test
result["shortcut_required"] = check_shortcut
result["stage_seconds"] = reporter.stage_timings()
_record_install(package, target.label, {"result": result, "time": time.time()})
return result
b = entry.get("build") or {}
recipe = library.recipe_from_dict(entry["recipe"])
if not test_build and not b.get("apk"): # never built yet (e.g. `frameport install` right after a scan)
if not test_build and not b.get("apk"):
build_game(package, reporter)
return install_game(package, target, reporter, apk_only, add_to_library)
apk = Path(apk) if apk else Path(b["alt_apk"] if recipe.use_alt and b.get("alt_apk") else b["apk"])
if not test_build and not apk.exists(): # the converted copy was removed after an earlier install: make it again
if not test_build and not apk.is_file():
build_game(package, reporter)
return install_game(package, target, reporter, apk_only, add_to_library)
if not test_build and b.get("superseded"): # workarounds this build left out (upstream fixed): not in settings.conf
if optimized and not update.rebuild:
expected_sha = b.get("alt_sha256") if recipe.use_alt else b.get("sha256")
reporter.check_cancel()
if builder.sha256(apk) != expected_sha:
reporter.log("the cached APK changed; rebuilding before installation")
build_game(package, reporter)
return install_game(package, target, reporter, apk_only, add_to_library)
if not test_build and b.get("superseded"):
recipe = dataclasses.replace(recipe, patches=upstream.without_superseded(recipe.patches, b["superseded"]))
if not entry.get("data_dir") and entry.get("apk") and not apk_only:
entry = _find_late_data(entry, reporter) # OBBs copied next to the APK after it was added (GitHub #156)
data_dir = Path(entry["data_dir"]) if entry.get("data_dir") else None
title = steam_title(entry)
result = target.install(package, title, apk, data_dir, recipe, reporter, apk_only,
preserve_data = apk_only or (optimized and deployment is not None and not update.data and not force)
result = target.install(package, title, apk, data_dir, recipe, reporter, preserve_data,
data_files=entry.get("data_files"))
if add_to_library:
if add_to_library: # also after a reinstall: the agent restarts Steam only when the entry changes
target.add_to_library([package], reporter)
result["launch_test_required"] = True
result["shortcut_required"] = True
result["stage_seconds"] = reporter.stage_timings()
if optimized and update.artwork:
library.update_game(package, lambda game: game.pop("steam_art_stale", None))
_record_install(package, target.label, {"apk": str(apk), "result": result, "time": time.time()})
if not test_build:
remove_converted_copies(package)
+1 -1
View File
@@ -56,7 +56,7 @@ class CatalogEntry:
pcvr_remove: list[str] = field(default_factory=list)
proton_env: dict = field(default_factory=dict)
proton_tool: str = ""
as_is: bool = False # Rift: the dump runs unchanged (e.g. a SteamVR build: no Revive)
as_is: bool = False # install the APK or Rift dump unchanged; device and launch settings still apply
verified: dict = field(default_factory=dict)
updated: str = "" # date of the last maintainer change without a new headset test (e.g. a fix from a report)
min_app: str = "" # oldest FramePort version this entry is for (remote entries for a newer app are skipped)
+4 -1
View File
@@ -37,6 +37,7 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
elif entry:
recipe.source = f"catalog ({entry.origin})"
recipe.status, recipe.notes, recipe.title = entry.status, entry.notes, entry.title or recipe.title
recipe.as_is = entry.as_is
why = f"Known-good recipe for {entry.title} (tested {entry.verified.get('date', '?')})."
for pid in entry.overport_remove:
recipe.patches.pop(pid, None)
@@ -94,7 +95,9 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
recipe.notes = f"No 64-bit ARM code ({', '.join(analysis.abis)}): the Steam Frame can't run it."
if not rift:
_static_blockers(analysis, recipe)
_non_quest(analysis, recipe)
if not rift and (not entry or analysis.vr_kind == "none"):
# Saved recipes still need the flat-app build rules: no OVRPort, and APK fixes only when needed (#271).
_non_quest(analysis, recipe)
if not rift:
a32_recipe(analysis, recipe)
if not rift and not entry and (analysis.extra or {}).get("frame_patched"):
+11
View File
@@ -39,6 +39,10 @@ class FrameLeptonTarget(Target):
def installed(self) -> list[dict]:
return self.connect().frame.agent("list_installed")["games"]
def deployment(self, package: str) -> dict | None:
games = self.connect().frame.agent("list_installed", package=package, details=True)["games"]
return games[0] if games else None
def install(self, package, title, apk: Path, data_dir, recipe: Recipe, reporter: Reporter, apk_only=False,
data_files=None):
plan = installer.InstallPlan(package, title, apk, data_dir, recipe, apk_only, dest=self.install_dest(),
@@ -48,6 +52,9 @@ class FrameLeptonTarget(Target):
def add_to_library(self, packages, reporter):
return installer.add_to_steam(self.connect().frame, packages, reporter)
def update_existing(self, entry, deployment, plan, recipe, reporter, manifest=None):
return installer.update_existing(self.connect().frame, entry, deployment, plan, recipe, reporter, manifest)
def update_steam_art(self, package, reporter, title=None):
return installer.update_steam_art(self.connect().frame, package, reporter, title=title)
@@ -67,6 +74,10 @@ class FrameLeptonTarget(Target):
return self.connect().frame.agent("launch", package=package, timeout=60)
def set_settings(self, package, settings):
from ..core import library
superseded = ((library.game(package) or {}).get("build") or {}).get("superseded") or {}
settings = {key: value for key, value in settings.items() if f"adapter.{key}" not in superseded}
return self.connect().frame.agent("set_settings", package=package, settings=settings)
def uninstall(self, package, keep_data=True):
+31 -9
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import re
import shutil
import tempfile
from pathlib import Path
from ..core.events import Reporter
@@ -59,15 +60,36 @@ def patch(apk: Path, outdir: Path, name: str, patches: list[str], reporter: Repo
outdir.mkdir(parents=True, exist_ok=True)
target = outdir / name
target.unlink(missing_ok=True)
args = ["-jar", _jar(), "patch", f"--input={apk}", f"--output={outdir}", f"--output-name={name}",
f"--workspace={workspace()}", f"--version={version}", "--patches=" + ";".join(patches)]
reporter.log("OVRPort " + " ".join(a for a in args[3:] if not a.startswith("--patches")))
p = toolchain.run_java(args)
# overport draws spinners with \r; keep only final line states
for line in (p.stdout + p.stderr).splitlines():
line = line.split("\r")[-1].strip()
if line:
reporter.log(" " + line)
jar, work = _jar(), workspace()
def run(input_apk):
args = ["-jar", jar, "patch", f"--input={input_apk}", f"--output={outdir}", f"--output-name={name}",
f"--workspace={work}", f"--version={version}", "--patches=" + ";".join(patches)]
reporter.log("OVRPort " + " ".join(a for a in args[3:] if not a.startswith("--patches")))
result = toolchain.run_java(args)
# overport draws spinners with \r; keep only final line states
for line in (result.stdout + result.stderr).splitlines():
line = line.split("\r")[-1].strip()
if line:
reporter.log(" " + line)
return result
p = run(apk)
output = p.stdout + p.stderr
if p.returncode and "java.util.NoSuchElementException: Sequence is empty." in output \
and "PatcherContext.appName(" in output:
from ..apk.axml import copy_activity_label_to_application
from ..apk.workspace import ApkWorkspace
with ApkWorkspace(apk) as staged:
manifest = copy_activity_label_to_application(staged.read("AndroidManifest.xml"))
if manifest is not None:
reporter.log("OVRPort could not read the app name; retrying with the activity's label")
with tempfile.TemporaryDirectory(prefix="ovrport-label-", dir=outdir) as tmp:
staged.put("AndroidManifest.xml", manifest)
prepared = staged.write(Path(tmp) / apk.name)
target.unlink(missing_ok=True)
p = run(prepared)
if p.returncode or not target.exists():
raise RuntimeError(f"OVRPort failed (exit {p.returncode}); see log")
return target
+20 -5
View File
@@ -164,6 +164,7 @@ class FramePortApp:
for key, value in window_geometry(library.setting("ui.window"), T.SCALE).items():
setattr(page.window, key, value)
page.window.on_event = self._on_window_event
self._log_window("ready")
if sys.platform == "win32" and (icon := G.window_icon(Path(assets_dir()))):
page.window.icon = str(icon) # source runs: FramePort's icon instead of Flet's
page.on_keyboard_event = self._on_key
@@ -263,10 +264,21 @@ class FramePortApp:
self.activity.set_open(open_activity)
self.render()
def _log_window(self, event: str) -> None:
if sys.platform == "win32":
from ..diag import display
try: # diagnostics only: never let a window property stop the app from starting
display.record_window(event, self.page.window, theme=T.THEME, reduce_motion=self.reduce_motion)
except Exception as exc: # noqa: BLE001
applog.log.debug("window diagnostics: %s", exc)
def _on_window_event(self, e) -> None:
"""Remember the window's size and position (once a resize/move ends; not while maximized or full screen) and
whether it is maximized."""
win, change = self.page.window, None
if e.type not in (ft.WindowEventType.MOVE, ft.WindowEventType.RESIZE):
self._log_window(getattr(e.type, "value", str(e.type)))
if e.type == ft.WindowEventType.CLOSE:
_stop_link_watch() # also when maximized (that returns below)
if e.type in (ft.WindowEventType.MAXIMIZE, ft.WindowEventType.UNMAXIMIZE):
@@ -1518,6 +1530,8 @@ class FramePortApp:
if not g:
return None # removed from the library meanwhile
rift = g.get("kind") == "rift"
# An explicit install action on a current game is the existing "Reinstall" action.
force = to == "frame" and C.install_state(g, getattr(self, "frame_info", None)) == "installed"
where = tr("your Frame") if to == "frame" else tr("this PC")
self._record(pkg, to=to, state="queued", apk_only=apk_only)
@@ -1526,12 +1540,13 @@ class FramePortApp:
self._record(pkg, to=to, state="running")
as_is = library.recipe_from_dict(library.game(pkg)["recipe"]).as_is
rep.stage("Checking the game" if rift or as_is else "Patching the game")
info = pipeline.build_game(pkg, rep)
if not info["ok"]:
raise RuntimeError(tr("the game failed its checks (see the list above)"))
if rift or to != "frame":
info = pipeline.build_game(pkg, rep)
if not info["ok"]:
raise RuntimeError(tr("the game failed its checks (see the list above)"))
rep.check_cancel()
target = self._target_for(to)
pipeline.install_game(pkg, target, rep, apk_only=apk_only)
result = pipeline.install_game(pkg, target, rep, apk_only=apk_only, force=force)
self._record(pkg, remove=True) # installed; the launch test below is a separate question
if to == "frame": # the install sent the current artwork to the Frame's Steam library
library.update_game(pkg, lambda e: e.pop("steam_art_stale", None))
@@ -1544,7 +1559,7 @@ class FramePortApp:
rep.stage("Installed")
return tr("{get} is installed on {where}: SteamVR shows it over your games").format(
get=g.get('title'), where=where)
if not library.setting("install.launch_test", True): # Settings → Installing
if result.get("launch_test_required") is False or not library.setting("install.launch_test", True):
rep.stage("Installed")
return tr("{get} is installed on {where}: put the headset on and launch it from your Steam "
"library").format(get=g.get('title'), where=where)
+4
View File
@@ -179,6 +179,10 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
if not dep or (dep.get("apk_present") is False and not dep.get("drive_missing")):
return "missing"
b = game.get("build") or {}
if game.get("kind") not in ("linux", "rift") and b.get("apk_recipe_fp") and dep.get("configuration_fp"):
from ..game_update import plan_update
return "outdated" if plan_update(game, dep).changed else "installed"
built = {b.get("sha256"), b.get("alt_sha256")} - {None}
if built and dep.get("sha256") and dep["sha256"] not in built:
return "outdated"
+1 -2
View File
@@ -22,8 +22,7 @@ HELP: dict[str, str] = _Translated({
# ---- game status / library
"status": "How well the game runs on the Steam Frame. \"Works\" and \"Works with issues\" were tested on a Frame; "
"\"Untested\" is a suggestion nobody has confirmed yet. \"Can't run\" means a known blocker.",
"update_ready": "The recipe or your game files changed since the install. Update to install the new build; saves "
"are kept.",
"update_ready": "The game's files or settings changed. Update applies only what's needed; saves are kept.",
"check_exe": "FramePort isn't sure which program starts this game. Open the game and use … → Change program… to "
"pick it.",
"platform_quest": "A Meta Quest game. FramePort patches it for the Frame, where it runs in Lepton, Valve's Android "
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more