Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5368d73ea | ||
|
|
bd92c25a09 | ||
|
|
05024e02bf | ||
|
|
56e122d85f | ||
|
|
3fcb901f8c | ||
|
|
faea99e837 | ||
|
|
05c4f5603b | ||
|
|
60fa213862 | ||
|
|
b67c403447 | ||
|
|
21e2693568 | ||
|
|
8d8a5dab59 | ||
|
|
185a947ba1 | ||
|
|
8622f8bc5c | ||
|
|
70d722063e | ||
|
|
e9ab42bcab | ||
|
|
6d9944844a | ||
|
|
11c52636c3 | ||
|
|
bf455baeb5 | ||
|
|
986e554fa5 | ||
|
|
f6ae3f7724 | ||
|
|
310863900f | ||
|
|
7462317dcb | ||
|
|
32a2694b05 | ||
|
|
421ee8cb5f | ||
|
|
693e75b19a | ||
|
|
bdbe9550ba | ||
|
|
c1653f21b1 | ||
|
|
f251129c11 | ||
|
|
5acf9f1d7a | ||
|
|
88dd4a99a6 | ||
|
|
e30c9735dc | ||
|
|
f237f64d84 | ||
|
|
fd11975ffb | ||
|
|
54fdeaca14 | ||
|
|
1182f8f275 | ||
|
|
90248b6742 | ||
|
|
c8f399da57 |
No files matched your search
@@ -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`) |
|
||||
|
||||
@@ -44,7 +44,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 85
|
||||
AGENT_VERSION = 88
|
||||
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
|
||||
@@ -1500,10 +1526,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 +1573,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}
|
||||
|
||||
|
||||
@@ -3802,7 +3835,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)")
|
||||
@@ -3823,6 +3856,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?)")
|
||||
@@ -3833,16 +3870,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
|
||||
@@ -3859,13 +3947,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)
|
||||
@@ -3875,22 +3987,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)
|
||||
@@ -5149,8 +5279,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:
|
||||
@@ -5160,7 +5290,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
|
||||
@@ -5174,6 +5316,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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
1a4dce5c4a6d0dfc5f20e55ac29e70c73cc2cc124c21ca6a7e1b316e6afb1c79 ./arm64-v8a/libVkLayer_fdm_darken.so
|
||||
5becb96ee86fbda0ce98c0e0fd064f8ba092314aceaa400e3dcc785705dc3c23 ./arm64-v8a/libVkLayer_fp_shaderfix.so
|
||||
e7554c6343ee2989b0a273ee6230e65c25bfe6499eefd00de19f5f0fee58754f ./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
|
||||
@@ -20,6 +21,7 @@ b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
===============================================================================
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -6,6 +6,18 @@
|
||||
# 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: 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
|
||||
@@ -367,6 +379,12 @@ signatures:
|
||||
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]
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +10,7 @@ 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 | |
|
||||
| Audica | Quest | ✅ Works | |
|
||||
| BAM | Quest | ✅ Works | |
|
||||
@@ -28,6 +29,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,6 +42,7 @@ 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 | |
|
||||
@@ -68,6 +71,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 | |
|
||||
@@ -81,6 +85,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 | |
|
||||
@@ -89,6 +94,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 | |
|
||||
@@ -145,7 +151,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. |
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Patches
|
||||
|
||||
Every patch and game setting FramePort knows: 163 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: 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.
|
||||
|
||||
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,7 +11,7 @@ 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) (51)
|
||||
- [Steam Frame patches](#group-frame) (52)
|
||||
- [Game settings](#group-adapter) (66)
|
||||
- [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,7 +39,7 @@ 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 | 34 |
|
||||
| [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 |
|
||||
@@ -54,11 +54,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 | 7 |
|
||||
| [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 |
|
||||
@@ -73,13 +74,13 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [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 |
|
||||
@@ -110,7 +111,7 @@ 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 |
|
||||
| [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 |
|
||||
@@ -121,7 +122,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [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 | 65 |
|
||||
| [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 | |
|
||||
@@ -142,7 +143,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [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 | 3 |
|
||||
| [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 | |
|
||||
@@ -169,7 +170,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 | 69 |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 73 |
|
||||
| [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) | |
|
||||
@@ -312,7 +313,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)
|
||||
@@ -414,7 +415,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 (34):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2, ExploreVR and 26 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)
|
||||
@@ -610,7 +611,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 (7):** 4XVR Video Player, Batman: Arkham Shadow, Dance Central, Fruit Ninja, Kill It With Fire VR, MacacoVR, Moss: Book II.
|
||||
- **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.
|
||||
@@ -651,6 +652,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.
|
||||
@@ -658,7 +672,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).
|
||||
@@ -841,13 +855,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)
|
||||
|
||||
@@ -856,7 +870,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)
|
||||
@@ -911,7 +925,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)
|
||||
@@ -925,7 +939,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.
|
||||
@@ -941,7 +955,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 -> false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -> true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with "Show the app window") or Type on Frame from the PC.
|
||||
@@ -1378,6 +1392,7 @@ 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).
|
||||
@@ -1540,7 +1555,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 (65):** AgeOfJoy, Ancient_Dungeon, Audica, BattleSisters, Beat Saber, Caves, Clockwork, COMPOUND and 57 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.
|
||||
@@ -1848,7 +1863,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 (3):** Arkio, Demeter, 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)
|
||||
@@ -2241,7 +2256,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 (69):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Audica, Beat Saber, Beat Saber (co-existence build), Blade & Sorcery: Nomad, BodyCombat and 61 more.
|
||||
- **Catalog games (73):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, Beat Saber, Beat Saber (co-existence build), Blade & Sorcery: Nomad and 65 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".
|
||||
|
||||
@@ -13,6 +13,11 @@ 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:` |
|
||||
@@ -39,7 +44,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 +60,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 |
|
||||
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 217 KiB |
@@ -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`). |
|
||||
|
||||
@@ -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_dex():
|
||||
javac = shutil.which("javac")
|
||||
if not javac:
|
||||
@@ -424,7 +429,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")
|
||||
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")
|
||||
ap.add_argument("--ndk")
|
||||
args = ap.parse_args()
|
||||
parts = set(args.only.split(","))
|
||||
@@ -438,9 +443,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)}
|
||||
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"):
|
||||
if name in parts:
|
||||
log(f"build {name}")
|
||||
|
||||
@@ -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
|
||||
|
||||
===============================================================================
|
||||
@@ -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())
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 @@
|
||||
<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 |
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"updated": "2026-10-12",
|
||||
"updated": "2026-10-14",
|
||||
"people": [
|
||||
{
|
||||
"login": "spoopyghosty0",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/336754034?v=4",
|
||||
"url": "https://github.com/spoopyghosty0",
|
||||
"commits": 542,
|
||||
"commits": 603,
|
||||
"issues": 5,
|
||||
"prs": 3,
|
||||
"total": 572
|
||||
"total": 633
|
||||
},
|
||||
{
|
||||
"login": "Klownicle",
|
||||
@@ -46,6 +46,24 @@
|
||||
"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",
|
||||
"url": "https://github.com/Lucas-Mathieu",
|
||||
"commits": 9,
|
||||
"issues": 0,
|
||||
"prs": 4,
|
||||
"total": 29
|
||||
},
|
||||
{
|
||||
"login": "inseend",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/11666993?v=4",
|
||||
@@ -65,31 +83,22 @@
|
||||
"total": 24
|
||||
},
|
||||
{
|
||||
"login": "UnicronNL",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1267745?v=4",
|
||||
"url": "https://github.com/UnicronNL",
|
||||
"commits": 5,
|
||||
"issues": 6,
|
||||
"prs": 0,
|
||||
"total": 23
|
||||
},
|
||||
{
|
||||
"login": "Lucas-Mathieu",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/184943223?v=4",
|
||||
"url": "https://github.com/Lucas-Mathieu",
|
||||
"commits": 7,
|
||||
"issues": 0,
|
||||
"login": "nleach7",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1406346?v=4",
|
||||
"url": "https://github.com/nleach7",
|
||||
"commits": 3,
|
||||
"issues": 2,
|
||||
"prs": 3,
|
||||
"total": 22
|
||||
"total": 24
|
||||
},
|
||||
{
|
||||
"login": "jim2011",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/15848374?v=4",
|
||||
"url": "https://github.com/jim2011",
|
||||
"commits": 0,
|
||||
"issues": 6,
|
||||
"issues": 7,
|
||||
"prs": 0,
|
||||
"total": 18
|
||||
"total": 21
|
||||
},
|
||||
{
|
||||
"login": "Jmckenzie84",
|
||||
@@ -109,6 +118,15 @@
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "eepykith",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/338898488?v=4",
|
||||
"url": "https://github.com/eepykith",
|
||||
"commits": 0,
|
||||
"issues": 5,
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "Scoota23",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/27732436?v=4",
|
||||
@@ -119,13 +137,22 @@
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "eepykith",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/338898488?v=4",
|
||||
"url": "https://github.com/eepykith",
|
||||
"login": "ssgod101",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/49173466?v=4",
|
||||
"url": "https://github.com/ssgod101",
|
||||
"commits": 0,
|
||||
"issues": 4,
|
||||
"issues": 5,
|
||||
"prs": 0,
|
||||
"total": 12
|
||||
"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",
|
||||
@@ -208,6 +235,24 @@
|
||||
"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",
|
||||
"url": "https://github.com/greggray24",
|
||||
"commits": 0,
|
||||
"issues": 3,
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "m4ddh7jm7c-bit",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/338352097?v=4",
|
||||
@@ -235,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",
|
||||
@@ -262,6 +325,33 @@
|
||||
"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",
|
||||
"url": "https://github.com/Loafmeister",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"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",
|
||||
@@ -281,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,
|
||||
@@ -307,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",
|
||||
@@ -316,6 +415,33 @@
|
||||
"prs": 1,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "alicecatpaw",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/7423859?v=4",
|
||||
"url": "https://github.com/alicecatpaw",
|
||||
"commits": 0,
|
||||
"issues": 0,
|
||||
"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",
|
||||
"url": "https://github.com/Akimya49",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "AlexanderCowell",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/225416878?v=4",
|
||||
@@ -334,6 +460,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "astromaddie",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/5488456?v=4",
|
||||
"url": "https://github.com/astromaddie",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "bcain2005-gif",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/258048498?v=4",
|
||||
@@ -343,6 +478,33 @@
|
||||
"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",
|
||||
"url": "https://github.com/chaseflow",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "clockhart192",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/16326214?v=4",
|
||||
"url": "https://github.com/clockhart192",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "darrenltw",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/151712415?v=4",
|
||||
@@ -397,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",
|
||||
@@ -451,6 +622,24 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "JMQuehl",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/110048375?v=4",
|
||||
"url": "https://github.com/JMQuehl",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"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",
|
||||
@@ -461,9 +650,9 @@
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Loafmeister",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/60628625?v=4",
|
||||
"url": "https://github.com/Loafmeister",
|
||||
"login": "LynkWyland",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/54166762?v=4",
|
||||
"url": "https://github.com/LynkWyland",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
@@ -487,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",
|
||||
@@ -524,9 +722,9 @@
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "ssgod101",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/49173466?v=4",
|
||||
"url": "https://github.com/ssgod101",
|
||||
"login": "SylanaZero",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/9733124?v=4",
|
||||
"url": "https://github.com/SylanaZero",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
@@ -542,9 +740,9 @@
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Tazzash",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/42785850?v=4",
|
||||
"url": "https://github.com/Tazzash",
|
||||
"login": "tirehabitat",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/97759168?v=4",
|
||||
"url": "https://github.com/tirehabitat",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
@@ -576,6 +774,24 @@
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "xenon2",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/4399658?v=4",
|
||||
"url": "https://github.com/xenon2",
|
||||
"commits": 0,
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -434,6 +434,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",
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"updated": "2026-10-12",
|
||||
"updated": "2026-10-13",
|
||||
"releases": [
|
||||
{
|
||||
"tag": "v1.1.0",
|
||||
"name": "FramePort v1.1.0",
|
||||
"date": "2026-10-13",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.1.0",
|
||||
"dev": false,
|
||||
"notes": "FramePort v1.1.0: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Games on an SD card whose name has a space start again** (they got no storage before).\n- **Experimental eye-tracked foveation** per game, through Valve's foveation layer (thanks Lucas-Mathieu, #247).\n- **Game menu: \"Update with a newer APK…\" and \"Update app only\"** (keeps modded game data on the Frame).\n- **Game fixes:** Wander 0.3570, Vermillion, Star Wars: Tales from the Galaxy's Edge, The Climb, Jurassic World Aftermath backups, Yupitergrad, Beyond Sandbox, and an optional fade fix for Batman's loading screens (thanks nleach7 and Klownicle).\n- **Controllers switched on after a 2D app started** now reach it as a gamepad.\n- **Fixes:** a changed Frame SSH key can be trusted with one click, uninstalling no longer stops on its own lock file, file pickers work without a desktop portal on Linux, separate desktop-icon switch for Linux apps, and the low-space warning checks the right drive.\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
|
||||
},
|
||||
{
|
||||
"tag": "v1.0.4",
|
||||
"name": "FramePort v1.0.4",
|
||||
|
||||
@@ -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"
|
||||
@@ -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,7 +38,8 @@ 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)
|
||||
ANALYSIS_VERSION = 14
|
||||
# 15: verified xLua/LuaJIT 47-bit lightuserdata engine (frame.luajit_pointers, GitHub #257)
|
||||
ANALYSIS_VERSION = 15
|
||||
|
||||
|
||||
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
|
||||
@@ -302,6 +304,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -481,6 +481,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": "",
|
||||
@@ -780,6 +781,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": "",
|
||||
@@ -808,6 +810,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).": "",
|
||||
@@ -994,6 +997,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": "",
|
||||
@@ -1049,6 +1053,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.": "",
|
||||
@@ -1481,7 +1486,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": "",
|
||||
@@ -1526,6 +1531,7 @@
|
||||
"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 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.": "",
|
||||
@@ -1544,7 +1550,6 @@
|
||||
"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 can't run on it.": "",
|
||||
"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 store's name": "",
|
||||
"The time the Frame spends collecting these numbers.": "",
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -97,6 +97,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.",
|
||||
|
||||
@@ -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,
|
||||
@@ -852,8 +856,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
|
||||
|
||||
|
||||
@@ -877,6 +883,78 @@ 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:
|
||||
if analysis_outdated(library.game(package) or {}): # analysed by an older FramePort: new fields first
|
||||
refresh_analyses([package], reporter)
|
||||
@@ -897,8 +975,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
|
||||
|
||||
|
||||
@@ -918,33 +998,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)
|
||||
|
||||
@@ -55,7 +55,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)
|
||||
|
||||
@@ -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)
|
||||
@@ -88,7 +89,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 and not entry and (analysis.extra or {}).get("frame_patched"):
|
||||
# already has FramePort's adapter (e.g. a PATCHED/ build): installing it unchanged is the safe default
|
||||
recipe.as_is = True
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -174,6 +174,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"
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 |
@@ -94,12 +94,16 @@ class SettingsView:
|
||||
|
||||
def installing(self) -> ft.Control:
|
||||
from ...core import library
|
||||
from ...install.installer import upload_threads
|
||||
|
||||
def changed(e):
|
||||
library.set_setting("install.launch_test", bool(e.control.value))
|
||||
def keep_changed(e):
|
||||
library.set_setting("build.keep_copies", bool(e.control.value))
|
||||
|
||||
def threads_changed(e):
|
||||
library.set_setting("install.upload_threads", upload_threads(e.control.value))
|
||||
|
||||
def video_changed(e):
|
||||
library.set_setting("video.hw_decode", bool(e.control.value))
|
||||
target = self.app.target if self.app.frame_state == "connected" else None
|
||||
@@ -120,7 +124,12 @@ class SettingsView:
|
||||
value=bool(library.setting("video.hw_decode", True)),
|
||||
on_change=video_changed), expand=True),
|
||||
C.help_icon("hw_video_decode")], spacing=T.S1)
|
||||
return ft.Column([launch, keep, video,
|
||||
parallel = C.dropdown(label=tr("Parallel uploads"), value=str(upload_threads()), width=T.px(240),
|
||||
options=[ft.DropdownOption(str(n), str(n)) for n in range(1, 9)],
|
||||
on_select=threads_changed)
|
||||
return ft.Column([launch, keep, video, parallel,
|
||||
C.meta(tr("Large files upload together using OpenSSH when available. "
|
||||
"Default: 3. Lower this on a busy network.")),
|
||||
C.ghost(tr("Remove converted copies now"), ft.Icons.CLEANING_SERVICES_OUTLINED, clean,
|
||||
tooltip=tr("Your own game files aren't touched."))],
|
||||
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
@@ -37,6 +37,65 @@ def test_vdf_roundtrip_and_upsert(monkeypatch, tmp_path):
|
||||
assert a.vdf_decode(vdf.read_bytes())["shortcuts"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("root_key", ["Shortcuts", "SHORTCUTS", "shortcuts"])
|
||||
def test_shortcuts_keep_steams_key_spelling_and_existing_games(monkeypatch, tmp_path, root_key):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
vdf = tmp_path / "shortcuts.vdf"
|
||||
exe = '"/game/launch.sh"'
|
||||
other = {"AppID": 123, "AppName": "Other", "EXE": '"/other"', "Tags": {"0": "Favorite"}}
|
||||
original = {root_key: {"0": {"AppID": 456, "AppName": "Old", "EXE": exe, "ICON": "/old.png",
|
||||
"Tags": {"0": "Favorite"}, "LastPlayTime": 99}, "1": other}}
|
||||
data = a.vdf_encode(original)
|
||||
vdf.write_bytes(data)
|
||||
assert a.vdf_encode(a.vdf_decode(data)) == data
|
||||
assert a.upsert_shortcut(str(vdf), exe, "New", "/game", tags=["Action"], write=False) == (456, True)
|
||||
assert vdf.read_bytes() == data
|
||||
assert a.upsert_shortcut(str(vdf), exe, "New", "/game", tags=["Action"]) == 456
|
||||
root = a.vdf_decode(vdf.read_bytes())
|
||||
assert list(root) == [root_key]
|
||||
entry = root["shortcuts"]["0"]
|
||||
assert entry["appname"] == "New" and "AppName" in list(entry) and "appname" not in list(entry)
|
||||
assert entry["appid"] == 456 and entry["icon"] == "/old.png" and entry["LastPlayTime"] == 99
|
||||
assert list(entry["tags"].values()) == ["Favorite", "Quest on Frame", "Action"]
|
||||
assert root["shortcuts"]["1"] == other
|
||||
assert a.upsert_shortcut(str(vdf), exe, "New", "/game", tags=["Action"], write=False) == (456, False)
|
||||
a.upsert_shortcut(str(vdf), '"/third"', "Third", "/")
|
||||
assert len(a.vdf_decode(vdf.read_bytes())["shortcuts"]) == 3
|
||||
assert a.prune_shortcuts(str(vdf), "New", '"/replacement"', "Quest on Frame") == [456]
|
||||
assert a.remove_shortcut(str(vdf), '"/other"')
|
||||
root = a.vdf_decode(vdf.read_bytes())
|
||||
assert list(root) == [root_key] and len(root["shortcuts"]) == 1
|
||||
assert root["shortcuts"]["0"]["appname"] == "Third"
|
||||
|
||||
|
||||
def test_capitalized_shortcuts_are_found_by_library_and_devkit_checks(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
cfg = Path(a.STEAM) / "userdata/42/config"
|
||||
cfg.mkdir(parents=True)
|
||||
exe = '"/game/launch.sh"'
|
||||
vdf = cfg / "shortcuts.vdf"
|
||||
vdf.write_bytes(a.vdf_encode({"Shortcuts": {"0": {"APPID": 0xF0000001, "EXE": exe,
|
||||
"APPNAME": "Game", "DEVKITGAMEID": "fp_game"}}}))
|
||||
monkeypatch.setattr(a, "library_users", lambda: ["42"])
|
||||
monkeypatch.setattr(a, "steam_users", lambda: ["42"])
|
||||
monkeypatch.setattr(a, "shortcut_args", lambda pkg: (exe,))
|
||||
monkeypatch.setattr(a.time, "sleep", lambda seconds: None)
|
||||
assert a.shortcut_appid_for(exe) == 0xF0000001
|
||||
assert a.devkit_appid("fp_game") == 0xF0000001
|
||||
assert a.shortcut_names("42") == {str(0xF0000001): "Game"}
|
||||
assert a.shortcuts_lost(["42"], ["com.x.y"], wait=0) == []
|
||||
|
||||
|
||||
def test_case_variant_duplicate_vdf_keys_are_not_overwritten(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
vdf = tmp_path / "shortcuts.vdf"
|
||||
data = a.vdf_encode({"Shortcuts": {"0": {"appid": 1}}, "shortcuts": {"0": {"appid": 2}}})
|
||||
vdf.write_bytes(data)
|
||||
with pytest.raises(a.AgentError, match="duplicate VDF key"):
|
||||
a.upsert_shortcut(str(vdf), '"/game"', "Game", "/")
|
||||
assert vdf.read_bytes() == data
|
||||
|
||||
|
||||
def test_launcher_template(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
anchor = tmp_path / "anchor"
|
||||
|
||||
@@ -1,6 +1,76 @@
|
||||
from frameport.apk import axml
|
||||
|
||||
|
||||
def test_missing_application_label_uses_activity_resource():
|
||||
from conftest import build_axml
|
||||
|
||||
manifest = build_axml([
|
||||
("start", "manifest", [("package", "str", "org.example.game")]),
|
||||
("start", "application", [("debuggable", "bool", False)]),
|
||||
("start", "activity", [("name", "str", "Main"), ("label", "ref", 0x7F020000)]),
|
||||
("end", "activity"), ("end", "application"), ("end", "manifest"),
|
||||
])
|
||||
fixed = axml.copy_activity_label_to_application(manifest)
|
||||
x = axml.Axml(fixed)
|
||||
app = next(el for el in x.elements() if el.name == "application")
|
||||
label = next(a for a in app.attrs if x.string(a.name) == "label")
|
||||
assert label.dtype == axml.TYPE_REFERENCE and label.value == 0x7F020000
|
||||
assert len(fixed) == len(manifest) + 20 and x.get_bool("application", "debuggable") is False
|
||||
original = axml.Axml(manifest)
|
||||
activity = next(el for el in original.elements() if el.name == "activity")
|
||||
new_activity = next(el for el in x.elements() if el.name == "activity")
|
||||
assert manifest[activity.offset:activity.offset + activity.size] == fixed[
|
||||
new_activity.offset:new_activity.offset + new_activity.size]
|
||||
assert axml.copy_activity_label_to_application(fixed) is None
|
||||
|
||||
|
||||
def test_application_label_is_never_replaced():
|
||||
from conftest import build_axml
|
||||
|
||||
for kind, value in [("str", "Original"), ("ref", 0x7F020001)]:
|
||||
manifest = build_axml([
|
||||
("start", "manifest", []), ("start", "application", [("label", kind, value)]),
|
||||
("start", "activity", [("label", "str", "Other")]), ("end", "activity"),
|
||||
("end", "application"), ("end", "manifest"),
|
||||
])
|
||||
assert axml.copy_activity_label_to_application(manifest) is None
|
||||
assert axml.copy_activity_label_to_application(build_axml([
|
||||
("start", "manifest", []), ("start", "application", []), ("end", "application"),
|
||||
("end", "manifest"),
|
||||
])) is None
|
||||
|
||||
|
||||
def test_copied_label_preserves_resource_order_and_special_attribute_indices():
|
||||
import struct
|
||||
|
||||
from conftest import build_axml
|
||||
|
||||
manifest = build_axml([
|
||||
("start", "manifest", []),
|
||||
("start", "application", [("theme", "ref", 0x0103000A), ("debuggable", "bool", False)]),
|
||||
("start", "activity", [("label", "str", "Game")]), ("end", "activity"),
|
||||
("end", "application"), ("end", "manifest"),
|
||||
])
|
||||
x = axml.Axml(manifest)
|
||||
ids = [0] * x.count
|
||||
for name, resource in [("theme", 0x01010000), ("label", 0x01010001), ("debuggable", 0x0101000F)]:
|
||||
ids[x.index(name)] = resource
|
||||
resource_map = struct.pack("<HHI", 0x0180, 8, 8 + len(ids) * 4) + struct.pack(f"<{len(ids)}I", *ids)
|
||||
pos = x.pool_off + x.pool_size
|
||||
data = bytearray(manifest[:pos] + resource_map + manifest[pos:])
|
||||
struct.pack_into("<I", data, 4, len(data))
|
||||
x = axml.Axml(data)
|
||||
app = next(el for el in x.elements() if el.name == "application")
|
||||
# All three special indices point at the second attribute, which the insertion moves to third.
|
||||
ext = app.offset + 16
|
||||
struct.pack_into("<HHH", data, ext + 14, 2, 2, 2)
|
||||
fixed = axml.copy_activity_label_to_application(bytes(data))
|
||||
x = axml.Axml(fixed)
|
||||
app = next(el for el in x.elements() if el.name == "application")
|
||||
assert [x.string(a.name) for a in app.attrs] == ["theme", "label", "debuggable"]
|
||||
assert struct.unpack_from("<HHH", fixed, app.offset + 16 + 14) == (3, 3, 3)
|
||||
|
||||
|
||||
def test_parse(quest_manifest):
|
||||
x = axml.Axml(quest_manifest)
|
||||
names = [e.name for e in x.elements()]
|
||||
|
||||
@@ -131,6 +131,19 @@ def test_bundle_offline_and_app_only(tmp_path):
|
||||
assert m["target"] is None and m["games"] == []
|
||||
|
||||
|
||||
def test_display_diagnostics_use_bundle_redaction(tmp_path, monkeypatch):
|
||||
from frameport.diag import display
|
||||
|
||||
monkeypatch.setattr(display, "collect", lambda: {"windows": {"status": "error",
|
||||
"error": r"C:\Users\Alice\display.txt at 192.168.50.2"},
|
||||
"driver": "32.0.15.61742"})
|
||||
out = bundle.collect(None, dest=tmp_path / "display.zip")
|
||||
with zipfile.ZipFile(out) as z:
|
||||
text = z.read("app/display.json").decode()
|
||||
assert "Alice" not in text and "192.168.50.2" not in text
|
||||
assert json.loads(text)["driver"] == "32.0.15.61742"
|
||||
|
||||
|
||||
def test_bundle_size_cap():
|
||||
w = bundle._Writer(redact.Redactor())
|
||||
import os
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Desktop diagnostics stay on demand, bounded and useful when Windows queries fail or return no events."""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.diag import display
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_window(monkeypatch):
|
||||
monkeypatch.setattr(display, "_window", {})
|
||||
|
||||
|
||||
def test_non_windows_does_not_start_display_queries(monkeypatch):
|
||||
monkeypatch.setattr(display, "_is_windows", lambda: False)
|
||||
|
||||
def unexpected():
|
||||
pytest.fail("Windows query ran on another platform")
|
||||
|
||||
monkeypatch.setattr(display, "_windows_displays", unexpected)
|
||||
monkeypatch.setattr(display, "_recent_events", unexpected)
|
||||
assert display.collect()["windows"] == {"status": "not_applicable"}
|
||||
|
||||
|
||||
def test_window_events_are_cached_without_running_queries(monkeypatch, caplog):
|
||||
monkeypatch.setattr(display, "_is_windows", lambda: True)
|
||||
monkeypatch.setitem(sys.modules, "flet.version", SimpleNamespace(flet_version="1.0.2", flutter_version="3.44.8"))
|
||||
window = SimpleNamespace(width=1280, height=720, left=3086, top=172,
|
||||
minimized=False, maximized=False, full_screen=False)
|
||||
with caplog.at_level("INFO", logger="frameport"):
|
||||
display.record_window("minimize", window, theme="portal", reduce_motion=True)
|
||||
assert "desktop window:" in caplog.text and '"event":"minimize"' in caplog.text
|
||||
assert display._window["minimized"] is True and display._window["position"] == [3086, 172]
|
||||
monkeypatch.setattr(display, "_windows_displays", lambda: {"status": "ok", "adapters": []})
|
||||
monkeypatch.setattr(display, "_recent_events", lambda: {"status": "ok", "events": []})
|
||||
result = display.collect()
|
||||
assert result["runtime"]["flutter"] == "3.44.8"
|
||||
assert result["window"]["event"] == "minimize"
|
||||
display.record_window("focus", window, theme="portal", reduce_motion=True)
|
||||
display.record_window("restore", window, theme="portal", reduce_motion=True)
|
||||
assert display._window["focused"] is True and display._window["minimized"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("response,status", [
|
||||
('{"status":"ok","events":[]}', "ok"),
|
||||
('{"status":"error","error":"AccessDenied","events":[]}', "error"),
|
||||
('{"status":"ok","events":"invalid"}', "error"),
|
||||
("not JSON", "error"),
|
||||
])
|
||||
def test_event_query_distinguishes_empty_results_from_failures(monkeypatch, response, status):
|
||||
def run(args, **kwargs):
|
||||
assert args[0].lower().endswith(("powershell.exe", "powershell"))
|
||||
assert args[1:4] == ["-NoLogo", "-NoProfile", "-NonInteractive"]
|
||||
assert not any(k.upper() == "PSMODULEPATH" for k in kwargs["env"]) # pwsh 7's modules break 5.1
|
||||
assert kwargs["timeout"] == 5 and kwargs["creationflags"] == 0x08000000
|
||||
assert "-FilterXPath" in args[-1] and "-MaxEvents 60" in args[-1]
|
||||
assert ".Message" not in args[-1] # event metadata, not unrelated free text or device IDs
|
||||
return subprocess.CompletedProcess(args, 0, response, "")
|
||||
|
||||
monkeypatch.setattr(display.subprocess, "run", run)
|
||||
result = display._recent_events()
|
||||
assert result["status"] == status
|
||||
if status == "ok":
|
||||
assert result["events"] == []
|
||||
|
||||
|
||||
def test_event_query_timeout_does_not_look_like_no_events(monkeypatch):
|
||||
def run(*args, **kwargs):
|
||||
raise subprocess.TimeoutExpired(args[0], kwargs["timeout"])
|
||||
|
||||
monkeypatch.setattr(display.subprocess, "run", run)
|
||||
assert display._recent_events() == {"status": "timeout", "timeout_seconds": 5}
|
||||
|
||||
|
||||
def test_display_failure_does_not_discard_event_or_window_data(monkeypatch):
|
||||
monkeypatch.setattr(display, "_is_windows", lambda: True)
|
||||
|
||||
def broken():
|
||||
raise OSError("display API unavailable")
|
||||
|
||||
monkeypatch.setattr(display, "_windows_displays", broken)
|
||||
monkeypatch.setattr(display, "_recent_events", lambda: {"status": "ok", "events": []})
|
||||
result = display.collect()
|
||||
assert result["windows"]["displays"]["status"] == "error"
|
||||
assert result["windows"]["system_events"]["status"] == "ok"
|
||||
json.dumps(result)
|
||||
@@ -0,0 +1,519 @@
|
||||
"""Update classification and the operations it must skip, including persisted live settings."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from test_agent import load_agent
|
||||
|
||||
from frameport import pipeline
|
||||
from frameport.core import library
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.game_update import apk_recipe_fingerprint, configuration, configuration_fingerprint, plan_update
|
||||
from frameport.ui.components import install_state
|
||||
|
||||
PKG = "com.example.update"
|
||||
|
||||
|
||||
def game_and_dep(patches=None):
|
||||
recipe = {"package": PKG, "source": "user", "patches": patches or {"adapter.scale": {"value": 1.5}}}
|
||||
game = {"package": PKG, "title": "Example", "recipe": recipe, "analysis": {"version": "1"},
|
||||
"build": {"apk": "/missing/converted.apk", "sha256": "apk-a", "ok": True,
|
||||
"source_version": "1", "apk_recipe_fp": apk_recipe_fingerprint(recipe, PKG)}}
|
||||
dep = {"package": PKG, "base": "/frame/game", "appid": 42, "sha256": "apk-a", "apk_present": True,
|
||||
"configuration_fp": configuration_fingerprint(recipe, PKG),
|
||||
"configuration": configuration(recipe, PKG), "data_manifest": {"main.obb": 4}}
|
||||
return game, dep
|
||||
|
||||
|
||||
def test_custom_recipe_and_catalog_metadata_do_not_force_update():
|
||||
game, dep = game_and_dep()
|
||||
game["recipe"].update(catalog_rev="new", notes="changed", title="New", source="user")
|
||||
game["catalog_update"] = "newer-tested-recipe"
|
||||
assert not plan_update(game, dep).changed
|
||||
assert install_state(game, {"installed": [dep]}) == "installed"
|
||||
|
||||
|
||||
def test_runtime_parameter_change_and_removal_do_not_rebuild():
|
||||
game, dep = game_and_dep()
|
||||
game["recipe"]["patches"]["adapter.scale"]["value"] = 1.2
|
||||
plan = plan_update(game, dep)
|
||||
assert plan.configure and not plan.rebuild and not plan.apk and not plan.launch_test
|
||||
game["recipe"]["patches"].clear()
|
||||
assert configuration(game["recipe"], PKG)["settings"]["scale"] == 1.0
|
||||
assert plan_update(game, dep).configure and not plan_update(game, dep).rebuild
|
||||
dep["configuration_fp"] = configuration_fingerprint(game["recipe"], PKG)
|
||||
assert not plan_update(game, dep).changed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("patch", ["frame.unity_text_input", "device.foveation", "adapter.controller_models",
|
||||
"adapter.surface_native", "adapter.zink_shader_dump"])
|
||||
def test_apk_support_changes_require_rebuild(patch):
|
||||
game, dep = game_and_dep()
|
||||
game["recipe"]["patches"][patch] = {}
|
||||
assert plan_update(game, dep).rebuild
|
||||
assert plan_update(game, dep).launch_test
|
||||
|
||||
|
||||
def test_alternate_variant_can_reuse_an_existing_build():
|
||||
game, dep = game_and_dep()
|
||||
game["build"].update(alt_sha256="apk-b", alt_apk="/missing/alt.apk")
|
||||
game["recipe"]["use_alt"] = True
|
||||
plan = plan_update(game, dep)
|
||||
assert plan.apk and not plan.rebuild
|
||||
dep["sha256"] = "apk-b"
|
||||
assert not plan_update(game, dep).rebuild
|
||||
game["build"].pop("alt_sha256")
|
||||
assert plan_update(game, dep).rebuild
|
||||
|
||||
|
||||
def test_data_and_artwork_have_separate_actions():
|
||||
game, dep = game_and_dep()
|
||||
plan = plan_update(game, dep, data_manifest={"main.obb": 5})
|
||||
assert plan.data and plan.launch_test and not plan.apk and not plan.rebuild
|
||||
assert not plan_update(game, dep, data_manifest={"main.obb": 5}, apk_only=True).data
|
||||
game["steam_art_stale"] = True
|
||||
plan = plan_update(game, dep)
|
||||
assert plan.artwork and plan.shortcut and not plan.launch_test
|
||||
|
||||
|
||||
def test_legacy_and_source_changes_use_full_path():
|
||||
game, dep = game_and_dep()
|
||||
game["build"].pop("apk_recipe_fp")
|
||||
assert plan_update(game, dep).rebuild
|
||||
game, dep = game_and_dep()
|
||||
game["analysis"]["version"] = "2"
|
||||
assert plan_update(game, dep).rebuild
|
||||
|
||||
|
||||
def test_superseded_setting_does_not_stay_pending():
|
||||
game, dep = game_and_dep({"adapter.haptic_fix": {"value": 1}})
|
||||
game["build"]["superseded"] = {"adapter.haptic_fix": "upstream"}
|
||||
game["build"]["apk_recipe_fp"] = apk_recipe_fingerprint(game["recipe"], PKG, game["build"]["superseded"])
|
||||
dep["configuration_fp"] = configuration_fingerprint(game["recipe"], PKG, game["build"]["superseded"])
|
||||
assert not plan_update(game, dep).changed
|
||||
assert configuration(game["recipe"], PKG, game["build"]["superseded"])["settings"]["haptic_fix"] == 0
|
||||
game["recipe"]["patches"].clear() # removing an omitted workaround is also a no-op
|
||||
assert not plan_update(game, dep).changed
|
||||
|
||||
|
||||
class Target:
|
||||
label = "test-frame"
|
||||
|
||||
def __init__(self, dep):
|
||||
self.dep, self.calls = dep, []
|
||||
|
||||
def installed(self):
|
||||
return [self.dep]
|
||||
|
||||
def update_existing(self, entry, dep, plan, recipe, reporter, manifest=None):
|
||||
self.calls.append("configure" if not plan.data else "data")
|
||||
return {"base": dep["base"], "appid": dep["appid"]}
|
||||
|
||||
def update_steam_art(self, package, reporter, title=None):
|
||||
self.calls.append("artwork")
|
||||
|
||||
def install(self, *args, **kwargs):
|
||||
self.calls.append("install")
|
||||
return {"base": self.dep["base"], "appid": self.dep["appid"]}
|
||||
|
||||
def add_to_library(self, *args):
|
||||
self.calls.append("shortcut")
|
||||
|
||||
|
||||
def store_game(monkeypatch, game):
|
||||
monkeypatch.setattr(library, "_migrate", lambda data: False)
|
||||
library.upsert_game(PKG, **{k: v for k, v in game.items() if k != "package"}, apk="/missing/source.apk")
|
||||
monkeypatch.setattr(pipeline, "_find_late_data", lambda entry, rep: entry)
|
||||
monkeypatch.setattr(pipeline, "_build_inputs_changed", lambda entry: False)
|
||||
monkeypatch.setattr(pipeline, "build_game", lambda *a, **k: pytest.fail("unnecessary rebuild"))
|
||||
|
||||
|
||||
def test_pipeline_configure_without_any_local_apk(monkeypatch):
|
||||
game, dep = game_and_dep()
|
||||
game["recipe"]["patches"]["adapter.scale"]["value"] = 1.2
|
||||
store_game(monkeypatch, game)
|
||||
target = Target(dep)
|
||||
result = pipeline.install_game(PKG, target, Reporter())
|
||||
assert target.calls == ["configure", "shortcut"]
|
||||
assert result["launch_test_required"] is False
|
||||
assert result["shortcut_required"] is True
|
||||
|
||||
|
||||
def test_pipeline_noop_and_artwork_skip_build_and_install(monkeypatch):
|
||||
game, dep = game_and_dep()
|
||||
store_game(monkeypatch, game)
|
||||
target = Target(dep)
|
||||
result = pipeline.install_game(PKG, target, Reporter())
|
||||
# nothing to send, but Steam may have lost the shortcut since: the agent checks it (no restart if present)
|
||||
assert target.calls == ["shortcut"] and result["launch_test_required"] is False
|
||||
target.calls.clear()
|
||||
library.update_game(PKG, lambda entry: entry.update(steam_art_stale=True))
|
||||
pipeline.install_game(PKG, target, Reporter())
|
||||
assert target.calls == ["artwork", "shortcut"]
|
||||
|
||||
|
||||
def test_pipeline_artwork_that_rewrote_the_shortcut_is_not_added_twice(monkeypatch):
|
||||
game, dep = game_and_dep()
|
||||
store_game(monkeypatch, game)
|
||||
target = Target(dep)
|
||||
target.update_steam_art = lambda *a, **k: (target.calls.append("artwork"), {"library": True})[1]
|
||||
library.update_game(PKG, lambda entry: entry.update(steam_art_stale=True))
|
||||
result = pipeline.install_game(PKG, target, Reporter())
|
||||
assert target.calls == ["artwork"] and result["shortcut_required"] is False
|
||||
|
||||
|
||||
def test_pipeline_cli_mode_asks_for_the_shortcut_check(monkeypatch):
|
||||
game, dep = game_and_dep()
|
||||
store_game(monkeypatch, game)
|
||||
target = Target(dep)
|
||||
result = pipeline.install_game(PKG, target, Reporter(), add_to_library=False)
|
||||
assert target.calls == [] and result["shortcut_required"] is True
|
||||
|
||||
|
||||
def test_pipeline_changed_data_without_local_apk(monkeypatch, tmp_path):
|
||||
game, dep = game_and_dep()
|
||||
data = tmp_path / "data"
|
||||
data.mkdir()
|
||||
(data / "main.obb").write_bytes(b"12345")
|
||||
game["data_dir"] = str(data)
|
||||
store_game(monkeypatch, game)
|
||||
target = Target(dep)
|
||||
result = pipeline.install_game(PKG, target, Reporter())
|
||||
assert target.calls == ["data", "shortcut"] and result["launch_test_required"] is True
|
||||
target.calls.clear()
|
||||
pipeline.install_game(PKG, target, Reporter(), apk_only=True)
|
||||
assert target.calls == ["shortcut"]
|
||||
|
||||
|
||||
def installed_agent(monkeypatch, tmp_path):
|
||||
agent = load_agent(monkeypatch, tmp_path)
|
||||
base = tmp_path / "Applications/quest-frame" / PKG
|
||||
(base / "lepton-app").mkdir(parents=True)
|
||||
(base / "lepton-app/game.apk").write_bytes(b"apk")
|
||||
dep = {"package": PKG, "title": "Example", "base": str(base), "appid": 42, "sha256": "apk-a",
|
||||
"tags": ["Favorite"], "apk": "original.apk", "apk_size": 3, "data_manifest": {"modded.obb": 4}}
|
||||
dep["apk_stat"] = {"size": 3, "mtime_ns": (base / "lepton-app/game.apk").stat().st_mtime_ns}
|
||||
agent.write_deployment(PKG, dep)
|
||||
monkeypatch.setattr(agent, "new_base", lambda args, pkg, deployment: (deployment["base"], 10 << 30))
|
||||
monkeypatch.setattr(agent, "container_running", lambda appid: False)
|
||||
monkeypatch.setattr(agent, "lepton_path", lambda: ("/lepton", None))
|
||||
monkeypatch.setattr(agent, "install_controller_models", lambda *a: {"ok": True})
|
||||
monkeypatch.setattr(agent, "write_launcher", lambda *a: None)
|
||||
monkeypatch.setattr(agent, "sync_game_config", lambda *a: {})
|
||||
return agent, base, dep
|
||||
|
||||
|
||||
def test_agent_configuration_preserves_apk_data_tags_and_ignores_staging(monkeypatch, tmp_path):
|
||||
agent, base, _ = installed_agent(monkeypatch, tmp_path)
|
||||
pending = base / "incoming"
|
||||
pending.mkdir()
|
||||
(pending / "game.apk").write_bytes(b"pending different apk")
|
||||
recipe = {"patches": {"adapter.scale": {"value": 1.5}}}
|
||||
applied = configuration(recipe, PKG)
|
||||
result = agent.cmd_configure({"package": PKG, "apk_sha256": "apk-a", "settings": applied["settings"],
|
||||
"configuration": applied, "configuration_fp": configuration_fingerprint(recipe, PKG),
|
||||
"recipe": {"patches": ["adapter.scale"]}})
|
||||
assert "data_manifest" not in result["deployment"]
|
||||
dep = agent.deployment(PKG)
|
||||
assert dep["tags"] == ["Favorite"] and dep["apk"] == "original.apk" and dep["sha256"] == "apk-a"
|
||||
assert dep["data_manifest"] == {"modded.obb": 4}
|
||||
assert (base / "lepton-app/game.apk").read_bytes() == b"apk"
|
||||
assert (pending / "game.apk").exists()
|
||||
# Live writes update the applied baseline, including resetting a deleted recipe override.
|
||||
agent.cmd_set_settings({"package": PKG, "settings": {"scale": 1.0}})
|
||||
assert agent.deployment(PKG)["configuration_fp"] == configuration_fingerprint({"patches": {}}, PKG)
|
||||
settings_conf = (base / "settings.conf").read_text()
|
||||
for bad in (".", "1.2.3", "-", "abc"): # refused before anything is written
|
||||
with pytest.raises(agent.AgentError):
|
||||
agent.cmd_set_settings({"package": PKG, "settings": {"scale": bad}})
|
||||
assert (base / "settings.conf").read_text() == settings_conf
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["running", "missing", "different", "drive"])
|
||||
def test_agent_refuses_unsafe_configurations(monkeypatch, tmp_path, failure):
|
||||
agent, base, original = installed_agent(monkeypatch, tmp_path)
|
||||
sha = "apk-a"
|
||||
if failure == "running":
|
||||
monkeypatch.setattr(agent, "container_running", lambda appid: True)
|
||||
elif failure == "missing":
|
||||
(base / "lepton-app/game.apk").unlink()
|
||||
elif failure == "different":
|
||||
sha = "other-apk"
|
||||
else:
|
||||
def unavailable(*args):
|
||||
raise agent.AgentError("drive missing")
|
||||
monkeypatch.setattr(agent, "new_base", unavailable)
|
||||
with pytest.raises(agent.AgentError):
|
||||
agent.cmd_configure({"package": PKG, "apk_sha256": sha, "settings": {"scale": 1.5}})
|
||||
assert agent.deployment(PKG) == original
|
||||
assert not (base / "settings.conf").exists()
|
||||
|
||||
|
||||
def test_generated_config_removal_restores_original_and_preserves_user_changes(monkeypatch, tmp_path):
|
||||
agent, base, _ = installed_agent(monkeypatch, tmp_path)
|
||||
files = Path(agent.data_files_dir(str(base), PKG))
|
||||
files.mkdir(parents=True)
|
||||
(files / "user.cfg").write_text("original")
|
||||
args = {"package": PKG, "apk_sha256": "apk-a", "files": {"user.cfg": "managed"}}
|
||||
agent.cmd_configure(args)
|
||||
agent.cmd_configure({**args, "files": {}})
|
||||
assert (files / "user.cfg").read_text() == "original"
|
||||
agent.cmd_configure(args)
|
||||
(files / "user.cfg").write_text("user mod")
|
||||
agent.cmd_configure({**args, "files": {}})
|
||||
assert (files / "user.cfg").read_text() == "user mod"
|
||||
|
||||
|
||||
def test_failed_configuration_does_not_record_success(monkeypatch, tmp_path):
|
||||
agent, base, original = installed_agent(monkeypatch, tmp_path)
|
||||
def fail(*args):
|
||||
raise RuntimeError("configuration sync failed")
|
||||
monkeypatch.setattr(agent, "sync_game_config", fail)
|
||||
with pytest.raises(RuntimeError):
|
||||
agent.cmd_configure({"package": PKG, "apk_sha256": "apk-a", "settings": {"scale": 1.5},
|
||||
"configuration_fp": "new"})
|
||||
assert agent.deployment(PKG) == original
|
||||
|
||||
|
||||
def test_generated_paths_checked_before_writing_settings(monkeypatch, tmp_path):
|
||||
agent, base, original = installed_agent(monkeypatch, tmp_path)
|
||||
with pytest.raises(agent.AgentError):
|
||||
agent.cmd_configure({"package": PKG, "apk_sha256": "apk-a", "settings": {"scale": 1.5},
|
||||
"files": {"../../escape": "bad"}})
|
||||
assert agent.deployment(PKG) == original
|
||||
assert not (base / "settings.conf").exists()
|
||||
|
||||
|
||||
def test_build_inputs_ignore_unrelated_native_assets_and_detect_source_changes(monkeypatch, tmp_path):
|
||||
import zipfile
|
||||
|
||||
from frameport.core import paths
|
||||
from frameport.tools import toolchain
|
||||
|
||||
game, _ = game_and_dep()
|
||||
source = tmp_path / "source.apk"
|
||||
source.write_bytes(b"source")
|
||||
game["apk"] = str(source)
|
||||
native = tmp_path / "artifacts"
|
||||
(native / "arm64-v8a").mkdir(parents=True)
|
||||
(native / "arm64-v8a/chosen.so").write_bytes(b"native")
|
||||
manifest = native / "SHA256SUMS"
|
||||
manifest.write_text("a" * 64 + " ./arm64-v8a/chosen.so\n")
|
||||
output = tmp_path / "output.apk"
|
||||
with zipfile.ZipFile(output, "w") as archive:
|
||||
archive.writestr("lib/arm64-v8a/chosen.so", b"native")
|
||||
monkeypatch.setattr(paths, "artifacts_dir", lambda: native)
|
||||
monkeypatch.setattr(toolchain, "overport_jar", lambda: None)
|
||||
game["build"].update(pipeline._build_record(game, output))
|
||||
assert not pipeline._build_inputs_changed(game)
|
||||
manifest.write_text(manifest.read_text() + "b" * 64 + " ./arm64-v8a/unrelated.so\n")
|
||||
assert not pipeline._build_inputs_changed(game)
|
||||
manifest.write_text("c" * 64 + " ./arm64-v8a/chosen.so\n")
|
||||
assert pipeline._build_inputs_changed(game)
|
||||
manifest.write_text("a" * 64 + " ./arm64-v8a/chosen.so\n")
|
||||
source.write_bytes(b"different source, same version")
|
||||
assert pipeline._build_inputs_changed(game)
|
||||
|
||||
|
||||
def test_stage_timings_include_the_final_stage(monkeypatch):
|
||||
from frameport.core import events
|
||||
|
||||
now = [10.0]
|
||||
monkeypatch.setattr(events.time, "monotonic", lambda: now[0])
|
||||
reporter = Reporter()
|
||||
reporter.stage("Build")
|
||||
now[0] += 3
|
||||
reporter.stage("Upload")
|
||||
now[0] += 2
|
||||
assert reporter.stage_timings() == {"Build": 3, "Upload": 2}
|
||||
assert reporter.stage_timings() == {"Build": 3, "Upload": 2} # snapshots do not double-count
|
||||
|
||||
|
||||
def test_artwork_update_transfers_only_changed_slot(monkeypatch, tmp_path):
|
||||
from frameport.artwork import sources, steam
|
||||
from frameport.install import installer
|
||||
|
||||
portrait, hero = tmp_path / "portrait.png", tmp_path / "hero.png"
|
||||
portrait.write_bytes(b"portrait")
|
||||
hero.write_bytes(b"old hero")
|
||||
art = {"portrait": portrait, "hero": hero}
|
||||
monkeypatch.setattr(steam, "steam_set_for", lambda package: art)
|
||||
monkeypatch.setattr(sources, "icon_source", lambda package: "custom")
|
||||
manifest = installer.art_manifest(PKG, art)
|
||||
hero.write_bytes(b"new hero")
|
||||
calls = []
|
||||
dep = {"package": PKG, "title": "Example", "anchor": "/frame/anchor", "artwork_manifest": manifest}
|
||||
|
||||
class Frame:
|
||||
def agent(self, command, **args):
|
||||
calls.append(command)
|
||||
return {"games": [dep]} if command == "list_installed" else {"verified": True}
|
||||
|
||||
def run(self, command):
|
||||
calls.append("mkdir")
|
||||
|
||||
def put(self, local, remote, **kwargs):
|
||||
calls.append(local.name)
|
||||
monkeypatch.setattr(installer, "add_to_steam", lambda *a: calls.append("shortcut"))
|
||||
installer.update_steam_art(Frame(), PKG, Reporter(), title="Example")
|
||||
assert "hero.png" in calls and "portrait.png" not in calls
|
||||
assert "record_artwork" in calls and "shortcut" in calls
|
||||
calls.clear()
|
||||
dep["artwork_manifest"] = installer.art_manifest(PKG, art)
|
||||
installer.update_steam_art(Frame(), PKG, Reporter(), title="Example")
|
||||
assert calls == ["list_installed"]
|
||||
|
||||
|
||||
def test_artwork_update_without_a_recorded_manifest_starts_clean(monkeypatch, tmp_path):
|
||||
"""Installs before agent v86 have no artwork_manifest: an old portrait.jpg must not stay next to portrait.png."""
|
||||
from frameport.artwork import sources, steam
|
||||
from frameport.install import installer
|
||||
|
||||
portrait = tmp_path / "portrait.png"
|
||||
portrait.write_bytes(b"portrait")
|
||||
monkeypatch.setattr(steam, "steam_set_for", lambda package: {"portrait": portrait})
|
||||
monkeypatch.setattr(sources, "icon_source", lambda package: "custom")
|
||||
monkeypatch.setattr(installer, "send_icon_source", lambda *a: None)
|
||||
monkeypatch.setattr(installer, "add_to_steam", lambda *a: {"state": "done"})
|
||||
dep = {"package": PKG, "title": "Example", "anchor": "/frame/anchor"}
|
||||
commands, puts = [], []
|
||||
|
||||
class Frame:
|
||||
def agent(self, command, **args):
|
||||
return {"games": [dep]} if command == "list_installed" else {"verified": True}
|
||||
|
||||
def run(self, command):
|
||||
commands.append(command)
|
||||
|
||||
def put(self, local, remote, **kwargs):
|
||||
puts.append(remote)
|
||||
|
||||
result = installer.update_steam_art(Frame(), PKG, Reporter(), title="Example")
|
||||
assert commands[0] == "rm -rf -- '/frame/anchor/artwork'" and commands[1].startswith("mkdir -p")
|
||||
assert puts == ["/frame/anchor/artwork/portrait.png"] and result["library"] is True
|
||||
|
||||
|
||||
def test_agent_artwork_manifest_requires_matching_uploaded_content(monkeypatch, tmp_path):
|
||||
agent, base, _ = installed_agent(monkeypatch, tmp_path)
|
||||
art = base / "artwork"
|
||||
art.mkdir()
|
||||
(art / "hero.png").write_bytes(b"hero")
|
||||
manifest = {"hero.png": agent.sha256_file(str(art / "hero.png"))}
|
||||
assert agent.cmd_record_artwork({"package": PKG, "manifest": manifest})["verified"]
|
||||
with pytest.raises(agent.AgentError):
|
||||
agent.cmd_record_artwork({"package": PKG, "manifest": {"hero.png": "f" * 64}})
|
||||
assert agent.deployment(PKG)["artwork_manifest"] == manifest
|
||||
|
||||
|
||||
def test_apk_only_finalize_does_not_apply_staged_data_or_artwork(monkeypatch, tmp_path):
|
||||
agent, base, _ = installed_agent(monkeypatch, tmp_path)
|
||||
(base / "lepton-app/obb").mkdir()
|
||||
(base / "lepton-app/obb/main.obb").write_bytes(b"user mod")
|
||||
incoming = base / "incoming"
|
||||
(incoming / "obb").mkdir(parents=True)
|
||||
(incoming / "obb/main.obb").write_bytes(b"old staged copy")
|
||||
(incoming / "game.apk").write_bytes(b"new apk")
|
||||
(base / "incoming-artwork").mkdir()
|
||||
(base / "incoming-artwork/hero.png").write_bytes(b"old staged art")
|
||||
monkeypatch.setattr(agent, "cmd_prepare", lambda args: {"base": str(base), "anchor": str(base), "appid": 42,
|
||||
"incoming": str(incoming), "lepton": "/lepton"})
|
||||
result = agent.cmd_finalize({"package": PKG, "title": "Example", "preserve_data": True,
|
||||
"consume_artwork": False,
|
||||
"apk_sha256": agent.sha256_file(str(incoming / "game.apk"))})
|
||||
assert result["moved_data_files"] == 0
|
||||
assert (base / "lepton-app/obb/main.obb").read_bytes() == b"user mod"
|
||||
assert (incoming / "obb/main.obb").read_bytes() == b"old staged copy"
|
||||
assert not (base / "artwork").exists()
|
||||
assert (base / "incoming-artwork/hero.png").exists()
|
||||
|
||||
|
||||
def test_data_digest_fast_path_and_subset_manifest():
|
||||
from frameport.game_update import fingerprint
|
||||
|
||||
game, dep = game_and_dep()
|
||||
dep.pop("data_manifest")
|
||||
dep["data_fp"] = fingerprint({"main.obb": 4})
|
||||
assert not plan_update(game, dep, data_manifest={"main.obb": 4}).data
|
||||
assert plan_update(game, dep, data_manifest={"main.obb": 5}).data
|
||||
dep["data_manifest"] = {"main.obb": 4, "unused.obb": 10}
|
||||
assert not plan_update(game, dep, data_manifest={"main.obb": 4}).data
|
||||
|
||||
|
||||
def test_routine_deployment_summary_excludes_large_manifests():
|
||||
import importlib.util
|
||||
|
||||
path = Path(__file__).resolve().parents[1] / "agent/frameport_agent.py"
|
||||
spec = importlib.util.spec_from_file_location("frameport_agent_summary", path)
|
||||
agent = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(agent)
|
||||
full = {"package": PKG, "data_fp": "digest", "configuration_fp": "runtime", "sha256": "apk",
|
||||
"data_manifest": {"main.obb": 123}, "configuration": {"settings": {}}, "managed_config_files": {}}
|
||||
summary = agent.deployment_summary(full)
|
||||
assert summary == {"package": PKG, "data_fp": "digest", "configuration_fp": "runtime", "sha256": "apk"}
|
||||
assert "data_manifest" in full # summarizing does not mutate the durable record
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changed_data, force, preserve", [(False, False, True), (True, False, False),
|
||||
(False, True, False)])
|
||||
def test_apk_recipe_update_preserves_unchanged_source_data(monkeypatch, tmp_path, changed_data, force, preserve):
|
||||
game, dep = game_and_dep()
|
||||
data = tmp_path / "data"
|
||||
data.mkdir()
|
||||
(data / "main.obb").write_bytes(b"12345" if changed_data else b"1234")
|
||||
game["data_dir"] = str(data)
|
||||
game["recipe"]["patches"]["frame.unity_text_input"] = {}
|
||||
store_game(monkeypatch, game)
|
||||
output = tmp_path / "built.apk"
|
||||
output.write_bytes(b"checked new build")
|
||||
def build(package, reporter):
|
||||
current = library.game(package)
|
||||
library.update_game(package, lambda entry: entry["build"].update(
|
||||
apk=str(output), sha256=pipeline.builder.sha256(output),
|
||||
apk_recipe_fp=apk_recipe_fingerprint(current["recipe"], package)))
|
||||
return {"ok": True}
|
||||
monkeypatch.setattr(pipeline, "build_game", build)
|
||||
target = Target(dep)
|
||||
installed = []
|
||||
def install(*args, **kwargs):
|
||||
installed.append(args[6])
|
||||
return {"base": dep["base"], "appid": dep["appid"]}
|
||||
target.install = install
|
||||
result = pipeline.install_game(PKG, target, Reporter(), force=force)
|
||||
assert installed == [preserve]
|
||||
assert result["launch_test_required"] is True
|
||||
|
||||
|
||||
def test_agent_drops_records_an_older_app_did_not_send(monkeypatch, tmp_path):
|
||||
"""An app before agent v86 installs without fingerprints: a newer PC mustn't skip work on the old record."""
|
||||
agent, base, dep = installed_agent(monkeypatch, tmp_path)
|
||||
agent.write_deployment(PKG, {**dep, "configuration_fp": "old", "apk_recipe_fp": "old",
|
||||
"artwork_manifest": {"portrait.jpg": "a" * 64}})
|
||||
anchor = tmp_path / "Applications/quest-frame" / PKG
|
||||
prep = {"base": str(base), "anchor": str(anchor), "appid": 42, "lepton": "/lepton"}
|
||||
# no new art: the record of the art in place stays
|
||||
agent.configure_install({"package": PKG, "title": "Example", "settings": {}}, prep, 0)
|
||||
record = agent.deployment(PKG)
|
||||
assert "configuration_fp" not in record and "apk_recipe_fp" not in record
|
||||
assert record["artwork_manifest"] == {"portrait.jpg": "a" * 64}
|
||||
# new art moved in without a manifest: its record is dropped too
|
||||
(base / "incoming-artwork").mkdir()
|
||||
(base / "incoming-artwork/portrait.png").write_bytes(b"new")
|
||||
agent.configure_install({"package": PKG, "title": "Example", "settings": {}, "consume_artwork": True}, prep, 0)
|
||||
assert "artwork_manifest" not in agent.deployment(PKG)
|
||||
assert (anchor / "artwork/portrait.png").exists()
|
||||
|
||||
|
||||
def test_agent_config_written_by_an_older_frameport_is_not_kept_as_the_users(monkeypatch, tmp_path):
|
||||
agent, base, dep = installed_agent(monkeypatch, tmp_path)
|
||||
files = base / "lepton-data/external/Android/data" / PKG / "files"
|
||||
files.mkdir(parents=True)
|
||||
(files / "user.cfg").write_text("r_x=0\n") # FramePort's own, from before agent v86 kept records
|
||||
(files / "mine.cfg").write_text("users own\n")
|
||||
prep = {"base": str(base), "anchor": str(tmp_path / "Applications/quest-frame" / PKG), "appid": 42,
|
||||
"lepton": "/lepton"}
|
||||
agent.configure_install({"package": PKG, "title": "Example", "settings": {},
|
||||
"files": {"user.cfg": "r_x=0\n", "mine.cfg": "r_y=1\n"}}, prep, 0)
|
||||
managed = agent.deployment(PKG)["managed_config_files"]
|
||||
assert managed["user.cfg"]["backup"] is False and managed["mine.cfg"]["backup"] is True
|
||||
# the recipe drops both: FramePort's file goes away, the user's own comes back
|
||||
agent.configure_install({"package": PKG, "title": "Example", "settings": {}, "files": {}}, prep, 0)
|
||||
assert not (files / "user.cfg").exists() and (files / "mine.cfg").read_text() == "users own\n"
|
||||
@@ -1,7 +1,13 @@
|
||||
"""One FramePort window at a time (core/instance.py)."""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core import instance
|
||||
|
||||
@@ -9,24 +15,36 @@ from frameport.core import instance
|
||||
def _hold_lock(path):
|
||||
"""A separate process holding the GUI lock (like a running FramePort)."""
|
||||
code = textwrap.dedent(f"""
|
||||
import sys, time
|
||||
import os, sys, time
|
||||
sys.path.insert(0, {str(instance.__file__.rsplit('/frameport/', 1)[0])!r})
|
||||
from pathlib import Path
|
||||
from frameport.core import instance
|
||||
assert instance.acquire(Path({str(path)!r}))
|
||||
print("locked", flush=True)
|
||||
print("locked", os.getpid(), flush=True)
|
||||
time.sleep(30)
|
||||
""")
|
||||
p = subprocess.Popen([sys.executable, "-c", code], stdout=subprocess.PIPE, text=True)
|
||||
assert p.stdout.readline().strip() == "locked"
|
||||
status = p.stdout.readline().strip().split()
|
||||
assert status[0] == "locked"
|
||||
p.gui_pid = int(status[1]) # Windows venv's python.exe can launch another interpreter process
|
||||
return p
|
||||
|
||||
|
||||
def _end_holder(holder):
|
||||
import psutil
|
||||
|
||||
try:
|
||||
psutil.Process(holder.gui_pid).kill()
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
holder.wait(5)
|
||||
|
||||
|
||||
def test_second_start_is_refused_while_a_window_is_open(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FRAMEPORT_HOME", str(tmp_path))
|
||||
holder = _hold_lock(instance.lock_file())
|
||||
try:
|
||||
assert instance.holder() == holder.pid
|
||||
assert instance.holder() == holder.gui_pid
|
||||
assert not instance.acquire()
|
||||
assert instance.claim(lambda: True) is False # a window answers: it's asked to come to the front
|
||||
assert instance.take_focus_request() and not instance.take_focus_request()
|
||||
@@ -34,19 +52,90 @@ def test_second_start_is_refused_while_a_window_is_open(tmp_path, monkeypatch):
|
||||
assert instance.claim(lambda: False, retries=1) is False
|
||||
assert holder.poll() is None
|
||||
finally:
|
||||
holder.kill()
|
||||
holder.wait()
|
||||
_end_holder(holder)
|
||||
assert instance.acquire() # released by the OS when the holder ends
|
||||
|
||||
|
||||
def test_leftover_frameport_gui_is_ended(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FRAMEPORT_HOME", str(tmp_path))
|
||||
holder = _hold_lock(instance.lock_file())
|
||||
monkeypatch.setattr(instance, "_is_frameport_gui", lambda pid: pid == holder.pid)
|
||||
monkeypatch.setattr(instance, "_is_frameport_gui", lambda pid: pid == holder.gui_pid)
|
||||
monkeypatch.setattr(instance, "STARTUP_GRACE", 0.0)
|
||||
try:
|
||||
assert instance.claim(lambda: False) is True # no window: the leftover process is ended, the lock taken
|
||||
assert holder.wait(5) is not None
|
||||
finally:
|
||||
if holder.poll() is None:
|
||||
holder.kill()
|
||||
_end_holder(holder)
|
||||
|
||||
|
||||
def test_holder_is_readable_when_windows_blocks_the_lock_contents(tmp_path, monkeypatch):
|
||||
path = tmp_path / "gui.lock"
|
||||
assert instance.acquire(path)
|
||||
read_text = Path.read_text
|
||||
|
||||
def locked(self, *args, **kwargs):
|
||||
if self == path:
|
||||
raise PermissionError("Windows byte-range lock")
|
||||
return read_text(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", locked)
|
||||
try:
|
||||
assert instance.holder(path) == os.getpid()
|
||||
owner = instance._owner_file(path)
|
||||
data = json.loads(owner.read_text())
|
||||
data["created"] -= 100 # a PID reused by another process must not be accepted
|
||||
owner.write_text(json.dumps(data))
|
||||
assert instance.holder(path) is None
|
||||
finally:
|
||||
instance.release()
|
||||
assert not instance._owner_file(path).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd,expected", [
|
||||
(["python", "-m", "frameport.ui.app"], True),
|
||||
(["python", "-m", "frameport.cli"], False),
|
||||
(["python", "-c", "import frameport"], False),
|
||||
(["/apps/FramePort.exe"], True),
|
||||
])
|
||||
def test_recovery_only_matches_gui_commands(monkeypatch, cmd, expected):
|
||||
import psutil
|
||||
|
||||
monkeypatch.setattr(psutil, "Process", lambda pid: SimpleNamespace(cmdline=lambda: cmd))
|
||||
assert instance._is_frameport_gui(123) is expected
|
||||
|
||||
|
||||
def test_legacy_recovery_only_finds_the_same_data_folders_lock(tmp_path, monkeypatch):
|
||||
import psutil
|
||||
|
||||
lock = tmp_path / "gui.lock"
|
||||
other = tmp_path / "another-app/gui.lock"
|
||||
processes = [SimpleNamespace(pid=123, open_files=lambda: [SimpleNamespace(path=str(lock))]),
|
||||
SimpleNamespace(pid=124, open_files=lambda: [SimpleNamespace(path=str(other))])]
|
||||
monkeypatch.setattr(psutil, "process_iter", lambda attrs: processes)
|
||||
monkeypatch.setattr(instance, "_is_frameport_gui", lambda pid: True)
|
||||
assert instance._legacy_holders(lock) == [123]
|
||||
|
||||
|
||||
def test_claim_recovers_a_legacy_windows_holder_without_readable_pid(monkeypatch):
|
||||
monkeypatch.setattr(instance, "sys", SimpleNamespace(platform="win32"))
|
||||
attempts = iter([False, True])
|
||||
ended = []
|
||||
monkeypatch.setattr(instance, "acquire", lambda: next(attempts))
|
||||
monkeypatch.setattr(instance, "holder", lambda: None)
|
||||
monkeypatch.setattr(instance, "_legacy_holders", lambda path: [123])
|
||||
monkeypatch.setattr(instance, "end_leftover", lambda pid: ended.append(pid))
|
||||
assert instance.claim(lambda: False)
|
||||
assert ended == [123]
|
||||
|
||||
|
||||
def test_lock_is_kept_when_the_owner_file_cannot_be_written(tmp_path, monkeypatch):
|
||||
"""An antivirus or a read-only file must not stop the GUI from starting once it holds the lock."""
|
||||
def fail(*args, **kwargs):
|
||||
raise PermissionError("locked by another program")
|
||||
|
||||
monkeypatch.setattr(instance, "write_atomic", fail)
|
||||
try:
|
||||
assert instance.acquire(tmp_path / "gui.lock")
|
||||
finally:
|
||||
instance.release()
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Lua engine substitution must preserve native bindings and their self-references through the PLT."""
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
from elftools.elf.elffile import ELFFile
|
||||
|
||||
from frameport.analysis import elf, luajit
|
||||
from frameport.apk.workspace import ApkWorkspace
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.core.models import Analysis
|
||||
from frameport.patches import base
|
||||
from frameport.patches.frame import artifact, luajit_pointers
|
||||
|
||||
|
||||
def _analysis(extra=None):
|
||||
return Analysis(package="example.game", version="1", label="Game", abis=["arm64-v8a"], engine="Unity",
|
||||
xr="OpenXR", graphics="GLES", direct_vrapi=False, libs=["libxlua.so"], launcher_activity=None,
|
||||
has_info_category=False, meta_permissions=[], uses_glad_gl=False, unity_msaa_levels=0,
|
||||
oculus_os_classes=False, is_overport_output=False, debuggable=True, extra=extra or {})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def libraries(tmp_path):
|
||||
cc = shutil.which("cc")
|
||||
if not cc or sys.platform != "linux":
|
||||
pytest.skip("native loader regression uses Linux cc")
|
||||
original = tmp_path / "original.c"
|
||||
original.write_text("""
|
||||
int lua_gettop(void *state) { (void)state; return -77; }
|
||||
void lua_pushlightuserdata(void **state, void *p) { *state = p; }
|
||||
void *lua_touserdata(void **state, int index) { (void)index; return *state; }
|
||||
int xlua_get_lib_version(void) { return 105; }
|
||||
void *xlua_gl(void *state) { return state; }
|
||||
int luaopen_perflib(void *state) { return lua_gettop(state); }
|
||||
int game_custom_binding(void *state) { return lua_gettop(state) + 1; }
|
||||
""")
|
||||
runtime = tmp_path / "runtime.c"
|
||||
runtime.write_text("""
|
||||
int lua_gettop(void *state) { (void)state; return 42; }
|
||||
void lua_pushlightuserdata(void **state, void *p) { *state = p; }
|
||||
void *lua_touserdata(void **state, int index) { (void)index; return *state; }
|
||||
""")
|
||||
loader = tmp_path / "libxlua.so"
|
||||
core = tmp_path / luajit_pointers.RUNTIME
|
||||
for source, dest in [(original, loader), (runtime, core)]:
|
||||
subprocess.run([cc, "-shared", "-fPIC", "-O2", str(source), "-Wl,-rpath,$ORIGIN",
|
||||
f"-Wl,-soname,{dest.name}", "-o", str(dest)], check=True, capture_output=True)
|
||||
return loader, core
|
||||
|
||||
|
||||
def _call_library(path):
|
||||
# A fresh process avoids reusing a dlopen handle after changing the file.
|
||||
script = """
|
||||
import ctypes, json, sys
|
||||
d = ctypes.CDLL(sys.argv[1])
|
||||
print(json.dumps([d.xlua_get_lib_version(), d.luaopen_perflib(None), d.game_custom_binding(None), d.lua_gettop(None)]))
|
||||
"""
|
||||
result = subprocess.run([sys.executable, "-c", script, str(path)], check=True, text=True, capture_output=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_redirect_definitions_redirects_dlsym_and_internal_calls(libraries):
|
||||
loader, core = libraries
|
||||
assert _call_library(loader) == [105, -77, -76, -77]
|
||||
original = loader.read_bytes()
|
||||
names = elf.dyn_symbols(original, True) & elf.dyn_symbols(core.read_bytes(), True)
|
||||
redirected, changed = elf.redirect_exports(original, names)
|
||||
assert set(changed) == names
|
||||
assert names <= elf.dyn_symbols(redirected, False)
|
||||
assert "luaopen_perflib" in elf.dyn_symbols(redirected, True)
|
||||
loader.write_bytes(elf.add_needed(redirected, core.name))
|
||||
assert _call_library(loader) == [105, 42, 43, 42]
|
||||
again, changed = elf.redirect_exports(redirected, names)
|
||||
assert again == redirected and changed == []
|
||||
|
||||
|
||||
def test_patch_keeps_binding_code_and_refuses_unreviewed_builds(tmp_path, libraries, monkeypatch):
|
||||
loader, core = libraries
|
||||
original = loader.read_bytes()
|
||||
digest = hashlib.sha256(original).hexdigest()
|
||||
monkeypatch.setattr(luajit, "XLUA_47BIT_SHA256", frozenset({digest}))
|
||||
monkeypatch.setattr(luajit_pointers, "artifact", lambda abi, name: core.read_bytes())
|
||||
apk = tmp_path / "game.apk"
|
||||
with zipfile.ZipFile(apk, "w") as z:
|
||||
z.writestr("lib/arm64-v8a/libxlua.so", original)
|
||||
analysis = _analysis({"xlua_47bit": True})
|
||||
patch = base.get("frame.luajit_pointers")
|
||||
assert patch.detect(analysis).recommended
|
||||
assert not patch.applies(_analysis())
|
||||
with ApkWorkspace(apk) as ws:
|
||||
ctx = base.ApkContext(ws, analysis, {}, Reporter(), {patch.id: {}})
|
||||
assert patch.apply(ctx)
|
||||
patched = ws.read_lib("libxlua.so")
|
||||
with loader.open("rb") as f:
|
||||
section = ELFFile(f).get_section_by_name(".text")
|
||||
start, size = section["sh_offset"], section["sh_size"]
|
||||
assert patched[start:start + size] == original[start:start + size]
|
||||
kept = {"xlua_gl", "xlua_get_lib_version", "luaopen_perflib", "game_custom_binding"}
|
||||
assert kept <= elf.dyn_symbols(patched, True)
|
||||
assert all(ok for _, ok, _ in patch.validate(ctx))
|
||||
assert not patch.apply(ctx)
|
||||
ws.put(ws.lib(luajit_pointers.RUNTIME), b"old artifact")
|
||||
assert patch.apply(ctx) # repair only a stale engine artifact
|
||||
assert ws.read_lib("libxlua.so") == patched
|
||||
ws.put(ws.lib("libxlua.so"), original + b"different build")
|
||||
with pytest.raises(ValueError, match="not been verified"):
|
||||
patch.apply(ctx)
|
||||
|
||||
|
||||
def test_shipped_engine_is_complete_and_preserves_glref_contract():
|
||||
data = artifact("arm64-v8a", luajit_pointers.RUNTIME)
|
||||
assert elf.soname(data) == luajit_pointers.RUNTIME
|
||||
assert {"luaL_newstate", "lua_pushlightuserdata", "lua_touserdata", "lua_topointer",
|
||||
"luaopen_ffi", "luaL_register", "lua_close"} <= elf.dyn_symbols(data, True)
|
||||
assert "xlua_get_lib_version" not in elf.dyn_symbols(data, True) # game's bindings stay in the original ELF
|
||||
assert b"full-width lightuserdata runtime enabled" in data
|
||||
assert all(va % 16384 == off % 16384 for va, off, _ in elf.load_segments(data))
|
||||
|
||||
|
||||
def test_analysis_recommends_only_the_reviewed_engine(tmp_path, quest_manifest, libraries, monkeypatch):
|
||||
from frameport.analysis.detect import analyze
|
||||
from frameport.recommend.engine import suggest
|
||||
|
||||
loader, _ = libraries
|
||||
data = loader.read_bytes()
|
||||
monkeypatch.setattr(luajit, "XLUA_47BIT_SHA256", frozenset({hashlib.sha256(data).hexdigest()}))
|
||||
apk = tmp_path / "analysis.apk"
|
||||
with zipfile.ZipFile(apk, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", quest_manifest)
|
||||
z.writestr("lib/arm64-v8a/libxlua.so", data)
|
||||
analysis = analyze(apk)
|
||||
assert analysis.extra["xlua_47bit"]
|
||||
assert analysis.extra["xlua_sha256"] == hashlib.sha256(data).hexdigest()
|
||||
assert "frame.luajit_pointers" in suggest(analysis, use_catalog=False).patches
|
||||
monkeypatch.setattr(luajit, "XLUA_47BIT_SHA256", frozenset())
|
||||
analysis = analyze(apk)
|
||||
assert not analysis.extra["xlua_47bit"]
|
||||
assert analysis.extra["xlua_sha256"] == hashlib.sha256(data).hexdigest() # review an unknown build from diagnostics
|
||||
assert "frame.luajit_pointers" not in suggest(analysis, use_catalog=False).patches
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Bulk backend selection, paired-host trust, resumable native batches and cancellation."""
|
||||
import contextlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core.events import Cancelled, Reporter
|
||||
from frameport.frame import native_upload as native
|
||||
from frameport.frame.connection import Frame, FrameTarget
|
||||
from frameport.install import installer
|
||||
|
||||
|
||||
def test_pinned_credentials(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(native, "ssh_dir", lambda: tmp_path)
|
||||
(tmp_path / "id_ed25519").write_text("private")
|
||||
key = SimpleNamespace(get_name=lambda: "ssh-ed25519", get_base64=lambda: "PAIRED_KEY")
|
||||
client = SimpleNamespace(get_transport=lambda: SimpleNamespace(get_remote_server_key=lambda: key))
|
||||
frame = Frame(FrameTarget("paired.example", port=2222), client=client)
|
||||
with native.credentials(frame) as args:
|
||||
known = next(arg.split("=", 1)[1].strip('"') for arg in args if arg.startswith("UserKnownHostsFile="))
|
||||
from pathlib import Path
|
||||
assert Path(known).read_text() == "[paired.example]:2222 ssh-ed25519 PAIRED_KEY\n"
|
||||
assert "StrictHostKeyChecking=yes" in args
|
||||
assert "BatchMode=yes" in args
|
||||
copy = args[args.index("-i") + 1]
|
||||
assert Path(copy).read_text() == "private"
|
||||
assert Path(copy) != tmp_path / "id_ed25519"
|
||||
assert not Path(copy).exists()
|
||||
assert (tmp_path / "id_ed25519").read_text() == "private"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["bad\npath", "bad\rpath", "bad\x00path"])
|
||||
def test_quote_refuses_batch_injection(value):
|
||||
with pytest.raises(ValueError):
|
||||
native.quote(value)
|
||||
|
||||
|
||||
def test_quote_protects_spaces_quotes_globs():
|
||||
assert native.quote('a *?["b') == '"a \\*\\?\\[\\"b"'
|
||||
|
||||
|
||||
def test_size_lookup_does_not_hide_connection_errors():
|
||||
sftp = Mock()
|
||||
sftp.stat.side_effect = ConnectionError("lost connection")
|
||||
with pytest.raises(ConnectionError):
|
||||
native.size_of(sftp, "/file")
|
||||
|
||||
|
||||
def setup_batch(tmp_path, monkeypatch, code=0):
|
||||
paths = [tmp_path / "main.obb", tmp_path / "patch.obb"]
|
||||
for path in paths:
|
||||
path.write_bytes(b"0123456789")
|
||||
items = [(path, path.name, 10) for path in paths]
|
||||
files = {"/incoming/main.obb.part": 4, "/incoming/patch.obb.part": 20}
|
||||
monitor = Mock()
|
||||
monitor.__enter__ = Mock(return_value=monitor)
|
||||
monitor.__exit__ = Mock(return_value=False)
|
||||
def stat(path):
|
||||
if path not in files:
|
||||
raise FileNotFoundError(path)
|
||||
return SimpleNamespace(st_size=files[path])
|
||||
monitor.stat.side_effect = stat
|
||||
monitor.remove.side_effect = lambda path: files.pop(path)
|
||||
frame = SimpleNamespace(sftp=monitor, client=SimpleNamespace(open_sftp=lambda: monitor),
|
||||
target=FrameTarget("paired"), run=Mock(return_value=(0, "", "")))
|
||||
monkeypatch.setattr(native.shutil, "which", lambda _: "sftp")
|
||||
@contextlib.contextmanager
|
||||
def creds(frame):
|
||||
yield ["-o", "StrictHostKeyChecking=yes"]
|
||||
monkeypatch.setattr(native, "credentials", creds)
|
||||
commands, processes = [], []
|
||||
def popen(args, **kwargs):
|
||||
commands.append(kwargs["stdin"].read().decode())
|
||||
for _, rel, size in items:
|
||||
files["/incoming/" + rel + ".part"] = size
|
||||
process = Mock(returncode=code)
|
||||
process.poll.return_value = code
|
||||
processes.append(process)
|
||||
return process
|
||||
monkeypatch.setattr(native.subprocess, "Popen", popen)
|
||||
return frame, items, files, commands, processes
|
||||
|
||||
|
||||
def test_native_batches_resume_reset_oversize_and_verify(tmp_path, monkeypatch):
|
||||
frame, items, files, commands, processes = setup_batch(tmp_path, monkeypatch)
|
||||
assert native.upload(frame, items, "/incoming", Reporter(), 20, 0, 3) == 20
|
||||
assert len(processes) == 2 # never more processes than files
|
||||
assert commands[0].startswith("reput ")
|
||||
assert commands[1].startswith("put ")
|
||||
assert all(".part" in command for command in commands)
|
||||
frame.sftp.remove.assert_called_once_with("/incoming/patch.obb.part")
|
||||
assert frame.run.call_count == 1
|
||||
assert all("mv -f --" in call.args[0] for call in frame.run.call_args_list)
|
||||
frame.sftp.get_channel().settimeout.assert_called_once_with(2)
|
||||
assert all(process.wait.called for process in processes)
|
||||
|
||||
|
||||
def test_native_complete_parts_are_only_renamed(tmp_path, monkeypatch):
|
||||
"""A .part as big as its file (left by a cancelled run) isn't sent again: sftp's reput refuses it."""
|
||||
frame, items, files, commands, processes = setup_batch(tmp_path, monkeypatch)
|
||||
files["/incoming/main.obb.part"] = 10
|
||||
assert native.upload(frame, items, "/incoming", Reporter(), 20, 0, 3) == 20
|
||||
assert len(processes) == 1 and "main.obb" not in commands[0] and commands[0].startswith("put ")
|
||||
assert "main.obb.part" in frame.run.call_args.args[0]
|
||||
files["/incoming/patch.obb.part"] = 10
|
||||
commands.clear()
|
||||
processes.clear()
|
||||
assert native.upload(frame, items, "/incoming", Reporter(), 20, 0, 3) == 20
|
||||
assert not processes and frame.run.call_count == 2
|
||||
|
||||
|
||||
def test_native_failure_does_not_promote_files(tmp_path, monkeypatch):
|
||||
frame, items, files, commands, processes = setup_batch(tmp_path, monkeypatch, code=1)
|
||||
with pytest.raises(OSError, match="OpenSSH SFTP failed"):
|
||||
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 2)
|
||||
assert not frame.run.called
|
||||
assert files["/incoming/main.obb.part"] == 10
|
||||
assert all(process.wait.called for process in processes)
|
||||
|
||||
|
||||
def test_native_success_still_checks_remote_sizes(tmp_path, monkeypatch):
|
||||
frame, items, files, commands, processes = setup_batch(tmp_path, monkeypatch)
|
||||
original = native.subprocess.Popen
|
||||
def truncated(*args, **kwargs):
|
||||
process = original(*args, **kwargs)
|
||||
files["/incoming/main.obb.part"] = 9
|
||||
return process
|
||||
monkeypatch.setattr(native.subprocess, "Popen", truncated)
|
||||
with pytest.raises(OSError, match="unexpected size"):
|
||||
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 2)
|
||||
assert not frame.run.called
|
||||
assert all(process.wait.called for process in processes)
|
||||
|
||||
|
||||
def test_native_cancel_stops_owned_processes(tmp_path, monkeypatch):
|
||||
frame, items, files, commands, processes = setup_batch(tmp_path, monkeypatch)
|
||||
report = Reporter()
|
||||
report.progress = lambda *args, **kwargs: report.cancelled.set()
|
||||
monkeypatch.setattr(native, "stop_process", Mock())
|
||||
with pytest.raises(Cancelled):
|
||||
native.upload(frame, items, "/incoming", report, 20, 0, 2)
|
||||
assert not frame.run.called
|
||||
assert native.stop_process.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cancel", [False, True])
|
||||
def test_production_fallback_and_cancel(tmp_path, monkeypatch, cancel):
|
||||
path = tmp_path / "game.apk"
|
||||
path.write_bytes(b"apk")
|
||||
frame = Frame(FrameTarget("paired"))
|
||||
frame.mkdirs = Mock()
|
||||
frame.put = Mock()
|
||||
report = Reporter()
|
||||
fractions = []
|
||||
report.progress = lambda fraction, *args, **kwargs: fractions.append(fraction)
|
||||
def fail(frame, items, remote, reporter, *args):
|
||||
reporter.progress(0.8, "native")
|
||||
if cancel:
|
||||
raise Cancelled()
|
||||
raise OSError("native unavailable")
|
||||
def fallback(local, remote, progress, **kwargs):
|
||||
progress(0, installer.BIG_FILE)
|
||||
progress(installer.BIG_FILE, installer.BIG_FILE)
|
||||
frame.put.side_effect = fallback
|
||||
monkeypatch.setattr(native, "upload", fail)
|
||||
call = lambda: installer.upload_files(frame, [(path, "game.apk", installer.BIG_FILE)],
|
||||
"/incoming", report, installer.BIG_FILE, workers=1)
|
||||
if cancel:
|
||||
with pytest.raises(Cancelled):
|
||||
call()
|
||||
assert not frame.put.called
|
||||
else:
|
||||
assert call() == installer.BIG_FILE
|
||||
frame.put.assert_called_once()
|
||||
assert fractions == sorted(fractions)
|
||||
@@ -0,0 +1,78 @@
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.apk import axml
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.tools import overport
|
||||
|
||||
NAME_FAILURE = ("java.util.NoSuchElementException: Sequence is empty.\n"
|
||||
"at moe.crx.overport.patching.PatcherContext.appName(PatcherContext.kt:231)")
|
||||
|
||||
|
||||
def manifest(app_label=False):
|
||||
from conftest import build_axml
|
||||
|
||||
return build_axml([
|
||||
("start", "manifest", []), ("start", "application", [("label", "str", "Game")] if app_label else []),
|
||||
("start", "activity", [("label", "str", "Game")]), ("end", "activity"),
|
||||
("end", "application"), ("end", "manifest"),
|
||||
])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("retry_fails", [False, True])
|
||||
def test_overport_retries_missing_app_name_with_a_temporary_apk(monkeypatch, tmp_path, retry_fails):
|
||||
apk = tmp_path / "source.apk"
|
||||
with zipfile.ZipFile(apk, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", manifest())
|
||||
z.writestr("assets/unchanged", b"game data")
|
||||
original = apk.read_bytes()
|
||||
calls = []
|
||||
outdir = tmp_path / "out"
|
||||
monkeypatch.setattr(overport, "_jar", lambda: "overport.jar")
|
||||
|
||||
def run(args):
|
||||
source = Path(next(a.removeprefix("--input=") for a in args if a.startswith("--input=")))
|
||||
calls.append(source)
|
||||
if len(calls) == 1:
|
||||
return subprocess.CompletedProcess(args, 1, "", NAME_FAILURE)
|
||||
with zipfile.ZipFile(source) as z:
|
||||
x = axml.Axml(z.read("AndroidManifest.xml"))
|
||||
app = next(el for el in x.elements() if el.name == "application")
|
||||
assert x.attr_str(app, "label") == "Game"
|
||||
assert z.read("assets/unchanged") == b"game data"
|
||||
if retry_fails:
|
||||
return subprocess.CompletedProcess(args, 1, "", NAME_FAILURE)
|
||||
(outdir / "patched.apk").write_bytes(b"output")
|
||||
return subprocess.CompletedProcess(args, 0, "done", "")
|
||||
|
||||
monkeypatch.setattr(overport.toolchain, "run_java", run)
|
||||
if retry_fails:
|
||||
with pytest.raises(RuntimeError, match="OVRPort failed"):
|
||||
overport.patch(apk, outdir, "patched.apk", ["patch_copy_libraries"], Reporter())
|
||||
else:
|
||||
result = overport.patch(apk, outdir, "patched.apk", ["patch_copy_libraries"], Reporter())
|
||||
assert result == outdir / "patched.apk"
|
||||
assert len(calls) == 2
|
||||
assert calls[0] == apk and calls[1] != apk and not calls[1].exists()
|
||||
assert apk.read_bytes() == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error,app_label", [("some other failure", False), (NAME_FAILURE, True)])
|
||||
def test_overport_does_not_retry_unrelated_failures(monkeypatch, tmp_path, error, app_label):
|
||||
apk = tmp_path / "source.apk"
|
||||
with zipfile.ZipFile(apk, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", manifest(app_label))
|
||||
calls = []
|
||||
monkeypatch.setattr(overport, "_jar", lambda: "overport.jar")
|
||||
|
||||
def run(args):
|
||||
calls.append(args)
|
||||
return subprocess.CompletedProcess(args, 1, "", error)
|
||||
|
||||
monkeypatch.setattr(overport.toolchain, "run_java", run)
|
||||
with pytest.raises(RuntimeError, match="OVRPort failed"):
|
||||
overport.patch(apk, tmp_path / "out", "patched.apk", ["patch_copy_libraries"], Reporter())
|
||||
assert len(calls) == 1
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Bounded independent upload sessions, aggregate progress and cancellation of blocked workers."""
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core import library
|
||||
from frameport.core.events import Cancelled, Reporter
|
||||
from frameport.install import installer
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, expected", [(1, 1), (2, 2), (8, 8), (0, 1), (99, 8),
|
||||
("3", 3), ("bad", 3), (float("inf"), 3)])
|
||||
def test_upload_threads_bounds(value, expected):
|
||||
assert installer.upload_threads(value) == expected
|
||||
|
||||
|
||||
def test_upload_threads_default_and_setting():
|
||||
assert installer.upload_threads() == 3
|
||||
library.set_setting("install.upload_threads", 8)
|
||||
assert installer.upload_threads() == 8
|
||||
|
||||
|
||||
def items(tmp_path, count=4):
|
||||
result = []
|
||||
for n in range(count):
|
||||
path = tmp_path / f"main-{n}.obb"
|
||||
path.write_bytes(b"a" * 10)
|
||||
result.append((path, f"obb/{path.name}", 10))
|
||||
return result
|
||||
|
||||
|
||||
class Control:
|
||||
def __init__(self, action):
|
||||
self.action = action
|
||||
self.sessions, self.calls, self.tars, self.dirs = [], [], [], []
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def mkdirs(self, dirs):
|
||||
self.dirs.extend(dirs)
|
||||
|
||||
def upload_session(self):
|
||||
owner = self
|
||||
class Session:
|
||||
def __init__(self):
|
||||
self.closed = threading.Event()
|
||||
|
||||
def put(self, path, remote, progress, **kwargs):
|
||||
progress(0, 10)
|
||||
owner.action(self, path, progress)
|
||||
with owner.lock:
|
||||
owner.calls.append((remote, kwargs))
|
||||
|
||||
def close(self):
|
||||
self.closed.set()
|
||||
session = Session()
|
||||
with self.lock:
|
||||
self.sessions.append(session)
|
||||
return session
|
||||
|
||||
def put(self, path, remote, progress, **kwargs):
|
||||
self.calls.append((remote, kwargs))
|
||||
progress(0, 10)
|
||||
progress(10, 10)
|
||||
|
||||
def put_tar(self, files, root, progress):
|
||||
self.tars.append([rel for _, rel in files])
|
||||
progress(sum(path.stat().st_size for path, _ in files), files[-1][1])
|
||||
|
||||
def close(self):
|
||||
pytest.fail("the control connection must remain open")
|
||||
|
||||
|
||||
def test_two_workers_and_monotonic_aggregate_progress(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(installer, "BIG_FILE", 5)
|
||||
barrier = threading.Barrier(2)
|
||||
activity = {"active": 0, "peak": 0}
|
||||
lock = threading.Lock()
|
||||
def action(session, path, progress):
|
||||
with lock:
|
||||
activity["active"] += 1
|
||||
activity["peak"] = max(activity["peak"], activity["active"])
|
||||
barrier.wait(timeout=3)
|
||||
progress(5, 10)
|
||||
progress(10, 10)
|
||||
with lock:
|
||||
activity["active"] -= 1
|
||||
frame = Control(action)
|
||||
events = []
|
||||
reporter = Reporter([events.append])
|
||||
assert installer.upload_files(frame, items(tmp_path), "/incoming", reporter, 40, workers=2) == 40
|
||||
assert activity["peak"] == 2
|
||||
assert len(frame.sessions) == 2 and all(session.closed.is_set() for session in frame.sessions)
|
||||
assert sorted(remote for remote, _ in frame.calls) == [f"/incoming/obb/main-{n}.obb" for n in range(4)]
|
||||
assert all(kwargs["mkdir"] is False and callable(kwargs["check_cancel"]) for _, kwargs in frame.calls)
|
||||
fractions = [event.fraction for event in events if event.kind == "progress"]
|
||||
assert fractions == sorted(fractions) and fractions[-1] == 1.0
|
||||
|
||||
|
||||
def test_one_worker_uses_existing_serial_transport(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(installer, "BIG_FILE", 5)
|
||||
frame = Control(lambda *args: pytest.fail("no worker transport needed"))
|
||||
installer.upload_files(frame, items(tmp_path), "/incoming", Reporter(), 40, workers=1)
|
||||
assert not frame.sessions and len(frame.calls) == 4
|
||||
|
||||
|
||||
def test_worker_count_is_limited_to_large_files_and_tar_is_sequential(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(installer, "BIG_FILE", 5)
|
||||
barrier = threading.Barrier(2)
|
||||
def action(session, path, progress):
|
||||
assert not frame.tars
|
||||
barrier.wait(timeout=3)
|
||||
progress(10, 10)
|
||||
frame = Control(action)
|
||||
selected = items(tmp_path, 2)
|
||||
small = tmp_path / "small.txt"
|
||||
small.write_bytes(b"x")
|
||||
selected.append((small, "obb/small.txt", 1))
|
||||
assert installer.upload_files(frame, selected, "/incoming", Reporter(), 21, workers=8) == 21
|
||||
assert len(frame.sessions) == 2
|
||||
assert frame.tars == [["obb/small.txt"]]
|
||||
|
||||
|
||||
def test_cancel_interrupts_blocked_workers_and_stops_queued_files(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(installer, "BIG_FILE", 5)
|
||||
started = threading.Barrier(3)
|
||||
def action(session, path, progress):
|
||||
started.wait(timeout=3)
|
||||
assert session.closed.wait(3), "cancellation must close blocked worker sockets"
|
||||
raise OSError("transport closed")
|
||||
frame, reporter, errors = Control(action), Reporter(), []
|
||||
def run():
|
||||
try:
|
||||
installer.upload_files(frame, items(tmp_path), "/incoming", reporter, 40, workers=2)
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
thread = threading.Thread(target=run)
|
||||
thread.start()
|
||||
started.wait(timeout=3)
|
||||
reporter.cancelled.set()
|
||||
thread.join(timeout=3)
|
||||
assert not thread.is_alive()
|
||||
assert len(errors) == 1 and isinstance(errors[0], Cancelled)
|
||||
assert all(session.closed.is_set() for session in frame.sessions)
|
||||
assert not frame.calls
|
||||
|
||||
|
||||
def test_failure_propagates_and_closes_sessions(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(installer, "BIG_FILE", 5)
|
||||
def fail(session, path, progress):
|
||||
raise OSError("disk full")
|
||||
frame = Control(fail)
|
||||
with pytest.raises(OSError, match="disk full"):
|
||||
installer.upload_files(frame, items(tmp_path), "/incoming", Reporter(), 40, workers=2)
|
||||
assert all(session.closed.is_set() for session in frame.sessions)
|
||||
assert not frame.calls
|
||||
|
||||
|
||||
def test_parallel_upload_setting_options_and_persistence():
|
||||
import flet as ft
|
||||
|
||||
from frameport.ui.views.settings import SettingsView
|
||||
|
||||
app = SimpleNamespace(target=None, frame_state="disconnected")
|
||||
controls = SettingsView(app).installing().controls
|
||||
dropdown = next(control for control in controls if isinstance(control, ft.Dropdown))
|
||||
assert dropdown.value == "3"
|
||||
assert [option.key for option in dropdown.options] == [str(n) for n in range(1, 9)]
|
||||
dropdown.on_select(SimpleNamespace(control=SimpleNamespace(value="8")))
|
||||
assert library.setting("install.upload_threads") == 8
|
||||
|
||||
|
||||
def test_upload_session_checks_paired_headset_key_and_keeps_control(monkeypatch):
|
||||
import paramiko
|
||||
|
||||
from frameport.frame.connection import Frame, FrameTarget
|
||||
|
||||
closed, children = [], []
|
||||
def client(key):
|
||||
return SimpleNamespace(get_transport=lambda: SimpleNamespace(get_remote_server_key=lambda: key),
|
||||
close=lambda: closed.append(key))
|
||||
control = Frame(FrameTarget("frame"))
|
||||
control.client = client("paired")
|
||||
def connect(other):
|
||||
other.client = client("other headset")
|
||||
children.append(other)
|
||||
return other
|
||||
monkeypatch.setattr(Frame, "connect", connect)
|
||||
with pytest.raises(paramiko.SSHException, match="different headset"):
|
||||
control.upload_session()
|
||||
assert closed == ["other headset"]
|
||||
assert control.client.get_transport().get_remote_server_key() == "paired"
|
||||
assert children[0].target is not control.target
|
||||
|
||||
|
||||
def test_closed_worker_does_not_retry_after_cancellation(monkeypatch, tmp_path):
|
||||
from frameport.frame.connection import Frame, FrameTarget
|
||||
|
||||
frame = Frame(FrameTarget("frame"))
|
||||
calls = []
|
||||
cancelled = threading.Event()
|
||||
def put(*args):
|
||||
calls.append("put")
|
||||
cancelled.set()
|
||||
raise OSError("socket closed")
|
||||
def check():
|
||||
if cancelled.is_set():
|
||||
raise Cancelled()
|
||||
monkeypatch.setattr(frame, "_put", put)
|
||||
monkeypatch.setattr(frame, "connect", lambda *args: pytest.fail("must not reconnect a cancelled worker"))
|
||||
with pytest.raises(Cancelled):
|
||||
frame.put(tmp_path / "file", "/incoming/file", check_cancel=check)
|
||||
assert calls == ["put"]
|
||||
@@ -1,5 +1,7 @@
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.analysis.stubgen import build_stub_library
|
||||
from frameport.apk.workspace import ApkWorkspace
|
||||
from frameport.core.events import Reporter
|
||||
@@ -443,6 +445,53 @@ def test_android_app_without_vr_gets_no_vr_translation():
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("info_activity,sdl_clipboard", [(False, False), (True, False), (False, True)])
|
||||
def test_saved_flat_android_recipe_keeps_its_build_mode(monkeypatch, info_activity, sdl_clipboard):
|
||||
"""Saving Plezy as known-good must not start an empty OVRPort conversion (#271)."""
|
||||
from frameport.core import library
|
||||
from frameport.recommend import catalog, engine
|
||||
|
||||
analysis = _analysis(package="org.example.savedflat", engine="Other", xr="?", libs=[],
|
||||
has_info_category=info_activity, is_overport_output=False,
|
||||
extra={"size": 1, "vr_kind": "none", "media_codec": True, "sdl_java": sdl_clipboard})
|
||||
original = engine.suggest(analysis, use_catalog=False)
|
||||
original.status, original.notes = "issues", "Some videos have color artifacts."
|
||||
game = {"package": analysis.package, "title": "Flat app", "analysis": analysis.to_dict(),
|
||||
"recipe": library.recipe_to_dict(original)}
|
||||
catalog.save_user_entry(catalog.entry_from_library(game))
|
||||
restored = engine.suggest(analysis)
|
||||
assert restored.source == "catalog (user)"
|
||||
assert not restored.overport and restored.as_is == original.as_is
|
||||
assert restored.patches == original.patches
|
||||
assert restored.status == "issues" and original.notes in restored.notes
|
||||
assert "frame.hw_video_decode" in restored.patches and not engine.warnings(restored)
|
||||
|
||||
# Existing affected entries recover when the next app update re-derives their saved recipe.
|
||||
broken = library.recipe_to_dict(original)
|
||||
broken.update(as_is=False, overport=True, source="catalog (user)")
|
||||
data = {"settings": {"recipes.app_version": "0.0.0"},
|
||||
"games": {analysis.package: {**game, "recipe": broken}}}
|
||||
monkeypatch.setattr(library, "REFRESH_ON_UPDATE", True)
|
||||
assert library._follow_catalog(data)
|
||||
repaired = library.recipe_from_dict(data["games"][analysis.package]["recipe"])
|
||||
assert not repaired.overport and repaired.as_is == original.as_is
|
||||
|
||||
|
||||
def test_saved_already_patched_android_recipe_keeps_install_as_is():
|
||||
from frameport.core import library
|
||||
from frameport.recommend import catalog, engine
|
||||
|
||||
analysis = _analysis(package="org.example.savedpatched", has_info_category=False,
|
||||
extra={"size": 1, "frame_patched": True})
|
||||
original = engine.suggest(analysis, use_catalog=False)
|
||||
assert original.as_is
|
||||
game = {"package": analysis.package, "title": "Patched app", "analysis": analysis.to_dict(),
|
||||
"recipe": library.recipe_to_dict(original)}
|
||||
catalog.save_user_entry(catalog.entry_from_library(game))
|
||||
restored = engine.suggest(analysis)
|
||||
assert restored.as_is
|
||||
|
||||
|
||||
def test_patch_registry_is_complete_for_every_thread(monkeypatch):
|
||||
"""A lookup from a second thread while the first is still importing the patch modules waits for them."""
|
||||
import threading
|
||||
@@ -776,6 +825,38 @@ def test_tbxr_vendor_on_lambda1vr():
|
||||
assert count == 1 and b"\0alve\0" in fixed and len(fixed) == len(xash)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gl4es", [True, False])
|
||||
def test_tbxr_shim_leaves_desktop_draws_to_gl4es(tmp_path, quest_manifest, gl4es):
|
||||
"""#191: the same shim must preserve GL4ES dispatch without removing other games' GLES hooks."""
|
||||
from frameport.analysis import elf
|
||||
from frameport.patches.frame import artifact
|
||||
from frameport.patches.frame.tbxr_vendor import GL4ES_DRAW_EXPORTS
|
||||
|
||||
original = artifact("arm64-v8a", "libglshim.so")
|
||||
patch = base.get("frame.tbxr_vendor")
|
||||
a = _analysis(libs=["libxash.so"], extra={"tbxr_libs": ["libxash.so"]})
|
||||
with ApkWorkspace(_apk(tmp_path, quest_manifest)) as ws:
|
||||
ws.put(ws.lib("libglshim.so"), original)
|
||||
if gl4es:
|
||||
ws.put(ws.lib("libgl4es.so"), build_stub_library(["glDrawElements"], soname="libgl4es.so"))
|
||||
ctx = base.ApkContext(ws, a, {}, Reporter(), {patch.id: {}})
|
||||
if gl4es:
|
||||
assert patch.validate(ctx)[0][1] is False
|
||||
assert patch.apply(ctx)
|
||||
shim = ws.read_lib("libglshim.so")
|
||||
_, visible = elf.hide_exports(shim, GL4ES_DRAW_EXPORTS)
|
||||
assert bool(visible) is not gl4es
|
||||
if gl4es:
|
||||
assert patch.validate(ctx)[0][1] is True
|
||||
# Functions remain callable inside the shim; its GL queries and framebuffer fix stay intact.
|
||||
assert elf.find_symbols(shim, GL4ES_DRAW_EXPORTS) == elf.find_symbols(original, GL4ES_DRAW_EXPORTS)
|
||||
assert elf.find_symbols(shim, ["eglGetProcAddress", "glGetString", "glGetIntegerv"]) == \
|
||||
elf.find_symbols(original, ["eglGetProcAddress", "glGetString", "glGetIntegerv"])
|
||||
else:
|
||||
assert shim == original
|
||||
assert not patch.apply(ctx)
|
||||
|
||||
|
||||
def test_unity_user_presence_points_the_xr_plugin_at_the_shim(tmp_path, quest_manifest):
|
||||
"""BONELAB: OVRPlugin reported the worn headset as not worn and the Marrow rig froze (no tracking, no controls)."""
|
||||
from frameport.analysis import elf
|
||||
|
||||
@@ -17,6 +17,20 @@ LOG_BAD = ("""
|
||||
""")
|
||||
|
||||
|
||||
def test_lua_pointer_error_and_cleanup_stack_are_distinguished():
|
||||
pointer = triage("PANIC: bad light userdata pointer", "EXITED")
|
||||
finding = next(f for f in pointer.findings if f.id == "lua-lightuserdata-pointer")
|
||||
assert finding.suggest == ["frame.luajit_pointers"] and finding.severity == "fatal"
|
||||
cleanup = ("FORTIFY: pthread_mutex_lock called on a destroyed mutex\n"
|
||||
"libc.so (__cxa_finalize+148)\nlibc.so (exit+24)\n"
|
||||
"libxlua.so (lj_err_throw+428)\nFatal signal 6 (SIGABRT)\n")
|
||||
result = triage(cleanup, "EXITED")
|
||||
finding = next(f for f in result.findings if f.id == "lua-startup-exit")
|
||||
assert finding.suggest == [] # unhandled Lua errors have other causes too
|
||||
assert "lua-lightuserdata-pointer" not in {f.id for f in result.findings}
|
||||
assert "lua-startup-exit" not in {f.id for f in triage("Unknown Platform\n" + LOG_OK, "RUNNING").findings}
|
||||
|
||||
|
||||
def test_healthy_log():
|
||||
r = triage(LOG_OK, "RUNNING", "com.example.game")
|
||||
assert r.verdict == "pass"
|
||||
@@ -119,6 +133,20 @@ def test_crash_logcat_signatures():
|
||||
assert [(f.id, f.suggest) for f in r.findings] == [("swapchain-size-abort", ["frame.swapchain_limit"])]
|
||||
|
||||
|
||||
def test_tbxr_fdm_crash_is_identified_as_vr_teardown():
|
||||
log = "F libc: Fatal signal 11 (SIGSEGV), fault addr 0x0 in AppThreadFunction\n"
|
||||
crash = ("#00 libVkLayer_VALVE_fdm_injection.so (fdm_injection_xrDestroySwapchain+136)\n"
|
||||
"#01 libopenxr_loader_generic.so (xrDestroySwapchain+2600)\n"
|
||||
"#03 libxash.so (ovrRenderer_Destroy+44)\n"
|
||||
"#04 libxash.so (TBXR_LeaveVR+76)\n")
|
||||
r = triage(log, "EXITED", "com.drbeef.lambda1vr", crash=crash)
|
||||
assert [f.id for f in r.findings] == ["tbxr-fdm-shutdown-crash"]
|
||||
assert r.suggestions() == [] # no unverified graphics or foveation fix
|
||||
assert [f.id for f in triage(log, "EXITED", crash=crash.replace("TBXR_LeaveVR", "OtherCall")).findings] == \
|
||||
["native-crash"]
|
||||
assert not triage(crash, "RUNNING").findings # symbol names alone are not a crash
|
||||
|
||||
|
||||
def test_ovrplugin_extension_probe_is_not_a_scene_game():
|
||||
probe = LOG_OK + "09-28 17:39:02.200 1147 1174 I OVRPlugin: Unavailable OpenXR extension: XR_FB_scene\n"
|
||||
assert "scene-missing" not in {f.id for f in triage(probe, "RUNNING", "com.example.game").findings}
|
||||
|
||||