mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-21 06:01:35 +02:00
Compare commits
33
Commits
v1.2.1
...
quest-buttons
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f2b009ee1 | ||
|
|
4ea677b40e | ||
|
|
6373ae8870 | ||
|
|
faa388600b | ||
|
|
0b9d8c6dde | ||
|
|
073adcc75b | ||
|
|
6c63105b13 | ||
|
|
a121f72f23 | ||
|
|
ba010139b6 | ||
|
|
0f418e5539 | ||
|
|
360658e9b5 | ||
|
|
48ab03a637 | ||
|
|
39443ef2ae | ||
|
|
b0f2a86a01 | ||
|
|
3f22994633 | ||
|
|
25e282d620 | ||
|
|
9106d3f090 | ||
|
|
39fefd225f | ||
|
|
282f0e33c3 | ||
|
|
8381dacdaa | ||
|
|
fc5908618c | ||
|
|
d7f0d51472 | ||
|
|
55c6da7010 | ||
|
|
f996e5e7d9 | ||
|
|
1e1c435191 | ||
|
|
33942aa509 | ||
|
|
dd7f5d67c7 | ||
|
|
7d4e20844f | ||
|
|
fc8443fc56 | ||
|
|
3c43b0fb5d | ||
|
|
8e8b3c819c | ||
|
|
434e614d6c | ||
|
|
ef6edfc73e |
No files matched your search
@@ -553,6 +553,7 @@ Output is deterministic (same input + runtime → same bytes), which is what mak
|
||||
- VrApi-direct engines (CryEngine Climb 2, POTW) need the VrApi bridge; the bridge drops whole frames on unknown layer
|
||||
types (→ black screen with audio).
|
||||
|
||||
**Quest controller mode:** adapter `quest_buttons` (0/1/2, per game + library `input.quest_buttons`) suggests Frame Controller bindings translated from the game's Touch set (`suggest_frame`, mapping docs/input/quest_buttons.json = single source for C table, tests, diagram `scripts/controller_diagram.py`); runtime accepts them headless. → docs/DEVICE_NOTES.md#quest-controller-mode-quest_buttons-2026-10-17
|
||||
**Steam Frame controller models:** adapter setting `controller_models` (`native/adapter/render_model.c`, emulates XR_FB_render_model) + agent `install_controller_models` (Frame's SteamVR models → glb at finalize); no catalog game uses runtime models; Valve's models are never committed or copied off the Frame. → docs/DEVICE_NOTES.md#steam-frame-controller-models
|
||||
**overport's dispatcher (`libopenxr_loader.so`) only forwards functions in its own table** (`overportOXR: Unknown
|
||||
proc addr: …`), so adapter emulations of functions it doesn't know are unreachable. For XR_FB_render_model,
|
||||
@@ -761,3 +762,15 @@ the unresolved eye distortion, and testing the release bundles on real Windows/m
|
||||
(regenerates docs/PATCHES.md + site/src/data/patches.json, the site's id → anchor map; a test runs `--check`).
|
||||
- Known-good backups of every working APK and the signing keys: `PATCHED/_known-good-2026-09-28/` (don't delete).
|
||||
- Your development Frame: `frameport frame info` (remembered in `<user data>/frames.json`); SSH as `steamos@<frame>`.
|
||||
|
||||
|
||||
Steam library scheduling (#305, agent v99): `install.steam_library` = now/later/off; agent `library_mode` persists it.
|
||||
Automatic restarts also hold on a fresh local `frameport-app/gui.alive` (10 s; existing 12 h cap).
|
||||
Later journals changes and installs steam.service ExecStartPre/ExecStartPost hooks to reapply after Steam's exit
|
||||
writes and verify before clearing; preserve this ordering and avoid locking across systemctl's startup hooks.
|
||||
Off Play (also pending Later Play) uses detached systemd-run launch.sh, with no shortcut/devkit registration;
|
||||
Steam's per-game controller/overlay/Exit game integration is lost. Restart Steam now explicitly bypasses holds,
|
||||
asks before ending a running game, and closes local FramePort/Desktop Mode. Purge removes the startup hook.
|
||||
Hook hardening: both lines `-` + `/usr/bin/timeout -k 5 20`; ExecStartPost only `systemd-run --no-block`s the
|
||||
verify (unit frameport-library-verify) so Steam's start never waits; drop-in removed once mode != later and nothing
|
||||
pending (`drop_library_hooks_if_idle`). Steam = static user unit steam.service (Type=simple, Restart=always, 90 s).
|
||||
@@ -124,6 +124,7 @@ What FramePort adds: [Architecture](docs/ARCHITECTURE.md).
|
||||
| [Compatibility](docs/COMPATIBILITY.md) | What runs and how well |
|
||||
| [Tested games](docs/GAMES.md) | Tested games and how well they run |
|
||||
| [FAQ](docs/FAQ.md) | Game folders and common questions |
|
||||
| [Controller buttons](docs/CONTROLLERS.md) | Quest Touch buttons on the Frame: SteamVR default, Mirror and Quest pair |
|
||||
| [Porting playbook](docs/PLAYBOOK.md) | Symptoms and fixes per game |
|
||||
| [Steam Frame runtime reference](docs/FRAME_RUNTIME.md) | Facts about the Frame's runtime |
|
||||
| [Architecture](docs/ARCHITECTURE.md) | How the code is organized |
|
||||
|
||||
+317
-12
@@ -31,6 +31,7 @@ import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -45,7 +46,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 96
|
||||
AGENT_VERSION = 99
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -1112,6 +1113,216 @@ def remove_shortcut(vdf_path, exe):
|
||||
STATUS_FILE = os.path.join(HOME, ".local/share/frameport/shortcuts-status.json")
|
||||
|
||||
|
||||
LIBRARY_MODE_FILE = os.path.join(HOME, ".local/share/frameport/library-mode.json")
|
||||
PENDING_FILE = os.path.join(HOME, ".local/share/frameport/library-pending.json")
|
||||
RESTART_REQUEST_FILE = os.path.join(HOME, ".local/share/frameport/restart-request.json")
|
||||
|
||||
|
||||
def library_mode(args=None):
|
||||
mode = (args or {}).get("mode")
|
||||
if mode is None:
|
||||
try:
|
||||
mode = json.load(open(LIBRARY_MODE_FILE))["mode"]
|
||||
except (OSError, ValueError, KeyError):
|
||||
mode = "now"
|
||||
if mode not in ("now", "later", "off"):
|
||||
raise AgentError("Steam library mode must be now, later or off")
|
||||
return mode
|
||||
|
||||
|
||||
def cmd_library_mode(args):
|
||||
mode = library_mode(args)
|
||||
_write_json(LIBRARY_MODE_FILE, {"mode": mode})
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
with open(STATUS_FILE + ".lock", "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
if mode == "off":
|
||||
pending = pending_library()
|
||||
pending["packages"] = []
|
||||
_write_json(PENDING_FILE, pending)
|
||||
drop_library_hooks_if_idle()
|
||||
return {"mode": mode}
|
||||
|
||||
|
||||
def pending_library():
|
||||
try:
|
||||
pending = json.load(open(PENDING_FILE))
|
||||
except (OSError, ValueError):
|
||||
pending = {}
|
||||
if not isinstance(pending, dict):
|
||||
pending = {}
|
||||
pending["packages"] = [p for p in pending.get("packages") or [] if isinstance(p, str)]
|
||||
pending["remove"] = [r for r in pending.get("remove") or [] if isinstance(r, dict)]
|
||||
return pending
|
||||
|
||||
|
||||
def queue_library(packages, remove):
|
||||
# Serialize with the worker and Steam's startup hooks; persist before touching the live VDF.
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
with open(STATUS_FILE + ".lock", "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
return _queue_library(packages, remove)
|
||||
|
||||
|
||||
def _queue_library(packages, remove):
|
||||
"""Caller holds the shortcut lock."""
|
||||
pending = pending_library()
|
||||
pending["packages"] = sorted(set(pending["packages"]) | set(packages))
|
||||
pending["remove"].extend(remove)
|
||||
_write_json(PENDING_FILE, pending)
|
||||
result = {"state": "pending", **pending, "added": [], "errors": []}
|
||||
for user in library_users():
|
||||
update_library(user, packages, result)
|
||||
_write_json(STATUS_FILE, result)
|
||||
return result
|
||||
|
||||
|
||||
LIBRARY_HOOK_DIR = os.path.join(HOME, ".config/systemd/user/steam.service.d")
|
||||
LIBRARY_HOOK = os.path.join(LIBRARY_HOOK_DIR, "frameport-library.conf")
|
||||
TIMEOUT_BIN = "/usr/bin/timeout" # coreutils; present on SteamOS (checked on the Frame, 2026-10-16)
|
||||
SYSTEMD_RUN_BIN = "/usr/bin/systemd-run"
|
||||
LIBRARY_HOOK_SECONDS = 20 # steam.service's start timeout is 90 s: a stuck hook must never get near it
|
||||
LIBRARY_VERIFY_UNIT = "frameport-library-verify"
|
||||
|
||||
|
||||
def _unit_quote(value):
|
||||
"""One argument of a systemd Exec= line: quoted, with the specifier (%) and variable ($) characters escaped."""
|
||||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$") + '"'
|
||||
|
||||
|
||||
def library_hooks_text(python=None, agent=None, timeout_bin=None):
|
||||
"""steam.service drop-in for the `later` mode. Both hooks start with '-' (a failure never fails Steam's start)
|
||||
and run under timeout(1) (systemd's '-' ignores exit codes, not a hook that hangs until the unit's start
|
||||
timeout), so a missing interpreter/agent (exit 127/2) or a hang can't keep Steam from starting. The
|
||||
pre-start hook rewrites shortcuts.vdf before Steam reads it; the post-start check (it waits for Steam and may
|
||||
register devkit entries) runs as its own transient unit, so Steam's start never waits for it."""
|
||||
python = python or sys.executable
|
||||
agent = agent or os.path.abspath(__file__)
|
||||
timeout_bin = TIMEOUT_BIN if timeout_bin is None else timeout_bin
|
||||
wrap = f"{timeout_bin} -k 5 {LIBRARY_HOOK_SECONDS} " if timeout_bin else ""
|
||||
command = _unit_quote(python) + " " + _unit_quote(agent)
|
||||
return ("# Written by FramePort (Steam library: \"later\"); removed again when nothing waits for Steam's start.\n"
|
||||
"[Service]\n"
|
||||
f"ExecStartPre=-{wrap}{command} library_pending\n"
|
||||
f"ExecStartPost=-{wrap}{SYSTEMD_RUN_BIN} --user --collect --quiet --no-block "
|
||||
f"--unit={LIBRARY_VERIFY_UNIT} --property=RuntimeMaxSec=300 {command} library_pending_verify\n")
|
||||
|
||||
|
||||
def ensure_library_hooks():
|
||||
"""SteamOS runs Steam as the user unit steam.service (Type=simple, Restart=always, TimeoutStartSec 90 s).
|
||||
Apply AFTER its exit writes and BEFORE its next start; keep the journal until the post-start verification
|
||||
succeeds, including across reboots/crashes."""
|
||||
text = library_hooks_text(timeout_bin=TIMEOUT_BIN if os.access(TIMEOUT_BIN, os.X_OK) else "")
|
||||
try:
|
||||
if open(LIBRARY_HOOK).read() == text:
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
os.makedirs(LIBRARY_HOOK_DIR, exist_ok=True)
|
||||
with open(LIBRARY_HOOK + ".tmp", "w") as f:
|
||||
f.write(text)
|
||||
os.replace(LIBRARY_HOOK + ".tmp", LIBRARY_HOOK)
|
||||
if run(["systemctl", "--user", "daemon-reload"]).returncode:
|
||||
raise AgentError("Could not enable Steam library startup hooks")
|
||||
|
||||
|
||||
def remove_library_hooks():
|
||||
"""Remove the steam.service drop-in (mode switched away from `later` with nothing pending, purge).
|
||||
Returns whether there was one."""
|
||||
if not os.path.exists(LIBRARY_HOOK):
|
||||
return False
|
||||
try:
|
||||
os.remove(LIBRARY_HOOK)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
try:
|
||||
os.rmdir(LIBRARY_HOOK_DIR) # only when empty: the user's own drop-ins (extra_args.conf) stay
|
||||
except OSError:
|
||||
pass
|
||||
run(["systemctl", "--user", "daemon-reload"])
|
||||
return True
|
||||
|
||||
|
||||
def drop_library_hooks_if_idle():
|
||||
"""The drop-in only stays while the `later` mode is on or something still waits for Steam's next start."""
|
||||
pending = pending_library()
|
||||
if library_mode() != "later" and not pending["packages"] and not pending["remove"]:
|
||||
return remove_library_hooks()
|
||||
return False
|
||||
|
||||
|
||||
def cmd_library_pending(args):
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
with open(STATUS_FILE + ".lock", "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
return {"worker_active": True} # that worker writes before start and verifies afterward
|
||||
pending = pending_library()
|
||||
result = {"added": [], "errors": []}
|
||||
if not pending["packages"] and not pending["remove"]:
|
||||
return result # every Steam start runs this: leave shortcuts.vdf alone when nothing waits
|
||||
# An uninstall queued after an install wins; never resurrect a deleted deployment.
|
||||
packages = [p for p in pending["packages"] if deployment(p)]
|
||||
for user in steam_users():
|
||||
remove_from_library(user, pending["remove"], result)
|
||||
for user in library_users():
|
||||
update_library(user, packages, result)
|
||||
pending["errors"] = result["errors"]
|
||||
_write_json(PENDING_FILE, pending)
|
||||
return result
|
||||
|
||||
|
||||
def cmd_library_pending_verify(args):
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
with open(STATUS_FILE + ".lock", "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
return {"worker_active": True} # that worker writes before start and verifies afterward
|
||||
pending = pending_library()
|
||||
packages = [p for p in pending["packages"] if deployment(p)]
|
||||
result = {"state": "done", "added": [], "errors": list(pending.get("errors", []))}
|
||||
if not packages and not pending["remove"]:
|
||||
if pending["packages"]: # only uninstalled games were left
|
||||
_write_json(PENDING_FILE, {"packages": [], "remove": []})
|
||||
drop_library_hooks_if_idle()
|
||||
return result
|
||||
for pkg in shortcuts_lost(library_users(), packages):
|
||||
add_devkit_entry(pkg, result)
|
||||
if not result["errors"]:
|
||||
_write_json(PENDING_FILE, {"packages": [], "remove": []})
|
||||
drop_library_hooks_if_idle()
|
||||
_write_json(STATUS_FILE, result)
|
||||
return result
|
||||
|
||||
|
||||
def frameport_running():
|
||||
"""A local GUI heartbeat is refreshed every second; crashed/closed windows expire in ten seconds."""
|
||||
data = os.environ.get("XDG_DATA_HOME", os.path.join(HOME, ".local/share"))
|
||||
try:
|
||||
age = time.time() - os.path.getmtime(os.path.join(data, "frameport-app/gui.alive"))
|
||||
return 0 <= age < 10
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def restart_hold():
|
||||
return "game" if game_running() else "desktop" if desktop_mode() else "frameport" if frameport_running() else None
|
||||
|
||||
|
||||
def cmd_restart_steam(args):
|
||||
if game_running() and not args.get("confirm"):
|
||||
return {"confirmation_required": True}
|
||||
status = cmd_shortcut_status({})
|
||||
if status.get("state") == "waiting" and time.time() - float(status.get("started") or 0) < 60:
|
||||
# Wake the worker already holding the lock; a second worker would wait behind the local GUI hold.
|
||||
_write_json(RESTART_REQUEST_FILE, {"requested": time.time()})
|
||||
return {"started": True, "waiting_worker": True}
|
||||
return cmd_shortcuts({"packages": pending_library()["packages"], "restart_now": True,
|
||||
"remove": pending_library()["remove"], "mode": "now"})
|
||||
|
||||
|
||||
def cmd_shortcuts(args):
|
||||
"""Add library entries (+ grid artwork) for installed games. Steam must be closed while shortcuts.vdf is
|
||||
rewritten, so the work runs in a detached systemd unit (terminals/SSH sessions started from Steam live in
|
||||
@@ -1119,18 +1330,27 @@ def cmd_shortcuts(args):
|
||||
packages = [check_pkg(p) for p in args.get("packages", [])]
|
||||
remove = [r for r in args.get("remove", [])
|
||||
if isinstance(r, dict) and str(r.get("exe", "")).startswith('"' + ANCHORS)]
|
||||
if not packages and not remove:
|
||||
mode = library_mode(args)
|
||||
if mode == "off" and not remove:
|
||||
return {"state": "done", "disabled": True, "added": [], "errors": []}
|
||||
if mode == "off":
|
||||
packages = []
|
||||
if mode == "later":
|
||||
ensure_library_hooks()
|
||||
return queue_library(packages, remove)
|
||||
if not packages and not remove and not args.get("restart_now"):
|
||||
return {"started": False}
|
||||
current = cmd_shortcut_status({})
|
||||
alive = time.time() - float(current.get("started") or 0) < 60 # a waiting worker rewrites its status every 10 s
|
||||
if current.get("state") == "waiting" and alive and set(packages) <= set(current.get("packages") or []) \
|
||||
and not remove:
|
||||
if (not args.get("restart_now") and current.get("state") == "waiting" and alive
|
||||
and set(packages) <= set(current.get("packages") or []) and not remove):
|
||||
return {"started": False, "waiting": True} # the update for these games already waits (Gaming Mode / game)
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
with open(STATUS_FILE, "w") as f:
|
||||
json.dump({"state": "running", "packages": packages, "started": time.time()}, f)
|
||||
unit = f"frameport-shortcuts-{int(time.time())}"
|
||||
payload = json.dumps({"packages": packages, "remove": remove, "restart": args.get("restart", True)})
|
||||
payload = json.dumps({"packages": packages, "remove": remove, "restart": args.get("restart", True),
|
||||
"restart_now": bool(args.get("restart_now"))})
|
||||
run(["systemd-run", "--user", "--collect", "--quiet", f"--unit={unit}", "--setenv=HOME=" + HOME,
|
||||
sys.executable, os.path.abspath(__file__), "_shortcuts_worker", payload])
|
||||
return {"started": True, "unit": unit}
|
||||
@@ -1207,7 +1427,14 @@ def shortcuts_worker(payload):
|
||||
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
with open(STATUS_FILE + ".lock", "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
if json.loads(payload).get("restart_now"):
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
_write_json(RESTART_REQUEST_FILE, {"requested": time.time()})
|
||||
return # wake an existing hold, rather than queueing another restart behind it
|
||||
else:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
_shortcuts_worker(payload)
|
||||
|
||||
|
||||
@@ -1231,7 +1458,7 @@ def _shortcuts_worker(payload):
|
||||
copy_grid_art(pkg, appid)
|
||||
args["packages"] = [p for p in args["packages"] if p not in devkit]
|
||||
result["devkit"] = devkit
|
||||
if not args.get("remove") and not library_changes(users, args["packages"]):
|
||||
if not args.get("restart_now") and not args.get("remove") and not library_changes(users, args["packages"]):
|
||||
# nothing to change in shortcuts.vdf (e.g. a reinstall): only the artwork, no Steam restart
|
||||
for user in users:
|
||||
update_library(user, args["packages"], result, shortcuts=False)
|
||||
@@ -1241,8 +1468,25 @@ def _shortcuts_worker(payload):
|
||||
json.dump(result, f)
|
||||
return
|
||||
deadline = time.time() + 12 * 3600
|
||||
while time.time() < deadline: # restarting Steam would end the game being played, or Desktop Mode
|
||||
reason = "game" if game_running() else "desktop" if desktop_mode() else None
|
||||
while not args.get("restart_now") and time.time() < deadline:
|
||||
try:
|
||||
requested = json.load(open(RESTART_REQUEST_FILE))["requested"]
|
||||
if 0 <= time.time() - requested < 60:
|
||||
os.remove(RESTART_REQUEST_FILE)
|
||||
break
|
||||
except (OSError, ValueError, KeyError):
|
||||
pass
|
||||
mode = library_mode()
|
||||
if mode == "later":
|
||||
ensure_library_hooks()
|
||||
_queue_library(args["packages"], args.get("remove", []))
|
||||
return
|
||||
if mode == "off" and not args.get("remove"):
|
||||
_write_json(STATUS_FILE, {"state": "done", "disabled": True, "added": [], "errors": []})
|
||||
return
|
||||
if mode == "off":
|
||||
args["packages"] = []
|
||||
reason = restart_hold()
|
||||
if not reason:
|
||||
break
|
||||
with open(STATUS_FILE, "w") as f:
|
||||
@@ -1253,6 +1497,9 @@ def _shortcuts_worker(payload):
|
||||
service = stop_steam()
|
||||
except AgentError:
|
||||
raise AgentError("Steam did not close; library not modified") from None
|
||||
pending = pending_library()
|
||||
args["packages"] = sorted(set(args["packages"]) | {p for p in pending["packages"] if deployment(p)})
|
||||
args.setdefault("remove", []).extend(pending["remove"])
|
||||
for user in steam_users(): # uninstalled games leave every account's library
|
||||
remove_from_library(user, args.get("remove", []), result)
|
||||
for user in users:
|
||||
@@ -1273,6 +1520,13 @@ def _shortcuts_worker(payload):
|
||||
result["state"] = "failed"
|
||||
result["errors"].append(str(exc))
|
||||
run(["systemctl", "--user", "start", "steam.service"])
|
||||
if result["state"] == "done" and not result["errors"]:
|
||||
_write_json(PENDING_FILE, {"packages": [], "remove": []})
|
||||
drop_library_hooks_if_idle()
|
||||
try:
|
||||
os.remove(RESTART_REQUEST_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
result["finished"] = time.time()
|
||||
with open(STATUS_FILE, "w") as f:
|
||||
json.dump(result, f)
|
||||
@@ -1418,6 +1672,7 @@ def _drive(path, install_dir, m, internal=False, steam_library=False):
|
||||
return {"id": "internal" if internal else path, "path": path, "install_dir": install_dir,
|
||||
"label": "Internal storage" if internal else (os.path.basename(path.rstrip("/")) or path),
|
||||
"fstype": fstype, "device": m[0] if m else "", "internal": internal,
|
||||
"read_only": bool(m and "ro" in m[3]),
|
||||
"removable": not internal and _inside(path, MEDIA_ROOT), "steam_library": steam_library,
|
||||
"free_bytes": free, "total_bytes": total, "usable": not reason, "reason": reason}
|
||||
|
||||
@@ -1784,6 +2039,18 @@ def cmd_launch(args):
|
||||
check_base_present(dep["base"], dep.get("title") or pkg)
|
||||
if run(["pgrep", "-x", "steam"]).returncode != 0:
|
||||
raise AgentError("Steam isn't running on the Frame")
|
||||
mode = library_mode(args)
|
||||
if mode == "off" or (mode == "later" and pkg in pending_library()["packages"]):
|
||||
# Detached from SSH/FramePort. The launcher keeps Lepton/Proton's environment; SteamVR must be running.
|
||||
unit = f"frameport-play-{dep['appid']}"
|
||||
if game_is_running(dep) or run(["systemctl", "--user", "is-active", "--quiet", unit]).returncode == 0:
|
||||
raise AgentError("the game is already running") # (or its launcher is still starting it)
|
||||
run(["systemctl", "--user", "reset-failed", unit]) # a failed earlier start would block the unit name
|
||||
p = run(["systemd-run", "--user", "--collect", "--quiet", f"--unit={unit}",
|
||||
os.path.join(ANCHORS, pkg, "launch.sh")])
|
||||
if p.returncode:
|
||||
raise AgentError("could not start the launcher: " + p.stderr[-300:])
|
||||
return {"package": pkg, "title": dep.get("title"), "via": "direct"}
|
||||
devkit = devkit_gameid(pkg)
|
||||
appid = devkit_appid(devkit) if devkit else None # this Frame needed the fallback entry before: use it
|
||||
if devkit and not appid:
|
||||
@@ -3330,7 +3597,7 @@ def set_gamepad_line(text, want):
|
||||
# BACK, which would close many apps): Steam's virtual pad reports Valve's ids, so Android would fall back to Generic.kl
|
||||
# (triggers on Z/RZ, right stick on RX/RY: games then mix up the right stick and the triggers). The D-pad keys
|
||||
# (BTN_DPAD_*, as AOSP's Generic.kl) cover a pad that sends its D-pad as buttons rather than the hat axes: the Frame's
|
||||
# controllers have no D-pad, so it only comes from a Steam Input layout (e.g. a stick set to D-pad, GitHub #162).
|
||||
# left controller has a D-pad; which inputs reach a 2D app still depends on its Steam Input layout (GitHub #162).
|
||||
GAMEPAD_KL = """# Steam Input virtual gamepad (Xbox 360 layout). Written by FramePort (device.steam_gamepad).
|
||||
key 304 BUTTON_A
|
||||
key 305 BUTTON_B
|
||||
@@ -4183,6 +4450,7 @@ def configure_install(args, prep, moved):
|
||||
"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"]
|
||||
dep["foveation_env"] = {key: (args.get("env") or {}).get(key) for key in FOVEATION_ENV_KEYS}
|
||||
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.
|
||||
@@ -5447,6 +5715,31 @@ def cmd_keep_awake(args):
|
||||
return {"awake": False}
|
||||
|
||||
|
||||
FOVEATION_ENV_KEYS = {"FDM_DEBUG", "ENABLE_VULKAN_FDM_INJECTION_LAYER",
|
||||
"LEPTON_ENV_DISABLE_VULKAN_FDM_INJECTION_LAYER", "VK_INSTANCE_LAYERS", "FOVE_INNER", "FOVE_OUTER",
|
||||
"LEPTON_ENV_FRAMEPORT_FOVEATION_TEST", "LEPTON_ENV_FRAMEPORT_CLIMB_STEREO_OFFSETS",
|
||||
"LEPTON_ENV_FRAMEPORT_VRAPI_FDM_ISOLATE_MONO", "LEPTON_ENV_FRAMEPORT_VRAPI_KEEP_FOCUSED_IMAGE"}
|
||||
|
||||
|
||||
def update_foveation_launcher(pkg, dep, env):
|
||||
if not isinstance(env, dict) or set(env) - FOVEATION_ENV_KEYS or any(
|
||||
v is not None and (not isinstance(v, str) or "\n" in v or "\r" in v) for v in env.values()):
|
||||
raise AgentError("invalid foveation environment")
|
||||
path = os.path.join(ANCHORS, pkg, "launch.sh")
|
||||
text = open(path).read()
|
||||
# Replace only managed exports; preserve all unrelated launcher customizations.
|
||||
text = "".join(line for line in text.splitlines(keepends=True)
|
||||
if not any(line.startswith("export " + key + "=") for key in env))
|
||||
lines = "".join(f"export {key}={shlex.quote(value)}\n" for key, value in env.items() if value is not None)
|
||||
text = text.replace("\n", "\n" + lines, 1)
|
||||
with open(path + ".tmp", "w") as f:
|
||||
f.write(text)
|
||||
os.chmod(path + ".tmp", 0o755)
|
||||
os.replace(path + ".tmp", path)
|
||||
dep["foveation_env"] = env
|
||||
write_deployment(pkg, dep)
|
||||
|
||||
|
||||
def cmd_set_settings(args):
|
||||
pkg = check_pkg(args["package"])
|
||||
dep = deployment(pkg) or {}
|
||||
@@ -5464,6 +5757,8 @@ def cmd_set_settings(args):
|
||||
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)
|
||||
if "foveation_env" in args:
|
||||
update_foveation_launcher(pkg, dep, args["foveation_env"])
|
||||
text = "".join(f"{k}={v}\n" for k, v in current.items())
|
||||
with open(conf, "w") as f:
|
||||
f.write(text)
|
||||
@@ -5484,7 +5779,8 @@ def cmd_set_settings(args):
|
||||
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,
|
||||
return {"settings": current, "foveation_env": dep.get("foveation_env", {}),
|
||||
"controller_models": models, "config_sync": synced,
|
||||
"refresh_rate": sync_refresh_rate(pkg, dep)}
|
||||
|
||||
|
||||
@@ -5576,7 +5872,9 @@ def cmd_uninstall(args):
|
||||
removed_sc = False
|
||||
if args.get("remove_shortcut") and steam_users():
|
||||
# Steam keeps its own copy of shortcuts.vdf and writes it back: change it only with Steam closed (worker)
|
||||
removed_sc = cmd_shortcuts({"remove": [{"exe": f'"{anchor}/launch.sh"', "appid": dep.get("appid")}]})["started"]
|
||||
# "later" queues the removal and "off" does nothing now: neither starts a worker (no "started")
|
||||
removed_sc = bool(cmd_shortcuts({"remove": [{"exe": f'"{anchor}/launch.sh"',
|
||||
"appid": dep.get("appid")}]}).get("started"))
|
||||
if args.get("remove_shortcut"):
|
||||
try:
|
||||
devkit_unregister(pkg, steam_running=run(["pgrep", "-x", "steam"]).returncode == 0)
|
||||
@@ -6648,6 +6946,11 @@ def purge_worker(payload):
|
||||
result["removed"].append(VR_OVERLAY_UNIT_PATH)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"overlay autostart service: {exc}")
|
||||
try:
|
||||
if remove_library_hooks():
|
||||
result["removed"].append(LIBRARY_HOOK)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"Steam library hook: {exc}")
|
||||
try:
|
||||
service = stop_steam()
|
||||
except AgentError as exc:
|
||||
@@ -8635,6 +8938,8 @@ def main():
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in COMMANDS:
|
||||
print(json.dumps({"ok": False, "error": f"usage: frameport_agent.py <{'|'.join(sorted(COMMANDS))}>"}))
|
||||
return 2
|
||||
if sys.argv[1] == "library_pending" and hasattr(signal, "alarm"):
|
||||
signal.alarm(LIBRARY_HOOK_SECONDS + 5) # Steam's ExecStartPre: own deadline too, should timeout(1) be missing
|
||||
raw = sys.stdin.read() if not sys.stdin.isatty() else ""
|
||||
try:
|
||||
args = json.loads(raw) if raw.strip() else {}
|
||||
|
||||
@@ -13,11 +13,11 @@ d6337034fb0bdcca7ef9b7063d85b7586b8508b63b435959e8fffaba0691091c ./arm64-v8a/li
|
||||
4a13a33e112c9e4e3c5ff4337ed6e988a9e5b3602f48d654df148ef5fafbf426 ./arm64-v8a/libfpglmv.so
|
||||
36400eae4ba3807bcfcd54463daa217347032b07218792faa448d8cbbd086a0e ./arm64-v8a/libframe_xrshim.so
|
||||
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
|
||||
b1ec76d3c93862ef2e5dcffab44b4b45cdbd791df3c6870350a04be44a0e0137 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
7b63b609c773b5d14b14a23e35e14e0b61a1b8f410ba74ffc9125b67c0c89373 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
|
||||
b35a45e3e4948352a5433553420848eb7a8837afbe114edb1e95979872e25426 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
cf09f4c503d8e42392637780ca9d7142e1f97ea41a705871c8b55e3e5a65e9d0 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
|
||||
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
|
||||
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,29 @@
|
||||
package: com.CyDream.BattleTalent
|
||||
title: BattleTalent
|
||||
status: issues
|
||||
notes: "Plays normally. The mixed-reality mode doesn't appear in the start menu (also with an APK from a Quest 3); MR
|
||||
progress in #355. Multiplayer not tested yet."
|
||||
tested_version: '4.7'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.hw_video_decode
|
||||
- frame.luajit_pointers
|
||||
- frame.unity_runtime_msaa_off
|
||||
- frame.meta_permissions
|
||||
- frame.unity_multipass
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
scene_emul: 1
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
known_good_sha256: 17bbf8d7fc0f24d61986a0c5e1e9175cfe1239de6f8bcb2f588a57f80d83a1d3
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 96
|
||||
issue: 381
|
||||
source_hint: com.CyDream.BattleTalent
|
||||
@@ -0,0 +1,11 @@
|
||||
package: com.TriangleFactory.Breachers
|
||||
title: Breachers
|
||||
status: unsupported
|
||||
notes: Online-only multiplayer that needs Meta's online services, which aren't available on the Frame. The game's own
|
||||
copy of Meta's platform library (libovr_custom.so) quits at start ("Failed to launch SystemActivities").
|
||||
tested_version: 40.0.23
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
issue: 357
|
||||
@@ -0,0 +1,17 @@
|
||||
package: com.baywilly.crazytaxivr
|
||||
title: CrazyTaxiVR
|
||||
status: works
|
||||
tested_version: CrazyTaxiVR-42
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
adapter:
|
||||
scale: 0.85
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
known_good_sha256: 6801a09fec435776457dd2abd1c60d7db240be26cb846699946303aa94f53b04
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 380
|
||||
source_hint: CrazyTaxiVR
|
||||
@@ -0,0 +1,22 @@
|
||||
package: com.smtrandom.toytrain
|
||||
title: ToyTrains
|
||||
status: works
|
||||
tested_version: 01.008.009
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.hw_video_decode
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
known_good_sha256: f8665ccd5ce9907164e0700c6924e78821918df550154f3b869cb9907aa50ede
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 96
|
||||
issue: 384
|
||||
source_hint: Toy Trains
|
||||
@@ -6,6 +6,11 @@
|
||||
# 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: quest2-headset-identity
|
||||
pattern: 'venderId[^\r\n]*systemName Oculus Quest2'
|
||||
severity: info
|
||||
diagnosis: OVRPlugin sees OVRPort's Quest 2 identity. Quest 3-only graphics options may stay hidden (e.g. Asgard's Wrath 2). Choose Quest 3 under Headset model and rebuild; both the dispatcher name and XR_META_headset_id UUID must change. An intentional Quest 2 selection preserves the original identity.
|
||||
suggest: [device.headset_model]
|
||||
- id: meta-asset-status-error
|
||||
pattern: 'Asset status error\s+\(code 100\)'
|
||||
severity: error
|
||||
@@ -301,6 +306,13 @@ signatures:
|
||||
piece (for example Transformers Beyond Reality). Rebuild with the reservation marked as not committed.
|
||||
suggest: [frame.unreal_noreserve]
|
||||
supersedes: [native-crash]
|
||||
- id: color-space-missing
|
||||
# GitHub #336: OutRun Quest VR requires XR_FB_color_space (Rec.709) and quits before xrCreateSession
|
||||
pattern: 'Rec\.709 color space unavailable'
|
||||
severity: fatal
|
||||
diagnosis: The game needs Meta's colour-space extension, which the Frame's runtime doesn't offer, and quits before
|
||||
it starts VR (for example OutRun Quest VR). FrameBridge can offer it.
|
||||
suggest: [adapter.fb_color_space]
|
||||
- id: graphics-requirements-missing
|
||||
pattern: '(XR_ERROR_GRAPHICS_REQUIREMENTS_CALL_MISSING|Failed to create XR session: -50\b|xrCreateSession[^\n]*-50\b)'
|
||||
severity: fatal
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Controller buttons
|
||||
|
||||
Quest games usually bind Quest Touch controllers. Steam Frame has a d-pad and View button on the left controller,
|
||||
and an A/B/X/Y diamond and Menu button on the right. Without a game's own Frame bindings, SteamVR maps Touch X/A
|
||||
to the bottom button and Touch Y/B to all three top buttons. View sends Touch Menu. The right X and Y therefore
|
||||
send Touch B, rather than the left-hand X and Y of a Quest controller.
|
||||
|
||||
**Controller buttons** chooses an alternative for Quest games running through FrameBridge:
|
||||
|
||||
- **SteamVR default (0)** leaves SteamVR's Touch remapping in place. This is the initial default.
|
||||
- **Mirror (1)** uses every face button. On each controller, bottom and inner send X/A; top and outer send Y/B.
|
||||
- **Quest pair (2)** uses two face buttons per controller, diagonally placed like Touch. The other face buttons
|
||||
send no Touch input.
|
||||
|
||||

|
||||
|
||||
The small label identifies the physical button; the larger label is the Touch input the game receives. Blue is
|
||||
X/A, orange is Y/B or Menu, and a dash means no Touch input. Left and right refer to the hand holding the controller.
|
||||
|
||||
<!-- quest-buttons-table:start -->
|
||||
| Frame input | SteamVR default (0) | Mirror (1) | Quest pair (2) |
|
||||
|---|---|---|---|
|
||||
| Left d-pad ↑ | Y | Y | — |
|
||||
| Left d-pad ↓ | X | X | X |
|
||||
| Left d-pad ← | Y | Y | Y |
|
||||
| Left d-pad → | Y | X | — |
|
||||
| Left View | Menu | Menu | Menu |
|
||||
| Right A (bottom) | A | A | A |
|
||||
| Right B (right) | B | B | B |
|
||||
| Right X (left) | B | A | — |
|
||||
| Right Y (top) | B | B | — |
|
||||
| Right Menu | — | — | — |
|
||||
| Left bumper | — | — | — |
|
||||
| Right bumper | — | — | — |
|
||||
<!-- quest-buttons-table:end -->
|
||||
|
||||
The table describes button clicks and face-button touches. View sends Menu click only. Triggers, grip pressure,
|
||||
thumbsticks, tracked poses and vibration keep their corresponding Touch bindings.
|
||||
|
||||
## Choose a mode
|
||||
|
||||
For one game, open its **Game settings → Controllers → Controller buttons**. Choose **Use global default** to
|
||||
follow Settings, or select a specific mode to keep an override for that game. An explicit **SteamVR default**
|
||||
keeps mode 0 even when the global choice is Mirror or Quest pair.
|
||||
|
||||
For the library default, open **Settings → Controllers**. The diagram follows the selected mode. New installs
|
||||
and configuration updates use this default for games without an override. With a Frame connected, choose
|
||||
**Apply to installed games now** to send it to installed Quest games that follow the global default. Overrides
|
||||
are kept. Changing the default does not mark every game as needing an update.
|
||||
|
||||
Changes take effect at the **next launch**. Close and relaunch a running game.
|
||||
Games installed before this feature need **Update on Frame** once for the mode to work.
|
||||
|
||||
The CLI can change an installed game's runtime setting:
|
||||
|
||||
```sh
|
||||
frameport settings <pkg> quest_buttons=2
|
||||
```
|
||||
|
||||
Use `0` for SteamVR default or `1` for Mirror. This command writes the installed settings directly; it does not
|
||||
save a per-game recipe override. To keep the value through later installs and global applies, also save it in
|
||||
the recipe, or use Game settings:
|
||||
|
||||
```sh
|
||||
frameport recipe <pkg> --set quest_buttons=2
|
||||
```
|
||||
|
||||
## Limits
|
||||
|
||||
- SteamVR's binding UI can still override these suggested bindings. Check custom bindings if the result differs
|
||||
from the diagram.
|
||||
- Bumpers have no Touch counterpart and are unbound. Thumbrest touch is dropped; the Frame profile has no
|
||||
corresponding input. System buttons keep their runtime role.
|
||||
- Mirror and Quest pair require the Frame runtime's `XR_VALVE_frame_controller_interaction` extension. If it is
|
||||
absent, FrameBridge logs that fact and leaves SteamVR's remap in place.
|
||||
- Games suggesting their own Frame Controller bindings keep those bindings and continue seeing the Frame
|
||||
Controller profile, even if they also suggest Touch bindings. These modes do not replace the game's own mapping.
|
||||
- Touch suggestions using bare pose, trigger, squeeze or button paths are translated too; explicit components
|
||||
such as `/pose`, `/value`, `/click` and `/touch` are also supported where the mapping provides them.
|
||||
- Finger proximity emulation (`proximity_emul`) continues to work with Mirror and Quest pair. Dropping the
|
||||
unsupported thumbrest path does not disable proximity emulation from the other touch inputs.
|
||||
- The setting applies to Quest VR games, including 32-bit games through the bridge. It does not change PC VR or
|
||||
flat Android Steam Input layouts.
|
||||
|
||||
The mapping follows [Valve's Frame Controller input reference](https://partner.steamgames.com/doc/steamhardware/steamframe/input)
|
||||
and the [shared mapping data](input/quest_buttons.json). To regenerate the diagram, app illustrations and table,
|
||||
run `uv run python scripts/controller_diagram.py`; add `--check` to verify them without writing files.
|
||||
@@ -4,6 +4,19 @@ Detailed device findings per game and issue, moved verbatim from CLAUDE.md (whic
|
||||
with the key fact and the patch/setting names for each). Read the matching section before debugging a game;
|
||||
general runtime facts are in docs/FRAME_RUNTIME.md and symptom → fix rows in docs/PLAYBOOK.md.
|
||||
|
||||
## Per-game language (GitHub #354, 2026-10-17)
|
||||
|
||||
Asgard's Wrath 2 starts in English with no in-game language choice. Headless verification used Transformers:
|
||||
`LEPTON_GFXRECON_FP_LOCALE="0\npersist.sys.locale=de-DE"` gave container
|
||||
`getprop persist.sys.locale = de-DE`, while `ro.product.locale` remained `en-US`. The game's VM started with
|
||||
`user.locale=de-DE`, Android configuration was `[de_DE]`, and Unreal logged `Locale=de_DE`.
|
||||
Patch `device.language` sets this boot property independently of `device.hide_navbar`.
|
||||
Text/voice switching in individual games, including Asgard's Wrath 2, still needs headset verification.
|
||||
|
||||
Recipes store the selection as `patches: {device.language: {tag: de-DE}}`. The catalog's `device` field is a
|
||||
list of patch IDs, with dedicated fields for some parameterized device settings (files, environment, config sync,
|
||||
foveation); it has no field for a language tag. Catalog import/export cannot currently preserve this selection.
|
||||
|
||||
## a32 bridge: games, lessons, debugging aids, profiling
|
||||
|
||||
Learned: Unity 2019 contexts
|
||||
@@ -639,3 +652,87 @@ the entry's `link` record (+ `direct`); `pipeline.check_link_updates` (GUI start
|
||||
`link_bar` + game page callout → `link_dialog.update_from_link` (fetch again, one confirmation, `download_and_install`
|
||||
per game; add_from_link clears the offer and keeps a name the user gave). CLI `frameport link-updates [--install]`.
|
||||
|
||||
|
||||
## XR_FB_color_space (#336)
|
||||
|
||||
OutRun Quest VR (`org.outrun.quest.vr`, its own Khronos loader, no OVRPlugin) logs "Rec.709 color space unavailable" right after xrCreateInstance and quits ~20 s later without a session: the Frame's runtime (and OVRPort's dispatcher) list no XR_FB_color_space. Adapter setting `fb_color_space` (off by default, `native/adapter/color_space.c`) offers the extension when the runtime lacks it, strips it from the runtime's create info, fills XrSystemColorSpacePropertiesFB with Rec.709 and answers xrEnumerateColorSpacesFB / xrSetColorSpaceFB (no conversion: the runtime has no API for it). The functions are reachable through FrameBridge's xrGetInstanceProcAddr and `framebridge_extension_proc` (xrshim); OVRPort's fixed dispatcher table can't forward them, hence off by default. Not yet tried on the device (the build was not downloaded: it ships Sega's game data). Log: `color_space: emulating XR_FB_color_space`.
|
||||
|
||||
## Per-game headset identity (#373, 2026-10-17)
|
||||
|
||||
Owner decision: all Quest / OVRPort conversions default to Quest 3; Customize → Headset model offers Quest 3,
|
||||
Quest 3S, Quest Pro and Quest 2. Rebuild and reinstall after changing it. Rift, Linux and 2D apps are excluded.
|
||||
Target: Asgard's Wrath 2's **Enhanced Rendering Features** and **90 Hz Mode**. This implementation has static and
|
||||
host tests; unlocking those options and actual 90 Hz presentation still need an owner headset check.
|
||||
|
||||
Device evidence supplied by the owner (headless Transformers, OVRPort runtime 3.4.3-aa54c3f): Lepton's image already
|
||||
sets `ro.product.model=Lepton`, manufacturer `Valve`, device `lepton_arm64_only`. A `LEPTON_GFXRECON_*` boot-property
|
||||
line cannot replace existing write-once `ro.*` values. Removed the WIP headset property injection. Android
|
||||
`Build.MODEL` stays Lepton. A general app-start dex/reflection override is follow-up: the existing `Dex.return_early`
|
||||
tooling replaces method bodies, but does not supply a universal early startup hook that safely sets Build fields.
|
||||
|
||||
OVRPlugin logged `venderId 10291, systemName Oculus Quest2` even with FrameBridge rewriting the name. The APK's
|
||||
`lib/arm64-v8a/libopenxr_loader.so` dispatcher sits above FrameBridge: `xrGetSystemProperties` at VA `0x18554`
|
||||
overwrites the name (`0x18620`–`0x18638`, string at file/VA `0x6034`) and returns its own
|
||||
`XR_META_headset_id` (`0x186e8` loads address `0x29338`, `0x18778`–`0x1877c` copies 16 bytes;
|
||||
UUID file offset `0x21338`). Vendor id remains 10291. A lower-layer-only override cannot fix those answers.
|
||||
|
||||
Disassembly of the supplied Unreal `libOVRPlugin.so`:
|
||||
|
||||
- `ovrp_GetSystemHeadsetType` at `0x251730` calls `ovrp_GetSystemHeadsetType2` at `0x247bb0`, which calls the
|
||||
`0x2590b0` thunk → `0x292f80`.
|
||||
- System properties' name is copied to the global std::string `0x62d328` at `0x261b30`–`0x261b3c`.
|
||||
`0x261b9c`–`0x261bb4` stores the headset-id-present flag (`0x62d2c0`) and UUID (`0x62d2c1`).
|
||||
- `0x292f80` checks that flag. When present it compares the UUID, in inline 64-bit constants, returning
|
||||
Quest 1 = 8 (`0x293134`), Quest 2 = 9 (`0x2931ac`), Pro = 10 (`0x2931b8`),
|
||||
Quest 3 = 11 (`0x2931c4`), Quest 3S = 12 (`0x293060`–`0x29309c`). Unknown UUID → 9.
|
||||
- Only without the UUID does `0x2930a8` fall back to systemName (older Quest/Pro names, default 9).
|
||||
`0x292d60` is a separate product-name normalizer: it recognizes newer Meta names but returns the generic
|
||||
`Meta Quest` for known Quest products. It is not the headset-type selector. Android Build is not consulted
|
||||
in the traced headset-type path.
|
||||
- Cross-check: the available armv7 OVRPlugin's Thumb entry `0x120c5c` → thunk `0x12bc38` → `0x148ac8`
|
||||
likewise tests UUID first (16-byte comparisons), then name at `0x148b1a`. This older build recognizes through
|
||||
Quest 3 (return 11 at `0x148b14`), not Quest 3S. It is consistent with the Unreal mapping for Quest 2/Pro/3.
|
||||
|
||||
Raw XrUuidEXT byte order (hyphens just group sequential bytes; do not swap GUID fields):
|
||||
|
||||
| Model | UUID | Unreal headset type |
|
||||
| --- | --- | --- |
|
||||
| Quest 2 | `3064f239-15ed-4722-a659-5ea1ecd899c2` | 9 |
|
||||
| Quest Pro | `b58443c9-df86-40cd-8918-2a278e18ab71` | 10 |
|
||||
| Quest 3 | `082746ba-a071-4cf7-8f87-bade5cce43ce` | 11 |
|
||||
| Quest 3S | `9510492b-0d42-4872-bcf4-fb1a8def6f0e` | 12 |
|
||||
|
||||
`device.headset_model` now applies at APK stage after FrameBridge. It verifies original name, UUID and the
|
||||
instructions loading both in the known dispatcher before writing either. Unknown builds fail with an explicit
|
||||
unsupported-dispatcher message and a Quest 2 escape hatch. Same-size data edits only: NUL-padded 14-byte name and
|
||||
16-byte UUID. Quest Pro uses the short dispatcher label `Quest Pro` because `Meta Quest Pro` plus NUL needs 15 bytes;
|
||||
its UUID selects type 10. Quest 2 changes no dispatcher bytes and adds no embedded headset setting (native default preserves the old name).
|
||||
FrameBridge retains the full standard name override
|
||||
for apps calling it directly; setting 2 preserves its original result. No UUID extension is invented in FrameBridge.
|
||||
|
||||
Owner verification: rebuild/reinstall Asgard's Wrath 2 with Quest 3 selected. Capture the OVRPlugin line containing
|
||||
`venderId 10291, systemName Meta Quest 3`, and its `HeadsetId:` line (16 hex bytes matching Quest 3 above).
|
||||
Capture any game/OVRPlugin headset-type line: expect `Meta_Quest_3` / `Quest_3` / numeric 11 if the build logs it;
|
||||
absence of such a line is not proof of failure. In the headset check both named graphics options become available,
|
||||
can be enabled and persist after restart. Check actual runtime refresh/pacing separately when selecting 90 Hz;
|
||||
identity alone does not implement refresh-rate support. Rebuild with Quest 2 for the control: original `Oculus Quest2`,
|
||||
original Quest 2 UUID, type 9 if logged, and previous option visibility. Also smoke-test a Unity OVRPlugin game
|
||||
with the default Quest 3 choice for regressions. No device access was used during implementation.
|
||||
|
||||
## Quest controller mode (`quest_buttons`, 2026-10-17)
|
||||
|
||||
Adapter setting `quest_buttons` (0 SteamVR default, 1 Mirror, 2 Quest pair; contract docs/input/QUEST_BUTTONS.md,
|
||||
user page docs/CONTROLLERS.md). FrameBridge enables `XR_VALVE_frame_controller_interaction` and, after a game's Touch
|
||||
suggestion succeeds, suggests `/interaction_profiles/valve/frame_controller_valve` bindings translated from the
|
||||
accepted Touch set (SteamVR uses a Frame binding before its own Touch remap: Touch B/Y on all three top buttons).
|
||||
|
||||
Headless check on the device (I Am Cat, Unity + OVRPlugin, mode 2, `input_diag=1`): the runtime offers and enables the
|
||||
extension in a Lepton app, and accepts the generated Frame suggestion (`quest_buttons: frame_controller bindings
|
||||
(mode 2, 27 bindings, 7 dropped): 0`); the game's Touch (34 paths) and hand-interaction suggestions were accepted as
|
||||
before; 72 fps, no new errors. Translations as in the JSON: left `x/*` -> `dpad_down/*`, `y/*` -> `dpad_left/*`,
|
||||
`menu/click` -> `view/click`. Not provable headless (tracking needs the headset worn): that SteamVR then reads the
|
||||
buttons from the Frame binding; one launch in the headset settles it.
|
||||
|
||||
Pitfall seen during the test: OVRPort runs from two sessions on the same data folder share
|
||||
`overport-workspace/dec/working.apk` and fail at once (`OVRPort failed (exit 1)`, `FileAlreadyExistsException` in
|
||||
OVRPort's own output, which the CLI doesn't print without a verbose reporter). Build one game at a time per data folder.
|
||||
+8
-5
@@ -86,7 +86,7 @@ AXRB/ ← AXRB's download folder (Downloads/AX
|
||||
|
||||
Every game has three places; the answers below refer to them.
|
||||
|
||||
- **Game settings:** right-click the game in the **Library** → **Game settings…**. Sharpness, refresh rate,
|
||||
- **Game settings:** right-click the game in the **Library** → **Game settings…**. Render resolution (supersampling), refresh rate,
|
||||
controllers, menus and more, in plain words. Changes apply the next time the game starts.
|
||||
- **Patches:** click the game in the **Library** to open its page, then **Customize** (in the **Patches** section).
|
||||
Turn a patch on or off, then click **Update on Frame** at the top of the page.
|
||||
@@ -94,7 +94,10 @@ Every game has three places; the answers below refer to them.
|
||||
It has **Reset to suggested recipe…** and **Report a problem…**, among others.
|
||||
|
||||
The sidebar has two tabs for troubleshooting: **Monitor** (the running game's frame rate, CPU and graphics) and
|
||||
**Files** (each installed game's storage on the Frame, under **Game storage**).
|
||||
**Files** (each installed game's storage on the Frame, under **Game storage**). Quest games expose their Android
|
||||
storage; PC VR and Linux games expose their install folders, including installations on another drive.
|
||||
SD cards, USB drives and other Steam library drives appear under **Drives**, with free space when available.
|
||||
Read-only drives can be browsed and downloaded from, but cannot be changed.
|
||||
|
||||
## A game doesn't work right. Where do I start?
|
||||
|
||||
@@ -151,7 +154,7 @@ need **Update on Frame**).
|
||||
| One eye shimmers while menus look steady | Game page → **Customize** → **Eye-tracked foveation (Valve)**: **Fixed**, else **Off** |
|
||||
| Menus or screens upside down | **Game settings…** → **Fix upside-down menus** |
|
||||
| Curved menus or video screens missing | **Game settings…** → **Show curved menus and screens** |
|
||||
| Blurry | **Game settings…** → raise **Sharpness** (costs frame rate) |
|
||||
| Blurry | **Game settings…** → raise **Render resolution (supersampling)** (costs frame rate) |
|
||||
|
||||
The [patch reference](PATCHES.md) lists every patch with what it does. If nothing helps, report it with a short
|
||||
description of what you see.
|
||||
@@ -161,7 +164,7 @@ description of what you see.
|
||||
How to tell: open **Monitor** in the sidebar while you play. Smooth play needs the game's frame rate at the refresh
|
||||
rate (for example 72 or 90 fps).
|
||||
|
||||
- **Below the refresh rate:** right-click the game → **Game settings…** → lower **Sharpness** (for example to 0.8×) or
|
||||
- **Below the refresh rate:** right-click the game → **Game settings…** → lower **Render resolution (supersampling)** (for example to 0.8×) or
|
||||
pick a lower **Refresh rate**. After a session, the game's page may suggest a value.
|
||||
- **At the refresh rate but it still judders:** report it; that points to timing, not speed.
|
||||
|
||||
@@ -184,7 +187,7 @@ Right-click the game → **Game settings…**:
|
||||
How to tell: if it only happens on battery and never while plugged in, it's power, not FramePort. Heavy games can
|
||||
draw more than the battery delivers, especially at lower charge, and the Frame then turns off without an error.
|
||||
|
||||
- Play plugged in, or right-click the game → **Game settings…** → lower **Sharpness** to reduce the load.
|
||||
- Play plugged in, or right-click the game → **Game settings…** → lower **Render resolution (supersampling)** to reduce the load.
|
||||
- It also turns off while plugged in: report it. The diagnostics include the log of the boot that ended.
|
||||
|
||||
## My progress isn't saved
|
||||
|
||||
+80
-6
@@ -43,6 +43,12 @@
|
||||
work on Android 11: `policy_control` is gone, `cmd statusbar send-disable-flag home recents` has no `back` and
|
||||
moves the back button onto the app's controls, the `sysui_nav_bar` layout and disabling SystemUI had no effect.
|
||||
|
||||
- **Per-game language** (verified 2026-10-17): `device.language` uses the same unescaped
|
||||
`LEPTON_GFXRECON_*` boot-property path: `LEPTON_GFXRECON_FP_LOCALE="0\npersist.sys.locale=<tag>"`.
|
||||
Android reports the selected locale to the game at startup; `ro.product.locale` stays `en-US`.
|
||||
Choose Game language under Customize and apply with Update on Frame. English (US) removes the override.
|
||||
Games need to include and use the requested language; text and voice still need headset verification.
|
||||
|
||||
## Where FramePort keeps games (internal storage, microSD)
|
||||
- Every game has an **anchor** on internal storage: `~/Applications/quest-frame/<pkg>/` with `launch.sh`,
|
||||
`deployment.json`, `artwork/` and `plays.log`. Steam's shortcut points at the anchor's `launch.sh`, so it never
|
||||
@@ -112,6 +118,26 @@
|
||||
- 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.
|
||||
|
||||
### Frame Controller input
|
||||
|
||||
The runtime offers `XR_VALVE_frame_controller_interaction`, with interaction profile
|
||||
`/interaction_profiles/valve/frame_controller_valve`. [Valve's input reference](https://partner.steamgames.com/doc/steamhardware/steamframe/input)
|
||||
describes two gamepad halves. Under `/user/hand/<hand>/input/`:
|
||||
|
||||
- Left: `dpad_up`, `dpad_down`, `dpad_left`, `dpad_right`, `view` (click and touch).
|
||||
- Right: `a`, `b`, `x`, `y`, `menu` (click and touch). The face diamond is A bottom, B right, X left, Y top.
|
||||
- Both: `system`, `bumper`; `squeeze` and `trigger` (click, touch, value); `thumbstick` (vector, x, y, click,
|
||||
touch); `grip/pose`, `aim/pose`. Haptics use `/user/hand/<hand>/output/haptic`.
|
||||
|
||||
SteamVR prefers a game's Frame Controller binding. Without one, its Touch remap sends left d-pad down as Touch X
|
||||
and left/up/right as Touch Y; right A as Touch A and X/Y/B as Touch B. Left View sends Touch Menu.
|
||||
FrameBridge's `quest_buttons` preference (`0` default, `1` Mirror, `2` Quest pair) enables the extension when offered
|
||||
and suggests a Frame binding translated from the game's Touch suggestions. Common pressure/trigger/thumbstick,
|
||||
pose and haptic paths keep their Touch counterparts; thumbrest touch and system click are dropped, bumpers stay
|
||||
unbound. The player's SteamVR custom bindings can override suggestions. This works on both bridge ABIs, without
|
||||
the arm64-only extension shim. See [Controller buttons](CONTROLLERS.md) and the [mapping contract](input/QUEST_BUTTONS.md).
|
||||
Device verification of the new modes is pending integration with the native stream.
|
||||
|
||||
## 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
|
||||
@@ -390,7 +416,8 @@ over Quest games.
|
||||
|
||||
## Shared foveation controls (tested 2026-10-12)
|
||||
|
||||
Under a Quest game's Advanced patches, choose **Foveated rendering → Eye-tracked**, then **Update on Frame**.
|
||||
Under a Quest game's **Game settings → Picture**, choose **Foveated rendering → Eye-tracked**, then **Save**.
|
||||
Changes apply at the next game start.
|
||||
The full-resolution area selector defaults to **Default (30% radius)**: no radius override, preserving the
|
||||
runtime profile used in the headset experiments. Larger radii retain more detail and save less shading work.
|
||||
Custom radii set `FOVE_INNER`; `FOVE_OUTER` is twice that value, matching the runtime's profile proportions.
|
||||
@@ -398,7 +425,7 @@ The inspected runtime's built-in inner/outer pairs are 0.30/0.60, 0.25/0.50 and
|
||||
The shared-texture injection path selects profile 1: 0.30 inner radius and 0.60 outer radius.
|
||||
|
||||
`device.foveation` enables Valve's shared Vulkan injection and suppresses competing native foveation through
|
||||
`foveation_fix=1`. Recipes and shared catalog entries preserve mode, radius and test selection. This remains
|
||||
`foveation_fix=1`. Game settings stores mode, radius and test selection as a per-game library preference, outside shared recipes. This remains
|
||||
experimental: complete images were confirmed in RE4, Batman, Deadpool, The Climb 2 and Asgard's Wrath 2,
|
||||
not all Quest games or renderers. RE4 and Batman checks include title/calibration scenes; these are not
|
||||
equivalent to extended gameplay coverage. Asgard gameplay was visually confirmed, without an on/off benchmark.
|
||||
@@ -414,14 +441,14 @@ installation. The runtime correction additionally checks the exact Valve library
|
||||
instructions before touching executable memory; unsupported libraries request injection be disabled and log
|
||||
the reason. No system library is changed. A SteamOS update requires revalidation of this compatibility path.
|
||||
Both-eye gaze following was confirmed in Climb's darkening diagnostic, but long-session stability remains
|
||||
experimental. Off rebuilds from the source APK and removes this private support, including its visibility fix.
|
||||
experimental. Off disables injection through launcher exports; the private APK compatibility support remains installed.
|
||||
|
||||
**Darken lower-resolution regions (test)** adds an optional Vulkan shader diagnostic. It dims RGB where the
|
||||
**Darken coarse fragments (test)** enables the dormant Vulkan shader diagnostic. It dims RGB where the
|
||||
actual `FragSizeEXT` footprint exceeds one pixel; alpha is unchanged. Deadpool's shaded regions were confirmed
|
||||
to follow gaze in the headset. Unsupported shader output interfaces are skipped, and multiple passes may
|
||||
compound darkening, so this is a diagnostic rather than a precise composited mask. Do not benchmark with it on.
|
||||
Uncheck it and update from the original source APK to remove the diagnostic library, except in Climb where
|
||||
the same library also carries the compatibility correction. Darkening stays dormant without its launcher opt-in.
|
||||
Uncheck it and save Game settings to remove the launcher opt-in. The library stays installed and dormant;
|
||||
in Climb the same library also carries the compatibility correction.
|
||||
|
||||
High-resolution Deadpool samples crossed from about 45 to 90 FPS with injection enabled, but temperatures and
|
||||
power rose as it rendered twice as many frames. Foveation does not guarantee lower temperature at uncapped FPS.
|
||||
@@ -449,3 +476,50 @@ sighting, command lines checked once per process, render fds cached (rescanned e
|
||||
game processes without one), temperatures every 2 s, the CPU-cluster power rails every 5 s and the battery gauge every
|
||||
2-10 s, the stream measured ~10-13 ms CPU per 1 s tick on the device (idle clocks; idle or with 4XVR running) = about
|
||||
1 % of one core, ~0.15 % of the whole CPU. The I2C sensors (power monitors, battery gauge) are the slowest reads.
|
||||
|
||||
### Foveation user preferences (discussion #365)
|
||||
|
||||
Game settings → Picture provides Default / Eye-tracked / Fixed / Off, radius, and an advanced diagnostic switch.
|
||||
`set_settings` (agent 98) atomically rewrites just the managed launcher exports and records them in deployment.json;
|
||||
no APK rebuild, reinstall or live device intervention is needed. Changes apply at the next start. Default restores
|
||||
recipe/catalog launcher defaults; explicit preferences win. One-time library migration extracts old recipe choices
|
||||
and leaves internal APK compatibility support. Catalog defaults (including Climb 2 and Batman) still apply to new
|
||||
entries. Shared working configs omit `device.foveation` entirely.
|
||||
|
||||
APK-side: adapter builds automatically prepare Batman's hash-verified allocation fix and Climb 2's
|
||||
renderer-guarded private bridge for verified builds, independently of the runtime choice. Unknown versions skip
|
||||
automatic compatibility edits; explicit catalog requests still fail validation rather than guessing offsets. FrameBridge
|
||||
builds include the dormant diagnostic layer (activated only by `FRAMEPORT_FOVEATION_TEST=1`), so future test toggles
|
||||
need only a launcher update. Existing builds need one normal update to acquire that support. Render resolution
|
||||
(supersampling) changes the game's render resolution: higher is sharper but slower. Climb 2 foveation still requires
|
||||
1.0 scale.
|
||||
|
||||
## Steam library scheduling
|
||||
|
||||
Agent v99 holds automatic shortcut restarts while the local app's
|
||||
`~/.local/share/frameport-app/gui.alive` heartbeat is under ten seconds old, as well as while a game or Desktop Mode
|
||||
is open. Closed/crashed GUIs expire; the existing twelve-hour hold limit still applies.
|
||||
|
||||
`library_mode` accepts `now` (default), `later`, and `off`; FramePort stores its preference as
|
||||
`install.steam_library`. Later writes shortcuts and journals packages/removals in
|
||||
`~/.local/share/frameport/library-pending.json`. Steam rewrites shortcuts.vdf on exit, so a user drop-in
|
||||
`~/.config/systemd/user/steam.service.d/frameport-library.conf` reapplies the journal in ExecStartPre, after the
|
||||
previous session has exited. ExecStartPost verifies the entries and uses the existing live devkit fallback if Steam
|
||||
removed them. The journal is cleared only after success, and survives agent/app exit and reboot. The hooks share the
|
||||
shortcut worker lock; an active worker performs its own write/verification instead. Both hook lines start with
|
||||
`-` and run under `/usr/bin/timeout -k 5 20` (present on SteamOS; `-` ignores exit codes, including 127 for a missing
|
||||
interpreter or agent, but not a hook that hangs until the unit's 90 s start timeout); ExecStartPre also has its own
|
||||
SIGALRM deadline. ExecStartPost only starts the verification as the transient unit `frameport-library-verify`
|
||||
(`--no-block`, RuntimeMaxSec 300), so Steam's start never waits for its Steam/devkit waits. The pre hook leaves
|
||||
shortcuts.vdf alone when nothing is pending. The drop-in is removed (and the user manager reloaded) as soon as the
|
||||
mode isn't `later` and nothing is pending, and by purge; an otherwise empty `steam.service.d` goes with it.
|
||||
SteamOS 0.3.0 facts (checked 2026-10-16): Steam is the static user unit `/usr/lib/systemd/user/steam.service`
|
||||
(Type=simple, Restart=always, TimeoutStartSec 90 s, its own `-` ExecStartPre lines; `extra_args.conf` drop-ins are
|
||||
Valve's documented way to extend it), systemd 257; no user drop-ins exist by default.
|
||||
This automatic application targets SteamOS's `steam.service`; if Steam is started outside that service, use
|
||||
FramePort's Restart Steam now action to apply the journal safely.
|
||||
|
||||
Off skips new shortcuts (existing entries remain). Play in Off, and Play for a pending Later entry, starts launch.sh
|
||||
in a detached user systemd unit, just as launch tests do, without adding a devkit entry. Steam/SteamVR must already
|
||||
be running. This loses Steam's per-game overlay/controller setup and Exit game integration; use FramePort Monitor
|
||||
to stop the game. This direct path has unit coverage; headset input/visual behaviour needs device validation.
|
||||
@@ -37,6 +37,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| COMPOUND | Quest | ✅ Works | |
|
||||
| Cook-Out | Quest | ✅ Works | |
|
||||
| Cosmodread | Quest | ✅ Works | |
|
||||
| CrazyTaxiVR | Quest | ✅ Works | |
|
||||
| Creed | Quest | ✅ Works | |
|
||||
| Crisis Zone VR | Quest | ✅ Works | |
|
||||
| cubism | Quest | ✅ Works | |
|
||||
@@ -147,6 +148,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Time Crisis VR (Experimental) | Quest | ✅ Works | |
|
||||
| Totally Baseball | Quest | ✅ Works | |
|
||||
| Toy Master | Quest | ✅ Works | |
|
||||
| ToyTrains | Quest | ✅ Works | |
|
||||
| TRIANGLE STRATEGY | Quest | ✅ Works | |
|
||||
| Under Cover | Quest | ✅ Works | |
|
||||
| Vacation Simulator | Quest | ✅ Works | |
|
||||
@@ -166,6 +168,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| AFFECTED: The Manor | Quest | ⚠️ Works with issues | Gear VR game (32-bit only), runs through FramePort's 32-bit bridge (experimental). |
|
||||
| Ancient_Dungeon | Quest | ⚠️ Works with issues | Multiplayer isn't available (offline). |
|
||||
| Arcsmith | Quest | ⚠️ Works with issues | Right eye distorts during movement (unresolved; swap, tracking, Valve layers, depth and pacing ruled out). |
|
||||
| BattleTalent | Quest | ⚠️ Works with issues | Plays normally. |
|
||||
| Does it Stack? | Quest | ⚠️ Works with issues | Mixed reality mode does not work (works on Demeo), everything else seems to be OK |
|
||||
| Dungeons of Eternity | Quest | ⚠️ Works with issues | It can not connect to servers |
|
||||
| End Space | Quest | ⚠️ Works with issues | Controls stop responding after the first mission loads. |
|
||||
@@ -195,6 +198,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Time Stall | Quest | ⚠️ Works with issues | Both eyes distort during movement (unresolved). |
|
||||
| Vampire: The Masquerade - Justice | Quest | ⚠️ Works with issues | Game runs fine and playable, on loading there's a random sphere in the middle of the sceen. |
|
||||
| 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… |
|
||||
| Breachers | Quest | ❌ Doesn't run | Online-only multiplayer that needs Meta's online services, which aren't available on the Frame. |
|
||||
| Deadly Delivery | Quest | ❌ Doesn't run | Quits after Meta's entitlement check fails: it needs Meta's online services. |
|
||||
| Espire 1: VR Operative (Quest Edition) | Quest | ❌ Doesn't run | Mesa GL driver crash during texture upload. |
|
||||
| GOLF+ | Quest | ❌ Doesn't run | Black screen. |
|
||||
|
||||
+26
-2
@@ -86,8 +86,24 @@ FramePort can run on the Frame itself, without a PC:
|
||||
2. In Desktop Mode, download `FramePort-linux-arm64.tar.gz`, unpack it (`tar xzf FramePort-linux-arm64.tar.gz`) and
|
||||
start `FramePort/FramePort`.
|
||||
|
||||
Games appear in the Steam library when you go back to Gaming Mode. Please report anything odd with **Report a
|
||||
problem…**.
|
||||
With the default **Steam library: Now**, games appear after you close FramePort and return to Gaming Mode.
|
||||
FramePort also waits while its window is open in Gaming Mode, so you can install several games in one session.
|
||||
Please report anything odd with **Report a problem…**.
|
||||
|
||||
Settings → Installing → **Steam library** controls future installs:
|
||||
|
||||
- **Now** (default): restart Steam after adding shortcuts, waiting while a game, Desktop Mode, or FramePort's local
|
||||
window is open (up to 12 hours). Unchanged shortcuts need no restart.
|
||||
- **Later**: save shortcuts without restarting Steam. Keep installing; changes are applied at the next Steam
|
||||
service start, including after a reboot. **Restart Steam now** applies them immediately. It closes Desktop Mode
|
||||
and FramePort running on the Frame, and asks again if a game is running.
|
||||
- **Off**: skip new Steam shortcuts; existing entries stay. FramePort's **Play** runs the installed launcher directly.
|
||||
Steam and SteamVR must already be running. Steam's per-game overlay, controller setup, and Exit game integration
|
||||
are unavailable; stop the game from FramePort's Monitor. Pending games in Later also use this direct Play path.
|
||||
|
||||
CLI: `frameport frame steam-library now|later|off` saves the setting and applies it to the connected Frame.
|
||||
`frameport frame restart-steam` applies pending entries now; `--yes` confirms ending a running game.
|
||||
These settings concern game shortcuts; installing a missing Steam runtime such as Proton can still require a restart.
|
||||
|
||||
## Installing games
|
||||
|
||||
@@ -117,6 +133,14 @@ installed game keeps its Steam entry, artwork and saves (Steam restarts once).
|
||||
- A game on a card only starts while the card is inserted.
|
||||
- Cards formatted as FAT, exFAT or NTFS can't hold games: format the card in SteamOS first.
|
||||
|
||||
### Why do the controller buttons repeat or do the wrong thing?
|
||||
|
||||
SteamVR maps Quest Touch onto the Frame's controller buttons by default: three top buttons repeat Touch Y/B.
|
||||
Choose **Game settings → Controllers → Controller buttons** to use Mirror or Quest pair for one game, or
|
||||
**Settings → Controllers** for the library default. **Apply to installed games now** sends the default to Quest
|
||||
games without an override. Changes take effect at the next launch.
|
||||
See [Controller buttons](CONTROLLERS.md) for the diagram, mappings and limits.
|
||||
|
||||
### Android apps and Windows programs
|
||||
|
||||
- Android apps without VR are installed unchanged and shown as a window in the headset. If one shows nothing (or a
|
||||
|
||||
+91
-29
@@ -2,7 +2,7 @@
|
||||
|
||||
# Patches
|
||||
|
||||
Every patch and game setting FramePort knows: 179 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: 183 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.
|
||||
|
||||
@@ -12,8 +12,8 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
|
||||
- [Patches from OVRPort](#group-overport) (25)
|
||||
- [Steam Frame patches](#group-frame) (61)
|
||||
- [Game settings](#group-adapter) (72)
|
||||
- [Files and settings on the Frame](#group-device) (8)
|
||||
- [Game settings](#group-adapter) (74)
|
||||
- [Files and settings on the Frame](#group-device) (10)
|
||||
- [PC VR (Proton and Revive)](#group-pcvr) (13)
|
||||
|
||||
## Index
|
||||
@@ -58,12 +58,12 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [GL shim (Mesa GLSL compatibility)](#frame-gl_shim) | `frame.gl_shim` | Steam Frame patches | 2 |
|
||||
| [Play services: Android 11 version checks](#frame-gms_platform_version) | `frame.gms_platform_version` | Steam Frame patches | |
|
||||
| [Godot apps: start without a clipboard service](#frame-godot_clipboard) | `frame.godot_clipboard` | Steam Frame patches | 1 |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 29 |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 31 |
|
||||
| [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 |
|
||||
| [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 | 3 |
|
||||
| [Fix Lua startup crashes on Frame](#frame-luajit_pointers) | `frame.luajit_pointers` | Steam Frame patches | 1 |
|
||||
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 4 |
|
||||
| [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 | 17 |
|
||||
@@ -81,12 +81,12 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Team Beef ports: treat the Frame as a Meta headset](#frame-tbxr_vendor) | `frame.tbxr_vendor` | Steam Frame patches | 1 |
|
||||
| [Unity App UI: no Android 12 vibrator](#frame-unity_appui_vibrator) | `frame.unity_appui_vibrator` | Steam Frame patches | |
|
||||
| [GL shim for Unity (no multisampled render-to-texture)](#frame-unity_gl_shim) | `frame.unity_gl_shim` | Steam Frame patches | 25 |
|
||||
| [Unity: render each eye separately (no multiview)](#frame-unity_multipass) | `frame.unity_multipass` | Steam Frame patches | 1 |
|
||||
| [Unity: render each eye separately (no multiview)](#frame-unity_multipass) | `frame.unity_multipass` | Steam Frame patches | 2 |
|
||||
| [Unity: disable MSAA](#frame-unity_no_msaa) | `frame.unity_no_msaa` | Steam Frame patches | 10 |
|
||||
| [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 | 14 |
|
||||
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 30 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 109 |
|
||||
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 31 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 111 |
|
||||
| [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 |
|
||||
@@ -123,6 +123,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [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 | 2 |
|
||||
| [Colour space support](#adapter-fb_color_space) | `adapter.fb_color_space` | Game settings | |
|
||||
| [Upside-down panel images (diagnostics)](#adapter-flip_dump) | `adapter.flip_dump` | Game settings | |
|
||||
| [Fix upside-down menus](#adapter-flip_emul) | `adapter.flip_emul` | Game settings | |
|
||||
| [Rotate panels 180°](#adapter-flip_quads) | `adapter.flip_quads` | Game settings | |
|
||||
@@ -135,7 +136,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 | 94 |
|
||||
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 96 |
|
||||
| [Vibration strength](#adapter-haptic_scale) | `adapter.haptic_scale` | Game settings | |
|
||||
| [Turn off Quest Vulkan foveation](#adapter-hide_fb_foveation) | `adapter.hide_fb_foveation` | Game settings | |
|
||||
| [Turn off space warp](#adapter-hide_space_warp) | `adapter.hide_space_warp` | Game settings | 5 |
|
||||
@@ -151,13 +152,14 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Fix hands lagging behind controllers](#adapter-pose_time_fix) | `adapter.pose_time_fix` | Game settings | 15 |
|
||||
| [Treat newer Quest controllers as Touch](#adapter-profile_remap) | `adapter.profile_remap` | Game settings | |
|
||||
| [Animate fingers from touch](#adapter-proximity_emul) | `adapter.proximity_emul` | Game settings | |
|
||||
| [Controller buttons](#adapter-quest_buttons) | `adapter.quest_buttons` | Game settings | |
|
||||
| [Keep pictures in bounds](#adapter-rect_clamp) | `adapter.rect_clamp` | Game settings | |
|
||||
| [Refresh rate](#adapter-refresh_rate) | `adapter.refresh_rate` | Game settings | 3 |
|
||||
| [Wait for the game's GPU](#adapter-release_wait) | `adapter.release_wait` | Game settings | |
|
||||
| [Re-create play space](#adapter-respace_kick) | `adapter.respace_kick` | Game settings | |
|
||||
| [Sharpness](#adapter-scale) | `adapter.scale` | Game settings | 8 |
|
||||
| [Render resolution (supersampling)](#adapter-scale) | `adapter.scale` | Game settings | 9 |
|
||||
| [Room depth](#adapter-scene_depth) | `adapter.scene_depth` | Game settings | |
|
||||
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 11 |
|
||||
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 12 |
|
||||
| [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 | |
|
||||
@@ -182,10 +184,12 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Show as VR or as a flat window](#device-display_mode) | `device.display_mode` | Files and settings on the Frame | |
|
||||
| [Settings files for the game](#device-files) | `device.files` | Files and settings on the Frame | 3 |
|
||||
| [Foveated rendering (Valve)](#device-foveation) | `device.foveation` | Files and settings on the Frame | 5 |
|
||||
| [Headset model](#device-headset_model) | `device.headset_model` | Files and settings on the Frame | default |
|
||||
| [Hide Android's navigation bar](#device-hide_navbar) | `device.hide_navbar` | Files and settings on the Frame | |
|
||||
| [Game language](#device-language) | `device.language` | 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 | 112 |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 114 |
|
||||
| [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) | |
|
||||
@@ -517,7 +521,7 @@ Runs a 32-bit game (Gear VR, Oculus Go, early Quest) on the Frame, which only ru
|
||||
|
||||
FramePort's layer between the game and the Steam Frame: fills in what the Frame lacks (controllers, room, passthrough). Needed for every converted game.
|
||||
|
||||
- `frame.adapter` · on by default
|
||||
- `frame.adapter` · on by default · revision 2
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Required for every OpenXR/overport build on the Frame.
|
||||
- **Suggested by launch-test signatures:**
|
||||
@@ -683,7 +687,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 (29):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Breathedge VR, Dance Central, Exploding Kittens VR, Eye of the Temple and 21 more.
|
||||
- **Catalog games (31):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, BattleTalent, Breathedge VR, Dance Central, Exploding Kittens VR and 23 more.
|
||||
- **Source:** [patches/frame/hw_video_decode.py](../src/frameport/patches/frame/hw_video_decode.py)
|
||||
|
||||
> Decodes the game's videos on the Frame's hardware video decoder instead of Android's software decoders, which stutter or fall behind with 4K/8K video (for example video players, Batman: Arkham Shadow's cutscenes). Adds FramePort's codec plugin (OMX.frameport.avc/hevc/vp9.decoder) to this game's container only; the APK isn't changed. When the hardware is busy (for example Steam's own hardware video decoding is on) the video is decoded in software as before.
|
||||
@@ -731,6 +735,7 @@ Lets games with a verified older Lua engine get past a startup crash on the Fram
|
||||
- `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.
|
||||
- **Catalog games (1):** BattleTalent.
|
||||
- **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)
|
||||
@@ -744,7 +749,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 (3):** Arkio, Demeter, Real VR Fishing.
|
||||
- **Catalog games (4):** Arkio, BattleTalent, Demeter, Real VR Fishing.
|
||||
- **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).
|
||||
@@ -983,7 +988,7 @@ Draws each eye separately. Try it when one eye shows a gray or broken picture.
|
||||
- `frame.unity_multipass` · revision 2
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Catalog games (1):** I Am Cat.
|
||||
- **Catalog games (2):** BattleTalent, I Am Cat.
|
||||
- **Source:** [patches/frame/unity_runtime.py](../src/frameport/patches/frame/unity_runtime.py)
|
||||
|
||||
> Oculus XR Plugin games render both eyes in one pass (multiview) on Android. If one eye shows only effects or gray (for example I Am Cat), rendering each eye in its own pass can fix it, at some GPU cost. Rewrites OculusSettings.GetStereoRenderingMode and its inlined read in OculusLoader.Initialize -> MultiPass in libil2cpp.so (found with Cpp2IL). Try it when one eye is wrong.
|
||||
@@ -1038,7 +1043,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 (30):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 22 more.
|
||||
- **Catalog games (31):** Audica, BattleSisters, BattleTalent, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 23 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.
|
||||
@@ -1054,7 +1059,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 (109):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable, Audica and 101 more.
|
||||
- **Catalog games (111):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable, Audica and 103 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.
|
||||
@@ -1565,6 +1570,22 @@ For games whose fades to black don't show, so you see the level loading (for exa
|
||||
|
||||
> Games that fade to black by dimming their picture with a layer color scale (XR_KHR_composition_layer_color_scale_bias, for example Batman: Arkham Shadow's loading screens) can show the level loading instead of black on the Frame. FrameBridge then draws the fade itself: a black quad in front of the eyes whose opacity follows the game's dimming. 2 also removes the game's own dimming (if both show, the fade is doubled).
|
||||
|
||||
### <a id="adapter-fb_color_space"></a>Colour space support
|
||||
|
||||
For games that stop at start because the headset reports no colour space (for example OutRun Quest VR).
|
||||
|
||||
- `adapter.fb_color_space`
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Troubleshooting → "Colour space support" (advanced).
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `color-space-missing` (fatal): The game needs Meta's colour-space extension, which the Frame's runtime doesn't offer, and quits before it starts VR (for example OutRun Quest VR).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Offers Meta's colour-space extension when the Frame's runtime lacks it: the game is told the headset uses Rec.709 and may choose any colour space (no conversion is done). For games that quit before their VR session with "Rec.709 color space unavailable" (for example OutRun Quest VR, GitHub #336). Only reaches games that use their own OpenXR loader; OVRPort's dispatcher can't forward the new functions.
|
||||
|
||||
### <a id="adapter-flip_dump"></a>Upside-down panel images (diagnostics)
|
||||
|
||||
Saves a few UI panels before and after FrameBridge turns them upright, for testing without the headset.
|
||||
@@ -1752,7 +1773,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 (94):** AgeOfJoy, Ancient_Dungeon, Attack on Titan VR: Unbreakable, Audica, AugmentedEmpire, Battlemarked, BattleSisters, Beat Saber and 86 more.
|
||||
- **Catalog games (96):** AgeOfJoy, Ancient_Dungeon, Attack on Titan VR: Unbreakable, Audica, AugmentedEmpire, Battlemarked, BattleSisters, BattleTalent and 88 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.
|
||||
@@ -1979,6 +2000,20 @@ For games whose hands' thumbs never move when you touch the buttons or thumbstic
|
||||
|
||||
> Meta's OVRPlugin reports a thumb (or index finger) resting near the controller through XR_FB_touch_controller_proximity, which the Frame's runtime lacks: "near" stays false and games that animate the hands from it show a thumb that never moves (for example Vader Immortal; found by Klownicle, GitHub #49). Binds the game's proximity actions to the capacitive touch inputs instead: 1 = thumb (thumbstick, face buttons or thumb rest touched), 2 = also the index finger (trigger touched). Only when the runtime lacks the extension.
|
||||
|
||||
### <a id="adapter-quest_buttons"></a>Controller buttons
|
||||
|
||||
Choose which Quest Touch buttons the Frame's face buttons send. Takes effect at the next launch.
|
||||
|
||||
- `adapter.quest_buttons`
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Controllers → "Controller buttons": SteamVR default (`0`), Mirror (`1`), Quest pair (`2`).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Choose how the Frame's face buttons send Quest Touch inputs: 0 = SteamVR default, 1 = Mirror, 2 = Quest pair. Used at the next launch; SteamVR custom bindings can override it. Requires the runtime's XR_VALVE_frame_controller_interaction extension. Mapping: https://github.com/spoopyghosty0/frameport/blob/main/docs/CONTROLLERS.md
|
||||
|
||||
### <a id="adapter-rect_clamp"></a>Keep pictures in bounds
|
||||
|
||||
Stops games from hanging when their eye picture is a little too big or empty (for example PowerWash Simulator). Leave it on.
|
||||
@@ -2036,17 +2071,17 @@ Re-creates the game's play space after the first frames. For testing.
|
||||
|
||||
> Recreate spaces after the first frames (diagnostic).
|
||||
|
||||
### <a id="adapter-scale"></a>Sharpness
|
||||
### <a id="adapter-scale"></a>Render resolution (supersampling)
|
||||
|
||||
Higher looks sharper but needs more power; lower runs smoother. 1.0× is the game's own.
|
||||
Changes the game's render resolution. Higher is sharper but slower; 1.0× is the game's own resolution.
|
||||
|
||||
- `adapter.scale`
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:**
|
||||
- Value: float, default `1.0` (always written).
|
||||
- Game settings → Picture → "Sharpness": slider 0.5–2 in steps of 0.1.
|
||||
- **Catalog games (8):** 4XVR Video Player, BlazeRush, cubism, FGO VR, I Expect You To Die 2, The 7th Guest VR, Vampire: The Masquerade - Justice, Walkabout Mini Golf.
|
||||
- Game settings → Picture → "Render resolution (supersampling)": slider 0.5–2 in steps of 0.1.
|
||||
- **Catalog games (9):** 4XVR Video Player, BlazeRush, CrazyTaxiVR, cubism, FGO VR, I Expect You To Die 2, The 7th Guest VR, Vampire: The Masquerade - Justice and 1 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Multiplies the recommended eye-buffer width and height (0.5–2.0). 1.5 ≈ 2.25× pixels.
|
||||
@@ -2076,7 +2111,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 (11):** Arkio, AugmentedEmpire, Battlemarked, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord, Neko Atsume Purrfect, Ocean Rift and 3 more.
|
||||
- **Catalog games (12):** Arkio, AugmentedEmpire, Battlemarked, BattleTalent, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord, Neko Atsume Purrfect and 4 more.
|
||||
- **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)
|
||||
@@ -2390,7 +2425,7 @@ Files, environment and window options written on the Frame when a Quest game or
|
||||
|
||||
### <a id="device-config_sync"></a>Keep the game's saved resolution in step
|
||||
|
||||
Keeps the resolution the game saves in step with the headset's picture size (else the picture is shifted, with black borders, after a sharpness change).
|
||||
Keeps the resolution the game saves in step with the headset's picture size (else the picture is shifted, with black borders, after a render resolution change).
|
||||
|
||||
- `device.config_sync`
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
@@ -2434,13 +2469,28 @@ Render less detail outside your gaze; adjust the full-resolution area or darken
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:**
|
||||
- `mode`: str, default empty — tracked, fixed or off (empty = Valve's default)
|
||||
- `support`: bool, default `False` — Internal APK compatibility support
|
||||
- `mode`: str, default empty — Catalog default: tracked, fixed or off (empty = Valve's default)
|
||||
- `radius`: float, default `0.0`, 0–0.45 — Full-resolution radius; 0 keeps the current runtime default
|
||||
- `test`: bool, default `False` — Darken lower-resolution regions (diagnostic)
|
||||
- **Catalog games (5):** Ocean Rift, Quantum Void, Tea For God, The 7th Guest VR, Vampire: The Masquerade - Justice.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Reduces shading away from the full-resolution area, following each eye's gaze. Default area size keeps the runtime's current profile unchanged. Custom radius is a fraction of the shorter eye-image dimension; the transition ends at twice that radius. Larger areas retain more detail but save less GPU work. Fixed stops the area following your eyes. The optional test darkens actual coarse-shaded fragments; rebuild/reinstall to toggle it. Tested on RE4, Deadpool and Asgard's Wrath 2. The Climb 2 gets a version-guarded private bridge at 1.0 scale; Batman gets its hash-verified allocation fix (unsupported OVRPlugin versions are refused). Other games may need additional fixes; turn this off if artifacts appear.
|
||||
> Reduces shading away from the full-resolution area, following each eye's gaze. Default area size keeps the runtime's current profile unchanged. Custom radius is a fraction of the shorter eye-image dimension; the transition ends at twice that radius. Larger areas retain more detail but save less GPU work. Fixed stops the area following your eyes. The optional test darkens actual coarse-shaded fragments. Runtime choices are in Game settings. Tested on RE4, Deadpool and Asgard's Wrath 2. The Climb 2 gets a version-guarded private bridge at 1.0 scale; Batman gets its hash-verified allocation fix (unsupported OVRPlugin versions are refused). Other games may need additional fixes; turn this off if artifacts appear.
|
||||
|
||||
### <a id="device-headset_model"></a>Headset model
|
||||
|
||||
Choose the headset identity the game sees; Quest 3 is the default.
|
||||
|
||||
- `device.headset_model` · on by default
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Quest 3 hardware is closest to Steam Frame: report Quest 3 by default.
|
||||
- **Options:** `model`: str, default empty — Headset model reported to the game.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `quest2-headset-identity` (info): OVRPlugin sees OVRPort's Quest 2 identity.
|
||||
- **Source:** [patches/frame/headset_model.py](../src/frameport/patches/frame/headset_model.py)
|
||||
|
||||
> Choose the OpenXR headset name and headset UUID reported to Quest games. Quest 3 is the default; Quest 2 preserves OVRPort's original answers. Requires a rebuild. Android Build.MODEL remains Lepton.
|
||||
|
||||
### <a id="device-hide_navbar"></a>Hide Android's navigation bar
|
||||
|
||||
@@ -2453,6 +2503,18 @@ Hides Android's back, home and recents buttons, which cover the app's own button
|
||||
|
||||
> Removes Android's back/home/recents buttons from a 2D app's window, where they cover the app's own controls. Turn it off for an app that needs the on-screen back button.
|
||||
|
||||
### <a id="device-language"></a>Game language
|
||||
|
||||
Choose the language for games that follow the system language. The game must include it. Apply with Update on Frame.
|
||||
|
||||
- `device.language` · also for 2D apps
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:** `tag`: str, default empty — Android language tag, e.g. de-DE (empty = English, US)
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> The language Android reports to the game. Lepton's Android is set to English (US), and games that follow the system language (most Unity and Unreal games) start in English. A language the game doesn't have still shows English. Applies with Update on Frame (the APK isn't changed).
|
||||
|
||||
### <a id="device-lepton_env"></a>Extra Lepton settings
|
||||
|
||||
Extra settings for the Android container (for testing).
|
||||
@@ -2484,7 +2546,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 (112):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable and 104 more.
|
||||
- **Catalog games (114):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable and 106 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".
|
||||
|
||||
+11
-2
@@ -13,6 +13,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
## Startup failures (visible in launch.log)
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Quest 3-only options missing; OVRPlugin logs `venderId 10291, systemName Oculus Quest2` (#373, Asgard's Wrath 2) | OVRPort's dispatcher overwrites FrameBridge's name and supplies a Quest 2 `XR_META_headset_id`; OVRPlugin prioritizes that UUID | `device.headset_model`: Quest 3 by default, per-game Quest 3S / Quest Pro / Quest 2; rebuild the APK. Byte-checked dispatcher name + UUID edit; Quest 2 leaves the dispatcher unchanged. See DEVICE_NOTES 'Per-game headset identity' |
|
||||
| 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 |
|
||||
@@ -20,7 +21,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| 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:` |
|
||||
| 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`) | Game settings → Picture → "Foveated rendering (Valve)": **Fixed** (`FDM_DEBUG=disable_offsets`; verify injection remains active on the installed runtime) or **Off** (`VK_INSTANCE_LAYERS=""`); catalog field `foveation:` |
|
||||
| With `device.foveation` Eye-tracked/Fixed, the sharp area looks right but everything around it is black with scattered coloured blocks (menus on quads too); pacing fine (GitHub #303, Medieval Dynasty; Vampire Survivors reported the same) | Unity's Oculus XR plugin asks for SUBSAMPLED eye images (`XR_META_vulkan_swapchain_create_info`, `VK_IMAGE_CREATE_SUBSAMPLED_BIT_EXT`); under Valve's FDM their coarse areas stay in the compact subsampled layout, which the compositor reads as an ordinary image. `foveation_fix=1` only dropped the `XrSwapchainCreateInfoFoveationFB` nodes and passed the META node on. With `foveation_fix=0` + `foveation_storage_fix=1` the adapter logs `foveation_storage_fix: shared allocation SUBSAMPLED cleared` for the eye swapchains | `device.foveation` (tracked/fixed) now also sets `foveation_storage_fix=1`, which clears SUBSAMPLED whatever `foveation_fix` is (generic version of Batman's OVRPlugin allocation fix). Rebuild + reinstall (new adapter). Headless-checked only (72 fps, bit cleared); picture needs the headset |
|
||||
| A Unity OpenXR game closes right after the OpenXR instance is created: `F VrApiLoader: vrapi_SetPropertyInt was called before vrapi_Initialize()!`, SIGABRT on UnityMain (GitHub #62, Jurassic World Aftermath) | the APK still ships Meta's VrApi loader; OVRPlugin (1.89, on OpenXR) calls it without starting VrApi and Meta's loader aborts. The VrApi bridge can't replace it (39 of the loader's 114 functions: OVRPlugin wouldn't link) | `frame.vrapi_stub` (same exports, each returns 0); triage `vrapi-before-init` |
|
||||
| A VrApi game exits at once: `UnsatisfiedLinkError … dlopen failed: cannot locate symbol "vrapi_PollEvent"` (GitHub #57, BlazeRush) | the engine imports a VrApi function the VrApi bridge didn't implement | the bridge has `vrapi_PollEvent`, `RecenterPose`, `SetDisplayRefreshRate`, `GetSystemPropertyFloatArray` (rebuild the game); the static check "VrApi functions resolvable" names any other missing one; triage `vrapi-symbol-missing` |
|
||||
@@ -156,7 +157,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Controllers do nothing (for example stuck on a setup screen); log: `xrSuggestInteractionProfileBindings … XR_ERROR_PATH_UNSUPPORTED` | the game only suggests bindings for Meta's newer profiles (Touch Plus / Touch Pro); the Frame's Android runtime knows `oculus/touch_controller`, not those | adapter `profile_remap` (default on): the bindings are suggested again for Touch (log: `controller profile … -> oculus/touch_controller`) |
|
||||
| Controllers "don't sync": virtual hands/controllers lie on the floor below you, no buttons (Unity OpenXR games that only suggest Touch Plus/Pro, e.g. Hotel Infinity #327); log with `input_diag=1`: only `meta/touch_controller_plus` (or Pro) suggested, `controller profile … -> oculus/touch_controller` | the runtime then reports `oculus/touch_controller` for each hand, a profile the game never suggested: Unity's OpenXR input creates no device for it | adapter `profile_remap` (default on) reports the game's own profile for such hands (log: `profile_remap: /user/hand/left reported as meta/touch_controller_plus`) |
|
||||
| Controllers "don't sync": hands on the floor below you, no buttons, in a Unity OpenXR game with Meta's OpenXR features (e.g. Spatial Zombies #328); log: `xrCreateAction: XR_ERROR_PATH_UNSUPPORTED` (input_diag: `xrCreateAction -> XR_ERROR_PATH_UNSUPPORTED (thumbstick)`), then `xrGetCurrentInteractionProfile: XR_ERROR_ACTIONSET_NOT_ATTACHED` | Meta's "Detached … Controller" devices add `/user/detached_controller_meta/left|right` to Unity's merged actions; the Frame has no XR_META_detached_controllers, the action fails and Unity attaches no action set (OVRPlugin's input goes with it) | adapter `profile_remap` (default on) leaves those paths out of actions and bindings (log: `profile_remap: action thumbstick without 2 detached-controller path(s)`) |
|
||||
| Some buttons do nothing or the wrong thing (for example a Quest game's X/Y on the Frame's left controller) | the game binds only Quest Touch and SteamVR maps Touch onto the Frame controllers with its own remap (Touch left X/Y land on the d-pad, right X and Y repeat B), or the runtime rejected a profile or an input call | turn on adapter `input_diag` (diagnostics, off by default; `frameport settings <pkg> input_diag=1`) and look for `input_diag:` lines: `bindings: <profile> accepted` or `unsupported: interaction profile <profile> -> <result>` with the rejected paths, `bindings: /user/hand/<hand> uses <profile>`, `unsupported: function <name>`, `unsupported: xrCreateAction -> <result> (<action>)` |
|
||||
| Some buttons do nothing or the wrong thing (for example a Quest game's X/Y on the Frame's left controller) | the game binds only Quest Touch and SteamVR maps Touch onto the Frame controllers with its own remap (Touch left X/Y land on the d-pad, right X and Y repeat B), or the runtime rejected a profile or an input call | choose **Game settings → Controllers → Controller buttons**: Mirror (`quest_buttons=1`) or Quest pair (`2`); `0` restores SteamVR default. **Settings → Controllers** sets the global default and can apply it to installed games without overrides. Used from the next launch; [mapping and limits](CONTROLLERS.md). For rejected bindings, turn on adapter `input_diag` (`frameport settings <pkg> input_diag=1`) and inspect `input_diag:` lines: accepted profiles, rejected paths and input calls. With a mode enabled, expect `quest_buttons: frame_controller bindings (mode N, K bindings, D dropped): <XrResult>` |
|
||||
| Unity game hangs or the whole Frame restarts (for example in a menu); log: `The current MSAA level is 0, but the recommended MSAA level is 4. Switching to the recommended level.` (for example Lucky's Tale) | Meta's OVRManager (`useRecommendedMSAALevel`) turns 4x MSAA on at runtime, past QualitySettings; multisampled render-to-texture on GLES/Zink hangs the GPU | `frame.unity_runtime_msaa_off` (Cpp2IL: `OVRDisplay.get_recommendedMSAALevel` → 0) |
|
||||
| Unity game: one eye shows only effects or gray, the other is fine (for example I Am Cat, Oculus XR Plugin on GLES) | the game's single-pass multiview rendering goes wrong for array slice 1 (the adapter submits both slices correctly) | `frame.unity_multipass` (Cpp2IL: `OculusSettings.GetStereoRenderingMode` → MultiPass); `swap_eyes=1` tells whether the game or the adapter is at fault |
|
||||
| Setup/intro loops every launch (Espire 2) | save folders created without write permission | launcher repairs permissions every 2 s (built in) |
|
||||
@@ -181,6 +182,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| A phone/tablet app shows nothing in the headset, or a VR app opens as a flat window | FramePort's VR/2D guess (`vr_kind`) is wrong for this app | patch `device.display_mode` → **Flat window** or **VR** (Customize → "Show as VR or as a flat window"), then Update on Frame |
|
||||
| An install button opens another program (or nothing) instead of FramePort | another program owns the link type, or the switch is off, or macOS (Flet can't receive links there) | Settings → Install links → **Use FramePort for these links**; else Add games → Install from a link… (paste the button's address) |
|
||||
| 2D Android game ignores the controllers: they only move a pointer, an SDL game keeps its touch controls ("no controller attached"); Android's log lists only `wayland_touch`/`wayland_keyboard`/`wayland_pointer` (GitHub #162, Skate 3) | Lepton's Android gets keyboard, pointer and touch from the Wayland seat only: no gamepad device | patch `device.steam_gamepad` (2D apps; suggested when the manifest declares `android.hardware.gamepad` or `LEANBACK_LAUNCHER`; no APK change, reinstall): the launcher puts FramePort's Podman wrapper (`~/.local/share/frameport/agent/bin/podman`) first on PATH, which binds the host's `/dev/input` at `/dev/fp-input` plus an Xbox 360 key layout (Vendor_28de_Product_11ff.kl, incl. D-pad buttons), and exports `LEPTON_ENV_SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=1`; the launcher's `_gamepad_links` worker (agent v85) keeps `/dev/input/eventN` links to Steam Input's virtual pads (uinput, 28de, "Microsoft X-Box 360 pad N") in step while the game runs, so a pad Steam makes later (controllers woke up, reconnected) is added and a gone one removed (Android's EventHub follows through inotify). Check: `<base>/gamepad.log` (`pads: event5` / `pads: none`), launch.log `EventHub: New device … pad`, `podman exec lepton-steamlaunch-<appid> dumpsys input` lists the pad. Steam Input must be on for the shortcut; Steam activates the shortcut's own layout while the game has focus (controller.txt `Queueing activation for controller: 0 app: <shortcut appid as signed int>`), and Steam's UI layout (app 769) while the dashboard is open. Off for one start: `FRAMEPORT_NO_GAMEPAD=1 %command%` |
|
||||
| Game starts in English and has no in-game language choice | Game follows Lepton's English Android locale | Choose Game language under Customize (`device.language`), then Update on Frame; the game must include the selected language |
|
||||
| 2D Android app: back/home/recents buttons cover the app's own buttons | Android's navigation bar | patch `device.hide_navbar` (on by default for apps without VR) sets `qemu.hw.mainkeys=1`; reinstall to apply |
|
||||
| Black screen, audio works, GLES engine with direct VrApi | Mesa rejects Quest-style GLSL | `frame.gl_shim` (logs `GLShim: SHADER COMPILE FAILED` + source lines) |
|
||||
| Black screen, audio works, log `Unsupported VrApi layer type N` | bridge drops frames containing that layer | extend the bridge (cylinder=3 is converted to a quad already) |
|
||||
@@ -289,3 +291,10 @@ Scan a folder with Rift game dumps (Windows game folders) like Quest dumps; they
|
||||
| Proton game hangs at start in a headless/SSH launch; log `no driver could be loaded` / `explorer process failed to start` | no display session (DISPLAY/GAMESCOPE_WAYLAND_DISPLAY) | the Proton launch.sh takes them from the running Steam (built in) |
|
||||
| Unreal game starts (window created) then `CrashReportClient` runs | the game crashed under Proton; often no VR runtime reached it (Revive off) | keep `pcvr.revive` on (repacks' bundled LibRevive64.dll isn't loaded by itself); read the game log + crash summary in the launch log |
|
||||
| `VK_ERROR_DEVICE_LOST` under DXVK | freedreno GPU hang | `pcvr.proton_log` to capture; PC mode |
|
||||
| Game page shows another game's description or screenshots, or they stay wrong after Rename (GitHub #377: "Lollipop" matched an adult Steam game) | Store details were looked up by title and kept after a rename; Steam exact-title matches had no content check | Rename/reset drops title-matched details and screenshots and fetches them again under the new name (package-matched details and custom art stay); Steam matches with sexual-content descriptors are rejected for details and artwork |
|
||||
| `Rec.709 color space unavailable`, the game quits before xrCreateSession (GitHub #336, OutRun Quest VR) | The Frame's runtime has no XR_FB_color_space | Adapter setting `fb_color_space=1` (triage `color-space-missing` suggests it): FrameBridge offers the extension (v3), reports Rec.709 and accepts any colour space without converting. Reaches games with their own OpenXR loader; OVRPort's dispatcher can't forward the new functions. Device-unverified |
|
||||
|
||||
Foveation mode, radius and diagnostic test are per-game user preferences (discussion #365), excluded from
|
||||
shared working recipes. Saving Game settings rewrites launcher exports through `set_settings`; changes apply
|
||||
at the next game start without rebuilding or reinstalling. Existing builds need one update for dormant diagnostic
|
||||
support. Catalog `device.foveation` defaults and the guarded Batman/Climb 2 APK compatibility work remain supported.
|
||||
@@ -0,0 +1,150 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="864" viewBox="0 0 1080 864" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Quest Touch buttons on Steam Frame controllers</title>
|
||||
<desc id="desc">Quest Touch reference and three Frame mappings: SteamVR default, Mirror, and Quest pair. Each Steam Frame controller button is labelled with the Quest Touch button it sends. A dash means it sends nothing. Left Touch buttons are X and Y; right are A and B.</desc>
|
||||
<rect width="1080" height="864" rx="16" fill="#0D0E12"/>
|
||||
<g font-family="DejaVu Sans,Arial,sans-serif">
|
||||
<text x="20" y="32" fill="#EDEEF2" font-size="24" font-weight="600" text-anchor="start">Controller buttons</text>
|
||||
<text x="20" y="57" fill="#A7ABB7" font-size="14" font-weight="400" text-anchor="start">Blue: X / A · Orange: Y / B or Menu · —: sends nothing</text>
|
||||
<g transform="translate(16 72)">
|
||||
<rect x="0" y="0" width="520" height="380" rx="12" fill="#16181E" stroke="#2A2D37"/>
|
||||
<text x="20" y="31" fill="#EDEEF2" font-size="20" font-weight="600" text-anchor="start">Quest Touch reference</text>
|
||||
<text x="20" y="55" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="start">Two face buttons per hand, plus Menu on the left</text>
|
||||
<text x="140" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
|
||||
<circle cx="163" cy="206" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="163" y="212.84" fill="#3AA8FF" font-size="19" font-weight="700" text-anchor="middle">X</text>
|
||||
<circle cx="117" cy="151" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="117" y="157.84" fill="#FF8A1F" font-size="19" font-weight="700" text-anchor="middle">Y</text>
|
||||
<rect x="83" y="251" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="140" y="272" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Menu</text>
|
||||
<text x="380" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
|
||||
<circle cx="357" cy="206" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="357" y="212.84" fill="#3AA8FF" font-size="19" font-weight="700" text-anchor="middle">A</text>
|
||||
<circle cx="403" cy="151" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="403" y="157.84" fill="#FF8A1F" font-size="19" font-weight="700" text-anchor="middle">B</text>
|
||||
<text x="260.0" y="366" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Lower: X / A · Upper: Y / B</text>
|
||||
</g>
|
||||
<g transform="translate(544 72)">
|
||||
<rect x="0" y="0" width="520" height="380" rx="12" fill="#16181E" stroke="#2A2D37"/>
|
||||
<text x="20" y="31" fill="#EDEEF2" font-size="20" font-weight="600" text-anchor="start">SteamVR default</text>
|
||||
<text x="20" y="55" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="start">Steam Frame controllers: each button shows the Quest button it sends</text>
|
||||
<text x="140" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
|
||||
<path d="M102,256 C104,314 114,334 136,338 C158,342 178,326 194,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="140" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="174" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
|
||||
<circle cx="174" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<circle cx="126" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="126" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
|
||||
<rect x="131" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#FF8A1F"/>
|
||||
<text x="144" y="130" fill="#FF8A1F" font-size="10" font-weight="600" text-anchor="middle">View: Menu</text>
|
||||
<path d="M84,144 h28 v30 h30 v28 h-30 v30 h-28 v-30 h-30 v-28 h30 Z" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="98" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
|
||||
<text x="69" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
|
||||
<text x="127" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
|
||||
<text x="98" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">X</text>
|
||||
<text x="380" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
|
||||
<path d="M326,256 C328,314 338,334 360,338 C382,342 402,326 418,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="380" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="346" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
|
||||
<circle cx="346" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<circle cx="394" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="394" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
|
||||
<rect x="363" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#868A96"/>
|
||||
<text x="376" y="130" fill="#868A96" font-size="10" font-weight="600" text-anchor="middle">Menu: —</text>
|
||||
<circle cx="422" cy="159" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="422" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
|
||||
<text x="437" y="149" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Y</text>
|
||||
<circle cx="393" cy="188" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="393" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
|
||||
<text x="408" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">X</text>
|
||||
<circle cx="451" cy="188" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="451" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
|
||||
<text x="466" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">B</text>
|
||||
<circle cx="422" cy="217" r="14" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="422" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">A</text>
|
||||
<text x="437" y="207" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">A</text>
|
||||
<text x="260.0" y="366" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Sticks, triggers and grips unchanged · bumpers send nothing</text>
|
||||
</g>
|
||||
<g transform="translate(16 468)">
|
||||
<rect x="0" y="0" width="520" height="380" rx="12" fill="#16181E" stroke="#2A2D37"/>
|
||||
<text x="20" y="31" fill="#EDEEF2" font-size="20" font-weight="600" text-anchor="start">Mirror</text>
|
||||
<text x="20" y="55" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="start">Steam Frame controllers: each button shows the Quest button it sends</text>
|
||||
<text x="140" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
|
||||
<path d="M102,256 C104,314 114,334 136,338 C158,342 178,326 194,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="140" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="174" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
|
||||
<circle cx="174" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<circle cx="126" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="126" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
|
||||
<rect x="131" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#FF8A1F"/>
|
||||
<text x="144" y="130" fill="#FF8A1F" font-size="10" font-weight="600" text-anchor="middle">View: Menu</text>
|
||||
<path d="M84,144 h28 v30 h30 v28 h-30 v30 h-28 v-30 h-30 v-28 h30 Z" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="98" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
|
||||
<text x="69" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
|
||||
<text x="127" y="193.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">X</text>
|
||||
<text x="98" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">X</text>
|
||||
<text x="380" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
|
||||
<path d="M326,256 C328,314 338,334 360,338 C382,342 402,326 418,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="380" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="346" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
|
||||
<circle cx="346" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<circle cx="394" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="394" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
|
||||
<rect x="363" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#868A96"/>
|
||||
<text x="376" y="130" fill="#868A96" font-size="10" font-weight="600" text-anchor="middle">Menu: —</text>
|
||||
<circle cx="422" cy="159" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="422" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
|
||||
<text x="437" y="149" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Y</text>
|
||||
<circle cx="393" cy="188" r="14" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="393" y="193.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">A</text>
|
||||
<text x="408" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">X</text>
|
||||
<circle cx="451" cy="188" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="451" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
|
||||
<text x="466" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">B</text>
|
||||
<circle cx="422" cy="217" r="14" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="422" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">A</text>
|
||||
<text x="437" y="207" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">A</text>
|
||||
<text x="260.0" y="366" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Sticks, triggers and grips unchanged · bumpers send nothing</text>
|
||||
</g>
|
||||
<g transform="translate(544 468)">
|
||||
<rect x="0" y="0" width="520" height="380" rx="12" fill="#16181E" stroke="#2A2D37"/>
|
||||
<text x="20" y="31" fill="#EDEEF2" font-size="20" font-weight="600" text-anchor="start">Quest pair</text>
|
||||
<text x="20" y="55" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="start">Steam Frame controllers: each button shows the Quest button it sends</text>
|
||||
<text x="140" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
|
||||
<path d="M102,256 C104,314 114,334 136,338 C158,342 178,326 194,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="140" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="174" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
|
||||
<circle cx="174" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<circle cx="126" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="126" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
|
||||
<rect x="131" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#FF8A1F"/>
|
||||
<text x="144" y="130" fill="#FF8A1F" font-size="10" font-weight="600" text-anchor="middle">View: Menu</text>
|
||||
<path d="M84,144 h28 v30 h30 v28 h-30 v30 h-28 v-30 h-30 v-28 h30 Z" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="98" y="164.04" fill="#868A96" font-size="14" font-weight="700" text-anchor="middle">—</text>
|
||||
<text x="69" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
|
||||
<text x="127" y="193.04" fill="#868A96" font-size="14" font-weight="700" text-anchor="middle">—</text>
|
||||
<text x="98" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">X</text>
|
||||
<text x="380" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
|
||||
<path d="M326,256 C328,314 338,334 360,338 C382,342 402,326 418,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="380" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="346" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
|
||||
<circle cx="346" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<circle cx="394" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="394" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
|
||||
<rect x="363" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#868A96"/>
|
||||
<text x="376" y="130" fill="#868A96" font-size="10" font-weight="600" text-anchor="middle">Menu: —</text>
|
||||
<circle cx="422" cy="159" r="14" fill="#1D1F27" stroke="#868A96" stroke-width="1.5"/>
|
||||
<text x="422" y="164.04" fill="#868A96" font-size="14" font-weight="700" text-anchor="middle">—</text>
|
||||
<text x="437" y="149" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Y</text>
|
||||
<circle cx="393" cy="188" r="14" fill="#1D1F27" stroke="#868A96" stroke-width="1.5"/>
|
||||
<text x="393" y="193.04" fill="#868A96" font-size="14" font-weight="700" text-anchor="middle">—</text>
|
||||
<text x="408" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">X</text>
|
||||
<circle cx="451" cy="188" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="451" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
|
||||
<text x="466" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">B</text>
|
||||
<circle cx="422" cy="217" r="14" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="422" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">A</text>
|
||||
<text x="437" y="207" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">A</text>
|
||||
<text x="260.0" y="366" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Sticks, triggers and grips unchanged · bumpers send nothing</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,47 @@
|
||||
# Quest controller mode: contract (`quest_buttons`)
|
||||
|
||||
This file is the contract between the native (FrameBridge) and app/docs halves of the feature. The mapping itself
|
||||
lives in [`quest_buttons.json`](quest_buttons.json); everything else follows it.
|
||||
|
||||
## Problem
|
||||
Quest games suggest bindings only for `/interaction_profiles/oculus/touch_controller`. The Steam Frame controllers
|
||||
are two gamepad halves (Valve: https://partner.steamgames.com/doc/steamhardware/steamframe/input):
|
||||
- left: `dpad_up|dpad_down|dpad_left|dpad_right` (click, touch), `view` (click, touch), `system`, `bumper`,
|
||||
`squeeze` (click, touch, value), `trigger` (click, touch, value), `thumbstick` (vector, x, y, click, touch),
|
||||
`grip/pose`, `aim/pose`, `output/haptic`
|
||||
- right: `a|b|x|y` (click, touch), `menu` (click, touch), `system`, `bumper`, and the same squeeze/trigger/thumbstick/
|
||||
poses/haptic.
|
||||
|
||||
SteamVR uses a game's Frame Controller binding first; without one it remaps Touch: Touch B/Y -> the three top buttons
|
||||
(left d-pad left/up/right, right X/Y/B), Touch A/X -> the bottom one, Touch menu -> View (mode `0` in the JSON).
|
||||
|
||||
## Setting
|
||||
FrameBridge adapter setting `quest_buttons` (int, read from settings like every other key; numbers only):
|
||||
- `0` = off (default): FrameBridge does nothing new, SteamVR's remap applies.
|
||||
- `1` = Mirror, `2` = Quest pair: see `modes` in the JSON.
|
||||
|
||||
Library setting `input.quest_buttons` (0/1/2, default 0) = the global default, used at install for games whose recipe
|
||||
doesn't name `adapter.quest_buttons`.
|
||||
|
||||
## Native behaviour (FrameBridge)
|
||||
1. At `xrCreateInstance`, when `quest_buttons` != 0 and the runtime lists `XR_VALVE_frame_controller_interaction`,
|
||||
add it to the enabled extensions (the adapter already rewrites this list). Not listed -> log and stay off.
|
||||
2. When the game's suggestion for the Touch profile succeeds (directly, or through `profile_remap`'s
|
||||
`remap_to_touch`), suggest a translated set for `/interaction_profiles/valve/frame_controller_valve`:
|
||||
- each binding `/user/hand/<hand>/input/<touch input>[/<component>]` maps by the JSON: `common` 1:1, button
|
||||
`x|y|a|b` + `/click` or `/touch` -> each listed Frame button + the same component, `menu/click` -> listed
|
||||
Frame button + `/click`; `output/haptic` 1:1; anything in `dropped` or unknown is left out.
|
||||
- a later Touch suggestion by the same game replaces the Frame set (suggest again; the last call per profile
|
||||
wins in OpenXR).
|
||||
- a failed Frame suggestion is logged and ignored: the game's own call result is never changed.
|
||||
3. `xrGetCurrentInteractionProfile`: with `quest_buttons` != 0, a `/valve/` answer is reported as Touch even when
|
||||
`controller_fix` is off.
|
||||
4. Log lines (logcat tag of the adapter):
|
||||
- `quest_buttons: extension XR_VALVE_frame_controller_interaction enabled` / `... not offered by the runtime`
|
||||
- `quest_buttons: frame_controller bindings (mode N, K bindings, D dropped): <XrResult>`
|
||||
- with `input_diag=1`: `input_diag: quest_buttons <touch path> -> <frame path>` per added binding.
|
||||
|
||||
## Owners
|
||||
- native stream: `native/adapter/*`, `tests/test_quest_buttons.py`, rebuilt adapter artifact.
|
||||
- app stream: `src/frameport/**` (settings, install fallback, UI, help), `docs/**` except this file and the JSON,
|
||||
`scripts/controller_diagram.py`, `README.md` link, `agent/frameport_agent.py` comment only, Python tests.
|
||||
@@ -0,0 +1,249 @@
|
||||
# App stream: Quest controller buttons
|
||||
|
||||
## Implementation
|
||||
|
||||
- Added `adapter.quest_buttons` (integer, default 0), common Controllers choice with SteamVR default / Mirror /
|
||||
Quest pair. It is available on both bridge ABIs, is not an XR shim setting and has no detection suggestion.
|
||||
No agent, patch, setting or analysis revision was changed.
|
||||
- `patches.settings.adapter_settings(..., defaults=...)` and the headset identity wrapper stay pure.
|
||||
`core.controllers.effective_adapter_settings` supplies the library preference at build, install, live suggestion
|
||||
and dialog boundaries. Recipes override it, including an explicit 0. APK `libframe_settings.so` and installed
|
||||
`settings.conf` receive the effective value.
|
||||
- Recipe/APK/configuration fingerprints do not read the global preference. The existing agent updates its
|
||||
configuration fingerprint during `set_settings`; `game_update.configuration_matches` therefore accepts all
|
||||
three inherited controller values, while still checking explicit overrides and every other configuration field.
|
||||
An offline test exercises the real agent handler to prevent a global apply making the library outdated.
|
||||
- Game settings has a **Use global default (mode)** option. Saving unchanged inherited settings keeps the recipe
|
||||
unchanged; choosing SteamVR default stores an explicit 0. Reset restores the effective global value.
|
||||
- Settings → Controllers stores `input.quest_buttons`, shows a diagram for the choice, and offers **Apply to
|
||||
installed games now** while connected. The existing FIFO Frame tool job pushes only `quest_buttons` through
|
||||
`target.set_settings`. It skips non-Quest games, flat Android apps known to the library, and recipe overrides.
|
||||
A library-less install with an override listed in its deployment is also skipped. Cancellation is checked per game.
|
||||
- The CLI already has `settings <pkg> quest_buttons=N` (runtime only) and `recipe <pkg> --set quest_buttons=N`
|
||||
(persistent recipe). No global CLI option was added: there is no existing general library configuration command,
|
||||
and `settings` specifically writes an installed game's settings. The global preference is available in the GUI.
|
||||
- `scripts/controller_diagram.py` reads the unmodified JSON contract and literal Portal theme colours. It generates
|
||||
the full four-panel SVG, three bundled compact SVGs and the marked Markdown table. `--check` checks all outputs;
|
||||
`--compact --mode N [--out path]` generates just one pair. Bundled images use the existing glyph asset copy and
|
||||
URL path, with no image bytes or filesystem reads in the diagram render helper.
|
||||
- Added the user guide and README/install/playbook/runtime links, regenerated the patch docs, site map and
|
||||
translation template. The only agent edit corrects the old D-pad comment. No shared smoke script was edited.
|
||||
|
||||
## Offline checks and visual review
|
||||
|
||||
Commands use the existing venv without resolving packages. The default uv cache is read-only in this sandbox;
|
||||
plain `uv run` attempted an editable metadata refresh requiring unavailable cached build dependencies.
|
||||
|
||||
```sh
|
||||
export UV_CACHE_DIR=/tmp/frameport-qb-uv
|
||||
export UV_NO_SYNC=1
|
||||
uv run pytest -q
|
||||
uv run python scripts/patch_docs.py --check
|
||||
uv run python scripts/controller_diagram.py --check
|
||||
uv run python scripts/i18n_extract.py --check
|
||||
```
|
||||
|
||||
The focused settings, update, translation, patch and UI tests passed (147 passed, 2 skipped before the final
|
||||
agent-fingerprint regression; the follow-up controller/update/UI-state run passed 89 tests, with the existing
|
||||
socket test deselected). Final full suite: **2015 passed, 45 skipped, 12 failed, 16 setup errors** (67.76 s). All 28 remaining failures/errors
|
||||
are existing local-socket tests rejected by the sandbox (deeplink, devkit, install links, link updates, livestream,
|
||||
setup URL and pairing). The translation failure from the first run was fixed. The changed feature's tests pass;
|
||||
the full suite cannot be reported as passing in this environment. The log is local at `/tmp/qb-app-final-pytest.log`.
|
||||
Patch docs, diagram and translation generation checks pass.
|
||||
|
||||
The real Flet smoke command was attempted:
|
||||
|
||||
```sh
|
||||
FRAMEPORT_NO_UPDATE_CHECK=1 FRAMEPORT_NO_ANNOUNCEMENTS=1 \
|
||||
uv run python scripts/ui_smoke.py --out .shots --fake-frame \
|
||||
--game com.BithellGames.Arcsmith --only tools,game-settings-dialog
|
||||
```
|
||||
|
||||
It cannot start here: the sandbox rejects even creation/binding of loopback sockets (`PermissionError: [Errno 1]
|
||||
Operation not permitted`). Flet reports it cannot bind its server on port 8557. Consequently no Settings or Game
|
||||
settings PNG was produced or inspected. This is an outstanding maintainer validation step, not a passed UI check.
|
||||
No Frame or external network connection was attempted.
|
||||
|
||||
The diagrams were rendered offline with ffmpeg and visually inspected. Both are readable, with correct left/right
|
||||
positions and dimmed unbound buttons. Local, uncommitted review PNGs:
|
||||
|
||||
- `.shots/quest-buttons.png` — all four panels.
|
||||
- `.shots/quest-buttons-compact.png` — the app's Mirror image.
|
||||
|
||||
The Impeccable mechanical detector returned no findings on the changed UI files; targeted Ruff and whitespace
|
||||
checks passed.
|
||||
|
||||
## Capture the actual UI on a machine that permits loopback
|
||||
|
||||
Use a test library in `FRAMEPORT_HOME` containing a Quest game. `ui_smoke.py` has no Controllers section option,
|
||||
so this temporary wrapper reuses its real browser/driver and scrolls the two relevant surfaces. It does not change
|
||||
tracked files. Set `pkg` to a Quest game in that test library first.
|
||||
|
||||
```sh
|
||||
pkg=com.example.questgame
|
||||
cat > .home/capture_controllers.py <<'PY'
|
||||
import sys
|
||||
import time
|
||||
sys.path.insert(0, 'scripts')
|
||||
import ui_smoke as smoke
|
||||
|
||||
original_driver = smoke.driver
|
||||
|
||||
def driver(app, steps, *args):
|
||||
adjusted = []
|
||||
for name, action in steps:
|
||||
if name == 'tools':
|
||||
def controllers(a, original=action):
|
||||
original(a)
|
||||
time.sleep(1)
|
||||
a.page.run_task(a.settings_view.show_section, 'controllers')
|
||||
adjusted.append(('settings-controllers', controllers))
|
||||
elif name == 'game-settings-dialog':
|
||||
def game_settings(a, original=action):
|
||||
original(a)
|
||||
time.sleep(1)
|
||||
dialog = next(d for d in reversed(a.page._dialogs.controls)
|
||||
if d.open and type(d).__name__ == 'AlertDialog')
|
||||
a.page.run_task(dialog.content.content.scroll_to, offset=400, duration=0)
|
||||
adjusted.append(('game-settings-controllers', game_settings))
|
||||
else:
|
||||
adjusted.append((name, action))
|
||||
return original_driver(app, adjusted, *args)
|
||||
|
||||
smoke.driver = driver
|
||||
raise SystemExit(smoke.main())
|
||||
PY
|
||||
FRAMEPORT_NO_UPDATE_CHECK=1 FRAMEPORT_NO_ANNOUNCEMENTS=1 \
|
||||
uv run python .home/capture_controllers.py --out .shots/controllers \
|
||||
--fake-frame --game "$pkg" --only tools,game-settings-dialog
|
||||
```
|
||||
|
||||
Inspect `.shots/controllers/01-settings-controllers.png` and
|
||||
`.shots/controllers/02-game-settings-controllers.png` at 1280×820; repeat with `--viewport 1024x768`. Verify the
|
||||
choice text, diagram, explanation and apply button fit; scroll a little in the dialog if the game's relevant
|
||||
Picture settings make offset 400 miss the row. Also choose all three modes and the global option in the actual
|
||||
GUI, checking that the diagram changes and re-opening preserves inheritance or the explicit override.
|
||||
|
||||
## Exact device verification after native integration
|
||||
|
||||
These steps are for the maintainer; none were executed in this stream. Merge the native stream and its adapter
|
||||
artifacts first. Use a Quest game that binds all four Touch face buttons (and, separately, an available 32-bit
|
||||
bridge game). Set `pkg` to its real library package; keep the same `FRAMEPORT_HOME` used for that test library and
|
||||
its paired Frame. Close the game before rebuilding/installing.
|
||||
|
||||
1. Build/install the integrated adapter once. With global mode Mirror and the game's **Use global default**
|
||||
selected, run:
|
||||
|
||||
```sh
|
||||
pkg=com.example.questgame
|
||||
uv run frameport build "$pkg"
|
||||
uv run frameport install "$pkg" --force --apk-only
|
||||
uv run frameport settings "$pkg" input_diag=1
|
||||
```
|
||||
|
||||
Confirm the build's `lib/<abi>/libframe_settings.so` contains `quest_buttons=1`. The following PC-side inspection
|
||||
reads the APK path recorded by the build:
|
||||
|
||||
```sh
|
||||
uv run python - "$pkg" <<'PY'
|
||||
import sys, zipfile
|
||||
from frameport.core import library
|
||||
with zipfile.ZipFile(library.game(sys.argv[1])['build']['apk']) as apk:
|
||||
for name in apk.namelist():
|
||||
if name.endswith('/libframe_settings.so'):
|
||||
print(name, apk.read(name).decode())
|
||||
PY
|
||||
```
|
||||
|
||||
2. For each mode, write it and launch from Steam/FramePort Play while wearing the headset:
|
||||
|
||||
```sh
|
||||
uv run frameport settings "$pkg" quest_buttons=1 input_diag=1
|
||||
# Close the game, then repeat with quest_buttons=2, then quest_buttons=0.
|
||||
```
|
||||
|
||||
On the Frame, set `pkg` to the same package and locate its actual base (works for internal storage or another
|
||||
drive). After each launch inspect:
|
||||
|
||||
```sh
|
||||
pkg=com.example.questgame
|
||||
base=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["base"])' \
|
||||
"$HOME/Applications/quest-frame/$pkg/deployment.json")
|
||||
grep '^quest_buttons=' "$base/settings.conf"
|
||||
grep -E 'quest_buttons:|input_diag: quest_buttons' "$base/launch.log"
|
||||
```
|
||||
|
||||
Expected for modes 1/2:
|
||||
|
||||
```text
|
||||
quest_buttons: extension XR_VALVE_frame_controller_interaction enabled
|
||||
quest_buttons: frame_controller bindings (mode N, K bindings, D dropped): <XrResult>
|
||||
input_diag: quest_buttons <touch path> -> <frame path>
|
||||
```
|
||||
|
||||
`N` is 1 or 2, `K` is nonzero, and the Frame suggestion result must indicate success. Input diagnostics must
|
||||
match the JSON paths. For mode 0 there should be no new FrameBridge Frame suggestion. Check the physical face
|
||||
buttons against every row of `docs/CONTROLLERS.md`: Mirror repeats X/A on bottom+inner, Y/B on top+outer;
|
||||
Quest pair leaves left up/right and right X/Y unbound. Left View sends Menu in every mode. Check touch actions
|
||||
where the game exposes them, triggers, grip pressure, sticks, poses and haptics. Bumpers are unbound. Repeat on
|
||||
the 32-bit bridge ABI. Existing custom SteamVR bindings should still take precedence.
|
||||
|
||||
3. Verify global apply with two installed games: one following the global choice, one explicitly set to SteamVR
|
||||
default (0). Set Settings → Controllers to Quest pair; click **Apply to installed games now**. Expect one FIFO
|
||||
Frame tool job, `quest_buttons=2` only on inherited games, and the explicit game's 0 retained. Check that neither
|
||||
game gains an Update ready badge solely from the global change/apply. Re-open Game settings: the inherited
|
||||
choice says **Use global default (Quest pair)**. Relaunch to verify the mode; a running game keeps its old mode.
|
||||
Also test changing an explicit mode and then resetting to the global choice.
|
||||
|
||||
4. If an extension-less runtime is available, modes 1/2 must log
|
||||
`quest_buttons: extension XR_VALVE_frame_controller_interaction not offered by the runtime` and retain the
|
||||
SteamVR default mapping without a launch failure. Otherwise leave this case to the native offline tests.
|
||||
|
||||
5. Undo: set the global choice to SteamVR default and apply to inherited installed games. Restore any pre-test
|
||||
per-game choices in Game settings, and disable diagnostics:
|
||||
|
||||
```sh
|
||||
uv run frameport settings "$pkg" quest_buttons=0 input_diag=0
|
||||
```
|
||||
|
||||
A persistent test override made with the recipe CLI can be removed with
|
||||
`uv run frameport recipe "$pkg" --disable adapter.quest_buttons`; then select the global choice and save in
|
||||
Game settings to push its current effective value.
|
||||
|
||||
## Contract questions / remaining integration work
|
||||
|
||||
- The contract translates successful Touch suggestions. A game suggesting only its own Frame bindings is
|
||||
unaffected. If a game suggests both Touch and its own Frame profile, ordering/priority is not specified by the
|
||||
contract; verify with the native stream before claiming both-profile games are unaffected. The user guide
|
||||
qualifies this limit accordingly.
|
||||
- No device claims were added. The runtime reference marks new-mode verification pending integration.
|
||||
- A direct CLI `settings` write is intentionally temporary relative to the recipe/global preference. Use
|
||||
`recipe --set quest_buttons=N` or the dialog for a persistent per-game override.
|
||||
- Actual UI screenshots and the socket-dependent full-suite tests require a less restricted local environment.
|
||||
|
||||
## Round 2
|
||||
|
||||
- Global apply still writes every inherited Quest install, but separates games ready for the next launch from
|
||||
games that need **Update on Frame first**. The job result supplies the completion toast, so both show the same
|
||||
counts. The Settings explanation includes the one-time update requirement.
|
||||
- `_build_inputs_changed` also checks the converter and source APK, so it cannot decide adapter support alone.
|
||||
Its manifest reader was extracted into `build.native_checksums` and is shared by `_build_inputs` and the
|
||||
controller check. Only adapter entries are compared. Missing/different checksums or an unknown/different
|
||||
installed APK are reported as needing an update; unrelated artifact changes do not affect this count. This is
|
||||
deliberately conservative when an older adapter already supports the feature but differs from the bundled one.
|
||||
- The section reads its default once into a local snapshot, uses that for the dropdown and preview, and updates it
|
||||
on selection. Regression tests check the single read and that the existing preview control changes in place.
|
||||
- The user guide now covers game-owned Frame bindings/profile preservation, bare binding paths and continued
|
||||
proximity emulation. This supersedes the earlier question about games suggesting both Frame and Touch profiles.
|
||||
The maintainer's triangle arrows and shared JSON were left unchanged.
|
||||
- Focused tests: **135 passed, 2 skipped** (`test_controller_preferences.py`, `test_patches.py`,
|
||||
`test_game_update.py`). Patch-doc, translation-template and controller-diagram checks pass; the translation
|
||||
template was regenerated. Targeted Ruff and whitespace checks pass.
|
||||
- Device follow-up: on an old/unknown install, choose Mirror and apply globally. Expect its setting to be pushed but
|
||||
the result/toast to count it under **needs Update on Frame first**. Update that game once, apply again, then
|
||||
relaunch: expect it in the next-launch count and the existing mode-1 binding logs described above. Keep a second
|
||||
game with an explicit override and verify it is skipped throughout. Undo by choosing SteamVR default and applying
|
||||
again; restore any per-game test overrides. No device or external network access was used in this round.
|
||||
- Final whole suite: **2029 passed, 116 skipped, 12 failed, 16 setup errors** (67.52 s). The remaining failures
|
||||
and errors are the existing local-socket tests blocked by the sandbox, as in Round 1. Full log:
|
||||
`/tmp/qb-app-round2-pytest.log`. No test skips or socket workarounds were added.
|
||||
@@ -0,0 +1,192 @@
|
||||
# Quest controller mode: native stream
|
||||
|
||||
## Original implementation
|
||||
|
||||
- `native/adapter/quest_buttons.c` parses the numeric adapter setting. Only 0, 1 and 2 are accepted; malformed,
|
||||
fractional and out-of-range values select 0 and log `quest_buttons: invalid setting; using 0`.
|
||||
- `xrCreateInstance` reuses the existing next-layer extension enumeration. Modes 1 and 2 enable
|
||||
`XR_VALVE_frame_controller_interaction` only when offered, without adding a second copy when the game requested
|
||||
it. An unavailable/failed enumeration disables this feature. Failed instance creation clears the enabled flag.
|
||||
- `suggest_frame()` in `session_fixes.c` translates successful Touch suggestions using the tables corresponding to
|
||||
`quest_buttons.json`. It preserves action handles and click/touch components, supports both hands, poses,
|
||||
thumbstick vector/components and haptics, and counts unknown/dropped source bindings. Each input produces at most
|
||||
two Frame bindings. Mode 0 adds no runtime calls or feature logs.
|
||||
- Touch Pro/Plus fallback translates the filtered Touch-shaped copy after Touch succeeds. Proximity-emulation
|
||||
additions are deliberately excluded. A later Touch suggestion submits a fresh Frame set. Frame suggestion errors
|
||||
are logged without changing the game's result. A successful game-owned Frame suggestion prevents subsequent
|
||||
automatic Frame suggestions; if it arrives after an automatic suggestion, its runtime call replaces that set.
|
||||
- With the setting and extension enabled, `xrGetCurrentInteractionProfile` translates Valve profiles to Touch even
|
||||
with `controller_fix=0`. Existing `profile_remap` then still reports the game's Touch Pro/Plus profile when needed.
|
||||
Input diagnostics continue to show the runtime's original profile and additionally log every translated binding.
|
||||
- Both shipped adapter architectures were rebuilt, with updated `artifacts/SHA256SUMS`. No agent, analysis, patch
|
||||
or setting revision was changed. Contract files and the VrApi bridge are unchanged.
|
||||
|
||||
## Original offline validation
|
||||
|
||||
Commands run with `UV_CACHE_DIR=/tmp/frameport-qb-native-uv` to keep uv cache writes inside the writable sandbox;
|
||||
`--offline --no-sync` uses the existing worktree environment without fetching dependencies.
|
||||
|
||||
```sh
|
||||
FRAMEPORT_NATIVE_TESTS=1 uv run --offline --no-sync pytest -q tests/test_quest_buttons.py tests/test_profile_report.py
|
||||
uv run --offline --no-sync python native/build.py --only adapter
|
||||
uv run --offline --no-sync python scripts/patch_docs.py --check
|
||||
uv run --offline --no-sync pytest -q
|
||||
```
|
||||
|
||||
The new host harness includes `quest_buttons.c`, `input_diag.c`, and the complete `session_fixes.c`, plus verbatim
|
||||
copies of the adapter's exported create-instance/current-profile functions. It compiles with host `cc -Werror`.
|
||||
Python derives expected Frame bindings from the unchanged JSON contract, independently of the C mapping table.
|
||||
The targeted run passed 73 tests, including the existing profile-report tests. Two consecutive adapter builds
|
||||
produced identical SHA-256 hashes for both architectures; the manifest matches both files. Documentation checks pass.
|
||||
|
||||
The cached NDK/OpenXR inputs were already present. During the builds, the worktree's ignored `native/.cache` was
|
||||
temporarily a local directory containing input symlinks, so shader intermediates were written only inside this
|
||||
worktree. The original shared-cache symlink was restored afterward. No inputs were fetched or changed and no device
|
||||
was contacted.
|
||||
|
||||
Additional native baseline issue: `FRAMEPORT_NATIVE_TESTS=1 ... tests/test_input_diag.py` has an existing failure in
|
||||
`test_logs_each_finding_once_and_nothing_when_off`. Its harness requests `input_diag_hook("xrCreateAction")`, which
|
||||
the baseline implementation no longer exposes: action diagnostics moved to `session_fixes.c`. Compiling the harness
|
||||
against pristine `25e282d`'s `input_diag.c` reproduced the same line-85 failure. That unrelated behavior/test was left
|
||||
unchanged.
|
||||
|
||||
Full-suite result: **1,993 passed, 116 skipped, 12 failed, 16 errors**. All 28 failing/erroring cases stop at socket
|
||||
creation with `PermissionError: [Errno 1] Operation not permitted`: local HTTP fixtures in deep links, link updates,
|
||||
devkit, livestream, setup URL, and pairing UI tests. The sandbox disallows even loopback sockets. No feature
|
||||
assertion failed. The required unfiltered command was run; a green unfiltered run still requires a host that permits
|
||||
local sockets. The socket restriction was not relaxed.
|
||||
|
||||
An additional run deselecting exactly those 28 unsupported cases passed: **1,993 passed, 116 skipped, 28 deselected**.
|
||||
|
||||
## Integration questions and limits
|
||||
|
||||
- The contract has no missing mapping needed by this implementation. The right controller's Menu button has no
|
||||
mapping in modes 1/2; only left Touch `menu/click` maps to Frame `view/click`. Unknown paths, including menu touch
|
||||
and proximity, are intentionally dropped.
|
||||
- The game's own Frame bindings win only after its Frame suggestion succeeds. A rejected Frame suggestion does
|
||||
not suppress automatic translation. This avoids disabling working input because the game submitted an invalid set.
|
||||
- Headless/host tests cannot establish SteamVR's actual preference for these suggested bindings, physical button
|
||||
layout, capacitive touch behavior, haptics, or whether the installed Frame runtime offers the extension. These
|
||||
require the maintainer's checks below. An empty translated set is submitted and its result logged/ignored like
|
||||
any other Frame suggestion; the runtime can reject it.
|
||||
|
||||
## Maintainer device checks (not executed)
|
||||
|
||||
Use an isolated `.home` already containing a test library entry, its signing key/toolchain, and a paired test Frame.
|
||||
Use a normal Quest OpenXR game and a VrApi game using the bridge. Repeat with a game that suggests only Touch
|
||||
Pro/Plus if available. Replace the sample package name with each game's actual package. Stop other games first.
|
||||
|
||||
1. Rebuild/reinstall the test game so the new adapter is actually inside the APK. A setting update alone cannot
|
||||
upgrade an already installed adapter. Keep the original `controller_fix` and `input_diag` values for restoration.
|
||||
|
||||
```sh
|
||||
export FRAMEPORT_HOME="$PWD/.home"
|
||||
export PKG=com.example.game
|
||||
uv run frameport install "$PKG" --force
|
||||
```
|
||||
|
||||
2. Collect a headless startup log for each mode, with `controller_fix=0` and diagnostics enabled. Each launch test
|
||||
stops its game before the next iteration. The script uses the remembered Frame in this isolated home.
|
||||
|
||||
```sh
|
||||
for MODE in 0 1 2; do
|
||||
export MODE
|
||||
uv run frameport settings "$PKG" quest_buttons="$MODE" input_diag=1 controller_fix=0
|
||||
uv run python - <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
from frameport.cli import _target
|
||||
from frameport.core.events import Reporter
|
||||
|
||||
target = _target(None)
|
||||
try:
|
||||
result, log = target.launch_test(os.environ["PKG"], Reporter(), seconds=30)
|
||||
output = Path(os.environ["FRAMEPORT_HOME"]) / f"quest-buttons-mode-{os.environ['MODE']}.log"
|
||||
output.write_text(log)
|
||||
print(output)
|
||||
for line in log.splitlines():
|
||||
if "quest_buttons" in line or "controller profile" in line:
|
||||
print(line)
|
||||
finally:
|
||||
target.frame.close()
|
||||
PY
|
||||
done
|
||||
```
|
||||
|
||||
Expected mode 0: no `quest_buttons:` or `input_diag: quest_buttons` lines. Modes 1/2, if offered:
|
||||
|
||||
```text
|
||||
quest_buttons: extension XR_VALVE_frame_controller_interaction enabled
|
||||
quest_buttons: frame_controller bindings (mode 1, K bindings, D dropped): 0
|
||||
input_diag: quest_buttons /user/hand/left/input/x/click -> /user/hand/left/input/dpad_down/click
|
||||
input_diag: quest_buttons /user/hand/left/input/x/click -> /user/hand/left/input/dpad_right/click
|
||||
input_diag: quest_buttons /user/hand/left/input/menu/click -> /user/hand/left/input/view/click
|
||||
```
|
||||
|
||||
`K`/`D` depend on the game. Mode 2 reports `mode 2` and only the first X mapping above. Face-button touch paths
|
||||
keep `/touch`. Frame suggestions must return `0`; a negative result indicates runtime rejection to investigate.
|
||||
A Touch Pro/Plus-only game should also log `controller profile ... -> oculus/touch_controller` and a Frame binding
|
||||
summary. If the runtime lacks the extension, expect
|
||||
`quest_buttons: extension XR_VALVE_frame_controller_interaction not offered by the runtime`, with no translated
|
||||
bindings; input should retain its previous behavior.
|
||||
|
||||
3. For each mode, set it with the command below, then Play from the FramePort library or Steam while wearing the
|
||||
headset. Exit through Steam before changing modes; settings are read at process startup.
|
||||
|
||||
```sh
|
||||
uv run frameport settings "$PKG" quest_buttons=1 input_diag=1 controller_fix=0
|
||||
```
|
||||
|
||||
Mirror: left down/right = Touch X; left up/left = Touch Y; right A/X = Touch A; right B/Y = Touch B. Quest pair:
|
||||
left down = X, left left = Y, right A = A, right B = B; the unused face buttons should not activate those actions.
|
||||
In both modes left View activates Touch Menu. Check thumbstick movement/click/touch, trigger/squeeze values,
|
||||
trigger touch, tracking/aim, and haptic feedback. Verify both hands remain connected with `controller_fix=0`.
|
||||
Mode 0 should match the previous SteamVR mapping. Capacitive-touch verification needs a game that exposes finger
|
||||
animation or an input sample; absence of a suggested touch action is not a failure of this mapping.
|
||||
|
||||
4. Restore settings after stopping the game. The example restores the usual defaults; substitute recorded original
|
||||
values if different. Reinstalling the prior known-good APK also removes the new adapter if an artifact rollback
|
||||
is needed; setting 0 alone disables the feature on the next launch.
|
||||
|
||||
```sh
|
||||
uv run frameport settings "$PKG" quest_buttons=0 input_diag=0 controller_fix=1
|
||||
```
|
||||
|
||||
For explicit current-profile verification in an OpenXR input sample, call `xrGetCurrentInteractionProfile` for
|
||||
`/user/hand/left` and `/user/hand/right`, then `xrPathToString` on each non-null result after action-set attachment.
|
||||
With modes 1/2 and `controller_fix=0`, expect `/interaction_profiles/oculus/touch_controller`, or the game's own
|
||||
Touch Pro/Plus profile when its binding was remapped. Existing `input_diag` profile lines intentionally show the raw
|
||||
runtime answer, so they cannot alone verify the profile returned to the game.
|
||||
|
||||
## Round 2
|
||||
|
||||
The review fixes supersede the original proximity exclusion, broad Valve-profile rewrite, empty-set submission,
|
||||
and failed-instance state handling described above:
|
||||
|
||||
1. Added bare `grip`/`aim` to the JSON common mapping and `FRAME_COMMON`; no other contract fields changed.
|
||||
2. Current-profile rewriting with `controller_fix=0` requires the exact Frame profile and a successful generated
|
||||
binding set still in effect. A successful game-owned Frame suggestion ends that ownership; failed suggestions
|
||||
leave the last accepted set in effect. Existing `controller_fix=1` behavior is retained.
|
||||
3. Frame bindings are generated at the point a Touch set succeeds, including proximity-emulation touch bindings.
|
||||
Direct Touch, Touch Pro/Plus remapping, and rejected-expansion fallback all use the effective accepted set.
|
||||
4. An all-dropped translation makes no Frame suggestion and logs
|
||||
`quest_buttons: frame_controller bindings (mode N, 0 bindings, D dropped): not suggested`. An earlier accepted
|
||||
generated set can remain stale; it is intentionally retained because an empty replacement is invalid.
|
||||
5. Extension decisions remain local until instance creation succeeds. Only successful creation publishes the active
|
||||
instance and resets binding ownership/Touch-remap state; a failed second attempt preserves the live first state.
|
||||
6. One mutex serializes each complete suggestion transaction, including fallback/remapping, Frame generation, and
|
||||
flags. Instance-state publication and current-profile reporting use the same mutex. Internal helpers call the
|
||||
next layer directly and never reacquire it.
|
||||
|
||||
Regression tests reproduced items 1–5 before the fixes (21 failed cases). Afterward, the requested host run passed
|
||||
97 tests, including timeout-bounded nested proximity fallback and Touch Pro remapping. Diagram regeneration made no
|
||||
changes; controller-diagram and patch-documentation checks pass. Both adapter architectures were rebuilt twice with
|
||||
identical hashes matching `SHA256SUMS`. The full suite finished with 2,015 passed, 140 skipped, 12 failed and 16 errors;
|
||||
all 28 failures/errors are the same sandbox-denied local socket creation described above.
|
||||
|
||||
Device follow-up: repeat the install/log/physical checks above, adding `proximity_emul=2` to the mode-1/mode-2 settings
|
||||
command for a game exposing finger proximity. Expect translated proximity actions on face-button/thumbstick touch
|
||||
and trigger touch, and working finger animation. In an OpenXR sample with `controller_fix=0`, also query profiles
|
||||
before any generated set, after a successful generated set, and after the app's own successful Frame suggestion:
|
||||
expect Frame, Touch, Frame respectively. Another Valve profile must stay unchanged. Restore the recorded original
|
||||
`proximity_emul` setting along with the other settings. No device operations were performed in this worktree.
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"_comment": "Single source of the quest_buttons mapping (FrameBridge adapter setting). Keys are Touch input names under /user/hand/<hand>/input/, values the Frame Controller inputs the binding is also suggested for. A button entry covers its bare path (e.g. input/x, which OpenXR resolves to click) and its click and touch components; menu covers the bare path and click only. native/adapter's table, tests/test_quest_buttons.py and scripts/controller_diagram.py follow this file. See docs/input/QUEST_BUTTONS.md.",
|
||||
"extension": "XR_VALVE_frame_controller_interaction",
|
||||
"touch_profile": "/interaction_profiles/oculus/touch_controller",
|
||||
"frame_profile": "/interaction_profiles/valve/frame_controller_valve",
|
||||
"source": "https://partner.steamgames.com/doc/steamhardware/steamframe/input",
|
||||
"common": {
|
||||
"squeeze": ["squeeze"],
|
||||
"squeeze/value": ["squeeze/value"],
|
||||
"trigger": ["trigger"],
|
||||
"trigger/value": ["trigger/value"],
|
||||
"trigger/touch": ["trigger/touch"],
|
||||
"thumbstick": ["thumbstick"],
|
||||
"thumbstick/x": ["thumbstick/x"],
|
||||
"thumbstick/y": ["thumbstick/y"],
|
||||
"thumbstick/click": ["thumbstick/click"],
|
||||
"thumbstick/touch": ["thumbstick/touch"],
|
||||
"grip": ["grip"],
|
||||
"grip/pose": ["grip/pose"],
|
||||
"aim": ["aim"],
|
||||
"aim/pose": ["aim/pose"]
|
||||
},
|
||||
"outputs": {"haptic": ["haptic"]},
|
||||
"dropped": ["thumbrest/touch", "system/click"],
|
||||
"buttons": {
|
||||
"left": ["x", "y"],
|
||||
"right": ["a", "b"]
|
||||
},
|
||||
"modes": {
|
||||
"0": {
|
||||
"name": "SteamVR default",
|
||||
"note": "FrameBridge suggests nothing for the Frame profile; SteamVR remaps Touch itself (documented by Valve). Listed for the diagram only.",
|
||||
"left": {"x": ["dpad_down"], "y": ["dpad_left", "dpad_up", "dpad_right"], "menu": ["view"]},
|
||||
"right": {"a": ["a"], "b": ["x", "y", "b"]}
|
||||
},
|
||||
"1": {
|
||||
"name": "Mirror",
|
||||
"note": "Every face button works; the left controller mirrors the right: bottom + inner = X/A, top + outer = Y/B.",
|
||||
"left": {"x": ["dpad_down", "dpad_right"], "y": ["dpad_up", "dpad_left"], "menu": ["view"]},
|
||||
"right": {"a": ["a", "x"], "b": ["b", "y"]}
|
||||
},
|
||||
"2": {
|
||||
"name": "Quest pair",
|
||||
"note": "Two buttons per controller, placed diagonally like Quest Touch; the other face buttons do nothing.",
|
||||
"left": {"x": ["dpad_down"], "y": ["dpad_left"], "menu": ["view"]},
|
||||
"right": {"a": ["a"], "b": ["b"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Included by frame_adapter.c. Compatibility only: the Android runtime exposes no
|
||||
// colour conversion API. REC709 describes the conventional SDR content path, not
|
||||
// a measured native panel gamut. Accepting other spaces does not convert pixels.
|
||||
// Proc lookup is also exposed through framebridge_extension_proc for an installed
|
||||
// xrshim: OVRPort's fixed dispatcher cannot forward these new function names.
|
||||
// Shim installation and dispatcher extension-list synthesis live outside this adapter.
|
||||
static int emulate_color_space;
|
||||
static const XrColorSpaceFB color_spaces[] = {
|
||||
XR_COLOR_SPACE_UNMANAGED_FB, XR_COLOR_SPACE_REC2020_FB, XR_COLOR_SPACE_REC709_FB,
|
||||
XR_COLOR_SPACE_RIFT_CV1_FB, XR_COLOR_SPACE_RIFT_S_FB, XR_COLOR_SPACE_QUEST_FB,
|
||||
XR_COLOR_SPACE_P3_FB, XR_COLOR_SPACE_ADOBE_RGB_FB
|
||||
};
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL emu_enumerate_color_spaces(XrSession session,
|
||||
uint32_t capacity, uint32_t *count, XrColorSpaceFB *spaces) {
|
||||
(void)session;
|
||||
if (!count) return XR_ERROR_VALIDATION_FAILURE;
|
||||
*count = sizeof(color_spaces) / sizeof(color_spaces[0]);
|
||||
if (!capacity) return XR_SUCCESS;
|
||||
if (!spaces) return XR_ERROR_VALIDATION_FAILURE;
|
||||
if (capacity < *count) return XR_ERROR_SIZE_INSUFFICIENT;
|
||||
memcpy(spaces, color_spaces, sizeof(color_spaces));
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL emu_set_color_space(XrSession session, XrColorSpaceFB space) {
|
||||
(void)session;
|
||||
for (size_t i = 0; i < sizeof(color_spaces) / sizeof(color_spaces[0]); ++i) {
|
||||
if (space != color_spaces[i]) continue;
|
||||
static int logged;
|
||||
if (!logged) {
|
||||
logged = 1;
|
||||
LOG("color_space: accepted space=%d; no colour conversion (runtime has no API)", (int)space);
|
||||
}
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
return XR_ERROR_COLOR_SPACE_UNSUPPORTED_FB;
|
||||
}
|
||||
|
||||
static void color_space_properties(XrSystemProperties *properties) {
|
||||
if (!emulate_color_space || !properties) return;
|
||||
for (XrBaseOutStructure *p = properties->next; p; p = p->next)
|
||||
if (p->type == XR_TYPE_SYSTEM_COLOR_SPACE_PROPERTIES_FB)
|
||||
((XrSystemColorSpacePropertiesFB *)p)->colorSpace = XR_COLOR_SPACE_REC709_FB;
|
||||
}
|
||||
|
||||
static PFN_xrVoidFunction color_space_emulation(const char *name) {
|
||||
if (!emulate_color_space) return NULL;
|
||||
if (!strcmp(name, "xrEnumerateColorSpacesFB")) return (PFN_xrVoidFunction)emu_enumerate_color_spaces;
|
||||
if (!strcmp(name, "xrSetColorSpaceFB")) return (PFN_xrVoidFunction)emu_set_color_space;
|
||||
return NULL;
|
||||
}
|
||||
@@ -129,6 +129,7 @@ static int controller_fix = 1;
|
||||
static int swapchain_fix = 1;
|
||||
static int cube_standin = 1; // cube swapchains the runtime refuses: served by the adapter (cube_standin.c)
|
||||
static int rect_clamp = 1;
|
||||
static int fb_color_space; // fb_color_space: offer XR_FB_color_space when the runtime lacks it (#336)
|
||||
static int action_dedup = 1; // rename duplicate action names in a set (session_fixes.c; GitHub #323)
|
||||
static int pose_consistency = 0; // repeat xrLocateViews queries for one display time get the same poses (GitHub #8) // clamp submitted image rects to their swapchain (SteamVR rejects a 1 px overrun)
|
||||
static int pose_time_fix; // poses asked for at CLOCK_MONOTONIC "now" or far in the past: at XrTime "now" (pose_time.c)
|
||||
@@ -176,12 +177,17 @@ static int equirect_stereo; // 0 auto (mono if the layer limit can't hold both
|
||||
static XrTime last_predicted_time;
|
||||
static void render_model_init(const char *package);
|
||||
|
||||
#include "headset_model.c"
|
||||
#include "quest_buttons.c"
|
||||
|
||||
static void read_settings(const char *path) {
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return;
|
||||
char line[256];
|
||||
float value;
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
headset_model_setting(line);
|
||||
quest_buttons_setting(line);
|
||||
if (sscanf(line, "scale=%f", &value) == 1 && value >= 0.5f && value <= 2.0f) scale = value;
|
||||
if (sscanf(line, "foveation_fix=%f", &value) == 1) foveation_fix = value != 0;
|
||||
if (sscanf(line, "foveation_storage_fix=%f", &value) == 1) foveation_storage_fix = value != 0;
|
||||
@@ -199,6 +205,7 @@ static void read_settings(const char *path) {
|
||||
if (sscanf(line, "swapchain_fix=%f", &value) == 1) swapchain_fix = value != 0;
|
||||
if (sscanf(line, "cube_standin=%f", &value) == 1) cube_standin = value != 0;
|
||||
if (sscanf(line, "rect_clamp=%f", &value) == 1) rect_clamp = value != 0;
|
||||
if (sscanf(line, "fb_color_space=%f", &value) == 1) fb_color_space = value != 0;
|
||||
if (sscanf(line, "action_dedup=%f", &value) == 1) action_dedup = value != 0;
|
||||
if (sscanf(line, "pose_consistency=%f", &value) == 1) pose_consistency = value != 0;
|
||||
if (sscanf(line, "pose_debug=%f", &value) == 1) pose_debug = value != 0;
|
||||
@@ -319,6 +326,7 @@ static PFN_xrVoidFunction lookup(XrInstance instance, const char *name) {
|
||||
|
||||
#include "scene_emu.c"
|
||||
#include "render_model.c"
|
||||
#include "color_space.c"
|
||||
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info, XrInstance *instance) {
|
||||
PFN_xrCreateInstance fn = (PFN_xrCreateInstance)lookup(XR_NULL_HANDLE, "xrCreateInstance");
|
||||
@@ -341,7 +349,8 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
XR_NULL_HANDLE, "xrEnumerateInstanceExtensionProperties");
|
||||
uint32_t available_count = 0;
|
||||
XrExtensionProperties *available = NULL;
|
||||
if (enum_ext && XR_SUCCEEDED(enum_ext(NULL, 0, &available_count, NULL)) && available_count) {
|
||||
int extensions_known = enum_ext && XR_SUCCEEDED(enum_ext(NULL, 0, &available_count, NULL));
|
||||
if (extensions_known && available_count) {
|
||||
available = calloc(available_count, sizeof(*available));
|
||||
if (available) {
|
||||
for (uint32_t i = 0; i < available_count; ++i) available[i].type = XR_TYPE_EXTENSION_PROPERTIES;
|
||||
@@ -350,15 +359,18 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
available = NULL;
|
||||
}
|
||||
}
|
||||
if (!available) extensions_known = 0;
|
||||
}
|
||||
|
||||
XrInstanceCreateInfo fixed = *info;
|
||||
const char **names = calloc(info->enabledExtensionCount + 7, sizeof(*names));
|
||||
const char **names = calloc(info->enabledExtensionCount + 8, sizeof(*names));
|
||||
if (!names) { free(available); return XR_ERROR_OUT_OF_MEMORY; }
|
||||
int requested_color_space = 0;
|
||||
uint32_t kept = 0;
|
||||
for (uint32_t i = 0; i < info->enabledExtensionCount; ++i) {
|
||||
const char *ext = info->enabledExtensionNames[i];
|
||||
int supported = !available; // if we could not enumerate, pass everything through
|
||||
if (extensions_known && !strcmp(ext, XR_FB_COLOR_SPACE_EXTENSION_NAME)) supported = 0;
|
||||
for (uint32_t j = 0; available && j < available_count && !supported; ++j)
|
||||
supported = !strcmp(available[j].extensionName, ext);
|
||||
if (hide_fb_foveation_vulkan && fb_is_foveation_extension(ext)) {
|
||||
@@ -370,7 +382,9 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
continue;
|
||||
}
|
||||
if (supported) names[kept++] = ext;
|
||||
else if (scene_emul && is_scene_extension(ext)) {
|
||||
else if (!strcmp(ext, XR_FB_COLOR_SPACE_EXTENSION_NAME)) {
|
||||
requested_color_space = 1;
|
||||
} else if (scene_emul && is_scene_extension(ext)) {
|
||||
if (!emulate_scene) LOG("emulating Meta scene/spatial-entity extensions from the guardian bounds");
|
||||
emulate_scene = 1;
|
||||
} else if (controller_models && render_models_available && !strcmp(ext, XR_FB_RENDER_MODEL_EXTENSION_NAME)) {
|
||||
@@ -385,6 +399,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
names[kept++] = "XR_KHR_android_create_instance";
|
||||
LOG("added %s", "XR_KHR_android_create_instance");
|
||||
}
|
||||
int frame_enabled = quest_buttons_extensions(available, available_count, names, &kept);
|
||||
{ // no graphics API extension at all: Meta's runtime still accepts a GLES session, the Frame's doesn't
|
||||
// (Lambda1VR's TBXR enables only XR_EXT_local_floor). Add GLES, which every such app uses, if offered.
|
||||
int graphics = 0, gles_offered = 0;
|
||||
@@ -461,7 +476,21 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
XrResult result = fn(&fixed, instance);
|
||||
free(names);
|
||||
LOG("xrCreateInstance result=%d", result);
|
||||
if (XR_SUCCEEDED(result)) { active_instance = *instance; mx_audio_initialize(); }
|
||||
emulate_color_space = fb_color_space && XR_SUCCEEDED(result) && requested_color_space;
|
||||
if (emulate_color_space) {
|
||||
static int logged;
|
||||
if (!logged) {
|
||||
logged = 1;
|
||||
LOG("color_space: emulating XR_FB_color_space; REC709 SDR compatibility, no colour conversion");
|
||||
}
|
||||
}
|
||||
if (XR_SUCCEEDED(result)) {
|
||||
pthread_mutex_lock(&bindings_lock);
|
||||
active_instance = *instance;
|
||||
reset_bindings(frame_enabled);
|
||||
pthread_mutex_unlock(&bindings_lock);
|
||||
mx_audio_initialize();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -908,6 +937,8 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetSystemProperties(XrInstance instance, XrSyst
|
||||
PFN_xrGetSystemProperties fn = (PFN_xrGetSystemProperties)lookup(instance, "xrGetSystemProperties");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
XrResult result = fn(instance, system, properties);
|
||||
if (XR_SUCCEEDED(result)) headset_model_properties(properties);
|
||||
if (XR_SUCCEEDED(result)) color_space_properties(properties);
|
||||
if (XR_SUCCEEDED(result) && properties && properties->graphicsProperties.maxLayerCount)
|
||||
runtime_max_layers = properties->graphicsProperties.maxLayerCount;
|
||||
if (XR_SUCCEEDED(result) && properties && layer_debug)
|
||||
@@ -2083,6 +2114,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetCurrentInteractionProfile(XrSession session,
|
||||
PFN_xrGetCurrentInteractionProfile fn =
|
||||
(PFN_xrGetCurrentInteractionProfile)lookup(active_instance, "xrGetCurrentInteractionProfile");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
pthread_mutex_lock(&bindings_lock);
|
||||
XrResult result = detached_current_profile(user, state, fn(session, user, state));
|
||||
if (input_diag && XR_SUCCEEDED(result) && state) input_diag_current_profile(user, state->interactionProfile);
|
||||
if (layer_debug && active_instance) { // which device each hand has, as the runtime reports it
|
||||
@@ -2097,7 +2129,22 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetCurrentInteractionProfile(XrSession session,
|
||||
static int logged;
|
||||
if (logged++ < 40) LOG("layer_debug: interaction profile of %s: %s (result %d)", who, what, result);
|
||||
}
|
||||
if (XR_SUCCEEDED(result) && state) current_profile_fix(user, state);
|
||||
if (XR_SUCCEEDED(result) && state) {
|
||||
// A Frame binding replaces SteamVR's Touch remap; Quest games still expect a Touch-shaped profile.
|
||||
if (quest_buttons && quest_buttons_enabled && generated_frame_bindings && !app_suggested_frame &&
|
||||
state->interactionProfile) {
|
||||
PFN_xrPathToString str = (PFN_xrPathToString)lookup(active_instance, "xrPathToString");
|
||||
PFN_xrStringToPath path = (PFN_xrStringToPath)lookup(active_instance, "xrStringToPath");
|
||||
char name[XR_MAX_PATH_LENGTH];
|
||||
uint32_t size = 0;
|
||||
XrPath touch;
|
||||
if (str && path && XR_SUCCEEDED(str(active_instance, state->interactionProfile, sizeof(name), &size, name)) &&
|
||||
!strcmp(name, FRAME_PROFILE) && XR_SUCCEEDED(path(active_instance, TOUCH_PROFILE, &touch)))
|
||||
state->interactionProfile = touch;
|
||||
}
|
||||
current_profile_fix(user, state);
|
||||
}
|
||||
pthread_mutex_unlock(&bindings_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2106,8 +2153,9 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateInstanceExtensionProperties(const char
|
||||
PFN_xrEnumerateInstanceExtensionProperties fn = (PFN_xrEnumerateInstanceExtensionProperties)lookup(
|
||||
XR_NULL_HANDLE, "xrEnumerateInstanceExtensionProperties");
|
||||
if (!fn) return XR_ERROR_INITIALIZATION_FAILED;
|
||||
if (!count) return XR_ERROR_VALIDATION_FAILURE;
|
||||
if ((!foveation_fix && !hide_fb_foveation && !hide_space_warp && !passthrough_emul && !scene_emul && !controller_models) || layer)
|
||||
if (!count || (capacity && !properties)) return XR_ERROR_VALIDATION_FAILURE;
|
||||
if ((!foveation_fix && !hide_fb_foveation && !hide_space_warp && !passthrough_emul && !scene_emul && !controller_models &&
|
||||
!fb_color_space) || layer)
|
||||
return fn(layer, capacity, count, properties);
|
||||
|
||||
uint32_t total = 0;
|
||||
@@ -2119,8 +2167,10 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateInstanceExtensionProperties(const char
|
||||
result = fn(layer, total, &total, all);
|
||||
if (XR_FAILED(result)) { free(all); return result; }
|
||||
|
||||
int has_color_space = 0;
|
||||
uint32_t kept = 0;
|
||||
for (uint32_t i = 0; i < total; ++i) {
|
||||
if (!strcmp(all[i].extensionName, XR_FB_COLOR_SPACE_EXTENSION_NAME)) has_color_space = 1;
|
||||
if (!strcmp(all[i].extensionName, "XR_FB_passthrough")) runtime_has_fb_passthrough = 1;
|
||||
if (!strcmp(all[i].extensionName, XR_FB_RENDER_MODEL_EXTENSION_NAME)) runtime_has_render_model = 1;
|
||||
if (hide_fb_foveation && fb_is_foveation_extension(all[i].extensionName)) continue;
|
||||
@@ -2151,6 +2201,11 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateInstanceExtensionProperties(const char
|
||||
if (properties && kept < capacity) properties[kept] = fake;
|
||||
++kept;
|
||||
}
|
||||
if (fb_color_space && !has_color_space) {
|
||||
XrExtensionProperties fake = {XR_TYPE_EXTENSION_PROPERTIES, NULL, XR_FB_COLOR_SPACE_EXTENSION_NAME, 3};
|
||||
if (properties && kept < capacity) properties[kept] = fake;
|
||||
++kept;
|
||||
}
|
||||
free(all);
|
||||
*count = kept;
|
||||
return (capacity && capacity < kept) ? XR_ERROR_SIZE_INSUFFICIENT : XR_SUCCESS;
|
||||
@@ -2200,7 +2255,8 @@ __attribute__((visibility("default"))) PFN_xrVoidFunction framebridge_extension_
|
||||
NATIVE_PROC(xrCreateVulkanInstanceKHR)
|
||||
#undef NATIVE_PROC
|
||||
}
|
||||
return name ? render_model_emulation(name) : NULL;
|
||||
PFN_xrVoidFunction color = name ? color_space_emulation(name) : NULL;
|
||||
return color ? color : (name ? render_model_emulation(name) : NULL);
|
||||
}
|
||||
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const char *name,
|
||||
@@ -2224,6 +2280,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
|
||||
PFN_xrVoidFunction emulated = passthrough_emulation(name);
|
||||
if (!emulated) emulated = scene_emulation(name);
|
||||
if (!emulated) emulated = render_model_emulation(name);
|
||||
if (!emulated) emulated = color_space_emulation(name);
|
||||
if (!emulated && emulate_scene && !strcmp(name, "xrLocateSpacesKHR")) emulated = (PFN_xrVoidFunction)emu_locate_spaces_khr;
|
||||
if (emulated) { *function = emulated; return XR_SUCCESS; }
|
||||
if (hide_fb_foveation_vulkan && !strcmp(name, "xrUpdateSwapchainFB")) {
|
||||
@@ -2295,7 +2352,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
|
||||
}
|
||||
if (sync_guard || layer_debug || input_diag) HOOK_AS(xrSyncActions, hook_xrSyncActions)
|
||||
if (layer_debug) HOOK_AS(xrGetActionStateBoolean, hook_xrGetActionStateBoolean)
|
||||
if (layer_debug || aim_correction_on() || profile_remap || input_diag || proximity_emul)
|
||||
if (layer_debug || aim_correction_on() || profile_remap || input_diag || proximity_emul || quest_buttons)
|
||||
HOOK_AS(xrSuggestInteractionProfileBindings, hook_xrSuggestInteractionProfileBindings)
|
||||
if (action_dedup || proximity_emul || profile_remap || input_diag) HOOK_AS(xrCreateAction, hook_xrCreateAction)
|
||||
if (layer_debug || aim_correction_on()) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Per-game OpenXR identity. 0/2 preserve the runtime's original Quest 2 compatibility behaviour.
|
||||
// OVRPort owns XR_META_headset_id above this layer; the APK patch rewrites its UUID.
|
||||
static int headset_model;
|
||||
|
||||
static void headset_model_setting(const char *line) {
|
||||
int value;
|
||||
char trailing;
|
||||
if (sscanf(line, "headset_model=%d %c", &value, &trailing) == 1 && value >= 2 && value <= 5)
|
||||
headset_model = value;
|
||||
}
|
||||
|
||||
static void headset_model_properties(XrSystemProperties *properties) {
|
||||
const char *name = NULL;
|
||||
switch (headset_model) {
|
||||
case 3: name = "Meta Quest 3"; break;
|
||||
case 4: name = "Meta Quest 3S"; break;
|
||||
case 5: name = "Meta Quest Pro"; break;
|
||||
}
|
||||
if (properties && name)
|
||||
snprintf(properties->systemName, sizeof(properties->systemName), "%s", name);
|
||||
}
|
||||
@@ -84,6 +84,10 @@ static void input_diag_current_profile(XrPath user, XrPath profile) {
|
||||
if (diag_first(key)) LOG("input_diag: bindings: %s uses %s", u, p);
|
||||
}
|
||||
|
||||
static void input_diag_quest_binding(const char *touch, const char *frame) {
|
||||
if (input_diag) LOG("input_diag: quest_buttons %s -> %s", touch, frame);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- failing calls
|
||||
// xrSyncActions and xrSuggestInteractionProfileBindings are reported from session_fixes.c's hooks,
|
||||
// xrApplyHapticFeedback from frame_adapter.c's (always hooked for haptic_scale).
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Included by frame_adapter.c: opt-in Frame controller bindings need the runtime's extension enabled first.
|
||||
static int quest_buttons;
|
||||
// Published instance/binding state is guarded by bindings_lock, including current-profile reporting.
|
||||
static int quest_buttons_enabled;
|
||||
static int app_suggested_frame;
|
||||
static int generated_frame_bindings;
|
||||
static pthread_mutex_t bindings_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static void reset_bindings(int frame_enabled); // session_fixes.c, called with bindings_lock held
|
||||
static const char *const FRAME_PROFILE = "/interaction_profiles/valve/frame_controller_valve";
|
||||
static const char *const FRAME_EXTENSION = "XR_VALVE_frame_controller_interaction";
|
||||
|
||||
static void quest_buttons_setting(const char *line) {
|
||||
if (strncmp(line, "quest_buttons=", 14)) return;
|
||||
float value;
|
||||
char extra;
|
||||
if (sscanf(line, "quest_buttons=%f %c", &value, &extra) == 1 &&
|
||||
(value == 0 || value == 1 || value == 2)) quest_buttons = (int)value;
|
||||
else {
|
||||
quest_buttons = 0;
|
||||
LOG("quest_buttons: invalid setting; using 0");
|
||||
}
|
||||
}
|
||||
|
||||
// `names` has room for one more extension. Use only the next layer's list, never an emulated extension list.
|
||||
static int quest_buttons_extensions(const XrExtensionProperties *available, uint32_t count,
|
||||
const char **names, uint32_t *kept) {
|
||||
// This is a proposed instance's extension list; do not change the live instance until creation succeeds.
|
||||
if (!quest_buttons) return 0;
|
||||
int offered = 0, requested = 0;
|
||||
for (uint32_t i = 0; available && i < count; ++i)
|
||||
offered |= !strcmp(available[i].extensionName, FRAME_EXTENSION);
|
||||
for (uint32_t i = 0; i < *kept; ++i) requested |= !strcmp(names[i], FRAME_EXTENSION);
|
||||
if (offered) {
|
||||
if (!requested) names[(*kept)++] = FRAME_EXTENSION;
|
||||
LOG("quest_buttons: extension %s enabled", FRAME_EXTENSION);
|
||||
} else LOG("quest_buttons: extension %s not offered by the runtime", FRAME_EXTENSION);
|
||||
return offered;
|
||||
}
|
||||
@@ -300,6 +300,12 @@ static int app_suggested_touch;
|
||||
#include "profile_report.c" // remapped_profile, current_profile_fix (xrGetCurrentInteractionProfile)
|
||||
#include "detached_controllers.c" // detached controllers' paths on a runtime without them
|
||||
|
||||
static void reset_bindings(int frame_enabled) {
|
||||
quest_buttons_enabled = frame_enabled;
|
||||
app_suggested_frame = generated_frame_bindings = app_suggested_touch = 0;
|
||||
remapped_profile[0] = 0;
|
||||
}
|
||||
|
||||
static int in_list(const char *s, const char *const *list, size_t n) {
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
if (!strcmp(s, list[i])) return 1;
|
||||
@@ -419,16 +425,117 @@ static int add_binding(XrInstance instance, PFN_xrStringToPath to_path, XrAction
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Mirrors docs/input/quest_buttons.json; the host test derives its expected bindings from that contract.
|
||||
static const char *const FRAME_COMMON[] = {"squeeze", "squeeze/value", "trigger", "trigger/value", "trigger/touch",
|
||||
"thumbstick",
|
||||
"thumbstick/x", "thumbstick/y", "thumbstick/click", "thumbstick/touch", "grip", "grip/pose", "aim", "aim/pose"};
|
||||
static const char *const FRAME_DROPPED[] = {"thumbrest/touch", "system/click"};
|
||||
static const struct {
|
||||
const char *hand, *touch;
|
||||
const char *targets[2][2]; // Mirror, Quest pair
|
||||
} FRAME_BUTTONS[] = {
|
||||
{"left", "x", {{"dpad_down", "dpad_right"}, {"dpad_down", NULL}}},
|
||||
{"left", "y", {{"dpad_up", "dpad_left"}, {"dpad_left", NULL}}},
|
||||
{"left", "menu", {{"view", NULL}, {"view", NULL}}},
|
||||
{"right", "a", {{"a", "x"}, {"a", NULL}}},
|
||||
{"right", "b", {{"b", "y"}, {"b", NULL}}},
|
||||
};
|
||||
|
||||
static void suggest_frame(XrInstance instance, PFN_xrSuggestInteractionProfileBindings fn,
|
||||
PFN_xrPathToString to_string, const XrInteractionProfileSuggestedBinding *suggested) {
|
||||
if (!quest_buttons || !quest_buttons_enabled || app_suggested_frame) return;
|
||||
PFN_xrStringToPath to_path = (PFN_xrStringToPath)lookup(instance, "xrStringToPath");
|
||||
XrPath frame;
|
||||
uint32_t n = suggested->countSuggestedBindings, kept = 0, dropped = 0;
|
||||
XrResult result = XR_ERROR_PATH_UNSUPPORTED;
|
||||
XrActionSuggestedBinding *bindings = NULL;
|
||||
if (!to_path || !to_string || XR_FAILED(to_path(instance, FRAME_PROFILE, &frame))) goto done;
|
||||
// Every source contributes at most two bindings; check before multiplying the uint32_t count.
|
||||
if (n > UINT32_MAX / 2 || !(bindings = calloc(n ? (size_t)n * 2 : 1, sizeof(*bindings)))) {
|
||||
result = XR_ERROR_OUT_OF_MEMORY;
|
||||
goto done;
|
||||
}
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
const XrActionSuggestedBinding *source = &suggested->suggestedBindings[i];
|
||||
char path[XR_MAX_PATH_LENGTH], target[XR_MAX_PATH_LENGTH];
|
||||
uint32_t size = 0, before = kept;
|
||||
if (XR_FAILED(to_string(instance, source->binding, sizeof(path), &size, path))) { ++dropped; continue; }
|
||||
const char *hand = NULL, *relative = NULL;
|
||||
if (!strncmp(path, "/user/hand/left/", 16)) { hand = "left"; relative = path + 16; }
|
||||
else if (!strncmp(path, "/user/hand/right/", 17)) { hand = "right"; relative = path + 17; }
|
||||
if (!hand) { ++dropped; continue; }
|
||||
const char *targets[2] = {NULL, NULL};
|
||||
char button_targets[2][64];
|
||||
if (!strcmp(relative, "output/haptic")) targets[0] = relative;
|
||||
else if (!strncmp(relative, "input/", 6)) {
|
||||
const char *input = relative + 6;
|
||||
if (IN(input, FRAME_COMMON)) targets[0] = relative;
|
||||
else if (!IN(input, FRAME_DROPPED)) {
|
||||
for (size_t b = 0; b < sizeof(FRAME_BUTTONS) / sizeof(*FRAME_BUTTONS); ++b) {
|
||||
const char *button = FRAME_BUTTONS[b].touch;
|
||||
size_t len = strlen(button);
|
||||
if (strcmp(hand, FRAME_BUTTONS[b].hand) || strncmp(input, button, len) ||
|
||||
(input[len] != '/' && input[len])) continue;
|
||||
// a bare button path (input/x) is legal OpenXR: the runtime picks click, so keep it bare
|
||||
const char *component = input[len] ? input + len + 1 : "";
|
||||
if (*component && strcmp(component, "click") &&
|
||||
(strcmp(button, "menu") == 0 || strcmp(component, "touch"))) continue;
|
||||
for (int t = 0; t < 2; ++t) {
|
||||
const char *name = FRAME_BUTTONS[b].targets[quest_buttons - 1][t];
|
||||
if (!name) continue;
|
||||
snprintf(button_targets[t], sizeof(button_targets[t]), "input/%s%s%s", name,
|
||||
*component ? "/" : "", component);
|
||||
targets[t] = button_targets[t];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int t = 0; t < 2; ++t) {
|
||||
if (!targets[t]) continue;
|
||||
snprintf(target, sizeof(target), "/user/hand/%s/%s", hand, targets[t]);
|
||||
XrPath p;
|
||||
if (XR_FAILED(to_path(instance, target, &p))) continue;
|
||||
bindings[kept++] = (XrActionSuggestedBinding){source->action, p};
|
||||
input_diag_quest_binding(path, target);
|
||||
}
|
||||
if (kept == before) ++dropped;
|
||||
}
|
||||
if (!kept) {
|
||||
LOG("quest_buttons: frame_controller bindings (mode %d, 0 bindings, %u dropped): not suggested",
|
||||
quest_buttons, dropped);
|
||||
free(bindings);
|
||||
return; // A previously accepted Frame set stays in effect: OpenXR cannot replace it with an empty set.
|
||||
}
|
||||
XrInteractionProfileSuggestedBinding copy = {XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING, NULL,
|
||||
frame, kept, bindings};
|
||||
result = fn(instance, ©);
|
||||
if (XR_SUCCEEDED(result)) generated_frame_bindings = 1;
|
||||
done:
|
||||
LOG("quest_buttons: frame_controller bindings (mode %d, %u bindings, %u dropped): %d",
|
||||
quest_buttons, kept, dropped, result);
|
||||
free(bindings);
|
||||
}
|
||||
|
||||
// Run this for every Touch attempt so Frame bindings come from exactly the set the runtime accepted.
|
||||
static XrResult suggest_touch_set(XrInstance instance, PFN_xrSuggestInteractionProfileBindings fn,
|
||||
PFN_xrPathToString to_string, const XrInteractionProfileSuggestedBinding *suggested) {
|
||||
XrResult r = fn(instance, suggested);
|
||||
if (XR_SUCCEEDED(r)) suggest_frame(instance, fn, to_string, suggested);
|
||||
return r;
|
||||
}
|
||||
|
||||
// Suggests Touch bindings with finger proximity bound to the touch inputs (proximity_emul), falling back to the
|
||||
// bindings as given when the runtime refuses them.
|
||||
static XrResult suggest_touch(XrInstance instance, PFN_xrSuggestInteractionProfileBindings fn,
|
||||
PFN_xrPathToString to_string, const XrInteractionProfileSuggestedBinding *suggested) {
|
||||
PFN_xrStringToPath to_path = (PFN_xrStringToPath)lookup(instance, "xrStringToPath");
|
||||
if (!proximity_emul || runtime_has_proximity || !to_path || !to_string) return fn(instance, suggested);
|
||||
if (!proximity_emul || runtime_has_proximity || !to_path || !to_string)
|
||||
return suggest_touch_set(instance, fn, to_string, suggested);
|
||||
XrAction thumb = thumb_proximity_action, index = proximity_emul > 1 ? trigger_proximity_action : XR_NULL_HANDLE;
|
||||
uint32_t n = suggested->countSuggestedBindings, kept = 0, dropped = 0, added = 0;
|
||||
XrActionSuggestedBinding *b = calloc(n + 2 * 5, sizeof(*b));
|
||||
if (!b) return fn(instance, suggested);
|
||||
if (!b) return suggest_touch_set(instance, fn, to_string, suggested);
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
char path[XR_MAX_PATH_LENGTH];
|
||||
uint32_t size = 0;
|
||||
@@ -449,15 +556,15 @@ static XrResult suggest_touch(XrInstance instance, PFN_xrSuggestInteractionProfi
|
||||
if (index) added += add_binding(instance, to_path, b, &kept, index, name, "trigger/touch");
|
||||
}
|
||||
XrResult r;
|
||||
if (!added && !dropped) r = fn(instance, suggested);
|
||||
if (!added && !dropped) r = suggest_touch_set(instance, fn, to_string, suggested);
|
||||
else {
|
||||
XrInteractionProfileSuggestedBinding copy = *suggested;
|
||||
copy.countSuggestedBindings = kept;
|
||||
copy.suggestedBindings = b;
|
||||
r = fn(instance, ©);
|
||||
r = suggest_touch_set(instance, fn, to_string, ©);
|
||||
LOG("proximity_emul: finger proximity bound to touch (%u binding(s) added, %u proximity path(s) replaced): %d",
|
||||
added, dropped, r);
|
||||
if (XR_FAILED(r)) r = fn(instance, suggested);
|
||||
if (XR_FAILED(r)) r = suggest_touch_set(instance, fn, to_string, suggested);
|
||||
}
|
||||
free(b);
|
||||
return r;
|
||||
@@ -498,6 +605,9 @@ static XRAPI_ATTR XrResult XRAPI_CALL hook_xrSuggestInteractionProfileBindings(X
|
||||
PFN_xrSuggestInteractionProfileBindings fn =
|
||||
(PFN_xrSuggestInteractionProfileBindings)lookup(instance, "xrSuggestInteractionProfileBindings");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
// The last accepted call per profile wins. Keep the game's call, fallback, generated set and flags together.
|
||||
// Internal helpers use the next-layer function directly, so fallback paths never take this mutex twice.
|
||||
pthread_mutex_lock(&bindings_lock);
|
||||
XrInteractionProfileSuggestedBinding copy;
|
||||
XrActionSuggestedBinding *kept = suggested && suggested->countSuggestedBindings
|
||||
? calloc(suggested->countSuggestedBindings, sizeof(*kept)) : NULL;
|
||||
@@ -505,6 +615,7 @@ static XRAPI_ATTR XrResult XRAPI_CALL hook_xrSuggestInteractionProfileBindings(X
|
||||
// a profile with only detached-controller bindings: nothing the runtime could bind
|
||||
XrResult r = use != suggested && !use->countSuggestedBindings ? XR_SUCCESS : suggest_bindings(instance, fn, use);
|
||||
free(kept);
|
||||
pthread_mutex_unlock(&bindings_lock);
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -525,6 +636,10 @@ static XrResult suggest_bindings(XrInstance instance, PFN_xrSuggestInteractionPr
|
||||
XrResult r = known && !strcmp(profile, TOUCH_PROFILE) ? suggest_touch(instance, fn, to_string, suggested)
|
||||
: fn(instance, suggested);
|
||||
input_diag_suggested(instance, suggested, r, to_string); // the runtime's answer to the game's own suggestion
|
||||
if (known && XR_SUCCEEDED(r) && !strcmp(profile, FRAME_PROFILE) && quest_buttons && quest_buttons_enabled) {
|
||||
app_suggested_frame = 1;
|
||||
generated_frame_bindings = 0;
|
||||
}
|
||||
if (!profile_remap || !known) return r;
|
||||
if (!strcmp(profile, TOUCH_PROFILE)) {
|
||||
if (XR_SUCCEEDED(r)) app_suggested_touch = 1; // the app's own Touch bindings win over a remap
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate controller illustrations and the user mapping table from the shared input contract."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CONTRACT = ROOT / "docs/input/quest_buttons.json"
|
||||
# Frame controller face, own drawing after public photos (Wikimedia Commons "Steam Frame controllers.jpg",
|
||||
# CC BY 4.0): offsets from the head's centre for a head of radius 100; `o` = the controller's outer side
|
||||
# (-1 left, +1 right). Left: d-pad outer, View above it, stick inner, Steam below. Right: the mirror, with the
|
||||
# face buttons as a diamond (Y top, X left, B right, A bottom) and Menu above them.
|
||||
PANEL_W, PANEL_H = 520, 380
|
||||
HEAD_R = 100
|
||||
DOC = ROOT / "docs/CONTROLLERS.md"
|
||||
START = "<!-- quest-buttons-table:start -->"
|
||||
END = "<!-- quest-buttons-table:end -->"
|
||||
|
||||
|
||||
def portal_colors() -> dict:
|
||||
# Read literal tokens without importing the GUI or consulting user preferences.
|
||||
tree = ast.parse((ROOT / "src/frameport/ui/theme.py").read_text(encoding="utf-8"))
|
||||
return next(ast.literal_eval(n.value) for n in tree.body if isinstance(n, ast.Assign)
|
||||
and any(isinstance(t, ast.Name) and t.id == "_PORTAL" for t in n.targets))
|
||||
|
||||
|
||||
def inverse(mode: dict, hand: str) -> dict:
|
||||
return {frame: touch.upper() if touch != "menu" else "Menu"
|
||||
for touch, frames in mode.get(hand, {}).items() for frame in frames}
|
||||
|
||||
|
||||
def mapping_table(data: dict) -> str:
|
||||
rows = ["| Frame input | SteamVR default (0) | Mirror (1) | Quest pair (2) |", "|---|---|---|---|"]
|
||||
inputs = (("left", "dpad_up", "Left d-pad ↑"), ("left", "dpad_down", "Left d-pad ↓"),
|
||||
("left", "dpad_left", "Left d-pad ←"), ("left", "dpad_right", "Left d-pad →"),
|
||||
("left", "view", "Left View"), ("right", "a", "Right A (bottom)"),
|
||||
("right", "b", "Right B (right)"), ("right", "x", "Right X (left)"),
|
||||
("right", "y", "Right Y (top)"), ("right", "menu", "Right Menu"),
|
||||
("left", "bumper", "Left bumper"), ("right", "bumper", "Right bumper"))
|
||||
for hand, key, label in inputs:
|
||||
values = [inverse(data["modes"][str(n)], hand).get(key, "—") for n in range(3)]
|
||||
rows.append("| " + " | ".join([label, *values]) + " |")
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def illustration(data: dict, colors: dict, mode: str | None = None) -> str:
|
||||
compact = mode is not None
|
||||
width, height = (PANEL_W, PANEL_H) if compact else (2 * PANEL_W + 40, 2 * PANEL_H + 104)
|
||||
parts = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
|
||||
f'viewBox="0 0 {width} {height}" role="img" aria-labelledby="title desc">',
|
||||
'<title id="title">Quest Touch buttons on Steam Frame controllers</title>',
|
||||
'<desc id="desc">' + escape(
|
||||
(data["modes"][mode]["name"] + ": " + data["modes"][mode]["note"] if compact else
|
||||
"Quest Touch reference and three Frame mappings: SteamVR default, Mirror, and Quest pair.")
|
||||
+ " Each Steam Frame controller button is labelled with the Quest Touch button it sends. "
|
||||
"A dash means it sends nothing. Left Touch buttons are X and Y; right are A and B.") + '</desc>',
|
||||
f'<rect width="{width}" height="{height}" rx="16" fill="{colors["BG"]}"/>',
|
||||
'<g font-family="DejaVu Sans,Arial,sans-serif">']
|
||||
|
||||
def text(x, y, label, size=14, color=None, anchor="start", weight=400):
|
||||
parts.append(f'<text x="{x}" y="{y}" fill="{color or colors["TEXT"]}" font-size="{size}" '
|
||||
f'font-weight="{weight}" text-anchor="{anchor}">{escape(label)}</text>')
|
||||
|
||||
def tone(touch):
|
||||
if touch == "—":
|
||||
return colors["TEXT_3"]
|
||||
return colors["SECONDARY"] if touch in ("X", "A") else colors["ACCENT"]
|
||||
|
||||
def sends(x, y, touch, size=15):
|
||||
text(x, y + size * 0.36, touch, size if len(touch) == 1 else size - 4, tone(touch), "middle", 700)
|
||||
|
||||
def touch_button(x, y, label):
|
||||
parts.append(f'<circle cx="{x}" cy="{y}" r="25" fill="{colors["SURFACE_2"]}" '
|
||||
f'stroke="{tone(label)}" stroke-width="1.5"/>')
|
||||
sends(x, y, label, 19)
|
||||
|
||||
def controller(hx, hy, hand, mapped):
|
||||
o = -1 if hand == "left" else 1
|
||||
shell, edge = colors["BG"], colors["BORDER"]
|
||||
# the handle hangs below the head, a little toward the inner side
|
||||
s = -o * 8
|
||||
parts.append(f'<path d="M{hx - 46 + s},{hy + 70} C{hx - 44 + s},{hy + 128} {hx - 34 + s},{hy + 148} '
|
||||
f'{hx - 12 + s},{hy + 152} C{hx + 10 + s},{hy + 156} {hx + 30 + s},{hy + 140} '
|
||||
f'{hx + 46 + s},{hy + 70} Z" fill="{shell}" stroke="{edge}" stroke-width="2"/>')
|
||||
parts.append(f'<circle cx="{hx}" cy="{hy}" r="{HEAD_R}" fill="{shell}" stroke="{edge}" stroke-width="2"/>')
|
||||
# thumbstick (inner side) and Steam button (not mapped: always Steam)
|
||||
sx = hx - o * 34
|
||||
parts.append(f'<circle cx="{sx}" cy="{hy + 18}" r="24" fill="{colors["SURFACE"]}" stroke="{edge}"/>')
|
||||
parts.append(f'<circle cx="{sx}" cy="{hy + 18}" r="15" fill="{colors["SURFACE_2"]}" stroke="{edge}"/>')
|
||||
parts.append(f'<circle cx="{hx + o * 14}" cy="{hy + 62}" r="10" fill="{colors["SURFACE_2"]}" '
|
||||
f'stroke="{edge}"/>')
|
||||
text(hx + o * 14, hy + 86, "Steam", 9, colors["TEXT_3"], "middle")
|
||||
# View (left) / Menu (right) pill above the d-pad / face buttons
|
||||
px, py = hx - o * 4, hy - 46
|
||||
pill = mapped.get("view" if hand == "left" else "menu", "—")
|
||||
parts.append(f'<rect x="{px - 13}" y="{py - 5}" width="26" height="10" rx="5" '
|
||||
f'fill="{colors["SURFACE_2"]}" stroke="{tone(pill)}"/>')
|
||||
# a colon, not an arrow: the app's SVG renderer has no arrow glyphs
|
||||
text(px, py - 10, ("View" if hand == "left" else "Menu") + ": " + pill, 10, tone(pill), "middle", 600)
|
||||
cx, cy = hx + o * 42, hy + 2
|
||||
if hand == "left": # d-pad: a cross of four arms
|
||||
parts.append(f'<path d="M{cx - 14},{cy - 44} h28 v30 h30 v28 h-30 v30 h-28 v-30 h-30 v-28 h30 Z" '
|
||||
f'fill="{colors["SURFACE_2"]}" stroke="{edge}"/>')
|
||||
for dx, dy, name in ((0, -29, "dpad_up"), (-29, 0, "dpad_left"), (29, 0, "dpad_right"),
|
||||
(0, 29, "dpad_down")):
|
||||
sends(cx + dx, cy + dy, mapped.get(name, "—"), 14)
|
||||
else: # face buttons as a diamond, each with its printed letter
|
||||
for dx, dy, name in ((0, -29, "y"), (-29, 0, "x"), (29, 0, "b"), (0, 29, "a")):
|
||||
touch = mapped.get(name, "—")
|
||||
parts.append(f'<circle cx="{cx + dx}" cy="{cy + dy}" r="14" fill="{colors["SURFACE_2"]}" '
|
||||
f'stroke="{tone(touch)}" stroke-width="1.5"/>')
|
||||
sends(cx + dx, cy + dy, touch, 14)
|
||||
text(cx + dx + 15, cy + dy - 10, name.upper(), 9, colors["TEXT_3"], "middle")
|
||||
|
||||
def panel(x, y, key):
|
||||
parts.append(f'<g transform="translate({x} {y})">')
|
||||
parts.append(f'<rect x="0" y="0" width="{PANEL_W}" height="{PANEL_H}" rx="12" '
|
||||
f'fill="{colors["SURFACE"]}" stroke="{colors["BORDER"]}"/>')
|
||||
title = "Quest Touch reference" if key is None else data["modes"][key]["name"]
|
||||
text(20, 31, title, 20, weight=600)
|
||||
if key is None:
|
||||
text(20, 55, "Two face buttons per hand, plus Menu on the left", 13, colors["TEXT_2"])
|
||||
else:
|
||||
text(20, 55, "Steam Frame controllers: each button shows the Quest button it sends", 13,
|
||||
colors["TEXT_2"])
|
||||
for hand, hx in (("left", 140), ("right", 380)):
|
||||
text(hx, 84 if key is None else 78, hand.capitalize(), 13, colors["TEXT_2"], "middle")
|
||||
if key is None:
|
||||
low, high = ("X", "Y") if hand == "left" else ("A", "B")
|
||||
inner = 23 if hand == "left" else -23
|
||||
touch_button(hx + inner, 206, low)
|
||||
touch_button(hx - inner, 151, high)
|
||||
if hand == "left":
|
||||
parts.append(f'<rect x="{hx - 57}" y="{251}" width="114" height="32" rx="8" '
|
||||
f'fill="{colors["SURFACE_2"]}"/>')
|
||||
text(hx, 272, "Menu", 12, colors["TEXT_2"], "middle")
|
||||
else:
|
||||
controller(hx, 186, hand, inverse(data["modes"][key], hand))
|
||||
footnote = ("Lower: X / A · Upper: Y / B" if key is None else
|
||||
"Sticks, triggers and grips unchanged · bumpers send nothing")
|
||||
text(PANEL_W / 2, PANEL_H - 14, footnote, 12, colors["TEXT_2"], "middle")
|
||||
parts.append('</g>')
|
||||
|
||||
if compact:
|
||||
panel(0, 0, mode)
|
||||
else:
|
||||
text(20, 32, "Controller buttons", 24, weight=600)
|
||||
text(20, 57, "Blue: X / A · Orange: Y / B or Menu · —: sends nothing", 14, colors["TEXT_2"])
|
||||
for x, y, key in ((16, 72, None), (PANEL_W + 24, 72, "0"), (16, PANEL_H + 88, "1"),
|
||||
(PANEL_W + 24, PANEL_H + 88, "2")):
|
||||
panel(x, y, key)
|
||||
return "\n".join([*parts, '</g>', '</svg>', ''])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check", action="store_true", help="fail if generated files differ")
|
||||
parser.add_argument("--compact", action="store_true", help="generate one compact controller pair")
|
||||
parser.add_argument("--mode", choices=("0", "1", "2"), default="1")
|
||||
parser.add_argument("--out", type=Path, help="destination for --compact (default: the bundled app asset)")
|
||||
args = parser.parse_args()
|
||||
data = json.loads(CONTRACT.read_text(encoding="utf-8"))
|
||||
colors = portal_colors()
|
||||
assets = ROOT / "src/frameport/ui/icons"
|
||||
if args.compact:
|
||||
outputs = {args.out or assets / f"quest-buttons-{args.mode}.svg": illustration(data, colors, args.mode)}
|
||||
else:
|
||||
outputs = {ROOT / "docs/images/controllers/quest-buttons.svg": illustration(data, colors)}
|
||||
outputs.update({assets / f"quest-buttons-{n}.svg": illustration(data, colors, str(n)) for n in range(3)})
|
||||
doc = DOC.read_text(encoding="utf-8")
|
||||
before, rest = doc.split(START, 1)
|
||||
_, after = rest.split(END, 1)
|
||||
outputs[DOC] = before + START + "\n" + mapping_table(data) + "\n" + END + after
|
||||
stale = []
|
||||
for path, content in outputs.items():
|
||||
if args.check:
|
||||
if not path.exists() or path.read_text(encoding="utf-8") != content:
|
||||
stale.append(str(path.relative_to(ROOT) if path.is_relative_to(ROOT) else path))
|
||||
else:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8", newline="\n")
|
||||
if stale:
|
||||
print("Regenerate with uv run python scripts/controller_diagram.py: " + ", ".join(stale))
|
||||
return int(bool(stale))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -48,7 +48,7 @@ def from_code() -> tuple[dict, list[str]]:
|
||||
def from_data() -> dict:
|
||||
"""Help texts and patch titles/descriptions (defined as data, translated where they're shown)."""
|
||||
from frameport.patches import base
|
||||
from frameport.patches.settings import GROUPS, UI
|
||||
from frameport.patches.settings import FOVEATION_UI, GROUPS, UI
|
||||
from frameport.ui.help import HELP
|
||||
|
||||
texts = {dict.__getitem__(HELP, k): "" for k in HELP}
|
||||
@@ -61,11 +61,11 @@ def from_data() -> dict:
|
||||
texts[label] = ""
|
||||
for _, title in GROUPS: # the Game settings dialog
|
||||
texts[title] = ""
|
||||
for ui in UI.values():
|
||||
for ui in [*UI.values(), *FOVEATION_UI.values()]:
|
||||
for t in (ui.get("label"), ui.get("help")):
|
||||
if t:
|
||||
texts[t] = ""
|
||||
if ui["control"][0] == "choice":
|
||||
if ui.get("control", (None,))[0] == "choice":
|
||||
for _, label in ui["control"][1]:
|
||||
if any(c.isalpha() for c in label):
|
||||
texts[label] = ""
|
||||
|
||||
@@ -180,6 +180,8 @@ def render(shots: list[dict], variables: dict, defaults: dict, out: Path, log=pr
|
||||
break
|
||||
else:
|
||||
server.errors.append(f"{shot['name']}: three renders, no two alike (an unstable page?)")
|
||||
for k, p in enumerate(takes): # kept for --keep (CI's artifact): what differs between the renders
|
||||
p.replace(out / f"unstable-{shot['name']}-{k}.png")
|
||||
for p in takes:
|
||||
p.unlink(missing_ok=True)
|
||||
if final.exists():
|
||||
|
||||
+4
-1
@@ -148,7 +148,8 @@ def main() -> int:
|
||||
steps.insert(2, ("game-customize", lambda a: a.open_game(game, advanced=True)))
|
||||
steps += [("share-dialog", lambda a: a.share_config_dialog(game)),
|
||||
("report-dialog", lambda a: (a.page.pop_dialog(), a.report_problem_dialog(game))),
|
||||
("custom-art-dialog", lambda a: (a.page.pop_dialog(), a.custom_artwork(game)))]
|
||||
("custom-art-dialog", lambda a: (a.page.pop_dialog(), a.custom_artwork(game))),
|
||||
("game-settings-dialog", lambda a: (a.page.pop_dialog(), a.settings_dialog(game)))]
|
||||
# a pretend install of --game, mid-upload: the transit in the sidebar, the game page and Activity
|
||||
steps += [("transit-game", lambda a: (a.page.pop_dialog(), start_fake_install(a, game), a.open_game(game))),
|
||||
("transit-activity", lambda a: a.show_activity(True))]
|
||||
@@ -287,6 +288,8 @@ def main() -> int:
|
||||
("linux-change-program", lambda a: a.choose_exe(LINUX_FOLDER)),
|
||||
("frame", lambda a: (a.page.pop_dialog(), a.navigate(1))),
|
||||
("linux-move", lambda a: a.move_game(LINUX_APPIMAGE) if args.fake_frame else None),
|
||||
# GitHub #361: Files lists drives and the install folders of PC VR games and Linux apps
|
||||
("linux-files", lambda a: (a.page.pop_dialog(), a.go("files"))),
|
||||
("linux-game-menu", lambda a: (a.page.pop_dialog(), a.open_game_menu(LINUX_FOLDER))),
|
||||
("linux-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
|
||||
a.library_view.open_menu(LINUX_APPIMAGE)))]
|
||||
|
||||
+325
-163
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"updated": "2026-10-14",
|
||||
"updated": "2026-10-17",
|
||||
"people": [
|
||||
{
|
||||
"login": "spoopyghosty0",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/336754034?v=4",
|
||||
"url": "https://github.com/spoopyghosty0",
|
||||
"commits": 603,
|
||||
"issues": 5,
|
||||
"prs": 3,
|
||||
"total": 633
|
||||
"commits": 707,
|
||||
"issues": 6,
|
||||
"prs": 4,
|
||||
"total": 745
|
||||
},
|
||||
{
|
||||
"login": "Klownicle",
|
||||
@@ -19,20 +19,29 @@
|
||||
"prs": 0,
|
||||
"total": 60
|
||||
},
|
||||
{
|
||||
"login": "StevenPartridge",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/22158507?v=4",
|
||||
"url": "https://github.com/StevenPartridge",
|
||||
"commits": 0,
|
||||
"issues": 15,
|
||||
"prs": 0,
|
||||
"total": 45
|
||||
},
|
||||
{
|
||||
"login": "tkp206093",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/29872453?v=4",
|
||||
"url": "https://github.com/tkp206093",
|
||||
"commits": 0,
|
||||
"issues": 18,
|
||||
"prs": 0,
|
||||
"total": 54
|
||||
},
|
||||
{
|
||||
"login": "UnicronNL",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1267745?v=4",
|
||||
"url": "https://github.com/UnicronNL",
|
||||
"commits": 18,
|
||||
"issues": 6,
|
||||
"prs": 2,
|
||||
"total": 46
|
||||
},
|
||||
{
|
||||
"login": "StevenPartridge",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/22158507?v=4",
|
||||
"url": "https://github.com/StevenPartridge",
|
||||
"commits": 0,
|
||||
"issues": 15,
|
||||
"prs": 0,
|
||||
"total": 45
|
||||
@@ -47,13 +56,40 @@
|
||||
"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": "brunoga",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1944863?v=4",
|
||||
"url": "https://github.com/brunoga",
|
||||
"commits": 0,
|
||||
"issues": 10,
|
||||
"prs": 0,
|
||||
"total": 30
|
||||
},
|
||||
{
|
||||
"login": "LynkWyland",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/54166762?v=4",
|
||||
"url": "https://github.com/LynkWyland",
|
||||
"commits": 0,
|
||||
"issues": 10,
|
||||
"prs": 0,
|
||||
"total": 30
|
||||
},
|
||||
{
|
||||
"login": "nleach7",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1406346?v=4",
|
||||
"url": "https://github.com/nleach7",
|
||||
"commits": 4,
|
||||
"issues": 2,
|
||||
"prs": 4,
|
||||
"total": 30
|
||||
},
|
||||
{
|
||||
"login": "supercoleman04",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/342592118?v=4",
|
||||
"url": "https://github.com/supercoleman04",
|
||||
"commits": 0,
|
||||
"issues": 10,
|
||||
"prs": 0,
|
||||
"total": 30
|
||||
},
|
||||
{
|
||||
"login": "Lucas-Mathieu",
|
||||
@@ -64,6 +100,15 @@
|
||||
"prs": 4,
|
||||
"total": 29
|
||||
},
|
||||
{
|
||||
"login": "Genosystem",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/40860703?v=4",
|
||||
"url": "https://github.com/Genosystem",
|
||||
"commits": 0,
|
||||
"issues": 9,
|
||||
"prs": 0,
|
||||
"total": 27
|
||||
},
|
||||
{
|
||||
"login": "inseend",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/11666993?v=4",
|
||||
@@ -74,31 +119,31 @@
|
||||
"total": 27
|
||||
},
|
||||
{
|
||||
"login": "Genosystem",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/40860703?v=4",
|
||||
"url": "https://github.com/Genosystem",
|
||||
"login": "Izimzizi",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/77581918?v=4",
|
||||
"url": "https://github.com/Izimzizi",
|
||||
"commits": 0,
|
||||
"issues": 8,
|
||||
"issues": 9,
|
||||
"prs": 0,
|
||||
"total": 24
|
||||
},
|
||||
{
|
||||
"login": "nleach7",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1406346?v=4",
|
||||
"url": "https://github.com/nleach7",
|
||||
"commits": 3,
|
||||
"issues": 2,
|
||||
"prs": 3,
|
||||
"total": 24
|
||||
"total": 27
|
||||
},
|
||||
{
|
||||
"login": "jim2011",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/15848374?v=4",
|
||||
"url": "https://github.com/jim2011",
|
||||
"commits": 0,
|
||||
"issues": 7,
|
||||
"issues": 8,
|
||||
"prs": 0,
|
||||
"total": 21
|
||||
"total": 24
|
||||
},
|
||||
{
|
||||
"login": "coah80",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/100182500?v=4",
|
||||
"url": "https://github.com/coah80",
|
||||
"commits": 1,
|
||||
"issues": 3,
|
||||
"prs": 2,
|
||||
"total": 20
|
||||
},
|
||||
{
|
||||
"login": "Jmckenzie84",
|
||||
@@ -109,6 +154,15 @@
|
||||
"prs": 0,
|
||||
"total": 18
|
||||
},
|
||||
{
|
||||
"login": "broli95",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/22918304?v=4",
|
||||
"url": "https://github.com/broli95",
|
||||
"commits": 0,
|
||||
"issues": 5,
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "buildmine10",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/33100363?v=4",
|
||||
@@ -118,6 +172,15 @@
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "diegojansen7",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/38290452?v=4",
|
||||
"url": "https://github.com/diegojansen7",
|
||||
"commits": 0,
|
||||
"issues": 5,
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "eepykith",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/338898488?v=4",
|
||||
@@ -127,6 +190,15 @@
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "greggray24",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/255593097?v=4",
|
||||
"url": "https://github.com/greggray24",
|
||||
"commits": 0,
|
||||
"issues": 5,
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "Scoota23",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/27732436?v=4",
|
||||
@@ -146,13 +218,40 @@
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "coah80",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/100182500?v=4",
|
||||
"url": "https://github.com/coah80",
|
||||
"login": "TheBigO1562",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/27372892?v=4",
|
||||
"url": "https://github.com/TheBigO1562",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 2,
|
||||
"total": 13
|
||||
"issues": 5,
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "jeliastam",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/2113009?v=4",
|
||||
"url": "https://github.com/jeliastam",
|
||||
"commits": 0,
|
||||
"issues": 4,
|
||||
"prs": 0,
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "Loafmeister",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/60628625?v=4",
|
||||
"url": "https://github.com/Loafmeister",
|
||||
"commits": 0,
|
||||
"issues": 4,
|
||||
"prs": 0,
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "Molier",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/7408635?v=4",
|
||||
"url": "https://github.com/Molier",
|
||||
"commits": 1,
|
||||
"issues": 2,
|
||||
"prs": 1,
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "nickwilde77555",
|
||||
@@ -164,18 +263,18 @@
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "Rizsraketa",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/64963535?v=4",
|
||||
"url": "https://github.com/Rizsraketa",
|
||||
"login": "puppet-head",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/8073775?v=4",
|
||||
"url": "https://github.com/puppet-head",
|
||||
"commits": 0,
|
||||
"issues": 4,
|
||||
"prs": 0,
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "TheBigO1562",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/27372892?v=4",
|
||||
"url": "https://github.com/TheBigO1562",
|
||||
"login": "Rizsraketa",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/64963535?v=4",
|
||||
"url": "https://github.com/Rizsraketa",
|
||||
"commits": 0,
|
||||
"issues": 4,
|
||||
"prs": 0,
|
||||
@@ -217,24 +316,6 @@
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "broli95",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/22918304?v=4",
|
||||
"url": "https://github.com/broli95",
|
||||
"commits": 0,
|
||||
"issues": 3,
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "diegojansen7",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/38290452?v=4",
|
||||
"url": "https://github.com/diegojansen7",
|
||||
"commits": 0,
|
||||
"issues": 3,
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "ghost",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/10137?v=4",
|
||||
@@ -244,15 +325,6 @@
|
||||
"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",
|
||||
@@ -262,15 +334,6 @@
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "Molier",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/7408635?v=4",
|
||||
"url": "https://github.com/Molier",
|
||||
"commits": 1,
|
||||
"issues": 1,
|
||||
"prs": 1,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "pocketpal69",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/339918777?v=4",
|
||||
@@ -280,15 +343,6 @@
|
||||
"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",
|
||||
@@ -298,6 +352,15 @@
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "Akimya49",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/46827872?v=4",
|
||||
"url": "https://github.com/Akimya49",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "DJ3thenew23",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/3744521?v=4",
|
||||
@@ -307,6 +370,24 @@
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "expiredmilk58",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/217830041?v=4",
|
||||
"url": "https://github.com/expiredmilk58",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "fazejohny",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/12040179?v=4",
|
||||
"url": "https://github.com/fazejohny",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "ganona",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/112239200?v=4",
|
||||
@@ -316,6 +397,15 @@
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "gnargle",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/9372593?v=4",
|
||||
"url": "https://github.com/gnargle",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "GreySyntax",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/242626?v=4",
|
||||
@@ -326,18 +416,9 @@
|
||||
"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",
|
||||
"login": "konga056",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/67296924?v=4",
|
||||
"url": "https://github.com/konga056",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
@@ -370,6 +451,24 @@
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "Momofier",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/13684228?v=4",
|
||||
"url": "https://github.com/Momofier",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "Natouille04",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/60348431?v=4",
|
||||
"url": "https://github.com/Natouille04",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "peetzweg",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/839848?v=4",
|
||||
@@ -433,15 +532,6 @@
|
||||
"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",
|
||||
@@ -478,6 +568,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "blazedkitsune",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/98661870?v=4",
|
||||
"url": "https://github.com/blazedkitsune",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "bobosand77",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/342317426?v=4",
|
||||
@@ -487,6 +586,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "calebbakr",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/57006406?v=4",
|
||||
"url": "https://github.com/calebbakr",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "chaseflow",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/235484785?v=4",
|
||||
@@ -505,6 +613,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "daftydoo",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/342841267?v=4",
|
||||
"url": "https://github.com/daftydoo",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "darrenltw",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/151712415?v=4",
|
||||
@@ -523,6 +640,24 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "DerMueller",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/82468245?v=4",
|
||||
"url": "https://github.com/DerMueller",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "DexgamingX",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/85261590?v=4",
|
||||
"url": "https://github.com/DexgamingX",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "DolphinOfficial",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/92276540?v=4",
|
||||
@@ -550,24 +685,6 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "expiredmilk58",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/217830041?v=4",
|
||||
"url": "https://github.com/expiredmilk58",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"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",
|
||||
@@ -595,15 +712,6 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "gnargle",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/9372593?v=4",
|
||||
"url": "https://github.com/gnargle",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "hokey1981",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/42741984?v=4",
|
||||
@@ -622,6 +730,24 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "itsdavemars",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/53187388?v=4",
|
||||
"url": "https://github.com/itsdavemars",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "jakecrowley",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/8465805?v=4",
|
||||
"url": "https://github.com/jakecrowley",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "JMQuehl",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/110048375?v=4",
|
||||
@@ -631,6 +757,24 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "KampfCobain",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/76251370?v=4",
|
||||
"url": "https://github.com/KampfCobain",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Kanobar",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/122025313?v=4",
|
||||
"url": "https://github.com/Kanobar",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "LeaPhant",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/14080165?v=4",
|
||||
@@ -649,15 +793,6 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "LynkWyland",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/54166762?v=4",
|
||||
"url": "https://github.com/LynkWyland",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Mitchlett",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/46537377?v=4",
|
||||
@@ -668,18 +803,9 @@
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Momofier",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/13684228?v=4",
|
||||
"url": "https://github.com/Momofier",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Natouille04",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/60348431?v=4",
|
||||
"url": "https://github.com/Natouille04",
|
||||
"login": "MrDarkMoon",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/120572423?v=4",
|
||||
"url": "https://github.com/MrDarkMoon",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
@@ -703,6 +829,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "pinbear-creator",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/194687704?v=4",
|
||||
"url": "https://github.com/pinbear-creator",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Puchor",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/46112576?v=4",
|
||||
@@ -712,6 +847,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Rabbitbeam",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/22737155?v=4",
|
||||
"url": "https://github.com/Rabbitbeam",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "s1lenceSRG",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/340965322?v=4",
|
||||
@@ -739,6 +883,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Techbane",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/25048972?v=4",
|
||||
"url": "https://github.com/Techbane",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "tirehabitat",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/97759168?v=4",
|
||||
@@ -748,6 +901,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Twankilou",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/53940281?v=4",
|
||||
"url": "https://github.com/Twankilou",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "TylerJaacks",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1505374?v=4",
|
||||
|
||||
@@ -84,6 +84,11 @@
|
||||
"title": "Draw fades over the picture",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.fb_color_space": {
|
||||
"anchor": "adapter-fb_color_space",
|
||||
"title": "Colour space support",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.flip_dump": {
|
||||
"anchor": "adapter-flip_dump",
|
||||
"title": "Upside-down panel images (diagnostics)",
|
||||
@@ -224,6 +229,11 @@
|
||||
"title": "Animate fingers from touch",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.quest_buttons": {
|
||||
"anchor": "adapter-quest_buttons",
|
||||
"title": "Controller buttons",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.rect_clamp": {
|
||||
"anchor": "adapter-rect_clamp",
|
||||
"title": "Keep pictures in bounds",
|
||||
@@ -246,7 +256,7 @@
|
||||
},
|
||||
"adapter.scale": {
|
||||
"anchor": "adapter-scale",
|
||||
"title": "Sharpness",
|
||||
"title": "Render resolution (supersampling)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.scene_depth": {
|
||||
@@ -379,11 +389,21 @@
|
||||
"title": "Foveated rendering (Valve)",
|
||||
"group": "Files and settings on the Frame"
|
||||
},
|
||||
"device.headset_model": {
|
||||
"anchor": "device-headset_model",
|
||||
"title": "Headset model",
|
||||
"group": "Files and settings on the Frame"
|
||||
},
|
||||
"device.hide_navbar": {
|
||||
"anchor": "device-hide_navbar",
|
||||
"title": "Hide Android's navigation bar",
|
||||
"group": "Files and settings on the Frame"
|
||||
},
|
||||
"device.language": {
|
||||
"anchor": "device-language",
|
||||
"title": "Game language",
|
||||
"group": "Files and settings on the Frame"
|
||||
},
|
||||
"device.lepton_env": {
|
||||
"anchor": "device-lepton_env",
|
||||
"title": "Extra Lepton settings",
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
{
|
||||
"updated": "2026-10-13",
|
||||
"updated": "2026-10-17",
|
||||
"releases": [
|
||||
{
|
||||
"tag": "v1.2.1",
|
||||
"name": "FramePort v1.2.1",
|
||||
"date": "2026-10-17",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.2.1",
|
||||
"dev": false,
|
||||
"notes": "FramePort v1.2.1: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Save backups for Quest games:** back up a game's saves from the Frame and put them back later.\n- **Library folders:** see every folder FramePort scans in one list; moved games are found again on the next scan instead of showing as missing.\n- **Refresh rate fix** (thanks @nleach7): a game set to 90 Hz or more no longer starts at 72 Hz.\n- **Install links:** games installed from a link are checked for a newer version when FramePort starts.\n- **Game fixes:** Star Wars: Tales from the Galaxy's Edge no longer freezes at a later loading screen (thanks @MrDarkMoon). Fixes for PreyVR's black main menu, the Table Troopers tutorial, Spectre and Escaping Wonderland's endless loading screen, Pinball FX2 VR, BattleTalent, controllers in Hotel Infinity and Spatial Zombies, Scared By Squares, Medieval Dynasty's foveation, Star Wars: Beyond Victory in mixed reality, Titan Isles, PianoVision and Eleven Table Tennis. Curved menus that showed black now show in both eyes.\n- **Clearer reports:** Google Daydream / Cardboard builds get a warning (they can't run on the Frame), and the log check now names failed Meta online requests.\n- **Fixes:** uploads with OpenSSH work with \"[\" or \"]\" in a folder name, \"Update with a newer version…\" for Linux apps and PC VR games, and Settings shows the options most people use first.\n- **35 new working recipes.**\n- **Improvements to automatic patch detection** for games without a verified recipe.\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.2.0",
|
||||
"name": "FramePort v1.2.0",
|
||||
"date": "2026-10-15",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.2.0",
|
||||
"dev": false,
|
||||
"notes": "FramePort v1.2.0: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Run many 32-bit Gear VR, Oculus Go and older Quest games** (experimental, thanks UnicronNL): a built-in 32-bit translator now runs AFFECTED The Manor, Gunjack, Spunky Dungeon, Sports Scramble, Journey of the Gods and Shadow Point.\n- **New working recipes:** Assassin's Creed Nexus, Ghostbusters: Rise of the Ghost Lord, Majora's Mask VR and Plezy.\n- **Game fixes:** Vader Immortal no longer slows down the longer you play, Medal of Honor and other Unreal games get past the invisible data-check screen, and UNDERDOGS and games that crashed on a cube-map (skybox) keep running.\n- **Fixes:** more reliable Steam library shortcuts, \"Add to Steam library\" works again after an older install left duplicate entries, and faster uploads for games with many small files.\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": "dev",
|
||||
"name": "FramePort dev build 1.2.1.dev433",
|
||||
"date": "2026-10-15",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/dev",
|
||||
"dev": true,
|
||||
"notes": "Dev build **1.2.1.dev433** from commit 6cd1eaa (2026-10-15). For testing fixes before\nthe next release; you get that release as a normal update.\n\n## Please test\n\nPreyVR (and other Doom3Quest-based ports with a curved menu): fix for the black menu. The menu is a per-eye cylinder the Frame's runtime rejected as too many layers; this build shows it to both eyes. Install, then rebuild PreyVR on the Frame and launch it from Steam — the menu should appear.\n\n## Changes since v1.2.0\n\n- adapter: show a per-eye cylinder menu to both eyes (fix black curved menus)\n- adapter: log the created swapchain handle in xrCreateSwapchain\n- Catalog: 16 community-verified recipes\n"
|
||||
},
|
||||
{
|
||||
"tag": "v1.1.1",
|
||||
"name": "FramePort v1.1.1",
|
||||
"date": "2026-10-14",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.1.1",
|
||||
"dev": false,
|
||||
"notes": "FramePort v1.1.1: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Faster game updates:** a game that hasn't changed isn't rebuilt or uploaded again, and settings-only changes reach the Frame in seconds.\n- **Faster, resumable uploads:** big files go through OpenSSH in parallel when it's available, with the old uploader as a fallback.\n- **New working recipes:** Arkio, COMPOUND, Eleven Table Tennis, Moss: Book II, RacketPinball and Shoot The Zombirds VR (thanks marcoszaballos, Tazzash, greggray24 and wanwanwanwano), plus the fade fix on by default for Batman: Arkham Shadow and Iron Man VR.\n- **Game fixes:** BattleTalent starts (xLua), Lambda1VR and other Team Beef ports no longer stretch animated models, Accounting+ and 2D apps such as Plezy convert again.\n- **Fixes:** Steam keeps FramePort's shortcuts when another app was added first (#263), FramePort starts again when Windows hid its old window, and diagnostics include Windows display information.\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.1.0",
|
||||
"name": "FramePort v1.1.0",
|
||||
@@ -49,14 +81,6 @@
|
||||
"dev": false,
|
||||
"notes": "FramePort v1.0.0: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **A new look:** Portal and Original themes, an \"On your Frame\" shelf in the Library, and install progress shown as the game passing through a portal.\n- **Easier setup:** run `curl -fsSL https://frameport.app/s | bash` on the Frame and allow it on the PC. A Frame in Developer Mode pairs without a click.\n- **SteamVR overlay apps** (for example Temporal Reality) run on the Frame over your games and can start with SteamVR.\n- **More folders just work:** SideQuest backups and AXRB downloads import with their data files.\n- **Rename any game**, and the name in Steam follows.\n- **Hardware video decoding** for games that play videos (opt-in per game), a Steam Input gamepad for flat Android apps, and Godot games start.\n- **BlazeRush works** (fixes from a tester's guide), and Unreal games no longer hang on their OBB check.\n- **Play sessions are checked too:** after you play, FramePort reads the session's log and suggests fixes on the game's page.\n- **126 game recipes** (76 in 0.12.0), and fixes for Vader Immortal, VR4, Doom3Quest, Star Wars Pinball, Green Hell VR, Budget Cuts and more.\n- The update dialog lists every change since your version. Install buttons now use frameport.app links, and game reports are titled \"[Game report]\".\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": "dev",
|
||||
"name": "FramePort dev build 0.12.1.dev263",
|
||||
"date": "2026-10-11",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/dev",
|
||||
"dev": true,
|
||||
"notes": "Dev build **0.12.1.dev263** from commit dccdcaa (2026-10-11). For testing fixes before\nthe next release; you get that release as a normal update.\n\n## Please test\n\n1.0 release candidate. New since dev261: SteamVR overlay apps (for example Temporal Reality's Linux arm64 build) installed with Add a Linux app run on the Frame over your games, with a Start with SteamVR switch.\n\n## Changes since v0.12.0\n\n- SteamVR overlay apps on the Frame (agent v75)\n- Sharing: fall back to a plain issue when the form opens empty (#167)\n- Monitor: per-module collection needs agent 73 (main's 72 lacks it); correct agent versions in comments\n- serialize_flet_updates: tolerate Flet versions without Session.patch_control or Prop (log, keep the rest)\n- Issue forms: quote the descriptions so the templates are valid YAML\n- Auto pairing scan: skip container/VM/VPN interfaces and CGNAT, back off after empty scans\n- pyproject: Development Status 5 - Production/Stable\n- Catalog: Vacation Simulator starts; controllers aren't tracked yet (shared working config)\n- Catalog: The Thrill Of The Fight runs with Unity's overlay copy skipped; the right eye misses the opponent and referee (shared working config)\n- Catalog: Fruit Salon works (shared working config)\n- Auto pairing: hold off only while a setup request is open (not for allowed, denied or expired ones)\n- Docs: diagrams instead of ASCII art\n- Install links: FramePort's https://frameport.app/install?… page is the documented button link (pasted links parse too); README, docs, site and in-app texts \n- Pairing: setup requests expire when the Frame stops polling; setup.sh explains HTTP 429\n- Diagnostics: send every shader module of the newest session (#140)\n- CLI frame pair: don't announce the pairing server over mDNS (it can't answer setup-URL requests)\n- Issue reports: a game's report is titled \"[Game report] <game>\" with the game-compatibility label; reports about FramePort itself \"[App] FramePort\" with bug (\n- Setup line: curl -fsSL https://frameport.app/s (fail on HTTP errors, explicit https)\n- Site: Cloudflare and Google analytics, with a cookie choice\n- Docs: update screenshots and videos\n- Site: SEO for frameport.app\n- Site: What's new page and section from GitHub releases\n- Site: shorter, consistent copy, served at frameport.app\n- Docs: shorter, consistent copy\n- App: shorter, consistent interface text\n- Developer docs, CLI and launch-test diagnoses: consistent terms\n- Steam Input gamepad for 2D Android apps (device.steam_gamepad, agent v72, GitHub #162)\n- Wording: style word list and length rules\n- Vulkan shim: vk_shader_dump writes the game's SPIR-V modules with a creation index (GitHub #140)\n- Triage: unity-data-missing fails a launch test when Unity can't read the game's data files (incomplete OBB or one from another version; Batman passed while st\n- Game page shows Update on Frame after a newer APK of an installed game was added (builds record the APK version they were made from; GitHub #161); triage only\n- Catalog: Eleven (Quest) and GOLF+ unsupported (Meta online services / the game's login refuse the platform), Beat Saber DLC isn't detected, Doom3Quest works (\n- Catalog: 13 shared working configs (Freedom, Zombieland VR, Caves, Ancient Dungeon, Containment Protocol, Down the Rabbit Hole, End Space, ExploreVR, Racket: \n- Site: two neon worlds, a portal-first download page, one-eye live view\n- Game page: open the last launch test's log\n- Site: interactive docs, a download page, two more worlds, solid rocks\n- Site: three hero worlds, a 3D live view, a closer app trial, real screenshots\n- Install walkthrough: the setup line and Allow, in the video and on the site\n- Pair Frames in Developer Mode without a click on the PC\n- Shared video decoder (codec revision 8): hidden VP9 frames no longer end the EOS drain early\n"
|
||||
},
|
||||
{
|
||||
"tag": "v0.12.0",
|
||||
"name": "FramePort v0.12.0",
|
||||
|
||||
@@ -19,7 +19,6 @@ import urllib.parse
|
||||
from ..core import cache
|
||||
from . import fetch, sources
|
||||
|
||||
STEAM_DETAILS = "https://store.steampowered.com/api/appdetails?appids={id}&l=en"
|
||||
MAX_SHOTS = 6
|
||||
|
||||
|
||||
@@ -51,10 +50,7 @@ def steam_app(title: str) -> dict | None:
|
||||
hit = sources.match_steam(title)
|
||||
if not hit:
|
||||
return None
|
||||
data = cache.cached_json(f"steamapp-{hit['id']}.json", STEAM_DETAILS.format(id=hit["id"]), max_age=sources.MAX_AGE,
|
||||
fallback={})
|
||||
d = (data or {}).get(str(hit["id"])) or {}
|
||||
return d.get("data") if d.get("success") else None
|
||||
return sources.steam_details(hit["id"])
|
||||
|
||||
|
||||
def fetch_details(entry: dict, screenshots: bool = True) -> dict:
|
||||
@@ -68,6 +64,11 @@ def fetch_details(entry: dict, screenshots: bool = True) -> dict:
|
||||
except Exception: # noqa: BLE001 - offline
|
||||
app = None
|
||||
if app:
|
||||
pkg = entry.get("quest_package") if entry.get("kind") == "rift" else entry["package"]
|
||||
out.setdefault("matches", {})["oculusdb"] = {
|
||||
"by": "package" if pkg and app.get("packageName") == pkg else "title",
|
||||
"key": pkg if pkg and app.get("packageName") == pkg else title,
|
||||
}
|
||||
out["sources"].append("oculusdb")
|
||||
out["description"] = _text(app.get("display_long_description"))
|
||||
out["genres"] = [g for g in app.get("genre_names") or [] if g]
|
||||
@@ -82,6 +83,7 @@ def fetch_details(entry: dict, screenshots: bool = True) -> dict:
|
||||
except Exception: # noqa: BLE001
|
||||
st = None
|
||||
if st:
|
||||
out.setdefault("matches", {})["steam"] = {"by": "title", "key": title}
|
||||
out["sources"].append("steam")
|
||||
out["steam_appid"] = st.get("steam_appid")
|
||||
out["short"] = _text(st.get("short_description"))
|
||||
@@ -103,6 +105,26 @@ def fetch_details(entry: dict, screenshots: bool = True) -> dict:
|
||||
return {k: v for k, v in out.items() if v not in (None, "", [])}
|
||||
|
||||
|
||||
def title_matched(entry: dict) -> bool:
|
||||
"""Legacy Steam and Rift name matches must be refreshed after a rename."""
|
||||
details = entry.get("details") or {}
|
||||
matches = details.get("matches") or {}
|
||||
for source in details.get("sources") or []:
|
||||
if source in matches:
|
||||
if matches[source].get("by") == "title":
|
||||
return True
|
||||
elif source == "steam" or source == "oculusdb" and entry.get("kind") == "rift":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def clear_screenshots(package: str) -> None:
|
||||
d = fetch.artwork_dir(package)
|
||||
for pattern in ("shot_*.jpg", "t_shot_*"):
|
||||
for path in d.glob(pattern):
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def save_screenshots(package: str, urls: list[str]) -> list[str]:
|
||||
d = fetch.artwork_dir(package)
|
||||
saved = []
|
||||
|
||||
@@ -92,6 +92,31 @@ def match_quest(title: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
STEAM_DETAILS = "https://store.steampowered.com/api/appdetails?appids={id}&l=en"
|
||||
|
||||
|
||||
def sexual_content(app: dict) -> bool:
|
||||
"""Steam's sexual-content categories; general mature content (5) alone is allowed."""
|
||||
descriptors = app.get("content_descriptors") or {}
|
||||
ids = {str(i) for i in descriptors.get("ids") or []}
|
||||
if ids & {"1", "3", "4"}:
|
||||
return True
|
||||
try:
|
||||
age = int(app.get("required_age") or 0)
|
||||
except (TypeError, ValueError):
|
||||
age = 0
|
||||
notes = str(descriptors.get("notes") or "").lower()
|
||||
return age >= 18 and any(word in notes for word in ("sexual", "nudity", "nude", "porn"))
|
||||
|
||||
|
||||
def steam_details(appid: int | str) -> dict | None:
|
||||
data = cache.cached_json(f"steamapp-{appid}.json", STEAM_DETAILS.format(id=appid), max_age=MAX_AGE,
|
||||
fallback={})
|
||||
result = (data or {}).get(str(appid)) or {}
|
||||
app = result.get("data") if result.get("success") else None
|
||||
return app if app and not sexual_content(app) else None
|
||||
|
||||
|
||||
def match_steam(title: str) -> dict | None:
|
||||
want = norm(title)
|
||||
return next((i for i in steam_search(title) if norm(i.get("name") or "") == want), None)
|
||||
@@ -124,6 +149,8 @@ def apply_oculusdb(package: str, app: dict) -> bool:
|
||||
|
||||
|
||||
def apply_steam(package: str, appid: int | str) -> bool:
|
||||
if not steam_details(appid):
|
||||
return False
|
||||
got = False
|
||||
for kind, file in STEAM_FILES.items():
|
||||
data = _get(STEAM_CDN.format(id=appid, file=file))
|
||||
|
||||
@@ -24,6 +24,19 @@ def sha256(path: Path) -> str:
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def native_checksums(names: list[str]) -> dict[str, str | None] | None:
|
||||
"""Current bundled checksums for selected APK artifacts; None means the manifest is unavailable."""
|
||||
from .core.paths import artifacts_dir
|
||||
|
||||
try:
|
||||
lines = (artifacts_dir() / "SHA256SUMS").read_text().splitlines()
|
||||
except OSError:
|
||||
return None
|
||||
checksums = {parts[1].strip().removeprefix("./"): parts[0]
|
||||
for line in lines if len(parts := line.split(maxsplit=1)) == 2}
|
||||
return {name: checksums.get(name) for name in names}
|
||||
|
||||
|
||||
def overport_ids(recipe: Recipe, alt: bool = False) -> list[str]:
|
||||
"""OVRPort defaults (OVRPort's order) first, then extras in recipe order, then the alt-build extras."""
|
||||
chosen = [pid for pid in recipe.patches if base.get(pid).category == "overport"]
|
||||
@@ -38,6 +51,9 @@ def overport_ids(recipe: Recipe, alt: bool = False) -> list[str]:
|
||||
|
||||
def apk_patches(selection: dict) -> list[base.Patch]:
|
||||
patches = [base.get(pid) for pid in selection if base.get(pid).stage == "apk"]
|
||||
if "frame.adapter" in selection and "device.foveation" not in selection:
|
||||
# Prepare verified game-specific support independently of the player's runtime preference.
|
||||
patches.append(base.get("device.foveation"))
|
||||
return sorted(patches, key=lambda p: p.order)
|
||||
|
||||
|
||||
|
||||
@@ -915,6 +915,31 @@ def saves_undo(package: str = typer.Argument(..., help="the game (package or par
|
||||
typer.echo(f"{pkg}: the saves from before the last restore are back ({r.get('restored', 0)} file(s))")
|
||||
|
||||
|
||||
@frame_app.command("steam-library")
|
||||
def frame_steam_library(mode: str = typer.Argument(..., help="now, later or off"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""Save Steam shortcut behaviour for future installs and Play, and apply it on the Frame."""
|
||||
from .core import library
|
||||
|
||||
if mode not in ("now", "later", "off"):
|
||||
raise typer.BadParameter("choose now, later or off")
|
||||
_target(frame).connect().frame.agent("library_mode", mode=mode)
|
||||
library.set_setting("install.steam_library", mode)
|
||||
typer.echo(mode)
|
||||
|
||||
|
||||
@frame_app.command("restart-steam")
|
||||
def frame_restart_steam(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
yes: bool = typer.Option(False, "--yes", help="confirm ending a running game")):
|
||||
"""Restart Steam now and apply pending library changes. Closes local FramePort/Desktop Mode too."""
|
||||
agent = _target(frame).connect().frame
|
||||
result = agent.agent("restart_steam", confirm=yes)
|
||||
if result.get("confirmation_required"):
|
||||
typer.confirm("A game is running. End it and restart Steam?", abort=True)
|
||||
result = agent.agent("restart_steam", confirm=True)
|
||||
typer.echo(json.dumps(result))
|
||||
|
||||
|
||||
@frame_app.command("info")
|
||||
def frame_info(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""The Frame's SteamOS build, runtimes, storage and installed games (JSON)."""
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Controller preferences at the library boundary; recipe and configuration identities stay preference-free."""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import library
|
||||
|
||||
QUEST_BUTTONS = "adapter.quest_buttons"
|
||||
CHOICES = ((0, "SteamVR default"), (1, "Mirror"), (2, "Quest pair"))
|
||||
|
||||
|
||||
def quest_buttons_default() -> int:
|
||||
value = library.setting("input.quest_buttons", 0)
|
||||
return int(value) if value in (0, 1, 2, "0", "1", "2") else 0
|
||||
|
||||
|
||||
def effective_adapter_settings(patches: dict) -> dict:
|
||||
from ..patches.frame.headset_model import adapter_settings
|
||||
|
||||
return adapter_settings(patches, defaults={"quest_buttons": quest_buttons_default()})
|
||||
|
||||
|
||||
def _adapter_current(game: dict, installed: dict) -> bool:
|
||||
from ..build import native_checksums
|
||||
|
||||
build = game.get("build") or {}
|
||||
# A newer local build does not prove that its adapter reached the installed APK.
|
||||
sha = installed.get("sha256")
|
||||
if not sha or sha not in (build.get("sha256"), build.get("alt_sha256")):
|
||||
return False
|
||||
recorded = (build.get("build_inputs") or {}).get("native") or {}
|
||||
adapters = [name for name in recorded if name.endswith("/libopenxr_loader_generic.so")]
|
||||
current = native_checksums(adapters) or {}
|
||||
return bool(adapters) and all(recorded[name] and recorded[name] == current.get(name) for name in adapters)
|
||||
|
||||
|
||||
def apply_default(target, reporter) -> dict[str, list[str]]:
|
||||
"""Push only inherited Quest preferences. Read the library and device off the UI thread."""
|
||||
mode = quest_buttons_default()
|
||||
reporter.stage("Apply controller buttons")
|
||||
games = {g["package"]: g for g in library.games()}
|
||||
applied, needs_update = [], []
|
||||
for installed in target.installed():
|
||||
reporter.check_cancel()
|
||||
package = installed["package"]
|
||||
game = games.get(package, {})
|
||||
if installed.get("kind", "quest") != "quest" or game.get("kind", "quest") != "quest":
|
||||
continue
|
||||
analysis = game.get("analysis") or {}
|
||||
if (analysis.get("extra") or {}).get("vr_kind", "quest") != "quest":
|
||||
continue
|
||||
patches = (game.get("recipe") or {}).get("patches") or {}
|
||||
recorded = (installed.get("recipe") or {}).get("patches") or []
|
||||
if QUEST_BUTTONS in patches or (not game and QUEST_BUTTONS in recorded):
|
||||
continue
|
||||
target.set_settings(package, {"quest_buttons": mode})
|
||||
current = _adapter_current(game, installed)
|
||||
(applied if current else needs_update).append(package)
|
||||
when = "next launch" if current else "needs Update on Frame first"
|
||||
reporter.log(f"{package}: quest_buttons={mode} ({when})")
|
||||
return {"applied": applied, "needs_update": needs_update}
|
||||
@@ -180,6 +180,16 @@ def _migrate(data: dict) -> bool:
|
||||
"""One-time updates of stored recipes when a new default is introduced (each runs once per library)."""
|
||||
done = data.setdefault("settings", {}).setdefault("migrations", [])
|
||||
changed = False
|
||||
if "foveation_preference" not in done:
|
||||
for g in (data.get("games") or {}).values():
|
||||
recipe = g.get("recipe") or {}
|
||||
params = (recipe.get("patches") or {}).get("device.foveation")
|
||||
if params is not None:
|
||||
g.setdefault("foveation", dict(params))
|
||||
recipe["patches"]["device.foveation"] = {"support": True}
|
||||
recipe.get("reasons", {}).pop("device.foveation", None)
|
||||
done.append("foveation_preference")
|
||||
changed = True
|
||||
if "no_crash_reporter" not in done:
|
||||
# the Unreal crash reporter is disabled by default for PC VR Unreal games, also those added before
|
||||
for g in (data.get("games") or {}).values():
|
||||
@@ -359,6 +369,12 @@ def _migrate(data: dict) -> bool:
|
||||
games.pop(pkg)
|
||||
done.append("drop_stub_entries")
|
||||
changed = True
|
||||
if "headset_model_quest3" not in done:
|
||||
from ..patches.frame.headset_model import migrate
|
||||
|
||||
migrate(data)
|
||||
done.append("headset_model_quest3")
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
|
||||
@@ -113,6 +113,16 @@ def _links(template: str, fields: dict[str, str], trim: list[str], layout: dict,
|
||||
|
||||
def _working_config_fields(g: dict, recipe_yaml: str, status: str, notes: str, env: dict,
|
||||
red: redact.Redactor | None) -> dict[str, str]:
|
||||
import yaml
|
||||
|
||||
parsed = yaml.safe_load(recipe_yaml)
|
||||
if isinstance(parsed, dict):
|
||||
for key in ("foveation", "foveation_radius", "foveation_test"):
|
||||
parsed.pop(key, None)
|
||||
for key in ("patches", "reasons"):
|
||||
if isinstance(parsed.get(key), dict):
|
||||
parsed[key].pop("device.foveation", None)
|
||||
recipe_yaml = yaml.safe_dump(parsed, sort_keys=False)
|
||||
red = red or redact.Redactor()
|
||||
title = g.get("title") or g["package"]
|
||||
kind = {"rift": "Oculus Rift (PC VR)", "linux": "Linux app (arm64)"}.get(g.get("kind"), "Meta Quest")
|
||||
|
||||
@@ -35,7 +35,8 @@ def apk_recipe_fingerprint(recipe: dict, package: str = "", superseded=None) ->
|
||||
|
||||
|
||||
def configuration(recipe: dict, package: str = "", superseded=None) -> dict:
|
||||
from .patches.settings import BASE_SETTINGS, SETTINGS, adapter_settings
|
||||
from .patches.frame.headset_model import adapter_settings
|
||||
from .patches.settings import BASE_SETTINGS, 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.
|
||||
@@ -52,6 +53,21 @@ def configuration_fingerprint(recipe: dict, package: str = "", superseded=None)
|
||||
return fingerprint(configuration(recipe, package, superseded))
|
||||
|
||||
|
||||
def configuration_matches(recipe: dict, applied: str | None, package: str = "", superseded=None) -> bool:
|
||||
current = configuration(recipe, package, superseded)
|
||||
if fingerprint(current) == applied:
|
||||
return True
|
||||
if "adapter.quest_buttons" in (recipe.get("patches") or {}):
|
||||
return False
|
||||
# set_settings records the live value in the agent's configuration fingerprint. Any inherited mode is
|
||||
# current: changing the library default is applied explicitly, without making the whole library outdated.
|
||||
for mode in (1, 2):
|
||||
current["settings"]["quest_buttons"] = mode
|
||||
if fingerprint(current) == applied:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdatePlan:
|
||||
rebuild: bool = False
|
||||
@@ -88,8 +104,8 @@ def plan_update(game: dict, deployment: dict | None, *, apk_only: bool = False,
|
||||
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"))
|
||||
configure = apk or not configuration_matches(recipe, dep.get("configuration_fp"),
|
||||
package, build.get("superseded"))
|
||||
if data_manifest is None:
|
||||
data_manifest = game.get("data_manifest")
|
||||
data = False
|
||||
|
||||
@@ -17,7 +17,7 @@ INTERNAL = "internal"
|
||||
|
||||
def list_drives(frame: Frame) -> list[dict]:
|
||||
"""The agent's `drives`: [{id, path, install_dir, label, fstype, internal, removable, free_bytes, total_bytes,
|
||||
usable, reason, games}]."""
|
||||
usable, reason, read_only, games}]."""
|
||||
return frame.agent("drives")["drives"]
|
||||
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ from pathlib import Path
|
||||
|
||||
from ..artwork import fetch as artwork
|
||||
from ..build import sha256
|
||||
from ..core.controllers import effective_adapter_settings
|
||||
from ..core.events import Cancelled, Reporter
|
||||
from ..core.models import Recipe, data_manifest
|
||||
from ..frame.connection import Frame, sh_quote
|
||||
from ..patches import base
|
||||
from ..patches.settings import adapter_settings
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -31,7 +31,7 @@ class InstallPlan:
|
||||
|
||||
|
||||
def install_context(recipe: Recipe) -> base.InstallContext:
|
||||
ctx = base.InstallContext(recipe.package, {}, {}, {}, adapter_settings(recipe.patches))
|
||||
ctx = base.InstallContext(recipe.package, {}, {}, {}, effective_adapter_settings(recipe.patches))
|
||||
# device.foveation last: the player's explicit choice beats a recipe's lepton_env VK_INSTANCE_LAYERS
|
||||
for pid, params in sorted(recipe.patches.items(), key=lambda kv: kv[0] == "device.foveation"):
|
||||
patch = base.get(pid)
|
||||
@@ -46,10 +46,22 @@ def configuration_args(entry: dict, recipe: Recipe) -> dict:
|
||||
from ..game_update import configuration, configuration_fingerprint
|
||||
|
||||
ctx = install_context(recipe)
|
||||
from ..patches.settings import foveation_env, foveation_preference
|
||||
|
||||
overrides = foveation_env(recipe.package, foveation_preference(entry), ctx.adapter_settings, ctx.env)
|
||||
for key, value in overrides.items():
|
||||
if value is None:
|
||||
ctx.env.pop(key, None)
|
||||
else:
|
||||
ctx.env[key] = value
|
||||
original = entry["recipe"]
|
||||
build = entry.get("build") or {}
|
||||
applied = configuration(original, recipe.package, build.get("superseded"))
|
||||
settings = applied["settings"]
|
||||
settings = dict(applied["settings"])
|
||||
# Preferences affect the runtime payload, never the recorded fingerprint.
|
||||
settings["quest_buttons"] = ctx.adapter_settings["quest_buttons"]
|
||||
if foveation_preference(entry).get("mode") in ("tracked", "fixed"):
|
||||
settings.update(foveation_fix=1, foveation_storage_fix=1)
|
||||
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,
|
||||
@@ -817,7 +829,15 @@ def add_to_steam(frame: Frame, packages: list[str], reporter: Reporter, wait: fl
|
||||
import time
|
||||
|
||||
reporter.stage("Add to Steam library")
|
||||
frame.agent("shortcuts", packages=packages, restart=True)
|
||||
from ..core import library
|
||||
|
||||
mode = library.setting("install.steam_library", "now")
|
||||
frame.agent("library_mode", mode=mode)
|
||||
submitted = frame.agent("shortcuts", packages=packages, restart=True, mode=mode)
|
||||
if submitted.get("disabled") or submitted.get("state") == "pending":
|
||||
reporter.check("Steam library", None, "disabled; use FramePort Play" if mode == "off" else
|
||||
"queued for the next Steam start; you can keep installing")
|
||||
return submitted
|
||||
end = time.time() + wait
|
||||
status = {}
|
||||
while time.time() < end:
|
||||
@@ -830,6 +850,8 @@ def add_to_steam(frame: Frame, packages: list[str], reporter: Reporter, wait: fl
|
||||
break
|
||||
if status.get("state") == "waiting": # Steam restarts later: after the game, or back in Gaming Mode
|
||||
reporter.check("Steam library", None,
|
||||
"added after FramePort on the Frame closes (Steam has to restart for it)"
|
||||
if status.get("reason") == "frameport" else
|
||||
"added when you're back in Gaming Mode (Steam has to restart for it)"
|
||||
if status.get("reason") == "desktop" else
|
||||
"added when the game that's running now is closed (Steam has to restart for it)")
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"920 × 430": "",
|
||||
"96 Hz": "",
|
||||
"A folder with Quest, Android or PC VR games.": "",
|
||||
"A game is running": "",
|
||||
"A game is running on the Frame": "",
|
||||
"A game is running on the Frame. Close it in the headset first, then try again.": "",
|
||||
"a known blocker": "",
|
||||
@@ -185,7 +186,12 @@
|
||||
"APK": "",
|
||||
"Appearance": "",
|
||||
"AppImage": "",
|
||||
"Apply with Update on Frame.": "",
|
||||
"Applied to {n} game. Takes effect at the next launch.": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"Apply controller buttons to installed games": "",
|
||||
"Apply to installed games now": "",
|
||||
"Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead (components Touch lacks are dropped), and the runtime's answer for each hand is reported to them as their own profile (Unity's OpenXR input only connects controllers of a profile it knows). Meta's detached-controller paths (XR_META_detached_controllers, which the Frame lacks) are left out of the app's actions and bindings: one such path made Unity attach no controller input at all. Without it such apps get no controller input.": "",
|
||||
"Artwork for {get}": "",
|
||||
"Artwork updated ({src}). The Frame's Steam library still shows the old art.": "",
|
||||
@@ -251,6 +257,7 @@
|
||||
"Changes apply the next time FramePort starts.": "",
|
||||
"Changes apply to the next install. Hover a patch above to see why it's there.": "",
|
||||
"Changes take effect the next time the game starts. Not sure? Leave a setting as it is.": "",
|
||||
"Changes the game's render resolution. Higher is sharper but slower; 1.0× is the game's own resolution.": "",
|
||||
"Changes: ": "",
|
||||
"Change…": "",
|
||||
"Check for new versions automatically": "",
|
||||
@@ -274,7 +281,13 @@
|
||||
"Choose a folder": "",
|
||||
"Choose a folder with Quest, Android or PC VR games. FramePort finds them and gets them ready.": "",
|
||||
"Choose Flat window if a phone or tablet app shows nothing in the headset, VR if a VR app opens as a flat window.": "",
|
||||
"Choose how the Frame's face buttons send Quest Touch inputs: 0 = SteamVR default, 1 = Mirror, 2 = Quest pair. Used at the next launch; SteamVR custom bindings can override it. Requires the runtime's XR_VALVE_frame_controller_interaction extension. Mapping: https://github.com/spoopyghosty0/frameport/blob/main/docs/CONTROLLERS.md": "",
|
||||
"Choose PNG, JPEG or WebP images. Missing shapes are made from the ones you choose, and FramePort never replaces them.": "",
|
||||
"Choose the headset identity the game sees; Quest 3 is the default.": "",
|
||||
"Choose the language for games that follow the system language. The game must include it. Apply with Update on Frame.": "",
|
||||
"Choose the OpenXR headset name and headset UUID reported to Quest games. Quest 3 is the default; Quest 2 preserves OVRPort's original answers. Requires a rebuild. Android Build.MODEL remains Lepton.": "",
|
||||
"Choose which Quest Touch buttons the Frame's face buttons send.": "",
|
||||
"Choose which Quest Touch buttons the Frame's face buttons send. Takes effect at the next launch.": "",
|
||||
"Choose…": "",
|
||||
"Chosen by you.": "",
|
||||
"Clean up Frida leftovers in smali": "",
|
||||
@@ -297,12 +310,14 @@
|
||||
"Collect FramePort logs": "",
|
||||
"Collect logs": "",
|
||||
"Collect logs: {title}": "",
|
||||
"Colour space support": "",
|
||||
"Command-line arguments for the game program. Games that support several VR runtimes pick one with an argument, for example Unreal's -hmd=OpenXR or -hmd=SteamVR, Unity's -vrmode OpenVR, or a game's own switch (-Runtime=OpenXRHMD). FramePort fills them in from the game files (the repack's VD.bat with the Oculus module swapped for OpenXR, else the engine's usual switch).": "",
|
||||
"Computes some of the game's effects more precisely, so they don't draw streaks or flashes (for example BlazeRush's particles). Comes from a game's recipe.": "",
|
||||
"Connect": "",
|
||||
"Connect by address…": "",
|
||||
"Connect Frames in Developer Mode automatically": "",
|
||||
"Connect the Frame's USB-C port to this PC with a data cable (not a charge-only one).": "",
|
||||
"Connect to the Frame first.": "",
|
||||
"Connect to your Steam Frame to browse its videos, downloads and each game's storage.": "",
|
||||
"Connect to your Steam Frame to see its screenshots here.": "",
|
||||
"Connect to your Steam Frame to stream its view and sound to this PC.": "",
|
||||
@@ -322,7 +337,9 @@
|
||||
"Connection lost, reconnecting…": "",
|
||||
"Content files from the game's data": "",
|
||||
"Continue": "",
|
||||
"Controller buttons": "",
|
||||
"Controller input diagnostics (log)": "",
|
||||
"Controller mapping guide": "",
|
||||
"Controllers": "",
|
||||
"Controllers as a gamepad (Steam Input)": "",
|
||||
"Converts Quest games to OpenXR": "",
|
||||
@@ -374,7 +391,8 @@
|
||||
"Create": "",
|
||||
"Customize": "",
|
||||
"Cylinder layers (curved menus and movie screens, for example 4XVR), which the Frame's runtime lacks, are shown as a few flat strips along the curve. 0 = drop them. 360° (equirect) layers can't be shown on the Frame.": "",
|
||||
"Darken lower-resolution regions (test)": "",
|
||||
"Dansk": "",
|
||||
"Darken coarse fragments (test)": "",
|
||||
"Data": "",
|
||||
"data file": "",
|
||||
"data folder": "",
|
||||
@@ -386,6 +404,7 @@
|
||||
"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.": "",
|
||||
"Default": "",
|
||||
"Default (30% radius)": "",
|
||||
"Default controller buttons": "",
|
||||
"Delete": "",
|
||||
"Delete {names}?": "",
|
||||
"Delete {n} items…": "",
|
||||
@@ -407,10 +426,12 @@
|
||||
"Desktop": "",
|
||||
"Desktop Mode": "",
|
||||
"Details": "",
|
||||
"Deutsch": "",
|
||||
"dev build": "",
|
||||
"Dev builds are early, less tested versions and may have bugs. You still get the next release as a normal update (you have {version}).": "",
|
||||
"Developer Mode lets FramePort find and connect to the Frame; the setup turns it on for you. With it on, you can also open Settings → Developer → Pair new host on the Frame and approve FramePort there.": "",
|
||||
"Diagnostic for Vulkan games that fail on their first frames with missing Quest foveation images.": "",
|
||||
"Diagnostic only; do not use for benchmarking.": "",
|
||||
"Diagnostics for frame.gl_multiview_fbo: checks for GL errors after every single-view draw of a multiview shader and logs counters every 5 seconds (tag GLMV in launch.log). 2 (frameport settings) also checks every draw and logs the draw state, failed texture uploads and shader compiles and, for idTech 4 engines, the game clock.": "",
|
||||
"Diagnostics: removes layer color fades. Some fades no longer show.": "",
|
||||
"Diagnostics: the Vulkan shim (frame.vk_sanitize) adds Khronos' validation layer to the game's instance; its findings go to launch.log. The layer library (libVkLayer_khronos_validation.so) must be in the APK.": "",
|
||||
@@ -460,6 +481,7 @@
|
||||
"Draw fades over the picture": "",
|
||||
"drawing frames": "",
|
||||
"Draws each eye separately. Try it when one eye shows a gray or broken picture.": "",
|
||||
"Drives": "",
|
||||
"Drop broken layers": "",
|
||||
"Drop layer color fades": "",
|
||||
"Drop layers whose swapchain failed or whose extension isn't enabled.": "",
|
||||
@@ -477,11 +499,15 @@
|
||||
"End {title}?": "",
|
||||
"Ending this stops Steam, SteamVR or the desktop.": "",
|
||||
"End…": "",
|
||||
"English (UK)": "",
|
||||
"English (US)": "",
|
||||
"Enter the Frame's address (for example steamos@frame.local or its IP)": "",
|
||||
"Environment variables exported by the launcher (for example VK_INSTANCE_LAYERS=\"\" to test without Valve's layers).": "",
|
||||
"Environment variables for the Proton launcher on the Frame (for example DXVK_HUD=fps), one KEY=value per line.": "",
|
||||
"Epic Online Services (EOS) keeps its login keychain in the app's settings storage. When it can't write there, it resets the keychain and tries again without end (for example Beyond Sandbox: a start that never finishes, 100 % CPU, a log of gigabytes). The reset method returns at once instead (an in-place edit of classes.dex), so EOS reports the failure to the game once.": "",
|
||||
"Epic Online Services: stop the keychain retry loop": "",
|
||||
"Español": "",
|
||||
"Español (Latinoamérica)": "",
|
||||
"Every 'local' play space the game creates lines up with the one at the start. For games whose menus or screens jump to where you look on the Frame.": "",
|
||||
"Every 5 s, logs per located space how far the requested times are from the frame's predicted display time, and the runtime clock's offset from the monotonic clock. No effect on the game.": "",
|
||||
"Every key goes through": "",
|
||||
@@ -553,6 +579,7 @@
|
||||
"For games that pause or recenter when the headset briefly loses focus.": "",
|
||||
"For games that show the headset's own controllers. Takes effect after a reinstall.": "",
|
||||
"For games that show your room on their objects and darkness where your room should be.": "",
|
||||
"For games that stop at start because the headset reports no colour space (for example OutRun Quest VR).": "",
|
||||
"For games where one eye flickers or jitters. Costs some speed.": "",
|
||||
"For games whose fades to black don't show, so you see the level loading (for example Batman: Arkham Shadow).": "",
|
||||
"For games whose hands trail the controllers when you move them (for example BattleSisters).": "",
|
||||
@@ -636,6 +663,7 @@
|
||||
"FramePort's tools, library, artwork and your games' signing keys. Back it up: an update needs the same key, or the game must be reinstalled and loses its saves.": "",
|
||||
"frames per second": "",
|
||||
"Frames that are set up show up here by themselves.": "",
|
||||
"Français": "",
|
||||
"Free up space": "",
|
||||
"Free up space on the Frame": "",
|
||||
"Free up space?": "",
|
||||
@@ -645,12 +673,13 @@
|
||||
"From the {src}": "",
|
||||
"Full (headset view size)": "",
|
||||
"Full launch log": "",
|
||||
"Full-resolution area size": "",
|
||||
"Full-resolution area": "",
|
||||
"Game": "",
|
||||
"Game data (.obb) is missing, so these games hang at start. Put the .obb files in a folder named after the game next to the APK, then add it again.": "",
|
||||
"Game data (.obb) is missing. Put it in a folder named {package} next to the APK and add the folder again.": "",
|
||||
"Game data (.obb) not found": "",
|
||||
"game data (OBB)": "",
|
||||
"Game language": "",
|
||||
"Game launch capacity": "",
|
||||
"Game not found": "",
|
||||
"Game page": "",
|
||||
@@ -715,6 +744,7 @@
|
||||
"Heads-up: that APK is an older version than the one before.": "",
|
||||
"Heads-up: {name} looks like a different app than {title}.": "",
|
||||
"headset found": "",
|
||||
"Headset model": "",
|
||||
"Hi there!": "",
|
||||
"Hide Android's navigation bar": "",
|
||||
"Hide details": "",
|
||||
@@ -733,7 +763,6 @@
|
||||
"High": "",
|
||||
"Higher is sharper but needs more power.": "",
|
||||
"Higher keeps more detail but uses more memory.": "",
|
||||
"Higher looks sharper but needs more power; lower runs smoother. 1.0× is the game's own.": "",
|
||||
"Home folder": "",
|
||||
"hottest: {part}": "",
|
||||
"How a PC VR game starts: Proton runs it on the Frame; Revive lets Oculus games run on SteamVR.": "",
|
||||
@@ -820,6 +849,7 @@
|
||||
"It's doing its best": "",
|
||||
"it's here now": "",
|
||||
"It's known not to run on the Frame.": "",
|
||||
"Italiano": "",
|
||||
"its drive": "",
|
||||
"Its own Revive · runs the game directly": "",
|
||||
"Java runtime": "",
|
||||
@@ -838,7 +868,7 @@
|
||||
"Keeps one label so the title is stable.": "",
|
||||
"Keeps one name for the game, so it shows the same everywhere.": "",
|
||||
"Keeps the hand and brush visible over Vermillion's painting canvas (the canvas is drawn in the scene instead of as a layer in front).": "",
|
||||
"Keeps the resolution the game saves in step with the headset's picture size (else the picture is shifted, with black borders, after a sharpness change).": "",
|
||||
"Keeps the resolution the game saves in step with the headset's picture size (else the picture is shifted, with black borders, after a render resolution change).": "",
|
||||
"Keeps your progress in games that file their saves under your Meta user.": "",
|
||||
"Keyboard connected to {label}.": "",
|
||||
"Keyboard disconnected: {error}": "",
|
||||
@@ -847,12 +877,14 @@
|
||||
"Language": "",
|
||||
"Language packs from the game's files": "",
|
||||
"Large files upload together using OpenSSH when available. Default: 3. Lower this on a busy network.": "",
|
||||
"Larger areas retain more detail but save less GPU work.": "",
|
||||
"Last build": "",
|
||||
"Last launch test's log": "",
|
||||
"Last launch test: passed": "",
|
||||
"Last launch test: {get} · got as far as: {value}": "",
|
||||
"Last session · ended {ago}": "",
|
||||
"late": "",
|
||||
"Later — next Steam start": "",
|
||||
"Launch log": "",
|
||||
"Launch log · {title}": "",
|
||||
"Launch options (SteamVR or OpenXR)": "",
|
||||
@@ -908,6 +940,7 @@
|
||||
"Link": "",
|
||||
"Linux": "",
|
||||
"Linux app": "",
|
||||
"Linux apps": "",
|
||||
"Linux build": "",
|
||||
"Linux builds should be made for arm64; others run slower.": "",
|
||||
"Linux VR app · OpenXR": "",
|
||||
@@ -967,6 +1000,7 @@
|
||||
"Meta's OVRPlugin reports a thumb (or index finger) resting near the controller through XR_FB_touch_controller_proximity, which the Frame's runtime lacks: \"near\" stays false and games that animate the hands from it show a thumb that never moves (for example Vader Immortal; found by Klownicle, GitHub #49). Binds the game's proximity actions to the capacitive touch inputs instead: 1 = thumb (thumbstick, face buttons or thumb rest touched), 2 = also the index finger (trigger touched). Only when the runtime lacks the extension.": "",
|
||||
"MiB": "",
|
||||
"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.": "",
|
||||
"Mirror": "",
|
||||
"Mirrored": "",
|
||||
"Mixed reality": "",
|
||||
"Mixed-reality games that build their level from your room get a simple room with walls.": "",
|
||||
@@ -987,6 +1021,7 @@
|
||||
"Name": "",
|
||||
"Name and artwork": "",
|
||||
"Native video surfaces (Batman renderer)": "",
|
||||
"Nederlands": "",
|
||||
"Needed by another patch": "",
|
||||
"Needs the Meta Horizon app for its license check, so it crashes on the Frame. Play it on this PC.": "",
|
||||
"Needs Windows (or WSL).": "",
|
||||
@@ -1036,6 +1071,7 @@
|
||||
"None": "",
|
||||
"None found": "",
|
||||
"Normal": "",
|
||||
"Norsk": "",
|
||||
"Not connected: {error}": "",
|
||||
"Not enough space on the Frame": "",
|
||||
"Not enough space on the Frame ({detail}). Uninstall games you don't play or free up space on the Steam Frame page.": "",
|
||||
@@ -1061,6 +1097,9 @@
|
||||
"Nothing to patch: it runs unchanged.": "",
|
||||
"Nothing to see here.": "",
|
||||
"nothing yet": "",
|
||||
"Now (default)": "",
|
||||
"Now waits until Steam can restart safely. Later saves shortcuts for the next Steam start. Off uses FramePort Play without Steam per-game integration; existing shortcuts stay.": "",
|
||||
"Now waits while a game, Desktop Mode, or local FramePort is open. Later lets you keep installing without restarting Steam. Off uses FramePort Play without Steam per-game integration.": "",
|
||||
"Now {scale:.0%}.": "",
|
||||
"Now {scale:.0%}. Changes apply the next time FramePort starts.": "",
|
||||
"Now {scale:.0%}; {new:.0%} after restarting FramePort.": "",
|
||||
@@ -1072,8 +1111,10 @@
|
||||
"Oculus OS telemetry class stubs": "",
|
||||
"Oculus XR Plugin games render both eyes in one pass (multiview) on Android. If one eye shows only effects or gray (for example I Am Cat), rendering each eye in its own pass can fix it, at some GPU cost. Rewrites OculusSettings.GetStereoRenderingMode and its inlined read in OculusLoader.Initialize -> MultiPass in libil2cpp.so (found with Cpp2IL). Try it when one eye is wrong.": "",
|
||||
"Off": "",
|
||||
"Off — launch from FramePort": "",
|
||||
"Off: these links open whatever app is set up for them.": "",
|
||||
"off: {names}": "",
|
||||
"Offers Meta's colour-space extension when the Frame's runtime lacks it: the game is told the headset uses Rec.709 and may choose any colour space (no conversion is done). For games that quit before their VR session with \"Rec.709 color space unavailable\" (for example OutRun Quest VR, GitHub #336). Only reaches games that use their own OpenXR loader; OVRPort's dispatcher can't forward the new functions.": "",
|
||||
"Offline": "",
|
||||
"OK": "",
|
||||
"Older way to turn upside-down panels; prefer Fix upside-down menus.": "",
|
||||
@@ -1202,7 +1243,10 @@
|
||||
"Pointer start": "",
|
||||
"Pointer turn": "",
|
||||
"Points Oculus games at Revive on the Frame.": "",
|
||||
"Polski": "",
|
||||
"Portal on true black, for OLED screens": "",
|
||||
"Português (Brasil)": "",
|
||||
"Português (Portugal)": "",
|
||||
"Pose timing diagnostics (log)": "",
|
||||
"Poses a game asks for at a CLOCK_MONOTONIC time, or more than 0.5 s before the frame's display time, are located at the same moment in the runtime's time (or now). Meta's OVRPlugin passes the monotonic clock on as the OpenXR time, which on the Frame runs seconds behind the runtime's clock, so its \"now\" poses (Unity's physics step, controller poses) came from the past: hands trailing the controllers (for example BattleSisters, Sniper Elite VR).": "",
|
||||
"Power": "",
|
||||
@@ -1232,9 +1276,15 @@
|
||||
"Puts settings files next to the game on the Frame, for example to turn off an unsupported effect.": "",
|
||||
"Quality": "",
|
||||
"Quest": "",
|
||||
"Quest 2": "",
|
||||
"Quest 3 (default)": "",
|
||||
"Quest 3S": "",
|
||||
"Quest game": "",
|
||||
"Quest games": "",
|
||||
"Quest games (Lepton)": "",
|
||||
"Quest games bind Touch controllers. SteamVR normally repeats Touch Y/B on three top buttons. Mirror gives the bottom and inner buttons X/A, the top and outer Y/B. Quest pair uses two diagonal buttons per hand. Requires the Frame runtime's controller extension; SteamVR custom bindings can override the choice. Changes apply at the next launch.": "",
|
||||
"Quest pair": "",
|
||||
"Quest Pro": "",
|
||||
"Quest ✓": "",
|
||||
"Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR games can run on the Frame.": "",
|
||||
"Quest ✗": "",
|
||||
@@ -1246,6 +1296,7 @@
|
||||
"Raises the minimum Android version the game declares, so the new parts load.": "",
|
||||
"Re-create play space": "",
|
||||
"Re-creates the game's play space after the first frames. For testing.": "",
|
||||
"Read-only": "",
|
||||
"Reading the file list from the Frame…": "",
|
||||
"Reading the install link": "",
|
||||
"Reading the new APK": "",
|
||||
@@ -1265,7 +1316,8 @@
|
||||
"Reconnect": "",
|
||||
"Recreate spaces after the first frames (diagnostic).": "",
|
||||
"Reduce motion": "",
|
||||
"Reduces shading away from the full-resolution area, following each eye's gaze. Default area size keeps the runtime's current profile unchanged. Custom radius is a fraction of the shorter eye-image dimension; the transition ends at twice that radius. Larger areas retain more detail but save less GPU work. Fixed stops the area following your eyes. The optional test darkens actual coarse-shaded fragments; rebuild/reinstall to toggle it. Tested on RE4, Deadpool and Asgard's Wrath 2. The Climb 2 gets a version-guarded private bridge at 1.0 scale; Batman gets its hash-verified allocation fix (unsupported OVRPlugin versions are refused). Other games may need additional fixes; turn this off if artifacts appear.": "",
|
||||
"Reduces shading away from the full-resolution area, following each eye's gaze. Default area size keeps the runtime's current profile unchanged. Custom radius is a fraction of the shorter eye-image dimension; the transition ends at twice that radius. Larger areas retain more detail but save less GPU work. Fixed stops the area following your eyes. The optional test darkens actual coarse-shaded fragments. Runtime choices are in Game settings. Tested on RE4, Deadpool and Asgard's Wrath 2. The Climb 2 gets a version-guarded private bridge at 1.0 scale; Batman gets its hash-verified allocation fix (unsupported OVRPlugin versions are refused). Other games may need additional fixes; turn this off if artifacts appear.": "",
|
||||
"Reduces shading away from the full-resolution area. Changes apply at the next game start.": "",
|
||||
"Refresh": "",
|
||||
"Refresh rate": "",
|
||||
"Refresh store details": "",
|
||||
@@ -1319,6 +1371,8 @@
|
||||
"Renamed to {title}. Steam restarts once to show it.": "",
|
||||
"Rename…": "",
|
||||
"Render less detail outside your gaze; adjust the full-resolution area or darken coarse regions to test it.": "",
|
||||
"Render resolution (supersampling)": "",
|
||||
"Render resolution (supersampling), foveated rendering, refresh rate, controllers, menus and more for this game. Changes take effect the next time it starts.": "",
|
||||
"Repairs OpenGL ES shaders that hang the graphics chip, from the game's recipe.": "",
|
||||
"Repairs shaders that hang the graphics chip at certain effects, from the game's recipe (for example Vader Immortal's lightspeed jump).": "",
|
||||
"Repeat xrLocateViews queries for the same display time get the first, fully tracked answer again. Games that ask several times per frame got slightly different poses and rendered parts of the frame with different heads: judder (for example I Am Cat; proposed by Klownicle, GitHub #8).": "",
|
||||
@@ -1345,10 +1399,13 @@
|
||||
"Reset {title}'s recipe?": "",
|
||||
"Restart": "",
|
||||
"Restart now": "",
|
||||
"Restart Steam now": "",
|
||||
"Restart Steam now?": "",
|
||||
"Restart the Frame": "",
|
||||
"Restart the Frame?": "",
|
||||
"Restart the Frame…": "",
|
||||
"Restarting": "",
|
||||
"Restarting Steam ends the running game. Continue?": "",
|
||||
"Restore": "",
|
||||
"Restore saves · {title}": "",
|
||||
"Restore saves: {title}": "",
|
||||
@@ -1435,8 +1492,6 @@
|
||||
"Shader-fix Vulkan layer (frame.zink_shader_fix): writes every distinct SPIR-V module the GL driver creates to Android/data/<package>/files/fp_spirv/<size>_<sha256>.spv, to capture a shader for a new zink_shader_fix. No effect on the game.": "",
|
||||
"Share working recipe · {title}": "",
|
||||
"Share working recipe…": "",
|
||||
"Sharpness": "",
|
||||
"Sharpness, refresh rate, controllers, menus and more for this game. Changes take effect the next time it starts.": "",
|
||||
"Show 360° backgrounds and videos": "",
|
||||
"Show advanced settings": "",
|
||||
"Show all patches ({len} don't apply to this game)": "",
|
||||
@@ -1540,14 +1595,18 @@
|
||||
"Steam doesn't sync Quest games' saves. This copies each installed Quest game's saves to this PC (caches and big downloaded content stay out). Restore one from the game's menu → Restore saves….": "",
|
||||
"Steam for Windows not found": "",
|
||||
"Steam Frame": "",
|
||||
"Steam Frame controllers: each button shows the Quest Touch button it sends. A dash means it sends nothing.": "",
|
||||
"Steam Frame patches": "",
|
||||
"Steam Frame: installed on the Frame, with an entry in its Steam library. This PC: a Steam shortcut that starts the game through Revive, for a headset connected to this PC.": "",
|
||||
"Steam game": "",
|
||||
"Steam is restarting. FramePort on the Frame will close.": "",
|
||||
"Steam library": "",
|
||||
"Steam on the Frame downloads Proton (about 1 GiB). Steam restarts once, which closes a running game.": "",
|
||||
"Steam on the Frame isn't responding. Restart the Frame and try again.": "",
|
||||
"SteamOS · Developer Mode": "",
|
||||
"steamos@frame.local or an IP address": "",
|
||||
"SteamVR": "",
|
||||
"SteamVR default": "",
|
||||
"SteamVR isn't running on the Frame: put the headset on, then take the screenshot.": "",
|
||||
"SteamVR overlay": "",
|
||||
"SteamVR overlay app (Frame)": "",
|
||||
@@ -1590,6 +1649,8 @@
|
||||
"Suggested after a launch test": "",
|
||||
"Suggested by FramePort from the game's engine": "",
|
||||
"Suggested for this game": "",
|
||||
"Suomi": "",
|
||||
"Svenska": "",
|
||||
"Swap eyes": "",
|
||||
"Swap left/right views (diagnostic).": "",
|
||||
"swap {n}": "",
|
||||
@@ -1660,6 +1721,7 @@
|
||||
"The headset's view is open in your browser. Closed the tab? Open it again.": "",
|
||||
"the installed game (APK and its data)": "",
|
||||
"The issue's text is also on your clipboard. If the form's fields are empty, open a plain issue instead: it comes filled in.": "",
|
||||
"The language Android reports to the game. Lepton's Android is set to English (US), and games that follow the system language (most Unity and Unreal games) start in English. A language the game doesn't have still shows English. Applies with Update on Frame (the APK isn't changed).": "",
|
||||
"The launch settings changed since the install — update the Steam shortcut.": "",
|
||||
"The live view couldn't play {first}, so it opened {where}.": "",
|
||||
"The live view is streaming.": "",
|
||||
@@ -1689,9 +1751,10 @@
|
||||
"They run on the Frame too.": "",
|
||||
"They're deleted on the Frame for good. Steam's own list may show them until Steam restarts.": "",
|
||||
"They're part of SteamOS. Ending them may restart Steam and stop the running game.": "",
|
||||
"This applies pending library changes and closes Desktop Mode and FramePort on the Frame.": "",
|
||||
"This deletes the {what} on the Frame. It can't be undone.": "",
|
||||
"This folder contains an installer without a launchable Linux program. FramePort runs programs, not installers. Use the project's own install instructions.": "",
|
||||
"This folder is empty. Upload files with the buttons above.": "",
|
||||
"This folder is empty.": "",
|
||||
"This Frame isn't set up for FramePort yet. Go to Steam Frame → Start setup.": "",
|
||||
"This Frame isn't set up for FramePort yet. Run the setup once.": "",
|
||||
"This Frame's SSH key changed since FramePort last connected (SteamOS was reset, or another device now has its address). If it's your Frame, choose Trust the new key on the Steam Frame page.": "",
|
||||
@@ -1747,6 +1810,7 @@
|
||||
"Type on Frame": "",
|
||||
"Type to search games and tags": "",
|
||||
"Typed it, except characters the Frame's US keyboard layout doesn't have: {chars}": "",
|
||||
"Türkçe": "",
|
||||
"ufw is active. Allow the setup port with: sudo ufw allow {port}/tcp (remove it afterwards with: sudo ufw delete allow {port}/tcp)": "",
|
||||
"unclear": "",
|
||||
"Undo last restore ({when})": "",
|
||||
@@ -1852,6 +1916,7 @@
|
||||
"Use controllers": "",
|
||||
"Use FramePort": "",
|
||||
"Use FramePort for these links": "",
|
||||
"Use global default ({mode})": "",
|
||||
"Use the new recipe": "",
|
||||
"Use the repack's launcher (bundled Revive)": "",
|
||||
"Use the setup command": "",
|
||||
@@ -1859,6 +1924,7 @@
|
||||
"Use this PC's keyboard on the Frame.": "",
|
||||
"Use this program": "",
|
||||
"Use your own artwork…": "",
|
||||
"Used when installing or updating games that follow the global default. Each game's Game settings can override it. Changes take effect at the next launch. Games installed before this feature need Update on Frame once for the mode to work.": "",
|
||||
"Uses its own Revive — experimental on the Frame": "",
|
||||
"Uses OVRPort, Revive (LibreVR), Valve's Lepton and Proton. Not affiliated with Valve or Meta.": "",
|
||||
"Uses the store title/icon (OVRPort image service) for the app label.": "",
|
||||
@@ -2042,6 +2108,10 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game needs Update on Frame first.": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game now uses the other version found there": [
|
||||
"",
|
||||
""
|
||||
@@ -2143,6 +2213,9 @@
|
||||
"{value} failed ({returncode})": "",
|
||||
"{value} installed, but didn't start": "",
|
||||
"{value} took too long — update by hand: {join}": "",
|
||||
"Čeština": "",
|
||||
"Русский": "",
|
||||
"Українська": "",
|
||||
"• On the Frame (if selected below): {n} game ({size}), its Steam entry and FramePort's files": [
|
||||
"",
|
||||
""
|
||||
@@ -2151,5 +2224,9 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"… {node} more (use the filter to find them)": ""
|
||||
"… {node} more (use the filter to find them)": "",
|
||||
"中文(简体)": "",
|
||||
"中文(繁體)": "",
|
||||
"日本語": "",
|
||||
"한국어": ""
|
||||
}
|
||||
@@ -14,9 +14,12 @@ OVRPLUGIN = "libOVRPlugin.so"
|
||||
|
||||
|
||||
def settings_text(recipe_patches: dict) -> bytes:
|
||||
from ..settings import adapter_settings
|
||||
from ...core.controllers import effective_adapter_settings
|
||||
|
||||
return "".join(f"{k}={v}\n" for k, v in adapter_settings(recipe_patches).items()).encode()
|
||||
settings = effective_adapter_settings(recipe_patches)
|
||||
if settings.get("headset_model") == 2:
|
||||
settings.pop("headset_model") # native default 0 preserves the old name; keep Quest 2 settings byte-stable
|
||||
return "".join(f"{k}={v}\n" for k, v in settings.items()).encode()
|
||||
|
||||
|
||||
class FrameBridgeAdapter(Patch):
|
||||
@@ -32,6 +35,7 @@ class FrameBridgeAdapter(Patch):
|
||||
"Its settings are under Game settings."
|
||||
)
|
||||
category = "frame"
|
||||
revision = 2 # dormant foveation diagnostic support; runtime choice never changes APK inputs
|
||||
order = 10
|
||||
default_on = True
|
||||
|
||||
@@ -43,6 +47,9 @@ class FrameBridgeAdapter(Patch):
|
||||
if not ws.has(ws.lib(GENERIC)):
|
||||
raise RuntimeError("OVRPort output has no libopenxr_loader_generic.so (not an OVRPort build?)")
|
||||
adapter = artifact(ws.abi, GENERIC)
|
||||
if ws.abi == "arm64-v8a":
|
||||
from .foveation import LAYER
|
||||
adapter = elf.add_needed(adapter, LAYER)
|
||||
settings = settings_text(ctx.recipe_patches)
|
||||
changed = False
|
||||
if not ws.has(ws.lib(ORIGINAL)):
|
||||
@@ -67,11 +74,19 @@ class FrameBridgeAdapter(Patch):
|
||||
ws.add.pop(target, None)
|
||||
ws.replace.pop(target, None)
|
||||
changed = True
|
||||
if ws.abi == "arm64-v8a":
|
||||
from .foveation import diagnostic_support
|
||||
changed |= diagnostic_support(ws)
|
||||
return changed
|
||||
|
||||
def validate(self, ctx: ApkContext):
|
||||
ws = ctx.ws
|
||||
ok = ws.has(ws.lib(ORIGINAL)) and ws.has(ws.lib(SETTINGS)) and ws.read_lib(GENERIC) == artifact(ws.abi, GENERIC)
|
||||
ok = ws.has(ws.lib(ORIGINAL)) and ws.has(ws.lib(SETTINGS))
|
||||
expected = artifact(ws.abi, GENERIC)
|
||||
if ws.abi == "arm64-v8a":
|
||||
from .foveation import LAYER
|
||||
expected = elf.add_needed(expected, LAYER)
|
||||
ok = ok and ws.read_lib(GENERIC) == expected
|
||||
return [("FrameBridge adapter present", ok, "adapter, original loader and settings library")]
|
||||
|
||||
|
||||
|
||||
@@ -58,10 +58,34 @@ def engine_lib(ws):
|
||||
return None
|
||||
|
||||
|
||||
def apply(ctx):
|
||||
def apk_options(ctx):
|
||||
"""Personal settings never select APK edits. Prepare only verified compatibility builds automatically."""
|
||||
from ..settings import foveation_options
|
||||
|
||||
mode, _radius, test = foveation_options(ctx.params)
|
||||
requested = foveation_options(ctx.params)
|
||||
if ctx.params.get("support") or requested[0] in ("", "off"):
|
||||
ws = ctx.ws
|
||||
if ctx.analysis.package == "com.camouflaj.manta":
|
||||
if not ws.has(ws.lib("libOVRPlugin.so")):
|
||||
return "", 0, False
|
||||
try:
|
||||
ordinary_storage(ws.read_lib("libOVRPlugin.so"))
|
||||
except ValueError:
|
||||
ctx.notes.append("unknown Batman OVRPlugin: automatic foveation allocation fix skipped")
|
||||
return "", 0, False
|
||||
elif ctx.analysis.package == "com.crytek.climb2":
|
||||
if (not ws.has(ws.lib("libCryRenderVulkan.so")) or
|
||||
hashlib.sha256(ws.read_lib("libCryRenderVulkan.so")).hexdigest() != CLIMB_RENDERER):
|
||||
ctx.notes.append("unknown Climb renderer: automatic foveation bridge skipped")
|
||||
return "", 0, False
|
||||
else:
|
||||
return "", 0, False
|
||||
return "tracked", 0, False
|
||||
return requested
|
||||
|
||||
|
||||
def apply(ctx):
|
||||
mode, _radius, test = apk_options(ctx)
|
||||
if mode not in ("tracked", "fixed"):
|
||||
return False
|
||||
ws = ctx.ws
|
||||
@@ -103,9 +127,7 @@ def apply(ctx):
|
||||
|
||||
|
||||
def validate(ctx):
|
||||
from ..settings import foveation_options
|
||||
|
||||
mode, _radius, test = foveation_options(ctx.params)
|
||||
mode, _radius, test = apk_options(ctx)
|
||||
checks = []
|
||||
if mode in ("tracked", "fixed") and ctx.analysis.package == "com.camouflaj.manta":
|
||||
data = ctx.ws.read_lib("libOVRPlugin.so")
|
||||
@@ -115,3 +137,23 @@ def validate(ctx):
|
||||
checks.append(("Foveation darkening layer loaded", bool(lib) and ctx.ws.has(ctx.ws.lib(LAYER))
|
||||
and LAYER in elf.needed(ctx.ws.read_lib(lib)), lib or "no renderer"))
|
||||
return checks
|
||||
|
||||
|
||||
def diagnostic_support(ws):
|
||||
"""The diagnostic constructor is dormant without FRAMEPORT_FOVEATION_TEST=1."""
|
||||
lib = engine_lib(ws) or "libopenxr_loader_generic.so"
|
||||
data = ws.read_lib(lib)
|
||||
changed = False
|
||||
if LAYER not in elf.needed(data):
|
||||
target = ws.lib(lib)
|
||||
updated = elf.add_needed(data, LAYER)
|
||||
if target in ws.add:
|
||||
ws.add[target] = updated
|
||||
else:
|
||||
ws.put(target, updated)
|
||||
changed = True
|
||||
layer = artifact(ws.abi, LAYER)
|
||||
if not ws.has(ws.lib(LAYER)) or ws.read_lib(LAYER) != layer:
|
||||
ws.put(ws.lib(LAYER), layer)
|
||||
changed = True
|
||||
return changed
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Per-game OpenXR headset identity (GitHub #373)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import Param, Patch, Suggestion, register
|
||||
|
||||
# Quest 2 deliberately leaves both layers untouched: the compatibility escape hatch.
|
||||
MODELS = {"": (3, "Quest 3"), "quest3s": (4, "Quest 3S"),
|
||||
"questpro": (5, "Quest Pro"), "quest2": (2, "Quest 2")}
|
||||
|
||||
|
||||
# Raw XrUuidEXT bytes, in comparison order in OVRPlugin 0x292f80 (not GUID field-endian order).
|
||||
UUIDS = {"quest2": bytes.fromhex("3064f23915ed4722a6595ea1ecd899c2"),
|
||||
"questpro": bytes.fromhex("b58443c9df8640cd89182a278e18ab71"),
|
||||
"": bytes.fromhex("082746baa0714cf78f87bade5cce43ce"),
|
||||
"quest3s": bytes.fromhex("9510492b0d424872bcf4fb1a8def6f0e")}
|
||||
# "Meta Quest Pro" plus NUL exceeds the original 14-byte slot. Its UUID selects Pro; use a short label.
|
||||
SYSTEM_NAMES = {"": b"Meta Quest 3", "quest3s": b"Meta Quest 3S",
|
||||
"questpro": b"Quest Pro", "quest2": b"Oculus Quest2"}
|
||||
# Verified OVRPort 3.4.3-aa54c3f arm64 dispatcher: file offsets (VAs differ).
|
||||
# Guard both data and the instructions that load those exact answers, before changing either.
|
||||
NAME_OFFSET, UUID_OFFSET = 0x6034, 0x21338
|
||||
GUARDS = {0x18620: bytes.fromhex("61ffffd021d00091807200910220805203208052c401805262210094"),
|
||||
0x186e4: bytes.fromhex("1f2003d584620810"),
|
||||
0x18778: bytes.fromhex("8000c03de006803de2ffff17")}
|
||||
|
||||
|
||||
def patch_dispatcher(data: bytes, key: str) -> bytes:
|
||||
"""Same-size data-only edit; unknown dispatchers fail atomically. Quest 2 is an exact no-op."""
|
||||
if key not in SYSTEM_NAMES:
|
||||
raise ValueError(f"Unknown headset model: {key!r}")
|
||||
if key == "quest2":
|
||||
return data
|
||||
expected = {NAME_OFFSET: b"Oculus Quest2\0", UUID_OFFSET: UUIDS["quest2"], **GUARDS}
|
||||
if not all(data[off:off + len(value)] == value for off, value in expected.items()):
|
||||
raise ValueError("Unsupported OVRPort headset identity dispatcher: expected 3.4.3-aa54c3f arm64 "
|
||||
"systemName/UUID and load instructions; select Quest 2 to preserve its identity.")
|
||||
out = bytearray(data)
|
||||
out[NAME_OFFSET:NAME_OFFSET + 14] = SYSTEM_NAMES[key].ljust(14, b"\0")
|
||||
out[UUID_OFFSET:UUID_OFFSET + 16] = UUIDS[key]
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def model(params):
|
||||
key = (params or {}).get("model", "")
|
||||
if key not in MODELS:
|
||||
raise ValueError(f"Unknown headset model: {key!r}")
|
||||
return MODELS[key]
|
||||
|
||||
|
||||
def adapter_settings(recipe_patches, *, defaults=None):
|
||||
"""Share the device choice with embedded settings and configuration-only updates."""
|
||||
from ..settings import adapter_settings as other_settings
|
||||
|
||||
out = other_settings(recipe_patches, defaults=defaults)
|
||||
if "device.headset_model" in recipe_patches:
|
||||
out["headset_model"] = model(recipe_patches["device.headset_model"])[0]
|
||||
return out
|
||||
|
||||
|
||||
class HeadsetModel(Patch):
|
||||
id = "device.headset_model"
|
||||
title = "Headset model"
|
||||
description = ("Choose the OpenXR headset name and headset UUID reported to Quest games. "
|
||||
"Quest 3 is the default; Quest 2 preserves OVRPort's original answers. "
|
||||
"Requires a rebuild. Android Build.MODEL remains Lepton.")
|
||||
category = "device"
|
||||
stage = "apk"
|
||||
order = 12
|
||||
default_on = True
|
||||
summary = "Choose the headset identity the game sees; Quest 3 is the default."
|
||||
CHOICES = (("", "Quest 3 (default)"), ("quest3s", "Quest 3S"), ("questpro", "Quest Pro"), ("quest2", "Quest 2"))
|
||||
params = [Param("model", "str", "", "Headset model reported to the game.")]
|
||||
|
||||
def applies(self, a):
|
||||
return a.vr_kind == "quest" and (a.extra or {}).get("kind") not in ("rift", "linux")
|
||||
|
||||
def detect(self, a):
|
||||
if self.applies(a):
|
||||
return Suggestion(True, "Quest 3 hardware is closest to Steam Frame: report Quest 3 by default.")
|
||||
return None
|
||||
|
||||
def apply(self, ctx):
|
||||
if not self.applies(ctx.analysis):
|
||||
return False
|
||||
key = (ctx.params or {}).get("model", "")
|
||||
model(ctx.params) # validate before even a missing-library no-op
|
||||
ws = ctx.ws
|
||||
loader = "libopenxr_loader.so"
|
||||
if key == "quest2":
|
||||
return False
|
||||
if not ws.has(ws.lib(loader)):
|
||||
ctx.checks.append((self.id, None, "No OVRPort dispatcher; only FrameBridge's systemName override applies."))
|
||||
return False
|
||||
fixed = patch_dispatcher(ws.read_lib(loader), key)
|
||||
ws.put(ws.lib(loader), fixed)
|
||||
ctx.notes.append(f"{loader}: systemName={SYSTEM_NAMES[key].decode()}, HeadsetId={UUIDS[key].hex()}")
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def migrate(data):
|
||||
"""Once per library, include existing Quest recipes in the new default."""
|
||||
for game in (data.get("games") or {}).values():
|
||||
analysis, recipe = game.get("analysis"), game.get("recipe")
|
||||
if not isinstance(analysis, dict) or not isinstance(recipe, dict):
|
||||
continue
|
||||
extra = analysis.get("extra") or {}
|
||||
if (game.get("kind") in ("rift", "linux") or extra.get("kind") in ("rift", "linux")
|
||||
or extra.get("vr_kind", "quest") != "quest"):
|
||||
continue
|
||||
recipe.setdefault("patches", {}).setdefault(HeadsetModel.id, {})
|
||||
recipe.setdefault("reasons", {}).setdefault(HeadsetModel.id, "Report Quest 3 by default.")
|
||||
|
||||
|
||||
register(HeadsetModel)
|
||||
@@ -7,6 +7,7 @@ The GL shim reads framebridge.conf too (gl_hide_multiview), as does the multivie
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
|
||||
from .base import InstallContext, Param, Patch, Suggestion, register
|
||||
|
||||
@@ -50,6 +51,11 @@ SETTINGS = [
|
||||
("controller_fix", "int", 1, "Report Touch controllers",
|
||||
"Reports Frame controllers as Oculus Touch and hides synthetic hand tracking. Set 0 for games that require hand "
|
||||
"tracking (for example Silhouette)."),
|
||||
("quest_buttons", "int", 0, "Controller buttons",
|
||||
"Choose how the Frame's face buttons send Quest Touch inputs: 0 = SteamVR default, 1 = Mirror, "
|
||||
"2 = Quest pair. Used at the next launch; SteamVR custom bindings can override it. Requires the runtime's "
|
||||
"XR_VALVE_frame_controller_interaction extension. Mapping: "
|
||||
"https://github.com/spoopyghosty0/frameport/blob/main/docs/CONTROLLERS.md"),
|
||||
("swapchain_fix", "int", 1, "Swapchain format fallback",
|
||||
"Retry rejected GLES formats/MSAA with sRGB, samples=1."),
|
||||
("cube_standin", "int", 1, "Stand-in cube swapchains",
|
||||
@@ -189,6 +195,11 @@ SETTINGS = [
|
||||
"on some Frames; SteamVR then rejects every frame (xrEndFrame -25, XR_ERROR_SWAPCHAIN_RECT_INVALID) and the game "
|
||||
"stops drawing (for example PowerWash Simulator stuck at \"Waiting\"). An empty rect (0x0, e.g. BattleTalent's "
|
||||
"first frames, GitHub #293) is rejected the same way and becomes the whole swapchain image."),
|
||||
("fb_color_space", "int", 0, "Colour space extension (XR_FB_color_space)",
|
||||
"Offers Meta's colour-space extension when the Frame's runtime lacks it: the game is told the headset uses "
|
||||
"Rec.709 and may choose any colour space (no conversion is done). For games that quit before their VR "
|
||||
"session with \"Rec.709 color space unavailable\" (for example OutRun Quest VR, GitHub #336). Only reaches "
|
||||
"games that use their own OpenXR loader; OVRPort's dispatcher can't forward the new functions."),
|
||||
("action_dedup", "int", 1, "Unique controller action names",
|
||||
"Gives a controller action whose name already exists in its action set a free name (\"turn\" -> \"turn_2\"). "
|
||||
"The Frame's runtime accepts duplicate names but then can't load the game's input manifest and treats the game "
|
||||
@@ -291,9 +302,19 @@ HIDDEN = {"surface_native", "gl_int_attribs"}
|
||||
GROUPS = [("picture", "Picture"), ("controllers", "Controllers"), ("screens", "Menus and screens"),
|
||||
("room", "Mixed reality"), ("video360", "360° pictures and video"), ("troubleshooting", "Troubleshooting")]
|
||||
REFRESH_CHOICES = [(0.0, "Game's choice"), *((float(hz), f"{hz} Hz") for hz in (72, 80, 90, 96, 108, 120, 144))]
|
||||
FOVEATION_UI = {
|
||||
"mode": dict(group="picture", level="common", label="Foveated rendering (Valve)",
|
||||
help="Reduces shading away from the full-resolution area. Changes apply at the next game start."),
|
||||
"radius": dict(group="picture", level="common", label="Full-resolution area",
|
||||
help="Larger areas retain more detail but save less GPU work."),
|
||||
"test": dict(group="picture", level="advanced", label="Darken coarse fragments (test)",
|
||||
help="Diagnostic only; do not use for benchmarking."),
|
||||
}
|
||||
|
||||
UI: dict[str, dict] = {
|
||||
"scale": dict(group="picture", level="common", label="Sharpness",
|
||||
help="Higher looks sharper but needs more power; lower runs smoother. 1.0× is the game's own.",
|
||||
"scale": dict(group="picture", level="common", label="Render resolution (supersampling)",
|
||||
help="Changes the game's render resolution. Higher is sharper but slower; 1.0× is the game's own "
|
||||
"resolution.",
|
||||
control=("slider", 0.5, 2.0, 0.1, "{:.1f}×")),
|
||||
"refresh_rate": dict(group="picture", level="common", label="Refresh rate",
|
||||
help="How often the picture updates. Higher is smoother if the game can keep up.",
|
||||
@@ -301,6 +322,10 @@ UI: dict[str, dict] = {
|
||||
"controller_fix": dict(group="controllers", level="common", label="Use controllers",
|
||||
help="Turn off only for games you play with your hands instead of controllers.",
|
||||
control=("switch",)),
|
||||
"quest_buttons": dict(group="controllers", level="common", label="Controller buttons",
|
||||
help="Choose which Quest Touch buttons the Frame's face buttons send. "
|
||||
"Takes effect at the next launch.",
|
||||
control=("choice", [(0, "SteamVR default"), (1, "Mirror"), (2, "Quest pair")])),
|
||||
"focus_hold_ms": dict(group="screens", level="advanced", label="Ignore focus dips up to", depends="focus_hold",
|
||||
help="Longer for headsets that briefly think they're off your head.",
|
||||
control=("slider", 500.0, 3000.0, 100.0, "{:.0f} ms")),
|
||||
@@ -427,6 +452,9 @@ UI: dict[str, dict] = {
|
||||
help="Logs graphics pipelines the Frame's driver refuses. No effect on the game.", control=("switch",)),
|
||||
"vk_validation": dict(group="troubleshooting", level="advanced", label="Vulkan error checks (log)",
|
||||
help="Logs Vulkan mistakes the game makes. Needs a special test build.", control=("switch",)),
|
||||
"fb_color_space": dict(group="troubleshooting", level="advanced", label="Colour space support",
|
||||
help="For games that stop at start because the headset reports no colour space "
|
||||
"(for example OutRun Quest VR).", control=("switch",)),
|
||||
"rect_clamp": dict(group="troubleshooting", level="advanced", label="Keep pictures in bounds",
|
||||
help="Stops games from hanging when their eye picture is a little too big or empty (for example "
|
||||
"PowerWash Simulator). Leave it on.", control=("switch",)),
|
||||
@@ -589,6 +617,7 @@ class AdapterSetting(Patch):
|
||||
"vk_query_slots": lambda a: a.engine == "Unreal" and ap.is_vulkan(a),
|
||||
"vk_validation": lambda a: a.engine == "Unreal",
|
||||
"vk_pipeline_log": lambda a: a.engine in ("Unreal", "Other") and "arm64-v8a" in a.abis,
|
||||
"fb_color_space": lambda a: a.xr == "OpenXR",
|
||||
# wherever the Vulkan shim can be (vk_sanitize); Unreal's Vulkan builds are rarely detected as such
|
||||
"vk_shader_dump": lambda a: a.engine in ("Unreal", "Other") and "arm64-v8a" in a.abis,
|
||||
"zink_shader_fix": ap.is_gles,
|
||||
@@ -609,10 +638,11 @@ class AdapterSetting(Patch):
|
||||
pass # collected by adapter_settings()
|
||||
|
||||
|
||||
def adapter_settings(recipe_patches: dict) -> dict:
|
||||
def adapter_settings(recipe_patches: dict, *, defaults: dict | None = None) -> dict:
|
||||
"""Ordered settings for libframe_settings.so / settings.conf: base keys first, then enabled overrides in
|
||||
selection order."""
|
||||
selection order. Callers supply preference defaults; this helper never reads the library."""
|
||||
out = dict(BASE_SETTINGS)
|
||||
out.update(defaults or {})
|
||||
for pid, params in recipe_patches.items():
|
||||
if pid.startswith("adapter."):
|
||||
key = pid.split(".", 1)[1]
|
||||
@@ -720,6 +750,43 @@ class HideNavBar(Patch):
|
||||
ctx.env.update(self.ENV)
|
||||
|
||||
|
||||
LANGUAGE_TAG = re.compile(r"^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2}|-[0-9]{3})?$")
|
||||
|
||||
|
||||
class Language(Patch):
|
||||
id = "device.language"
|
||||
title = "Game language"
|
||||
description = ("The language Android reports to the game. Lepton's Android is set to English (US), and games that "
|
||||
"follow the system language (most Unity and Unreal games) start in English. A language the game "
|
||||
"doesn't have still shows English. Applies with Update on Frame (the APK isn't changed).")
|
||||
category = "device"
|
||||
needs_vr = False
|
||||
stage = "install"
|
||||
params = [Param("tag", "str", "", "Android language tag, e.g. de-DE (empty = English, US)")]
|
||||
CHOICES = (("", "English (US)"), ("en-GB", "English (UK)"), ("de-DE", "Deutsch"), ("fr-FR", "Français"),
|
||||
("es-ES", "Español"), ("es-MX", "Español (Latinoamérica)"), ("it-IT", "Italiano"),
|
||||
("pt-BR", "Português (Brasil)"), ("pt-PT", "Português (Portugal)"), ("nl-NL", "Nederlands"),
|
||||
("pl-PL", "Polski"), ("cs-CZ", "Čeština"), ("sv-SE", "Svenska"), ("da-DK", "Dansk"),
|
||||
("nb-NO", "Norsk"), ("fi-FI", "Suomi"), ("tr-TR", "Türkçe"), ("ru-RU", "Русский"),
|
||||
("uk-UA", "Українська"), ("ja-JP", "日本語"), ("ko-KR", "한국어"), ("zh-CN", "中文(简体)"),
|
||||
("zh-TW", "中文(繁體)"))
|
||||
# persist.sys.locale is Android's system language, read when the container boots. Lepton has no setting for it;
|
||||
# like device.hide_navbar it rides as a second line of a LEPTON_GFXRECON_* value (copied into the boot
|
||||
# properties unescaped, see docs/FRAME_RUNTIME.md), in its own variable so both can be on.
|
||||
ENV_KEY = "LEPTON_GFXRECON_FP_LOCALE"
|
||||
|
||||
def applies(self, a):
|
||||
return True
|
||||
|
||||
def install(self, ctx: InstallContext) -> None:
|
||||
tag = str(ctx.params.get("tag") or "").strip()
|
||||
if not tag:
|
||||
return
|
||||
if not LANGUAGE_TAG.match(tag):
|
||||
raise ValueError(f"Not a language tag: {tag!r} (for example de-DE)")
|
||||
ctx.env[self.ENV_KEY] = f"0\npersist.sys.locale={tag}"
|
||||
|
||||
|
||||
class SteamGamepad(Patch):
|
||||
id = "device.steam_gamepad"
|
||||
title = "Controllers as a gamepad (Steam Input)"
|
||||
@@ -770,7 +837,7 @@ class Foveation(Patch):
|
||||
"Default area size keeps the runtime's current profile unchanged. Custom radius is a fraction "
|
||||
"of the shorter eye-image dimension; the transition ends at twice that radius. Larger areas "
|
||||
"retain more detail but save less GPU work. Fixed stops the area following your eyes. "
|
||||
"The optional test darkens actual coarse-shaded fragments; rebuild/reinstall to toggle it. "
|
||||
"The optional test darkens actual coarse-shaded fragments. Runtime choices are in Game settings. "
|
||||
"Tested on RE4, Deadpool and Asgard's Wrath 2. "
|
||||
"The Climb 2 gets a version-guarded private bridge at 1.0 scale; Batman gets its "
|
||||
"hash-verified allocation fix (unsupported OVRPlugin versions are refused). Other games may "
|
||||
@@ -781,7 +848,8 @@ class Foveation(Patch):
|
||||
revision = 3
|
||||
order = 95 # after renderer/bridge replacements
|
||||
experimental = True
|
||||
params = [Param("mode", "str", "", "tracked, fixed or off (empty = Valve's default)"),
|
||||
params = [Param("support", "bool", False, "Internal APK compatibility support"),
|
||||
Param("mode", "str", "", "Catalog default: tracked, fixed or off (empty = Valve's default)"),
|
||||
Param("radius", "float", 0.0, "Full-resolution radius; 0 keeps the current runtime default", 0, 0.45),
|
||||
Param("test", "bool", False, "Darken lower-resolution regions (diagnostic)")]
|
||||
CHOICES = (("", "Default"), ("tracked", "Eye-tracked"),
|
||||
@@ -878,7 +946,29 @@ register(DeviceFiles)
|
||||
register(ConfigSync)
|
||||
register(LeptonEnv)
|
||||
register(HideNavBar)
|
||||
register(Language)
|
||||
register(SteamGamepad)
|
||||
register(TextInputWindow)
|
||||
register(DisplayMode)
|
||||
register(Foveation)
|
||||
|
||||
|
||||
FOVEATION_ENV_KEYS = ("FDM_DEBUG", "ENABLE_VULKAN_FDM_INJECTION_LAYER",
|
||||
"LEPTON_ENV_DISABLE_VULKAN_FDM_INJECTION_LAYER", "VK_INSTANCE_LAYERS", "FOVE_INNER", "FOVE_OUTER",
|
||||
"LEPTON_ENV_FRAMEPORT_FOVEATION_TEST", "LEPTON_ENV_FRAMEPORT_CLIMB_STEREO_OFFSETS",
|
||||
"LEPTON_ENV_FRAMEPORT_VRAPI_FDM_ISOLATE_MONO", "LEPTON_ENV_FRAMEPORT_VRAPI_KEEP_FOCUSED_IMAGE")
|
||||
|
||||
|
||||
def foveation_preference(entry):
|
||||
if "foveation" in entry:
|
||||
return dict(entry["foveation"])
|
||||
params = ((entry.get("recipe") or {}).get("patches") or {}).get("device.foveation") or {}
|
||||
return {k: v for k, v in params.items() if k in ("mode", "radius", "test")}
|
||||
|
||||
|
||||
def foveation_env(package, params, settings=None, baseline=None):
|
||||
"""Complete launcher override; None removes an obsolete export, including on Default."""
|
||||
from .base import InstallContext
|
||||
ctx = InstallContext(package, params, {}, dict(baseline or {}), dict(settings or {}))
|
||||
Foveation().install(ctx)
|
||||
return {key: ctx.env.get(key) for key in FOVEATION_ENV_KEYS}
|
||||
@@ -139,7 +139,9 @@ SUMMARIES = {
|
||||
"device.files": "Puts settings files next to the game on the Frame, for example to turn off an unsupported "
|
||||
"effect.",
|
||||
"device.config_sync": "Keeps the resolution the game saves in step with the headset's picture size (else the "
|
||||
"picture is shifted, with black borders, after a sharpness change).",
|
||||
"picture is shifted, with black borders, after a render resolution change).",
|
||||
"device.language": "Choose the language for games that follow the system language. The game must include it. "
|
||||
"Apply with Update on Frame.",
|
||||
"device.hide_navbar": "Hides Android's back, home and recents buttons, which cover the app's own buttons.",
|
||||
"device.text_input_window": "Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard. VR "
|
||||
"keeps working.",
|
||||
|
||||
+19
-11
@@ -524,7 +524,7 @@ def title_suggestions(entry: dict) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def rename_game(package: str, title: str | None) -> dict:
|
||||
def rename_game(package: str, title: str | None, *, refresh_details: bool = True) -> dict:
|
||||
"""Rename a game in the library. The new title is locked (scans, artwork and store lookups and catalog updates
|
||||
keep it); an empty title or the automatic name unlocks it again. Returns the entry. Installed copies keep their
|
||||
old Steam name until sync_title (or the next install) updates them."""
|
||||
@@ -534,7 +534,9 @@ def rename_game(package: str, title: str | None) -> dict:
|
||||
old: dict = {}
|
||||
|
||||
def change(g: dict) -> None:
|
||||
old.update(title=g.get("title"))
|
||||
from .artwork.details import title_matched
|
||||
|
||||
old.update(title=g.get("title"), title_matched=title_matched(g))
|
||||
auto = automatic_title(g)
|
||||
if not title or title == auto:
|
||||
g["title"] = auto
|
||||
@@ -544,10 +546,19 @@ def rename_game(package: str, title: str | None) -> dict:
|
||||
g.setdefault("auto_title", auto)
|
||||
g["title"] = title
|
||||
g["title_locked"] = True
|
||||
if g["title"] != old["title"] and old["title_matched"]:
|
||||
g.pop("details", None)
|
||||
if g["title"] != old["title"] and any(w != PC_LABEL for w in g.get("installs") or {}):
|
||||
g["steam_name_stale"] = True # the Frame's Steam library shows the old name until sync_title
|
||||
entry = library.update_game(package, change)
|
||||
if entry.get("title") != old.get("title"):
|
||||
if old["title_matched"]:
|
||||
from .artwork.details import clear_screenshots
|
||||
|
||||
clear_screenshots(package)
|
||||
if refresh_details:
|
||||
fetch_details(package)
|
||||
entry = library.game(package)
|
||||
try: # FramePort's own cover/banner (no store art) are coloured by the name: make them again
|
||||
from .artwork.steam import ensure_cover
|
||||
|
||||
@@ -1264,19 +1275,15 @@ def _build_lock(package: str) -> 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}
|
||||
native = builder.native_checksums(native_names)
|
||||
if native is None:
|
||||
native = previous.get("native") or {}
|
||||
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():
|
||||
@@ -1758,10 +1765,11 @@ def apply_suggestions_live(package: str, suggestions: list[str], target: Target
|
||||
recipe = apply_suggestions(package, suggestions)
|
||||
if target is None or not adapter_only(suggestions):
|
||||
return False
|
||||
from .patches.settings import SETTINGS, adapter_settings
|
||||
from .core.controllers import effective_adapter_settings
|
||||
from .patches.settings import SETTINGS
|
||||
|
||||
numeric = {key for key, kind, *_ in SETTINGS if kind in ("int", "float")}
|
||||
target.set_settings(package, {k: v for k, v in adapter_settings(recipe.patches).items() if k in numeric})
|
||||
target.set_settings(package, {k: v for k, v in effective_adapter_settings(recipe.patches).items() if k in numeric})
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ class Target(ABC):
|
||||
return {}
|
||||
|
||||
@abstractmethod
|
||||
def set_settings(self, package: str, settings: dict) -> dict: ...
|
||||
def set_settings(self, package: str, settings: dict, *, foveation=None) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
def uninstall(self, package: str, keep_data: bool = True) -> dict: ...
|
||||
|
||||
@@ -71,14 +71,29 @@ class FrameLeptonTarget(Target):
|
||||
return self.connect().frame.agent("collect_diag", timeout=180, package=package)
|
||||
|
||||
def launch(self, package):
|
||||
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 {}
|
||||
return self.connect().frame.agent("launch", package=package, timeout=60,
|
||||
mode=library.setting("install.steam_library", "now"))
|
||||
|
||||
def set_settings(self, package, settings, *, foveation=None):
|
||||
from ..core import library
|
||||
from ..install.installer import install_context
|
||||
from ..patches.settings import foveation_env, foveation_preference
|
||||
|
||||
entry = library.game(package) or {}
|
||||
superseded = (entry.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)
|
||||
extra = {}
|
||||
if settings or foveation is not None:
|
||||
preference = foveation if foveation is not None else foveation_preference(entry)
|
||||
ctx = install_context(library.recipe_from_dict(entry["recipe"])) if entry.get("recipe") else None
|
||||
effective = {**(ctx.adapter_settings if ctx else {}), **settings}
|
||||
if preference.get("mode") in ("tracked", "fixed"):
|
||||
settings.update(foveation_fix=1, foveation_storage_fix=1)
|
||||
if ctx or foveation is not None:
|
||||
extra["foveation_env"] = foveation_env(package, preference, effective, ctx.env if ctx else {})
|
||||
return self.connect().frame.agent("set_settings", package=package, settings=settings, **extra)
|
||||
|
||||
def uninstall(self, package, keep_data=True):
|
||||
return self.connect().frame.agent("uninstall", package=package, keep_data=keep_data, remove_shortcut=True)
|
||||
|
||||
@@ -336,7 +336,7 @@ class PcReviveTarget(Target):
|
||||
f"{f.diagnosis} [{f.evidence[:160]}]")
|
||||
return result, log
|
||||
|
||||
def set_settings(self, package, settings):
|
||||
def set_settings(self, package, settings, *, foveation=None):
|
||||
return {"settings": {}}
|
||||
|
||||
def uninstall(self, package, keep_data=True):
|
||||
|
||||
@@ -1995,15 +1995,21 @@ class FramePortApp:
|
||||
"""Store the new name, then update the game's Steam entry where it's installed and reachable now (the Frame:
|
||||
same shortcut, Steam restarts once; this PC's Steam for PC VR games). A Frame that isn't connected gets it
|
||||
with the game page's "Update name on Frame" or the next install."""
|
||||
before = (library.game(pkg) or {}).get("title")
|
||||
from ..artwork.details import title_matched
|
||||
|
||||
previous = library.game(pkg) or {}
|
||||
before = previous.get("title")
|
||||
refetch = title_matched(previous)
|
||||
try:
|
||||
g = pipeline.rename_game(pkg, value)
|
||||
g = pipeline.rename_game(pkg, value, refresh_details=False)
|
||||
except ValueError as exc:
|
||||
self.toast(str(exc), error=True)
|
||||
return
|
||||
title = self._title(pkg)
|
||||
if g.get("title") == before:
|
||||
return
|
||||
if refetch:
|
||||
self.refresh_details(pkg)
|
||||
on_frame = self.frame_state == "connected" and \
|
||||
C.install_state(g, self.frame_info) in ("installed", "outdated")
|
||||
on_pc = g.get("kind") == "rift" and pkg in self.pc_installs()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Bundled controller mapping illustrations, installed with the other UI SVG assets at startup."""
|
||||
from __future__ import annotations
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ..artwork.thumbs import asset_url
|
||||
from ..core.paths import user_data_dir
|
||||
from ..i18n import tr
|
||||
from . import glyphs as G
|
||||
from . import theme as T
|
||||
|
||||
|
||||
def diagram_source(mode: int) -> str:
|
||||
if mode not in (0, 1, 2):
|
||||
mode = 0 # retain an unknown CLI value in the choice while keeping a valid preview asset
|
||||
return asset_url(user_data_dir() / G.FOLDER / f"quest-buttons-{mode}.svg")
|
||||
|
||||
|
||||
def diagram(mode: int) -> ft.Image:
|
||||
return ft.Image(src=diagram_source(mode), width=T.px(440), height=T.px(322), fit=ft.BoxFit.CONTAIN,
|
||||
semantics_label=tr("Steam Frame controllers: each button shows the Quest Touch button it sends. "
|
||||
"A dash means it sends nothing."))
|
||||
@@ -19,6 +19,8 @@ class _Translated(dict):
|
||||
|
||||
|
||||
HELP: dict[str, str] = _Translated({
|
||||
"steam_library": "Now waits until Steam can restart safely. Later saves shortcuts for the next Steam start. "
|
||||
"Off uses FramePort Play without Steam per-game integration; existing shortcuts stay.",
|
||||
# ---- 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.",
|
||||
@@ -140,11 +142,16 @@ HELP: dict[str, str] = _Translated({
|
||||
"the library before the game.",
|
||||
"desktop_entry": "Adds the app to Desktop Mode's app menu and, if you like, its desktop. Some apps work better "
|
||||
"there with a mouse and keyboard. An icon you delete from the desktop stays deleted.",
|
||||
"adapter_settings": "Sharpness, refresh rate, controllers, menus and more for this game. Changes take effect the "
|
||||
"adapter_settings": "Render resolution (supersampling), foveated rendering, refresh rate, controllers, menus and "
|
||||
"more for this game. Changes take effect the "
|
||||
"next time it starts.",
|
||||
"frame_summary": "Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR "
|
||||
"games can run on the Frame.",
|
||||
# ---- settings
|
||||
"quest_buttons": "Quest games bind Touch controllers. SteamVR normally repeats Touch Y/B on three top "
|
||||
"buttons. Mirror gives the bottom and inner buttons X/A, the top and outer Y/B. Quest pair "
|
||||
"uses two diagonal buttons per hand. Requires the Frame runtime's controller extension; "
|
||||
"SteamVR custom bindings can override the choice. Changes apply at the next launch.",
|
||||
"data_folder": "FramePort's tools, library, artwork and your games' signing keys. Back it up: an update needs the "
|
||||
"same key, or the game must be reinstalled and loses its saves.",
|
||||
"catalog": "Recipes tested on a real Frame: built in, fetched online and the ones you saved as known-good.",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="380" viewBox="0 0 520 380" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Quest Touch buttons on Steam Frame controllers</title>
|
||||
<desc id="desc">SteamVR default: FrameBridge suggests nothing for the Frame profile; SteamVR remaps Touch itself (documented by Valve). Listed for the diagram only. Each Steam Frame controller button is labelled with the Quest Touch button it sends. A dash means it sends nothing. Left Touch buttons are X and Y; right are A and B.</desc>
|
||||
<rect width="520" height="380" rx="16" fill="#0D0E12"/>
|
||||
<g font-family="DejaVu Sans,Arial,sans-serif">
|
||||
<g transform="translate(0 0)">
|
||||
<rect x="0" y="0" width="520" height="380" rx="12" fill="#16181E" stroke="#2A2D37"/>
|
||||
<text x="20" y="31" fill="#EDEEF2" font-size="20" font-weight="600" text-anchor="start">SteamVR default</text>
|
||||
<text x="20" y="55" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="start">Steam Frame controllers: each button shows the Quest button it sends</text>
|
||||
<text x="140" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
|
||||
<path d="M102,256 C104,314 114,334 136,338 C158,342 178,326 194,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="140" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="174" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
|
||||
<circle cx="174" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<circle cx="126" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="126" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
|
||||
<rect x="131" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#FF8A1F"/>
|
||||
<text x="144" y="130" fill="#FF8A1F" font-size="10" font-weight="600" text-anchor="middle">View: Menu</text>
|
||||
<path d="M84,144 h28 v30 h30 v28 h-30 v30 h-28 v-30 h-30 v-28 h30 Z" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="98" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
|
||||
<text x="69" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
|
||||
<text x="127" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
|
||||
<text x="98" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">X</text>
|
||||
<text x="380" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
|
||||
<path d="M326,256 C328,314 338,334 360,338 C382,342 402,326 418,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="380" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="346" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
|
||||
<circle cx="346" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<circle cx="394" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="394" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
|
||||
<rect x="363" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#868A96"/>
|
||||
<text x="376" y="130" fill="#868A96" font-size="10" font-weight="600" text-anchor="middle">Menu: —</text>
|
||||
<circle cx="422" cy="159" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="422" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
|
||||
<text x="437" y="149" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Y</text>
|
||||
<circle cx="393" cy="188" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="393" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
|
||||
<text x="408" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">X</text>
|
||||
<circle cx="451" cy="188" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="451" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
|
||||
<text x="466" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">B</text>
|
||||
<circle cx="422" cy="217" r="14" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="422" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">A</text>
|
||||
<text x="437" y="207" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">A</text>
|
||||
<text x="260.0" y="366" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Sticks, triggers and grips unchanged · bumpers send nothing</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,48 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="380" viewBox="0 0 520 380" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Quest Touch buttons on Steam Frame controllers</title>
|
||||
<desc id="desc">Mirror: Every face button works; the left controller mirrors the right: bottom + inner = X/A, top + outer = Y/B. Each Steam Frame controller button is labelled with the Quest Touch button it sends. A dash means it sends nothing. Left Touch buttons are X and Y; right are A and B.</desc>
|
||||
<rect width="520" height="380" rx="16" fill="#0D0E12"/>
|
||||
<g font-family="DejaVu Sans,Arial,sans-serif">
|
||||
<g transform="translate(0 0)">
|
||||
<rect x="0" y="0" width="520" height="380" rx="12" fill="#16181E" stroke="#2A2D37"/>
|
||||
<text x="20" y="31" fill="#EDEEF2" font-size="20" font-weight="600" text-anchor="start">Mirror</text>
|
||||
<text x="20" y="55" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="start">Steam Frame controllers: each button shows the Quest button it sends</text>
|
||||
<text x="140" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
|
||||
<path d="M102,256 C104,314 114,334 136,338 C158,342 178,326 194,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="140" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="174" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
|
||||
<circle cx="174" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<circle cx="126" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="126" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
|
||||
<rect x="131" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#FF8A1F"/>
|
||||
<text x="144" y="130" fill="#FF8A1F" font-size="10" font-weight="600" text-anchor="middle">View: Menu</text>
|
||||
<path d="M84,144 h28 v30 h30 v28 h-30 v30 h-28 v-30 h-30 v-28 h30 Z" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="98" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
|
||||
<text x="69" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
|
||||
<text x="127" y="193.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">X</text>
|
||||
<text x="98" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">X</text>
|
||||
<text x="380" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
|
||||
<path d="M326,256 C328,314 338,334 360,338 C382,342 402,326 418,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="380" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="346" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
|
||||
<circle cx="346" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<circle cx="394" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="394" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
|
||||
<rect x="363" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#868A96"/>
|
||||
<text x="376" y="130" fill="#868A96" font-size="10" font-weight="600" text-anchor="middle">Menu: —</text>
|
||||
<circle cx="422" cy="159" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="422" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
|
||||
<text x="437" y="149" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Y</text>
|
||||
<circle cx="393" cy="188" r="14" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="393" y="193.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">A</text>
|
||||
<text x="408" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">X</text>
|
||||
<circle cx="451" cy="188" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="451" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
|
||||
<text x="466" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">B</text>
|
||||
<circle cx="422" cy="217" r="14" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="422" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">A</text>
|
||||
<text x="437" y="207" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">A</text>
|
||||
<text x="260.0" y="366" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Sticks, triggers and grips unchanged · bumpers send nothing</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,48 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="380" viewBox="0 0 520 380" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Quest Touch buttons on Steam Frame controllers</title>
|
||||
<desc id="desc">Quest pair: Two buttons per controller, placed diagonally like Quest Touch; the other face buttons do nothing. Each Steam Frame controller button is labelled with the Quest Touch button it sends. A dash means it sends nothing. Left Touch buttons are X and Y; right are A and B.</desc>
|
||||
<rect width="520" height="380" rx="16" fill="#0D0E12"/>
|
||||
<g font-family="DejaVu Sans,Arial,sans-serif">
|
||||
<g transform="translate(0 0)">
|
||||
<rect x="0" y="0" width="520" height="380" rx="12" fill="#16181E" stroke="#2A2D37"/>
|
||||
<text x="20" y="31" fill="#EDEEF2" font-size="20" font-weight="600" text-anchor="start">Quest pair</text>
|
||||
<text x="20" y="55" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="start">Steam Frame controllers: each button shows the Quest button it sends</text>
|
||||
<text x="140" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
|
||||
<path d="M102,256 C104,314 114,334 136,338 C158,342 178,326 194,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="140" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="174" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
|
||||
<circle cx="174" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<circle cx="126" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="126" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
|
||||
<rect x="131" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#FF8A1F"/>
|
||||
<text x="144" y="130" fill="#FF8A1F" font-size="10" font-weight="600" text-anchor="middle">View: Menu</text>
|
||||
<path d="M84,144 h28 v30 h30 v28 h-30 v30 h-28 v-30 h-30 v-28 h30 Z" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="98" y="164.04" fill="#868A96" font-size="14" font-weight="700" text-anchor="middle">—</text>
|
||||
<text x="69" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
|
||||
<text x="127" y="193.04" fill="#868A96" font-size="14" font-weight="700" text-anchor="middle">—</text>
|
||||
<text x="98" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">X</text>
|
||||
<text x="380" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
|
||||
<path d="M326,256 C328,314 338,334 360,338 C382,342 402,326 418,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="380" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
|
||||
<circle cx="346" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
|
||||
<circle cx="346" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<circle cx="394" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
|
||||
<text x="394" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
|
||||
<rect x="363" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#868A96"/>
|
||||
<text x="376" y="130" fill="#868A96" font-size="10" font-weight="600" text-anchor="middle">Menu: —</text>
|
||||
<circle cx="422" cy="159" r="14" fill="#1D1F27" stroke="#868A96" stroke-width="1.5"/>
|
||||
<text x="422" y="164.04" fill="#868A96" font-size="14" font-weight="700" text-anchor="middle">—</text>
|
||||
<text x="437" y="149" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Y</text>
|
||||
<circle cx="393" cy="188" r="14" fill="#1D1F27" stroke="#868A96" stroke-width="1.5"/>
|
||||
<text x="393" y="193.04" fill="#868A96" font-size="14" font-weight="700" text-anchor="middle">—</text>
|
||||
<text x="408" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">X</text>
|
||||
<circle cx="451" cy="188" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="451" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
|
||||
<text x="466" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">B</text>
|
||||
<circle cx="422" cy="217" r="14" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="422" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">A</text>
|
||||
<text x="437" y="207" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">A</text>
|
||||
<text x="260.0" y="366" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Sticks, triggers and grips unchanged · bumpers send nothing</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -1,6 +1,7 @@
|
||||
"""Game settings (FrameBridge adapter settings) for non-technical users: only the settings that can matter for this
|
||||
game, in plain words, with switches, sliders and choices instead of number fields; advanced ones on request.
|
||||
Saved into the game's recipe (kept for reinstalls) and, when the game is on the Frame, applied there right away."""
|
||||
Adapter values are kept in the recipe; foveation is a separate library preference.
|
||||
Installed games receive settings and launcher changes without rebuilding the APK."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -9,11 +10,23 @@ import flet as ft
|
||||
|
||||
from ... import pipeline
|
||||
from ...core import library
|
||||
from ...core.controllers import QUEST_BUTTONS, effective_adapter_settings, quest_buttons_default
|
||||
from ...i18n import tr
|
||||
from ...patches import base
|
||||
from ...patches.settings import BASE_SETTINGS, GROUPS, HIDDEN, SETTINGS, UI, adapter_settings
|
||||
from ...patches.settings import (
|
||||
BASE_SETTINGS,
|
||||
FOVEATION_UI,
|
||||
GROUPS,
|
||||
HIDDEN,
|
||||
SETTINGS,
|
||||
UI,
|
||||
Foveation,
|
||||
foveation_options,
|
||||
foveation_preference,
|
||||
)
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
from ..controllers import diagram, diagram_source
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
@@ -45,20 +58,23 @@ def relevant(analysis, key: str, value) -> bool:
|
||||
|
||||
def recipe_values(g: dict) -> dict:
|
||||
vals = {key: default(key) for key in SPECS}
|
||||
vals.update({k: _num(k, v) for k, v in adapter_settings(library.recipe_from_dict(g["recipe"]).patches).items()
|
||||
if k in SPECS})
|
||||
patches = library.recipe_from_dict(g["recipe"]).patches
|
||||
vals.update({k: _num(k, v) for k, v in effective_adapter_settings(patches).items() if k in SPECS})
|
||||
return vals
|
||||
|
||||
|
||||
def save_to_recipe(package: str, values: dict) -> None:
|
||||
def save_to_recipe(package: str, values: dict, *, inherit_quest_buttons: bool | None = None) -> None:
|
||||
"""Adapter patches = the settings that differ from their defaults (BASE_SETTINGS are written anyway)."""
|
||||
g = library.game(package)
|
||||
r = library.recipe_from_dict(g["recipe"])
|
||||
before = {pid: dict(v or {}) for pid, v in r.patches.items() if pid.startswith("adapter.")}
|
||||
if inherit_quest_buttons is None:
|
||||
inherit_quest_buttons = (QUEST_BUTTONS not in r.patches
|
||||
and values.get("quest_buttons") == quest_buttons_default())
|
||||
for key in SPECS:
|
||||
pid = f"adapter.{key}"
|
||||
v = _num(key, values.get(key, default(key)))
|
||||
if v == default(key):
|
||||
if (inherit_quest_buttons if key == "quest_buttons" else v == default(key)):
|
||||
r.patches.pop(pid, None)
|
||||
r.reasons.pop(pid, None)
|
||||
else:
|
||||
@@ -81,27 +97,83 @@ def show_adapter_dialog(app: FramePortApp, package: str) -> None:
|
||||
return
|
||||
|
||||
def load():
|
||||
cur = target.set_settings(package, {})["settings"] # no changes: returns the current values
|
||||
result = target.set_settings(package, {}) # no changes: returns the current values
|
||||
cur = result["settings"]
|
||||
vals = {key: default(key) for key in SPECS}
|
||||
vals.update({k: _num(k, v) for k, v in cur.items() if k in SPECS})
|
||||
_open(app, package, None, vals)
|
||||
env = result.get("foveation_env") or {}
|
||||
mode = ("off" if env.get("LEPTON_ENV_DISABLE_VULKAN_FDM_INJECTION_LAYER") == "1" else
|
||||
("fixed" if "disable_offsets" in env.get("FDM_DEBUG", "") else "tracked")
|
||||
if env.get("ENABLE_VULKAN_FDM_INJECTION_LAYER") == "1" else "")
|
||||
pref = {"mode": mode}
|
||||
if mode in ("tracked", "fixed"):
|
||||
pref.update(radius=float(env.get("FOVE_INNER") or 0),
|
||||
test=env.get("LEPTON_ENV_FRAMEPORT_FOVEATION_TEST") == "1")
|
||||
_open(app, package, None, vals, pref)
|
||||
app.run_bg(load)
|
||||
return
|
||||
_open(app, package, g, recipe_values(g))
|
||||
|
||||
|
||||
def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None:
|
||||
def _open(app: FramePortApp, package: str, g: dict | None, values: dict, foveation_values=None,
|
||||
inherit_quest_buttons: bool | None = None) -> None:
|
||||
analysis = library.analysis_from_dict(g["analysis"]) if g and g.get("analysis") else None
|
||||
suggested = recipe_values({"recipe": g["suggested"]}) if g and g.get("suggested") else \
|
||||
{key: default(key) for key in SPECS}
|
||||
values = dict(values)
|
||||
global_mode = quest_buttons_default()
|
||||
inherited = {"on": (g is not None and QUEST_BUTTONS not in (g.get("recipe") or {}).get("patches", {}))
|
||||
if inherit_quest_buttons is None else inherit_quest_buttons}
|
||||
if inherited["on"]:
|
||||
values["quest_buttons"] = global_mode
|
||||
controller_preview = diagram(values["quest_buttons"])
|
||||
foveation = dict(foveation_values) if foveation_values is not None else foveation_preference(g or {})
|
||||
foveation_controls = []
|
||||
foveation_rows = {}
|
||||
|
||||
def foveation_change(key, value):
|
||||
foveation[key] = value
|
||||
for name in ("radius", "test"):
|
||||
if name in foveation_rows:
|
||||
foveation_rows[name].visible = (foveation.get("mode") in ("tracked", "fixed") and
|
||||
(name != "test" or advanced["on"]))
|
||||
C.update(foveation_rows[name])
|
||||
|
||||
foveation_specs = (("mode", Foveation.CHOICES), ("radius", Foveation.RADIUS_CHOICES), ("test", None))
|
||||
for key, choices in (foveation_specs if not g or g.get("kind", "quest") == "quest" else ()):
|
||||
ui = FOVEATION_UI[key]
|
||||
if choices:
|
||||
value = str(foveation.get(key, "")) if key == "mode" else f"{float(foveation.get(key) or 0):g}"
|
||||
choices = list(choices)
|
||||
if value not in [str(v) for v, _ in choices]:
|
||||
choices.append((value, value))
|
||||
control = C.dropdown(value=value, width=T.px(220),
|
||||
options=[ft.DropdownOption(key=str(v), text=tr(label)) for v, label in choices],
|
||||
on_select=lambda e, k=key: foveation_change(k, float(e.control.value) if k == "radius"
|
||||
else e.control.value))
|
||||
else:
|
||||
control = C.switch(value=bool(foveation.get(key)),
|
||||
on_change=lambda e: foveation_change("test", bool(e.control.value)))
|
||||
foveation_rows[key] = ft.Container(ft.Row([
|
||||
ft.Column([C.body(tr(ui["label"]), T.TEXT, weight=ft.FontWeight.W_500),
|
||||
C.meta(tr(ui["help"]), T.TEXT_2)], spacing=T.px(2), expand=True), control],
|
||||
spacing=T.S4, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
padding=ft.Padding(0, T.px(8), 0, T.px(8)))
|
||||
foveation_controls.append(foveation_rows[key])
|
||||
controls: dict[str, ft.Control] = {} # key -> the input control
|
||||
rows: dict[str, ft.Container] = {} # key -> its row (visibility)
|
||||
readouts: dict[str, ft.Text] = {} # slider value labels
|
||||
advanced = {"on": any(UI[k]["level"] == "advanced" and values[k] != default(k) for k in SPECS)}
|
||||
advanced = {"on": bool(foveation.get("test"))
|
||||
or any(UI[k]["level"] == "advanced" and values[k] != default(k) for k in SPECS)}
|
||||
|
||||
def set_value(key, v):
|
||||
if key == "quest_buttons":
|
||||
inherited["on"] = v == "global"
|
||||
v = global_mode if inherited["on"] else v
|
||||
values[key] = _num(key, v)
|
||||
if key == "quest_buttons":
|
||||
controller_preview.src = diagram_source(values[key])
|
||||
C.update(controller_preview)
|
||||
if key in readouts:
|
||||
readouts[key].value = UI[key]["control"][4].format(values[key])
|
||||
C.update(readouts[key])
|
||||
@@ -123,7 +195,11 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None
|
||||
choices = list(spec[0])
|
||||
if v not in [c for c, _ in choices]:
|
||||
choices.append((v, str(v))) # a value set elsewhere (CLI, catalog) stays selectable
|
||||
return C.dropdown(value=str(v), width=T.px(220),
|
||||
if key == "quest_buttons" and g is not None:
|
||||
name = next(label for mode, label in UI[key]["control"][1] if mode == global_mode)
|
||||
choices.insert(0, ("global", tr("Use global default ({mode})").format(mode=tr(name))))
|
||||
return C.dropdown(value="global" if key == "quest_buttons" and inherited["on"] else str(v),
|
||||
width=T.px(300 if key == "quest_buttons" else 220),
|
||||
options=[ft.DropdownOption(key=str(c), text=tr(label)) for c, label in choices],
|
||||
on_select=lambda e: set_value(key, e.control.value))
|
||||
|
||||
@@ -132,10 +208,12 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None
|
||||
label = tr(ui["label"]) if ui.get("label") else tr(base.get(f"adapter.{key}").title)
|
||||
help_text = tr(ui["help"]) if ui.get("help") else tr(base.get(f"adapter.{key}").description)
|
||||
controls[key] = control_for(key)
|
||||
rows[key] = ft.Container(ft.Row([
|
||||
line = ft.Row([
|
||||
ft.Column([C.body(label, T.TEXT, weight=ft.FontWeight.W_500), C.meta(help_text, T.TEXT_2)],
|
||||
spacing=T.px(2), expand=True),
|
||||
controls[key]], spacing=T.S4, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
controls[key]], spacing=T.S4, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
rows[key] = ft.Container(ft.Column([line, controller_preview], spacing=T.S2)
|
||||
if key == "quest_buttons" else line,
|
||||
padding=ft.Padding(0, T.px(8), 0, T.px(8)))
|
||||
return rows[key]
|
||||
|
||||
@@ -146,11 +224,13 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None
|
||||
if not keys:
|
||||
continue
|
||||
heading = C.body(tr(title), T.TEXT_2, weight=ft.FontWeight.W_600)
|
||||
section = ft.Column([heading, *[row(k) for k in keys]], spacing=0)
|
||||
section = ft.Column([heading, *[row(k) for k in keys],
|
||||
*(foveation_controls if group == "picture" else [])], spacing=0)
|
||||
sections.append((group, section, keys))
|
||||
body.append(section)
|
||||
|
||||
def apply_visibility():
|
||||
foveation_change("mode", foveation.get("mode", ""))
|
||||
for _group, section, keys in sections:
|
||||
shown = 0
|
||||
for k in keys:
|
||||
@@ -169,14 +249,20 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None
|
||||
for k in list(values):
|
||||
values[k] = suggested.get(k, default(k))
|
||||
app.page.pop_dialog()
|
||||
_open(app, package, g, values)
|
||||
recommended = (g.get("suggested") or {}).get("patches", {}) if g else {}
|
||||
_open(app, package, g, values, {}, inherit_quest_buttons=g is not None and QUEST_BUTTONS not in recommended)
|
||||
|
||||
before = dict(values)
|
||||
|
||||
def save(e):
|
||||
if foveation.get("mode", "") not in ("tracked", "fixed"):
|
||||
foveation.pop("radius", None)
|
||||
foveation.pop("test", None)
|
||||
foveation_options(foveation)
|
||||
app.page.pop_dialog()
|
||||
if g is not None:
|
||||
save_to_recipe(package, values)
|
||||
library.set_fields(package, foveation=dict(foveation))
|
||||
save_to_recipe(package, values, inherit_quest_buttons=inherited["on"])
|
||||
reinstall = any(UI[k].get("reinstall") and values[k] != before[k] for k in SPECS)
|
||||
installed = app.target is not None and (g is None or C.install_state(g, app.frame_info) in
|
||||
("installed", "outdated"))
|
||||
@@ -187,13 +273,13 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None
|
||||
target = app.target
|
||||
|
||||
def push():
|
||||
target.set_settings(package, {k: values[k] for k in SPECS})
|
||||
target.set_settings(package, {k: values[k] for k in SPECS}, foveation=foveation)
|
||||
app.toast(tr("Saved. Restart the game to use the new settings.") +
|
||||
(tr(" Reinstall it for the controller models.") if reinstall else ""))
|
||||
app.refresh_view()
|
||||
app.run_bg(push)
|
||||
|
||||
show_adv = any(UI[k]["level"] == "advanced" for _, _, keys in sections for k in keys)
|
||||
show_adv = bool(foveation_controls) or any(UI[k]["level"] == "advanced" for _, _, keys in sections for k in keys)
|
||||
content = ft.Column([
|
||||
C.body(tr("Changes take effect the next time the game starts. Not sure? Leave a setting as it is."), T.TEXT_2),
|
||||
*body,
|
||||
|
||||
+105
-21
@@ -1,8 +1,9 @@
|
||||
"""Files: browse and manage files on the Steam Frame (instead of a separate file-transfer app).
|
||||
|
||||
Locations are the folders Quest games see (Videos / Downloads / Documents, shared by every game as /sdcard/Movies,
|
||||
/sdcard/Download, /sdcard/Documents), each installed game's own storage, and the Frame's home folder. Everything runs
|
||||
over the app's SSH connection (SFTP); uploads and downloads are jobs in the Activity panel. Persistent like the
|
||||
/sdcard/Download, /sdcard/Documents), each installed Quest game's own storage, PC VR/Linux install folders, extra
|
||||
drives, and the Frame's home folder. Everything runs over the app's SSH connection (SFTP); uploads and downloads
|
||||
are jobs in the Activity panel. Persistent like the
|
||||
Library: the chosen location and folder survive switching tabs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -69,6 +70,35 @@ def crumbs(root: str, path: str, root_label: str) -> list[tuple[str, str]]:
|
||||
return out
|
||||
|
||||
|
||||
def drive_locations(listed: list[dict]) -> list[dict]:
|
||||
"""Files roots are mounts, including drives unsuitable for installing games."""
|
||||
return [{"id": f"drive:{d['path']}", "label": d.get("label") or d["path"], "path": d["path"],
|
||||
"android": "", "shared": False,
|
||||
"read_only": bool(d.get("read_only") or d.get("readonly") or d.get("reason") == "mounted read-only"),
|
||||
"free_bytes": d.get("free_bytes")}
|
||||
for d in listed if not d.get("internal") and d.get("path", "").startswith("/")]
|
||||
|
||||
|
||||
def game_section(kind: str | None) -> str:
|
||||
"""Which Files section an installed game is listed in: its Android storage (Quest, the default), or the install
|
||||
folder of a PC VR game / Linux app."""
|
||||
return "pcvr" if kind in ("rift", "pcvr") else "linux" if kind == "linux" else "quest"
|
||||
|
||||
|
||||
def game_location(dep: dict, title: str, home: str) -> dict:
|
||||
"""Quest storage resolves lazily; other games expose their deployment's base."""
|
||||
pkg = dep["package"]
|
||||
loc = {"id": f"app:{pkg}", "label": title, "package": pkg}
|
||||
if dep.get("kind") in ("pcvr", "rift", "linux"):
|
||||
drive = dep.get("drive") or {}
|
||||
fallback = (posixpath.join(drive["path"], "FramePort", pkg)
|
||||
if drive.get("path") and not drive.get("internal")
|
||||
else posixpath.join(home, "Applications/quest-frame", pkg))
|
||||
base = dep.get("base") or fallback
|
||||
loc.update(path=base, android="", shared=False)
|
||||
return loc
|
||||
|
||||
|
||||
class FilesView:
|
||||
def __init__(self, app: FramePortApp):
|
||||
self.app = app
|
||||
@@ -78,6 +108,7 @@ class FilesView:
|
||||
self.hidden = False
|
||||
self.game_locs: dict[str, dict] = {} # package -> resolved location (agent storage_targets, cached)
|
||||
self.shared: list[dict] = []
|
||||
self.drives: list[dict] | None = None
|
||||
self.locations = ft.Column(spacing=T.px(2), scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
self.crumb_row = ft.Row(spacing=T.px(2), wrap=True)
|
||||
self.where = C.meta("")
|
||||
@@ -192,12 +223,26 @@ class FilesView:
|
||||
def _render_locations(self) -> None:
|
||||
rows = [self._location_row(loc_, SHARED_ICONS.get(loc_["id"], ft.Icons.FOLDER_OUTLINED), loc_["android"])
|
||||
for loc_ in self.shared]
|
||||
games = self._installed_games()
|
||||
if games:
|
||||
rows.append(ft.Container(C.meta(tr("Game storage").upper()), padding=ft.Padding(T.S2, T.S3, 0, T.px(2))))
|
||||
if self.drives:
|
||||
from ...install.drives import free_text
|
||||
|
||||
rows.append(ft.Container(C.meta(tr("Drives").upper()), padding=ft.Padding(T.S2, T.S3, 0, T.px(2))))
|
||||
rows.extend(self._location_row(loc, ft.Icons.STORAGE_OUTLINED,
|
||||
" · ".join(filter(None, [free_text(loc),
|
||||
tr("Read-only") if loc.get("read_only") else ""])))
|
||||
for loc in self.drives)
|
||||
kinds = {d["package"]: d.get("kind") for d in (self.app.frame_info or {}).get("installed", [])}
|
||||
installed = self._installed_games()
|
||||
for section, heading, icon in (("quest", tr("Game storage"), ft.Icons.SPORTS_ESPORTS_OUTLINED),
|
||||
("pcvr", tr("PC VR games"), ft.Icons.DESKTOP_WINDOWS_ROUNDED),
|
||||
("linux", tr("Linux apps"), ft.Icons.TERMINAL_ROUNDED)):
|
||||
games = [g for g in installed if game_section(kinds.get(g[0])) == section]
|
||||
if not games:
|
||||
continue
|
||||
rows.append(ft.Container(C.meta(heading.upper()), padding=ft.Padding(T.S2, T.S3, 0, T.px(2))))
|
||||
for pkg, title in games:
|
||||
loc = self.game_locs.get(pkg) or {"id": f"app:{pkg}", "label": title, "package": pkg}
|
||||
rows.append(self._location_row(loc, ft.Icons.SPORTS_ESPORTS_OUTLINED))
|
||||
loc = self.game_locs.get(pkg) or self._game_location(pkg, title)
|
||||
rows.append(self._location_row(loc, icon))
|
||||
rows.append(ft.Container(C.meta(tr("Advanced").upper()), padding=ft.Padding(T.S2, T.S3, 0, T.px(2))))
|
||||
home = {"id": "home", "label": tr("Home folder"), "path": self.app.target.frame.home, "android": "",
|
||||
"shared": False}
|
||||
@@ -210,12 +255,25 @@ class FilesView:
|
||||
|
||||
out = []
|
||||
for d in (self.app.frame_info or {}).get("installed", []):
|
||||
if d.get("kind") in ("pcvr", "linux"): # no Android storage
|
||||
continue
|
||||
g = library.game(d["package"]) or {}
|
||||
out.append((d["package"], g.get("title") or d.get("title") or d["package"]))
|
||||
return sorted(out, key=lambda x: x[1].lower())
|
||||
|
||||
def _game_location(self, package: str, title: str) -> dict:
|
||||
dep = next((d for d in (self.app.frame_info or {}).get("installed", [])
|
||||
if d["package"] == package), {"package": package})
|
||||
loc = game_location(dep, title, self.app.target.frame.home)
|
||||
if loc.get("path"):
|
||||
from ...install.files import inside
|
||||
|
||||
for drive in self.drives or []:
|
||||
try:
|
||||
inside(drive["path"], loc["path"])
|
||||
except ValueError:
|
||||
continue
|
||||
loc["read_only"] = drive["read_only"]
|
||||
return loc
|
||||
|
||||
# ---------------------------------------------------------------- loading (background threads)
|
||||
def _load_locations(self, package: str | None) -> None:
|
||||
from ...install import files
|
||||
@@ -229,10 +287,16 @@ class FilesView:
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.status.value = tr("Couldn't read the Frame's folders: {exc}").format(exc=explain(exc))
|
||||
C.update(self.status)
|
||||
if self.drives is None:
|
||||
try: # the Frame target's list (as the Storage section and Move to… use), kept for the space check too
|
||||
listed = self.app.target.drives()
|
||||
self.app.frame_drives = listed
|
||||
self.drives = drive_locations(listed)
|
||||
except Exception: # older agents / temporarily unavailable; retry on the next mount
|
||||
pass
|
||||
self._render_locations()
|
||||
if package:
|
||||
self.open_location({"id": f"app:{package}", "package": package,
|
||||
"label": dict(self._installed_games()).get(package, package)})
|
||||
self.open_location(self._game_location(package, dict(self._installed_games()).get(package, package)))
|
||||
elif self.loc is None and self.shared:
|
||||
self.open_location(self.shared[0])
|
||||
elif self.loc is not None:
|
||||
@@ -252,6 +316,11 @@ class FilesView:
|
||||
loc = {**loc, "path": t["path"], "android": t["android"], "shared": False}
|
||||
self.game_locs[loc["package"]] = loc
|
||||
self.loc, self.path = loc, loc["path"]
|
||||
self.selected.clear()
|
||||
for control in self.toolbar.controls[:3]:
|
||||
control.disabled = not self._writable()
|
||||
self.sel_bar.content.controls[3].disabled = not self._writable()
|
||||
C.update(self.toolbar)
|
||||
self._render_locations()
|
||||
self.load()
|
||||
self.app.run_bg(work)
|
||||
@@ -299,7 +368,7 @@ class FilesView:
|
||||
rows.append(self._row(None))
|
||||
rows += [self._row(e) for e in self.entries]
|
||||
if not self.entries:
|
||||
rows.append(ft.Container(C.meta(tr("This folder is empty. Upload files with the buttons above.")),
|
||||
rows.append(ft.Container(C.meta(tr("This folder is empty.")),
|
||||
padding=T.S4))
|
||||
self.listing.controls = rows
|
||||
n_dirs = sum(e.is_dir for e in self.entries)
|
||||
@@ -321,7 +390,7 @@ class FilesView:
|
||||
+ (f" · {when}" if when else ""))
|
||||
actions = [C.icon_btn(ft.Icons.DOWNLOAD_ROUNDED, tr("Download to this PC…"),
|
||||
lambda ev, x=e: self._download_later([x]))]
|
||||
if not self._protected(e):
|
||||
if self._writable() and not self._protected(e):
|
||||
actions += [C.icon_btn(ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, tr("Rename…"),
|
||||
lambda ev, x=e: self.rename(x)),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Delete…"), lambda ev, x=e: self.delete([x]))]
|
||||
@@ -353,9 +422,10 @@ class FilesView:
|
||||
"""[(label, icon, handler) | None] for a right-click on entry `e` (None = on the folder's empty space). With
|
||||
several entries selected and `e` among them, the actions act on the whole selection."""
|
||||
if e is None:
|
||||
return [(tr("Upload files…"), ft.Icons.UPLOAD_FILE_OUTLINED, self.upload_files),
|
||||
return [*([(tr("Upload files…"), ft.Icons.UPLOAD_FILE_OUTLINED, self.upload_files),
|
||||
(tr("Upload folder…"), ft.Icons.DRIVE_FOLDER_UPLOAD_OUTLINED, self.upload_folder),
|
||||
(tr("New folder…"), ft.Icons.CREATE_NEW_FOLDER_OUTLINED, lambda ev: self.new_folder()),
|
||||
(tr("New folder…"), ft.Icons.CREATE_NEW_FOLDER_OUTLINED, lambda ev: self.new_folder())]
|
||||
if self._writable() else []),
|
||||
None,
|
||||
*([(tr("Select all"), ft.Icons.SELECT_ALL_ROUNDED, lambda ev: self._select_all())]
|
||||
if any(self._selectable(x) for x in self.entries) else []),
|
||||
@@ -365,14 +435,14 @@ class FilesView:
|
||||
by_path = {x.path: x for x in self.entries}
|
||||
items = [by_path[p] for p in C.menu_targets(e.path, [x.path for x in self.entries if x.path in self.selected])]
|
||||
several = len(items) > 1
|
||||
deletable = [x for x in items if not self._protected(x)]
|
||||
deletable = [x for x in items if self._writable() and not self._protected(x)]
|
||||
out: list[tuple | None] = []
|
||||
if not several and e.is_dir:
|
||||
out.append((tr("Open"), ft.Icons.FOLDER_OPEN_ROUNDED, lambda ev: self.cd(e.path)))
|
||||
out.append((tr("Download {n} items to this PC…").format(n=len(items)) if several
|
||||
else tr("Download to this PC…"), ft.Icons.DOWNLOAD_ROUNDED,
|
||||
lambda ev: self._download_later(items)))
|
||||
if not several and not self._protected(e):
|
||||
if not several and self._writable() and not self._protected(e):
|
||||
out.append((tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, lambda ev: self.rename(e)))
|
||||
if not several:
|
||||
out.append((tr("Copy path"), ft.Icons.CONTENT_COPY_ROUNDED, lambda ev: self.app.copy(self._shown_path(
|
||||
@@ -411,7 +481,9 @@ class FilesView:
|
||||
self.app.page.run_task(self.download, items)
|
||||
|
||||
def cd(self, path: str) -> None:
|
||||
self.path = path
|
||||
from ...install.files import inside
|
||||
|
||||
self.path = inside(self.loc["path"], path)
|
||||
self.selected.clear()
|
||||
self.load()
|
||||
|
||||
@@ -465,9 +537,13 @@ class FilesView:
|
||||
def _selectable(self, e) -> bool:
|
||||
return not self._protected(e)
|
||||
|
||||
def _writable(self) -> bool:
|
||||
return bool(self.loc) and not self.loc.get("read_only", False)
|
||||
|
||||
def _protected(self, e) -> bool:
|
||||
"""Lepton's links at the top of a game's storage (Movies → ~/Videos, …): renaming/deleting them breaks them."""
|
||||
return e.link and posixpath.dirname(e.path) == posixpath.normpath(self.loc["path"])
|
||||
return (self.loc["id"].startswith("app:") and self.loc.get("android") == "/sdcard"
|
||||
and e.link and posixpath.dirname(e.path) == posixpath.normpath(self.loc["path"]))
|
||||
|
||||
def _update_selection(self, render: bool = True) -> None:
|
||||
n = len(self.selected)
|
||||
@@ -513,9 +589,11 @@ class FilesView:
|
||||
self.upload([Path(path)])
|
||||
|
||||
def upload(self, paths: list[Path]) -> None:
|
||||
if not self.loc:
|
||||
if not self._writable():
|
||||
return
|
||||
app, loc, dest = self.app, self.loc, self.path
|
||||
from ...install.files import inside
|
||||
|
||||
app, loc, dest = self.app, self.loc, inside(self.loc["path"], self.path)
|
||||
|
||||
def run(job):
|
||||
from ...install import files
|
||||
@@ -543,15 +621,21 @@ class FilesView:
|
||||
None, kind="tool-frame", open_panel=True)
|
||||
|
||||
def new_folder(self) -> None:
|
||||
if not self._writable():
|
||||
return
|
||||
self._ask_name(tr("New folder"), tr("Folder name"), "", tr("Create"), lambda name: self._fs(
|
||||
lambda files, frame: files.make_dir(frame, self.loc["path"], self.path, name)))
|
||||
|
||||
def rename(self, e) -> None:
|
||||
if not self._writable():
|
||||
return
|
||||
self._ask_name(tr("Rename {name}").format(name=e.name), tr("New name"), e.name, tr("Rename"),
|
||||
lambda name: self._fs(
|
||||
lambda files, frame: files.rename(frame, self.loc["path"], e.path, name)))
|
||||
|
||||
def delete(self, items: list) -> None:
|
||||
if not self._writable():
|
||||
return
|
||||
names = ", ".join(x.name for x in items[:3]) + (" …" if len(items) > 3 else "")
|
||||
what = tr("folder and everything in it") if any(x.is_dir for x in items) else tr("file")
|
||||
C.confirm(self.app.page, tr("Delete {names}?").format(names=names),
|
||||
|
||||
@@ -765,26 +765,13 @@ class GameView:
|
||||
r = state["recipe"]
|
||||
value = e.control.value or ""
|
||||
if value: # a non-default choice is stored as the patch's parameter
|
||||
if pid == "device.foveation" and value in ("tracked", "fixed"):
|
||||
r.patches.setdefault(pid, {})[key] = value
|
||||
else:
|
||||
r.patches[pid] = {key: value}
|
||||
r.patches[pid] = {key: value}
|
||||
r.reasons[pid] = tr("Chosen by you.")
|
||||
else:
|
||||
r.patches.pop(pid, None)
|
||||
save(r)
|
||||
if pid == "device.foveation":
|
||||
app.open_game(package, advanced=True, show_all=self.show_all)
|
||||
return handler
|
||||
|
||||
def foveation_param(key):
|
||||
def handler(e):
|
||||
r = state["recipe"]
|
||||
params = r.patches.setdefault("device.foveation", {})
|
||||
if params.get("mode") not in ("tracked", "fixed"):
|
||||
params["mode"] = "tracked"
|
||||
params[key] = bool(e.control.value) if key == "test" else float(e.control.value or 0)
|
||||
r.reasons["device.foveation"] = tr("Chosen by you.")
|
||||
if pid == "device.headset_model":
|
||||
r.patches[pid] = {} # the empty choice is Quest 3, the always-on default
|
||||
else:
|
||||
r.patches.pop(pid, None)
|
||||
save(r)
|
||||
return handler
|
||||
|
||||
@@ -820,7 +807,8 @@ class GameView:
|
||||
], spacing=T.S3), padding=ft.Padding(T.S4, T.S3, T.S4, T.S3)))
|
||||
continue
|
||||
rows = []
|
||||
for p in [p for p in base.all_patches() if p.category == cat and p.id in listed]:
|
||||
for p in [p for p in base.all_patches()
|
||||
if p.category == cat and p.id in listed and p.id != "device.foveation"]:
|
||||
on = p.id in recipe.patches
|
||||
reason = recipe.reasons.get(p.id, "")
|
||||
# plain by default; "Show technical details" adds the exact description, the id and the parameters
|
||||
@@ -844,21 +832,6 @@ class GameView:
|
||||
options=[ft.DropdownOption(key=k, text=tr(label)) for k, label in choices],
|
||||
on_select=choose(p.id, p.params[0].key))
|
||||
controls = [picker]
|
||||
if p.id == "device.foveation" and current in ("tracked", "fixed"):
|
||||
params = recipe.params(p.id)
|
||||
radius = f"{float(params.get('radius') or 0):g}"
|
||||
sizes = list(p.RADIUS_CHOICES)
|
||||
if radius not in dict(sizes):
|
||||
sizes.append((radius, f"{float(radius):.0%} radius"))
|
||||
controls += [C.meta(tr("Full-resolution area size")),
|
||||
C.dropdown(value=radius, width=T.px(280),
|
||||
options=[ft.DropdownOption(key=k, text=tr(label))
|
||||
for k, label in sizes],
|
||||
on_select=foveation_param("radius")),
|
||||
ft.Switch(label=tr("Darken lower-resolution regions (test)"),
|
||||
value=bool(params.get("test", False)),
|
||||
on_change=foveation_param("test"), active_color=T.ACCENT),
|
||||
C.meta(tr("Apply with Update on Frame."))]
|
||||
rows.append(ft.Container(ft.Row([
|
||||
ft.Column([ft.Row([C.body(C.patch_title(p.title, technical), T.TEXT,
|
||||
weight=ft.FontWeight.W_500)]
|
||||
@@ -866,7 +839,7 @@ class GameView:
|
||||
tooltip=C.tip(HELP["experimental"]))]
|
||||
if p.experimental else []), spacing=T.S2, wrap=True), *sub],
|
||||
spacing=T.px(3), expand=True),
|
||||
ft.Column(controls, spacing=T.S2, width=T.px(340 if p.id == "device.foveation" else 280)),
|
||||
ft.Column(controls, spacing=T.S2, width=T.px(280)),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S4, T.px(10), T.S4, T.px(10)), border=ft.Border(
|
||||
top=ft.BorderSide(1, T.BORDER))))
|
||||
continue
|
||||
|
||||
@@ -24,14 +24,14 @@ TOOL_WHY = {"java": tr("Runs OVRPort and apksigner"), "overport": tr("Converts Q
|
||||
# the page's sections in order (the index on the left lists them): what most users need first (updates of the app and
|
||||
# their games, installing, where games come from, looks), then what only some need (link handlers, tools FramePort
|
||||
# manages itself, PC VR on this PC), "Uninstall FramePort" last
|
||||
SECTIONS = ("updates", "installing", "folders", "appearance", "data", "feedback", "links", "tools", "pc", "about",
|
||||
"remove")
|
||||
SECTIONS = ("updates", "installing", "controllers", "folders", "appearance", "data", "feedback", "links", "tools",
|
||||
"pc", "about", "remove")
|
||||
|
||||
|
||||
def section_titles() -> dict[str, str]:
|
||||
"""Index labels of SECTIONS (the sections' own headings may be longer)."""
|
||||
return {"appearance": tr("Appearance"), "updates": tr("Updates"), "links": tr("Install links"),
|
||||
"folders": tr("Library folders"),
|
||||
"folders": tr("Library folders"), "controllers": tr("Controllers"),
|
||||
"installing": tr("Installing"), "tools": tr("Tools"), "pc": tr("This PC"), "data": tr("Data"),
|
||||
"feedback": tr("Problems and feedback"), "about": tr("About"), "remove": tr("Uninstall FramePort")}
|
||||
|
||||
@@ -99,6 +99,35 @@ class SettingsView:
|
||||
from ...core import library
|
||||
from ...install.installer import upload_threads
|
||||
|
||||
def steam_changed(e):
|
||||
mode = e.control.value
|
||||
library.set_setting("install.steam_library", mode)
|
||||
if self.app.frame_state == "connected":
|
||||
self.app.run_bg(lambda: self.app.target.connect().frame.agent("library_mode", mode=mode))
|
||||
|
||||
def restart(e):
|
||||
app = self.app
|
||||
if app.frame_state != "connected":
|
||||
app.toast(tr("Connect to the Frame first."))
|
||||
return
|
||||
def work(confirm=False):
|
||||
result = app.target.connect().frame.agent("restart_steam", confirm=confirm)
|
||||
if result.get("confirmation_required"):
|
||||
C.confirm(app.page, tr("A game is running"),
|
||||
tr("Restarting Steam ends the running game. Continue?"), tr("Restart Steam now"),
|
||||
lambda: app.run_bg(lambda: work(True)), danger=True)
|
||||
else:
|
||||
app.toast(tr("Steam is restarting. FramePort on the Frame will close."))
|
||||
C.confirm(app.page, tr("Restart Steam now?"),
|
||||
tr("This applies pending library changes and closes Desktop Mode and FramePort on the Frame."),
|
||||
tr("Restart Steam now"), lambda: app.run_bg(work))
|
||||
|
||||
steam = ft.Row([C.dropdown(label=tr("Steam library"),
|
||||
value=library.setting("install.steam_library", "now"), width=T.px(360),
|
||||
options=[ft.DropdownOption("now", tr("Now (default)")),
|
||||
ft.DropdownOption("later", tr("Later — next Steam start")),
|
||||
ft.DropdownOption("off", tr("Off — launch from FramePort"))],
|
||||
on_select=steam_changed), C.help_icon("steam_library")], spacing=T.S1)
|
||||
def changed(e):
|
||||
library.set_setting("install.launch_test", bool(e.control.value))
|
||||
def keep_changed(e):
|
||||
@@ -130,7 +159,11 @@ class SettingsView:
|
||||
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, parallel,
|
||||
return ft.Column([steam, C.meta(tr("Now waits while a game, Desktop Mode, or local FramePort is open. "
|
||||
"Later lets you keep installing without restarting Steam. "
|
||||
"Off uses FramePort Play without Steam per-game integration.")),
|
||||
C.ghost(tr("Restart Steam now"), ft.Icons.RESTART_ALT, restart),
|
||||
launch, parallel,
|
||||
C.meta(tr("Large files upload together using OpenSSH when available. "
|
||||
"Default: 3. Lower this on a busy network.")),
|
||||
video, keep,
|
||||
@@ -139,6 +172,47 @@ class SettingsView:
|
||||
ft.Container(height=T.S2), self.ssh_keys()],
|
||||
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
def controllers(self) -> ft.Control:
|
||||
from ...core import controllers, library
|
||||
from ..controllers import diagram, diagram_source
|
||||
|
||||
app = self.app
|
||||
mode = controllers.quest_buttons_default()
|
||||
preview = diagram(mode)
|
||||
|
||||
def changed(e):
|
||||
nonlocal mode
|
||||
mode = int(e.control.value)
|
||||
library.set_setting("input.quest_buttons", mode)
|
||||
preview.src = diagram_source(mode)
|
||||
C.update(preview)
|
||||
|
||||
def apply(e):
|
||||
def run(job):
|
||||
result = controllers.apply_default(app.target, job.reporter)
|
||||
message = tr_n("Applied to {n} game. Takes effect at the next launch.",
|
||||
"Applied to {n} games. Takes effect at the next launch.", len(result["applied"]))
|
||||
if result["needs_update"]:
|
||||
message += " " + tr_n("{n} game needs Update on Frame first.",
|
||||
"{n} games need Update on Frame first.", len(result["needs_update"]))
|
||||
return message # the job completion toast shows this result too
|
||||
app.submit(tr("Apply controller buttons to installed games"), run, kind="tool-frame")
|
||||
|
||||
choice = C.dropdown(label=tr("Default controller buttons"),
|
||||
value=str(mode), width=T.px(300),
|
||||
options=[ft.DropdownOption(str(v), tr(label)) for v, label in controllers.CHOICES],
|
||||
on_select=changed)
|
||||
return ft.Column([
|
||||
C.body(tr("Choose which Quest Touch buttons the Frame's face buttons send.")),
|
||||
choice, preview,
|
||||
C.meta(tr("Used when installing or updating games that follow the global default. "
|
||||
"Each game's Game settings can override it. Changes take effect at the next launch. "
|
||||
"Games installed before this feature need Update on Frame once for the mode to work.")),
|
||||
C.link(tr("Controller mapping guide"), REPO_URL + "/blob/main/docs/CONTROLLERS.md"),
|
||||
*([C.secondary(tr("Apply to installed games now"), G.SYNC, apply)]
|
||||
if app.frame_state == "connected" else []),
|
||||
], spacing=T.S3, horizontal_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
def library_folders(self) -> ft.Control:
|
||||
"""Settings → Library folders: the folders "Rescan folders" scans (core/folders.py, GitHub discussion #351).
|
||||
Status (is it there, how many games come from it) is read off the UI thread."""
|
||||
@@ -620,6 +694,8 @@ class SettingsView:
|
||||
"folders": C.section(tr("Library folders"), C.card(self.library_folders(), padding=T.S4),
|
||||
help="library_folders"),
|
||||
"installing": C.section(tr("Installing"), C.card(self.installing(), padding=T.S4), help="launch_test"),
|
||||
"controllers": C.section(tr("Controllers"), C.card(self.controllers(), padding=T.S4),
|
||||
help="quest_buttons"),
|
||||
"tools": C.section(tr("Tools"), C.card(self.tools, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)),
|
||||
subtitle=tr("FramePort keeps its own copies; nothing is installed system-wide"),
|
||||
action=ft.Row([C.ghost(tr("Update tools"), ft.Icons.UPDATE_ROUNDED,
|
||||
|
||||
@@ -830,6 +830,24 @@ def test_uninstall_removes_the_shortcut_with_steam_closed(monkeypatch, tmp_path)
|
||||
assert payload["remove"] == [{"exe": f'"{anchor}/launch.sh"', "appid": 7}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["later", "off"])
|
||||
def test_uninstall_with_library_mode_later_or_off(monkeypatch, tmp_path, mode):
|
||||
"""#305: in "later" the removal is queued and in "off" nothing starts; uninstall must still finish (found on
|
||||
the device: KeyError 'started' after the removal was already queued, the game stayed installed)."""
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
||||
anchor.mkdir(parents=True)
|
||||
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "base": str(anchor), "appid": 7}))
|
||||
monkeypatch.setattr(a, "container_running", lambda appid: False)
|
||||
monkeypatch.setattr(a, "steam_users", lambda: ["1"])
|
||||
monkeypatch.setattr(a, "library_mode", lambda args=None: mode)
|
||||
monkeypatch.setattr(a, "ensure_library_hooks", lambda: None)
|
||||
monkeypatch.setattr(a, "run", lambda cmd, *k, **kw: SimpleNamespace(returncode=0, stdout=""))
|
||||
r = a.cmd_uninstall({"package": "com.x.y", "remove_shortcut": True})
|
||||
# "off" still removes an existing shortcut right away (only adding is off); "later" queues it
|
||||
assert r["removed"] and r["shortcut_removed"] == (mode == "off")
|
||||
|
||||
|
||||
def test_prune_also_matches_the_earlier_tag_name(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
vdf = str(tmp_path / "shortcuts.vdf")
|
||||
@@ -2081,3 +2099,264 @@ def test_launcher_starts_lepton_again_once_when_the_game_restarts_itself(monkeyp
|
||||
(tmp_path / "starts").unlink()
|
||||
subprocess.run([str(launcher)], timeout=30, env={**env, "FRAMEPORT_NO_RESTART": "1"}, start_new_session=True)
|
||||
assert (tmp_path / "starts").read_text().count("started") == 1
|
||||
|
||||
|
||||
def test_foveation_settings_rewrite_only_launcher_exports(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
anchor = tmp_path / "anchors" / "test.game"
|
||||
anchor.mkdir(parents=True)
|
||||
base = tmp_path / "game"
|
||||
base.mkdir()
|
||||
launch = anchor / "launch.sh"
|
||||
launch.write_text("#!/bin/bash\nexport UNRELATED='keep me'\nexport FDM_DEBUG=old\n"
|
||||
"export FOVE_INNER=0.1\nrun_game\n")
|
||||
a.ANCHORS = str(anchor.parent)
|
||||
dep = {"base": str(base)}
|
||||
writes = []
|
||||
monkeypatch.setattr(a, "deployment", lambda pkg: dep)
|
||||
monkeypatch.setattr(a, "write_deployment", lambda pkg, record: writes.append(copy_record(record)))
|
||||
monkeypatch.setattr(a, "install_controller_models", lambda *args: {})
|
||||
monkeypatch.setattr(a, "sync_game_config", lambda *args: {})
|
||||
monkeypatch.setattr(a, "sync_refresh_rate", lambda *args: {})
|
||||
env = {"FDM_DEBUG": "enable,disable_offsets", "FOVE_INNER": None,
|
||||
"ENABLE_VULKAN_FDM_INJECTION_LAYER": "1"}
|
||||
result = a.cmd_set_settings({"package": "test.game", "settings": {"scale": 1.2}, "foveation_env": env})
|
||||
text = launch.read_text()
|
||||
assert "export FDM_DEBUG=enable,disable_offsets" in text and "FOVE_INNER=" not in text
|
||||
assert "export UNRELATED='keep me'\n" in text and text.endswith("run_game\n")
|
||||
assert result["foveation_env"] == env and writes[-1]["foveation_env"] == env
|
||||
assert (base / "settings.conf").read_text() == "scale=1.2\n"
|
||||
assert not list(anchor.glob("*.tmp"))
|
||||
with pytest.raises(a.AgentError):
|
||||
a.cmd_set_settings({"package": "test.game", "foveation_env": {"PATH": "bad"}})
|
||||
assert launch.read_text() == text
|
||||
|
||||
|
||||
def copy_record(record):
|
||||
return json.loads(json.dumps(record))
|
||||
|
||||
|
||||
def test_local_frameport_hold_expires(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.delenv("XDG_DATA_HOME", raising=False)
|
||||
monkeypatch.setattr(a, "game_running", lambda: False)
|
||||
monkeypatch.setattr(a, "desktop_mode", lambda: False)
|
||||
assert a.restart_hold() is None
|
||||
heartbeat = tmp_path / ".local/share/frameport-app/gui.alive"
|
||||
heartbeat.parent.mkdir(parents=True)
|
||||
heartbeat.touch()
|
||||
assert a.restart_hold() == "frameport"
|
||||
os.utime(heartbeat, (a.time.time() - 11, a.time.time() - 11))
|
||||
assert a.restart_hold() is None
|
||||
monkeypatch.setattr(a, "game_running", lambda: True)
|
||||
assert a.restart_hold() == "game"
|
||||
|
||||
|
||||
def test_library_later_survives_steam_exit_and_next_start(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
pkg = "com.x.y"
|
||||
anchor = Path(a.ANCHORS) / pkg
|
||||
anchor.mkdir(parents=True)
|
||||
(anchor / "deployment.json").write_text(json.dumps({"title": "X", "appid": 123, "base": str(anchor)}))
|
||||
cfg = Path(a.STEAM) / "userdata/42/config"
|
||||
cfg.mkdir(parents=True)
|
||||
monkeypatch.setattr(a, "library_users", lambda: ["42"])
|
||||
monkeypatch.setattr(a, "run", lambda *args, **kw: SimpleNamespace(returncode=0, stdout="", stderr=""))
|
||||
monkeypatch.setattr(a, "stop_steam", lambda: pytest.fail("later stopped Steam"))
|
||||
monkeypatch.setattr(a, "start_steam", lambda _: pytest.fail("later started Steam"))
|
||||
a.cmd_library_mode({"mode": "later"})
|
||||
assert a.cmd_shortcuts({"packages": [pkg]})["state"] == "pending"
|
||||
hook = tmp_path / ".config/systemd/user/steam.service.d/frameport-library.conf"
|
||||
assert "ExecStartPre=" in hook.read_text() and "ExecStartPost=" in hook.read_text()
|
||||
vdf = cfg / "shortcuts.vdf"
|
||||
assert pkg in a.pending_library()["packages"]
|
||||
vdf.write_bytes(a.vdf_encode({"shortcuts": {}})) # Steam saved its old in-memory copy at exit
|
||||
a.cmd_library_pending({})
|
||||
assert any(e["appname"] == "X" for e in a.vdf_decode(vdf.read_bytes())["shortcuts"].values())
|
||||
monkeypatch.setattr(a.time, "sleep", lambda _: None)
|
||||
a.cmd_library_pending_verify({})
|
||||
assert not a.pending_library()["packages"]
|
||||
|
||||
|
||||
def test_pending_retained_when_live_repair_fails(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
a._write_json(a.PENDING_FILE, {"packages": ["com.x.y"], "remove": []})
|
||||
monkeypatch.setattr(a, "deployment", lambda _: {"title": "X"})
|
||||
monkeypatch.setattr(a, "library_users", lambda: ["42"])
|
||||
monkeypatch.setattr(a, "shortcuts_lost", lambda *args: ["com.x.y"])
|
||||
monkeypatch.setattr(a, "devkit_register", lambda _: (_ for _ in ()).throw(a.AgentError("test")))
|
||||
assert a.cmd_library_pending_verify({})["errors"]
|
||||
assert a.pending_library()["packages"] == ["com.x.y"]
|
||||
|
||||
|
||||
def test_off_skips_shortcuts_and_play_uses_direct_launcher(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
a.cmd_library_mode({"mode": "off"})
|
||||
assert a.library_mode() == "off"
|
||||
assert a.cmd_shortcuts({"packages": ["com.x.y"]})["disabled"]
|
||||
calls = []
|
||||
monkeypatch.setattr(a, "deployment", lambda _: {"title": "X", "appid": 123})
|
||||
monkeypatch.setattr(a, "game_is_running", lambda _: False)
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **kw: calls.append(cmd)
|
||||
or SimpleNamespace(returncode=3 if "is-active" in cmd else 0, stderr=""))
|
||||
monkeypatch.setattr(a, "devkit_register", lambda _: pytest.fail("off registered a shortcut"))
|
||||
result = a.cmd_launch({"package": "com.x.y"})
|
||||
assert result["via"] == "direct"
|
||||
assert calls[-1][0] == "systemd-run" and calls[-1][-1].endswith("com.x.y/launch.sh")
|
||||
assert not any("rungameid" in str(c) for c in calls)
|
||||
|
||||
|
||||
def test_manual_restart_requires_game_confirmation(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "game_running", lambda: True)
|
||||
assert a.cmd_restart_steam({}) == {"confirmation_required": True}
|
||||
monkeypatch.setattr(a, "cmd_shortcuts", lambda args: args)
|
||||
assert a.cmd_restart_steam({"confirm": True})["restart_now"]
|
||||
|
||||
|
||||
def test_startup_hook_does_not_deadlock_active_worker(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
Path(a.STATUS_FILE).parent.mkdir(parents=True)
|
||||
with open(a.STATUS_FILE + ".lock", "w") as lock:
|
||||
a.fcntl.flock(lock, a.fcntl.LOCK_EX)
|
||||
assert a.cmd_library_pending({})["worker_active"]
|
||||
assert a.cmd_library_pending_verify({})["worker_active"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hold", [None, "frameport"])
|
||||
def test_shortcut_worker_holds_local_gui_until_closed(monkeypatch, tmp_path, hold):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
clock = [100.0]
|
||||
events = []
|
||||
monkeypatch.setattr(a.time, "time", lambda: clock[0])
|
||||
def sleep(seconds):
|
||||
status = a.cmd_shortcut_status({})
|
||||
events.append(status.get("reason"))
|
||||
clock[0] += seconds
|
||||
monkeypatch.setattr(a, "restart_hold", lambda: None)
|
||||
monkeypatch.setattr(a.time, "sleep", sleep)
|
||||
monkeypatch.setattr(a, "refresh_desktop_entries", lambda: None)
|
||||
monkeypatch.setattr(a, "library_users", lambda: [])
|
||||
monkeypatch.setattr(a, "steam_users", lambda: [])
|
||||
monkeypatch.setattr(a, "library_changes", lambda *args: True)
|
||||
monkeypatch.setattr(a, "restart_hold", lambda: hold)
|
||||
monkeypatch.setattr(a, "stop_steam", lambda: events.append("stop") or True)
|
||||
monkeypatch.setattr(a, "start_steam", lambda _: events.append("start"))
|
||||
monkeypatch.setattr(a, "shortcuts_lost", lambda *args: [])
|
||||
a.shortcuts_worker(json.dumps({"packages": [], "remove": []}))
|
||||
assert events == (["frameport"] if hold else []) + ["stop", "start"]
|
||||
assert a.cmd_shortcut_status({})["state"] == "done"
|
||||
|
||||
|
||||
def test_pending_later_play_never_registers_shortcut(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
a._write_json(a.PENDING_FILE, {"packages": ["com.x.y"], "remove": []})
|
||||
monkeypatch.setattr(a, "deployment", lambda _: {"appid": 123, "title": "X"})
|
||||
monkeypatch.setattr(a, "game_is_running", lambda _: False)
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **kw: SimpleNamespace(returncode=3 if "is-active" in cmd else 0,
|
||||
stderr=""))
|
||||
monkeypatch.setattr(a, "devkit_register", lambda _: pytest.fail("pending Play added a devkit shortcut"))
|
||||
assert a.cmd_launch({"package": "com.x.y", "mode": "later"})["via"] == "direct"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["restart", "later", "off", "cap"])
|
||||
def test_waiting_worker_can_be_released_without_deadlock(monkeypatch, tmp_path, action):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
clock = [100.0]
|
||||
stopped = []
|
||||
monkeypatch.setattr(a.time, "time", lambda: clock[0])
|
||||
monkeypatch.setattr(a, "refresh_desktop_entries", lambda: None)
|
||||
monkeypatch.setattr(a, "library_users", lambda: [])
|
||||
monkeypatch.setattr(a, "steam_users", lambda: [])
|
||||
monkeypatch.setattr(a, "library_changes", lambda *args: True)
|
||||
monkeypatch.setattr(a, "restart_hold", lambda: "frameport")
|
||||
monkeypatch.setattr(a, "game_running", lambda: False)
|
||||
monkeypatch.setattr(a, "ensure_library_hooks", lambda: None)
|
||||
monkeypatch.setattr(a, "stop_steam", lambda: stopped.append(True) or True)
|
||||
monkeypatch.setattr(a, "start_steam", lambda _: None)
|
||||
monkeypatch.setattr(a, "shortcuts_lost", lambda *args: [])
|
||||
def sleep(seconds):
|
||||
if action == "restart":
|
||||
assert a.cmd_restart_steam({})["waiting_worker"]
|
||||
elif action in ("later", "off"):
|
||||
a._write_json(a.LIBRARY_MODE_FILE, {"mode": action})
|
||||
clock[0] += 12 * 3600 if action == "cap" else seconds
|
||||
monkeypatch.setattr(a.time, "sleep", sleep)
|
||||
a.shortcuts_worker(json.dumps({"packages": [], "remove": []}))
|
||||
assert bool(stopped) == (action in ("restart", "cap"))
|
||||
status = a.cmd_shortcut_status({})
|
||||
assert status["state"] == ("pending" if action == "later" else "done")
|
||||
|
||||
|
||||
def test_manual_restart_worker_wakes_existing_lock_holder(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
Path(a.STATUS_FILE).parent.mkdir(parents=True)
|
||||
with open(a.STATUS_FILE + ".lock", "w") as lock:
|
||||
a.fcntl.flock(lock, a.fcntl.LOCK_EX)
|
||||
a.shortcuts_worker(json.dumps({"restart_now": True, "packages": []}))
|
||||
assert json.load(open(a.RESTART_REQUEST_FILE))["requested"] > 0
|
||||
|
||||
|
||||
def test_library_hook_is_bounded_and_never_fails_steam(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
text = a.library_hooks_text(python="/usr/bin/python3", agent="/home/u/100% $HOME/frameport_agent.py",
|
||||
timeout_bin="/usr/bin/timeout")
|
||||
lines = [line for line in text.splitlines() if line.startswith("Exec")]
|
||||
assert len(lines) == 2
|
||||
pre, post = lines
|
||||
# '-' first (a non-zero exit, also 127 for a missing interpreter, never fails Steam), then timeout(1)
|
||||
assert pre.startswith("ExecStartPre=-/usr/bin/timeout -k 5 20 ") and pre.endswith(" library_pending")
|
||||
assert post.startswith("ExecStartPost=-/usr/bin/timeout -k 5 20 /usr/bin/systemd-run --user ")
|
||||
assert "--no-block" in post and "RuntimeMaxSec=" in post and post.endswith(" library_pending_verify")
|
||||
assert '"/home/u/100%% $$HOME/frameport_agent.py"' in pre # systemd specifiers/variables escaped
|
||||
assert "[Service]" in text
|
||||
assert "timeout" not in a.library_hooks_text(timeout_bin="") # internal alarm then
|
||||
|
||||
|
||||
def test_library_hook_written_once_and_removed_when_idle(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
calls = []
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **kw: calls.append(cmd) or SimpleNamespace(returncode=0))
|
||||
hook = tmp_path / ".config/systemd/user/steam.service.d/frameport-library.conf"
|
||||
a.cmd_library_mode({"mode": "later"})
|
||||
a.ensure_library_hooks()
|
||||
a.ensure_library_hooks()
|
||||
assert hook.exists()
|
||||
assert calls.count(["systemctl", "--user", "daemon-reload"]) == 1 # unchanged drop-in: no reload
|
||||
# back to "now" with a game still waiting for Steam's start: the hook stays until it is applied
|
||||
a._write_json(a.PENDING_FILE, {"packages": ["com.x.y"], "remove": []})
|
||||
a.cmd_library_mode({"mode": "now"})
|
||||
assert hook.exists()
|
||||
monkeypatch.setattr(a, "deployment", lambda _: {"title": "X"})
|
||||
monkeypatch.setattr(a, "library_users", lambda: ["42"])
|
||||
monkeypatch.setattr(a, "shortcuts_lost", lambda *args: [])
|
||||
a.cmd_library_pending_verify({})
|
||||
assert not hook.exists() and not hook.parent.exists()
|
||||
assert calls.count(["systemctl", "--user", "daemon-reload"]) == 2
|
||||
# nothing pending: switching away from "later" removes it at once, the user's own drop-ins stay
|
||||
a.cmd_library_mode({"mode": "later"})
|
||||
a.ensure_library_hooks()
|
||||
(hook.parent / "extra_args.conf").write_text("[Service]\n")
|
||||
a.cmd_library_mode({"mode": "off"})
|
||||
assert not hook.exists() and (hook.parent / "extra_args.conf").exists()
|
||||
|
||||
|
||||
def test_library_pre_hook_leaves_vdf_alone_when_nothing_waits(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "library_users", lambda: pytest.fail("touched the library"))
|
||||
monkeypatch.setattr(a, "steam_users", lambda: pytest.fail("touched the library"))
|
||||
assert a.cmd_library_pending({}) == {"added": [], "errors": []}
|
||||
Path(a.PENDING_FILE).write_text("[]") # damaged journal: treated as empty, never a crash in Steam's start
|
||||
assert a.cmd_library_pending({}) == {"added": [], "errors": []}
|
||||
|
||||
|
||||
def test_off_play_refuses_while_launcher_unit_starts(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
a.cmd_library_mode({"mode": "off"})
|
||||
monkeypatch.setattr(a, "deployment", lambda _: {"title": "X", "appid": 123})
|
||||
monkeypatch.setattr(a, "game_is_running", lambda _: False)
|
||||
calls = []
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **kw: calls.append(cmd) or SimpleNamespace(returncode=0, stderr=""))
|
||||
with pytest.raises(a.AgentError):
|
||||
a.cmd_launch({"package": "com.x.y"})
|
||||
assert not any(c[0] == "systemd-run" for c in calls)
|
||||
@@ -34,6 +34,9 @@ def net(monkeypatch):
|
||||
term = url.split("/search/")[1].split("?")[0]
|
||||
rows = db["rift"] if "headsets=RIFT" in url else db["all"]
|
||||
return [r for r in rows if r.get("_term", "").lower() in term.lower().replace("%20", " ")]
|
||||
if "appdetails" in url:
|
||||
appid = url.split("appids=")[1].split("&")[0]
|
||||
return {appid: {"success": True, "data": {"steam_appid": int(appid)}}}
|
||||
if "storesearch" in url:
|
||||
return {"items": db["steam"]}
|
||||
return fallback
|
||||
|
||||
@@ -19,3 +19,41 @@ def test_errors_are_one_line_on_stderr(monkeypatch, capsys):
|
||||
monkeypatch.setenv("FRAMEPORT_DEBUG", "1")
|
||||
with pytest.raises(NoValidConnectionsError):
|
||||
cli.main()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["now", "later", "off"])
|
||||
def test_steam_library_setting_cli(monkeypatch, mode):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport import cli
|
||||
from frameport.core import library
|
||||
|
||||
calls = []
|
||||
frame = SimpleNamespace(agent=lambda command, **kw: calls.append((command, kw)) or {})
|
||||
monkeypatch.setattr(cli, "_target", lambda _: SimpleNamespace(connect=lambda: SimpleNamespace(frame=frame)))
|
||||
result = CliRunner().invoke(cli.app, ["frame", "steam-library", mode])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert library.setting("install.steam_library") == mode
|
||||
assert calls == [("library_mode", {"mode": mode})]
|
||||
|
||||
|
||||
def test_restart_steam_cli_confirmation(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport import cli
|
||||
|
||||
calls = []
|
||||
def agent(command, **kw):
|
||||
calls.append(kw)
|
||||
return {} if kw.get("confirm") else {"confirmation_required": True}
|
||||
frame = SimpleNamespace(agent=agent)
|
||||
monkeypatch.setattr(cli, "_target", lambda _: SimpleNamespace(connect=lambda: SimpleNamespace(frame=frame)))
|
||||
result = CliRunner().invoke(cli.app, ["frame", "restart-steam"], input="n\n")
|
||||
assert result.exit_code != 0 and calls == [{"confirm": False}]
|
||||
calls.clear()
|
||||
result = CliRunner().invoke(cli.app, ["frame", "restart-steam"], input="y\n")
|
||||
assert result.exit_code == 0 and calls == [{"confirm": False}, {"confirm": True}]
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Quest button preferences: inheritance, deployment payloads and generated mapping assets."""
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import flet as ft
|
||||
import pytest
|
||||
from test_game_update import game_and_dep, installed_agent
|
||||
from test_patches import _analysis
|
||||
|
||||
from frameport import pipeline
|
||||
from frameport.core import controllers, library, paths
|
||||
from frameport.core.events import Cancelled, Reporter
|
||||
from frameport.core.models import Recipe
|
||||
from frameport.game_update import configuration, configuration_fingerprint, plan_update
|
||||
from frameport.install.installer import configuration_args, install_context
|
||||
from frameport.patches import base
|
||||
from frameport.patches.frame.adapter import settings_text
|
||||
from frameport.patches.settings import SETTINGS, UI, XRSHIM_SETTINGS, adapter_settings
|
||||
from frameport.ui.views import adapter_dialog as ad
|
||||
from frameport.ui.views.settings import SettingsView
|
||||
|
||||
|
||||
def store(patches=None):
|
||||
recipe = Recipe("test.quest", patches=patches or {"frame.adapter": {}})
|
||||
library.upsert_game(recipe.package, title="Quest", recipe=library.recipe_to_dict(recipe))
|
||||
return library.game(recipe.package)
|
||||
|
||||
|
||||
def walk(control):
|
||||
yield control
|
||||
for child in getattr(control, "controls", []) or []:
|
||||
yield from walk(child)
|
||||
for attr in ("content", "title"):
|
||||
child = getattr(control, attr, None)
|
||||
if child is not None and not isinstance(child, str):
|
||||
yield from walk(child)
|
||||
for child in getattr(control, "actions", []) or []:
|
||||
yield from walk(child)
|
||||
|
||||
|
||||
def test_setting_metadata_and_no_suggestion():
|
||||
spec = next(s for s in SETTINGS if s[0] == "quest_buttons")
|
||||
assert spec[1:3] == ("int", 0)
|
||||
assert "docs/CONTROLLERS.md" in spec[4]
|
||||
assert UI["quest_buttons"]["control"] == ("choice", list(controllers.CHOICES))
|
||||
assert UI["quest_buttons"]["group"] == "controllers" and UI["quest_buttons"]["level"] == "common"
|
||||
patch = base.get(controllers.QUEST_BUTTONS)
|
||||
assert patch.detect(_analysis()) is None
|
||||
assert patch.a32 and "quest_buttons" not in XRSHIM_SETTINGS
|
||||
assert patch.applies(_analysis(abis=["armeabi-v7a"]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("global_mode", [0, 1, 2])
|
||||
@pytest.mark.parametrize("override", [None, 0, 1, 2])
|
||||
def test_effective_value_reaches_every_runtime_payload(global_mode, override):
|
||||
library.set_setting("input.quest_buttons", global_mode)
|
||||
patches = {controllers.QUEST_BUTTONS: {"value": override}} if override is not None else {}
|
||||
game = store(patches or {"frame.adapter": {}})
|
||||
recipe = library.recipe_from_dict(game["recipe"])
|
||||
expected = global_mode if override is None else override
|
||||
assert controllers.effective_adapter_settings(recipe.patches)["quest_buttons"] == expected
|
||||
assert install_context(recipe).adapter_settings["quest_buttons"] == expected
|
||||
args = configuration_args(game, recipe)
|
||||
assert args["settings"]["quest_buttons"] == expected
|
||||
assert f"quest_buttons={expected}\n".encode() in settings_text(recipe.patches)
|
||||
assert ad.recipe_values(game)["quest_buttons"] == expected
|
||||
# Fingerprinting is a pure operation on the recipe, not the active library preference.
|
||||
assert args["configuration"]["settings"]["quest_buttons"] == (override or 0)
|
||||
|
||||
|
||||
def test_pure_defaults_and_global_changes_do_not_mark_games_outdated(monkeypatch):
|
||||
def forbidden(*args):
|
||||
raise AssertionError("pure settings helper performed I/O")
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(library, "setting", forbidden)
|
||||
assert adapter_settings({}, defaults={"quest_buttons": 2})["quest_buttons"] == 2
|
||||
assert adapter_settings({controllers.QUEST_BUTTONS: {"value": 0}},
|
||||
defaults={"quest_buttons": 2})["quest_buttons"] == 0
|
||||
game, dep = game_and_dep()
|
||||
before = base.recipe_fingerprint(game["recipe"])
|
||||
config = configuration_fingerprint(game["recipe"])
|
||||
for mode in (1, 2, 0):
|
||||
library.set_setting("input.quest_buttons", mode)
|
||||
assert base.recipe_fingerprint(game["recipe"]) == before
|
||||
assert configuration_fingerprint(game["recipe"]) == config
|
||||
assert not plan_update(game, dep).changed
|
||||
|
||||
|
||||
def test_saving_game_keeps_inheritance_and_explicit_zero():
|
||||
library.set_setting("input.quest_buttons", 2)
|
||||
game = store()
|
||||
ad.save_to_recipe(game["package"], ad.recipe_values(game))
|
||||
assert library.game(game["package"])["recipe"] == game["recipe"]
|
||||
values = ad.recipe_values(game)
|
||||
values["quest_buttons"] = 0
|
||||
ad.save_to_recipe(game["package"], values, inherit_quest_buttons=False)
|
||||
assert library.game(game["package"])["recipe"]["patches"][controllers.QUEST_BUTTONS] == {"value": 0}
|
||||
ad.save_to_recipe(game["package"], values, inherit_quest_buttons=True)
|
||||
assert controllers.QUEST_BUTTONS not in library.game(game["package"])["recipe"]["patches"]
|
||||
assert ad.recipe_values(library.game(game["package"]))["quest_buttons"] == 2
|
||||
|
||||
|
||||
def test_agent_live_default_write_does_not_mark_inherited_game_outdated(monkeypatch, tmp_path):
|
||||
game, _ = game_and_dep()
|
||||
package = game["package"]
|
||||
agent, _, record = installed_agent(monkeypatch, tmp_path)
|
||||
record.update(configuration=configuration(game["recipe"], package),
|
||||
configuration_fp=configuration_fingerprint(game["recipe"], package))
|
||||
agent.write_deployment(package, record)
|
||||
for mode in (1, 2, 0):
|
||||
agent.cmd_set_settings({"package": package, "settings": {"quest_buttons": mode}})
|
||||
deployed = agent.deployment(package)
|
||||
assert not plan_update(game, deployed).changed
|
||||
agent.cmd_set_settings({"package": package, "settings": {"quest_buttons": 1}})
|
||||
deployed = agent.deployment(package)
|
||||
# Explicit overrides still need to match, and unrelated runtime changes are never ignored.
|
||||
game["recipe"]["patches"][controllers.QUEST_BUTTONS] = {"value": 0}
|
||||
assert plan_update(game, deployed).configure
|
||||
game["recipe"]["patches"].pop(controllers.QUEST_BUTTONS)
|
||||
game["recipe"]["patches"]["adapter.scale"] = {"value": 1.8}
|
||||
assert plan_update(game, deployed).configure
|
||||
|
||||
|
||||
def test_dialog_global_option_and_diagram_follow_choice():
|
||||
library.set_setting("input.quest_buttons", 1)
|
||||
game = store()
|
||||
dialogs = []
|
||||
app = SimpleNamespace(page=SimpleNamespace(show_dialog=dialogs.append, pop_dialog=lambda: None),
|
||||
target=None, toast=lambda *a, **k: None, refresh_view=lambda: None,
|
||||
_title=lambda p: "Quest")
|
||||
ad._open(app, game["package"], game, ad.recipe_values(game))
|
||||
controls = list(walk(dialogs[-1]))
|
||||
choice = next(c for c in controls if isinstance(c, ft.Dropdown) and any(o.key == "global" for o in c.options))
|
||||
image = next(c for c in controls if isinstance(c, ft.Image) and "quest-buttons" in c.src)
|
||||
assert choice.value == "global" and "Mirror" in choice.options[0].text
|
||||
assert image.src.endswith("quest-buttons-1.svg")
|
||||
choice.on_select(SimpleNamespace(control=SimpleNamespace(value="0")))
|
||||
assert image.src.endswith("quest-buttons-0.svg")
|
||||
dialogs[-1].actions[-1].on_click(None)
|
||||
assert library.game(game["package"])["recipe"]["patches"][controllers.QUEST_BUTTONS] == {"value": 0}
|
||||
|
||||
|
||||
def test_dialog_reset_restores_global_value_without_pinning_it():
|
||||
library.set_setting("input.quest_buttons", 2)
|
||||
game = store({"frame.adapter": {}, controllers.QUEST_BUTTONS: {"value": 1}})
|
||||
dialogs = []
|
||||
app = SimpleNamespace(page=SimpleNamespace(show_dialog=dialogs.append, pop_dialog=lambda: None),
|
||||
target=None, toast=lambda *a, **k: None, refresh_view=lambda: None,
|
||||
_title=lambda p: "Quest")
|
||||
ad._open(app, game["package"], game, ad.recipe_values(game))
|
||||
dialogs[-1].actions[0].on_click(None)
|
||||
assert next(c for c in walk(dialogs[-1]) if isinstance(c, ft.Image) and "quest-buttons" in c.src
|
||||
).src.endswith("quest-buttons-2.svg")
|
||||
dialogs[-1].actions[-1].on_click(None)
|
||||
assert controllers.QUEST_BUTTONS not in library.game(game["package"])["recipe"]["patches"]
|
||||
|
||||
|
||||
class Target:
|
||||
def __init__(self, installed):
|
||||
self.games, self.calls = installed, []
|
||||
|
||||
def installed(self):
|
||||
return self.games
|
||||
|
||||
def set_settings(self, package, values):
|
||||
self.calls.append((package, values))
|
||||
|
||||
|
||||
def test_apply_default_skips_overrides_and_non_quest_games():
|
||||
library.set_setting("input.quest_buttons", 2)
|
||||
store({controllers.QUEST_BUTTONS: {"value": 0}})
|
||||
library.upsert_game("flat", analysis={"extra": {"vr_kind": "none"}})
|
||||
target = Target([{"package": "test.quest"}, {"package": "inherited"}, {"package": "flat"},
|
||||
{"package": "rift", "kind": "pcvr"}, {"package": "linux", "kind": "linux"},
|
||||
{"package": "remote-override", "recipe": {"patches": [controllers.QUEST_BUTTONS]}}])
|
||||
assert controllers.apply_default(target, Reporter()) == {"applied": [], "needs_update": ["inherited"]}
|
||||
assert target.calls == [("inherited", {"quest_buttons": 2})]
|
||||
reporter = Reporter()
|
||||
reporter.cancelled.set()
|
||||
with pytest.raises(Cancelled):
|
||||
controllers.apply_default(target, reporter)
|
||||
assert len(target.calls) == 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter_manifest(monkeypatch, tmp_path):
|
||||
manifest = tmp_path / "artifacts" / "SHA256SUMS"
|
||||
manifest.parent.mkdir()
|
||||
manifest.write_text("\n".join(f"{'a' * 64} ./{abi}/libopenxr_loader_generic.so"
|
||||
for abi in ("arm64-v8a", "armeabi-v7a")) + "\n")
|
||||
monkeypatch.setattr(paths, "artifacts_dir", lambda: manifest.parent)
|
||||
return manifest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("abi", ["arm64-v8a", "armeabi-v7a"])
|
||||
@pytest.mark.parametrize(("recorded", "installed_sha", "ready"), [
|
||||
("a" * 64, "apk", True),
|
||||
("a" * 64, "alternate", True),
|
||||
("b" * 64, "apk", False),
|
||||
(None, "apk", False),
|
||||
("a" * 64, "older-apk", False),
|
||||
("a" * 64, None, False),
|
||||
])
|
||||
def test_apply_reports_current_old_and_unknown_adapters(adapter_manifest, abi, recorded, installed_sha, ready):
|
||||
game = store()
|
||||
native = {f"{abi}/libopenxr_loader_generic.so": recorded, f"{abi}/unrelated.so": "old"}
|
||||
library.set_fields(game["package"], build={"sha256": "apk", "alt_sha256": "alternate",
|
||||
"build_inputs": {"native": native}})
|
||||
target = Target([{"package": game["package"], "sha256": installed_sha}])
|
||||
result = controllers.apply_default(target, Reporter())
|
||||
assert result == {"applied": [game["package"]] if ready else [],
|
||||
"needs_update": [] if ready else [game["package"]]}
|
||||
assert target.calls == [(game["package"], {"quest_buttons": 0})]
|
||||
|
||||
|
||||
def test_apply_does_not_trust_unknown_adapter_or_manifest(adapter_manifest):
|
||||
game = store()
|
||||
library.set_fields(game["package"], build={"sha256": "apk", "build_inputs": {"native": {}}})
|
||||
target = Target([{"package": game["package"], "sha256": "apk"}])
|
||||
assert controllers.apply_default(target, Reporter())["needs_update"] == [game["package"]]
|
||||
library.set_fields(game["package"], build={"sha256": "apk", "build_inputs": {"native": {
|
||||
"arm64-v8a/libopenxr_loader_generic.so": "a" * 64}}})
|
||||
adapter_manifest.unlink()
|
||||
assert controllers.apply_default(target, Reporter())["needs_update"] == [game["package"]]
|
||||
|
||||
|
||||
def test_live_suggestion_push_uses_inherited_value(monkeypatch):
|
||||
library.set_setting("input.quest_buttons", 2)
|
||||
monkeypatch.setattr(pipeline, "apply_suggestions", lambda *a: Recipe("test.quest"))
|
||||
target = Target([])
|
||||
assert pipeline.apply_suggestions_live("test.quest", ["adapter.scale=1"], target)
|
||||
assert target.calls[0][1]["quest_buttons"] == 2
|
||||
|
||||
|
||||
def test_settings_apply_uses_frame_job_queue_and_selected_default(adapter_manifest):
|
||||
game = store()
|
||||
library.set_fields(game["package"], build={"sha256": "apk", "build_inputs": {"native": {
|
||||
"arm64-v8a/libopenxr_loader_generic.so": "a" * 64}}})
|
||||
target, jobs = Target([{"package": game["package"], "sha256": "apk"}, {"package": "old"}]), []
|
||||
app = SimpleNamespace(frame_state="connected", target=target,
|
||||
submit=lambda *a, **k: jobs.append((a, k)), toast=lambda *a: None)
|
||||
controls = list(walk(SettingsView(app).controllers()))
|
||||
choice = next(c for c in controls if isinstance(c, ft.Dropdown))
|
||||
choice.on_select(SimpleNamespace(control=SimpleNamespace(value="1")))
|
||||
apply = next(c for c in controls if getattr(c, "content", None) == "Apply to installed games now")
|
||||
apply.on_click(None)
|
||||
args, kwargs = jobs[0]
|
||||
assert kwargs["kind"] == "tool-frame" and not target.calls
|
||||
message = args[1](SimpleNamespace(reporter=Reporter()))
|
||||
assert "Applied to 1 game." in message and "1 game needs Update on Frame first." in message
|
||||
assert target.calls == [(game["package"], {"quest_buttons": 1}), ("old", {"quest_buttons": 1})]
|
||||
|
||||
|
||||
def test_controller_section_reads_default_once_and_updates_preview(monkeypatch):
|
||||
reads = []
|
||||
def snapshot():
|
||||
reads.append(True)
|
||||
assert len(reads) == 1
|
||||
return 1
|
||||
monkeypatch.setattr(controllers, "quest_buttons_default", snapshot)
|
||||
controls = list(walk(SettingsView(SimpleNamespace(frame_state="none")).controllers()))
|
||||
choice = next(c for c in controls if isinstance(c, ft.Dropdown))
|
||||
preview = next(c for c in controls if isinstance(c, ft.Image))
|
||||
assert choice.value == "1" and preview.src.endswith("quest-buttons-1.svg")
|
||||
choice.on_select(SimpleNamespace(control=SimpleNamespace(value="2")))
|
||||
assert preview.src.endswith("quest-buttons-2.svg") and len(reads) == 1
|
||||
assert library.setting("input.quest_buttons") == 2
|
||||
|
||||
|
||||
def test_generated_controller_assets_are_current():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
subprocess.run([sys.executable, str(root / "scripts/controller_diagram.py"), "--check"], check=True)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""device.language (GitHub #354): the game's Android language, passed to Lepton as persist.sys.locale."""
|
||||
import pytest
|
||||
|
||||
from frameport.core.models import Recipe
|
||||
from frameport.install.installer import install_context
|
||||
from frameport.patches import base
|
||||
from frameport.patches.settings import LANGUAGE_TAG, Language
|
||||
|
||||
|
||||
def _env(patches):
|
||||
base.load_all()
|
||||
return install_context(Recipe(package="com.example.game", patches=patches)).env
|
||||
|
||||
|
||||
def test_language_rides_in_its_own_boot_property_line():
|
||||
env = _env({"device.language": {"tag": "de-DE"}, "device.hide_navbar": {}})
|
||||
assert env["LEPTON_GFXRECON_FP_LOCALE"] == "0\npersist.sys.locale=de-DE"
|
||||
assert env["LEPTON_GFXRECON_FP_PROPS"] == "0\nqemu.hw.mainkeys=1" # both can be on
|
||||
|
||||
|
||||
def test_default_changes_nothing():
|
||||
assert "LEPTON_GFXRECON_FP_LOCALE" not in _env({"device.language": {"tag": ""}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", ["de-DE\nro.debuggable=1", "de DE", "../x", "DE"])
|
||||
def test_only_language_tags_reach_the_boot_properties(tag):
|
||||
with pytest.raises(ValueError):
|
||||
_env({"device.language": {"tag": tag}})
|
||||
|
||||
|
||||
def test_choices_are_valid_tags():
|
||||
assert all(not k or LANGUAGE_TAG.match(k) for k, _label in Language.CHOICES)
|
||||
assert Language.CHOICES[0][0] == "" and len({k for k, _ in Language.CHOICES}) == len(Language.CHOICES)
|
||||
|
||||
|
||||
def test_language_dropdown_is_visible_and_default_removes_recipe_patch(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import flet as ft
|
||||
|
||||
from frameport.core import library
|
||||
from frameport.ui.views import game
|
||||
|
||||
base.load_all()
|
||||
analysis = library.analysis_from_dict({"package": "test.game", "engine": "Unreal"})
|
||||
# Use the real visibility filter: the language setting must be offered even while disabled.
|
||||
from frameport.recommend import engine
|
||||
patch = base.get("device.language")
|
||||
shown, _ = engine.visible_patches(analysis, Recipe("test.game"))
|
||||
assert patch in shown
|
||||
monkeypatch.setattr(game.engine, "visible_patches", lambda *a: ([patch], []))
|
||||
monkeypatch.setattr(library, "analysis_from_dict", lambda a: None)
|
||||
monkeypatch.setattr(library, "setting", lambda *a: False)
|
||||
monkeypatch.setattr(game.engine, "warnings", lambda *a: [])
|
||||
saved = []
|
||||
monkeypatch.setattr(game.pipeline, "set_recipe", lambda package, r: saved.append(library.recipe_to_dict(r)))
|
||||
view = SimpleNamespace(app=SimpleNamespace(has_game_settings=lambda g: False),
|
||||
g={"recipe": library.recipe_to_dict(Recipe("test.game")), "analysis": {}},
|
||||
package="test.game", show_all=False)
|
||||
panel = game.GameView.advanced_panel(view)
|
||||
|
||||
def walk(control):
|
||||
yield control
|
||||
for child in getattr(control, "controls", []) or []:
|
||||
yield from walk(child)
|
||||
if getattr(control, "content", None) is not None:
|
||||
yield from walk(control.content)
|
||||
|
||||
picker = next(c for c in walk(panel) if isinstance(c, ft.Dropdown))
|
||||
assert picker.value == ""
|
||||
assert any(o.key == "de-DE" for o in picker.options)
|
||||
picker.on_select(SimpleNamespace(control=SimpleNamespace(value="de-DE")))
|
||||
assert saved[-1]["patches"]["device.language"] == {"tag": "de-DE"}
|
||||
assert library.recipe_from_dict(saved[-1]).params("device.language") == {"tag": "de-DE"}
|
||||
picker.on_select(SimpleNamespace(control=SimpleNamespace(value="")))
|
||||
assert "device.language" not in saved[-1]["patches"]
|
||||
@@ -74,6 +74,7 @@ def test_drives_lists_internal_and_removable_drives(monkeypatch, tmp_path):
|
||||
assert sd["usable"] and sd["removable"] and sd["fstype"] == "ext4"
|
||||
assert sd["path"] == str(sd_dir(tmp_path)) and sd["install_dir"] == str(sd_dir(tmp_path) / "FramePort")
|
||||
assert not drives["STICK"]["usable"] and "exfat" in drives["STICK"]["reason"]
|
||||
assert drives["RO"]["read_only"] and not sd["read_only"]
|
||||
assert not drives["RO"]["usable"] and "read-only" in drives["RO"]["reason"]
|
||||
|
||||
|
||||
@@ -595,3 +596,10 @@ def test_upgrade_gives_old_launchers_the_link(monkeypatch, tmp_path):
|
||||
assert path.read_text() == fresh
|
||||
assert a.upgrade_launchers() == [] # nothing left to do
|
||||
assert subprocess.run(["bash", "-n", str(path)]).returncode == 0
|
||||
|
||||
|
||||
def test_read_only_exfat_reports_mount_flag(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
d = a._drive(str(sd_dir(tmp_path)), "/unused", ("/dev/fake", str(sd_dir(tmp_path)), "exfat", ["ro"]))
|
||||
assert d["read_only"] and not d["usable"]
|
||||
assert "exfat" in d["reason"]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Host regression for FrameBridge's colour-space compatibility implementation."""
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
HARNESS = r"""
|
||||
#include <openxr/openxr.h>
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
static int log_count;
|
||||
#define LOG(...) ((void)++log_count)
|
||||
#include "color_space.c"
|
||||
int main(void) {
|
||||
assert(!color_space_emulation("xrSetColorSpaceFB"));
|
||||
emulate_color_space = 1;
|
||||
PFN_xrEnumerateColorSpacesFB enumerate =
|
||||
(PFN_xrEnumerateColorSpacesFB)color_space_emulation("xrEnumerateColorSpacesFB");
|
||||
PFN_xrSetColorSpaceFB set = (PFN_xrSetColorSpaceFB)color_space_emulation("xrSetColorSpaceFB");
|
||||
assert(enumerate && set && !color_space_emulation("xrOther"));
|
||||
uint32_t count = 0;
|
||||
XrColorSpaceFB spaces[9];
|
||||
spaces[0] = XR_COLOR_SPACE_MAX_ENUM_FB;
|
||||
spaces[8] = XR_COLOR_SPACE_MAX_ENUM_FB;
|
||||
assert(enumerate(0, 0, NULL, NULL) == XR_ERROR_VALIDATION_FAILURE);
|
||||
assert(enumerate(0, 0, &count, NULL) == XR_SUCCESS && count == 8);
|
||||
assert(enumerate(0, 8, &count, NULL) == XR_ERROR_VALIDATION_FAILURE);
|
||||
assert(enumerate(0, 7, &count, spaces) == XR_ERROR_SIZE_INSUFFICIENT && count == 8);
|
||||
assert(spaces[0] == XR_COLOR_SPACE_MAX_ENUM_FB);
|
||||
assert(enumerate(0, 9, &count, spaces) == XR_SUCCESS && count == 8);
|
||||
assert(spaces[8] == XR_COLOR_SPACE_MAX_ENUM_FB);
|
||||
const XrColorSpaceFB expected[] = {
|
||||
XR_COLOR_SPACE_UNMANAGED_FB, XR_COLOR_SPACE_REC2020_FB, XR_COLOR_SPACE_REC709_FB,
|
||||
XR_COLOR_SPACE_RIFT_CV1_FB, XR_COLOR_SPACE_RIFT_S_FB, XR_COLOR_SPACE_QUEST_FB,
|
||||
XR_COLOR_SPACE_P3_FB, XR_COLOR_SPACE_ADOBE_RGB_FB
|
||||
};
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
assert(spaces[i] == expected[i]);
|
||||
assert(set(0, spaces[i]) == XR_SUCCESS);
|
||||
}
|
||||
assert(log_count == 1);
|
||||
assert(set(0, (XrColorSpaceFB)-1) == XR_ERROR_COLOR_SPACE_UNSUPPORTED_FB);
|
||||
assert(set(0, XR_COLOR_SPACE_MAX_ENUM_FB) == XR_ERROR_COLOR_SPACE_UNSUPPORTED_FB);
|
||||
XrSystemColorSpacePropertiesFB color = {XR_TYPE_SYSTEM_COLOR_SPACE_PROPERTIES_FB};
|
||||
XrBaseOutStructure unknown = {XR_TYPE_UNKNOWN, (XrBaseOutStructure *)&color};
|
||||
XrSystemProperties properties = {XR_TYPE_SYSTEM_PROPERTIES, &unknown};
|
||||
color_space_properties(&properties);
|
||||
assert(color.colorSpace == XR_COLOR_SPACE_REC709_FB);
|
||||
assert(unknown.next == (XrBaseOutStructure *)&color && color.next == NULL);
|
||||
emulate_color_space = 0;
|
||||
color.colorSpace = XR_COLOR_SPACE_P3_FB;
|
||||
color_space_properties(&properties);
|
||||
color_space_properties(NULL);
|
||||
assert(color.colorSpace == XR_COLOR_SPACE_P3_FB);
|
||||
assert(!color_space_emulation("xrEnumerateColorSpacesFB"));
|
||||
return 0;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def test_color_space_host(tmp_path):
|
||||
cc = shutil.which("cc") or shutil.which("clang")
|
||||
inc = next((ROOT / "native/.cache").glob("openxr-*"), None)
|
||||
if not cc or not inc:
|
||||
pytest.skip("needs host C compiler and cached OpenXR headers (native/build.py)")
|
||||
source = tmp_path / "color_space_test.c"
|
||||
source.write_text(HARNESS)
|
||||
exe = tmp_path / "color_space_test"
|
||||
subprocess.run([cc, "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-missing-field-initializers", "-I", str(inc),
|
||||
"-I", str(ROOT / "native/adapter"), str(source), "-o", str(exe)], check=True)
|
||||
subprocess.run([str(exe)], check=True)
|
||||
|
||||
|
||||
def test_color_space_artifacts():
|
||||
for abi in ("arm64-v8a", "armeabi-v7a"):
|
||||
data = (ROOT / "artifacts" / abi / "libopenxr_loader_generic.so").read_bytes()
|
||||
for marker in (b"XR_FB_color_space", b"color_space:", b"xrEnumerateColorSpacesFB", b"xrSetColorSpaceFB"):
|
||||
assert marker in data
|
||||
|
||||
|
||||
def test_color_space_hooks():
|
||||
source = (ROOT / "native/adapter/frame_adapter.c").read_text()
|
||||
assert 'XR_FB_COLOR_SPACE_EXTENSION_NAME, 3}' in source
|
||||
assert 'emulate_color_space = fb_color_space && XR_SUCCEEDED(result) && requested_color_space' in source
|
||||
assert 'if (fb_color_space && !has_color_space)' in source # only offered when the setting is on
|
||||
assert 'if (XR_SUCCEEDED(result)) color_space_properties(properties)' in source
|
||||
assert 'if (!emulated) emulated = color_space_emulation(name)' in source
|
||||
assert 'name ? color_space_emulation(name) : NULL' in source
|
||||
|
||||
|
||||
def test_extension_enumeration_host(tmp_path):
|
||||
"""Compile the actual adapter enumeration hook against a runtime with/without the extension."""
|
||||
cc = shutil.which("cc") or shutil.which("clang")
|
||||
inc = next((ROOT / "native/.cache").glob("openxr-*"), None)
|
||||
if not cc or not inc:
|
||||
pytest.skip("needs host C compiler and cached OpenXR headers")
|
||||
adapter = (ROOT / "native/adapter/frame_adapter.c").read_text()
|
||||
hook = adapter.split("XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateInstanceExtensionProperties(", 1)[1]
|
||||
hook = "XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateInstanceExtensionProperties(" + hook.split(
|
||||
"XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateApiLayerProperties(", 1)[0]
|
||||
source = tmp_path / "enumeration.c"
|
||||
source.write_text(r"""
|
||||
#include <openxr/openxr.h>
|
||||
#include <assert.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
static int native_color, fail;
|
||||
static XrResult runtime_enum(const char *layer, uint32_t cap, uint32_t *count, XrExtensionProperties *out) {
|
||||
if (layer) return XR_ERROR_API_LAYER_NOT_PRESENT;
|
||||
if (fail) return XR_ERROR_RUNTIME_FAILURE;
|
||||
*count = native_color;
|
||||
if (cap && native_color) {
|
||||
strcpy(out[0].extensionName, XR_FB_COLOR_SPACE_EXTENSION_NAME);
|
||||
out[0].extensionVersion = 42;
|
||||
}
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static PFN_xrVoidFunction lookup(XrInstance instance, const char *name) {
|
||||
(void)instance; (void)name; return (PFN_xrVoidFunction)runtime_enum;
|
||||
}
|
||||
static int runtime_has_fb_passthrough, runtime_has_render_model;
|
||||
static int hide_fb_foveation, foveation_fix, hide_space_warp, scene_emul, passthrough_emul;
|
||||
static int controller_models, render_models_available;
|
||||
static int fb_color_space = 1; // the setting is off by default; on here
|
||||
#define SCENE_EXTENSION_COUNT 1
|
||||
static const char *scene_extensions[] = {"XR_FAKE_scene"};
|
||||
static int fb_is_foveation_extension(const char *name) { (void)name; return 0; }
|
||||
""" + hook + r"""
|
||||
int main(void) {
|
||||
uint32_t count;
|
||||
XrExtensionProperties out[2] = {{XR_TYPE_EXTENSION_PROPERTIES}, {XR_TYPE_EXTENSION_PROPERTIES}};
|
||||
assert(xrEnumerateInstanceExtensionProperties(0, 0, &count, 0) == XR_SUCCESS && count == 1);
|
||||
assert(xrEnumerateInstanceExtensionProperties(0, 1, &count, out) == XR_SUCCESS && count == 1);
|
||||
assert(!strcmp(out[0].extensionName, XR_FB_COLOR_SPACE_EXTENSION_NAME) && out[0].extensionVersion == 3);
|
||||
assert(xrEnumerateInstanceExtensionProperties(0, 1, &count, 0) == XR_ERROR_VALIDATION_FAILURE);
|
||||
assert(xrEnumerateInstanceExtensionProperties(0, 0, 0, 0) == XR_ERROR_VALIDATION_FAILURE);
|
||||
assert(xrEnumerateInstanceExtensionProperties("layer", 0, &count, 0) == XR_ERROR_API_LAYER_NOT_PRESENT);
|
||||
passthrough_emul = 1;
|
||||
assert(xrEnumerateInstanceExtensionProperties(0, 1, &count, out) == XR_ERROR_SIZE_INSUFFICIENT && count == 2);
|
||||
passthrough_emul = 0;
|
||||
native_color = 1;
|
||||
assert(xrEnumerateInstanceExtensionProperties(0, 2, &count, out) == XR_SUCCESS && count == 1);
|
||||
assert(out[0].extensionVersion == 42);
|
||||
fail = 1;
|
||||
assert(xrEnumerateInstanceExtensionProperties(0, 0, &count, 0) == XR_ERROR_RUNTIME_FAILURE);
|
||||
return 0;
|
||||
}
|
||||
""")
|
||||
exe = tmp_path / "enumeration"
|
||||
subprocess.run([cc, "-std=c11", "-Wall", "-Wextra", "-Werror", "-Wno-missing-field-initializers",
|
||||
"-I", str(inc), str(source), "-o", str(exe)], check=True)
|
||||
subprocess.run([str(exe)], check=True)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Files location discovery and capabilities, without a device or GUI."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.install import files
|
||||
from frameport.ui.views.files import FilesView, drive_locations, game_location
|
||||
|
||||
|
||||
def test_drive_locations():
|
||||
assert drive_locations([]) == []
|
||||
locs = drive_locations([
|
||||
{"path": "/home/x", "internal": True},
|
||||
{"path": "/media/SD", "label": "SD card", "free_bytes": 123},
|
||||
{"path": "/media/USB", "read_only": True, "usable": False},
|
||||
{"path": "/games", "reason": "mounted read-only"},
|
||||
{"path": "/fat", "usable": False, "reason": "exfat can't hold game data"},
|
||||
{},
|
||||
])
|
||||
assert [x["path"] for x in locs] == ["/media/SD", "/media/USB", "/games", "/fat"]
|
||||
assert locs[0]["label"] == "SD card" and locs[0]["free_bytes"] == 123
|
||||
assert [x["read_only"] for x in locs] == [False, True, True, False]
|
||||
for loc in locs:
|
||||
assert files.inside(loc["path"], "mods") == loc["path"] + "/mods"
|
||||
with pytest.raises(ValueError):
|
||||
files.inside(loc["path"], "../outside")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["pcvr", "rift", "linux"])
|
||||
def test_game_install_locations(kind):
|
||||
dep = {"package": "game", "kind": kind}
|
||||
loc = game_location(dep, "Game", "/home/x")
|
||||
assert loc["path"] == "/home/x/Applications/quest-frame/game"
|
||||
assert not loc["android"]
|
||||
dep["drive"] = {"path": "/media/SD", "internal": False}
|
||||
assert game_location(dep, "Game", "/home/x")["path"] == "/media/SD/FramePort/game"
|
||||
dep["base"] = "/custom/deployed/game"
|
||||
loc = game_location(dep, "Game", "/home/x")
|
||||
assert loc["path"] == dep["base"]
|
||||
with pytest.raises(ValueError):
|
||||
files.inside(loc["path"], "/custom/deployed/other")
|
||||
assert "path" not in game_location({"package": "q", "kind": "quest"}, "Quest", "/home/x")
|
||||
|
||||
|
||||
def test_background_discovery_cached_and_game_open_uses_base(monkeypatch):
|
||||
calls = []
|
||||
frame = SimpleNamespace(home="/home/x")
|
||||
app = SimpleNamespace(target=SimpleNamespace(frame=frame, drives=lambda: calls.append("drives") or [
|
||||
{"path": "/media/SD", "read_only": True}]), frame_info={"installed": [
|
||||
{"package": "linux", "kind": "linux", "base": "/media/SD/FramePort/linux"}]},
|
||||
run_bg=lambda fn, *args: fn(*args))
|
||||
v = FilesView(app)
|
||||
monkeypatch.setattr(v, "_render_locations", lambda: None)
|
||||
monkeypatch.setattr(v, "load", lambda: None)
|
||||
monkeypatch.setattr(files, "storage_targets", lambda f, *a: calls.append("storage") or [
|
||||
{"id": "videos", "path": "/home/x/Videos", "android": "/sdcard/Movies"}])
|
||||
v._load_locations("linux")
|
||||
assert v.loc["path"] == "/media/SD/FramePort/linux"
|
||||
assert v.loc["read_only"] and all(c.disabled for c in v.toolbar.controls[:3])
|
||||
v._load_locations(None)
|
||||
assert calls == ["storage", "drives"]
|
||||
with pytest.raises(ValueError):
|
||||
v.cd("../other")
|
||||
|
||||
|
||||
def test_read_only_actions_and_quest_link_protection():
|
||||
v = FilesView(SimpleNamespace())
|
||||
v.loc = {"id": "drive:/sd", "path": "/sd", "read_only": True}
|
||||
v.path = "/sd"
|
||||
e = files.Entry("link", "/sd/link", True, 0, 0, link=True)
|
||||
v.entries = [e]
|
||||
assert not v._protected(e) and v._selectable(e)
|
||||
labels = [a[0] for a in v.menu_actions(e) if a]
|
||||
assert "Download to this PC…" in labels
|
||||
assert "Rename…" not in labels and "Delete…" not in labels
|
||||
assert "Upload files…" not in [a[0] for a in v.menu_actions(None) if a]
|
||||
# Handler guards also cover OS drops and stale callbacks.
|
||||
v.upload([])
|
||||
v.new_folder()
|
||||
v.rename(e)
|
||||
v.delete([e])
|
||||
v.loc = {"id": "app:q", "path": "/sd", "android": "/sdcard"}
|
||||
assert v._protected(e)
|
||||
v.loc["android"] = ""
|
||||
assert not v._protected(e)
|
||||
|
||||
|
||||
def test_installed_games_include_all_kinds_sorted(monkeypatch):
|
||||
from frameport.core import library
|
||||
|
||||
monkeypatch.setattr(library, "game", lambda pkg: {"title": "Alpha"} if pkg == "pc" else None)
|
||||
v = FilesView(SimpleNamespace(frame_info={"installed": [
|
||||
{"package": "q", "kind": "quest", "title": "Zulu"},
|
||||
{"package": "linux", "kind": "linux", "title": "Beta"},
|
||||
{"package": "pc", "kind": "pcvr", "title": "Fallback"},
|
||||
]}))
|
||||
assert v._installed_games() == [("pc", "Alpha"), ("linux", "Beta"), ("q", "Zulu")]
|
||||
|
||||
|
||||
def test_empty_drives_cached(monkeypatch):
|
||||
calls = []
|
||||
v = FilesView(SimpleNamespace(target=SimpleNamespace(frame=SimpleNamespace(home="/home/x"),
|
||||
drives=lambda: calls.append(1) or []),
|
||||
frame_info={"installed": []}))
|
||||
monkeypatch.setattr(v, "_render_locations", lambda: None)
|
||||
monkeypatch.setattr(files, "storage_targets", lambda f: [])
|
||||
v._load_locations(None)
|
||||
v._load_locations(None)
|
||||
assert v.drives == [] and len(calls) == 1
|
||||
|
||||
|
||||
def test_installed_games_are_grouped_by_kind():
|
||||
from frameport.ui.views.files import game_section
|
||||
|
||||
assert [game_section(k) for k in ("quest", None, "rift", "pcvr", "linux")] == \
|
||||
["quest", "quest", "pcvr", "pcvr", "linux"]
|
||||
+12
-15
@@ -133,16 +133,21 @@ def test_climb_private_bridge_guard_and_rebuild(tmp_path, quest_manifest, monkey
|
||||
assert foveation.LAYER in elf.needed(ws.read_lib("libvrapi.so"))
|
||||
assert not patch.apply(ctx)
|
||||
assert all(ok for _, ok, _ in patch.validate(ctx))
|
||||
# Rebuilding with Off from source removes private APK support.
|
||||
with ApkWorkspace(source) as ws:
|
||||
ctx = base.ApkContext(ws, SimpleNamespace(package="com.crytek.climb2"), {}, Reporter(), {})
|
||||
assert patch.apply(ctx) # support is prepared even with the runtime preference at Default
|
||||
assert foveation.LAYER in elf.needed(ws.read_lib("libvrapi.so"))
|
||||
assert all(ok for _, ok, _ in patch.validate(ctx))
|
||||
# Off retains verified APK support; the personal setting disables injection through the launcher.
|
||||
with ApkWorkspace(source) as ws:
|
||||
ctx = base.ApkContext(ws, SimpleNamespace(package="com.crytek.climb2"),
|
||||
{"mode": "off"}, Reporter(), {})
|
||||
assert not patch.apply(ctx)
|
||||
assert ws.read_lib("libvrapi.so") == original
|
||||
assert not ws.has(ws.lib(foveation.LAYER))
|
||||
assert patch.apply(ctx)
|
||||
assert ws.read_lib("libvrapi.so") != original
|
||||
assert ws.has(ws.lib(foveation.LAYER))
|
||||
|
||||
|
||||
def test_ui_controls_save_size_test_and_mode_without_losing_other_patches(monkeypatch):
|
||||
def test_customize_does_not_offer_foveation_choice(monkeypatch):
|
||||
import flet as ft
|
||||
|
||||
from frameport.core import library
|
||||
@@ -167,16 +172,8 @@ def test_ui_controls_save_size_test_and_mode_without_losing_other_patches(monkey
|
||||
yield from walk(control.content)
|
||||
|
||||
controls = list(walk(panel))
|
||||
mode = next(c for c in controls if isinstance(c, ft.Dropdown) and c.value == "tracked")
|
||||
radius = next(c for c in controls if isinstance(c, ft.Dropdown) and c.value == "0")
|
||||
test = next(c for c in controls if isinstance(c, ft.Switch) and "Darken" in (c.label or ""))
|
||||
radius.on_select(SimpleNamespace(control=SimpleNamespace(value="0.3")))
|
||||
test.on_change(SimpleNamespace(control=SimpleNamespace(value=True)))
|
||||
mode.on_select(SimpleNamespace(control=SimpleNamespace(value="fixed")))
|
||||
assert saved[-1]["patches"]["device.foveation"] == {"mode": "fixed", "radius": 0.3, "test": True}
|
||||
assert "frame.adapter" in saved[-1]["patches"]
|
||||
mode.on_select(SimpleNamespace(control=SimpleNamespace(value="off")))
|
||||
assert saved[-1]["patches"]["device.foveation"] == {"mode": "off"}
|
||||
assert not any(isinstance(c, ft.Dropdown) and c.value == "tracked" for c in controls)
|
||||
assert not saved
|
||||
|
||||
|
||||
def test_verified_batman_storage_and_other_byte_change_rejected():
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Discussion #365: personal runtime choices never become recipe/build inputs."""
|
||||
import copy
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from frameport.core import library
|
||||
from frameport.core.models import Recipe
|
||||
from frameport.game_update import apk_recipe_fingerprint
|
||||
from frameport.patches.settings import UI, foveation_env, foveation_preference
|
||||
|
||||
|
||||
def test_migration_extracts_choice_once_without_overwriting_preference():
|
||||
data = {"games": {
|
||||
"old": {"recipe": {"patches": {"device.foveation": {"mode": "fixed", "radius": .2, "test": True},
|
||||
"frame.adapter": {}}}},
|
||||
"chosen": {"recipe": {"patches": {"device.foveation": {"mode": "tracked"}}},
|
||||
"foveation": {"mode": "off"}},
|
||||
"plain": {"recipe": {"patches": {}}}}}
|
||||
library._migrate(data)
|
||||
old = data["games"]["old"]
|
||||
assert old["foveation"] == {"mode": "fixed", "radius": .2, "test": True}
|
||||
assert old["recipe"]["patches"]["device.foveation"] == {"support": True}
|
||||
assert "frame.adapter" in old["recipe"]["patches"]
|
||||
assert data["games"]["chosen"]["foveation"] == {"mode": "off"}
|
||||
assert "foveation" not in data["games"]["plain"]
|
||||
snapshot = copy.deepcopy(data)
|
||||
library._migrate(data)
|
||||
assert data == snapshot
|
||||
|
||||
|
||||
def test_working_config_excludes_legacy_foveation_in_all_outputs():
|
||||
from frameport.diag import issue
|
||||
game = {"package": "test.game", "title": "Test", "foveation": {"mode": "fixed"}}
|
||||
recipe = yaml.safe_dump({"package": "test.game", "patches": {
|
||||
"device.foveation": {"mode": "tracked", "radius": .2, "test": True}, "frame.adapter": {}},
|
||||
"reasons": {"device.foveation": "Eye tracking"}})
|
||||
args = (game, recipe, "works", "", {})
|
||||
assert "device.foveation" not in issue.working_config_body(*args)
|
||||
assert "device.foveation" not in issue.working_config_url(*args)
|
||||
links = issue.working_config_links(*args)
|
||||
assert "device.foveation" not in str(links)
|
||||
assert "frame.adapter" in issue.working_config_body(*args)
|
||||
from frameport.recommend import catalog
|
||||
exported = catalog.to_yaml(catalog.CatalogEntry(package="test.game", title="Test", status="works",
|
||||
foveation="fixed", foveation_radius=.3, foveation_test=True))
|
||||
args = (game, exported, "works", "", {})
|
||||
assert "foveation" not in issue.working_config_body(*args)
|
||||
assert "foveation" not in issue.working_config_url(*args)
|
||||
assert "foveation" not in str(issue.working_config_links(*args))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,expected", [("tracked", "enable"), ("fixed", "enable,disable_offsets"),
|
||||
("off", None), ("", None)])
|
||||
def test_launcher_preference_update_uses_settings_command_without_changing_recipe(monkeypatch, mode, expected):
|
||||
from frameport.targets.frame_lepton import FrameLeptonTarget
|
||||
recipe = library.recipe_to_dict(Recipe("test.game", patches={"frame.adapter": {}}))
|
||||
entry = {"package": "test.game", "recipe": recipe, "foveation": {"mode": mode}}
|
||||
original = copy.deepcopy(recipe)
|
||||
before = apk_recipe_fingerprint(recipe)
|
||||
monkeypatch.setattr(library, "game", lambda pkg: entry)
|
||||
calls = []
|
||||
target = object.__new__(FrameLeptonTarget)
|
||||
target.frame = SimpleNamespace(agent=lambda command, **args: calls.append((command, args)))
|
||||
monkeypatch.setattr(target, "connect", lambda: target)
|
||||
target.set_settings("test.game", {"scale": 1.0}, foveation={"mode": mode})
|
||||
assert len(calls) == 1 and calls[0][0] == "set_settings"
|
||||
assert calls[0][1]["foveation_env"]["FDM_DEBUG"] == expected
|
||||
assert recipe == original and apk_recipe_fingerprint(recipe) == before
|
||||
|
||||
|
||||
def test_catalog_defaults_survive_and_explicit_choice_wins():
|
||||
from frameport.install.installer import configuration_args
|
||||
recipe = Recipe("com.crytek.climb2", patches={"device.foveation": {"mode": "tracked"}})
|
||||
entry = {"recipe": library.recipe_to_dict(recipe)}
|
||||
assert foveation_preference(entry) == {"mode": "tracked"}
|
||||
assert configuration_args(entry, recipe)["env"]["FDM_DEBUG"] == "enable,yflip"
|
||||
entry["foveation"] = {"mode": "off"}
|
||||
env = configuration_args(entry, recipe)["env"]
|
||||
assert env["VK_INSTANCE_LAYERS"] == "" and "FDM_DEBUG" not in env
|
||||
assert not any("CLIMB" in k for k in env)
|
||||
# Default restores the catalog recommendation.
|
||||
entry["foveation"] = {"mode": ""}
|
||||
assert configuration_args(entry, recipe)["env"]["FDM_DEBUG"] == "enable,yflip"
|
||||
|
||||
|
||||
def test_radius_test_and_off_reset_managed_exports():
|
||||
env = foveation_env("test.game", {"mode": "fixed", "radius": .2, "test": True})
|
||||
assert env["FOVE_INNER"] == "0.2" and env["FOVE_OUTER"] == "0.4"
|
||||
assert env["LEPTON_ENV_FRAMEPORT_FOVEATION_TEST"] == "1"
|
||||
off = foveation_env("test.game", {"mode": "off"}, baseline={k: v for k, v in env.items() if v})
|
||||
assert off["FOVE_INNER"] is None and off["LEPTON_ENV_FRAMEPORT_FOVEATION_TEST"] is None
|
||||
|
||||
|
||||
def test_resolution_label_explains_actual_effect():
|
||||
assert UI["scale"]["label"] == "Render resolution (supersampling)"
|
||||
assert "render resolution" in UI["scale"]["help"]
|
||||
assert "sharper but slower" in UI["scale"]["help"]
|
||||
|
||||
|
||||
def test_game_dialog_saves_personal_choice_without_touching_recipe(monkeypatch):
|
||||
import flet as ft
|
||||
|
||||
from frameport.ui.views import adapter_dialog as ad
|
||||
|
||||
recipe = library.recipe_to_dict(Recipe("test.game", patches={"frame.adapter": {}}))
|
||||
library.upsert_game("test.game", title="Test", recipe=recipe)
|
||||
game = library.game("test.game")
|
||||
dialogs = []
|
||||
app = SimpleNamespace(page=SimpleNamespace(show_dialog=dialogs.append, pop_dialog=lambda: None),
|
||||
target=None, toast=lambda *a, **kw: None, refresh_view=lambda: None,
|
||||
_title=lambda pkg: "Test")
|
||||
ad._open(app, "test.game", game, ad.recipe_values(game))
|
||||
|
||||
def walk(control):
|
||||
yield control
|
||||
for child in getattr(control, "controls", []) or []:
|
||||
yield from walk(child)
|
||||
for attr in ("content", "title"):
|
||||
child = getattr(control, attr, None)
|
||||
if child is not None and not isinstance(child, str):
|
||||
yield from walk(child)
|
||||
for child in getattr(control, "actions", []) or []:
|
||||
yield from walk(child)
|
||||
|
||||
controls = list(walk(dialogs[-1]))
|
||||
mode = next(c for c in controls if isinstance(c, ft.Dropdown) and
|
||||
any(o.key == "tracked" for o in c.options))
|
||||
mode.on_select(SimpleNamespace(control=SimpleNamespace(value="fixed")))
|
||||
radius = next(c for c in controls if isinstance(c, ft.Dropdown) and
|
||||
any(o.key == "0.3" for o in c.options))
|
||||
radius.on_select(SimpleNamespace(control=SimpleNamespace(value="0.3")))
|
||||
advanced_row = next(c for c in controls if isinstance(c, ft.Row) and c.data == "switch" and
|
||||
any(getattr(t, "value", None) == "Show advanced settings" for t in walk(c)))
|
||||
advanced = next(c for c in walk(advanced_row) if isinstance(c, ft.Switch))
|
||||
advanced.on_change(SimpleNamespace(control=SimpleNamespace(value=True)))
|
||||
test_row = next(c for c in controls if isinstance(c, ft.Container) and
|
||||
isinstance(c.content, ft.Row) and any(
|
||||
getattr(t, "value", None) == "Darken coarse fragments (test)" for t in walk(c)))
|
||||
assert test_row.visible
|
||||
test_switch = next(c for c in walk(test_row) if isinstance(c, ft.Switch))
|
||||
test_switch.on_change(SimpleNamespace(control=SimpleNamespace(value=True)))
|
||||
# The dialog's primary Save button calls the only recipe-independent preference write.
|
||||
save = dialogs[-1].actions[-1]
|
||||
save.on_click(None)
|
||||
saved = library.game("test.game")
|
||||
assert saved["foveation"] == {"mode": "fixed", "radius": .3, "test": True}
|
||||
assert saved["recipe"] == game["recipe"]
|
||||
ad._open(app, "test.game", saved, ad.recipe_values(saved))
|
||||
mode = next(c for c in walk(dialogs[-1]) if isinstance(c, ft.Dropdown) and
|
||||
any(o.key == "tracked" for o in c.options))
|
||||
mode.on_select(SimpleNamespace(control=SimpleNamespace(value="off")))
|
||||
dialogs[-1].actions[-1].on_click(None)
|
||||
assert library.game("test.game")["foveation"] == {"mode": "off"}
|
||||
assert library.game("test.game")["recipe"] == game["recipe"]
|
||||
|
||||
|
||||
def test_adapter_build_prepares_internal_foveation_support():
|
||||
from frameport.build import apk_patches
|
||||
assert "device.foveation" in [p.id for p in apk_patches({"frame.adapter": {}})]
|
||||
assert "device.foveation" not in [p.id for p in apk_patches({"frame.launcher": {}})]
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Per-game identity, default migration, UI choice and host OpenXR rewrite."""
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core import library
|
||||
from frameport.core.models import Recipe
|
||||
from frameport.game_update import apk_recipe_fingerprint, configuration, configuration_fingerprint
|
||||
from frameport.install.installer import install_context
|
||||
from frameport.patches import base
|
||||
from frameport.patches.frame.adapter import settings_text
|
||||
from frameport.patches.frame.headset_model import MODELS, HeadsetModel, migrate
|
||||
from frameport.recommend import engine
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", MODELS)
|
||||
def test_identity_layers(key):
|
||||
patches = {HeadsetModel.id: {"model": key}, "device.hide_navbar": {}, "device.language": {"tag": "de-DE"}}
|
||||
ctx = install_context(Recipe("test.game", patches=patches))
|
||||
number, _ = MODELS[key]
|
||||
assert ctx.adapter_settings["headset_model"] == number
|
||||
assert configuration({"patches": patches})["settings"]["headset_model"] == number
|
||||
if key == "quest2":
|
||||
assert settings_text(patches) == settings_text({k: v for k, v in patches.items() if k != HeadsetModel.id})
|
||||
else:
|
||||
assert f"headset_model={number}\n".encode() in settings_text(patches)
|
||||
assert ctx.env["LEPTON_GFXRECON_FP_PROPS"] == "0\nqemu.hw.mainkeys=1"
|
||||
assert ctx.env["LEPTON_GFXRECON_FP_LOCALE"] == "0\npersist.sys.locale=de-DE"
|
||||
assert "LEPTON_GFXRECON_FP_HEADSET" not in ctx.env
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["unknown", "quest3\nro.debuggable=1", 3, None])
|
||||
def test_invalid_choices(value):
|
||||
with pytest.raises(ValueError):
|
||||
install_context(Recipe("test.game", patches={HeadsetModel.id: {"model": value}}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extra,enabled", [({}, True), ({"vr_kind": "quest"}, True),
|
||||
({"kind": "rift"}, False), ({"kind": "linux"}, False),
|
||||
({"vr_kind": "none"}, False), ({"vr_kind": "other"}, False)])
|
||||
def test_default_scope(extra, enabled):
|
||||
analysis = library.analysis_from_dict({"package": "test.game", "extra": extra})
|
||||
patch = base.get(HeadsetModel.id)
|
||||
assert patch.default_on
|
||||
assert patch.applies(analysis) == enabled
|
||||
assert (HeadsetModel.id in engine.suggest(analysis, use_catalog=False).patches) == enabled
|
||||
|
||||
|
||||
def test_migration_preserves_user_choices_and_excludes_other_apps():
|
||||
def game(extra=None, kind=None):
|
||||
return {"analysis": {"extra": extra or {}}, "recipe": {"patches": {}}, "kind": kind}
|
||||
|
||||
games = {"quest": game(), "chosen": game(), "rift": game({"kind": "rift"}),
|
||||
"linux": game(kind="linux"), "flat": game({"vr_kind": "none"})}
|
||||
games["chosen"]["recipe"]["patches"][HeadsetModel.id] = {"model": "quest2"}
|
||||
data = {"games": games}
|
||||
migrate(data)
|
||||
assert games["quest"]["recipe"]["patches"][HeadsetModel.id] == {}
|
||||
assert games["chosen"]["recipe"]["patches"][HeadsetModel.id] == {"model": "quest2"}
|
||||
assert all(HeadsetModel.id not in games[k]["recipe"]["patches"] for k in ("rift", "linux", "flat"))
|
||||
library._migrate(data)
|
||||
assert "headset_model_quest3" in data["settings"]["migrations"]
|
||||
games["quest"]["recipe"]["patches"][HeadsetModel.id] = {"model": "questpro"}
|
||||
assert not library._migrate(data)
|
||||
assert games["quest"]["recipe"]["patches"][HeadsetModel.id] == {"model": "questpro"}
|
||||
|
||||
|
||||
def test_new_default_requires_adapter_delivery():
|
||||
before = {"patches": {"frame.adapter": {}}}
|
||||
after = {"patches": {"frame.adapter": {}, HeadsetModel.id: {}}}
|
||||
assert apk_recipe_fingerprint(before) != apk_recipe_fingerprint(after)
|
||||
assert configuration_fingerprint(before) != configuration_fingerprint(after)
|
||||
|
||||
|
||||
def test_dropdown_keeps_quest3_enabled(monkeypatch):
|
||||
import flet as ft
|
||||
|
||||
from frameport.ui.views import game
|
||||
|
||||
patch = base.get(HeadsetModel.id)
|
||||
assert set(dict(patch.CHOICES)) == set(MODELS)
|
||||
monkeypatch.setattr(game.engine, "visible_patches", lambda *a: ([patch], []))
|
||||
monkeypatch.setattr(library, "analysis_from_dict", lambda a: None)
|
||||
monkeypatch.setattr(library, "setting", lambda *a: False)
|
||||
monkeypatch.setattr(game.engine, "warnings", lambda *a: [])
|
||||
saved = []
|
||||
monkeypatch.setattr(game.pipeline, "set_recipe", lambda package, r: saved.append(library.recipe_to_dict(r)))
|
||||
recipe = Recipe("test.game", patches={HeadsetModel.id: {"model": "quest2"}})
|
||||
view = SimpleNamespace(app=SimpleNamespace(has_game_settings=lambda g: False),
|
||||
g={"recipe": library.recipe_to_dict(recipe), "analysis": {}},
|
||||
package="test.game", show_all=False)
|
||||
|
||||
def walk(control):
|
||||
yield control
|
||||
for child in getattr(control, "controls", []) or []:
|
||||
yield from walk(child)
|
||||
if getattr(control, "content", None) is not None:
|
||||
yield from walk(control.content)
|
||||
|
||||
picker = next(c for c in walk(game.GameView.advanced_panel(view)) if isinstance(c, ft.Dropdown))
|
||||
assert picker.value == "quest2"
|
||||
picker.on_select(SimpleNamespace(control=SimpleNamespace(value="")))
|
||||
assert saved[-1]["patches"][HeadsetModel.id] == {}
|
||||
|
||||
|
||||
def test_system_name_host(tmp_path):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
cc = shutil.which("cc") or shutil.which("clang")
|
||||
inc = next((root / "native/.cache").glob("openxr-*"), None)
|
||||
if not cc or not inc:
|
||||
pytest.skip("needs host C compiler and cached OpenXR headers")
|
||||
source = tmp_path / "headset_model_test.c"
|
||||
source.write_text(r'''
|
||||
#include <openxr/openxr.h>
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "headset_model.c"
|
||||
int main(void) {
|
||||
XrSystemProperties p = {0};
|
||||
p.vendorId = 10291;
|
||||
p.graphicsProperties.maxLayerCount = 16;
|
||||
int sentinel;
|
||||
p.next = &sentinel;
|
||||
strcpy(p.systemName, "Oculus Quest2");
|
||||
headset_model_properties(&p);
|
||||
assert(!strcmp(p.systemName, "Oculus Quest2"));
|
||||
const char *names[] = {"Meta Quest 3", "Meta Quest 3S", "Meta Quest Pro"};
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
char line[32];
|
||||
snprintf(line, sizeof(line), "headset_model=%d\n", i + 3);
|
||||
headset_model_setting(line);
|
||||
headset_model_properties(&p);
|
||||
assert(!strcmp(p.systemName, names[i]));
|
||||
assert(p.vendorId == 10291 && p.graphicsProperties.maxLayerCount == 16 && p.next == &sentinel);
|
||||
}
|
||||
headset_model_setting("headset_model=2\n");
|
||||
strcpy(p.systemName, "Oculus Quest2");
|
||||
headset_model_properties(&p);
|
||||
assert(!strcmp(p.systemName, "Oculus Quest2"));
|
||||
headset_model_setting("headset_model=99\n");
|
||||
headset_model_setting("headset_model=3oops\n");
|
||||
assert(headset_model == 2);
|
||||
headset_model_properties(NULL);
|
||||
return 0;
|
||||
}
|
||||
''')
|
||||
exe = tmp_path / "headset_model_test"
|
||||
subprocess.run([cc, "-std=c11", "-Wall", "-Wextra", "-Werror", "-I", str(inc),
|
||||
"-I", str(root / "native/adapter"), str(source), "-o", str(exe)], check=True)
|
||||
subprocess.run([str(exe)], check=True)
|
||||
|
||||
|
||||
def dispatcher_blob():
|
||||
from frameport.patches.frame.headset_model import GUARDS, NAME_OFFSET, UUID_OFFSET, UUIDS
|
||||
blob = bytearray(b'\xa5' * (UUID_OFFSET + 32))
|
||||
for off, value in {**GUARDS, NAME_OFFSET: b'Oculus Quest2\0', UUID_OFFSET: UUIDS['quest2']}.items():
|
||||
blob[off:off + len(value)] = value
|
||||
return bytes(blob)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', MODELS)
|
||||
def test_dispatcher_exact_edit(key):
|
||||
from frameport.patches.frame.headset_model import NAME_OFFSET, SYSTEM_NAMES, UUID_OFFSET, UUIDS, patch_dispatcher
|
||||
original = dispatcher_blob()
|
||||
fixed = patch_dispatcher(original, key)
|
||||
assert len(fixed) == len(original)
|
||||
if key == 'quest2':
|
||||
assert fixed == original
|
||||
else:
|
||||
assert fixed[NAME_OFFSET:NAME_OFFSET + 14] == SYSTEM_NAMES[key].ljust(14, b'\0')
|
||||
assert fixed[UUID_OFFSET:UUID_OFFSET + 16] == UUIDS[key]
|
||||
assert all(a == b for i, (a, b) in enumerate(zip(original, fixed, strict=True))
|
||||
if not (NAME_OFFSET <= i < NAME_OFFSET + 14 or UUID_OFFSET <= i < UUID_OFFSET + 16))
|
||||
|
||||
|
||||
def test_dispatcher_rejects_unknown_and_partial_matches():
|
||||
from frameport.patches.frame.headset_model import GUARDS, NAME_OFFSET, UUID_OFFSET, patch_dispatcher
|
||||
for offset in [*GUARDS, NAME_OFFSET, UUID_OFFSET]:
|
||||
blob = bytearray(dispatcher_blob())
|
||||
blob[offset] ^= 1
|
||||
with pytest.raises(ValueError, match='Unsupported OVRPort'):
|
||||
patch_dispatcher(bytes(blob), '')
|
||||
with pytest.raises(ValueError, match='Unsupported OVRPort'):
|
||||
patch_dispatcher(b'', '')
|
||||
assert patch_dispatcher(b'unknown runtime', 'quest2') == b'unknown runtime'
|
||||
|
||||
|
||||
def test_real_dispatcher_when_available():
|
||||
import os
|
||||
|
||||
from frameport.core.paths import user_data_dir
|
||||
from frameport.patches.frame.headset_model import patch_dispatcher
|
||||
default = user_data_dir() / 'overport-workspace/libraries/3.4.3-aa54c3f/lib/arm64-v8a/libopenxr_loader.so'
|
||||
path = Path(os.environ.get('FRAMEPORT_TEST_DISPATCHER', default))
|
||||
if not path.is_file():
|
||||
pytest.skip('verified dispatcher not available; set FRAMEPORT_TEST_DISPATCHER')
|
||||
original = path.read_bytes()
|
||||
for key in MODELS:
|
||||
fixed = patch_dispatcher(original, key)
|
||||
assert len(fixed) == len(original)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', MODELS)
|
||||
def test_apk_stage_patches_dispatcher_and_embeds_settings(tmp_path, key):
|
||||
import zipfile
|
||||
|
||||
from frameport.apk.workspace import ApkWorkspace
|
||||
from frameport.build import apply_frame_fixes
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.patches.frame.headset_model import patch_dispatcher
|
||||
|
||||
apk = tmp_path / 'input.apk'
|
||||
original = dispatcher_blob()
|
||||
with zipfile.ZipFile(apk, 'w') as z:
|
||||
z.writestr('lib/arm64-v8a/libopenxr_loader.so', original)
|
||||
z.writestr('lib/arm64-v8a/libopenxr_loader_generic.so', b'original downstream loader')
|
||||
z.writestr('assets/game', b'untouched asset')
|
||||
patches = {'frame.adapter': {}, HeadsetModel.id: {'model': key}}
|
||||
recipe = Recipe('test.game', patches=patches)
|
||||
analysis = library.analysis_from_dict({'package': 'test.game', 'abis': ['arm64-v8a']})
|
||||
output = tmp_path / 'output.apk'
|
||||
applied, _, _ = apply_frame_fixes(apk, output, analysis, recipe, Reporter())
|
||||
assert (HeadsetModel.id in applied) == (key != 'quest2')
|
||||
with ApkWorkspace(output) as ws:
|
||||
assert ws.read_lib('libopenxr_loader.so') == patch_dispatcher(original, key)
|
||||
assert ws.read_lib('libframe_settings.so') == settings_text(patches)
|
||||
assert ws.read('assets/game') == b'untouched asset'
|
||||
|
||||
|
||||
def test_identity_triage():
|
||||
from frameport.validate.triage import triage
|
||||
findings = triage('OVRPlugin: venderId 10291, systemName Oculus Quest2').findings
|
||||
assert any(f.id == 'quest2-headset-identity' for f in findings)
|
||||
assert not any(f.id == 'quest2-headset-identity'
|
||||
for f in triage('OVRPlugin: venderId 10291, systemName Meta Quest 3').findings)
|
||||
@@ -98,15 +98,14 @@ int main(int argc, char **argv) {
|
||||
input_diag_current_profile(left, touch); // once
|
||||
input_diag_current_profile(right, XR_NULL_PATH);
|
||||
|
||||
PFN_xrCreateAction create = (PFN_xrCreateAction)input_diag_hook("xrCreateAction");
|
||||
CHECK(create && input_diag_hook("xrStringToPath") && !input_diag_hook("xrEndFrame"));
|
||||
// xrCreateAction is hooked by session_fixes.c (#323), which reports each created action here
|
||||
CHECK(!input_diag_hook("xrCreateAction") && input_diag_hook("xrStringToPath") && !input_diag_hook("xrEndFrame"));
|
||||
XrActionCreateInfo info = {XR_TYPE_ACTION_CREATE_INFO};
|
||||
XrAction action;
|
||||
snprintf(info.actionName, sizeof(info.actionName), "trigger pull");
|
||||
CHECK(create(XR_NULL_HANDLE, &info, &action) == XR_ERROR_NAME_INVALID);
|
||||
CHECK(create(XR_NULL_HANDLE, &info, &action) == XR_ERROR_NAME_INVALID); // once
|
||||
diag_log_action(&info, XR_ERROR_NAME_INVALID);
|
||||
diag_log_action(&info, XR_ERROR_NAME_INVALID); // the failure is reported once
|
||||
snprintf(info.actionName, sizeof(info.actionName), "trigger_pull");
|
||||
CHECK(create(XR_NULL_HANDLE, &info, &action) == XR_SUCCESS); // success: nothing
|
||||
diag_log_action(&info, XR_SUCCESS);
|
||||
|
||||
input_diag_lookup(active_instance, "xrCreateBodyTrackerFB", XR_ERROR_FUNCTION_UNSUPPORTED, NULL);
|
||||
input_diag_lookup(active_instance, "xrCreateBodyTrackerFB", XR_ERROR_FUNCTION_UNSUPPORTED, NULL); // once
|
||||
@@ -124,7 +123,10 @@ EXPECTED = [
|
||||
"input_diag: bindings: /interaction_profiles/oculus/touch_controller accepted (2 paths)",
|
||||
"input_diag: bindings: /user/hand/left uses /interaction_profiles/oculus/touch_controller",
|
||||
"input_diag: bindings: /user/hand/right uses none",
|
||||
'input_diag: action "trigger pull" ("") type 0, 0 subaction path(s) -> -45',
|
||||
"input_diag: unsupported: xrCreateAction -> XR_ERROR_NAME_INVALID (trigger pull)",
|
||||
'input_diag: action "trigger pull" ("") type 0, 0 subaction path(s) -> -45',
|
||||
'input_diag: action "trigger_pull" ("") type 0, 0 subaction path(s) -> 0',
|
||||
"input_diag: unsupported: function xrCreateBodyTrackerFB not provided by the runtime "
|
||||
"(XR_ERROR_FUNCTION_UNSUPPORTED)",
|
||||
]
|
||||
|
||||
@@ -46,13 +46,15 @@ def test_adapter_and_launcher(tmp_path, quest_manifest):
|
||||
for pid in ("frame.adapter", "frame.launcher"):
|
||||
ctx = base.ApkContext(ws, _analysis(), {}, Reporter(), recipe)
|
||||
assert base.get(pid).apply(ctx)
|
||||
assert all(ok for _, ok, _ in base.get("frame.adapter").validate(ctx))
|
||||
assert ws.has(ws.lib("libVkLayer_fdm_darken.so")) # dormant until the launcher test opt-in
|
||||
out = ws.write(tmp_path / "out.apk")
|
||||
with zipfile.ZipFile(out) as z:
|
||||
names = z.namelist()
|
||||
assert z.read("lib/arm64-v8a/libopenxr_loader_original.so") == b"\x7fELF-original-loader"
|
||||
assert z.read("lib/arm64-v8a/libopenxr_loader_generic.so").startswith(b"\x7fELF")
|
||||
assert (z.read("lib/arm64-v8a/libframe_settings.so")
|
||||
== b"scale=1.0\nfoveation_fix=1\ncontroller_fix=1\nscene_emul=1\n")
|
||||
== b"scale=1.0\nfoveation_fix=1\ncontroller_fix=1\nquest_buttons=0\nscene_emul=1\n")
|
||||
assert names.count("lib/arm64-v8a/libopenxr_loader_generic.so") == 1
|
||||
|
||||
|
||||
@@ -67,7 +69,10 @@ def test_adapter_refreshes_existing_wrapper(tmp_path, quest_manifest):
|
||||
patch = base.get("frame.adapter")
|
||||
ctx = base.ApkContext(ws, _analysis(), {}, Reporter(), {"frame.adapter": {}})
|
||||
assert patch.apply(ctx)
|
||||
assert ws.read_lib("libopenxr_loader_generic.so") == artifact(ws.abi, "libopenxr_loader_generic.so")
|
||||
from frameport.analysis import elf
|
||||
from frameport.patches.frame.foveation import LAYER
|
||||
assert ws.read_lib("libopenxr_loader_generic.so") == elf.add_needed(
|
||||
artifact(ws.abi, "libopenxr_loader_generic.so"), LAYER)
|
||||
assert ws.read_lib("libopenxr_loader_original.so") == b"original-loader"
|
||||
assert not patch.apply(ctx)
|
||||
|
||||
|
||||
@@ -178,8 +178,11 @@ def test_migration_rederives_rift_recipes(tmp_path, monkeypatch):
|
||||
games = library.load()["games"]
|
||||
r = games["rift.ue"]["recipe"]
|
||||
assert r["as_is"] and "pcvr.revive" in r["patches"] and "pcvr.oculus_unreal" in r["patches"]
|
||||
# Quest games untouched by the Rift migrations (they only gain the default-on binary fixes)
|
||||
assert set(games["com.q.ue"]["recipe"]["patches"]) <= {"frame.swapchain_limit", "frame.vk_sanitize"}
|
||||
# Quest games untouched by the Rift migrations (they only gain the default-on binary fixes and, from the
|
||||
# headset-model migration, the Quest 3 identity every Quest game gets)
|
||||
assert set(games["com.q.ue"]["recipe"]["patches"]) <= {"frame.swapchain_limit", "frame.vk_sanitize",
|
||||
"device.headset_model"}
|
||||
assert "device.headset_model" not in r["patches"] # never for PC VR games
|
||||
|
||||
def test_oculus_hmd_helper_artifact():
|
||||
"""The prebuilt helper is a freestanding Windows x64 console exe that only imports kernel32."""
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
"""FrameBridge Quest bindings, checked against the shared JSON contract with a fake OpenXR runtime.
|
||||
Opt-in (FRAMEPORT_NATIVE_TESTS=1, -m native): cached OpenXR headers and a host C compiler only, no device.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INC = next(iter((ROOT / "native/.cache").glob("openxr-*")), None)
|
||||
CC = shutil.which("cc") or shutil.which("clang")
|
||||
pytestmark = [pytest.mark.native, pytest.mark.skipif(
|
||||
not os.environ.get("FRAMEPORT_NATIVE_TESTS") or not INC or not CC,
|
||||
reason="set FRAMEPORT_NATIVE_TESTS=1 (needs the OpenXR headers from native/build.py and a host C compiler)")]
|
||||
CONTRACT = json.loads((ROOT / "docs/input/quest_buttons.json").read_text())
|
||||
TOUCH = CONTRACT["touch_profile"]
|
||||
FRAME = CONTRACT["frame_profile"]
|
||||
PRO = "/interaction_profiles/facebook/touch_controller_pro"
|
||||
|
||||
HARNESS = r"""
|
||||
#define XR_EXTENSION_PROTOTYPES
|
||||
#include <openxr/openxr.h>
|
||||
#include <math.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#define LOG(...) (fprintf(stderr, __VA_ARGS__), fputc('\n', stderr))
|
||||
static XrInstance active_instance = (XrInstance)(uintptr_t)1;
|
||||
static int input_diag, controller_fix, profile_remap, runtime_has_detached_controllers;
|
||||
static int stable_local, layer_debug, sync_guard, action_dedup, proximity_emul, runtime_has_proximity;
|
||||
static float aim_pitch, aim_yaw, aim_forward, refresh_rate, focus_hold_ms;
|
||||
static XrTime last_predicted_time;
|
||||
static XrDuration last_display_period;
|
||||
static void emul_on_begin_session(const XrSessionBeginInfo *info) { (void)info; }
|
||||
static int hide_fb_foveation_vulkan, hide_fb_foveation, hide_space_warp, scene_emul, emulate_scene;
|
||||
static int controller_models, render_models_available, emulate_render_model, passthrough_emul, emulate_passthrough;
|
||||
static int foveation_eye_enabled, foveation_eye, foveation_fix, surface_emul, runtime_has_image_layout;
|
||||
static int no_equirect, no_equirect2, no_cylinder, no_cube, fb_color_space, emulate_color_space;
|
||||
static void *android_vm;
|
||||
static void mx_audio_initialize(void) {}
|
||||
static int is_scene_extension(const char *name) { (void)name; return 0; }
|
||||
#include "hide_fb_foveation.h"
|
||||
#include "quest_buttons.c"
|
||||
|
||||
static char paths[1024][XR_MAX_PATH_LENGTH];
|
||||
static int path_count, offered, enum_calls, create_calls;
|
||||
static XrResult reject_touch_once;
|
||||
static XrResult touch_result, frame_result, create_result;
|
||||
static XrPath runtime_profile;
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_string_to_path(XrInstance i, const char *s, XrPath *out) {
|
||||
(void)i;
|
||||
for (int k = 0; k < path_count; ++k) if (!strcmp(paths[k], s)) { *out = (XrPath)(k + 1); return XR_SUCCESS; }
|
||||
if (path_count >= 1024) abort();
|
||||
snprintf(paths[path_count], XR_MAX_PATH_LENGTH, "%s", s);
|
||||
*out = (XrPath)++path_count;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_path_to_string(XrInstance i, XrPath p, uint32_t cap, uint32_t *n,
|
||||
char *out) {
|
||||
(void)i;
|
||||
if (!p || p > (XrPath)path_count) return XR_ERROR_PATH_INVALID;
|
||||
*n = (uint32_t)snprintf(out, cap, "%s", paths[p - 1]) + 1;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XrPath P(const char *s) { XrPath p; fake_string_to_path(0, s, &p); return p; }
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_suggest(XrInstance i, const XrInteractionProfileSuggestedBinding *s) {
|
||||
(void)i;
|
||||
const char *profile = paths[s->interactionProfile - 1];
|
||||
printf("CALL %s %u\n", profile, s->countSuggestedBindings);
|
||||
for (uint32_t k = 0; k < s->countSuggestedBindings; ++k)
|
||||
printf("%llu %s\n", (unsigned long long)(uintptr_t)s->suggestedBindings[k].action,
|
||||
paths[s->suggestedBindings[k].binding - 1]);
|
||||
if (!strcmp(profile, FRAME_PROFILE)) return frame_result;
|
||||
if (!strcmp(profile, "/interaction_profiles/oculus/touch_controller")) {
|
||||
if (reject_touch_once) { XrResult r = reject_touch_once; reject_touch_once = 0; return r; }
|
||||
return touch_result;
|
||||
}
|
||||
return XR_ERROR_PATH_UNSUPPORTED;
|
||||
}
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_enumerate(const char *layer, uint32_t cap, uint32_t *n,
|
||||
XrExtensionProperties *out) {
|
||||
(void)layer;
|
||||
++enum_calls;
|
||||
printf("ENUM %u\n", cap);
|
||||
if (offered == 5 || (offered == 4 && cap)) return XR_ERROR_RUNTIME_FAILURE;
|
||||
*n = offered == 2 ? 0 : (offered == 1 ? 2 : 1);
|
||||
if (cap && *n) {
|
||||
snprintf(out[0].extensionName, sizeof(out[0].extensionName), "XR_KHR_vulkan_enable");
|
||||
if (*n > 1) snprintf(out[1].extensionName, sizeof(out[1].extensionName), "%s", FRAME_EXTENSION);
|
||||
}
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_create(const XrInstanceCreateInfo *info, XrInstance *instance) {
|
||||
printf("CREATE %u\n", info->enabledExtensionCount);
|
||||
for (uint32_t k = 0; k < info->enabledExtensionCount; ++k) printf("EXT %s\n", info->enabledExtensionNames[k]);
|
||||
if (XR_SUCCEEDED(create_result)) *instance = (XrInstance)(uintptr_t)++create_calls;
|
||||
return create_result;
|
||||
}
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_current(XrSession s, XrPath hand, XrInteractionProfileState *out) {
|
||||
(void)s; (void)hand;
|
||||
out->interactionProfile = runtime_profile;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static PFN_xrVoidFunction lookup(XrInstance i, const char *name) {
|
||||
(void)i;
|
||||
if (!strcmp(name, "xrStringToPath")) return (PFN_xrVoidFunction)fake_string_to_path;
|
||||
if (!strcmp(name, "xrPathToString")) return (PFN_xrVoidFunction)fake_path_to_string;
|
||||
if (!strcmp(name, "xrSuggestInteractionProfileBindings")) return (PFN_xrVoidFunction)fake_suggest;
|
||||
if (!strcmp(name, "xrEnumerateInstanceExtensionProperties"))
|
||||
return offered == 3 ? NULL : (PFN_xrVoidFunction)fake_enumerate;
|
||||
if (!strcmp(name, "xrCreateInstance")) return (PFN_xrVoidFunction)fake_create;
|
||||
if (!strcmp(name, "xrGetCurrentInteractionProfile")) return (PFN_xrVoidFunction)fake_current;
|
||||
return NULL;
|
||||
}
|
||||
#include "input_diag.c"
|
||||
#include "session_fixes.c"
|
||||
// The two exported adapter functions below are copied verbatim into this host translation unit by the fixture;
|
||||
// the rest of frame_adapter.c needs Android graphics headers and is covered by the NDK build.
|
||||
@ADAPTER_FUNCTIONS@
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 9) return 2;
|
||||
char setting[256];
|
||||
snprintf(setting, sizeof(setting), "quest_buttons=%s\n", argv[1]);
|
||||
quest_buttons_setting(setting);
|
||||
printf("MODE %d\n", quest_buttons);
|
||||
offered = atoi(argv[2]);
|
||||
input_diag = atoi(argv[4]);
|
||||
profile_remap = atoi(argv[5]);
|
||||
controller_fix = atoi(argv[6]);
|
||||
create_result = (XrResult)atoi(argv[7]);
|
||||
proximity_emul = atoi(argv[8]);
|
||||
const char *extensions[2] = {"XR_KHR_vulkan_enable", FRAME_EXTENSION};
|
||||
XrInstanceCreateInfo ci = {XR_TYPE_INSTANCE_CREATE_INFO};
|
||||
ci.enabledExtensionCount = atoi(argv[3]) ? 2 : 1;
|
||||
ci.enabledExtensionNames = extensions;
|
||||
XrInstance instance;
|
||||
xrCreateInstance(&ci, &instance);
|
||||
printf("ENABLED %d\n", quest_buttons_enabled);
|
||||
char command[32], profile[XR_MAX_PATH_LENGTH];
|
||||
while (scanf("%31s", command) == 1) {
|
||||
if (!strcmp(command, "create")) {
|
||||
int result;
|
||||
if (scanf("%d %d", &offered, &result) != 2) return 2;
|
||||
create_result = (XrResult)result;
|
||||
xrCreateInstance(&ci, &instance);
|
||||
printf("ENABLED %d\nACTIVE %llu\n", quest_buttons_enabled, (unsigned long long)(uintptr_t)active_instance);
|
||||
} else if (!strcmp(command, "proximity")) {
|
||||
unsigned thumb, trigger;
|
||||
if (scanf("%u %u", &thumb, &trigger) != 2) return 2;
|
||||
thumb_proximity_action = (XrAction)(uintptr_t)thumb;
|
||||
trigger_proximity_action = (XrAction)(uintptr_t)trigger;
|
||||
} else if (!strcmp(command, "reject_touch_once")) {
|
||||
int result;
|
||||
if (scanf("%d", &result) != 1) return 2;
|
||||
reject_touch_once = (XrResult)result;
|
||||
} else if (!strcmp(command, "profile")) {
|
||||
if (scanf("%255s", profile) != 1) return 2;
|
||||
runtime_profile = !strcmp(profile, "none") ? XR_NULL_PATH : P(profile);
|
||||
XrInteractionProfileState state = {XR_TYPE_INTERACTION_PROFILE_STATE};
|
||||
xrGetCurrentInteractionProfile(XR_NULL_HANDLE, P("/user/hand/left"), &state);
|
||||
printf("PROFILE %s\n", state.interactionProfile ? paths[state.interactionProfile - 1] : "none");
|
||||
} else if (!strcmp(command, "suggest")) {
|
||||
unsigned n;
|
||||
int tr, fr;
|
||||
if (scanf("%255s %u %d %d", profile, &n, &tr, &fr) != 4 || n > 128) return 2;
|
||||
touch_result = (XrResult)tr;
|
||||
frame_result = (XrResult)fr;
|
||||
XrActionSuggestedBinding b[128];
|
||||
for (unsigned k = 0; k < n; ++k) {
|
||||
unsigned action;
|
||||
char path[XR_MAX_PATH_LENGTH];
|
||||
if (scanf("%u %255s", &action, path) != 2) return 2;
|
||||
b[k] = (XrActionSuggestedBinding){(XrAction)(uintptr_t)action, P(path)};
|
||||
}
|
||||
XrInteractionProfileSuggestedBinding s = {XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING, NULL,
|
||||
P(profile), n, b};
|
||||
printf("RESULT %d\n", hook_xrSuggestInteractionProfileBindings(instance, &s));
|
||||
} else return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def adapter_function(source, name):
|
||||
start = source.index(f"XRAPI_ATTR XrResult XRAPI_CALL {name}(")
|
||||
return source[start:source.index("\n}\n", start) + 3]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def harness(tmp_path_factory):
|
||||
d = tmp_path_factory.mktemp("quest_buttons")
|
||||
source = (ROOT / "native/adapter/frame_adapter.c").read_text()
|
||||
functions = "\n".join(adapter_function(source, name) for name in
|
||||
("xrCreateInstance", "xrGetCurrentInteractionProfile"))
|
||||
(d / "harness.c").write_text(HARNESS.replace("@ADAPTER_FUNCTIONS@", functions))
|
||||
subprocess.run([CC, "-std=c11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror", "-Wno-unused-function",
|
||||
"-Wno-missing-field-initializers", "-pthread", "-I", str(INC),
|
||||
"-I", str(ROOT / "native/adapter"), str(d / "harness.c"), "-lm", "-o", str(d / "harness")],
|
||||
check=True)
|
||||
return d / "harness"
|
||||
|
||||
|
||||
def run(harness, mode=1, commands="", *, offered=1, requested=False, diag=False, remap=False,
|
||||
controller_fix=False, create_result=0, proximity=0):
|
||||
p = subprocess.run([str(harness), str(mode), str(offered), str(int(requested)), str(int(diag)),
|
||||
str(int(remap)), str(int(controller_fix)), str(create_result), str(proximity)],
|
||||
input=commands, capture_output=True, text=True, timeout=5)
|
||||
assert p.returncode == 0, p.stderr
|
||||
return p
|
||||
|
||||
|
||||
def suggest(bindings, profile=TOUCH, result=0, frame_result=0):
|
||||
return (f"suggest {profile} {len(bindings)} {result} {frame_result}\n" +
|
||||
"".join(f"{action} {path}\n" for action, path in bindings))
|
||||
|
||||
|
||||
def calls(p):
|
||||
lines = iter(p.stdout.splitlines())
|
||||
out = []
|
||||
for line in lines:
|
||||
if line.startswith("CALL "):
|
||||
_, profile, count = line.split()
|
||||
bindings = []
|
||||
for _ in range(int(count)):
|
||||
action, path = next(lines).split()
|
||||
bindings.append((int(action), path))
|
||||
out.append((profile, bindings))
|
||||
return out
|
||||
|
||||
|
||||
def translate(bindings, mode):
|
||||
"""Independent oracle: use only the shared JSON, never the C table."""
|
||||
if not mode:
|
||||
return []
|
||||
out = []
|
||||
for action, path in bindings:
|
||||
for hand, buttons in CONTRACT["buttons"].items():
|
||||
prefix = f"/user/hand/{hand}/"
|
||||
if not path.startswith(prefix):
|
||||
continue
|
||||
kind, _, component = path[len(prefix):].partition("/")
|
||||
targets = []
|
||||
if kind == "output":
|
||||
targets = CONTRACT["outputs"].get(component, [])
|
||||
elif kind == "input" and component not in CONTRACT["dropped"]:
|
||||
targets = CONTRACT["common"].get(component, [])
|
||||
button, _, suffix = component.partition("/")
|
||||
if ((button in buttons and suffix in ("", "click", "touch")) or
|
||||
(button == "menu" and suffix in ("", "click"))):
|
||||
targets = [f"{target}/{suffix}" if suffix else target for target in
|
||||
CONTRACT["modes"][str(mode)][hand].get(button, [])]
|
||||
out.extend((action, f"{prefix}{kind}/{target}") for target in targets)
|
||||
return out
|
||||
|
||||
|
||||
def full_bindings():
|
||||
paths = []
|
||||
for hand, buttons in CONTRACT["buttons"].items():
|
||||
parts = [f"input/{p}" for p in CONTRACT["common"]]
|
||||
parts += [f"output/{p}" for p in CONTRACT["outputs"]]
|
||||
parts += [f"input/{button}/{component}" for button in buttons for component in ("click", "touch")]
|
||||
parts += [f"input/{button}" for button in buttons] + ["input/menu"]
|
||||
parts += [f"input/{p}" for p in CONTRACT["dropped"]]
|
||||
parts += ["input/menu/click", "input/menu/touch", "input/unknown/click", "input/trigger/click",
|
||||
"input/squeeze/click", "input/thumb_fb/proximity_fb", "output/unknown", "input/a/value"]
|
||||
paths.extend(f"/user/hand/{hand}/{part}" for part in parts)
|
||||
paths += ["/user/head/input/aim/pose", "/user/hand/leftover/input/x/click"]
|
||||
return list(enumerate(paths, 1))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0, 1, 2])
|
||||
def test_json_mapping_all_components(harness, mode):
|
||||
bindings = full_bindings()
|
||||
p = run(harness, mode, suggest(bindings), diag=True)
|
||||
expected = translate(bindings, mode)
|
||||
assert calls(p) == [(TOUCH, bindings)] + ([(FRAME, expected)] if mode else [])
|
||||
assert p.stdout.endswith("RESULT 0\n")
|
||||
logs = [line for line in p.stderr.splitlines() if line.startswith("input_diag: quest_buttons ")]
|
||||
if mode:
|
||||
assert len(logs) == len(expected)
|
||||
for action, target in expected:
|
||||
source = dict(bindings)[action]
|
||||
assert f"input_diag: quest_buttons {source} -> {target}" in logs
|
||||
dropped = sum(not translate([binding], mode) for binding in bindings)
|
||||
assert (f"quest_buttons: frame_controller bindings (mode {mode}, {len(expected)} bindings, "
|
||||
f"{dropped} dropped): 0") in p.stderr
|
||||
else:
|
||||
assert not logs and "quest_buttons:" not in p.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [1, 2])
|
||||
def test_vrapi_bridge_shaped_bindings(harness, mode):
|
||||
# Same action handles shared between hands, as in native/vrapi-bridge/input.cpp.
|
||||
bindings = []
|
||||
for hand, buttons in CONTRACT["buttons"].items():
|
||||
common = ["input/grip/pose", "input/trigger/value", "input/squeeze/value", "input/thumbstick",
|
||||
"input/thumbstick/click", "input/trigger/touch", "input/thumbstick/touch", "output/haptic"]
|
||||
bindings.extend((i, f"/user/hand/{hand}/{path}") for i, path in enumerate(common, 1))
|
||||
for i, button in enumerate(buttons):
|
||||
for offset, component in ((9, "click"), (11, "touch")):
|
||||
bindings.append((i + offset, f"/user/hand/{hand}/input/{button}/{component}"))
|
||||
bindings.append((13, "/user/hand/left/input/menu/click"))
|
||||
p = run(harness, mode, suggest(bindings))
|
||||
assert calls(p) == [(TOUCH, bindings), (FRAME, translate(bindings, mode))]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0, 1, 2])
|
||||
def test_touch_pro_fallback_and_current_profile(harness, mode):
|
||||
bindings = [(1, "/user/hand/left/input/x/click"), (1, "/user/hand/right/input/a/touch"),
|
||||
(2, "/user/hand/left/input/thumb_fb/proximity_fb")]
|
||||
touch = bindings[:2]
|
||||
p = run(harness, mode, suggest(bindings, PRO) + f"profile {FRAME}\nprofile {TOUCH}\n", remap=True)
|
||||
assert calls(p) == [(PRO, bindings), (TOUCH, touch)] + ([(FRAME, translate(touch, mode))] if mode else [])
|
||||
assert "RESULT 0\n" in p.stdout
|
||||
profiles = [line for line in p.stdout.splitlines() if line.startswith("PROFILE ")]
|
||||
assert profiles == [f"PROFILE {PRO if mode else FRAME}", f"PROFILE {PRO}"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame_result", [0, -2, -22])
|
||||
def test_frame_result_never_changes_game_result_and_later_touch_replaces(harness, frame_result):
|
||||
first = [(4, "/user/hand/right/input/a/click")]
|
||||
later = [(8, "/user/hand/left/input/y/touch")]
|
||||
p = run(harness, commands=suggest(first, frame_result=frame_result) + suggest(later))
|
||||
assert calls(p) == [(TOUCH, first), (FRAME, translate(first, 1)),
|
||||
(TOUCH, later), (FRAME, translate(later, 1))]
|
||||
assert p.stdout.count("RESULT 0\n") == 2
|
||||
assert f"0 dropped): {frame_result}" in p.stderr
|
||||
|
||||
|
||||
def test_failed_touch_does_not_suggest_frame(harness):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
p = run(harness, commands=suggest(bindings, result=-22))
|
||||
assert calls(p) == [(TOUCH, bindings)]
|
||||
assert p.stdout.endswith("RESULT -22\n")
|
||||
assert "frame_controller bindings" not in p.stderr
|
||||
|
||||
|
||||
def test_nonzero_touch_success_is_preserved(harness):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
p = run(harness, commands=suggest(bindings, result=1, frame_result=-2))
|
||||
assert calls(p) == [(TOUCH, bindings), (FRAME, translate(bindings, 1))]
|
||||
assert p.stdout.endswith("RESULT 1\n")
|
||||
assert "input_diag: quest_buttons" not in p.stderr
|
||||
|
||||
|
||||
def test_touch_pro_success_ignores_frame_failure(harness):
|
||||
bindings = [(1, "/user/hand/left/input/x/click")]
|
||||
p = run(harness, commands=suggest(bindings, PRO, frame_result=-22), remap=True)
|
||||
assert calls(p) == [(PRO, bindings), (TOUCH, bindings), (FRAME, translate(bindings, 1))]
|
||||
assert p.stdout.endswith("RESULT 0\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [1, 2])
|
||||
@pytest.mark.parametrize("previous", [False, True])
|
||||
def test_all_dropped_bindings_are_counted(harness, mode, previous):
|
||||
first = [(1, "/user/hand/right/input/a/click")]
|
||||
dropped = [(1, "/user/hand/left/input/thumbrest/touch")]
|
||||
commands = (suggest(first) if previous else "") + suggest(dropped) + f"profile {FRAME}\n"
|
||||
p = run(harness, mode, commands=commands)
|
||||
assert calls(p) == ([(TOUCH, first), (FRAME, translate(first, mode))] if previous else []) + [(TOUCH, dropped)]
|
||||
assert (f"quest_buttons: frame_controller bindings (mode {mode}, 0 bindings, 1 dropped): not suggested") in p.stderr
|
||||
assert p.stdout.endswith(f"PROFILE {TOUCH if previous else FRAME}\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("first", [False, True])
|
||||
def test_games_own_frame_suggestion_wins(harness, first):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
own = [(2, "/user/hand/right/input/y/click")]
|
||||
commands = suggest(own, FRAME) if first else suggest(bindings)
|
||||
if not first:
|
||||
commands += suggest(own, FRAME)
|
||||
commands += suggest(bindings)
|
||||
p = run(harness, commands=commands)
|
||||
expected = [(FRAME, own), (TOUCH, bindings)] if first else [
|
||||
(TOUCH, bindings), (FRAME, translate(bindings, 1)), (FRAME, own), (TOUCH, bindings)]
|
||||
assert calls(p) == expected
|
||||
|
||||
|
||||
def test_rejected_games_frame_binding_does_not_block_translation(harness):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
p = run(harness, commands=suggest(bindings, FRAME, frame_result=-22) + suggest(bindings))
|
||||
assert calls(p) == [(FRAME, bindings), (TOUCH, bindings), (FRAME, translate(bindings, 1))]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0, 1, 2])
|
||||
@pytest.mark.parametrize("offered", [0, 1, 2, 3, 4, 5])
|
||||
@pytest.mark.parametrize("requested", [False, True])
|
||||
def test_extension_enabled_only_when_offered_and_never_twice(harness, mode, offered, requested):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
p = run(harness, mode, suggest(bindings), offered=offered, requested=requested)
|
||||
enabled = bool(mode and offered == 1)
|
||||
assert f"ENABLED {int(enabled)}\n" in p.stdout
|
||||
exts = [line.removeprefix("EXT ") for line in p.stdout.splitlines() if line.startswith("EXT ")]
|
||||
# Existing pass-through policy retains app-requested extensions when enumeration is unavailable/fails.
|
||||
assert exts.count(CONTRACT["extension"]) == int(enabled or (requested and offered in (1, 2, 3, 4, 5)))
|
||||
assert calls(p) == [(TOUCH, bindings)] + ([(FRAME, translate(bindings, mode))] if enabled else [])
|
||||
if mode:
|
||||
message = "enabled" if enabled else "not offered by the runtime"
|
||||
assert f"quest_buttons: extension {CONTRACT['extension']} {message}" in p.stderr
|
||||
else:
|
||||
assert "quest_buttons:" not in p.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "1", "2", "-1", "3", "1.5", "nan", "inf", "Mirror", "1junk", ""])
|
||||
def test_setting_numbers_only_invalid_values_disable(harness, value):
|
||||
p = run(harness, value)
|
||||
expected = int(value) if value in ("0", "1", "2") else 0
|
||||
assert p.stdout.startswith(f"MODE {expected}\n")
|
||||
assert ("quest_buttons: invalid setting; using 0" in p.stderr) == (value not in ("0", "1", "2"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offered", [0, 1])
|
||||
def test_current_profile_controller_fix_off(harness, offered):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
p = run(harness, commands=suggest(bindings) + f"profile {FRAME}\nprofile {TOUCH}\nprofile none\n"
|
||||
"profile /interaction_profiles/khr/generic_controller\n", offered=offered)
|
||||
profiles = [line.removeprefix("PROFILE ") for line in p.stdout.splitlines() if line.startswith("PROFILE ")]
|
||||
assert profiles == [TOUCH if offered else FRAME, TOUCH, "none", "/interaction_profiles/khr/generic_controller"]
|
||||
|
||||
|
||||
def test_failed_instance_disables_feature(harness):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
p = run(harness, commands=suggest(bindings) + f"profile {FRAME}\n", create_result=-2)
|
||||
assert "ENABLED 0\n" in p.stdout
|
||||
assert calls(p) == [(TOUCH, bindings)]
|
||||
assert p.stdout.endswith(f"PROFILE {FRAME}\n")
|
||||
|
||||
|
||||
def test_off_preserves_runtime_call_bytes(harness):
|
||||
bindings = [(9, "/user/hand/left/input/x/click"), (10, "/user/hand/right/output/haptic")]
|
||||
p = run(harness, 0, suggest(bindings) + suggest(bindings, PRO), remap=True)
|
||||
serialized = "".join(f"{a} {path}\n" for a, path in bindings)
|
||||
assert p.stdout == ("MODE 0\nENUM 0\nENUM 2\nCREATE 1\nEXT XR_KHR_vulkan_enable\nENABLED 0\n" +
|
||||
f"CALL {TOUCH} 2\n{serialized}RESULT 0\n" +
|
||||
f"CALL {PRO} 2\n{serialized}RESULT -22\n")
|
||||
# Without a game's own Touch suggestion, profile_remap still makes exactly its original two calls.
|
||||
p = run(harness, 0, suggest(bindings, PRO), remap=True)
|
||||
assert calls(p) == [(PRO, bindings), (TOUCH, bindings)]
|
||||
assert "quest_buttons:" not in p.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [1, 2])
|
||||
@pytest.mark.parametrize("profile", [TOUCH, PRO])
|
||||
def test_proximity_expansion_becomes_frame_bindings(harness, mode, profile):
|
||||
bindings = [(1, "/user/hand/left/input/thumb_fb/proximity_fb"),
|
||||
(2, "/user/hand/right/input/trigger/value")]
|
||||
# Pro's unsupported proximity path is filtered out during remap, as before; known action handles still expand.
|
||||
p = run(harness, mode, commands="proximity 1 3\n" + suggest(bindings, profile), proximity=2, remap=True)
|
||||
recorded = calls(p)
|
||||
touch = recorded[-2]
|
||||
assert touch[0] == TOUCH and len(touch[1]) > len(bindings)
|
||||
assert recorded[-1] == (FRAME, translate(touch[1], mode))
|
||||
frame_actions = {action for action, _ in recorded[-1][1]}
|
||||
assert {1, 2, 3} <= frame_actions
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [1, 2])
|
||||
def test_bare_pose_parents_are_translated(harness, mode):
|
||||
bindings = [(i, f"/user/hand/{hand}/input/{pose}") for i, (hand, pose) in
|
||||
enumerate(((h, p) for h in ("left", "right") for p in ("grip", "aim")), 1)]
|
||||
assert all(CONTRACT["common"][pose] == [pose] for pose in ("grip", "aim"))
|
||||
p = run(harness, mode, suggest(bindings))
|
||||
assert calls(p) == [(TOUCH, bindings), (FRAME, translate(bindings, mode))]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario,expected", [
|
||||
("none", FRAME), ("rejected", FRAME), ("generated", TOUCH),
|
||||
("own", FRAME), ("generated_then_own", FRAME), ("generated_then_rejected", TOUCH),
|
||||
])
|
||||
def test_profile_rewrite_requires_generated_binding(harness, scenario, expected):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
commands = ""
|
||||
if scenario.startswith("generated"):
|
||||
commands += suggest(bindings)
|
||||
elif scenario == "rejected":
|
||||
commands += suggest(bindings, frame_result=-22)
|
||||
if scenario.endswith("own"):
|
||||
commands += suggest(bindings, FRAME)
|
||||
elif scenario == "generated_then_rejected":
|
||||
commands += suggest(bindings, FRAME, frame_result=-22)
|
||||
p = run(harness, commands=commands + f"profile {FRAME}\n")
|
||||
assert p.stdout.endswith(f"PROFILE {expected}\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("controller_fix", [False, True])
|
||||
def test_other_valve_profiles_keep_existing_controller_fix_behavior(harness, controller_fix):
|
||||
other = "/interaction_profiles/valve/index_controller"
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
p = run(harness, commands=suggest(bindings) + f"profile {other}\n", controller_fix=controller_fix)
|
||||
assert p.stdout.endswith(f"PROFILE {TOUCH if controller_fix else other}\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offered", [0, 1])
|
||||
@pytest.mark.parametrize("owner", ["generated", "app"])
|
||||
def test_failed_second_instance_preserves_live_binding_state(harness, offered, owner):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
commands = suggest(bindings, FRAME if owner == "app" else TOUCH)
|
||||
commands += f"create {offered} -2\nprofile {FRAME}\n" + suggest(bindings)
|
||||
p = run(harness, commands=commands)
|
||||
assert "ENABLED 1\nACTIVE 1\n" in p.stdout
|
||||
assert f"PROFILE {FRAME if owner == 'app' else TOUCH}\n" in p.stdout
|
||||
expected = [(FRAME, bindings), (TOUCH, bindings)] if owner == "app" else [
|
||||
(TOUCH, bindings), (FRAME, translate(bindings, 1)), (TOUCH, bindings), (FRAME, translate(bindings, 1))]
|
||||
assert calls(p) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("owner", ["generated", "app"])
|
||||
def test_successful_second_instance_resets_binding_ownership(harness, owner):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
commands = suggest(bindings, FRAME if owner == "app" else TOUCH)
|
||||
commands += f"create 1 0\nprofile {FRAME}\n" + suggest(bindings)
|
||||
p = run(harness, commands=commands)
|
||||
assert "ENABLED 1\nACTIVE 2\n" in p.stdout
|
||||
assert f"PROFILE {FRAME}\n" in p.stdout
|
||||
initial = [(FRAME, bindings)] if owner == "app" else [(TOUCH, bindings), (FRAME, translate(bindings, 1))]
|
||||
assert calls(p) == initial + [(TOUCH, bindings), (FRAME, translate(bindings, 1))]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("profile", [TOUCH, PRO])
|
||||
def test_nested_proximity_fallback_does_not_deadlock_and_translates_accepted_set(harness, profile):
|
||||
bindings = [(1, "/user/hand/left/input/x/click")]
|
||||
commands = "proximity 3 4\nreject_touch_once -22\n" + suggest(bindings, profile) + suggest(bindings)
|
||||
p = run(harness, commands=commands, remap=True, proximity=2)
|
||||
recorded = calls(p)
|
||||
offset = int(profile == PRO)
|
||||
assert recorded[offset][0] == TOUCH and len(recorded[offset][1]) > len(bindings)
|
||||
assert recorded[offset + 1:offset + 3] == [(TOUCH, bindings), (FRAME, translate(bindings, 1))]
|
||||
# A second hook transaction also completes: all fallback paths released the mutex.
|
||||
assert recorded[-1] == (FRAME, translate(recorded[-2][1], 1))
|
||||
|
||||
|
||||
def test_settings_and_hook_wiring():
|
||||
source = (ROOT / "native/adapter/frame_adapter.c").read_text()
|
||||
settings = source[source.index("static void read_settings("):source.index("static void initialize(")]
|
||||
assert "quest_buttons_setting(line);" in settings
|
||||
hook = source[source.index("if (layer_debug || aim_correction_on() || profile_remap"):]
|
||||
assert "|| quest_buttons)\n HOOK_AS(xrSuggestInteractionProfileBindings" in hook
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Store title provenance and sexual-content rejection (#377)."""
|
||||
import pytest
|
||||
|
||||
from frameport import pipeline
|
||||
from frameport.artwork import details, fetch, sources
|
||||
from frameport.core import library
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ids,age,notes", [([1], 0, ""), ([3], "18", ""), ([4], 0, ""),
|
||||
([5], 18, "Sexual content")])
|
||||
def test_adult_steam_rejected(monkeypatch, ids, age, notes):
|
||||
app = {"steam_appid": 1, "required_age": age, "content_descriptors": {"ids": ids, "notes": notes},
|
||||
"screenshots": [{"path_full": "explicit"}], "about_the_game": "adult"}
|
||||
|
||||
def cached(name, url, **kwargs):
|
||||
if "storesearch" in url:
|
||||
return {"items": [{"id": 1, "name": "Lollipop"}]}
|
||||
if "appdetails" in url:
|
||||
return {"1": {"success": True, "data": app}}
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(sources.cache, "cached_json", cached)
|
||||
monkeypatch.setattr(sources.cache, "http_get", lambda *a, **k: pytest.fail("downloaded adult image"))
|
||||
assert not details.fetch_details({"package": "com.game", "title": "Lollipop"}).get("description")
|
||||
assert not sources.apply_steam("com.game", 1)
|
||||
assert not sources.fetch_rift("rift.game", "Lollipop", oculus=False).get("source")
|
||||
assert not details.screenshot_files("com.game")
|
||||
|
||||
|
||||
def test_general_mature_allowed():
|
||||
assert not sources.sexual_content({"required_age": 18, "content_descriptors": {"ids": [5]}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stored", [
|
||||
{"sources": ["steam"]}, # legacy migration
|
||||
{"sources": ["oculusdb"], "matches": {"oculusdb": {"by": "title", "key": "Old"}}},
|
||||
{"sources": ["oculusdb"]}, # legacy Rift lookup is conservatively name-dependent
|
||||
])
|
||||
@pytest.mark.parametrize("reset", [False, True])
|
||||
def test_rename_clears_and_refetches(monkeypatch, stored, reset):
|
||||
pkg = "rift.game"
|
||||
library.upsert_game(pkg, title="Old", kind="rift", auto_title="Automatic", title_locked=True, details=stored)
|
||||
d = fetch.artwork_dir(pkg)
|
||||
for name in ("shot_1.jpg", "t_shot_1_480_abc.jpg", "portrait.jpg", ".picked"):
|
||||
(d / name).write_bytes(b"custom")
|
||||
seen = []
|
||||
|
||||
def refetch(entry, **kwargs):
|
||||
assert "details" not in entry
|
||||
assert not list(d.glob("shot_*")) and not list(d.glob("t_shot_*"))
|
||||
seen.append(entry["title"])
|
||||
return {"sources": [], "fetched": 1}
|
||||
|
||||
monkeypatch.setattr(details, "fetch_details", refetch)
|
||||
g = pipeline.rename_game(pkg, None if reset else "New")
|
||||
assert seen == ["Automatic" if reset else "New"]
|
||||
assert g["details"]["fetched"] == 1
|
||||
assert (d / ".picked").read_bytes() == (d / "portrait.jpg").read_bytes() == b"custom"
|
||||
|
||||
|
||||
def test_package_details_kept(monkeypatch):
|
||||
stored = {"sources": ["oculusdb"], "matches": {"oculusdb": {"by": "package", "key": "com.game"}}}
|
||||
library.upsert_game("rift.game", kind="rift", title="Old", details=stored)
|
||||
monkeypatch.setattr(details, "fetch_details", lambda *a, **k: pytest.fail("unnecessary fetch"))
|
||||
assert pipeline.rename_game("rift.game", "New")["details"] == stored
|
||||
|
||||
|
||||
def test_deferred_rename_does_no_network(monkeypatch):
|
||||
library.upsert_game("com.game", title="Old", details={"sources": ["steam"]})
|
||||
monkeypatch.setattr(details, "fetch_details", lambda *a, **k: pytest.fail("synchronous fetch"))
|
||||
assert "details" not in pipeline.rename_game("com.game", "New", refresh_details=False)
|
||||
|
||||
|
||||
def test_match_provenance(monkeypatch):
|
||||
monkeypatch.setattr(sources, "oculusdb_search", lambda *a, **k: [{"packageName": "com.game", "id": 1}])
|
||||
monkeypatch.setattr(details, "steam_app", lambda title: {"steam_appid": 2})
|
||||
d = details.fetch_details({"package": "com.game", "title": "Game"})
|
||||
assert d["matches"] == {"oculusdb": {"by": "package", "key": "com.game"},
|
||||
"steam": {"by": "title", "key": "Game"}}
|
||||
|
||||
|
||||
def test_gui_rename_schedules_details(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from frameport.ui.app import FramePortApp
|
||||
|
||||
library.upsert_game("com.game", title="Old", details={"sources": ["steam"]})
|
||||
monkeypatch.setattr(details, "fetch_details", lambda *a, **k: pytest.fail("network in GUI handler"))
|
||||
queued = []
|
||||
app = SimpleNamespace(_title=lambda pkg: library.game(pkg)["title"], frame_state="disconnected",
|
||||
pc_installs=lambda: {}, refresh_details=queued.append,
|
||||
toast=lambda *a, **k: None, refresh_view=lambda: None)
|
||||
FramePortApp._apply_rename(app, "com.game", "New")
|
||||
assert queued == ["com.game"] and "details" not in library.game("com.game")
|
||||
|
||||
|
||||
def test_cli_rename_and_reset_refetch(monkeypatch):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport.cli import app
|
||||
|
||||
library.upsert_game("com.game", title="Old", details={"sources": ["steam"]})
|
||||
seen = []
|
||||
|
||||
def refetch(entry, **kwargs):
|
||||
seen.append(entry["title"])
|
||||
return {"sources": ["steam"], "matches": {"steam": {"by": "title", "key": entry["title"]}}}
|
||||
|
||||
monkeypatch.setattr(details, "fetch_details", refetch)
|
||||
for args in (["New"], ["--reset"]):
|
||||
result = CliRunner().invoke(app, ["rename", "com.game", *args, "--no-sync"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert seen == ["New", "Old"]
|
||||
@@ -12,7 +12,8 @@ def test_video_patches_outdate_only_the_recipes_that_gain_them():
|
||||
from frameport.patches.base import get, recipe_fingerprint
|
||||
from frameport.ui.components import install_state
|
||||
|
||||
assert get("frame.adapter").revision == 1 and not get("frame.adapter").package_revisions
|
||||
# revision 2 (#365) adds the dormant foveation diagnostic to every build; this test is about the package check
|
||||
assert get("frame.adapter").revision == 2 and not get("frame.adapter").package_revisions
|
||||
before = {"patches": {"frame.adapter": {}}}
|
||||
after = {"patches": {"frame.adapter": {}, "frame.hw_video_decode": {}, "adapter.surface_native": {"value": 1}}}
|
||||
for package, recipe, expected in (("com.camouflaj.manta", after, "outdated"),
|
||||
|
||||
@@ -130,7 +130,9 @@ def test_build_leaves_out_the_haptic_workaround_with_a_fixed_runtime(tmp_path, m
|
||||
with zipfile.ZipFile(apk, "w") as z:
|
||||
z.writestr("lib/arm64-v8a/libopenxr_loader.so", runtime)
|
||||
z.writestr("lib/arm64-v8a/libopenxr_loader_generic.so", b"loader")
|
||||
monkeypatch.setattr(adapter, "artifact", lambda abi, name: b"artifact " + name.encode())
|
||||
real_artifact = adapter.artifact
|
||||
monkeypatch.setattr(adapter, "artifact", lambda abi, name: real_artifact(abi, name)
|
||||
if name == adapter.GENERIC else b"artifact " + name.encode())
|
||||
recipe = Recipe("com.x", patches={"frame.adapter": {}, "adapter.haptic_fix": {"value": 1}})
|
||||
analysis = Analysis(package="com.x", version="1", label="X", abis=["arm64-v8a"], engine="Unity", xr="OpenXR",
|
||||
graphics="Vulkan", direct_vrapi=False, libs=["libOVRPlugin.so"], launcher_activity=None,
|
||||
|
||||
Reference in new issue
Block a user