Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cda1a3d213 | ||
|
|
eaf4e3d5b6 | ||
|
|
05f221b216 | ||
|
|
472c607efd | ||
|
|
33bb14e034 | ||
|
|
0c06e1142f | ||
|
|
358db1360f | ||
|
|
1b43c1c544 | ||
|
|
5ae303fc45 | ||
|
|
72edbebac1 | ||
|
|
ca80018d98 | ||
|
|
a585e24647 | ||
|
|
b42d85d7c4 | ||
|
|
c9501bd446 | ||
|
|
bc728bb121 | ||
|
|
838952b4f5 | ||
|
|
eab28b98c5 | ||
|
|
1f64d089e1 | ||
|
|
49de808650 | ||
|
|
54a2d84755 | ||
|
|
31b4c684fe | ||
|
|
5d483746f9 | ||
|
|
1f27cd47a3 | ||
|
|
ff592e41e8 | ||
|
|
72d755973f | ||
|
|
d02ff3c9aa | ||
|
|
107215525f | ||
|
|
a32e48367a | ||
|
|
95a226d54f | ||
|
|
6a6b6be5b5 | ||
|
|
01ad5e6b97 | ||
|
|
33d306a11e | ||
|
|
8e463a55de | ||
|
|
faca9bf396 | ||
|
|
e0a9b5f7cb | ||
|
|
3e906d55cf | ||
|
|
5e668f33d3 | ||
|
|
feb4658dde | ||
|
|
4ed7929b1c | ||
|
|
61a6b3e2b2 | ||
|
|
78fc926a48 | ||
|
|
503e64367c | ||
|
|
3deb279270 | ||
|
|
0df1212bfe |
No files matched your search
@@ -251,7 +251,11 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
restore = untrusted input (staged + checked first), rollback copy `<base>/saves-before-restore.tar.gz` (undo =
|
||||
`rollback: true`). PC `install/saves.py` (`<data>/save-backups/<pkg>/<ts>.tar.gz` + `.json`, keep `saves.keep`
|
||||
= 10), GUI `ui/views/saves_dialog.py` (game menu section "Saves", Frame page "Back up all saves", uninstall's
|
||||
"back up before deleting"), CLI `frameport saves backup|list|restore|undo`.
|
||||
"back up before deleting"), CLI `frameport saves backup|list|restore|undo`. Agent v101: PC folder `saves.folder`
|
||||
(Settings → Data; old folders stay listed in `saves.previous_folders`), automatic snapshots `saves.auto` (default
|
||||
off; flag `~/.local/share/frameport/save-snapshots/enabled`, Quest launchers run `_snapshot_close` in the EXIT
|
||||
trap under the launch lock, newest 3 per game on the Frame; `install/saves.sync_snapshots` imports them as
|
||||
"automatic" once, ledger `<data>/save-snapshots-synced.json`). Device-checked 2026-10-17 with BlazeRush.
|
||||
- `patches/` — **the unit of modularity**. `base.py` (Patch interface, registry), `overport.py` (overport CLI patch ids,
|
||||
discovered dynamically via `overport patches`), `frame/*.py` (one module per Frame fix), `settings.py` (FrameBridge
|
||||
adapter keys + device files as patches). Add a patch = add a module that calls `register(...)`.
|
||||
@@ -553,6 +557,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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -46,7 +46,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 99
|
||||
AGENT_VERSION = 101
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -3308,6 +3308,7 @@ stop() {{
|
||||
[[ -n "$child" ]] && kill -TERM -- "-$child" 2>/dev/null || true
|
||||
podman kill "lepton-steamlaunch-$SteamAppId" >/dev/null 2>&1 || true
|
||||
{plays_end}
|
||||
{save_snapshot}
|
||||
}}
|
||||
trap stop EXIT
|
||||
trap 'exit 130' INT
|
||||
@@ -3440,6 +3441,11 @@ def plays_lines(anchor):
|
||||
f'echo "end $(date +%s)" >>{q} 2>/dev/null || true')
|
||||
|
||||
|
||||
def snapshot_line(pkg):
|
||||
return (f"timeout -k 5 90 python3 {shlex.quote(os.path.abspath(__file__))} _snapshot_close "
|
||||
f"{shlex.quote(pkg)} >>\"$app_dir/save-snapshot.log\" 2>&1 || true")
|
||||
|
||||
|
||||
def upgrade_launchers():
|
||||
"""Give launchers written by older agents the parent watchdog, the dashboard closer and the play-session log (in
|
||||
place: a new file, so a running launcher keeps reading the old one). Returns the packages changed."""
|
||||
@@ -3498,6 +3504,10 @@ def upgrade_launchers():
|
||||
pkg = os.path.basename(os.path.dirname(path))
|
||||
new = set_config_sync_line(new, pkg, wants_config_sync(os.path.dirname(path)))
|
||||
new = set_refresh_rate_line(new, pkg)
|
||||
if "export LEPTON_ENV_FRAMEBRIDGE_CONFIG=" in new and "_snapshot_close" not in new:
|
||||
# The end log follows podman kill in stop(), while the launcher still owns fd 9.
|
||||
end = plays_lines(os.path.dirname(path))[1]
|
||||
new = new.replace(end, end + "\n " + snapshot_line(os.path.basename(os.path.dirname(path))), 1)
|
||||
if new == text:
|
||||
continue
|
||||
tmp = path + ".tmp"
|
||||
@@ -3597,7 +3607,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
|
||||
@@ -3883,7 +3893,8 @@ def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False,
|
||||
cloud_dirs=cloud_dirs_line(pkg),
|
||||
video_codec=wrapper_lines(hw_video, gamepad),
|
||||
plays_start=plays_lines(anchor)[0],
|
||||
plays_end=plays_lines(anchor)[1], restart=restart_block(pkg, lepton))
|
||||
plays_end=plays_lines(anchor)[1], save_snapshot=snapshot_line(pkg),
|
||||
restart=restart_block(pkg, lepton))
|
||||
text = set_config_sync_line(text, pkg, config_sync)
|
||||
text = set_refresh_rate_line(text, pkg)
|
||||
path = os.path.join(anchor, "launch.sh")
|
||||
@@ -5716,7 +5727,7 @@ def cmd_keep_awake(args):
|
||||
|
||||
|
||||
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_DISABLE_VULKAN_FDM_INJECTION_LAYER", "VK_INSTANCE_LAYERS", "FOVE_INNER", "FOVE_OUTER", "FOVE_LEVEL",
|
||||
"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"}
|
||||
|
||||
@@ -8434,7 +8445,8 @@ def _dep_version(dep):
|
||||
return str((dep or {}).get("version") or (dep or {}).get("source_version") or "")
|
||||
|
||||
|
||||
def write_saves_tar(out, pkg, roots, dep=None, max_file=SAVES_MAX_FILE, max_total=SAVES_MAX_TOTAL):
|
||||
def write_saves_tar(out, pkg, roots, dep=None, max_file=SAVES_MAX_FILE, max_total=SAVES_MAX_TOTAL,
|
||||
automatic=False):
|
||||
"""Stream the game's saves as tar.gz to `out` (a binary file object). Returns the manifest (also the archive's
|
||||
last member, frameport-saves.json)."""
|
||||
import gzip
|
||||
@@ -8448,7 +8460,8 @@ def write_saves_tar(out, pkg, roots, dep=None, max_file=SAVES_MAX_FILE, max_tota
|
||||
f"save backup takes (that's game content, not saves)")
|
||||
manifest = {"format": SAVES_FORMAT, "package": pkg, "title": (dep or {}).get("title") or pkg,
|
||||
"version": _dep_version(dep), "created": time.time(), "agent": AGENT_VERSION,
|
||||
"roots": sorted(roots), "max_file": max_file, "files": [], "dirs": [], "skipped": found["skipped"]}
|
||||
"automatic": automatic, "roots": sorted(roots), "max_file": max_file,
|
||||
"files": [], "dirs": [], "skipped": found["skipped"]}
|
||||
gz = gzip.GzipFile(fileobj=out, mode="wb", compresslevel=6)
|
||||
with tarfile.open(fileobj=gz, mode="w|", format=tarfile.PAX_FORMAT) as tar:
|
||||
for arc, _p, s in found["dirs"]:
|
||||
@@ -8872,6 +8885,9 @@ def saves_ns_main(op, raw):
|
||||
return saves_backup_session(args, sys.stdout.buffer, sys.stderr)
|
||||
if op == "restore":
|
||||
return saves_restore_session(args, sys.stdin.buffer, sys.stdout)
|
||||
if op == "snapshot":
|
||||
print(json.dumps(create_save_snapshot(args["package"])))
|
||||
return 0
|
||||
if op == "info":
|
||||
try:
|
||||
_pkg, _dep, roots = saves_context(args)
|
||||
@@ -8883,10 +8899,135 @@ def saves_ns_main(op, raw):
|
||||
return 2
|
||||
|
||||
|
||||
SNAPSHOT_ROOT = os.path.expanduser("~/.local/share/frameport/save-snapshots")
|
||||
SNAPSHOT_NAME = re.compile(r"^[0-9]+-[a-f0-9]{64}\.tar\.gz$")
|
||||
|
||||
|
||||
def cmd_save_snapshot_settings(args):
|
||||
"""Global opt-in, read at every game exit, including when the PC is disconnected."""
|
||||
os.makedirs(SNAPSHOT_ROOT, exist_ok=True)
|
||||
flag = os.path.join(SNAPSHOT_ROOT, "enabled")
|
||||
if args.get("enabled") and not os.path.isfile(flag):
|
||||
with open(flag + ".tmp", "w") as f:
|
||||
f.write("1\n")
|
||||
os.replace(flag + ".tmp", flag)
|
||||
elif not args.get("enabled") and os.path.exists(flag):
|
||||
os.remove(flag)
|
||||
return {"enabled": os.path.isfile(flag)}
|
||||
|
||||
|
||||
def snapshot_files(pkg):
|
||||
folder = os.path.join(SNAPSHOT_ROOT, check_pkg(pkg))
|
||||
return sorted((p for p in glob.glob(os.path.join(folder, "*.tar.gz"))
|
||||
if SNAPSHOT_NAME.fullmatch(os.path.basename(p)) and not os.path.islink(p)), reverse=True)
|
||||
|
||||
|
||||
def cmd_save_snapshots(args):
|
||||
out = []
|
||||
for folder in sorted(glob.glob(os.path.join(SNAPSHOT_ROOT, "*"))):
|
||||
pkg = os.path.basename(folder)
|
||||
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+", pkg) or os.path.islink(folder):
|
||||
continue
|
||||
for path in reversed(snapshot_files(pkg)): # oldest first: PC retention keeps the newest
|
||||
name = os.path.basename(path)
|
||||
out.append({"package": pkg, "snapshot": name, "sha256": name.split("-", 1)[1][:-7]})
|
||||
return {"snapshots": out}
|
||||
|
||||
|
||||
def create_save_snapshot(pkg):
|
||||
"""Called in the user namespace with the launch lock held; publish complete archives only."""
|
||||
pkg, dep, roots = saves_context({"package": pkg})
|
||||
if saves_running(dep):
|
||||
raise AgentError("the game is running")
|
||||
folder = os.path.join(SNAPSHOT_ROOT, pkg)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
found = collect_saves(roots)
|
||||
if sum(st.st_size for _arc, _path, st in found["files"]) > SAVES_MAX_TOTAL:
|
||||
raise AgentError("saves exceed the snapshot size limit")
|
||||
entries = []
|
||||
for arc, path, st in found["files"]:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
entries.append((arc, st.st_size, digest.hexdigest()))
|
||||
fingerprint = hashlib.sha256(json.dumps(entries).encode()).hexdigest()
|
||||
previous = snapshot_files(pkg)
|
||||
if previous:
|
||||
try:
|
||||
with open(previous[0] + ".json") as f:
|
||||
if json.load(f).get("fingerprint") == fingerprint:
|
||||
return {"created": False, "reason": "unchanged"}
|
||||
except (OSError, ValueError):
|
||||
pass # damaged metadata must not prevent a new backup
|
||||
part = os.path.join(folder, "snapshot.part")
|
||||
try:
|
||||
with open(part, "wb") as f:
|
||||
write_saves_tar(f, pkg, roots, dep, automatic=True)
|
||||
digest = hashlib.sha256()
|
||||
with open(part, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
name = f"{time.time_ns()}-{digest.hexdigest()}.tar.gz"
|
||||
dest = os.path.join(folder, name)
|
||||
with open(dest + ".json", "w") as f:
|
||||
json.dump({"fingerprint": fingerprint}, f)
|
||||
os.replace(part, dest)
|
||||
for old in snapshot_files(pkg)[3:]:
|
||||
os.remove(old)
|
||||
if os.path.exists(old + ".json"):
|
||||
os.remove(old + ".json")
|
||||
return {"created": True, "snapshot": name}
|
||||
finally:
|
||||
if os.path.exists(part):
|
||||
os.remove(part)
|
||||
|
||||
|
||||
def snapshot_close(pkg):
|
||||
if not os.path.isfile(os.path.join(SNAPSHOT_ROOT, "enabled")):
|
||||
return 0
|
||||
_pkg, dep, _roots = saves_context({"package": pkg})
|
||||
# Never trust an environment variable claiming the game is stopped: verify the inherited lock itself.
|
||||
lock = os.stat(os.path.join(dep["base"], ".launch.lock"))
|
||||
inherited = os.fstat(9)
|
||||
if (lock.st_dev, lock.st_ino) != (inherited.st_dev, inherited.st_ino) or fcntl is None:
|
||||
raise AgentError("snapshot needs the launcher's lock")
|
||||
fcntl.flock(9, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
owner = as_owner()
|
||||
if owner:
|
||||
return subprocess.run(owner + [sys.executable or "python3", os.path.abspath(__file__),
|
||||
"_saves_ns", "snapshot", json.dumps({"package": pkg})],
|
||||
pass_fds=(9,)).returncode
|
||||
print(json.dumps(create_save_snapshot(pkg)))
|
||||
return 0
|
||||
|
||||
|
||||
def fetch_save_snapshot(args, out, err):
|
||||
try:
|
||||
pkg, name = check_pkg(args.get("package")), args.get("snapshot") or ""
|
||||
if not SNAPSHOT_NAME.fullmatch(name):
|
||||
raise AgentError("invalid save snapshot")
|
||||
path = os.path.join(SNAPSHOT_ROOT, pkg, name)
|
||||
if path not in snapshot_files(pkg) or os.path.islink(os.path.dirname(path)):
|
||||
raise AgentError("save snapshot no longer available")
|
||||
with open(path, "rb") as f:
|
||||
shutil.copyfileobj(f, out, 1 << 16)
|
||||
out.flush()
|
||||
err.write(json.dumps({"ok": True}) + "\n")
|
||||
return 0
|
||||
except (OSError, AgentError) as exc:
|
||||
err.write(json.dumps({"ok": False, "error": str(exc)}) + "\n")
|
||||
return 1
|
||||
|
||||
|
||||
COMMANDS = {n[4:]: f for n, f in globals().items() if n.startswith("cmd_")}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == "_snapshot_close":
|
||||
return snapshot_close(sys.argv[2])
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == "_fetch_save_snapshot":
|
||||
return fetch_save_snapshot(json.loads(sys.argv[2]), sys.stdout.buffer, sys.stderr)
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "_keyboard":
|
||||
return keyboard_session(sys.stdin, sys.stdout)
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "_vr_overlay_watch":
|
||||
|
||||
@@ -8,16 +8,16 @@ d6337034fb0bdcca7ef9b7063d85b7586b8508b63b435959e8fffaba0691091c ./arm64-v8a/li
|
||||
321da502e0f8f46f0880aad39fe6b84bdef88f925164568814cf704a1b31a577 ./arm64-v8a/libfp_ovrp.so
|
||||
40defdaddcda53bd2649eb48076fae1622bfc2bfc88e9e03c94fc8b45af2a4f2 ./arm64-v8a/libfp_ovrtrace.so
|
||||
0167c2fde5dfde62897f9d85afc4fc8e97784e988b5b8df6cea8c407240b3424 ./arm64-v8a/libfp_tkeys.so
|
||||
468b97ee3c36481d939a87425b15880c7a801d16732350e1e2832b385d63f20a ./arm64-v8a/libfp_vk.so
|
||||
e32f9b3222bfe2e58c193db809ac2cf54086df36fd2ec6651aa2e58308f6b39d ./arm64-v8a/libfp_vk.so
|
||||
383054f8b3b41dde76d062c71856cd3163655e50009c08454bc1793f393ead65 ./arm64-v8a/libfpg.so
|
||||
4a13a33e112c9e4e3c5ff4337ed6e988a9e5b3602f48d654df148ef5fafbf426 ./arm64-v8a/libfpglmv.so
|
||||
36400eae4ba3807bcfcd54463daa217347032b07218792faa448d8cbbd086a0e ./arm64-v8a/libframe_xrshim.so
|
||||
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
|
||||
2d1726aaa8ee0ce788df1b8727bd55ab72efae749e7652f38f67e5e317b74b2f ./arm64-v8a/libopenxr_loader_generic.so
|
||||
3f0a0152e58f2e4442df654039694047d5abd02528c7ffa3ee66116ef9dbde69 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
|
||||
eba4e9ed8563fa0de00d88bb85f960e79f13a6f60eddd437b203c8d3b7d308af ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
ac214cb722d9e48009dac084f5b4382a91b6c4f317831f54c425e8f4ff43d6c1 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
|
||||
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
|
||||
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package: ScaredBySquares.Game
|
||||
title: Scared By Squares
|
||||
status: unknown
|
||||
status: works
|
||||
notes: 'Showed black and dropped back to the home environment while the game kept running (sound only, GitHub 323).
|
||||
Fixed in FramePort 1.2.1 and checked on the Frame without the headset (the game runs at 72 fps and SteamVR keeps it
|
||||
as the running game); not yet confirmed in the headset. Rebuild and reinstall the game after updating FramePort.'
|
||||
Fixed in FramePort 1.2.1 (confirmed in the headset by the reporter). Rebuild and reinstall the game after updating
|
||||
FramePort.'
|
||||
details: 'The game (Unreal 4.27, OpenXR) creates two controller actions named "turn" (an action mapping and an axis
|
||||
mapping called Turn). The Frame''s runtime accepts the duplicate, then fails to load the input manifest it builds
|
||||
from the actions ("SetXrInputConfig failed. Failed to load manifest") and treats the game as closed. FrameBridge''s
|
||||
@@ -14,6 +14,9 @@ xr: OpenXR
|
||||
frame:
|
||||
- frame.hw_video_decode
|
||||
- frame.unreal_skip_obb_check
|
||||
updated: '2026-10-16'
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
issue: 323
|
||||
updated: '2026-10-17'
|
||||
min_app: 1.2.1
|
||||
source_hint: Scared By Squares
|
||||
@@ -0,0 +1,23 @@
|
||||
package: com.Appnori.SummerSports
|
||||
title: Summer Sports
|
||||
status: works
|
||||
tested_version: 0.5.1
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.unity_runtime_msaa_off
|
||||
- frame.unity_gl_shim
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
known_good_sha256: 582adf5a538eb9565921cc0384e61bf113ab9215c1ad462fade82d06ad3302fa
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 96
|
||||
issue: 395
|
||||
source_hint: All in One summer Sports VR
|
||||
@@ -0,0 +1,23 @@
|
||||
package: com.BolverkGamesFlat2VR.SurvivingMarsPioneer
|
||||
title: 'Surviving Mars: Pioneer'
|
||||
status: works
|
||||
tested_version: 1.0.14
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.hw_video_decode
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
scale: 2.0
|
||||
verified:
|
||||
date: '2026-10-18'
|
||||
known_good_sha256: 3a9ff281417c219736ee10969e6ce4f7d8395472cb60718db8839d65edac4777
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 96
|
||||
issue: 399
|
||||
source_hint: Surviving Mars Pioneer
|
||||
@@ -0,0 +1,24 @@
|
||||
package: com.CarbonStudio.TheWizards
|
||||
title: The Wizards
|
||||
status: works
|
||||
tested_version: '1.11'
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
use_alt: true
|
||||
frame:
|
||||
- frame.unreal_thumb_touch
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
pose_time_fix: 1
|
||||
scale: 2.0
|
||||
verified:
|
||||
date: '2026-10-18'
|
||||
known_good_sha256: c1d979d746c2be941e150e948aec755d85034de829abfe30e5bf0761a49135ef
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 96
|
||||
issue: 397
|
||||
source_hint: the wizards
|
||||
@@ -0,0 +1,25 @@
|
||||
package: com.CortopiaStudios.Spectre
|
||||
title: Spectre
|
||||
status: works
|
||||
notes: Works since 1.2.1 (the loading screen waited for a Meta analytics request).
|
||||
tested_version: 1.0.1.1354
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.hw_video_decode
|
||||
- frame.cortopia_org_id
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
scale: 2.0
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
known_good_sha256: a37a5400ef58906b575f6da1649b62ac7ce024eab831fae105457c1d5d12c2de
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 96
|
||||
issue: 390
|
||||
source_hint: Wonder
|
||||
@@ -0,0 +1,20 @@
|
||||
package: com.Meta4.TFBeyondReality
|
||||
title: 'Transformers Beyond Reality: Redux'
|
||||
status: unknown
|
||||
notes: Starts and runs at 72 fps in headless launch tests (2026-10-17); not yet checked in the headset. Steam's shader
|
||||
cache layer is left out and a few shaders the Frame's driver refuses are skipped, so some objects may be missing.
|
||||
details: Unreal Engine game (Binned3 allocator, GitHub issue 349). Crash 1 - the allocator's 76 GiB up-front
|
||||
reservation is refused by the Frame's kernel (frame.unreal_noreserve, suggested by analysis). Crash 2 - Unreal's
|
||||
"Shader compilation failures are Fatal." With Lepton's Fossilize layer loaded every graphics pipeline failed with
|
||||
VK_ERROR_OUT_OF_HOST_MEMORY, even a constant-colour one (not the pipeline cache, not the CPU allocator, not Valve's
|
||||
FDM/RPO layers); vk_no_fossilize takes the layer out of the game's instance. A few pipelines with the game's
|
||||
multiview vertex shaders then still fail in Turnip; vk_pipeline_fallback creates them with blank shaders.
|
||||
tested_version: 1.495.34291
|
||||
engine: Unreal
|
||||
xr: OpenXR
|
||||
adapter:
|
||||
vk_no_fossilize: 1
|
||||
vk_pipeline_fallback: 1
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
issue: 349
|
||||
@@ -0,0 +1,21 @@
|
||||
package: com.WenklyStudio.SurvivalNation
|
||||
title: Survival Nation
|
||||
status: works
|
||||
tested_version: 1.14.1
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
scale: 1.8
|
||||
verified:
|
||||
date: '2026-10-18'
|
||||
known_good_sha256: 7903dfee1c9c36d19e1c6c78a1f0ede1f0b25ce6a6065811997297e701009250
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 96
|
||||
issue: 401
|
||||
source_hint: survival nation
|
||||
@@ -0,0 +1,24 @@
|
||||
package: com.cosmorama.tabletroopers
|
||||
title: Table Troopers
|
||||
status: works
|
||||
tested_version: 2026.40.4
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.gms_platform_version
|
||||
- frame.photon_voice_audio_route
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
scale: 2.0
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
known_good_sha256: b0a4b0e29855277712b580bd5507721fff137bde77b1911b34cca89c627feef1
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 96
|
||||
issue: 391
|
||||
source_hint: Troopers
|
||||
@@ -0,0 +1,22 @@
|
||||
package: com.el.blackholepooldemo
|
||||
title: Black Hole Pool
|
||||
status: works
|
||||
tested_version: 5.3.2
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.vivox_audio_route
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
known_good_sha256: 17c23df3813324250ec1792d8dc0488dadf0df0a7ccbe33157d88f427889a92e
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 96
|
||||
issue: 396
|
||||
source_hint: Black Hole Pool
|
||||
@@ -0,0 +1,12 @@
|
||||
package: com.rrrgames.ThiefSimVRQuest
|
||||
title: Thief Simulator
|
||||
status: unsupported
|
||||
notes: Quits at start with the game's own "installed from unknown source" message. Lepton installs the game itself at
|
||||
every start (there is no Meta store app on the Frame), so the game never sees the Meta store as its source.
|
||||
tested_version: 1.1.7.3
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
issue: 368
|
||||
updated: '2026-10-17'
|
||||
@@ -0,0 +1,15 @@
|
||||
package: org.primedgun.v2
|
||||
title: PrimedGun v2
|
||||
status: unknown
|
||||
notes: 'Without the Vulkan library rename: the game builds the library name from two pieces, so the renamed name didn''t
|
||||
exist ("libfp_vkn.so"), its renderer fell back to OpenGL and VR couldn''t start (GitHub #254). Not yet confirmed in
|
||||
the headset.'
|
||||
tested_version: 2.0.0-alpha.4
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.sdl_clipboard
|
||||
frame_remove:
|
||||
- frame.vk_sanitize
|
||||
updated: '2026-10-18'
|
||||
source_hint: PrimedGun v2 Quest APK (github.com/iChris4/MetroidPrimePortVR)
|
||||
@@ -313,6 +313,16 @@ signatures:
|
||||
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: vulkan-pipeline-refused
|
||||
# GitHub #349: with Lepton's Fossilize layer loaded every Transformers pipeline failed with -1; without it a few
|
||||
# multiview vertex shaders still fail in Turnip. Unreal ends at the first one.
|
||||
pattern: '(Shader compilation failures are Fatal|vk shim: vkCreateGraphicsPipelines failed: -\d+)'
|
||||
severity: fatal
|
||||
diagnosis: The Frame's graphics driver refused to build some of the game's shaders, and Unreal ends the game at the
|
||||
first one (for example Transformers Beyond Reality). Try leaving out Steam's shader cache layer, and skipping the
|
||||
shaders the driver still refuses (they then draw nothing).
|
||||
suggest: [adapter.vk_no_fossilize, adapter.vk_pipeline_fallback]
|
||||
supersedes: [native-crash, force-quit]
|
||||
- id: graphics-requirements-missing
|
||||
pattern: '(XR_ERROR_GRAPHICS_REQUIREMENTS_CALL_MISSING|Failed to create XR session: -50\b|xrCreateSession[^\n]*-50\b)'
|
||||
severity: fatal
|
||||
@@ -523,6 +533,12 @@ signatures:
|
||||
diagnosis: The game crashed while leaving VR, inside Valve's foveation layer when it destroys a swapchain (for example Lambda1VR). This can happen after manually quitting; it is not a startup crash and does not explain distorted geometry seen before quitting.
|
||||
suggest: []
|
||||
supersedes: [native-crash]
|
||||
- id: batman-audio-geometry-allocation
|
||||
pattern: '(?s)\A(?=.*com\.camouflaj\.manta)(?=.*Trying to allocate: [1-9][0-9]{10,}B)(?=.*MetaXRAudioUnityMemoryAllocator\.cpp)'
|
||||
severity: fatal
|
||||
diagnosis: "Batman's Meta XR Audio geometry loader requested tens of gigabytes while loading a scene. Its background task can outlive the UnityWebRequest that owns the input buffer. Update Batman on Frame with the current FramePort: the adapter retains a private copy until parsing finishes, for the verified Batman build. This is a game allocation crash, not evidence of a headset thermal shutdown."
|
||||
suggest: [frame.adapter]
|
||||
supersedes: [native-crash]
|
||||
- id: native-crash
|
||||
pattern: 'Fatal signal (\d+) \(SIG[A-Z]+\)'
|
||||
severity: fatal
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Batman audio geometry input lifetime
|
||||
|
||||
Batman: Arkham Shadow 1.4.1 could abort while loading a save or streaming the area
|
||||
around a real-time cutscene. Unity reported requests for 26,388,238,103 or
|
||||
147,520,448,640 bytes through `MetaXRAudioUnityMemoryAllocator.cpp`, followed by
|
||||
`SIGTRAP` on an IL2CPP worker. The first captured stack reached
|
||||
`GeometricAudioMeshPreprocessor::readMeshInfo` through
|
||||
`ovrAudio_AudioGeometryReadMeshMemory`.
|
||||
|
||||
The serialized asset on disk was intact. In one failed run its vertex count was
|
||||
read as 1 instead of 17,251. The managed-code trace showed a lifetime risk:
|
||||
`LoadGeometryAsync` obtains a `NativeArray<byte>.ReadOnly` from a
|
||||
`UnityWebRequest` download handler, calls async-void `LoadGeometryFromMemory`,
|
||||
then returns. The background task retains the pointer/length pair, but not the
|
||||
request owning those bytes. Its `GC.KeepAlive` boxes the read-only view rather
|
||||
than retaining the request. The trace and device test support premature source
|
||||
reuse as the cause; no hardware watch established the exact freeing instruction.
|
||||
|
||||
## Repair and isolation
|
||||
|
||||
`audio_geometry_owned.h` copies the input synchronously at
|
||||
`LoadGeometryFromMemory`, before `Task.Run` can outlive the request. A mutex
|
||||
protects the list of copies. The original worker still parses and processes the
|
||||
audio geometry; its completion wrapper removes and frees the corresponding copy.
|
||||
This also handles a worker finishing before the queuing call returns. NativeArray
|
||||
length/padding bits and IL2CPP's hidden method-info arguments are preserved.
|
||||
Null/invalid lengths and allocation failure retain the original call. A task
|
||||
that never runs retains its copy until process exit; there is no timed expiry
|
||||
that could free data beneath a late worker.
|
||||
|
||||
`batman_audio_geometry.h` enables the repair only in `com.camouflaj.manta`, on
|
||||
AArch64, after the existing audio metadata initializer verifies Meta XR Audio
|
||||
Wwise build `e1619e7fb839badf0ea5ecc22d9f028d0b17c34d`. It additionally requires
|
||||
IL2CPP build `1d48a41d7e9011bef5fae0c23c404c62a4e7a73c` and exact 16-byte entry
|
||||
sequences at both managed hooks. Unknown builds are left untouched. The live
|
||||
IL2CPP addresses are `0x2e61bb4` (queue) and `0x2e63a28` (worker); text file
|
||||
offsets in this build are 0x1000 lower. These offsets must not be reused for
|
||||
another game or build.
|
||||
|
||||
Both geometry hooks and the metadata hooks use `audio_detour.h`. After the
|
||||
16-byte entry guard, only the first, naturally aligned instruction is replaced:
|
||||
a release atomic store publishes an AArch64 `B` to a nearby stub. The stub's
|
||||
absolute jump and the trampoline (one verified non-PC-relative instruction,
|
||||
then a direct branch to entry+4) are complete, instruction-cache flushed and RX
|
||||
before publication. The original-call pointer is published first. Remaining
|
||||
entry instructions are unchanged, so workers already past the entry can finish
|
||||
normally. `/proc/self/maps` supplies free gaps, clipped to the ranges of both
|
||||
branches. Page-aligned candidates are tried outward from the target (lower
|
||||
address first on equal distances) with `MAP_FIXED_NOREPLACE`; EEXIST/EINVAL
|
||||
advance to the next candidate. Existing mappings are never replaced, and a
|
||||
kernel that ignores the flag and relocates a mapping has that result unmapped.
|
||||
If no candidate works, that hook is skipped without changing its entry. No ELF
|
||||
page-slack fallback is used: zero bytes alone cannot establish that padding is
|
||||
outside all section content, and the loaded program headers do not prove this.
|
||||
|
||||
Every mprotect result is checked. Failures before entry publication free the
|
||||
mapping. If restoring RX after publication fails, the entry word is restored,
|
||||
its instruction cache is flushed, and restoring RX is attempted again. The
|
||||
mapping and original-call pointer must then remain valid until process exit:
|
||||
a worker may already have followed the published branch. Freeing them without
|
||||
quiescing those threads would introduce a use-after-unmap. A persistent kernel
|
||||
mprotect failure can leave the entry page RWX. Each hook failure logs its target
|
||||
and failing operation once; metadata installation is attempted once per verified
|
||||
library. Allocation failure reports the closest eligible free page, last
|
||||
attempted address, actual mmap result, errno and attempt count. Null closest and
|
||||
zero attempts mean no eligible free gap. Protection failures identify stub RX,
|
||||
entry RWX, or entry RX, including the rollback RX errno (zero means it succeeded).
|
||||
This exceptional rollback cannot provide immediate reclamation or guarantee RX
|
||||
when the kernel refuses it. The previous hint-based publication build failed installation on the Steam
|
||||
Frame; this gap-search replacement has host validation only.
|
||||
|
||||
This is separate from the existing [audio metadata retirement repair](AUDIO_METADATA.md).
|
||||
It does not change the decoder, acoustic processing, tracking, foveation,
|
||||
resolution, refresh rate, or other games' recipes. Temporary input dumps, native
|
||||
parser bypasses and redundant late copies used during investigation are not
|
||||
included. Production logs are limited to installation and the first four
|
||||
copy/completion pairs. No game assets are included in the repository.
|
||||
|
||||
The `frame.adapter` package revision increases only for Batman. Existing Batman
|
||||
installs therefore offer **Update on Frame**; other games are not marked outdated
|
||||
by this repair. Updating/rebuilding Batman installs the new adapter through the
|
||||
normal path; no launcher script or additional option is needed.
|
||||
|
||||
## Validation
|
||||
|
||||
- Native regressions cover deferred and inline completion, source destruction
|
||||
before worker execution, concurrent loads, cleanup, allocation failure,
|
||||
argument preservation, and rejection of unknown build IDs/function entries.
|
||||
Host C tests also cover branch-range endpoints, stub/trampoline encoding,
|
||||
publication order, synthetic maps with in-range/out-of-range/absent gaps and
|
||||
adjacent mappings, outward placement order, EEXIST/EINVAL fallback, rejected
|
||||
relocated mmap results and mprotect failure cleanup/rollback.
|
||||
- On the headset, the affected save loaded successfully with the repair. Logs
|
||||
showed private copies of 2,599,300, 159,020 and 628,088 bytes made before dispatch;
|
||||
each original parser completed and each copy was released. Another 180,120-byte
|
||||
geometry input also completed normally.
|
||||
- The game continued for over seven minutes after that load with zero allocation
|
||||
failures, rejected meshes or fatal signals. Acoustic geometry was loaded, not
|
||||
disabled. That device test used the ownership repair with diagnostic guards;
|
||||
none of the guards rejected a mesh. The production repair omits those guards.
|
||||
- The rebuilt latest adapter passes the repository checks recorded in the PR;
|
||||
the device result above is for the experimental build tested before integration.
|
||||
|
||||
This does not establish long-term stability or resolve the separately observed
|
||||
whole-headset power-offs. Those require their own power/reset investigation.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Controller buttons
|
||||
|
||||
> **Experimental.** The Frame's runtime accepts these bindings, but the modes haven't been tried in the headset on
|
||||
> many games yet. If buttons stop working in a game, set it back to **SteamVR default** (Game settings →
|
||||
> Controllers) and report it.
|
||||
|
||||
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.
|
||||
@@ -288,7 +288,7 @@ Steam placeholder set (no Steam art at all) → fixed in artwork/steam.py.
|
||||
|
||||
## PR #34: Meta XR Audio metadata (Batman)
|
||||
|
||||
**PR #34 (merged 2026-10-04, Lucas-Mathieu):** `native/adapter/audio_metadata.h` patches Meta XR Audio Wwise (only build ID e1619e7f…, Batman: Arkham Shadow 1.4.1) so queued audio metadata isn't freed while the current audio frame references it (smoke-bomb crash). Verified headless: installs ("metadata reclamation follows …"), 72 fps; we added a once-per-second scan limit. Outside PRs: review source, rebuild `artifacts/` ourselves, merge locally (contributor's commit kept), push main.
|
||||
**PR #34 (merged 2026-10-04, Lucas-Mathieu):** `native/adapter/audio_metadata.h` patches Meta XR Audio Wwise (only build ID e1619e7f…, Batman: Arkham Shadow 1.4.1) so queued audio metadata isn't freed while the current audio frame references it (smoke-bomb crash). The shared detour now publishes one aligned AArch64 branch to a range-checked nearby stub, with a one-instruction trampoline; protection failures roll back (published mappings must remain alive for in-flight threads). See [AUDIO_GEOMETRY.md](AUDIO_GEOMETRY.md) for failure limits and host validation; this publication change has not been device-tested. Original repair verified headless: installs ("metadata reclamation follows …"), 72 fps; we added a once-per-second scan limit. Outside PRs: review source, rebuild `artifacts/` ourselves, merge locally (contributor's commit kept), push main.
|
||||
|
||||
## Heuristics eval (2026-10-04)
|
||||
|
||||
@@ -447,11 +447,34 @@ head-pose deadline ends VR mode without a worn headset (not a game bug).
|
||||
|
||||
## Cube swapchains
|
||||
|
||||
**Cube swapchains (GitHub #107, 2026-10-09):** the Frame's runtime refuses `faceCount=6` swapchains (-2,
|
||||
XR_ERROR_RUNTIME_FAILURE); OVRPlugin ignores that ("CreateSwapchain for eye 0: 0x0, 0 stages") and crashes in
|
||||
ovrp_EndFrame4 (memset). FrameBridge `cube_standin` (default on, `native/adapter/cube_standin.c`) serves a refused cube
|
||||
swapchain as one GL cube-map texture in the app's context (GLES only) and drops its layers. Verified headless with
|
||||
Budget Cuts Ultimate (2048² sRGB, 12 mips; runs on at ~70 fps); what the cube layer showed is simply missing.
|
||||
**Cube swapchains (GitHub #107/#385, 2026-10-17):** the Frame's runtime refuses `faceCount=6`
|
||||
swapchains (-2, XR_ERROR_RUNTIME_FAILURE). `cube_standin` (default on, `native/adapter/cube_standin.c`)
|
||||
serves refused cubes with a GLES cube texture in the app's context or a Vulkan cube-compatible image.
|
||||
This prevents OVRPlugin's empty-image-list crash (Budget Cuts Ultimate; Vulkan UNDERDOGS).
|
||||
|
||||
`cube_quads` (int, default 1) now copies mip 0 into six runtime quad swapchains, replacing each cube at
|
||||
its original layer-list position. Face order is +X, -X, +Y, -Y, +Z, -Z; inward-facing quad right/up axes
|
||||
match GL cube s/t. Vulkan copies reverse rows for its top-left quad image origin. The cube orientation
|
||||
rotates the box; its centre follows the stereo view midpoint in the layer's space at displayTime
|
||||
(falls back to the space origin without valid tracking). Faces are 100 m square at 50 m distance.
|
||||
Eye visibility and layer flags are retained. Quads submitted by the game retain their order.
|
||||
|
||||
The Frame draws quads above **every** projection, regardless of order. This implementation conservatively
|
||||
drops cubes whenever any projection is submitted, including alpha projections: it supports cube-only
|
||||
loading screens with optional quad overlays, not skyboxes behind gameplay. Multiple cubes of the same
|
||||
size/format share six swapchains, using separate array slices so their images cannot overwrite one another.
|
||||
Each pool is copied once per frame. Other sizes need another six swapchains; runtime allocation refusal
|
||||
(particularly near its ~15-swapchain cap) frees partial allocations and disables that pool until reset.
|
||||
Pools are freed on stand-in creation/destruction, xrEndSession and session destruction; stand-ins are also freed at
|
||||
session destruction. Set `cube_quads=0` for the previous drop-only behaviour.
|
||||
|
||||
Look for `cube_quads: created six ... face swapchains (GLES|Vulkan)` and
|
||||
`cube_quads: submitting cube as six quads (half-size=50m, mip=0)`. Refusals log
|
||||
`cube_quads: face swapchain creation failed (result=...); dropping cube layers`; mixed frames log
|
||||
`cube_quads: skipping cube with projection; preserving scene visibility` once.
|
||||
Host math/settings/artifact checks and Android builds are available; no device test yet. Headset checks
|
||||
still needed: face seams/orientation, sRGB/alpha, GLES context availability, Vulkan synchronization and
|
||||
loading-to-gameplay transitions (HardBullet's 1024²/32² Vulkan cubes; Budget Cuts GLES).
|
||||
|
||||
## Hardware video decoding
|
||||
|
||||
@@ -718,3 +741,38 @@ can be enabled and persist after restart. Check actual runtime refresh/pacing se
|
||||
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.
|
||||
## Vulkan pipelines refused: Fossilize and Turnip (Transformers, #349)
|
||||
|
||||
Transformers Beyond Reality: Redux (Unreal, Vulkan) ended its first frames with Unreal's "Shader compilation failures
|
||||
are Fatal." (2026-10-17, headless on the dev Frame). `vk_pipeline_log=1` showed every graphics pipeline failing in
|
||||
pairs: the precache attempt with `VK_PIPELINE_CREATE_FAIL_ON_PIPELINE_COMPILE_REQUIRED_BIT` (flags 0x100) answers
|
||||
`1000297000` (= VK_PIPELINE_COMPILE_REQUIRED, normal), the real compile answers `-1` (VK_ERROR_OUT_OF_HOST_MEMORY)
|
||||
in the same millisecond, and the shim never logged a single success. The dumped SPIR-V was valid (spirv-val), one
|
||||
module a constant-colour fragment shader. Ruled out in one run each: the app's VkPipelineCache, its
|
||||
VkAllocationCallbacks (retry without either/both), Valve's VALVE_rpo/VALVE_fdm_injection (`device.foveation: off`).
|
||||
Khronos' validation layer added a crash of its own (null deref in its vkCreateRenderPass2 on the game's create info,
|
||||
the shim changed nothing there): don't use it for this game. Removing `VK_LAYER_fossilize` from GraphicsEnv's debug
|
||||
layer list (`android::GraphicsEnv::setDebugLayers`, as `native/zinkfix` adds its layer) made the pipelines compile:
|
||||
360 modules instead of 10, frames submitted. Two Fossilize instances are still created by other threads before the
|
||||
engine loads the shim; they aren't the renderer's. About six pipelines with the game's multiview vertex shader
|
||||
(module 12944, ViewIndex) still fail in Turnip; a blank fragment shader alone doesn't help, blank vertex + fragment
|
||||
does. Settings `vk_no_fossilize` + `vk_pipeline_fallback` (catalog entry): headless RUNNING after 72 s at 72 fps.
|
||||
Headset check pending (some objects may be missing).
|
||||
|
||||
## 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.
|
||||
@@ -202,13 +202,24 @@ Saves stay on the Frame when you update or reinstall a game.
|
||||
|
||||
## Can I back up a game's saves?
|
||||
|
||||
Yes. Steam doesn't sync Quest games' saves, so FramePort copies them to your PC: the game's menu (**…** on its page) →
|
||||
**Saves** → **Back up saves**, or **Back up all saves** on the Steam Frame page (Installed games). Close the game
|
||||
Yes. Steam doesn't sync Quest games' saves, so FramePort copies them to your PC: **Back up saves** next to Play on
|
||||
the installed game's page, or **Back up all saves** on the Steam Frame page (Installed games). Close the game
|
||||
first. A backup holds the game's own storage (`/data/data/<package>` and `/sdcard/Android/data/<package>`), without
|
||||
caches, shader caches and large downloaded content (videos, OBBs, files over 64 MB); the job says when something was
|
||||
left out. FramePort keeps the 10 newest backups per game (Settings → Data). They're in FramePort's data folder under
|
||||
`save-backups/<package>/` (**Open backups folder** in the game's menu).
|
||||
|
||||
In **Settings → Data**, **Choose backup folder…** selects a different folder, including a local Drive or Nextcloud
|
||||
sync folder. FramePort checks that it can write there. **Use the default** resets the location. Existing backups stay
|
||||
where they are and remain listed for restoring; retention only removes old backups in the currently selected folder.
|
||||
FramePort writes local files; your cloud client handles uploading them.
|
||||
|
||||
**Back up saves when a game closes** (Settings → Data, off by default) keeps the newest three changed snapshots per
|
||||
game on the Frame, even while FramePort is closed. Connect once after changing this setting to apply it to the Frame.
|
||||
New snapshots are copied to your backup folder when FramePort connects or polls the connection, marked **automatic**,
|
||||
and count toward the same backup limit. Unchanged saves are skipped. A snapshot captures what the game wrote to disk;
|
||||
it cannot recover progress a forcibly closed game never saved.
|
||||
|
||||
**Restore saves…** puts one back: it replaces the game's saves on the Frame. The Frame keeps a copy of the saves it
|
||||
had, so **Undo last restore** in the same window takes you back. When you uninstall a game and tick *Also delete its
|
||||
saves*, FramePort backs them up first. Command line: `frameport saves backup|list|restore|undo <game>`.
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
`launch.sh` at the new folder and only then deletes the old copy; on the same filesystem it renames instead.
|
||||
Moving a game whose Lepton data holds absolute paths, or a PC VR prefix, is not yet verified on the device.
|
||||
|
||||
## Save backups (Quest games, agent v95, discussion #352)
|
||||
## Save backups (Quest games, agent v101, discussion #352)
|
||||
- A Lepton game's saves are two folders of `<base>/lepton-data`: `internal/<pkg>` (= `/data/data/<pkg>`:
|
||||
`shared_prefs/`, `files/`, `databases/`; Unreal's `files/UE4Game|UnrealGame/.../Saved` often lives here) and
|
||||
`external/Android/data/<pkg>` (= `/sdcard/Android/data/<pkg>`: Unity's `files/`, Unreal's external `Saved`). On the
|
||||
@@ -91,6 +91,23 @@
|
||||
files owned by mapped ids are readable and keep their ids, take the game's launch lock (`<base>/.launch.lock`, a
|
||||
Play meanwhile is ignored as a double launch) and refuse while its container runs. `saves_info` sizes a backup and
|
||||
reports the rollback copy, `saves_rollback_delete` removes it.
|
||||
- PC destination: `saves.folder` (empty = `<data>/save-backups`); Settings → Data has an awaited folder picker,
|
||||
a default reset and a write probe. Old destinations (`saves.previous_folders`) stay listed; switching never moves
|
||||
or deletes files. `saves.keep` defaults to 10 and prunes only the selected destination.
|
||||
- `saves.auto` defaults off. Connection and 30 s poll hooks sync its value using `save_snapshot_settings` (flag
|
||||
`~/.local/share/frameport/save-snapshots/enabled`). Updated and newly written Quest launchers call `_snapshot_close`
|
||||
after stopping the container, inside the EXIT trap, with the launch lock still held on fd 9. The worker verifies
|
||||
that fd against `<base>/.launch.lock` and passes it through `podman unshare`; another launch cannot run meanwhile.
|
||||
`timeout -k 5 90` bounds the whole worker. PC VR/Linux launchers have no snapshot step.
|
||||
- Snapshots use the same archive builder, exclusions and 64 MiB/file, 4 GiB/total limits as manual backups.
|
||||
File paths/sizes/content hashes form a fingerprint; unchanged saves skip compression. Complete snapshots are
|
||||
atomically published under `~/.local/share/frameport/save-snapshots/<pkg>/<ns>-<sha256>.tar.gz`, with fingerprint
|
||||
sidecars. The newest three per game are retained; interrupted `.part` files are overwritten on the next attempt.
|
||||
- `save_snapshots` lists immutable snapshots oldest first per game; `_fetch_save_snapshot '<json>'` streams one
|
||||
(archive stdout, result JSON stderr). PC `install/saves.sync_snapshots` runs in the background, verifies the archive
|
||||
hash and package, imports it as a normal backup marked `automatic`, and applies `saves.keep`. A local
|
||||
`<data>/save-snapshots-synced.json` digest ledger prevents reimporting snapshots after pruning or deleting backups.
|
||||
Failures leave snapshots on the Frame for the next poll. This path does no disk/SSH work in rendering.
|
||||
|
||||
## OpenXR runtime (as seen by games through overport's loader)
|
||||
- Instance extensions present include KHR_android_create_instance (must be enabled; the adapter adds it),
|
||||
@@ -107,7 +124,9 @@
|
||||
`ComposeLayerQuad` and `ComposeLayerProjection`; cube/cylinder/equirect/equirect2 exist only as enum names, so
|
||||
there is nothing to switch on. It has a layer limit ("Exceeded the layer limit"; the value is logged by
|
||||
`layer_debug` from `maxLayerCount`). The adapter emulates cylinders (strips) and, per game, equirect layers
|
||||
(`equirect_emul`, an adapter projection layer). Quad layers are drawn above every projection layer regardless of
|
||||
(`equirect_emul`, an adapter projection layer). Refused cube swapchains use GLES/Vulkan stand-ins;
|
||||
`cube_quads` (default on) copies their faces into six quads for frames without a projection
|
||||
(see DEVICE_NOTES "Cube swapchains"). Quad layers are drawn above every projection layer regardless of
|
||||
submission order (seen 2026-10-01: quads placed before 4XVR's projection layer covered it).
|
||||
- Swapchain formats: GLES `GL_SRGB8_ALPHA8` (35907) / `GL_SRGB8` (35905) only, samples = 1. Vulkan: 43 (R8G8B8A8_SRGB)
|
||||
and 50, not 37/44 (UNORM).
|
||||
@@ -118,6 +137,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
|
||||
@@ -398,14 +437,57 @@ over Quest games.
|
||||
|
||||
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.
|
||||
The inspected runtime's built-in inner/outer pairs are 0.30/0.60, 0.25/0.50 and 0.20/0.40.
|
||||
The shared-texture injection path selects profile 1: 0.30 inner radius and 0.60 outer radius.
|
||||
The area selector now uses Valve's supported `FOVE_LEVEL` presets (GitHub #394; binary analysis
|
||||
2026-10-17). Default leaves the runtime choice alone; on the shared injection path this is `lo`.
|
||||
The old `FOVE_INNER`/`FOVE_OUTER` radius exports had no effect: Lepton does not pass them through,
|
||||
and the layer does not read them. The previously documented 0.30/0.60, 0.25/0.50, 0.20/0.40
|
||||
radius pairs do **not** describe the generated shared-injection density map.
|
||||
|
||||
The inspected arm64 `libVkLayer_VALVE_fdm_injection.so` has these semantics (virtual addresses):
|
||||
- `init_fdm_fove_level` at `0x1cdf4` calls the cached option reader, then
|
||||
`debug_parse_flags_option` (`0x21d74`) with table `0x27e70`: named tokens `lo` → 1,
|
||||
`med` → 2, `hi` → 3. It does not call atoi/strtol. Unset, empty, unknown and numeric
|
||||
strings parse to zero and retain the current profile (initialized to 1 at `0x1ce74`).
|
||||
Tokens are case-sensitive and separated by non-alphanumeric/non-underscore characters;
|
||||
matching values are ORed, so combinations can alias another profile. `all` also yields 3;
|
||||
`help` prints the table and returns zero. Use a single preset token.
|
||||
- Profile selection at `0x1918c–0x19364` uses normalized **rectangles**, not circular radii.
|
||||
`lo` has a full-density center rectangle `(x=.364, y=.367, width=.295, height=.286)`
|
||||
(constant at `0xe440`), then density byte `0x76` outside. `med` uses rectangles from
|
||||
`0xe988`: sizes `.250×.245`, `.295×.286`, `.386×.367`, with density bytes
|
||||
`0xff`, `0xa8`, `0x76` and `0x50` outside. `hi` uses `0xe9b8`: sizes
|
||||
`.246×.211`, `.281×.246`, `.351×.316`, `.386×.351`, with bytes
|
||||
`0xff`, `0xa8`, `0x76`, `0x50`, then `0x38` outside. Each byte is written to both
|
||||
R8G8 density channels. All three retain a full-density center (`0xff`); medium/high make it smaller
|
||||
and reduce peripheral density more. Coordinates are sampled on the density-map grid (`0x19378` onward).
|
||||
- `FDM_DEBUG` flags `lo`/`med`/`hi` also select presets (high takes precedence), but an
|
||||
explicit recognized `FOVE_LEVEL` overrides them (`0x1ce14`). `enable` gates injection;
|
||||
`disable_offsets` fixes the gaze position (`0x1b6c8`), and `yflip` flips the vertical
|
||||
offset (`0x1b9b8`). These three flags do not resize the profile.
|
||||
- `FDM_SWAPCHAIN_SIZE` is parsed by `atoi` (`0x1c518`), clamped to nonnegative, and
|
||||
seeds a known swapchain size (`0x1ccf8`). It is a size/matching hint, not a radius.
|
||||
The synthetic map is square with side `ceil(size/32)` (`0x19048–0x19088`); image
|
||||
association also matches this tile size (`0x16c78`). Changing render resolution changes
|
||||
the pixel extent, tile quantization and potentially the matched render target/map. This can
|
||||
change the visible diagnostic area without making the ignored radius exports effective;
|
||||
a matched device capture is needed to establish which path a particular game used.
|
||||
|
||||
Per-game library preferences now store `level`. Legacy radius choices are translated on read
|
||||
(and written as `level` on Save): nearest old nominal .30 → lo, .25 → med, .20 → hi,
|
||||
with larger values clamped by nearest choice; zero or unknown/invalid values → Default.
|
||||
This preserves approximate intent, not a claim that these presets implement those radii.
|
||||
Explicit `level` wins. Obsolete radius exports are removed from managed launchers.
|
||||
|
||||
For device verification, hold game scene, render resolution and gaze constant; compare explicit
|
||||
`lo`, `med`, `hi` across fresh game starts (Default should match lo absent another runtime override).
|
||||
First use Fixed for a stable comparison, then Eye-tracked to check gaze following. Enable
|
||||
**Darken coarse fragments (test)** and adapter `snapshot=N` to capture submitted eye images headlessly
|
||||
every N seconds (GLES only, left eye at quarter size); compare the undarkened center and peripheral bands. Existing adapter
|
||||
builds need diagnostic support installed. Turn the test and snapshots off before benchmarking.
|
||||
Agent `FOVEATION_ENV_KEYS` must include `"FOVE_LEVEL"` for launcher updates via `set_settings`.
|
||||
|
||||
`device.foveation` enables Valve's shared Vulkan injection and suppresses competing native foveation through
|
||||
`foveation_fix=1`. Game settings stores mode, radius and test selection as a per-game library preference, outside shared recipes. This remains
|
||||
`foveation_fix=1`. Game settings stores mode, profile 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.
|
||||
@@ -432,7 +514,7 @@ 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.
|
||||
The new configurable radii have configuration/build coverage; each radius has not been separately play-tested.
|
||||
Preset export/migration has configuration coverage; these preset comparisons have not been device-tested here.
|
||||
|
||||
## Monitoring sources (probed 2026-10-07, SteamOS 0.4.3, kernel 6.18; used by the Monitor tab, agent `_monitor`)
|
||||
|
||||
@@ -459,7 +541,7 @@ game processes without one), temperatures every 2 s, the CPU-cluster power rails
|
||||
|
||||
### Foveation user preferences (discussion #365)
|
||||
|
||||
Game settings → Picture provides Default / Eye-tracked / Fixed / Off, radius, and an advanced diagnostic switch.
|
||||
Game settings → Picture provides Default / Eye-tracked / Fixed / Off, a runtime profile, 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
|
||||
|
||||
@@ -26,6 +26,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Beat Saber | Quest | ✅ Works | |
|
||||
| Beat Saber | PC VR | ✅ Works | |
|
||||
| Beat Saber (co-existence build) | Quest | ✅ Works | |
|
||||
| Black Hole Pool | Quest | ✅ Works | |
|
||||
| Blade & Sorcery: Nomad | Quest | ✅ Works | |
|
||||
| BlazeRush | Quest | ✅ Works | |
|
||||
| BodyCombat | Quest | ✅ Works | |
|
||||
@@ -117,6 +118,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Riven | Quest | ✅ Works | |
|
||||
| Robo Recall | Quest | ✅ Works | |
|
||||
| RUINSMAGUS | Quest | ✅ Works | |
|
||||
| Scared By Squares | Quest | ✅ Works | |
|
||||
| Sclerosis | Quest | ✅ Works | |
|
||||
| Shadow Point | Quest | ✅ Works | |
|
||||
| Shoot The Zombirds VR | Quest | ✅ Works | |
|
||||
@@ -126,14 +128,19 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Sniper Elite VR | Quest | ✅ Works | |
|
||||
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
|
||||
| Space Pirate Trainer Quest | Quest | ✅ Works | |
|
||||
| Spectre | Quest | ✅ Works | |
|
||||
| Sports Scramble (Santa Cruz) | Quest | ✅ Works | |
|
||||
| Star Wars Pinball VR | Quest | ✅ Works | |
|
||||
| Star Wars: Tales from the Galaxy's Edge | Quest | ✅ Works | |
|
||||
| Stremio | Quest | ✅ Works | |
|
||||
| Summer Sports | Quest | ✅ Works | |
|
||||
| Superfly | Quest | ✅ Works | |
|
||||
| SUPERHOT VR | PC VR | ✅ Works | |
|
||||
| SUPERHOT VR | Quest | ✅ Works | |
|
||||
| Survival Nation | Quest | ✅ Works | |
|
||||
| Surviving Mars: Pioneer | Quest | ✅ Works | |
|
||||
| SynthRiders | Quest | ✅ Works | |
|
||||
| Table Troopers | Quest | ✅ Works | |
|
||||
| Taskmaster | Quest | ✅ Works | |
|
||||
| Tea For God | Quest | ✅ Works | |
|
||||
| TetrisEffect | Quest | ✅ Works | |
|
||||
@@ -144,6 +151,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| The Room VR | Quest | ✅ Works | |
|
||||
| The Tale of Onogoro | Quest | ✅ Works | |
|
||||
| The Walking Dead: Saints & Sinners | Quest | ✅ Works | |
|
||||
| The Wizards | Quest | ✅ Works | |
|
||||
| Time Crisis II VR (Test) | Quest | ✅ Works | |
|
||||
| Time Crisis VR (Experimental) | Quest | ✅ Works | |
|
||||
| Totally Baseball | Quest | ✅ Works | |
|
||||
@@ -204,3 +212,4 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| GOLF+ | Quest | ❌ Doesn't run | Black screen. |
|
||||
| HITMAN 3 VR: Reloaded | Quest | ❌ Doesn't run | Vulkan driver crash (freedreno), even without Valve layers. |
|
||||
| Roblox | Quest | ❌ Doesn't run | Crashes on its first VR frame on the Frame. |
|
||||
| Thief Simulator | Quest | ❌ Doesn't run | Quits at start with the game's own "installed from unknown source" message. |
|
||||
@@ -133,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
|
||||
|
||||
@@ -64,6 +64,14 @@ Versions compare number by number (`1.10` > `1.9`), and a suffix sorts before th
|
||||
|
||||
To build a link from Python: `frameport.deeplink.make_link(manifest_url="https://…/my-game.json")`.
|
||||
|
||||
**Where to host the manifest.** The install page shows the manifest's name, description and icon only when the
|
||||
manifest's host lets web pages read it (CORS); otherwise it shows "A game or app" (FramePort itself reads any public
|
||||
address). On GitHub, commit the manifest to the repository and link it as a file: `https://raw.githubusercontent.com/
|
||||
<owner>/<repo>/main/my-game.json`, or the `https://github.com/<owner>/<repo>/blob/main/my-game.json` page address,
|
||||
which the page and FramePort turn into the raw address. A manifest attached to a GitHub release
|
||||
(`…/releases/download/…`) installs fine, but web pages can't read it: the page then shows the release's name from
|
||||
GitHub's API instead.
|
||||
|
||||
## On a website
|
||||
|
||||
Image version (any page):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Patches
|
||||
|
||||
Every patch and game setting FramePort knows: 182 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: 186 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,7 +12,7 @@ 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) (73)
|
||||
- [Game settings](#group-adapter) (77)
|
||||
- [Files and settings on the Frame](#group-device) (10)
|
||||
- [PC VR (Proton and Revive)](#group-pcvr) (13)
|
||||
|
||||
@@ -39,7 +39,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Patch Oculus detection for Unity](#patch_oculus_unity) | `patch_oculus_unity` | Patches from OVRPort | default |
|
||||
| [Patch Oculus detection for Unreal](#patch_oculus_unreal) | `patch_oculus_unreal` | Patches from OVRPort | default |
|
||||
| [Remove localized app names](#patch_remove_localized_names) | `patch_remove_localized_names` | Patches from OVRPort | default |
|
||||
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 45 |
|
||||
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 46 |
|
||||
| [Remove uses-library](#patch_remove_uses_library) | `patch_remove_uses_library` | Patches from OVRPort | default |
|
||||
| [Remove VrApi library](#patch_remove_vrapi) | `patch_remove_vrapi` | Patches from OVRPort | |
|
||||
| [Replace application label and icon](#patch_replace_icon_label) | `patch_replace_icon_label` | Patches from OVRPort | default |
|
||||
@@ -52,13 +52,13 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Skip Meta avatars](#frame-avatar_stub) | `frame.avatar_stub` | Steam Frame patches | 1 |
|
||||
| [bHaptics: skip the Bluetooth permission request](#frame-bhaptics_permission) | `frame.bhaptics_permission` | Steam Frame patches | 6 |
|
||||
| [BlazeRush: controllers without Meta avatars, own shader cache](#frame-blazerush) | `frame.blazerush` | Steam Frame patches | 1 |
|
||||
| [Cortopia games: don't wait for Meta's org-scoped id](#frame-cortopia_org_id) | `frame.cortopia_org_id` | Steam Frame patches | |
|
||||
| [Cortopia games: don't wait for Meta's org-scoped id](#frame-cortopia_org_id) | `frame.cortopia_org_id` | Steam Frame patches | 1 |
|
||||
| [Epic Online Services: stop the keychain retry loop](#frame-eos_keychain_loop) | `frame.eos_keychain_loop` | Steam Frame patches | |
|
||||
| [OpenGL ES: multiview shaders on flat panels (HUD, menus)](#frame-gl_multiview_fbo) | `frame.gl_multiview_fbo` | Steam Frame patches | 1 |
|
||||
| [GL shim (Mesa GLSL compatibility)](#frame-gl_shim) | `frame.gl_shim` | Steam Frame patches | 2 |
|
||||
| [Play services: Android 11 version checks](#frame-gms_platform_version) | `frame.gms_platform_version` | Steam Frame patches | |
|
||||
| [Play services: Android 11 version checks](#frame-gms_platform_version) | `frame.gms_platform_version` | Steam Frame patches | 1 |
|
||||
| [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 | 31 |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 33 |
|
||||
| [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 |
|
||||
@@ -73,20 +73,20 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Platform compat (ovrMessageType_ToString)](#frame-ovrplatformcompat) | `frame.ovrplatformcompat` | Steam Frame patches | default |
|
||||
| [Stub missing Meta platform functions](#frame-ovrstubs) | `frame.ovrstubs` | Steam Frame patches | default |
|
||||
| [Unpaired pointer-authentication checks](#frame-pac_hints) | `frame.pac_hints` | Steam Frame patches | 1 |
|
||||
| [Photon Voice: skip Android 12 audio routing](#frame-photon_voice_audio_route) | `frame.photon_voice_audio_route` | Steam Frame patches | |
|
||||
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 5 |
|
||||
| [Photon Voice: skip Android 12 audio routing](#frame-photon_voice_audio_route) | `frame.photon_voice_audio_route` | Steam Frame patches | 1 |
|
||||
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 6 |
|
||||
| [Stress Level Zero: let Unity start Vulkan itself](#frame-slz_vulkan_hooks) | `frame.slz_vulkan_hooks` | Steam Frame patches | 1 |
|
||||
| [Start straight in VR (skip the 2D launcher)](#frame-start_activity) | `frame.start_activity` | Steam Frame patches | |
|
||||
| [Allow swapchains larger than 4096 px](#frame-swapchain_limit) | `frame.swapchain_limit` | Steam Frame patches | default |
|
||||
| [Team Beef ports: treat the Frame as a Meta headset](#frame-tbxr_vendor) | `frame.tbxr_vendor` | Steam Frame patches | 1 |
|
||||
| [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 |
|
||||
| [GL shim for Unity (no multisampled render-to-texture)](#frame-unity_gl_shim) | `frame.unity_gl_shim` | Steam Frame patches | 26 |
|
||||
| [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 | 31 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 111 |
|
||||
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 32 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 117 |
|
||||
| [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 |
|
||||
@@ -98,10 +98,10 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 9 |
|
||||
| [Unreal: skip the OBB delivery check](#frame-unreal_skip_obb_delivery) | `frame.unreal_skip_obb_delivery` | Steam Frame patches | 1 |
|
||||
| [Unreal: bypass the stalled loading splash](#frame-unreal_splash_bypass) | `frame.unreal_splash_bypass` | Steam Frame patches | 1 |
|
||||
| [Unreal: thumb follows the capacitive touches](#frame-unreal_thumb_touch) | `frame.unreal_thumb_touch` | Steam Frame patches | 7 |
|
||||
| [Unreal: thumb follows the capacitive touches](#frame-unreal_thumb_touch) | `frame.unreal_thumb_touch` | Steam Frame patches | 8 |
|
||||
| [USB MIDI: don't poll for USB devices](#frame-usb_midi_watcher) | `frame.usb_midi_watcher` | Steam Frame patches | |
|
||||
| [Vermillion: paint canvases in the scene](#frame-vermillion_scene_canvas) | `frame.vermillion_scene_canvas` | Steam Frame patches | 1 |
|
||||
| [Vivox voice chat: skip Android 12 audio routing](#frame-vivox_audio_route) | `frame.vivox_audio_route` | Steam Frame patches | 2 |
|
||||
| [Vivox voice chat: skip Android 12 audio routing](#frame-vivox_audio_route) | `frame.vivox_audio_route` | Steam Frame patches | 3 |
|
||||
| [Vulkan: drop invalid render-pass pointers](#frame-vk_sanitize) | `frame.vk_sanitize` | Steam Frame patches | default · 1 |
|
||||
| [VrApi → OpenXR bridge](#frame-vrapi_bridge) | `frame.vrapi_bridge` | Steam Frame patches | 4 |
|
||||
| [Quiet Meta's VrApi loader (OpenXR games)](#frame-vrapi_stub) | `frame.vrapi_stub` | Steam Frame patches | 2 |
|
||||
@@ -113,6 +113,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Fix inside-out mixed reality](#adapter-alpha_blend_emul) | `adapter.alpha_blend_emul` | Game settings | |
|
||||
| [Use controllers](#adapter-controller_fix) | `adapter.controller_fix` | Game settings | 1 |
|
||||
| [Show Steam Frame controllers](#adapter-controller_models) | `adapter.controller_models` | Game settings | 6 |
|
||||
| [Show cube loading screens](#adapter-cube_quads) | `adapter.cube_quads` | Game settings | |
|
||||
| [Stand in for cube layers](#adapter-cube_standin) | `adapter.cube_standin` | Game settings | |
|
||||
| [Show curved menus and screens](#adapter-cylinder_strips) | `adapter.cylinder_strips` | Game settings | |
|
||||
| [Show 360° backgrounds and videos](#adapter-equirect_emul) | `adapter.equirect_emul` | Game settings | 7 |
|
||||
@@ -136,7 +137,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 | 96 |
|
||||
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 102 |
|
||||
| [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 |
|
||||
@@ -149,14 +150,15 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [See your room](#adapter-passthrough_emul) | `adapter.passthrough_emul` | Game settings | |
|
||||
| [Steadier head tracking](#adapter-pose_consistency) | `adapter.pose_consistency` | Game settings | 2 |
|
||||
| [Pose timing diagnostics (log)](#adapter-pose_debug) | `adapter.pose_debug` | Game settings | |
|
||||
| [Fix hands lagging behind controllers](#adapter-pose_time_fix) | `adapter.pose_time_fix` | Game settings | 15 |
|
||||
| [Fix hands lagging behind controllers](#adapter-pose_time_fix) | `adapter.pose_time_fix` | Game settings | 16 |
|
||||
| [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 | |
|
||||
| [Render resolution (supersampling)](#adapter-scale) | `adapter.scale` | Game settings | 9 |
|
||||
| [Render resolution (supersampling)](#adapter-scale) | `adapter.scale` | Game settings | 14 |
|
||||
| [Room depth](#adapter-scene_depth) | `adapter.scene_depth` | Game settings | |
|
||||
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 12 |
|
||||
| [Room height](#adapter-scene_height) | `adapter.scene_height` | Game settings | |
|
||||
@@ -171,6 +173,8 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Retry refused picture formats](#adapter-swapchain_fix) | `adapter.swapchain_fix` | Game settings | |
|
||||
| [Steady controller input after focus](#adapter-sync_guard) | `adapter.sync_guard` | Game settings | 3 |
|
||||
| [Hide Vulkan foveation](#adapter-vk_hide_fdm) | `adapter.vk_hide_fdm` | Game settings | 1 |
|
||||
| [Without Steam's shader cache layer](#adapter-vk_no_fossilize) | `adapter.vk_no_fossilize` | Game settings | 1 |
|
||||
| [Skip refused graphics](#adapter-vk_pipeline_fallback) | `adapter.vk_pipeline_fallback` | Game settings | 1 |
|
||||
| [Vulkan pipeline failures (log)](#adapter-vk_pipeline_log) | `adapter.vk_pipeline_log` | Game settings | |
|
||||
| [Stop objects popping in and out](#adapter-vk_query_slots) | `adapter.vk_query_slots` | Game settings | |
|
||||
| [Vulkan shader dump (diagnostics)](#adapter-vk_shader_dump) | `adapter.vk_shader_dump` | Game settings | |
|
||||
@@ -188,7 +192,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [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 | 114 |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 120 |
|
||||
| [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) | |
|
||||
@@ -433,7 +437,7 @@ Stops some Unreal games from closing themselves right after starting.
|
||||
- `patch_remove_unreal_force_quit`
|
||||
- **Changes:** Changes the APK (during the OVRPort conversion).
|
||||
- **Offered (off):** Built as the alternate APK for Unreal games (use it if the game quits itself).
|
||||
- **Catalog games (45):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Breathedge VR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2 and 37 more.
|
||||
- **Catalog games (46):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Breathedge VR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2 and 38 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `force-quit` (error): The game quit itself right after starting.
|
||||
- **Source:** [patches/overport.py](../src/frameport/patches/overport.py)
|
||||
@@ -524,6 +528,7 @@ FramePort's layer between the game and the Steam Frame: fills in what the Frame
|
||||
- **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:**
|
||||
- `batman-audio-geometry-allocation` (fatal): Batman's Meta XR Audio geometry loader requested tens of gigabytes while loading a scene.
|
||||
- `cube-swapchain-refused` (fatal): The game asked for a cube-map swapchain (for example an OVROverlay skybox or loading cube), which the Frame's runtime doesn't have.
|
||||
- `cylinder-menu-layers-rejected` (info): The runtime rejected a frame's layers once (XR_ERROR_LAYER_INVALID).
|
||||
- `graphics-requirements-missing` (fatal): The game can't start VR on the Frame; rebuild it with the current FramePort.
|
||||
@@ -606,6 +611,7 @@ Gets Cortopia games (for example Escaping Wonderland) past their loading screen.
|
||||
- `frame.cortopia_org_id`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Cortopia's Meta platform start-up waits for an org-scoped user id that OVRPort's platform library never answers (stuck loading screen).
|
||||
- **Catalog games (1):** Spectre.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `cortopia-org-id-wait` (error): The game waits forever on its loading screen for an answer from Meta's platform (an org-scoped user id, used as its analytics id) that OVRPort's platform library doesn't give.
|
||||
- **Source:** [patches/frame/cortopia_org_id.py](../src/frameport/patches/frame/cortopia_org_id.py)
|
||||
@@ -659,6 +665,7 @@ Lets games with Google Play services code start on the Frame (they'd crash at st
|
||||
- `frame.gms_platform_version` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Google Play services' Android 12 checks were built in as 'always true'; on Lepton (Android 11) the game would crash at start.
|
||||
- **Catalog games (1):** Table Troopers.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `gms-android12-call` (fatal): Google Play services' code in the game called an Android 12 method (the game was built for Android 12 and newer, so its version checks were built in as "always true") and Lepton runs Android 11; the game crashes on its GoogleApiHandler thread at start (for example Titan Isles).
|
||||
- **Source:** [patches/frame/gms_platform_version.py](../src/frameport/patches/frame/gms_platform_version.py)
|
||||
@@ -686,7 +693,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 (31):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, BattleTalent, Breathedge VR, Dance Central, Exploding Kittens VR and 23 more.
|
||||
- **Catalog games (33):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, BattleTalent, Breathedge VR, Dance Central, Exploding Kittens VR and 25 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.
|
||||
@@ -881,6 +888,7 @@ Stops games with Photon Voice chat from crashing on the Frame when voice chat st
|
||||
- `frame.photon_voice_audio_route` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Photon Voice calls Android 12 audio methods, which Lepton (Android 11) lacks, and crashes when voice chat starts.
|
||||
- **Catalog games (1):** Table Troopers.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `photon-voice-api31` (fatal): The game's Photon Voice microphone plugin calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes when voice chat starts (for example Table Troopers at the tutorial).
|
||||
- **Source:** [patches/frame/photon_voice_audio_route.py](../src/frameport/patches/frame/photon_voice_audio_route.py)
|
||||
@@ -894,7 +902,7 @@ Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboar
|
||||
- `frame.sdl_clipboard` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** SDL app: it crashes at start in Lepton, which has no clipboard service.
|
||||
- **Catalog games (5):** Crisis Zone VR, Majora's Mask VR, Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
|
||||
- **Catalog games (6):** Crisis Zone VR, Majora's Mask VR, PrimedGun v2, Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `sdl-no-clipboard` (fatal): The app crashed at start because the Frame's Android has no clipboard.
|
||||
- **Source:** [patches/frame/sdl_clipboard.py](../src/frameport/patches/frame/sdl_clipboard.py)
|
||||
@@ -973,7 +981,7 @@ Stops a gray or frozen screen in Unity games that turn on anti-aliasing while ru
|
||||
- `frame.unity_gl_shim`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity GLES game that may switch to MSAA at runtime: hide multisampled render-to-texture, which crashes the Frame's GL driver (for example The Room VR).
|
||||
- **Catalog games (25):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 17 more.
|
||||
- **Catalog games (26):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 18 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unreal-msrtt-crash` (fatal): The game's graphics crashed a few seconds after start.
|
||||
- **Source:** [patches/frame/vrapi_bridge.py](../src/frameport/patches/frame/vrapi_bridge.py)
|
||||
@@ -1042,7 +1050,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 (31):** Audica, BattleSisters, BattleTalent, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 23 more.
|
||||
- **Catalog games (32):** Audica, BattleSisters, BattleTalent, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 24 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.
|
||||
@@ -1058,7 +1066,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 (111):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable, Audica and 103 more.
|
||||
- **Catalog games (117):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable, Audica and 109 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.
|
||||
@@ -1216,7 +1224,7 @@ Makes the hands' thumbs follow what your thumb touches (they always pointed up:
|
||||
- `frame.unreal_thumb_touch`
|
||||
- **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 (7):** Phantom: Covert Ops, Robo Recall, Star Wars: Tales from the Galaxy's Edge, Time Stall, Vader Immortal: Episode I, Vader Immortal: Episode II, Vader Immortal: Episode III.
|
||||
- **Catalog games (8):** Phantom: Covert Ops, Robo Recall, Star Wars: Tales from the Galaxy's Edge, The Wizards, Time Stall, Vader Immortal: Episode I, Vader Immortal: Episode II, Vader Immortal: Episode III.
|
||||
- **Source:** [patches/frame/unreal_thumb_touch.py](../src/frameport/patches/frame/unreal_thumb_touch.py)
|
||||
|
||||
> Unreal 4's Oculus input sets the ThumbUp axis (games animate the hand's thumb with it) from Meta's near-touch sensing, which the Frame never reports: the thumb always points up. Uses the touches instead (left X/Y/stick/thumb rest, right A/B/stick/thumb rest). Changes three instructions in OculusInput::SendControllerEvents, only where the code matches exactly (UE4 Oculus games of one engine generation, for example Vader Immortal, Robo Recall). Found by Klownicle (GitHub #49).
|
||||
@@ -1253,7 +1261,7 @@ Lets games with Vivox voice chat start on the Frame (they'd crash on an Android
|
||||
- `frame.vivox_audio_route` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Vivox voice chat: it calls Android 12 audio methods, which Lepton (Android 11) lacks, and crashes.
|
||||
- **Catalog games (2):** Dungeons of Eternity, Real VR Fishing.
|
||||
- **Catalog games (3):** Black Hole Pool, Dungeons of Eternity, Real VR Fishing.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `vivox-api31` (fatal): The game's Vivox voice-chat SDK calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes (for example Green Hell VR).
|
||||
- **Source:** [patches/frame/vivox_audio_route.py](../src/frameport/patches/frame/vivox_audio_route.py)
|
||||
@@ -1425,6 +1433,20 @@ For games that show the headset's own controllers. Takes effect after a reinstal
|
||||
|
||||
> Games that ask the headset for its controller models (Meta's runtime controller models, XR_FB_render_model) get the Steam Frame controllers instead of Quest Touch controllers. The models come from the Frame's own SteamVR and are converted on the Frame at install time. Games that ship their own controller meshes aren't affected. Turning it on needs a rebuild (it adds a small library in front of OVRPort's loader).
|
||||
|
||||
### <a id="adapter-cube_quads"></a>Show cube loading screens
|
||||
|
||||
Shows 360° cube loading screens as six panels around you. Requires stand-in cube layers. Skipped while a 3D scene is submitted.
|
||||
|
||||
- `adapter.cube_quads`
|
||||
- **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 `1`.
|
||||
- Game settings → Troubleshooting → "Show cube loading screens" (advanced).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Copies cube-map loading screens into six panels around the viewer (GLES and Vulkan). Mixed projection frames are skipped because the Frame draws panels above the scene. Requires cube_standin.
|
||||
|
||||
### <a id="adapter-cube_standin"></a>Stand in for cube layers
|
||||
|
||||
Keeps games running that use a 360° cube layer the Frame lacks (for example Budget Cuts Ultimate). Leave it on.
|
||||
@@ -1437,7 +1459,7 @@ Keeps games running that use a 360° cube layer the Frame lacks (for example Bud
|
||||
- Game settings → Troubleshooting → "Stand in for cube layers" (advanced).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> The Frame's runtime has no cube-map layers and refuses their swapchains; OVRPlugin then crashes when it submits the next frame (for example Budget Cuts Ultimate). FrameBridge serves such a swapchain itself (a GL cube map, GLES games) and drops its layers, so the game runs on without that layer.
|
||||
> The Frame's runtime has no cube-map layers and refuses their swapchains; OVRPlugin then crashes when it submits the next frame (for example Budget Cuts Ultimate). FrameBridge serves such a swapchain itself on GLES and Vulkan; cube_quads displays its loading screens.
|
||||
|
||||
### <a id="adapter-cylinder_strips"></a>Show curved menus and screens
|
||||
|
||||
@@ -1772,7 +1794,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 (96):** AgeOfJoy, Ancient_Dungeon, Attack on Titan VR: Unbreakable, Audica, AugmentedEmpire, Battlemarked, BattleSisters, BattleTalent and 88 more.
|
||||
- **Catalog games (102):** AgeOfJoy, Ancient_Dungeon, Attack on Titan VR: Unbreakable, Audica, AugmentedEmpire, Battlemarked, BattleSisters, BattleTalent and 94 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.
|
||||
@@ -1966,7 +1988,7 @@ For games whose hands trail the controllers when you move them (for example Batt
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Controllers → "Fix hands lagging behind controllers" (advanced).
|
||||
- **Catalog games (15):** Battlemarked, BattleSisters, Beat Saber, Dance Central, Dungeons of Eternity, Mini Motor Racing X, OrbusVR, Phantom: Covert Ops and 7 more.
|
||||
- **Catalog games (16):** Battlemarked, BattleSisters, Beat Saber, Dance Central, Dungeons of Eternity, Mini Motor Racing X, OrbusVR, Phantom: Covert Ops and 8 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> 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).
|
||||
@@ -1999,6 +2021,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` · experimental
|
||||
- **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.
|
||||
@@ -2066,7 +2102,7 @@ Changes the game's render resolution. Higher is sharper but slower; 1.0× is the
|
||||
- **Options:**
|
||||
- Value: float, default `1.0` (always written).
|
||||
- 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.
|
||||
- **Catalog games (14):** 4XVR Video Player, BlazeRush, CrazyTaxiVR, cubism, FGO VR, I Expect You To Die 2, Spectre, Survival Nation and 6 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.
|
||||
@@ -2282,6 +2318,40 @@ For Unreal Engine 5 games that crash on the Frame (for example Metro Awakening).
|
||||
|
||||
> Vulkan shim (frame.vk_sanitize): the game doesn't see VK_EXT_fragment_density_map(2). Unreal Engine 5 turns on fragment-density-map foveation when the driver offers it and expects the density map from the headset, which the Frame doesn't provide: image views and barriers for a missing image, then a crash in the Frame's Vulkan driver (for example Metro Awakening). Valve's own foveation layer isn't affected.
|
||||
|
||||
### <a id="adapter-vk_no_fossilize"></a>Without Steam's shader cache layer
|
||||
|
||||
Some games can't create any graphics with it (for example Transformers Beyond Reality).
|
||||
|
||||
- `adapter.vk_no_fossilize`
|
||||
- **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 → "Without Steam's shader cache layer" (advanced).
|
||||
- **Catalog games (1):** Transformers Beyond Reality: Redux.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `vulkan-pipeline-refused` (fatal): The Frame's graphics driver refused to build some of the game's shaders, and Unreal ends the game at the first one (for example Transformers Beyond Reality).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Vulkan shim (frame.vk_sanitize): takes Lepton's Fossilize shader-cache layer out of the game's Vulkan layers (Android's debug layer list) when the engine loads its Vulkan library. With Fossilize loaded every graphics pipeline of Transformers Beyond Reality failed (VK_ERROR_OUT_OF_HOST_MEMORY), which Unreal treats as fatal (GitHub #349). Lepton has no switch for it; instances created before the engine loads the shim keep it.
|
||||
|
||||
### <a id="adapter-vk_pipeline_fallback"></a>Skip refused graphics
|
||||
|
||||
When the Frame's driver refuses one of the game's shaders, that object is left out instead of the game ending.
|
||||
|
||||
- `adapter.vk_pipeline_fallback`
|
||||
- **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 → "Skip refused graphics" (advanced).
|
||||
- **Catalog games (1):** Transformers Beyond Reality: Redux.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `vulkan-pipeline-refused` (fatal): The Frame's graphics driver refused to build some of the game's shaders, and Unreal ends the game at the first one (for example Transformers Beyond Reality).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Vulkan shim (frame.vk_sanitize): a graphics pipeline the Frame's driver refuses is created again with a fragment shader that writes zero, then with a vertex shader that only sets the position as well, so the draw shows nothing instead of Unreal ending with "Shader compilation failures are Fatal." (for example a few multiview vertex shaders in Transformers Beyond Reality, GitHub #349). Logged as "retried with a blank".
|
||||
|
||||
### <a id="adapter-vk_pipeline_log"></a>Vulkan pipeline failures (log)
|
||||
|
||||
Logs graphics pipelines the Frame's driver refuses. No effect on the game.
|
||||
@@ -2456,12 +2526,12 @@ Render less detail outside your gaze; adjust the full-resolution area or darken
|
||||
- **Options:**
|
||||
- `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
|
||||
- `level`: str, default empty — Runtime profile: lo, med, hi (empty = 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. 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, following each eye's gaze. Default keeps the runtime profile unchanged. Low has the largest full-resolution center; medium and high use smaller centers and stronger peripheral detail reduction. 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
|
||||
|
||||
@@ -2531,7 +2601,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 (114):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable and 106 more.
|
||||
- **Catalog games (120):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable and 112 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".
|
||||
|
||||
@@ -20,6 +20,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Conversion fails with `NoSuchElementException: Sequence is empty` in `PatcherContext.appName` (GitHub #269, Accounting+) | OVRPort assumes an application label exists; some APKs only label their activity | FramePort retries that exact failure once using a temporary APK whose application inherits the activity's label; existing labels and the original APK are kept |
|
||||
| Updating a flat Android app after saving its recipe fails with `no OVRPort patches selected` (GitHub #271, Plezy) | older FramePort loads the saved recipe without restoring its unchanged-install setting or applying the flat-app build rules, then tries an empty VR conversion | update FramePort: saved recipes keep `as_is`, and flat apps still skip OVRPort; existing catalog-derived recipes refresh at the next app update. Until then, enable "Install as is" for an app whose APK needs no fixes |
|
||||
| A launch test suggests a patch (for example triage `sdl-no-clipboard` → `frame.sdl_clipboard`) but the game page doesn't show it / lists it as not applicable; or a game added long ago lacks warnings newer ones get (Android version, web wrapper, missing OBB) (GitHub #104, Dramatic Shape) | the library entry was analyzed by an older FramePort: the analysis field the patch reads (`sdl_java`, `min_sdk`, `web_wrapper`, `expects_obb`, …) is missing, so `applies()` is false | entries whose `analysis.extra.analysis_version` is below `detect.ANALYSIS_VERSION` are analyzed again at the GUI's start (background) and before a build (`pipeline.refresh_analyses`); by hand: game menu → "Analyze again", then Update on Frame. An unreadable APK is marked `analysis_failed` (retried after the next version bump). Adding an analysis field: bump `ANALYSIS_VERSION` |
|
||||
| Foveation full-resolution area looks identical at 10% and 45% (GitHub #394), while render resolution changes it | `FOVE_INNER`/`FOVE_OUTER` are neither passed through by Lepton nor read by Valve's layer; shared injection uses rectangular density presets | Game settings → Picture now exports named `FOVE_LEVEL=lo/med/hi`; Default preserves the runtime choice. Medium/high shrink the center and reduce peripheral density further. Legacy radius preferences map to the nearest old nominal preset. Compare Fixed + Darken coarse fragments with adapter `snapshot=N` at a fixed render resolution; see FRAME_RUNTIME.md for addresses and profile data. Agent launcher allowlist needs `FOVE_LEVEL`. |
|
||||
| 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`) | 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 |
|
||||
@@ -31,6 +32,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| A Unity Quest game stays on a loading screen or doesn't go on; `An unknown error occurred. Request failed.` from `Oculus.Platform.Request`1:OnComplete` (GitHub #346 Eleven, #360 Spectre, #368 Thief Simulator; triage `meta-request-failed`) | A Meta platform call the game waits for got no usable answer: OVRPort's platform library answers some calls without Meta's services (request id 0) and Meta's Unity wrapper drops the callback | Find the call in the stack (the game's own coroutine after `OnComplete`); a per-game fix that skips it when it isn't needed to play (e.g. an analytics id request). Calls the game needs online stay unsupported |
|
||||
| Game aborts at start: `OVRPlatform-Loader: DisplayErrorAndExit: Failed to launch SystemActivities` after an `ActivityNotFoundException` stack (GitHub #357 Breachers; triage `meta-system-activities`) | The game ships its own renamed copy of Meta's platform loader (e.g. `libovr_custom.so`), which bypasses OVRPort's dispatcher and tries to open Meta's system activities (Horizon OS) | None in general: such games are online games built on Meta's services. Serving OVRPort's dispatcher under that name is possible but unlikely to make them playable |
|
||||
| Missing sound or extra content in a game whose OBB folder has separate `*.pak` files (GitHub #61, Star Wars: Tales from the Galaxy's Edge: seasons + Wwise banks) | the game asks Meta's platform for these asset files (`ovr_AssetFile_GetList` / `DownloadById`); OVRPort's loader doesn't list them | copy every content/sound-bank PAK and use `frame.asset_files` (native/langpack lists the data's `*.pak` as installed `default` assets, IAP `free`, and answers DownloadById at once with the path; off: env FRAMEPORT_ASSET_FILES=0). Tales also needs the splash and depth-size fixes below |
|
||||
| Quest saves need a cloud folder or backups after playing (discussion #352) | Steam does not sync Quest saves | Settings → Data: choose a writable local cloud-sync folder and optionally enable **Back up saves when a game closes**, then connect to apply it. The Frame keeps three changed snapshots per game; FramePort imports them on connection/poll. Manual **Back up saves** is next to Play; Restore stays in the menu. Old backup folders remain available and are never moved silently |
|
||||
| A SideQuest backup has `apk/` and a sibling `obb/` with content/sound-bank PAKs, but the Frame only has the main OBB (GitHub #61, Tales from the Galaxy's Edge) | older scans treated that layout as loose expansion files and selected only the named OBBs; the asset-file patch then found no PAKs | the scanner chooses the OBB folder by version first, then keeps the whole sibling data folder when all its expansion files are selected for this APK; app updates repair affected library entries and refresh asset/language-pack detection. Reinstall to copy the missing files, or scan the backup again first. Folders with other packages or unselected OBB versions still send only the selected OBBs |
|
||||
| Tales from the Galaxy's Edge stops during map loading (GitHub #61): game thread in `FlushRenderingCommands` / `UEngine::LoadMap`, render thread in `FSplash::RenderFrame_RenderThread` / `ovrp_WaitToBeginFrame` / SteamVR `WaitFrame` | the separate Oculus loading splash waits for an unbegun frame while map travel waits for rendering | `frame.unreal_splash_bypass`: returns from the splash renderer, with a checked symbol, size and prologue. The last image stays visible during loading; the normal renderer and menu layers continue. Headset test with the depth-size fix: gameplay, both eyes, controller input and sound work |
|
||||
| Upper part of the intro/environment is black or clipped, but a separate menu panel is intact (GitHub #61, Tales from the Galaxy's Edge) | `FSceneRenderTargets::AllocMobileMultiViewDepth` caches the initial Android window dimensions (1920x1016/1020), leaving the depth attachment shorter than the eye color texture (1728x1728); drawing is limited by the smaller attachment | `frame.unreal_multiview_depth`: refreshes both cached depth dimensions from the current scene `BufferSize` before allocation. Checks the exported symbol, function size and surrounding instructions, then replaces only the branch that skips refresh. Keeps multiview and multisampling. Headset test: full view and gameplay work |
|
||||
@@ -107,6 +109,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| `xrConvert…TimeKHR` FUNCTION_UNSUPPORTED spam; VrApi bridge stalls before recenter | runtime lacks timespec conversion | current adapter emulates it |
|
||||
| Crash on the first frame, backtrace `libVkLayer_fossilize.so` ← `FVulkanRenderPass::FVulkanRenderPass` (crash logcat) | the engine leaves the pNext of unused Vulkan attachment references uninitialized; Lepton always loads Steam's Fossilize layer (guest is userdebug, so even a non-debuggable APK gets it), which follows the pointer | `frame.vk_sanitize` (default on for Unreal; Vulkan shim `libfp_vk.so`; triage `fossilize-renderpass`), for example Deadpool VR |
|
||||
| SIGABRT right after start, backtrace `libopenxr_loader.so (xrCreateSwapchain+…)` | overport's dispatcher aborts on swapchains > 4096 px ("Wrong createInfo size"); video players use 7680×3840 theater textures | `frame.swapchain_limit` (default on; guard → 16384 px; triage `swapchain-size-abort`), for example 4XVR |
|
||||
| Cube loading screen missing/frozen, `cube_standin: dropped a layer (type=1000006000)` (HardBullet #385, Budget Cuts #107) | Runtime refuses cube swapchains and composites only quad/projection layers | Rebuild with `cube_standin=1`, `cube_quads=1` (defaults): mip-0 cube faces become six inward quads. Pools reuse six swapchains per size/format, with array slices for distinct cubes. Cubes are conservatively skipped in any frame containing a projection, since Frame draws quads above it. Allocation/copy failure falls back to dropping with `cube_quads:` diagnostics. Host-tested; headset visuals pending |
|
||||
| Video/menu panel missing, log `dropped N unusable layer(s)` with cylinder (1000017000) or equirect2 (1000091000) layers | the Frame runtime lacks XR_KHR_composition_layer_cylinder/equirect(2)/cube (its SteamVR runtime only composites quad + projection layers) | adapter shows cylinders as flat strips (setting `cylinder_strips`, default on); 360° equirect layers (theaters, 360° videos): `adapter.equirect_emul` (GLES only, per game) |
|
||||
| 360° theater / 360° video black or missing (for example 4XVR) | equirect layers dropped | `adapter.equirect_emul=1`: a worker thread with a shared GLES context converts each 360° image to a cube map when it changes and draws one adapter projection layer from it for every frame with exactly that frame's views; it replaces the 360° layers in place, so the game's own projection layer (balcony, controllers, 360° videos it draws itself) stays on top. Quads can't be used: the Frame draws quad layers above every projection layer whatever the order. Log: `first 360 view ready`, `in 5 s: N 360 image update(s) … M view redraw(s)`. Upside down / mirrored / behind you: `equirect_flip` 1/2/4 (`frameport settings <pkg> equirect_flip=1`, no rebuild). Never Vulkan (a Vulkan renderer hung the Frame's GPU in AC Nexus) |
|
||||
| Fades to black don't show: you see the level loading behind a loading screen (Batman: Arkham Shadow, GitHub #197); with `layer_debug=1` launch.log shows `color scale 0.00 0.00 0.00 …` on the projection layer | the game fades by dimming its eye image with XR_KHR_composition_layer_color_scale_bias; whether the Frame's runtime applies it isn't confirmed in the headset yet | `adapter.fade_quad=1` (opt-in, `native/adapter/fade_quad.c`): FrameBridge adds a black, alpha-blended quad right after the dimmed projection layer (VIEW space, 1 m ahead, 6×6 m; opacity = 1 − scale in 16 steps from one 8×8 swapchain refilled only when the step changes: Vulkan clear / GLES clear; one swapchain per step hit XR_ERROR_LIMIT_REACHED in Batman), log `fade_quad: drawing fades over the picture`; `fade_quad=2` also removes the layer's own scale (if both show, the fade is doubled) |
|
||||
@@ -122,11 +125,12 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| PC VR game judders/stutters although the GPU keeps up; vrcompositor.txt "Timed out. N total" high or frames dropped | the game misses the headset's refresh (for example 96 Hz = 10.4 ms; Stormland's slow frames take 10.7 ms) | `pcvr.steamvr_tuning` (default on): next Play sets SteamVR per-app `preferredRefreshRate` + `motionSmoothingOverride` via `fp_vrsettings.exe` |
|
||||
| Quest app keeps recentering / snapping the view when you turn your head; log shows session state 5→4→3→4→5 within a second | the Frame briefly takes focus; the app recenters on focus changes | adapter `focus_hold` (on by default; an earlier global debounce made AC Nexus stay black and was removed) |
|
||||
| Video player / app finds no local videos; MediaProvider "Requested path /home/steamos/... doesn't appear under ..." | Lepton's /sdcard is a symlink to a host path, so Android's media index rejects every file | upload in the Files tab (Videos = /sdcard/Movies) and browse folders in the app |
|
||||
| Batman: Arkham Shadow closes during smoke-bomb effects; later input-tree corruption | Meta XR Audio Wwise deletes queued metadata still referenced by the current audio frame, then writes through the stale pointer | FrameBridge automatically retires metadata after current-frame references disappear, for the verified AArch64 SDK build only; see [AUDIO_METADATA.md](AUDIO_METADATA.md) |
|
||||
| Batman: Arkham Shadow closes during smoke-bomb effects; later input-tree corruption | Meta XR Audio Wwise deletes queued metadata still referenced by the current audio frame, then writes through the stale pointer | FrameBridge automatically retires metadata after current-frame references disappear, for the verified AArch64 SDK build only, using a single-word entry branch and nearby stub (hook skipped if no stub fits); see [AUDIO_METADATA.md](AUDIO_METADATA.md) |
|
||||
| Batman: Arkham Shadow aborts loading a save/scene, with a 26–147 GB allocation through `MetaXRAudioUnityMemoryAllocator.cpp` | The asynchronous audio geometry task retains a native view without its owning UnityWebRequest; source bytes can be reused before parsing | Update Batman on Frame: the adapter copies geometry before background dispatch and releases it after the original worker completes, only for the verified Batman IL2CPP/Meta SDK builds; see [AUDIO_GEOMETRY.md](AUDIO_GEOMETRY.md) |
|
||||
| Game stays on a loading or "Waiting" box although it reached FOCUSED; log `FrameBridge: xrEndFrame failed -25` then no more `pacing:` lines (for example PowerWash Simulator on some Frames, GitHub #39) | The eye image rect ends a few pixels past its swapchain (Unity rounds the swapchain width, the runtime's recommended size differs per Frame); SteamVR rejects every frame with XR_ERROR_SWAPCHAIN_RECT_INVALID | FrameBridge clamps every projection/quad image rect to its swapchain (`rect_clamp`, default on since 0.11.0; log `rect_clamp: view N image rect clamped`): rebuild + reinstall the game |
|
||||
| Black from the start, `projection view 0: ... rect=0,0 0x0` then `xrEndFrame failed -25` twice and OVRPlugin "Unable to submit Oculus frame" (BattleTalent, GitHub #293; right after Unity's "Switching to the recommended level" MSAA line) | The game submits its first projection views with an empty image rect; SteamVR rejects them and OVRPlugin gives up submitting | `rect_clamp` replaces an empty rect with the whole swapchain image (log `rect_clamp: view N empty image rect -> full swapchain WxH`): rebuild + reinstall. Check the build output for a WARN from the Unity IL2CPP fixes (`frame.unity_runtime_msaa_off` etc. "code not found in this game"): turned on, they may still change nothing |
|
||||
| Black flash, then the SteamVR home environment while the game keeps running (sound plays); vrserver.txt `[OpenXR] SetXrInputConfig failed. Failed to load manifest provided by SetXrInputConfig`, then the app "has exited" and reconnects as `VRApplication_OpenXRInstance`; headless launch tests pace at 10 fps (for example Scared By Squares, GitHub #323) | Two controller actions in one action set share a name (Unreal: an action mapping and an axis mapping both called "Turn"). The Frame's runtime accepts the duplicate but can't load the input manifest it builds and drops the app as a scene app | FrameBridge gives the later duplicate a free name (`action_dedup`, default on; log `action_dedup: action "turn" ("Turn") already exists in its set: created as "turn_2"`): rebuild + reinstall the game. `input_diag=1` logs every action set and action the game creates |
|
||||
| Unity game (OVRPlugin, GLES or Vulkan) crashes ~10 s in: `FrameBridge: xrCreateSwapchain … faces=6 … result=-2`, OVRPlugin `CreateSwapchain for eye 0: 0x0, 0 stages`, then render-thread SIGSEGV in memset ← libOVRPlugin ← `ovrp_EndFrame4` (libgallium/ANR follow; for example Budget Cuts Ultimate, GitHub #107); triage `cube-swapchain-refused` | The game shows a cube-map layer (OVROverlay cubemap); the Frame's runtime has no cube layers and refuses cube swapchains (XR_ERROR_RUNTIME_FAILURE); OVRPlugin doesn't check and writes into the image list it never got | FrameBridge `cube_standin` (default on): a refused cube swapchain is served as a GL cube map in the game's context or a device-local Vulkan cube image transitioned to COLOR_ATTACHMENT_OPTIMAL, and its layers are dropped (log `cube_standin: runtime refused …`): rebuild + reinstall. Vulkan path covers UNDERDOGS (GitHub #296); host-tested, headset verification pending |
|
||||
| Unity game (OVRPlugin, GLES or Vulkan) crashes ~10 s in: `FrameBridge: xrCreateSwapchain … faces=6 … result=-2`, OVRPlugin `CreateSwapchain for eye 0: 0x0, 0 stages`, then render-thread SIGSEGV in memset ← libOVRPlugin ← `ovrp_EndFrame4` (libgallium/ANR follow; for example Budget Cuts Ultimate, GitHub #107); triage `cube-swapchain-refused` | The game shows a cube-map layer (OVROverlay cubemap); the Frame's runtime has no cube layers and refuses cube swapchains (XR_ERROR_RUNTIME_FAILURE); OVRPlugin doesn't check and writes into the image list it never got | FrameBridge `cube_standin` (default on): a refused cube swapchain is served as a GL cube map in the game's context or a device-local Vulkan cube image transitioned to COLOR_ATTACHMENT_OPTIMAL, and `cube_quads` displays cube-only loading screens as six quads (log `cube_standin: runtime refused …`): rebuild + reinstall. Vulkan path covers UNDERDOGS (GitHub #296); host-tested, headset verification pending |
|
||||
| Game (Unity, GLES) freezes, `zink: DEVICE LOST` | multisampled render-to-texture hangs the GPU | `frame.unity_no_msaa`; if it persists: unfixable → PC version |
|
||||
| Unreal GLES game crashes ~3 s after start: `Fatal signal 11 … fault addr 0x10000 in tid … (RHIThread)`, `#01 … libgallium_dri.so` (GitHub #83 Star Wars Pinball VR, triage `unreal-msrtt-crash`) | Unreal's mobile MSAA renders through multisampled render-to-texture; Zink's `find_rp_state` then indexes `rendering_state_cache[6]` one past its end (sample count ≥ 32) and calls a junk hash function (0x10000). Mesa bug (no bounds check) | `frame.unreal_gl_shim` (GL shim with `gl_hide_msrtt`, multiview kept for Unreal: Unreal 4.25 only enables multiview with GL_OVR_multiview, GL_OVR_multiview2 **and** GL_OVR_multiview_multisampled_render_to_texture, so the shim keeps the last one visible for Unreal and maps glFramebufferTextureMultisampleMultiviewOVR to the single-sampled glFramebufferTextureMultiviewOVR; log `multiview multisampled render-to-texture (N samples) drawn single-sampled`) |
|
||||
| Crash with `SIGILL (ILL_ILLOPN)` and `*pc=0xd50323bf` (autiasp), for example on Unreal's HttpManager thread (GitHub #83 Star Wars Pinball VR, triage `pac-unpaired`) | The engine's OpenSSL ARMv8 assembly has autiasp without a matching paciasp (libUE4.so: 38 paciasp, 40 autiasp; Poly1305 NEON). Quest CPUs treat PAC hints as NOPs, the Frame's CPU checks them | `frame.pac_hints`: in a library with unpaired counts every paciasp/autiasp becomes a NOP (in place) |
|
||||
@@ -157,7 +161,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) |
|
||||
@@ -207,7 +211,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Unity game shows a picture but the player body is frozen: no head tracking, the controllers stay on the model, no buttons, although OpenXR input works (BONELAB 1.2068, Oculus XR Plugin) | OVRPlugin reports the headset as not worn (`ovrp_GetUserPresent2` → 0) a few seconds after start, and the game's rig only follows a present user (Marrow `XRHMD.IsUserPresent` = Unity's HMD `UserPresence`) | `frame.unity_user_presence` (the Oculus XR Plugin's lookup → `libfp_ovrp.so`, reports present; log `user presence: OVRPlugin 0 …`) |
|
||||
| Unreal 5 game crashes at its first render pass, backtrace `vulkan.freedreno.so` ← `libVkLayer_fossilize.so` ← `libfp_vk.so (create_render_pass2)` (Metro Awakening) | a subpass without a depth attachment names a depth/stencil resolve attachment; the Frame's Turnip reads the missing depth attachment (NULL) | the Vulkan shim drops that resolve (always, FramePort after 0.11.0; log `dropped the depth resolve of subpass N (no depth attachment)`); triage `unreal-vulkan-driver-crash` |
|
||||
| Unreal game crashes before its first frame: SIGSEGV `fault addr 0x10` on the main thread, backtrace all `libUnreal.so` above the linker's `soinfo::call_constructors` (inside `System.loadLibrary`), right after `Parsing commandline` (GitHub #349, Transformers Beyond Reality) | The game is built with Unreal's Binned3 allocator, which reserves its whole range while the library loads: one `mmap(NULL, 76 GiB, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS)`. Quest grants it; the Frame's kernel (heuristic overcommit, mode 0) refuses a writable private mapping larger than RAM + swap, and Unreal's out-of-memory handler ("Ran out of memory allocating ...") dereferences its not-yet-created log | `frame.unreal_noreserve` (suggested from analysis `unreal_binned3`): every such anonymous read/write mmap in the engine library gets `MAP_NORESERVE` (one instruction each: `mov w3, #0x22` → `#0x4022`); triage `unreal-reserve-refused`. Found by disassembling the crash pc (the OOM handler; its UTF-16 format string) and the caller's `mov x0, #0x1300000000` |
|
||||
| Unreal Vulkan game fails on its first frames and OVRPlugin reports `foveationImage = 0x0, width = 0, height = 0` (for example Transformers Beyond Reality: Redux, #349) | Quest FB foveation remains advertised by OVRPort's dispatcher despite `foveation_fix=1`. This is a hypothesis for the failure, not a confirmed cause: Redux's alternate no-ForceQuit build dereferences a null graphics-pipeline-state object during Vulkan pipeline binding, not a density image | Diagnostic `adapter.hide_fb_foveation=1` (off by default, Vulkan): filters the dispatcher and runtime extension lists/instance requests, removes foveation create/image chains and refuses profile creation/updates. Rebuild with the adapter **and xrshim** artifacts, then compare first-frame survival and Unreal's original fatal log. Does not disable Valve FDM injection. Device verification pending |
|
||||
| Unreal Vulkan game ends on its first frames with "Shader compilation failures are Fatal." (tombstone abort message; `System.exit` / ForceQuit; the no-ForceQuit build dereferences a null pipeline instead), `vk_pipeline_log=1` shows `vkCreateGraphicsPipelines failed: -1` (for example Transformers Beyond Reality: Redux, #349) | With Lepton's Fossilize layer loaded **every** graphics pipeline failed with VK_ERROR_OUT_OF_HOST_MEMORY, even a constant-colour one; retries without the pipeline cache, without the app's CPU allocator, and without Valve's FDM/RPO layers all failed too. Without Fossilize a few multiview vertex shaders still fail in Turnip (a blank fragment shader alone doesn't help, blank vertex + fragment does) | `adapter.vk_no_fossilize=1` (Vulkan shim removes `VK_LAYER_fossilize` from GraphicsEnv's debug layer list when the engine loads it) + `adapter.vk_pipeline_fallback=1` (refused pipelines are made with blank shaders; logged "retried with a blank"); triage `vulkan-pipeline-refused`. Headless: runs at 72 fps; headset check pending. The `foveationImage = 0x0` warning (`hide_fb_foveation`) was ruled out |
|
||||
| Unreal 5 game crashes in `vulkan.freedreno.so` called straight from `libUnreal.so` right after its swapchains, or a few seconds into the first frames (Metro Awakening) | Unreal turns on fragment-density-map foveation because Turnip offers VK_EXT_fragment_density_map, then records barriers (to `FRAGMENT_DENSITY_MAP_OPTIMAL`) and image views (2D array R8G8) for a density map the Frame never provides (VK_NULL_HANDLE); Turnip dereferences it | adapter `vk_hide_fdm=1` (the game doesn't see the extension) + `vk_spec_fixes=1` (leaves out null-image barriers, refuses null-image views); triage `unreal-fdm-missing`. Found by disassembling the driver at the crash pc: the struct offsets (0x58 = VkSubpassDescription2, 0x48/0x60 = image barriers, viewType/format in registers) name the call |
|
||||
| Own-engine game crashes in `je_free` from its own library on the first frames (Roblox) | the game frees memory it doesn't own; not the Vulkan shim or the format fallback | unsupported |
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 132 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 424 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"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ shots:
|
||||
steps:
|
||||
- go: monitor
|
||||
- wait_for: "Processes"
|
||||
- hook: monitor_settled # the history replay finished (a slow runner shot it halfway: numbers differed)
|
||||
|
||||
# newer screens (docs/SHOWCASE.md's gallery; the README picks what it needs)
|
||||
- name: live-view # idle: a running stream shows its relay's random port and the data rate (no stable picture)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <errno.h>
|
||||
#ifndef MAP_FIXED_NOREPLACE
|
||||
#define MAP_FIXED_NOREPLACE 0x100000
|
||||
#endif
|
||||
#include <string.h>
|
||||
#include <sys/mman.h>
|
||||
|
||||
static size_t mx_audio_page;
|
||||
static int mx_audio_branch(uintptr_t from,uintptr_t to,uint32_t *word) {
|
||||
if((from|to)&3)return 0;
|
||||
if(to>=from) {if(to-from>=((uintptr_t)1<<27))return 0;}
|
||||
else if(from-to>((uintptr_t)1<<27))return 0;
|
||||
*word=0x14000000u|((uint32_t)((to-from)>>2)&0x03ffffffu);
|
||||
return 1;
|
||||
}
|
||||
static void mx_audio_jump(unsigned char *out,uintptr_t destination) {
|
||||
const uint32_t jump[2]={0x58000050u,0xd61f0200u};
|
||||
memcpy(out,jump,8);memcpy(out+8,&destination,8);
|
||||
}
|
||||
struct mx_audio_candidate { uintptr_t address,distance; };
|
||||
static int mx_audio_candidate_order(const void *a,const void *b) {
|
||||
const struct mx_audio_candidate *x=a,*y=b;
|
||||
if(x->distance!=y->distance)return x->distance<y->distance?-1:1;
|
||||
return x->address<y->address?-1:x->address>y->address;
|
||||
}
|
||||
// Maps are sorted by address. Clip every free gap to the intersection of both
|
||||
// branch ranges; enumerate pages, then sort outward (lower address breaks ties).
|
||||
static int mx_audio_candidates(FILE *maps,uintptr_t target,
|
||||
struct mx_audio_candidate **out,size_t *count,const char **failure) {
|
||||
uintptr_t radius=(uintptr_t)1<<27;
|
||||
uintptr_t low=target>radius?target-radius:0;
|
||||
uintptr_t high=target>UINTPTR_MAX-radius?UINTPTR_MAX:target+radius;
|
||||
uintptr_t cursor=low;
|
||||
size_t capacity=0,line_capacity=0;char *line=NULL;
|
||||
*out=NULL;*count=0;
|
||||
for(;;) {
|
||||
unsigned long long start,end;
|
||||
*failure="read /proc/self/maps";
|
||||
int eof=getline(&line,&line_capacity,maps)<0;
|
||||
if(eof && (ferror(maps) || !feof(maps)))goto fail;
|
||||
if(!eof && (sscanf(line,"%llx-%llx",&start,&end)!=2 || end<=start)) {*failure="parse /proc/self/maps";errno=EINVAL;goto fail;}
|
||||
uintptr_t stop=eof?high:(uintptr_t)start;
|
||||
if(stop>high)stop=high;
|
||||
uintptr_t page=cursor;
|
||||
if(page%mx_audio_page)page+=mx_audio_page-page%mx_audio_page;
|
||||
for(;page<stop && stop-page>=mx_audio_page;page+=mx_audio_page) {
|
||||
uint32_t word;
|
||||
if(!page || !mx_audio_branch(target,page,&word) ||
|
||||
!mx_audio_branch(page+20,target+4,&word))continue;
|
||||
if(*count==capacity) {
|
||||
size_t next=capacity?capacity*2:64;
|
||||
void *p=realloc(*out,next*sizeof(**out));
|
||||
if(!p){*failure="allocate stub candidates";goto fail;}
|
||||
*out=p;capacity=next;
|
||||
}
|
||||
(*out)[(*count)++]=(struct mx_audio_candidate){page,page>target?page-target:target-page};
|
||||
}
|
||||
if(eof || stop==high)break;
|
||||
if((uintptr_t)end>cursor)cursor=(uintptr_t)end;
|
||||
if(cursor>=high)break;
|
||||
}
|
||||
free(line);
|
||||
if(*count)qsort(*out,*count,sizeof(**out),mx_audio_candidate_order);
|
||||
return 1;
|
||||
fail:
|
||||
{int saved=errno;free(line);free(*out);*out=NULL;*count=0;errno=saved;return 0;}
|
||||
}
|
||||
// Callers serialize installation. Only the verified SUB SP / STP prologues
|
||||
// below may be replayed: no PC-relative instruction or branch relocation.
|
||||
static void *mx_audio_detour(unsigned char *address,void *replacement,const uint32_t expected[4],void *original_slot) {
|
||||
uint32_t branch;
|
||||
uintptr_t target=(uintptr_t)address;
|
||||
const char *failure=NULL;int failure_errno=0,rollback_errno=0;
|
||||
if(target&3)failure="unaligned entry";
|
||||
else if(memcmp(address,expected,16))failure="16-byte guard mismatch";
|
||||
else if(expected[0]!=0xd10743ffu && expected[0]!=0xa9bd7bfdu &&
|
||||
expected[0]!=0xd10243ffu && expected[0]!=0xd10183ffu)
|
||||
failure="unsupported replay instruction";
|
||||
if(failure) {
|
||||
LOG("FrameBridge audio detour: target=%p failed: %s; entry unchanged",address,failure);
|
||||
return NULL;
|
||||
}
|
||||
if(!mx_audio_page || (mx_audio_page&(mx_audio_page-1))) {
|
||||
LOG("FrameBridge audio detour: target=%p failed: invalid page size=%zu; entry unchanged",address,mx_audio_page);
|
||||
return NULL;
|
||||
}
|
||||
struct mx_audio_candidate *candidates=NULL;size_t count=0;
|
||||
FILE *maps=fopen("/proc/self/maps","r");
|
||||
if(!maps) {
|
||||
LOG("FrameBridge audio detour: target=%p failed: open /proc/self/maps errno=%d; entry unchanged",address,errno);
|
||||
return NULL;
|
||||
}
|
||||
int parsed=mx_audio_candidates(maps,target,&candidates,&count,&failure);
|
||||
int parse_errno=errno;fclose(maps);
|
||||
if(!parsed) {
|
||||
LOG("FrameBridge audio detour: target=%p failed: %s errno=%d; entry unchanged",address,failure,parse_errno);
|
||||
return NULL;
|
||||
}
|
||||
unsigned char *code=MAP_FAILED;void *result=MAP_FAILED,*attempt=NULL;
|
||||
void *closest=count?(void*)candidates[0].address:NULL;
|
||||
int map_errno=0;size_t attempts=0;
|
||||
for(size_t i=0;i<count;i++) {
|
||||
attempt=(void*)candidates[i].address;attempts++;
|
||||
result=mmap(attempt,mx_audio_page,PROT_READ|PROT_WRITE,
|
||||
MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED_NOREPLACE,-1,0);
|
||||
if(result==MAP_FAILED) {map_errno=errno;continue;}
|
||||
// Old kernels may ignore NOREPLACE. Never accept a relocated mapping.
|
||||
if(result!=attempt) {munmap(result,mx_audio_page);map_errno=0;continue;}
|
||||
code=result;break;
|
||||
}
|
||||
free(candidates);
|
||||
if(code==MAP_FAILED) {
|
||||
LOG("FrameBridge audio detour: target=%p failed: stub allocation closest=%p attempted=%p mmap=%p errno=%d attempts=%zu; no safe ELF slack fallback; entry unchanged",
|
||||
address,closest,attempt,result,map_errno,attempts);
|
||||
return NULL;
|
||||
}
|
||||
uint32_t back;
|
||||
if(!mx_audio_branch(target,(uintptr_t)code,&branch) ||
|
||||
!mx_audio_branch((uintptr_t)code+20,target+4,&back)) {
|
||||
failure="branch range validation";goto fail;
|
||||
}
|
||||
mx_audio_jump(code,(uintptr_t)replacement);
|
||||
memcpy(code+16,expected,4);memcpy(code+20,&back,4);
|
||||
__builtin___clear_cache((char*)code,(char*)code+24);
|
||||
if(mprotect(code,mx_audio_page,PROT_READ|PROT_EXEC)) {failure="mprotect stub RX";failure_errno=errno;goto fail;}
|
||||
unsigned char *page=(void*)(target&~(mx_audio_page-1));
|
||||
if(mprotect(page,mx_audio_page,PROT_READ|PROT_WRITE|PROT_EXEC)) {failure="mprotect entry RWX";failure_errno=errno;goto fail;}
|
||||
// Publish the callable original before any worker can enter the hook.
|
||||
if(original_slot)__atomic_store_n((void**)original_slot,code+16,__ATOMIC_RELEASE);
|
||||
__atomic_store_n((uint32_t*)address,branch,__ATOMIC_RELEASE);
|
||||
__builtin___clear_cache((char*)address,(char*)address+4);
|
||||
if(mprotect(page,mx_audio_page,PROT_READ|PROT_EXEC)) {
|
||||
failure_errno=errno;
|
||||
__atomic_store_n((uint32_t*)address,expected[0],__ATOMIC_RELEASE);
|
||||
__builtin___clear_cache((char*)address,(char*)address+4);
|
||||
if(mprotect(page,mx_audio_page,PROT_READ|PROT_EXEC))
|
||||
rollback_errno=errno; // Cannot force the kernel to restore RX.
|
||||
// A worker may already be in the stub/hook/trampoline. No grace period
|
||||
// exists here, so neither the mapping nor original_slot can be retired.
|
||||
LOG("FrameBridge audio detour: target=%p failed: mprotect entry RX errno=%d; rollback mprotect entry RX errno=%d; entry restored; published code retained",address,failure_errno,rollback_errno);return NULL;
|
||||
}
|
||||
return code+16;
|
||||
fail:
|
||||
munmap(code,mx_audio_page);
|
||||
LOG("FrameBridge audio detour: target=%p failed: %s errno=%d; entry unchanged",address,failure,failure_errno);return NULL;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
#pragma once
|
||||
#include <pthread.h>
|
||||
#include <sched.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
// The source NativeArray belongs to a UnityWebRequest. Copy while its caller
|
||||
// still owns that request, before async-void queues the background parser.
|
||||
typedef struct MxGeometryOwned {
|
||||
struct MxGeometryOwned *next;
|
||||
size_t size;
|
||||
unsigned char bytes[];
|
||||
} MxGeometryOwned;
|
||||
static pthread_mutex_t mx_geometry_owned_mutex=PTHREAD_MUTEX_INITIALIZER;
|
||||
static MxGeometryOwned *mx_geometry_owned_head;
|
||||
static unsigned mx_geometry_queued,mx_geometry_finished;
|
||||
static void (*mx_geometry_queue_original)(void*,const void*,uint64_t,void*);
|
||||
static void (*mx_geometry_worker_original)(void*,void*);
|
||||
static void mx_geometry_queue(void *object,const void *data,uint64_t packed_length,void *method) {
|
||||
void (*original)(void*,const void*,uint64_t,void*);
|
||||
while(!(original=__atomic_load_n(&mx_geometry_queue_original,__ATOMIC_ACQUIRE)))sched_yield();
|
||||
int32_t length=(int32_t)(uint32_t)packed_length;
|
||||
size_t size=length>0?(size_t)length:0;
|
||||
if(!data || !size || size>SIZE_MAX-sizeof(MxGeometryOwned)){original(object,data,packed_length,method);return;}
|
||||
MxGeometryOwned *owned=malloc(sizeof(*owned)+size);
|
||||
if(!owned){original(object,data,packed_length,method);return;}
|
||||
owned->size=size;
|
||||
memcpy(owned->bytes,data,size);
|
||||
pthread_mutex_lock(&mx_geometry_owned_mutex);
|
||||
owned->next=mx_geometry_owned_head;mx_geometry_owned_head=owned;
|
||||
pthread_mutex_unlock(&mx_geometry_owned_mutex);
|
||||
unsigned queued=__atomic_fetch_add(&mx_geometry_queued,1,__ATOMIC_RELAXED);
|
||||
if(queued<4)LOG("FrameBridge audio geometry: owned %zu bytes before background task queued",size);
|
||||
original(object,owned->bytes,packed_length,method);
|
||||
}
|
||||
static void mx_geometry_worker(void *closure,void *method) {
|
||||
const void *data=NULL;
|
||||
if(closure)memcpy(&data,(const unsigned char*)closure+16,8);
|
||||
void (*original)(void*,void*);
|
||||
while(!(original=__atomic_load_n(&mx_geometry_worker_original,__ATOMIC_ACQUIRE)))sched_yield();
|
||||
original(closure,method);
|
||||
MxGeometryOwned *finished=NULL;
|
||||
pthread_mutex_lock(&mx_geometry_owned_mutex);
|
||||
for(MxGeometryOwned **p=&mx_geometry_owned_head;*p;p=&(*p)->next)if((*p)->bytes==data) {
|
||||
finished=*p;*p=finished->next;break;
|
||||
}
|
||||
pthread_mutex_unlock(&mx_geometry_owned_mutex);
|
||||
if(finished) {
|
||||
unsigned count=__atomic_fetch_add(&mx_geometry_finished,1,__ATOMIC_RELAXED);
|
||||
if(count<4)LOG("FrameBridge audio geometry: background parse finished; releasing %zu owned bytes",finished->size);
|
||||
free(finished);
|
||||
}
|
||||
}
|
||||
@@ -11,26 +11,7 @@
|
||||
#include <time.h>
|
||||
#include <link.h>
|
||||
#include <string.h>
|
||||
static size_t mx_audio_page;
|
||||
static void *mx_audio_detour(unsigned char *address,void *replacement,const uint32_t expected[4]) {
|
||||
if(memcmp(address,expected,16)) return NULL;
|
||||
unsigned char *trampoline=mmap(NULL,mx_audio_page,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,-1,0);
|
||||
if(trampoline==MAP_FAILED) return NULL;
|
||||
uint32_t branch[2]={0x58000050u,0xd61f0200u}; // ldr x16, +8; br x16
|
||||
memcpy(trampoline,address,16);
|
||||
memcpy(trampoline+16,branch,8);
|
||||
uintptr_t continuation=(uintptr_t)address+16;
|
||||
memcpy(trampoline+24,&continuation,8);
|
||||
__builtin___clear_cache((char*)trampoline,(char*)trampoline+32);
|
||||
if(mprotect(trampoline,mx_audio_page,PROT_READ|PROT_EXEC)) return NULL;
|
||||
unsigned char *page=(unsigned char*)((uintptr_t)address&~(mx_audio_page-1));
|
||||
if(mprotect(page,mx_audio_page,PROT_READ|PROT_WRITE|PROT_EXEC)) return NULL;
|
||||
memcpy(address,branch,8);
|
||||
memcpy(address+8,&replacement,8);
|
||||
__builtin___clear_cache((char*)address,(char*)address+16);
|
||||
mprotect(page,mx_audio_page,PROT_READ|PROT_EXEC);
|
||||
return trampoline;
|
||||
}
|
||||
#include "audio_detour.h"
|
||||
#include "audio_metadata_queue.h"
|
||||
typedef struct {float previous,current;} MxAudioRamp;
|
||||
static uintptr_t mx_audio_base;
|
||||
@@ -49,8 +30,8 @@ static void mx_audio_kill_experimental(void) {mx_audio_kill(1);}
|
||||
static void mx_audio_kill_regular(void) {mx_audio_kill(0);}
|
||||
static uint64_t mx_audio_consume(void *sink,void *main_mix,void *passthrough,const void *objects,MxAudioRamp ramp) {
|
||||
const unsigned char *previous=mx_audio_objects;mx_audio_objects=objects;
|
||||
// An audio thread can enter just after its prologue was patched, before
|
||||
// the installing thread publishes the completed trampoline.
|
||||
// The installer publishes the completed trampoline before the entry branch.
|
||||
// Keep the acquire load paired with that publication.
|
||||
uint64_t (*original)(void*,void*,void*,const void*,MxAudioRamp);
|
||||
while(!(original=__atomic_load_n(&mx_audio_consume_original,__ATOMIC_ACQUIRE)))sched_yield();
|
||||
uint64_t result=original(sink,main_mix,passthrough,objects,ramp);
|
||||
@@ -79,15 +60,19 @@ static int mx_audio_find(struct dl_phdr_info *info,size_t size,void *unused) {
|
||||
const uint32_t kill[4]={0xa9bd7bfd,0xf9000bf5,0xa9024ff4,0x910003fd};
|
||||
unsigned char *base=(void*)info->dlpi_addr;
|
||||
if(memcmp(base+0x2c8fac,consume,16) || memcmp(base+0x2be2c0,kill,16) || memcmp(base+0x2be7b0,kill,16)) {
|
||||
const uintptr_t offsets[3]={0x2c8fac,0x2be2c0,0x2be7b0};
|
||||
for(unsigned i=0;i<3;i++)if(memcmp(base+offsets[i],i?kill:consume,16))
|
||||
LOG("FrameBridge audio detour: target=%p failed: 16-byte guard mismatch; entry unchanged",base+offsets[i]);
|
||||
__atomic_store_n(&mx_audio_checked,1,__ATOMIC_RELEASE);return 1;
|
||||
}
|
||||
// One installation attempt per verified library, including failure paths.
|
||||
__atomic_store_n(&mx_audio_checked,1,__ATOMIC_RELEASE);
|
||||
__atomic_store_n(&mx_audio_base,info->dlpi_addr,__ATOMIC_RELEASE);
|
||||
void *original=mx_audio_detour(base+0x2c8fac,(void*)mx_audio_consume,consume);
|
||||
__atomic_store_n(&mx_audio_consume_original,original,__ATOMIC_RELEASE);
|
||||
if(!__atomic_load_n(&mx_audio_consume_original,__ATOMIC_ACQUIRE)) {
|
||||
void *original=mx_audio_detour(base+0x2c8fac,(void*)mx_audio_consume,consume,&mx_audio_consume_original);
|
||||
if(!original) {
|
||||
__atomic_store_n(&mx_audio_base,0,__ATOMIC_RELEASE);return 1;
|
||||
}
|
||||
if(!mx_audio_detour(base+0x2be2c0,(void*)mx_audio_kill_experimental,kill) || !mx_audio_detour(base+0x2be7b0,(void*)mx_audio_kill_regular,kill)) {
|
||||
if(!mx_audio_detour(base+0x2be2c0,(void*)mx_audio_kill_experimental,kill,NULL) || !mx_audio_detour(base+0x2be7b0,(void*)mx_audio_kill_regular,kill,NULL)) {
|
||||
LOG("FrameBridge audio metadata: incomplete installation");return 1;
|
||||
}
|
||||
LOG("FrameBridge audio metadata: metadata reclamation follows current audio-frame references; Meta audio base=%p",base);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Batman 1.4.1: keep the web request's native audio data alive across Task.Run.
|
||||
#pragma once
|
||||
#if defined(__aarch64__)
|
||||
#include "audio_geometry_owned.h"
|
||||
#include "batman_audio_geometry_target.h"
|
||||
static int mx_geometry_checked;
|
||||
static int mx_geometry_find(struct dl_phdr_info *info,size_t ignored,void *unused) {
|
||||
(void)ignored;(void)unused;
|
||||
if(!strstr(info->dlpi_name,"/libil2cpp.so"))return 0;
|
||||
int match=0;
|
||||
for(unsigned i=0;i<info->dlpi_phnum;i++)if(info->dlpi_phdr[i].p_type==PT_NOTE) {
|
||||
const unsigned char *p=(void*)(info->dlpi_addr+info->dlpi_phdr[i].p_vaddr);
|
||||
const unsigned char *end=p+info->dlpi_phdr[i].p_memsz;
|
||||
while(p+sizeof(Elf64_Nhdr)<=end) {
|
||||
const Elf64_Nhdr *note=(const void*)p;
|
||||
const unsigned char *name=p+sizeof(*note),*desc=name+((note->n_namesz+3)&~3u);
|
||||
const unsigned char *next=desc+((note->n_descsz+3)&~3u);
|
||||
if(next>end || next<=p)break;
|
||||
if(note->n_type==NT_GNU_BUILD_ID && note->n_namesz==4 && !memcmp(name,"GNU",4) &&
|
||||
mx_geometry_matches_id(desc,note->n_descsz))match=1;
|
||||
p=next;
|
||||
}
|
||||
}
|
||||
__atomic_store_n(&mx_geometry_checked,1,__ATOMIC_RELEASE);
|
||||
if(!match)return 1;
|
||||
unsigned char *base=(void*)info->dlpi_addr;
|
||||
// Live VAs, not the file offsets printed by the managed-code dump.
|
||||
unsigned char *queue=base+0x2e61bb4,*worker=base+0x2e63a28;
|
||||
if(!mx_geometry_matches_entries(queue,worker)) {
|
||||
if(memcmp(queue,mx_geometry_queue_entry,16))
|
||||
LOG("FrameBridge audio detour: target=%p failed: 16-byte guard mismatch; entry unchanged",queue);
|
||||
if(memcmp(worker,mx_geometry_worker_entry,16))
|
||||
LOG("FrameBridge audio detour: target=%p failed: 16-byte guard mismatch; entry unchanged",worker);
|
||||
return 1;
|
||||
}
|
||||
// Hook the completion boundary first so every queued copy has a consumer.
|
||||
void *original=mx_audio_detour(worker,(void*)mx_geometry_worker,mx_geometry_worker_entry,&mx_geometry_worker_original);
|
||||
if(!original)return 1;
|
||||
original=mx_audio_detour(queue,(void*)mx_geometry_queue,mx_geometry_queue_entry,&mx_geometry_queue_original);
|
||||
LOG("FrameBridge audio geometry: Batman async input lifetime %s",original?"installed":"not installed");
|
||||
return 1;
|
||||
}
|
||||
static pthread_mutex_t mx_geometry_install_mutex=PTHREAD_MUTEX_INITIALIZER;
|
||||
static void mx_batman_geometry_initialize(void) {
|
||||
// The metadata initializer already verified the Meta SDK build/prologues.
|
||||
// Keep this independent of its early-out: IL2CPP may be loaded afterwards.
|
||||
if(__atomic_load_n(&mx_geometry_checked,__ATOMIC_ACQUIRE) ||
|
||||
!__atomic_load_n(&mx_audio_base,__ATOMIC_ACQUIRE))return;
|
||||
if(pthread_mutex_trylock(&mx_geometry_install_mutex))return;
|
||||
if(!mx_geometry_checked) {
|
||||
static time_t last_scan;
|
||||
struct timespec now;clock_gettime(CLOCK_MONOTONIC,&now);
|
||||
if(now.tv_sec!=last_scan) {
|
||||
last_scan=now.tv_sec;
|
||||
char package[64]={0};FILE *cmd=fopen("/proc/self/cmdline","rb");
|
||||
if(cmd){(void)fread(package,1,sizeof(package)-1,cmd);fclose(cmd);}
|
||||
if(strcmp(package,"com.camouflaj.manta"))__atomic_store_n(&mx_geometry_checked,1,__ATOMIC_RELEASE);
|
||||
else dl_iterate_phdr(mx_geometry_find,NULL);
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&mx_geometry_install_mutex);
|
||||
}
|
||||
#else
|
||||
static void mx_batman_geometry_initialize(void) {}
|
||||
#endif
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
static const unsigned char mx_geometry_il2cpp_id[20]={
|
||||
0x1d,0x48,0xa4,0x1d,0x7e,0x90,0x11,0xbe,0xf5,0xfa,0xe0,0xc2,0x3c,0x40,0x4c,0x62,0xa4,0xe7,0xa7,0x3c
|
||||
};
|
||||
static const uint32_t mx_geometry_queue_entry[4]={0xd10243ff,0xa9065ffe,0xa90757f6,0xa9084ff4};
|
||||
static const uint32_t mx_geometry_worker_entry[4]={0xd10183ff,0xa90267fe,0xa9035ff8,0xa90457f6};
|
||||
static int mx_geometry_matches_id(const void *id,size_t size) {
|
||||
return size==sizeof(mx_geometry_il2cpp_id) && !memcmp(id,mx_geometry_il2cpp_id,size);
|
||||
}
|
||||
static int mx_geometry_matches_entries(const void *queue,const void *worker) {
|
||||
return !memcmp(queue,mx_geometry_queue_entry,sizeof(mx_geometry_queue_entry)) &&
|
||||
!memcmp(worker,mx_geometry_worker_entry,sizeof(mx_geometry_worker_entry));
|
||||
}
|
||||
@@ -6,10 +6,13 @@
|
||||
// Budget Cuts Ultimate's cube-map overlay, GitHub #107). Only when the runtime refused one, a cube swapchain is served
|
||||
// by the adapter instead: one GL cube-map texture or Vulkan cube-compatible image with the requested format,
|
||||
// size and mip levels. Acquire/wait/release succeed at once. The app renders or copies into it as usual; layers
|
||||
// that use it are dropped in xrEndFrame (the runtime couldn't show them anyway).
|
||||
// are converted to six quad faces by cube_quads when no projection is present.
|
||||
#define STANDIN_MAX 8
|
||||
typedef struct {
|
||||
XrSwapchain handle;
|
||||
XrSession session;
|
||||
XrSwapchainCreateInfo info;
|
||||
uint32_t slice;
|
||||
GLuint tex;
|
||||
VkDevice device;
|
||||
VkImage image;
|
||||
@@ -43,7 +46,7 @@ static int standin_vk_create(const XrSwapchainCreateInfo *info, standin_swapchai
|
||||
PFN_vkQueueWaitIdle wait_idle = (PFN_vkQueueWaitIdle)vk.GetDeviceProcAddr(vk.device, "vkQueueWaitIdle");
|
||||
if (!destroy_pool || !wait_idle) goto done;
|
||||
s->device = vk.device;
|
||||
VkImageUsageFlags usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;
|
||||
VkImageUsageFlags usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
|
||||
if (info->usageFlags & XR_SWAPCHAIN_USAGE_SAMPLED_BIT) usage |= VK_IMAGE_USAGE_SAMPLED_BIT;
|
||||
if (info->usageFlags & XR_SWAPCHAIN_USAGE_TRANSFER_SRC_BIT) usage |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
|
||||
if (info->usageFlags & XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT) usage |= VK_IMAGE_USAGE_TRANSFER_DST_BIT;
|
||||
@@ -98,7 +101,7 @@ done:
|
||||
}
|
||||
|
||||
// After the runtime refused a swapchain: serve a cube stand-in if the graphics API can supply it.
|
||||
static XrResult standin_after_failure(const XrSwapchainCreateInfo *info, XrSwapchain *out, XrResult result) {
|
||||
static XrResult standin_after_failure(XrSession session, const XrSwapchainCreateInfo *info, XrSwapchain *out, XrResult result) {
|
||||
if (XR_SUCCEEDED(result) || !cube_standin || !info || !out || info->faceCount != 6) return result;
|
||||
if (info->width != info->height || !info->width || info->arraySize > 1) return result;
|
||||
int gles = (p_eglCreateContext || emul_load_gl()) && p_eglGetCurrentContext() != EGL_NO_CONTEXT;
|
||||
@@ -109,6 +112,9 @@ static XrResult standin_after_failure(const XrSwapchainCreateInfo *info, XrSwapc
|
||||
if (slot >= 0) standins[slot].handle = (XrSwapchain)1; // reserved
|
||||
pthread_mutex_unlock(&standin_lock);
|
||||
if (slot < 0) { LOG("cube_standin: too many cube swapchains, the runtime's error stays"); return result; }
|
||||
cube_outputs_destroy();
|
||||
standins[slot].session = session;
|
||||
standins[slot].info = *info;
|
||||
if (!gles) {
|
||||
if (!standin_vk_create(info, &standins[slot])) {
|
||||
pthread_mutex_lock(&standin_lock);
|
||||
@@ -121,7 +127,7 @@ static XrResult standin_after_failure(const XrSwapchainCreateInfo *info, XrSwapc
|
||||
*out = standins[slot].handle = FAKE_HANDLE(XrSwapchain);
|
||||
pthread_mutex_unlock(&standin_lock);
|
||||
LOG("cube_standin: runtime refused a %ux%u cube swapchain (result=%d): served by FrameBridge "
|
||||
"(Vulkan cube image, %u levels); its cube layers are dropped", info->width, info->height, result, info->mipCount);
|
||||
"(Vulkan cube image, %u levels); cube_quads can display its layers", info->width, info->height, result, info->mipCount);
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
GLsizei levels = info->mipCount > 1 ? (GLsizei)info->mipCount : 1;
|
||||
@@ -150,7 +156,7 @@ static XrResult standin_after_failure(const XrSwapchainCreateInfo *info, XrSwapc
|
||||
pthread_mutex_unlock(&standin_lock);
|
||||
*out = handle;
|
||||
LOG("cube_standin: runtime refused a %ux%u cube swapchain (result=%d): served by FrameBridge (GL cube map %u, "
|
||||
"%d levels); its cube layers are dropped", info->width, info->height, result, tex, (int)levels);
|
||||
"%d levels); cube_quads can display its layers", info->width, info->height, result, tex, (int)levels);
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
|
||||
@@ -172,6 +178,7 @@ static XrResult standin_enumerate(XrSwapchain handle, uint32_t capacity, uint32_
|
||||
}
|
||||
|
||||
static void standin_destroy(XrSwapchain handle) {
|
||||
cube_outputs_destroy();
|
||||
pthread_mutex_lock(&standin_lock);
|
||||
for (int i = 0; i < STANDIN_MAX; ++i)
|
||||
if (standins[i].handle == handle) {
|
||||
@@ -220,3 +227,236 @@ static XRAPI_ATTR XrResult XRAPI_CALL standin_get_swapchain_state(XrSwapchain sw
|
||||
if (is_standin(swapchain)) return XR_ERROR_VALIDATION_FAILURE; // no state to report (apps treat it as "unsupported")
|
||||
return real_get_swapchain_state ? real_get_swapchain_state(swapchain, state) : XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
}
|
||||
|
||||
// Six runtime swapchains per size/format. Array slices keep different stand-ins independent,
|
||||
// even when several cube layers of the same size appear in one frame.
|
||||
static struct {
|
||||
XrSession session;
|
||||
uint32_t size, slices;
|
||||
int64_t format;
|
||||
int vulkan, failed, ready;
|
||||
XrSwapchain face[6];
|
||||
} cube_pools[STANDIN_MAX], *cube_out;
|
||||
|
||||
static void cube_pool_destroy(int pool) {
|
||||
PFN_xrDestroySwapchain destroy = (PFN_xrDestroySwapchain)lookup(active_instance, "xrDestroySwapchain");
|
||||
for (int f = 0; f < 6; ++f)
|
||||
if (cube_pools[pool].face[f] && destroy) destroy(cube_pools[pool].face[f]);
|
||||
memset(&cube_pools[pool], 0, sizeof(cube_pools[pool]));
|
||||
}
|
||||
|
||||
static void cube_outputs_destroy(void) {
|
||||
for (int i = 0; i < STANDIN_MAX; ++i) cube_pool_destroy(i);
|
||||
cube_out = NULL;
|
||||
}
|
||||
|
||||
// Keep a failed key so a refused allocation/copy is diagnosed once, then falls back without retrying each frame.
|
||||
static void cube_output_disable(void) {
|
||||
XrSession session = cube_out->session;
|
||||
uint32_t size = cube_out->size;
|
||||
int64_t format = cube_out->format;
|
||||
int vulkan = cube_out->vulkan;
|
||||
cube_pool_destroy((int)(cube_out - cube_pools));
|
||||
cube_out->session = session;
|
||||
cube_out->size = size;
|
||||
cube_out->format = format;
|
||||
cube_out->vulkan = vulkan;
|
||||
cube_out->failed = 1;
|
||||
}
|
||||
|
||||
static int cube_outputs_create(XrSession session, const standin_swapchain *s) {
|
||||
int pool = -1, empty = -1;
|
||||
for (int i = 0; i < STANDIN_MAX; ++i) {
|
||||
if (!cube_pools[i].session && empty < 0) empty = i;
|
||||
if (cube_pools[i].session == session && cube_pools[i].size == s->info.width &&
|
||||
cube_pools[i].format == s->info.format && cube_pools[i].vulkan == !!s->image) pool = i;
|
||||
}
|
||||
if (pool < 0) pool = empty;
|
||||
if (pool < 0) return 0;
|
||||
cube_out = &cube_pools[pool];
|
||||
if (cube_out->failed) return 0;
|
||||
if (cube_out->face[5]) return 1;
|
||||
cube_out->slices = 0;
|
||||
for (int i = 0; i < STANDIN_MAX; ++i)
|
||||
if (standins[i].handle && standins[i].session == session && standins[i].info.width == s->info.width &&
|
||||
standins[i].info.format == s->info.format && !!standins[i].image == !!s->image)
|
||||
standins[i].slice = cube_out->slices++;
|
||||
cube_out->session = session;
|
||||
cube_out->size = s->info.width;
|
||||
cube_out->format = s->info.format;
|
||||
cube_out->vulkan = !!s->image;
|
||||
PFN_xrCreateSwapchain create = (PFN_xrCreateSwapchain)lookup(active_instance, "xrCreateSwapchain");
|
||||
XrSwapchainCreateInfo ci = {XR_TYPE_SWAPCHAIN_CREATE_INFO, NULL, 0,
|
||||
XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT,
|
||||
s->info.format, 1, s->info.width, s->info.height, 1, cube_out->slices, 1};
|
||||
for (int f = 0; f < 6; ++f) {
|
||||
XrResult r = create ? create(session, &ci, &cube_out->face[f]) : XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
if (XR_FAILED(r)) {
|
||||
cube_output_disable();
|
||||
LOG("cube_quads: face swapchain creation failed (result=%d); dropping cube layers", r);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
LOG("cube_quads: created six %ux%u face swapchains (%s)", s->info.width, s->info.height,
|
||||
s->image ? "Vulkan" : "GLES");
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int cube_copy_face(const standin_swapchain *s, int face, uint32_t index, uint32_t slice) {
|
||||
PFN_xrEnumerateSwapchainImages enumerate = (PFN_xrEnumerateSwapchainImages)lookup(active_instance, "xrEnumerateSwapchainImages");
|
||||
uint32_t n = 0;
|
||||
if (!enumerate || XR_FAILED(enumerate(cube_out->face[face], 0, &n, NULL)) || index >= n) return 0;
|
||||
int ok = 0;
|
||||
if (s->image) {
|
||||
XrSwapchainImageVulkanKHR *images = calloc(n, sizeof(*images));
|
||||
if (!images) return 0;
|
||||
for (uint32_t i = 0; i < n; ++i) images[i].type = XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR;
|
||||
if (XR_SUCCEEDED(enumerate(cube_out->face[face], n, &n, (void *)images))) {
|
||||
pthread_mutex_lock(&flip_lock);
|
||||
PFN_vkQueueWaitIdle idle = (PFN_vkQueueWaitIdle)vk.GetDeviceProcAddr(vk.device, "vkQueueWaitIdle");
|
||||
VkCommandBufferBeginInfo begin = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, NULL,
|
||||
VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, NULL};
|
||||
if (idle && vk.ResetCommandBuffer(vk.cmd, 0) == VK_SUCCESS && vk.BeginCommandBuffer(vk.cmd, &begin) == VK_SUCCESS) {
|
||||
VkImage dst = images[index].image;
|
||||
barrier(s->image, face, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
|
||||
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT);
|
||||
barrier(dst, slice, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, VK_ACCESS_TRANSFER_WRITE_BIT);
|
||||
int32_t size = (int32_t)s->info.width;
|
||||
// Vulkan quad v=0 is the top; GLES quad t=0 is the bottom. Reverse rows for the same pose.
|
||||
VkImageBlit blit = {{VK_IMAGE_ASPECT_COLOR_BIT, 0, (uint32_t)face, 1}, {{0, 0, 0}, {size, size, 1}},
|
||||
{VK_IMAGE_ASPECT_COLOR_BIT, 0, slice, 1}, {{0, size, 0}, {size, 0, 1}}};
|
||||
vk.CmdBlitImage(vk.cmd, s->image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, dst,
|
||||
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_NEAREST);
|
||||
barrier(s->image, face, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
|
||||
VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT);
|
||||
barrier(dst, slice, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
|
||||
VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_COLOR_ATTACHMENT_READ_BIT);
|
||||
if (vk.EndCommandBuffer(vk.cmd) == VK_SUCCESS) {
|
||||
VkSubmitInfo submit = {VK_STRUCTURE_TYPE_SUBMIT_INFO, NULL, 0, NULL, NULL, 1, &vk.cmd, 0, NULL};
|
||||
ok = vk.QueueSubmit(vk.queue, 1, &submit, VK_NULL_HANDLE) == VK_SUCCESS && idle(vk.queue) == VK_SUCCESS;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&flip_lock);
|
||||
}
|
||||
free(images);
|
||||
} else {
|
||||
if (!p_eglGetCurrentContext || p_eglGetCurrentContext() == EGL_NO_CONTEXT) return 0;
|
||||
emul_gles_image *images = calloc(n, sizeof(*images));
|
||||
if (!images) return 0;
|
||||
for (uint32_t i = 0; i < n; ++i) images[i].type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR;
|
||||
if (XR_SUCCEEDED(enumerate(cube_out->face[face], n, &n, (void *)images))) {
|
||||
for (int i = 0; i < 8 && p_glGetError() != GL_NO_ERROR; ++i) {}
|
||||
GLint read, draw, scissor = p_glIsEnabled(GL_SCISSOR_TEST);
|
||||
p_glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &read);
|
||||
p_glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &draw);
|
||||
GLuint fb[2];
|
||||
p_glGenFramebuffers(2, fb);
|
||||
p_glBindFramebuffer(GL_READ_FRAMEBUFFER, fb[0]);
|
||||
p_glFramebufferTexture2D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, s->tex, 0);
|
||||
p_glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fb[1]);
|
||||
if (cube_out->slices == 1)
|
||||
p_glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, images[index].image, 0);
|
||||
else p_glFramebufferTextureLayer(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, images[index].image, 0, slice);
|
||||
ok = p_glCheckFramebufferStatus(GL_READ_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE &&
|
||||
p_glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE;
|
||||
if (ok) {
|
||||
p_glDisable(GL_SCISSOR_TEST);
|
||||
p_glBlitFramebuffer(0, 0, s->info.width, s->info.height, 0, 0, s->info.width, s->info.height,
|
||||
GL_COLOR_BUFFER_BIT, GL_NEAREST);
|
||||
ok = p_glGetError() == GL_NO_ERROR;
|
||||
p_glFlush();
|
||||
}
|
||||
if (scissor) p_glEnable(GL_SCISSOR_TEST);
|
||||
p_glBindFramebuffer(GL_READ_FRAMEBUFFER, read);
|
||||
p_glBindFramebuffer(GL_DRAW_FRAMEBUFFER, draw);
|
||||
p_glDeleteFramebuffers(2, fb);
|
||||
}
|
||||
free(images);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
static void cube_outputs_begin(void) {
|
||||
for (int i = 0; i < STANDIN_MAX; ++i) cube_pools[i].ready = 0;
|
||||
}
|
||||
|
||||
static void cube_session_destroy(XrSession session) {
|
||||
cube_outputs_destroy();
|
||||
for (int i = 0; i < STANDIN_MAX; ++i)
|
||||
if (standins[i].session == session && standins[i].handle) standin_destroy(standins[i].handle);
|
||||
}
|
||||
|
||||
// xrEndSession may be followed by another xrBeginSession without destroying the session or app cube handles.
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL cube_end_session(XrSession session) {
|
||||
cube_outputs_destroy();
|
||||
PFN_xrEndSession end = (PFN_xrEndSession)lookup(active_instance, "xrEndSession");
|
||||
return end ? end(session) : XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
}
|
||||
|
||||
static int cube_layer_quads(XrSession session, const XrFrameEndInfo *info, uint32_t at, XrCompositionLayerQuad *out) {
|
||||
const XrCompositionLayerCubeKHR *c = (const void *)info->layers[at];
|
||||
int slot = standin_find(c->swapchain);
|
||||
if (slot < 0 || c->imageArrayIndex != 0) return 0;
|
||||
// Quads always cover projections on Frame, including alpha projections. Conservatively skip any mixed frame.
|
||||
for (uint32_t i = 0; i < info->layerCount; ++i)
|
||||
if (info->layers[i] && (info->layers[i]->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION)) {
|
||||
static int logged;
|
||||
if (!logged++) LOG("cube_quads: skipping cube with projection; preserving scene visibility");
|
||||
return 0;
|
||||
}
|
||||
standin_swapchain *s = &standins[slot];
|
||||
if (s->session != session || !cube_outputs_create(session, s)) return 0;
|
||||
PFN_xrAcquireSwapchainImage acquire = (PFN_xrAcquireSwapchainImage)lookup(active_instance, "xrAcquireSwapchainImage");
|
||||
PFN_xrWaitSwapchainImage wait = (PFN_xrWaitSwapchainImage)lookup(active_instance, "xrWaitSwapchainImage");
|
||||
PFN_xrReleaseSwapchainImage release = (PFN_xrReleaseSwapchainImage)lookup(active_instance, "xrReleaseSwapchainImage");
|
||||
if (!acquire || !wait || !release) return 0;
|
||||
if (!cube_out->ready) for (int f = 0; f < 6; ++f) {
|
||||
uint32_t index;
|
||||
XrSwapchainImageAcquireInfo ai = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO, NULL};
|
||||
XrSwapchainImageWaitInfo wi = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO, NULL, XR_INFINITE_DURATION};
|
||||
XrSwapchainImageReleaseInfo ri = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO, NULL};
|
||||
if (XR_FAILED(acquire(cube_out->face[f], &ai, &index))) goto fail;
|
||||
if (XR_FAILED(wait(cube_out->face[f], &wi))) goto fail;
|
||||
int ok = 1;
|
||||
for (int k = 0; k < STANDIN_MAX && ok; ++k) {
|
||||
if (!standins[k].handle || standins[k].session != session || standins[k].info.width != s->info.width ||
|
||||
standins[k].info.format != s->info.format || !!standins[k].image != !!s->image) continue;
|
||||
int submitted = 0;
|
||||
for (uint32_t l = 0; l < info->layerCount; ++l)
|
||||
if (info->layers[l] && info->layers[l]->type == XR_TYPE_COMPOSITION_LAYER_CUBE_KHR &&
|
||||
((const XrCompositionLayerCubeKHR *)info->layers[l])->swapchain == standins[k].handle) submitted = 1;
|
||||
if (submitted) ok = cube_copy_face(&standins[k], f, index, standins[k].slice);
|
||||
}
|
||||
XrResult r = release(cube_out->face[f], &ri);
|
||||
if (!ok || XR_FAILED(r)) goto fail;
|
||||
}
|
||||
cube_out->ready = 1;
|
||||
XrVector3f centre = {0, 0, 0};
|
||||
PFN_xrLocateViews locate = (PFN_xrLocateViews)lookup(active_instance, "xrLocateViews");
|
||||
XrViewLocateInfo li = {XR_TYPE_VIEW_LOCATE_INFO, NULL, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, info->displayTime, c->space};
|
||||
XrViewState state = {XR_TYPE_VIEW_STATE};
|
||||
XrView views[2] = {{XR_TYPE_VIEW}, {XR_TYPE_VIEW}};
|
||||
uint32_t nviews;
|
||||
if (locate && XR_SUCCEEDED(locate(session, &li, &state, 2, &nviews, views)) && nviews == 2 &&
|
||||
(state.viewStateFlags & XR_VIEW_STATE_POSITION_VALID_BIT))
|
||||
centre = (XrVector3f){(views[0].pose.position.x + views[1].pose.position.x) / 2,
|
||||
(views[0].pose.position.y + views[1].pose.position.y) / 2,
|
||||
(views[0].pose.position.z + views[1].pose.position.z) / 2};
|
||||
for (int f = 0; f < 6; ++f) {
|
||||
out[f] = (XrCompositionLayerQuad){XR_TYPE_COMPOSITION_LAYER_QUAD, NULL, c->layerFlags, c->space,
|
||||
c->eyeVisibility, {cube_out->face[f], {{0, 0}, {(int32_t)s->info.width, (int32_t)s->info.height}}, s->slice},
|
||||
lm_cube_quad_pose(c->orientation, f, 50), {100, 100}};
|
||||
out[f].pose.position.x += centre.x;
|
||||
out[f].pose.position.y += centre.y;
|
||||
out[f].pose.position.z += centre.z;
|
||||
}
|
||||
{
|
||||
static int logged;
|
||||
if (!logged++) LOG("cube_quads: submitting cube as six quads (half-size=50m, mip=0)");
|
||||
}
|
||||
return 1;
|
||||
fail:
|
||||
cube_output_disable();
|
||||
LOG("cube_quads: face copy/acquire/wait/release failed; dropping cube layers");
|
||||
return 0;
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
// * drops composition layers that reference swapchains that failed to create (layer_fix);
|
||||
// * answers xrGetDisplayRefreshRateFB with the measured display rate when the runtime lacks it or reports 0 Hz
|
||||
// (session_fixes.c);
|
||||
// * serves cube swapchains the runtime refuses as GL/Vulkan cube maps and drops their layers (cube_standin, cube_standin.c);
|
||||
// * serves refused cube swapchains as GL/Vulkan cube maps and emulates cube-only frames with quads (cube_standin, cube_standin.c);
|
||||
// * optionally requests mutable-format swapchain images (mutable_fix, off by default);
|
||||
// * optionally swaps left/right images of stereo projection layers (swap_eyes, off by default);
|
||||
// * emulates XR_FB_passthrough with XR_ENVIRONMENT_BLEND_MODE_ALPHA_BLEND when the runtime lacks it
|
||||
@@ -77,6 +77,7 @@ static void fb_log(const char *fmt, ...) {
|
||||
}
|
||||
#define LOG(...) fb_log(__VA_ARGS__)
|
||||
#include "audio_metadata.h"
|
||||
#include "batman_audio_geometry.h"
|
||||
#include "surface_views.h"
|
||||
|
||||
static void *loader;
|
||||
@@ -127,6 +128,7 @@ static int frame_open;
|
||||
static int sc_acquires, sc_waits, sc_releases, sc_wait_fails; // per pacing period (layer_debug) // drop XrCompositionLayerColorScaleBiasKHR (the runtime's color pass)
|
||||
static int controller_fix = 1;
|
||||
static int swapchain_fix = 1;
|
||||
static int cube_quads = 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)
|
||||
@@ -178,6 +180,7 @@ 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");
|
||||
@@ -186,6 +189,7 @@ static void read_settings(const char *path) {
|
||||
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;
|
||||
@@ -201,6 +205,7 @@ static void read_settings(const char *path) {
|
||||
if (sscanf(line, "gl_error_clear=%f", &value) == 1) gl_error_clear = value != 0;
|
||||
if (sscanf(line, "controller_fix=%f", &value) == 1) controller_fix = value != 0;
|
||||
if (sscanf(line, "swapchain_fix=%f", &value) == 1) swapchain_fix = value != 0;
|
||||
if (sscanf(line, "cube_quads=%f", &value) == 1) cube_quads = 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;
|
||||
@@ -361,7 +366,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -397,6 +402,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;
|
||||
@@ -469,7 +475,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
}
|
||||
no_equirect = !has_equirect; no_equirect2 = !has_equirect2; no_cylinder = !has_cylinder; no_cube = !has_cube;
|
||||
free(available);
|
||||
mx_audio_initialize();
|
||||
mx_audio_initialize(); mx_batman_geometry_initialize();
|
||||
XrResult result = fn(&fixed, instance);
|
||||
free(names);
|
||||
LOG("xrCreateInstance result=%d", result);
|
||||
@@ -481,7 +487,14 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
LOG("color_space: emulating XR_FB_color_space; REC709 SDR compatibility, no colour conversion");
|
||||
}
|
||||
}
|
||||
if (XR_SUCCEEDED(result)) { active_instance = *instance; mx_audio_initialize(); }
|
||||
if (XR_SUCCEEDED(result)) {
|
||||
pthread_mutex_lock(&bindings_lock);
|
||||
active_instance = *instance;
|
||||
reset_bindings(frame_enabled);
|
||||
pthread_mutex_unlock(&bindings_lock);
|
||||
mx_audio_initialize();
|
||||
mx_batman_geometry_initialize();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -624,9 +637,11 @@ static int emul_is_virtual(XrSwapchain handle);
|
||||
static void emul_virtual_destroy(XrSwapchain handle);
|
||||
static XrResult emul_virtual_enumerate(XrSwapchain handle, uint32_t capacity, uint32_t *count, XrSwapchainImageBaseHeader *images);
|
||||
static void emul_on_acquire(XrSwapchain handle, uint32_t index);
|
||||
static XrResult standin_after_failure(const XrSwapchainCreateInfo *info, XrSwapchain *out, XrResult result);
|
||||
static XrResult standin_after_failure(XrSession session, const XrSwapchainCreateInfo *info, XrSwapchain *out, XrResult result);
|
||||
static int is_standin(XrSwapchain handle);
|
||||
static void standin_destroy(XrSwapchain handle);
|
||||
static void cube_outputs_destroy(void);
|
||||
static void cube_session_destroy(XrSession session);
|
||||
static XrResult standin_enumerate(XrSwapchain handle, uint32_t capacity, uint32_t *count, XrSwapchainImageBaseHeader *images);
|
||||
|
||||
static int known_swapchain(XrSwapchain handle) {
|
||||
@@ -730,7 +745,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
|
||||
snap_on_create(*swapchain, &fixed);
|
||||
return result;
|
||||
}
|
||||
if (!swapchain_fix) return standin_after_failure(&fixed, swapchain, result);
|
||||
if (!swapchain_fix) return standin_after_failure(session, &fixed, swapchain, result);
|
||||
|
||||
// Frame's runtime rejects some GLES formats (GL_RGBA8) and MSAA swapchains (overport #71).
|
||||
if (fixed.sampleCount > 1) {
|
||||
@@ -753,7 +768,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
|
||||
emul_on_create_swapchain(*swapchain, &fixed);
|
||||
snap_on_create(*swapchain, &fixed);
|
||||
}
|
||||
return standin_after_failure(&fixed, swapchain, result);
|
||||
return standin_after_failure(session, &fixed, swapchain, result);
|
||||
}
|
||||
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrDestroySwapchain(XrSwapchain swapchain) {
|
||||
@@ -1394,7 +1409,7 @@ static XrSession focused_session = XR_NULL_HANDLE;
|
||||
static int kicks_pending = -1; // -1: not yet armed
|
||||
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrPollEvent(XrInstance instance, XrEventDataBuffer *event) {
|
||||
mx_audio_initialize();
|
||||
mx_audio_initialize(); mx_batman_geometry_initialize();
|
||||
PFN_xrPollEvent fn = (PFN_xrPollEvent)lookup(instance, "xrPollEvent");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
if (emulate_scene && event) {
|
||||
@@ -1688,7 +1703,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
|
||||
frames = 0; drift_sum = drift_max = 0;
|
||||
}
|
||||
}
|
||||
if ((!layer_fix && !swap_eyes && !emulate_passthrough && !flip_quads && !flip_emul && !strip_depth && !fade_quad) || !info || !info->layerCount ||
|
||||
if ((!cube_standin && !layer_fix && !swap_eyes && !emulate_passthrough && !flip_quads && !flip_emul && !strip_depth && !fade_quad) || !info || !info->layerCount ||
|
||||
info->layerCount > 64)
|
||||
return fn(session, info);
|
||||
if (layer_debug) { // the submitted layer list (order, flags, eyes, chained structs) whenever its shape changes
|
||||
@@ -1735,6 +1750,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
|
||||
XrCompositionLayerQuad quads[64];
|
||||
XrCompositionLayerQuad fade_out; // fade_quad: at most one fade layer per frame
|
||||
int faded = 0;
|
||||
cube_outputs_begin();
|
||||
uint32_t count = 0, dropped = 0, swapped = 0, passthrough = 0;
|
||||
const XrCompositionLayerBaseHeader *surface_projections[SURF_MAX];
|
||||
int surface_projection_count = 0;
|
||||
@@ -1821,6 +1837,14 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
|
||||
++dropped; // an emulated video surface without a frame yet
|
||||
continue;
|
||||
}
|
||||
if (cube_quads && layer->type == XR_TYPE_COMPOSITION_LAYER_CUBE_KHR &&
|
||||
count + 6 + info->layerCount - i - 1 <= 16 && count + 6 <= 64 &&
|
||||
cube_layer_quads(session, info, i, &quads[count])) {
|
||||
for (int f = 0; f < 6; ++f) kept[count + f] = (const XrCompositionLayerBaseHeader *)&quads[count + f];
|
||||
count += 6;
|
||||
++swapped;
|
||||
continue;
|
||||
}
|
||||
if (layer_uses_standin(layer)) { // a cube layer whose swapchain the runtime refused (cube_standin.c)
|
||||
static int logged;
|
||||
if (logged++ < 3) LOG("cube_standin: dropped a layer (type=%d) that shows a stand-in swapchain", layer->type);
|
||||
@@ -2105,6 +2129,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
|
||||
@@ -2119,7 +2144,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;
|
||||
}
|
||||
|
||||
@@ -2295,6 +2335,10 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
|
||||
HOOK_AS(xrReleaseSwapchainImage, surf_composite_release)
|
||||
HOOK_AS(xrWaitSwapchainImage, surf_composite_wait)
|
||||
}
|
||||
if (cube_standin) {
|
||||
HOOK_AS(xrEndSession, cube_end_session)
|
||||
HOOK_AS(xrDestroySession, hook_xrDestroySession)
|
||||
}
|
||||
if (equirect_emul) {
|
||||
HOOK_AS(xrReleaseSwapchainImage, hook_xrReleaseSwapchainImage)
|
||||
HOOK_AS(xrWaitSwapchainImage, hook_xrWaitSwapchainImage)
|
||||
@@ -2327,7 +2371,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()) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -42,7 +42,7 @@ typedef struct { XrStructureType type; void *next; uint32_t image; } emul_gles_i
|
||||
X(eglChooseConfig) X(eglCreatePbufferSurface) X(eglDestroySurface) X(eglGetCurrentContext) X(eglGetError) \
|
||||
X(eglQueryString)
|
||||
#define EMUL_GL_FUNCS(X) X(glFenceSync) X(glDeleteSync) X(glWaitSync) X(glClientWaitSync) X(glFlush) \
|
||||
X(glGenFramebuffers) X(glDeleteFramebuffers) X(glBindFramebuffer) X(glFramebufferTexture2D) X(glCheckFramebufferStatus) X(glViewport) \
|
||||
X(glFramebufferTextureLayer) X(glIsEnabled) X(glBlitFramebuffer) X(glGenFramebuffers) X(glDeleteFramebuffers) X(glBindFramebuffer) X(glFramebufferTexture2D) X(glCheckFramebufferStatus) X(glViewport) \
|
||||
X(glUseProgram) X(glCreateShader) X(glShaderSource) X(glCompileShader) X(glGetShaderiv) X(glGetShaderInfoLog) \
|
||||
X(glDeleteShader) X(glCreateProgram) X(glAttachShader) X(glLinkProgram) X(glGetProgramiv) X(glGetProgramInfoLog) \
|
||||
X(glGetUniformLocation) X(glUniform1i) X(glUniform1f) X(glUniform3f) X(glUniform4f) X(glUniformMatrix3fv) \
|
||||
@@ -1003,6 +1003,7 @@ static void emul_on_destroy_session(XrSession session) {
|
||||
}
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrDestroySession(XrSession session) {
|
||||
cube_session_destroy(session);
|
||||
emul_on_destroy_session(session);
|
||||
PFN_xrDestroySession fn = (PFN_xrDestroySession)lookup(active_instance, "xrDestroySession");
|
||||
return fn ? fn(session) : XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
|
||||
@@ -88,6 +88,16 @@ static inline XrVector3f lm_glcube_dir(int face, float u, float v) {
|
||||
}
|
||||
}
|
||||
|
||||
// Cube image array order: +X,-X,+Y,-Y,+Z,-Z. Local +Z faces inward.
|
||||
// Quad right/up match GL cube s/t; Vulkan copies reverse rows to compensate its top-left image origin.
|
||||
static inline XrPosef lm_cube_quad_pose(XrQuaternionf orientation, int face, float distance) {
|
||||
static const int faces[6] = {1, 3, 4, 5, 2, 0};
|
||||
XrPosef p = lm_face_pose((XrPosef){orientation, {0, 0, 0}}, faces[face], distance);
|
||||
if (face != 2 && face != 3)
|
||||
p.orientation = lm_qmul(p.orientation, (XrQuaternionf){0, 0, 1, 0});
|
||||
return p;
|
||||
}
|
||||
|
||||
// GL's cube map lookup (OpenGL ES 3.0 spec, table 3.21): direction -> face and (s, t).
|
||||
static inline int lm_glcube_lookup(XrVector3f d, float *s, float *t) {
|
||||
float ax = fabsf(d.x), ay = fabsf(d.y), az = fabsf(d.z), sc, tc, ma;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -106,6 +106,8 @@ static int validation; // vk_validation: add Khronos' validation layer (bu
|
||||
static int hide_fdm; // vk_hide_fdm: the game doesn't see VK_EXT_fragment_density_map(2)
|
||||
static int want_dump; // vk_shader_dump: write the game's SPIR-V modules to files/fp_vk_shaders/
|
||||
static int want_pipeline_log; // vk_pipeline_log: log failing pipeline creations (pipeline_log.h)
|
||||
static int no_fossilize; // vk_no_fossilize: take Lepton's Fossilize layer out of GraphicsEnv's list
|
||||
static int pipeline_fallback; // vk_pipeline_fallback: retry a failed graphics pipeline with blank shaders
|
||||
static char app_files[400]; // /sdcard/Android/data/<package>/files
|
||||
|
||||
static void read_settings(const char *path) {
|
||||
@@ -119,6 +121,8 @@ static void read_settings(const char *path) {
|
||||
if (!strncmp(line, "vk_spec_fixes=", 14)) spec_fixes = atoi(line + 14) != 0;
|
||||
if (!strncmp(line, "vk_shader_dump=", 15)) want_dump = atoi(line + 15) != 0;
|
||||
if (!strncmp(line, "vk_pipeline_log=", 16)) want_pipeline_log = atoi(line + 16) != 0;
|
||||
if (!strncmp(line, "vk_no_fossilize=", 16)) no_fossilize = atoi(line + 16) != 0;
|
||||
if (!strncmp(line, "vk_pipeline_fallback=", 21)) pipeline_fallback = atoi(line + 21) != 0;
|
||||
if (!strncmp(line, "vk_query_slots=", 15)) {
|
||||
int v = atoi(line + 15);
|
||||
query_slots = v == 1 ? 2 : v >= 2 && v <= 4 ? v : 1; // 1 = on (the settings dialog's switch) = 2 slots
|
||||
@@ -183,6 +187,52 @@ static uint32_t *fixed_shader(const uint32_t *code, size_t size, size_t *out_siz
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Android's loader reads GraphicsEnv's debug layer list (Lepton's gpu_debug_layers) at each vkCreateInstance; this
|
||||
// takes one layer out of it (same libgraphicsenv calls as native/zinkfix, which adds its own layer)
|
||||
struct cxx_string { size_t cap; size_t size; char *data; };
|
||||
#define GE_INSTANCE "_ZN7android11GraphicsEnv11getInstanceEv"
|
||||
#define GE_GET_LAYERS "_ZN7android11GraphicsEnv14getDebugLayersEv"
|
||||
#define GE_SET_LAYERS "_ZN7android11GraphicsEnv14setDebugLayersENSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE"
|
||||
|
||||
static const char *cxx_chars(const void *s, size_t *len) {
|
||||
const unsigned char *b = s;
|
||||
if (!b) { *len = 0; return NULL; }
|
||||
if (b[0] & 1) {
|
||||
const struct cxx_string *l = s;
|
||||
*len = l->size;
|
||||
return l->data;
|
||||
}
|
||||
*len = b[0] >> 1;
|
||||
return (const char *)b + 1;
|
||||
}
|
||||
|
||||
static void drop_debug_layer(void *vk, const char *name) {
|
||||
void *(*get_instance)(void) = (void *(*)(void))dlsym(vk, GE_INSTANCE);
|
||||
const void *(*get_layers)(void *) = (const void *(*)(void *))dlsym(vk, GE_GET_LAYERS);
|
||||
void (*set_layers)(void *, struct cxx_string *) = (void (*)(void *, struct cxx_string *))dlsym(vk, GE_SET_LAYERS);
|
||||
void *env = get_instance && get_layers && set_layers ? get_instance() : NULL;
|
||||
size_t len = 0;
|
||||
const char *cur = env ? cxx_chars(get_layers(env), &len) : NULL;
|
||||
if (!env || len > 4096 || (len && !cur)) {
|
||||
LOG("vk shim: can't change the debug layer list (GraphicsEnv not found)");
|
||||
return;
|
||||
}
|
||||
char old[4097], *out = malloc(len + 16);
|
||||
if (!out) return;
|
||||
memcpy(old, cur ? cur : "", len);
|
||||
old[len] = 0;
|
||||
size_t n = 0;
|
||||
for (char *save = NULL, *tok = strtok_r(old, ":", &save); tok; tok = strtok_r(NULL, ":", &save)) {
|
||||
if (!strcmp(tok, name)) continue;
|
||||
n += (size_t)sprintf(out + n, "%s%s", n ? ":" : "", tok);
|
||||
}
|
||||
struct cxx_string tmp = {((len + 16) & ~(size_t)15) | 1, n, out};
|
||||
set_layers(env, &tmp);
|
||||
if ((tmp.cap & 1) && tmp.data == out) free(out);
|
||||
const char *now = cxx_chars(get_layers(env), &len);
|
||||
LOG("vk shim: debug layers now \"%.*s\" (without %s)", (int)len, now ? now : "", name);
|
||||
}
|
||||
|
||||
static void init(void) {
|
||||
read_all_settings();
|
||||
if (nfixes) LOG("vk shim: %d shader fix(es) configured", nfixes);
|
||||
@@ -194,6 +244,7 @@ static void init(void) {
|
||||
pl_on = 1;
|
||||
LOG("vk shim: logging failed pipeline creations and sampler Y'CbCr conversions");
|
||||
}
|
||||
if (pipeline_fallback) LOG("vk shim: failed graphics pipelines get blank shaders");
|
||||
if (spec_fixes) LOG("vk shim: Vulkan spec fixes on (depth images can be cleared, no depth resolve in shader-resolve "
|
||||
"subpasses)");
|
||||
real_vk = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL);
|
||||
@@ -202,10 +253,18 @@ static void init(void) {
|
||||
real_gdpa = (PFN_vkGetDeviceProcAddr)dlsym(real_vk, "vkGetDeviceProcAddr");
|
||||
real_rp2_trampoline = (PFN_vkCreateRenderPass2)dlsym(real_vk, "vkCreateRenderPass2");
|
||||
real_csm_trampoline = (PFN_vkCreateShaderModule)dlsym(real_vk, "vkCreateShaderModule");
|
||||
if (no_fossilize) drop_debug_layer(real_vk, "VK_LAYER_fossilize");
|
||||
}
|
||||
LOG("vk shim: libvulkan.so %s", real_gipa && real_gdpa ? "OK" : "MISSING");
|
||||
}
|
||||
|
||||
// The engine's first vkCreateInstance can come from another thread before any wrapped call runs init(), so the layer
|
||||
// list is changed when the engine loads this library
|
||||
__attribute__((constructor)) static void early_init(void) {
|
||||
read_all_settings();
|
||||
if (no_fossilize) pthread_once(&once, init);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- pointer checks
|
||||
|
||||
static pthread_mutex_t probe_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
@@ -868,13 +927,94 @@ static VKAPI_ATTR void VKAPI_CALL cmd_pipeline_barrier2_khr(VkCommandBuffer cb,
|
||||
|
||||
// ---------------------------------------------------------------- pipeline log (vk_pipeline_log, diagnostics)
|
||||
|
||||
// GitHub #349: Turnip fails some of Transformers' pipelines (-1 = its "ir3 variant failed"), and Unreal treats a
|
||||
// failed pipeline as fatal. A failed pipeline is retried with a fragment shader that writes zero to location 0, then
|
||||
// with a vertex shader that only writes gl_Position as well: the draw then shows nothing instead of ending the game.
|
||||
static const uint32_t FALLBACK_FS[] = {
|
||||
0x07230203, 0x00010000, 0x00070000, 0x0000000b, 0x00000000, 0x00020011, 0x00000001, 0x0003000e,
|
||||
0x00000000, 0x00000001, 0x0006000f, 0x00000004, 0x00000001, 0x6e69616d, 0x00000000, 0x00000002,
|
||||
0x00030010, 0x00000001, 0x00000007, 0x00040047, 0x00000002, 0x0000001e, 0x00000000, 0x00030016,
|
||||
0x00000003, 0x00000020, 0x00040017, 0x00000004, 0x00000003, 0x00000004, 0x0004002b, 0x00000003,
|
||||
0x00000005, 0x00000000, 0x0007002c, 0x00000004, 0x00000006, 0x00000005, 0x00000005, 0x00000005,
|
||||
0x00000005, 0x00040020, 0x00000007, 0x00000003, 0x00000004, 0x00020013, 0x00000008, 0x00030021,
|
||||
0x00000009, 0x00000008, 0x0004003b, 0x00000007, 0x00000002, 0x00000003, 0x00050036, 0x00000008,
|
||||
0x00000001, 0x00000000, 0x00000009, 0x000200f8, 0x0000000a, 0x0003003e, 0x00000002, 0x00000006,
|
||||
0x000100fd, 0x00010038,
|
||||
};
|
||||
static const uint32_t FALLBACK_VS[] = {
|
||||
0x07230203, 0x00010000, 0x00070000, 0x0000000c, 0x00000000, 0x00020011, 0x00000001, 0x0003000e,
|
||||
0x00000000, 0x00000001, 0x0006000f, 0x00000000, 0x00000001, 0x6e69616d, 0x00000000, 0x00000002,
|
||||
0x00040047, 0x00000002, 0x0000000b, 0x00000000, 0x00030016, 0x00000003, 0x00000020, 0x00040017,
|
||||
0x00000004, 0x00000003, 0x00000004, 0x0004002b, 0x00000003, 0x00000005, 0x00000000, 0x0004002b,
|
||||
0x00000003, 0x00000006, 0x3f800000, 0x0007002c, 0x00000004, 0x00000007, 0x00000005, 0x00000005,
|
||||
0x00000005, 0x00000006, 0x00040020, 0x00000008, 0x00000003, 0x00000004, 0x00020013, 0x00000009,
|
||||
0x00030021, 0x0000000a, 0x00000009, 0x0004003b, 0x00000008, 0x00000002, 0x00000003, 0x00050036,
|
||||
0x00000009, 0x00000001, 0x00000000, 0x0000000a, 0x000200f8, 0x0000000b, 0x0003003e, 0x00000002,
|
||||
0x00000007, 0x000100fd, 0x00010038,
|
||||
};
|
||||
|
||||
static pthread_mutex_t fallback_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static VkDevice fallback_device;
|
||||
static VkShaderModule fallback_module[2]; // [0] fragment, [1] vertex; kept for the device's lifetime
|
||||
|
||||
static VkShaderModule fallback_shader(VkDevice device, int vertex) {
|
||||
pthread_mutex_lock(&fallback_lock);
|
||||
if (fallback_device != device) {
|
||||
fallback_device = device; // one device per game in practice; a new one gets new modules
|
||||
fallback_module[0] = fallback_module[1] = VK_NULL_HANDLE;
|
||||
}
|
||||
if (!fallback_module[vertex]) {
|
||||
PFN_vkCreateShaderModule csm = (PFN_vkCreateShaderModule)any_fn(device, FN_CSM);
|
||||
VkShaderModuleCreateInfo mi = {VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, NULL, 0,
|
||||
vertex ? sizeof FALLBACK_VS : sizeof FALLBACK_FS,
|
||||
vertex ? FALLBACK_VS : FALLBACK_FS};
|
||||
if (csm && csm(device, &mi, NULL, &fallback_module[vertex]) != VK_SUCCESS) fallback_module[vertex] = VK_NULL_HANDLE;
|
||||
}
|
||||
VkShaderModule m = fallback_module[vertex];
|
||||
pthread_mutex_unlock(&fallback_lock);
|
||||
return m;
|
||||
}
|
||||
|
||||
static VkResult fallback_pipeline(VkDevice device, VkPipelineCache cache, const VkGraphicsPipelineCreateInfo *ci,
|
||||
const VkAllocationCallbacks *alloc, VkPipeline *out,
|
||||
PFN_vkCreateGraphicsPipelines real, VkResult failed) {
|
||||
static int logged;
|
||||
VkPipelineShaderStageCreateInfo st[6];
|
||||
memcpy(st, ci->pStages, sizeof *st * ci->stageCount);
|
||||
VkGraphicsPipelineCreateInfo p = *ci;
|
||||
p.pStages = st;
|
||||
VkResult r = failed;
|
||||
for (int pass = 0; pass < 2 && r < 0; pass++) { // 0: fragment shader only, 1: vertex shader too
|
||||
int replaced = 0;
|
||||
for (uint32_t s = 0; s < p.stageCount; s++) {
|
||||
int vertex = st[s].stage == VK_SHADER_STAGE_VERTEX_BIT;
|
||||
if (st[s].stage != VK_SHADER_STAGE_FRAGMENT_BIT && !(pass && vertex)) continue;
|
||||
VkShaderModule m = fallback_shader(device, vertex);
|
||||
if (!m) continue;
|
||||
st[s].pNext = NULL;
|
||||
st[s].flags = 0;
|
||||
st[s].module = m;
|
||||
st[s].pName = "main";
|
||||
st[s].pSpecializationInfo = NULL;
|
||||
replaced++;
|
||||
}
|
||||
if (!replaced) continue;
|
||||
r = real(device, cache, 1, &p, alloc, out);
|
||||
if (logged++ < 20)
|
||||
LOG("vk shim: failed pipeline (%d) retried with a blank %s shader: %d", failed,
|
||||
pass ? "vertex + fragment" : "fragment", r);
|
||||
}
|
||||
return r < 0 ? failed : r;
|
||||
}
|
||||
|
||||
static VKAPI_ATTR VkResult VKAPI_CALL create_graphics_pipelines(VkDevice device, VkPipelineCache cache, uint32_t count,
|
||||
const VkGraphicsPipelineCreateInfo *ci,
|
||||
const VkAllocationCallbacks *alloc, VkPipeline *out) {
|
||||
PFN_vkCreateGraphicsPipelines real = (PFN_vkCreateGraphicsPipelines)any_fn(device, FN_CGP);
|
||||
if (!real) return VK_ERROR_INITIALIZATION_FAILED;
|
||||
VkResult r = real(device, cache, count, ci, alloc, out);
|
||||
pl_graphics(count, ci, out, r);
|
||||
if (pl_on) pl_graphics(count, ci, out, r);
|
||||
if (pipeline_fallback && r < 0 && count == 1 && ci && ci->pStages && ci->stageCount <= 6) r = fallback_pipeline(device, cache, ci, alloc, out, real, r);
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -950,7 +1090,7 @@ static PFN_vkVoidFunction wrap(const char *name) {
|
||||
case FN_EDEP: return hide_fdm ? (PFN_vkVoidFunction)vkEnumerateDeviceExtensionProperties : NULL;
|
||||
case FN_CDEV: return hide_fdm || spec_fixes ? (PFN_vkVoidFunction)vkCreateDevice : NULL;
|
||||
case FN_CIV: return spec_fixes ? (PFN_vkVoidFunction)create_image_view : NULL;
|
||||
case FN_CGP: return pl_on ? (PFN_vkVoidFunction)create_graphics_pipelines : NULL;
|
||||
case FN_CGP: return pl_on || pipeline_fallback ? (PFN_vkVoidFunction)create_graphics_pipelines : NULL;
|
||||
case FN_CCP: return pl_on ? (PFN_vkVoidFunction)create_compute_pipelines : NULL;
|
||||
case FN_CSAMP: return pl_on ? (PFN_vkVoidFunction)create_sampler : NULL;
|
||||
case FN_CDSL: return pl_on ? (PFN_vkVoidFunction)create_set_layout : NULL;
|
||||
|
||||
@@ -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())
|
||||
@@ -56,7 +56,7 @@ def from_data() -> dict:
|
||||
for t in (p.title, p.description, p.summary):
|
||||
if t:
|
||||
texts[t] = ""
|
||||
for attr in ("CHOICES", "RADIUS_CHOICES"):
|
||||
for attr in ("CHOICES", "LEVEL_CHOICES"):
|
||||
for _, label in getattr(p, attr, ()):
|
||||
texts[label] = ""
|
||||
for _, title in GROUPS: # the Game settings dialog
|
||||
|
||||
@@ -92,7 +92,7 @@ def _entry(spec: dict, tech: dict, now: float) -> dict:
|
||||
rdict = library.recipe_to_dict(recipe)
|
||||
fields = dict(title=title, name=title, data_bytes=tech.get("data_bytes") or 0, analysis=a.to_dict(),
|
||||
recipe=rdict, suggested=rdict, status=recipe.status, added=now - DAY * spec.get("added", 30),
|
||||
build={"sha256": fake_sha(pkg), "ok": True, "recipe_fp": recipe_fingerprint(rdict),
|
||||
build={"sha256": fake_sha(pkg), "ok": True, "recipe_fp": recipe_fingerprint(rdict, pkg),
|
||||
"applied": sorted(p for p in rdict.get("patches") or {} if "." in p)})
|
||||
if rift:
|
||||
folder = f"D:/Games/PC VR/{title}"
|
||||
|
||||
@@ -634,6 +634,16 @@ class FrozenMonitorSession(FakeMonitorSession):
|
||||
"""The fake stream for still pictures: the two minutes of history, then nothing new (every render of a page shows
|
||||
the same numbers, whenever the screenshot is taken)."""
|
||||
|
||||
# the newest session (the Monitor tab's faster interval starts a new one after the Frame card's) and whether it
|
||||
# has replayed its history; the monitor_settled hook waits for that, else a slow machine shot the page halfway
|
||||
# through the replay (set_modules("all") asks for the replay again)
|
||||
LATEST: FrozenMonitorSession | None = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.settled = threading.Event()
|
||||
FrozenMonitorSession.LATEST = self
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _run(self) -> None:
|
||||
self.t = 0
|
||||
while not self.closed:
|
||||
@@ -641,8 +651,14 @@ class FrozenMonitorSession(FakeMonitorSession):
|
||||
self.on_sample(self._sample())
|
||||
self.t += 1
|
||||
else:
|
||||
self.settled.set()
|
||||
time.sleep(0.2)
|
||||
|
||||
def set_modules(self, m):
|
||||
if m == "all":
|
||||
self.settled.clear()
|
||||
return super().set_modules(m)
|
||||
|
||||
|
||||
class LiveMonitorSession(FakeMonitorSession):
|
||||
"""The fake stream, livelier for videos: a sample every 0.6 s and a steady frame rate with a short, shallow dip
|
||||
|
||||
@@ -240,6 +240,20 @@ def _monitor_details(session) -> None:
|
||||
fakes.open_monitor_details(session.app)
|
||||
|
||||
|
||||
def _monitor_settled(session, timeout: float = 30) -> None:
|
||||
"""Still pictures of the Monitor: wait until the frozen stream has replayed its history (then one more second
|
||||
for the page to draw the last sample)."""
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end: # the newest session, settled, and still the newest a moment later
|
||||
session_ = fakes.FrozenMonitorSession.LATEST
|
||||
if session_ is None:
|
||||
time.sleep(0.2)
|
||||
elif session_.settled.wait(max(0.0, end - time.monotonic())):
|
||||
time.sleep(1)
|
||||
if fakes.FrozenMonitorSession.LATEST is session_ and session_.settled.is_set():
|
||||
return
|
||||
|
||||
|
||||
def _type_tab(session) -> None:
|
||||
fakes.open_type_tab(session.app)
|
||||
|
||||
@@ -399,4 +413,5 @@ HOOKS = {"fake_install": _fake_install, "wait_jobs": _wait_jobs, "held_install":
|
||||
"settings_section": _settings_section, "first_run": _first_run, "pairing_done": _pairing_done,
|
||||
"frame_asks": _frame_asks,
|
||||
"easter_eggs": _easter_eggs, "fps_dip": _fps_dip, "perfect_pacing": _perfect_pacing,
|
||||
"battery": _battery, "long_upload": _long_upload, "press_keys": _press_keys}
|
||||
"battery": _battery, "long_upload": _long_upload, "press_keys": _press_keys,
|
||||
"monitor_settled": _monitor_settled}
|
||||
@@ -142,7 +142,8 @@ def main() -> int:
|
||||
# the index on the left: "This PC" scrolls the page there
|
||||
("settings-index", lambda a: a.page.run_task(a.settings_view.show_section, "pc")),
|
||||
("settings-folders", lambda a: a.page.run_task(a.settings_view.show_section, "folders")),
|
||||
("settings-installing", lambda a: a.page.run_task(a.settings_view.show_section, "installing"))]
|
||||
("settings-installing", lambda a: a.page.run_task(a.settings_view.show_section, "installing")),
|
||||
("settings-data", lambda a: a.page.run_task(a.settings_view.show_section, "data"))]
|
||||
if game:
|
||||
steps.insert(1, ("game", lambda a: a.open_game(game)))
|
||||
steps.insert(2, ("game-customize", lambda a: a.open_game(game, advanced=True)))
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
"title": "Show Steam Frame controllers",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.cube_quads": {
|
||||
"anchor": "adapter-cube_quads",
|
||||
"title": "Show cube loading screens",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.cube_standin": {
|
||||
"anchor": "adapter-cube_standin",
|
||||
"title": "Stand in for cube layers",
|
||||
@@ -229,6 +234,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",
|
||||
@@ -324,6 +334,16 @@
|
||||
"title": "Hide Vulkan foveation",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.vk_no_fossilize": {
|
||||
"anchor": "adapter-vk_no_fossilize",
|
||||
"title": "Without Steam's shader cache layer",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.vk_pipeline_fallback": {
|
||||
"anchor": "adapter-vk_pipeline_fallback",
|
||||
"title": "Skip refused graphics",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.vk_pipeline_log": {
|
||||
"anchor": "adapter-vk_pipeline_log",
|
||||
"title": "Vulkan pipeline failures (log)",
|
||||
|
||||
@@ -91,7 +91,7 @@ import { DOWNLOADS, downloadPage, url } from '../../data/site';
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { parseInstallParams, readManifest } from '../../scripts/install-link';
|
||||
import { githubRelease, parseInstallParams, readManifest } from '../../scripts/install-link';
|
||||
|
||||
const page = document.querySelector<HTMLElement>('main.page')!;
|
||||
const $ = <T extends HTMLElement>(sel: string) => page.querySelector<T>(sel)!;
|
||||
@@ -114,7 +114,31 @@ import { DOWNLOADS, downloadPage, url } from '../../data/site';
|
||||
$('[data-name]').textContent = 'Cool Game';
|
||||
}
|
||||
|
||||
// the manifest's own name and description, when its host allows reading it from a page (CORS)
|
||||
// a GitHub release download can't be read from a page (no CORS): name the release from GitHub's API instead
|
||||
const releaseInfo = () => {
|
||||
const rel = githubRelease(parsed.target);
|
||||
if (!rel) return;
|
||||
const api = `https://api.github.com/repos/${encodeURIComponent(rel.owner)}/${encodeURIComponent(rel.repo)}`;
|
||||
const opts: RequestInit = { credentials: 'omit', referrerPolicy: 'no-referrer' };
|
||||
const release = rel.tag ? `${api}/releases/tags/${encodeURIComponent(rel.tag)}` : `${api}/releases/latest`;
|
||||
const json = (url: string) => fetch(url, opts).then((r) => (r.ok ? r.json() : null));
|
||||
Promise.all([json(release), json(api)])
|
||||
.then(([r, repo]) => {
|
||||
const name = (typeof r?.name === 'string' && r.name.trim()) || (typeof repo?.name === 'string' && repo.name) || '';
|
||||
if (!name) return;
|
||||
$('[data-name]').textContent = name.slice(0, 200);
|
||||
document.title = `Install ${name.slice(0, 200)} with FramePort`;
|
||||
$('[data-files]').textContent = `A release of ${rel.owner}/${rel.repo}`;
|
||||
if (typeof repo?.description === 'string' && repo.description.trim()) {
|
||||
$('[data-desc]').textContent = repo.description.trim().slice(0, 2000);
|
||||
$('[data-desc]').hidden = false;
|
||||
}
|
||||
})
|
||||
.catch(() => { /* the address alone is shown */ });
|
||||
};
|
||||
|
||||
// the manifest's own name and description, when its host allows reading it from a page (CORS); GitHub file
|
||||
// pages already became their raw.githubusercontent.com address in parseInstallParams
|
||||
if (parsed.kind === 'manifest') {
|
||||
const ctl = new AbortController();
|
||||
setTimeout(() => ctl.abort(), 6000);
|
||||
@@ -122,7 +146,7 @@ import { DOWNLOADS, downloadPage, url } from '../../data/site';
|
||||
.then((r) => (r.ok && Number(r.headers.get('content-length') ?? 0) <= 256 * 1024 ? r.json() : null))
|
||||
.then((data) => {
|
||||
const m = readManifest(data);
|
||||
if (!m) return;
|
||||
if (!m) { releaseInfo(); return; }
|
||||
$('[data-name]').textContent = m.name;
|
||||
document.title = `Install ${m.name} with FramePort`;
|
||||
$('[data-files]').textContent = m.files.join(', ');
|
||||
@@ -135,7 +159,7 @@ import { DOWNLOADS, downloadPage, url } from '../../data/site';
|
||||
img.src = m.icon;
|
||||
}
|
||||
})
|
||||
.catch(() => { /* the address alone is shown */ });
|
||||
.catch(() => releaseInfo());
|
||||
}
|
||||
|
||||
// hand the link to FramePort; if this page is still in front afterwards, FramePort probably isn't there
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { DEMO_MANIFEST, checkUrl, isDemo, parseInstallParams, readManifest, toFrameportLink } from './install-link.ts';
|
||||
import {
|
||||
DEMO_MANIFEST, checkUrl, githubRelease, isDemo, parseInstallParams, rawGithubUrl, readManifest, toFrameportLink,
|
||||
} from './install-link.ts';
|
||||
|
||||
const MANIFEST = 'https://cdn.example.com/game.framedrop.json';
|
||||
|
||||
@@ -76,3 +78,28 @@ test('the published demo manifest previews as Cool Game', () => {
|
||||
assert.equal(m?.icon, 'https://frameport.app/demo/cool-game.svg');
|
||||
assert.deepEqual(m?.files, ['cool-game-arm64.apk']);
|
||||
});
|
||||
|
||||
test('GitHub file pages become raw.githubusercontent.com addresses (also in the frameport:// link)', () => {
|
||||
const raw = 'https://raw.githubusercontent.com/o/r/main/dir/game.json';
|
||||
for (const page of ['https://github.com/o/r/blob/main/dir/game.json', 'https://github.com/o/r/raw/main/dir/game.json',
|
||||
'https://www.github.com/o/r/blob/main/dir/game.json']) {
|
||||
assert.equal(rawGithubUrl(page), raw);
|
||||
}
|
||||
const p = parseInstallParams(`?manifest=${encodeURIComponent('https://github.com/o/r/blob/v1.2/game.json')}`);
|
||||
assert.equal(p.target, 'https://raw.githubusercontent.com/o/r/v1.2/game.json');
|
||||
assert.equal(p.link, toFrameportLink('manifest', p.target));
|
||||
// everything else is left alone: release downloads, raw addresses, other hosts
|
||||
for (const other of ['https://github.com/o/r/releases/latest/download/game.json', raw,
|
||||
'https://example.com/o/r/blob/main/game.json', 'http://github.com/o/r/blob/main/game.json']) {
|
||||
assert.equal(rawGithubUrl(other), other);
|
||||
}
|
||||
});
|
||||
|
||||
test('GitHub release downloads are recognised (for the release name from the API)', () => {
|
||||
assert.deepEqual(githubRelease('https://github.com/o/r/releases/latest/download/frameport-install.json'),
|
||||
{ owner: 'o', repo: 'r', tag: null });
|
||||
assert.deepEqual(githubRelease('https://github.com/o/r/releases/download/v0.1.0/frameport-install.json'),
|
||||
{ owner: 'o', repo: 'r', tag: 'v0.1.0' });
|
||||
assert.equal(githubRelease('https://github.com/o/r/blob/main/x.json'), null);
|
||||
assert.equal(githubRelease('https://raw.githubusercontent.com/o/r/main/x.json'), null);
|
||||
});
|
||||
@@ -68,6 +68,38 @@ export function checkUrl(text: string, what = 'link'): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A GitHub file page (github.com/<owner>/<repo>/blob/<ref>/<path>, or /raw/<ref>/<path>) as the file itself on
|
||||
* raw.githubusercontent.com: a /blob/ address is an HTML page, and raw.githubusercontent.com is the GitHub address a
|
||||
* web page may read (CORS). Other addresses come back unchanged. deeplink.raw_github_url does the same in the app.
|
||||
*/
|
||||
export function rawGithubUrl(text: string): string {
|
||||
let url: URL;
|
||||
try { url = new URL(text); } catch { return text; }
|
||||
if (url.protocol !== 'https:' || !['github.com', 'www.github.com'].includes(url.hostname.toLowerCase())) return text;
|
||||
const m = url.pathname.match(/^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/(.+)$/);
|
||||
if (!m) return text;
|
||||
return `https://raw.githubusercontent.com/${m[1]}/${m[2]}/${m[3]}`;
|
||||
}
|
||||
|
||||
export interface GithubRelease { owner: string; repo: string; tag: string | null }
|
||||
|
||||
/**
|
||||
* A GitHub release download (github.com/<owner>/<repo>/releases/download/<tag>/<file> or
|
||||
* …/releases/latest/download/<file>; tag null = latest), else null. Release downloads can't be read by a web page
|
||||
* (no CORS), so the page shows the release's name from GitHub's API instead of the manifest's.
|
||||
*/
|
||||
export function githubRelease(text: string): GithubRelease | null {
|
||||
let url: URL;
|
||||
try { url = new URL(text); } catch { return null; }
|
||||
if (url.protocol !== 'https:' || !['github.com', 'www.github.com'].includes(url.hostname.toLowerCase())) return null;
|
||||
const latest = url.pathname.match(/^\/([^/]+)\/([^/]+)\/releases\/latest\/download\/[^/]+$/);
|
||||
if (latest) return { owner: latest[1], repo: latest[2], tag: null };
|
||||
const tagged = url.pathname.match(/^\/([^/]+)\/([^/]+)\/releases\/download\/([^/]+)\/[^/]+$/);
|
||||
if (tagged) return { owner: tagged[1], repo: tagged[2], tag: decodeURIComponent(tagged[3]) };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toFrameportLink(kind: Kind, target: string): string {
|
||||
return `frameport://install?${kind}=${encodeURIComponent(target)}`;
|
||||
}
|
||||
@@ -75,8 +107,9 @@ export function toFrameportLink(kind: Kind, target: string): string {
|
||||
/** The page's query string → what to install, or why not. */
|
||||
export function parseInstallParams(search: string): Parsed {
|
||||
const q = new URLSearchParams(search);
|
||||
const manifest = q.get('manifest')?.trim();
|
||||
const file = q.get('url')?.trim();
|
||||
const raw = (v: string | undefined) => (v ? rawGithubUrl(v) : v);
|
||||
const manifest = raw(q.get('manifest')?.trim());
|
||||
const file = raw(q.get('url')?.trim());
|
||||
if (manifest) {
|
||||
const error = checkUrl(manifest, 'manifest address');
|
||||
if (error) return { ok: false, error };
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -864,7 +864,8 @@ def saves_list(package: str = typer.Argument(..., help="the game (package or par
|
||||
found = saves.list_backups(pkg)
|
||||
if as_json:
|
||||
typer.echo(json.dumps([{"path": str(b.path), "created": b.created, "size": b.size, "files": b.files,
|
||||
"bytes": b.bytes, "version": b.version, "skipped": b.skipped} for b in found],
|
||||
"bytes": b.bytes, "version": b.version, "skipped": b.skipped,
|
||||
"automatic": b.automatic} for b in found],
|
||||
indent=1))
|
||||
return
|
||||
if not found:
|
||||
@@ -873,7 +874,8 @@ def saves_list(package: str = typer.Argument(..., help="the game (package or par
|
||||
for i, b in enumerate(found, 1):
|
||||
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(b.created))
|
||||
typer.echo(f"{i:3} {when} {b.files:5} file(s) {b.bytes / 2**20:8.1f} MiB"
|
||||
+ (f" v{b.version}" if b.version else "") + f" {b.path.name}")
|
||||
+ (f" v{b.version}" if b.version else "")
|
||||
+ (" automatic" if b.automatic else "") + f" {b.path.name}")
|
||||
|
||||
|
||||
@saves_app.command("restore")
|
||||
|
||||
@@ -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}
|
||||
@@ -185,6 +185,23 @@ def demo_manifest() -> Manifest:
|
||||
version="1.0.0", demo=True)
|
||||
|
||||
|
||||
_GITHUB_FILE_PAGE = re.compile(r"^/([^/]+)/([^/]+)/(?:blob|raw)/(.+)$")
|
||||
|
||||
|
||||
def raw_github_url(url: str) -> str:
|
||||
"""A GitHub file page (github.com/<owner>/<repo>/blob/<ref>/<path>, or /raw/…) as the file itself on
|
||||
raw.githubusercontent.com (a /blob/ address is an HTML page); anything else unchanged. The install page
|
||||
(site/src/scripts/install-link.ts rawGithubUrl) does the same."""
|
||||
try:
|
||||
parts = urlsplit(url.strip())
|
||||
except ValueError:
|
||||
return url
|
||||
if parts.scheme != "https" or (parts.hostname or "").lower() not in ("github.com", "www.github.com"):
|
||||
return url
|
||||
m = _GITHUB_FILE_PAGE.match(parts.path)
|
||||
return f"https://raw.githubusercontent.com/{m[1]}/{m[2]}/{m[3]}" if m else url
|
||||
|
||||
|
||||
def parse(text: str) -> InstallRequest:
|
||||
"""framedrop://install?…, frameport://install?…, the https://framedropvr.com/install?… button link, a manifest
|
||||
URL (…json) or a direct file URL."""
|
||||
@@ -207,12 +224,12 @@ def parse(text: str) -> InstallRequest:
|
||||
if manifest and is_demo(manifest):
|
||||
return demo_request()
|
||||
if manifest:
|
||||
return InstallRequest(manifest_url=check_url(manifest, "manifest address"))
|
||||
return InstallRequest(manifest_url=check_url(raw_github_url(manifest), "manifest address"))
|
||||
if file:
|
||||
return InstallRequest(file_url=check_url(file, "file address"))
|
||||
return InstallRequest(file_url=check_url(raw_github_url(file), "file address"))
|
||||
raise LinkError("The link doesn't say what to install (no manifest= or url=).")
|
||||
if scheme in ("https", "http"):
|
||||
url = check_url(text)
|
||||
url = check_url(raw_github_url(text))
|
||||
name = filename_of(url).lower()
|
||||
if name.endswith(".json"):
|
||||
return InstallRequest(manifest_url=url)
|
||||
|
||||
@@ -20,9 +20,11 @@ def pc_usb_addresses(frame_ip: str) -> list[str]:
|
||||
"""This PC's IPv4 addresses in the same /24 as the Frame's USB address (Windows via ipconfig, else `ip`)."""
|
||||
prefix = frame_ip.rsplit(".", 1)[0] + "."
|
||||
texts = []
|
||||
# CREATE_NO_WINDOW: the windowed Windows app flashed a console for every ipconfig run (GitHub #398)
|
||||
hidden = {"creationflags": 0x08000000} if sys.platform == "win32" else {}
|
||||
for cmd in (["ip", "-4", "-o", "addr", "show"], ["ipconfig"], ["ifconfig"], ["ipconfig.exe"]):
|
||||
try:
|
||||
texts.append(subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout)
|
||||
texts.append(subprocess.run(cmd, capture_output=True, text=True, timeout=10, **hidden).stdout)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
text = "\n".join(texts)
|
||||
|
||||
@@ -53,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
|
||||
@@ -89,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
|
||||
|
||||
@@ -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.frame.headset_model 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)
|
||||
@@ -58,6 +58,8 @@ def configuration_args(entry: dict, recipe: Recipe) -> dict:
|
||||
build = entry.get("build") or {}
|
||||
applied = configuration(original, recipe.package, build.get("superseded"))
|
||||
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
|
||||
|
||||
@@ -11,9 +11,11 @@ game runs. No Flet here: the GUI and the CLI share it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -45,6 +47,7 @@ class Backup:
|
||||
bytes: int = 0 # the save files' size (uncompressed)
|
||||
version: str = ""
|
||||
title: str = ""
|
||||
automatic: bool = False
|
||||
skipped: int = 0 # large files left out
|
||||
|
||||
@property
|
||||
@@ -52,10 +55,41 @@ class Backup:
|
||||
return sidecar_of(self.path)
|
||||
|
||||
|
||||
def root_dir() -> Path:
|
||||
def default_root() -> Path:
|
||||
return user_data_dir() / "save-backups"
|
||||
|
||||
|
||||
def root_dir() -> Path:
|
||||
folder = library.setting("saves.folder", "")
|
||||
return Path(folder).expanduser() if folder else default_root()
|
||||
|
||||
|
||||
def writable_folder(folder: Path) -> Path:
|
||||
"""Probe actual creation, including on network/cloud mounts; don't change settings on failure."""
|
||||
folder = Path(folder).expanduser().absolute()
|
||||
try:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryFile(dir=folder) as probe:
|
||||
probe.write(b"FramePort save backup folder\n")
|
||||
probe.flush()
|
||||
except OSError as exc:
|
||||
raise SavesError(f"Cannot write save backups to {folder}: {exc}") from exc
|
||||
return folder
|
||||
|
||||
|
||||
def set_folder(folder: str | None) -> Path:
|
||||
dest = writable_folder(Path(folder) if folder else default_root())
|
||||
roots = [str(p) for p in backup_roots()]
|
||||
library.set_setting("saves.previous_folders", roots)
|
||||
library.set_setting("saves.folder", str(dest) if folder else "")
|
||||
return dest
|
||||
|
||||
|
||||
def backup_roots() -> list[Path]:
|
||||
return list(dict.fromkeys([root_dir(), default_root(),
|
||||
*(Path(p) for p in library.setting("saves.previous_folders", []))]))
|
||||
|
||||
|
||||
def backups_dir(pkg: str) -> Path:
|
||||
if not _PKG.match(pkg):
|
||||
raise ValueError(f"bad package name {pkg!r}")
|
||||
@@ -96,7 +130,8 @@ def _sidecar_data(manifest: dict, archive: Path) -> dict:
|
||||
"version": manifest.get("version") or "", "title": manifest.get("title") or "",
|
||||
"files": len(files), "bytes": sum(int(f.get("size") or 0) for f in files),
|
||||
"skipped": [s for s in manifest.get("skipped") or [] if s.get("reason") in ("size", "unreadable")],
|
||||
"archive": archive.name, "agent": manifest.get("agent")}
|
||||
"archive": archive.name, "agent": manifest.get("agent"),
|
||||
"automatic": bool(manifest.get("automatic"))}
|
||||
|
||||
|
||||
def _from_sidecar(archive: Path, data: dict) -> Backup:
|
||||
@@ -104,14 +139,15 @@ def _from_sidecar(archive: Path, data: dict) -> Backup:
|
||||
created=float(data.get("created") or archive.stat().st_mtime), size=archive.stat().st_size,
|
||||
files=int(data.get("files") or 0), bytes=int(data.get("bytes") or 0),
|
||||
version=str(data.get("version") or ""), title=str(data.get("title") or ""),
|
||||
skipped=len(data.get("skipped") or []))
|
||||
skipped=len(data.get("skipped") or []), automatic=bool(data.get("automatic")))
|
||||
|
||||
|
||||
def list_backups(pkg: str) -> list[Backup]:
|
||||
"""This game's backups on this PC, newest first (a missing/broken sidecar is rebuilt from the archive)."""
|
||||
d = backups_dir(pkg)
|
||||
backups_dir(pkg) # validate before using the package as a path
|
||||
out = []
|
||||
for archive in sorted(d.glob("*.tar.gz")) if d.is_dir() else []:
|
||||
archives = [p for root in backup_roots() for p in (root / pkg).glob("*.tar.gz")]
|
||||
for archive in sorted(archives):
|
||||
if not _NAME.match(archive.name):
|
||||
continue
|
||||
try:
|
||||
@@ -119,9 +155,12 @@ def list_backups(pkg: str) -> list[Backup]:
|
||||
except (OSError, ValueError):
|
||||
try:
|
||||
data = _sidecar_data(read_manifest(archive), archive)
|
||||
sidecar_of(archive).write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
try:
|
||||
sidecar_of(archive).write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
except OSError:
|
||||
pass # backups in an old read-only folder remain usable
|
||||
try:
|
||||
out.append(_from_sidecar(archive, data))
|
||||
except OSError:
|
||||
@@ -131,17 +170,17 @@ def list_backups(pkg: str) -> list[Backup]:
|
||||
|
||||
def has_backups(pkg: str) -> bool:
|
||||
try:
|
||||
d = backups_dir(pkg)
|
||||
backups_dir(pkg)
|
||||
except ValueError:
|
||||
return False
|
||||
return d.is_dir() and any(_NAME.match(p.name) for p in d.glob("*.tar.gz"))
|
||||
return any(_NAME.match(p.name) for root in backup_roots() for p in (root / pkg).glob("*.tar.gz"))
|
||||
|
||||
|
||||
def prune(pkg: str, keep: int | None = None, protect: Path | None = None) -> list[Path]:
|
||||
"""Delete all but the newest `keep` backups of a game (never `protect`). Returns the deleted archives."""
|
||||
keep = keep_count() if keep is None else max(1, keep)
|
||||
gone = []
|
||||
for b in list_backups(pkg)[keep:]:
|
||||
for b in [b for b in list_backups(pkg) if b.path.parent == backups_dir(pkg)][keep:]:
|
||||
if protect is not None and b.path == protect:
|
||||
continue
|
||||
for p in (b.path, b.sidecar):
|
||||
@@ -190,21 +229,31 @@ def _new_name(d: Path) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def backup(frame, pkg: str, reporter: Reporter | None = None, keep: int | None = None) -> Backup:
|
||||
def backup(frame, pkg: str, reporter: Reporter | None = None, keep: int | None = None,
|
||||
snapshot: str | None = None) -> Backup:
|
||||
"""Back up the game's saves from the Frame into a new archive on this PC, then keep the newest `keep`."""
|
||||
rep = reporter or Reporter()
|
||||
rep.stage("saves", "Backing up saves")
|
||||
d = writable_folder(backups_dir(pkg))
|
||||
try:
|
||||
expected = int(info(frame, pkg).get("bytes") or 0)
|
||||
expected = 0 if snapshot else int(info(frame, pkg).get("bytes") or 0)
|
||||
except Exception: # noqa: BLE001 - only for the progress bar
|
||||
expected = 0
|
||||
d = backups_dir(pkg)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
dest = _new_name(d)
|
||||
part = dest.with_name(dest.name + ".part")
|
||||
stdin, stdout, stderr = _exec(frame, "_backup_saves", {"package": pkg})
|
||||
# Automatic imports can overlap a manual job; reserve the name before opening SSH.
|
||||
while True:
|
||||
dest = _new_name(d)
|
||||
part = dest.with_name(dest.name + ".part")
|
||||
try:
|
||||
part.touch(exist_ok=False)
|
||||
break
|
||||
except FileExistsError:
|
||||
continue
|
||||
stdout = None
|
||||
received = 0
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
stdin, stdout, stderr = _exec(frame, "_fetch_save_snapshot" if snapshot else "_backup_saves",
|
||||
{"package": pkg, "snapshot": snapshot})
|
||||
stdin.channel.shutdown_write()
|
||||
with open(part, "wb") as f:
|
||||
while True:
|
||||
@@ -213,6 +262,7 @@ def backup(frame, pkg: str, reporter: Reporter | None = None, keep: int | None =
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
digest.update(chunk)
|
||||
received += len(chunk)
|
||||
if expected: # compressed: an estimate, never "done" before the end
|
||||
rep.progress(min(0.95, received / expected), f"{received / 2**20:.1f} MiB")
|
||||
@@ -220,21 +270,26 @@ def backup(frame, pkg: str, reporter: Reporter | None = None, keep: int | None =
|
||||
reply = _last_json(stderr.read().decode("utf-8", "replace")) or {}
|
||||
if code or not reply.get("ok"):
|
||||
raise SavesError(reply.get("error") or f"the Frame couldn't back up the saves (exit {code})")
|
||||
if snapshot and digest.hexdigest() != snapshot.split("-", 1)[1][:-7]:
|
||||
raise SavesError("the Frame sent a damaged save snapshot (hash mismatch)")
|
||||
manifest = read_manifest(part)
|
||||
if manifest.get("package") != pkg:
|
||||
raise SavesError(f"the Frame sent saves of {manifest.get('package')}, not {pkg}")
|
||||
part.replace(dest)
|
||||
except BaseException:
|
||||
try:
|
||||
stdout.channel.close()
|
||||
if stdout is not None:
|
||||
stdout.channel.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
part.unlink(missing_ok=True)
|
||||
raise
|
||||
data = _sidecar_data(manifest, dest)
|
||||
if snapshot:
|
||||
data.update(automatic=True, snapshot=snapshot)
|
||||
sidecar_of(dest).write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
result = _from_sidecar(dest, data)
|
||||
prune(pkg, keep, protect=dest)
|
||||
prune(pkg, keep, protect=None if snapshot else dest)
|
||||
rep.progress(1.0, "")
|
||||
rep.log(f"saved {result.files} file(s), {result.bytes / 2**20:.1f} MiB of saves, to {dest}"
|
||||
+ (f"; {result.skipped} large file(s) left out" if result.skipped else ""))
|
||||
@@ -299,3 +354,28 @@ def undo_restore(frame, pkg: str) -> dict:
|
||||
raise SavesError(reply.get("error") or "the Frame couldn't put the saves back")
|
||||
return reply["result"]
|
||||
|
||||
|
||||
|
||||
def sync_snapshots(frame, reporter: Reporter | None = None) -> int:
|
||||
"""Import each remote snapshot once, including after local retention has pruned its archive."""
|
||||
frame.agent("save_snapshot_settings", enabled=bool(library.setting("saves.auto", False)))
|
||||
state_path = user_data_dir() / "save-snapshots-synced.json"
|
||||
try:
|
||||
seen = set(json.loads(state_path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError):
|
||||
seen = set()
|
||||
count = 0
|
||||
for item in frame.agent("save_snapshots").get("snapshots", []):
|
||||
pkg, name = item["package"], item["snapshot"]
|
||||
# Content digest identity is stable across reconnects and different Frames.
|
||||
identity = pkg + ":" + item["sha256"]
|
||||
if identity in seen:
|
||||
continue
|
||||
backup(frame, pkg, reporter, snapshot=name)
|
||||
seen.add(identity)
|
||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = state_path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(sorted(seen)), encoding="utf-8")
|
||||
tmp.replace(state_path)
|
||||
count += 1
|
||||
return count
|
||||
@@ -39,17 +39,11 @@
|
||||
", {n} screenshots": "",
|
||||
"0 = the size of your play area.": "",
|
||||
"10 games on the Frame. It's getting cozy in there.": "",
|
||||
"10% radius": "",
|
||||
"100 games on the Frame. The Frame thanks you.": "",
|
||||
"108 Hz": "",
|
||||
"120 Hz": "",
|
||||
"144 Hz": "",
|
||||
"15% radius": "",
|
||||
"20% radius": "",
|
||||
"25% radius": "",
|
||||
"30% radius": "",
|
||||
"32-bit ARM bridge (Gear VR / Go games)": "",
|
||||
"35% radius": "",
|
||||
"360p (lightest)": "",
|
||||
"360° (equirect) layers, for example a video player's virtual theater or 360° videos (for example 4XVR), which the Frame's runtime lacks, are drawn by a background thread into a layer behind the game's own picture: the 360° image is converted only when it changes (a theater once, a 360° video once per video frame) and the view is drawn for each frame's head pose. GLES games only; without it these layers are missing (black).": "",
|
||||
"360° 3D": "",
|
||||
@@ -58,8 +52,6 @@
|
||||
"360° pictures and video": "",
|
||||
"360° sharpness": "",
|
||||
"360° video frame rate limit": "",
|
||||
"40% radius": "",
|
||||
"45% radius": "",
|
||||
"72 Hz": "",
|
||||
"720p (recommended)": "",
|
||||
"80 Hz": "",
|
||||
@@ -186,6 +178,12 @@
|
||||
"APK": "",
|
||||
"Appearance": "",
|
||||
"AppImage": "",
|
||||
"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.": "",
|
||||
@@ -202,12 +200,14 @@
|
||||
"Asset-file service bypass": "",
|
||||
"At most this many redraws per second of a 360° layer (0 = no limit). Video players need at least the video's frame rate.": "",
|
||||
"Automatic": "",
|
||||
"automatic": "",
|
||||
"Automatic ({auto:.0%})": "",
|
||||
"Automatic SteamVR performance settings (this PC)": "",
|
||||
"Back to library": "",
|
||||
"Back up all saves": "",
|
||||
"Back up its saves to this PC before deleting them": "",
|
||||
"Back up saves": "",
|
||||
"Back up saves when a game closes": "",
|
||||
"Back up saves: {title}": "",
|
||||
"Back up the signing keys ({len}) to {shown_dir} first": "",
|
||||
"Backed up the saves of {n} game": [
|
||||
@@ -274,11 +274,16 @@
|
||||
"Choose a file": "",
|
||||
"Choose a folder": "",
|
||||
"Choose a folder with Quest, Android or PC VR games. FramePort finds them and gets them ready.": "",
|
||||
"Choose backup folder…": "",
|
||||
"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 save backup folder": "",
|
||||
"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": "",
|
||||
@@ -328,13 +333,16 @@
|
||||
"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": "",
|
||||
"Cool Game went through the portal. It was only a demo: nothing was downloaded or installed.": "",
|
||||
"Copied": "",
|
||||
"Copied. Save it as a .json file, change the colors and install it.": "",
|
||||
"Copies cube-map loading screens into six panels around the viewer (GLES and Vulkan). Mixed projection frames are skipped because the Frame draws panels above the scene. Requires cube_standin.": "",
|
||||
"Copies the game and its saves to the other drive, checks the copy, then removes the old one. The game must be closed. A game on a microSD card only starts while the card is in.": "",
|
||||
"Copy": "",
|
||||
"Copy all": "",
|
||||
@@ -370,6 +378,7 @@
|
||||
"Couldn't read: {names}": "",
|
||||
"Couldn't remove the theme: {error}": "",
|
||||
"Couldn't start the live view: {exc}": "",
|
||||
"Couldn't sync save backups: {error}": "",
|
||||
"Couldn't take a screenshot: {error}": "",
|
||||
"Couldn't use {choice}: {exc}": "",
|
||||
"Counts the headset as worn, so games that only move the player while it's worn get head tracking and controls (for example BONELAB).": "",
|
||||
@@ -392,7 +401,8 @@
|
||||
"Declares Meta's permissions some mixed-reality games ask for, so they don't stop.": "",
|
||||
"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 (large)": "",
|
||||
"Default controller buttons": "",
|
||||
"Delete": "",
|
||||
"Delete {names}?": "",
|
||||
"Delete {n} items…": "",
|
||||
@@ -506,6 +516,7 @@
|
||||
"Every {n}": "",
|
||||
"everything in ~ (not seen by games)": "",
|
||||
"Everything is up to date": "",
|
||||
"Existing backups stay in their original folder and remain available.": "",
|
||||
"Experimental": "",
|
||||
"experimental": "",
|
||||
"Experimental bypass for a DLC manager blocked on Meta asset-file service errors at startup (e.g. PixelRipped 1995: Asset status error (code 100)). Returns an empty successful asset list and reports requested asset IDs as installed, with an empty file path. Does not supply DLC or change download requests. Enable only to test this startup failure; disable with FRAMEPORT_ASSET_FILES_STUB=0. Shares the language-pack library and needs no language packs.": "",
|
||||
@@ -857,6 +868,7 @@
|
||||
"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 render resolution change).": "",
|
||||
"Keeps three snapshots per game on the Frame and copies new ones here when connected.": "",
|
||||
"Keeps your progress in games that file their saves under your Meta user.": "",
|
||||
"Keyboard connected to {label}.": "",
|
||||
"Keyboard disconnected: {error}": "",
|
||||
@@ -864,6 +876,7 @@
|
||||
"kshoji's USB MIDI driver (Unity MIDI plugins, for example PianoVision) polls Android's USB service for MIDI devices; the Frame's Android has no USB service, so the app crashes a few seconds after start (NullPointerException on MidiDeviceConnectionWatchThread). The poll returns at once instead, so no USB MIDI device is ever found (an in-place edit of classes.dex).": "",
|
||||
"Language": "",
|
||||
"Language packs from the game's files": "",
|
||||
"Large": "",
|
||||
"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": "",
|
||||
@@ -977,6 +990,7 @@
|
||||
"Marks the game as a VR app so headsets show it as one.": "",
|
||||
"Maximum": "",
|
||||
"Maximum size in pixels of each face of the cube the 360° image is converted to (256–2730). Higher is sharper but uses more GPU memory.": "",
|
||||
"Medium (saves more)": "",
|
||||
"Memory": "",
|
||||
"Memory used by everything on the Frame. \"Waiting\" is how often programs waited for memory in the last 10 s; when it climbs, the Frame may freeze or close a game.": "",
|
||||
"Menus and screens": "",
|
||||
@@ -988,6 +1002,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.": "",
|
||||
@@ -1269,6 +1284,8 @@
|
||||
"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.": "",
|
||||
@@ -1301,7 +1318,7 @@
|
||||
"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. 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, following each eye's gaze. Default keeps the runtime profile unchanged. Low has the largest full-resolution center; medium and high use smaller centers and stronger peripheral detail reduction. 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": "",
|
||||
@@ -1481,6 +1498,7 @@
|
||||
"Show advanced settings": "",
|
||||
"Show all patches ({len} don't apply to this game)": "",
|
||||
"Show as VR or as a flat window": "",
|
||||
"Show cube loading screens": "",
|
||||
"Show curved menus and screens": "",
|
||||
"Show details": "",
|
||||
"Show hidden files": "",
|
||||
@@ -1499,6 +1517,7 @@
|
||||
"Show video panels": "",
|
||||
"Showing {len} of {n}": "",
|
||||
"Showing {shown} of {n}. Search to find others.": "",
|
||||
"Shows 360° cube loading screens as six panels around you. Requires stand-in cube layers. Skipped while a 3D scene is submitted.": "",
|
||||
"Shows 3D 360° pictures flat if they look doubled.": "",
|
||||
"Shows Batman: Arkham Shadow's cutscenes; set by that game's recipe.": "",
|
||||
"Shows each eye's picture to the other eye. For testing.": "",
|
||||
@@ -1516,18 +1535,21 @@
|
||||
"Size of text and layout. Under WSL, Automatic follows half of Windows' display scaling (150% → 125%).": "",
|
||||
"Skip for now": "",
|
||||
"Skip Meta avatars": "",
|
||||
"Skip refused graphics": "",
|
||||
"Skip this version": "",
|
||||
"Skips a Quest-only reporting step in Meta's audio library that crashes some games.": "",
|
||||
"Skips a separate data-file check when an Unreal game still hangs at startup with the content check off. Experimental; the game still needs its data.": "",
|
||||
"Skips Meta XR Audio's telemetry under x86_64 ARM translation (emulators). Not needed on the Frame, which runs games natively.": "",
|
||||
"Skips picture layers the Frame would refuse. Leave it on.": "",
|
||||
"Sleep": "",
|
||||
"Small (saves most)": "",
|
||||
"Smooth as butter": "",
|
||||
"software encoder": "",
|
||||
"Some apps open a flat Android launcher that then starts a separate VR activity (for example WiiCompiled). Shown with Lepton's flat window, that window stays in view after the VR part starts. This makes the VR activity the one Lepton starts (its LAUNCHER category), so the app opens in VR with no flat window. Everything only the launcher does (choosing or importing content) is skipped: turn it off for a run when you need the launcher, and turn off \"Show the app's Android window\".": "",
|
||||
"Some engines contain assembly (for example OpenSSL's) that checks a return-address signature it never made. The Quest's CPU ignores those checks, the Steam Frame's enforces them: the game crashes with SIGILL as soon as it uses that code, for example on its first network connection (Star Wars Pinball VR). In a library with unpaired checks, the signing and checking instructions become no-ops.": "",
|
||||
"Some engines leave the pNext of unused Vulkan attachment references uninitialized. The Frame's driver never reads it, but Lepton always loads Steam's Fossilize shader-cache layer, which follows it and crashes on the first frame (SIGSEGV in libVkLayer_fossilize.so from FVulkanRenderPass, for example Deadpool VR). Loads Vulkan through a small shim that keeps valid pointers and drops only unreadable ones or ones pointing at the wrong structure type. It also drops a depth resolve named in a subpass without a depth attachment, which crashes the Frame's driver at the first render pass (for example Metro Awakening).": "",
|
||||
"Some game folders are already set up for SteamVR: a Revive copy (LibRevive64.dll) next to the game, started by a small loader DLL (for example xinput1_3.dll) that Windows loads from the game's folder. Then FramePort starts the game program directly — like double-clicking it — instead of through its own Revive (two Revives conflict; for example the Oculus Platform check then fails). On the Frame, Wine is told to use those loader DLLs from the game folder instead of its own.": "",
|
||||
"Some games can't create any graphics with it (for example Transformers Beyond Reality).": "",
|
||||
"Some games keep content in separate files next to their OBB (for example Star Wars: Tales from the Galaxy's Edge: its seasons and sound banks) and ask Meta's platform for them as asset files. OVRPort's platform loader doesn't know them, so the game waits for content that never arrives (black screen after loading). Lists the content files shipped with the game's data (*.pak) as installed and answers their download request at once with the file's path; nothing is copied or downloaded. Shares the language-pack library.": "",
|
||||
"Some games save their render resolution in a settings file and load it after creating the headset's eye buffers; after a resolution scale change the saved size no longer matches and the picture is displaced or stretched, with black borders (for example BlazeRush's user_config.xml). FramePort writes the values its recipe names into the game's file while the game isn't running (after an install and after a Game settings change), from the Frame's own eye-buffer size times the resolution scale: w and h = the eye buffer's width and height, for example h*16/9. Only existing entries change; the previous file is kept as <file>.frameport-backup. The size is learned from the game's last start, so a file the game wrote before is corrected from the next install or settings change on.": "",
|
||||
"Some OpenGL ES engines compile every shader for multiview (both eyes in one draw, `num_views=2`) and use the same shaders to draw into ordinary single-layer framebuffers, for example a HUD or PDA screen rendered to a texture. Quest's driver draws them anyway; the Frame's driver (Mesa) follows the OVR_multiview rule and silently drops every such draw, so those panels stay black (for example Doom3Quest's HUD and PDA). Loads OpenGL ES through a small interposer (libfpglmv.so) that draws those cases with a single-view copy of the shader program (left-eye view; uniforms copied before each such draw). Eye-buffer draws are unchanged. Logs under the tag GLMV; the setting gl_mv_debug adds error checks and counters.": "",
|
||||
@@ -1580,6 +1602,7 @@
|
||||
"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": "",
|
||||
@@ -1590,6 +1613,7 @@
|
||||
"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)": "",
|
||||
@@ -1687,7 +1711,7 @@
|
||||
"The Frame's battery is at {pct}% and not charging. Plug it into a strong charger — installs pause at {pause}%.": "",
|
||||
"The Frame's battery is at {pct}%, so installs are paused. Plug it in and they continue.": "",
|
||||
"The Frame's desktop password, needed only once so FramePort can add its key.": "",
|
||||
"The Frame's runtime has no cube-map layers and refuses their swapchains; OVRPlugin then crashes when it submits the next frame (for example Budget Cuts Ultimate). FrameBridge serves such a swapchain itself (a GL cube map, GLES games) and drops its layers, so the game runs on without that layer.": "",
|
||||
"The Frame's runtime has no cube-map layers and refuses their swapchains; OVRPlugin then crashes when it submits the next frame (for example Budget Cuts Ultimate). FrameBridge serves such a swapchain itself on GLES and Vulkan; cube_quads displays its loading screens.": "",
|
||||
"The Frame's Steam couldn't start {title} (error {code}). Please report it.": "",
|
||||
"The Frame's Steam didn't accept {title}, so it was added as \"Devkit Game: …\". Starting it now — put the headset on.": "",
|
||||
"The Frame's Steam library still shows the old artwork.": "",
|
||||
@@ -1899,6 +1923,8 @@
|
||||
"Use controllers": "",
|
||||
"Use FramePort": "",
|
||||
"Use FramePort for these links": "",
|
||||
"Use global default ({mode})": "",
|
||||
"Use the default": "",
|
||||
"Use the new recipe": "",
|
||||
"Use the repack's launcher (bundled Revive)": "",
|
||||
"Use the setup command": "",
|
||||
@@ -1906,6 +1932,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.": "",
|
||||
@@ -1940,10 +1967,12 @@
|
||||
"Vulkan pipeline failures (log)": "",
|
||||
"Vulkan shader dump (diagnostics)": "",
|
||||
"Vulkan shader fixes": "",
|
||||
"Vulkan shim (frame.vk_sanitize): a graphics pipeline the Frame's driver refuses is created again with a fragment shader that writes zero, then with a vertex shader that only sets the position as well, so the draw shows nothing instead of Unreal ending with \"Shader compilation failures are Fatal.\" (for example a few multiview vertex shaders in Transformers Beyond Reality, GitHub #349). Logged as \"retried with a blank\".": "",
|
||||
"Vulkan shim (frame.vk_sanitize): logs every failing graphics/compute pipeline creation with its shader stages (named like the vk_shader_dump files), structure types and any sampler Y'CbCr conversion involved, to find the pipeline behind Unreal's \"Shader compilation failures are Fatal.\" No effect on the game.": "",
|
||||
"Vulkan shim (frame.vk_sanitize): makes two Unreal habits valid Vulkan - depth images get VK_IMAGE_USAGE_TRANSFER_DST_BIT (they are cleared with vkCmdClearDepthStencilImage) and Qualcomm shader-resolve subpasses lose an invalid depth resolve. Found with the validation layer in Into The Radius 2 (flickering models, windows behind models). Also leaves out image barriers without an image and refuses image views without one, which crash the Frame's driver (for example Metro Awakening).": "",
|
||||
"Vulkan shim (frame.vk_sanitize): on gives every occlusion query room for both eyes. In multiview passes the Frame's driver writes a zero result for the second eye into the next query, so engines that count on one slot cull visible objects (models popping in and out, for example Into The Radius 2). 0 = off, 1 = on (2 slots).": "",
|
||||
"Vulkan shim (frame.vk_sanitize): SPIR-V modules matching size + SHA-256 get words inserted at a byte offset, for example stores that initialize locals a shader reads before writing (an undefined loop counter hung the GPU in VR4's campaign). Format: <size>:<sha256>:<byte offset>:<word>,<word>,...; several separated by ';'. Comes from a game's recipe.": "",
|
||||
"Vulkan shim (frame.vk_sanitize): takes Lepton's Fossilize shader-cache layer out of the game's Vulkan layers (Android's debug layer list) when the engine loads its Vulkan library. With Fossilize loaded every graphics pipeline of Transformers Beyond Reality failed (VK_ERROR_OUT_OF_HOST_MEMORY), which Unreal treats as fatal (GitHub #349). Lepton has no switch for it; instances created before the engine loads the shim keep it.": "",
|
||||
"Vulkan shim (frame.vk_sanitize): the game doesn't see VK_EXT_fragment_density_map(2). Unreal Engine 5 turns on fragment-density-map foveation when the driver offers it and expects the density map from the headset, which the Frame doesn't provide: image views and barriers for a missing image, then a crash in the Frame's Vulkan driver (for example Metro Awakening). Valve's own foveation layer isn't affected.": "",
|
||||
"Vulkan shim (frame.vk_sanitize): writes every distinct SPIR-V module the game creates to Android/data/<package>/files/fp_vk_shaders/<size>_<sha256>.spv, with index.txt listing every creation in order with its time, to find the shader behind a GPU hang and capture it for a new vk_shader_fix. Diagnostics include the newest modules. No effect on the game.": "",
|
||||
"Vulkan: drop invalid render-pass pointers": "",
|
||||
@@ -1969,6 +1998,7 @@
|
||||
"What your Frame is ready for and what's installed": "",
|
||||
"What's new in FramePort {version}": "",
|
||||
"What's new…": "",
|
||||
"When the Frame's driver refuses one of the game's shaders, that object is left out instead of the game ending.": "",
|
||||
"Where it's installed": "",
|
||||
"Where new games go on the Frame, such as a microSD card. Installed games stay put; right-click → Move to… moves one. Cards formatted as FAT, exFAT or NTFS can't hold games.": "",
|
||||
"Where the power goes": "",
|
||||
@@ -1989,6 +2019,7 @@
|
||||
"Windows' firewall for WSL blocks the Frame. Show the setup command again and allow the change when Windows asks.": "",
|
||||
"With emulated passthrough, honours the eye layer's XR_FB_composition_layer_alpha_blend, which the Frame's runtime ignores: Unreal games keep inverted alpha and ask for inverse blending, so the room showed where the game drew and black where it should show the room (for example Star Wars: Beyond Victory). The alpha is inverted in the game's image before it is composited (Vulkan).": "",
|
||||
"Without a Meta account, OVRPort's platform library makes up a new user id at every start (ovr_GetLoggedInUserID). Games that file saves under the user id (for example Walkabout Mini Golf) then start with an empty profile each time and seem to lose all progress. Gives the game a fixed id instead, different for each game and each FramePort user.": "",
|
||||
"Without Steam's shader cache layer": "",
|
||||
"Working": "",
|
||||
"Working…": "",
|
||||
"Works": "",
|
||||
@@ -2089,6 +2120,10 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game needs Update on Frame first.": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game now uses the other version found there": [
|
||||
"",
|
||||
""
|
||||
|
||||
@@ -14,9 +14,9 @@ OVRPLUGIN = "libOVRPlugin.so"
|
||||
|
||||
|
||||
def settings_text(recipe_patches: dict) -> bytes:
|
||||
from .headset_model import adapter_settings
|
||||
from ...core.controllers import effective_adapter_settings
|
||||
|
||||
settings = adapter_settings(recipe_patches)
|
||||
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()
|
||||
@@ -24,6 +24,7 @@ def settings_text(recipe_patches: dict) -> bytes:
|
||||
|
||||
class FrameBridgeAdapter(Patch):
|
||||
id = "frame.adapter"
|
||||
package_revisions = {"com.camouflaj.manta": 3} # above revision 2: Batman builds before #389 are outdated
|
||||
a32 = True # also for 32-bit games on the bridge
|
||||
title = "FrameBridge OpenXR adapter"
|
||||
description = (
|
||||
|
||||
@@ -47,11 +47,11 @@ def model(params):
|
||||
return MODELS[key]
|
||||
|
||||
|
||||
def adapter_settings(recipe_patches):
|
||||
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)
|
||||
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
|
||||
@@ -91,7 +91,14 @@ class HeadsetModel(Patch):
|
||||
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)
|
||||
try:
|
||||
fixed = patch_dispatcher(ws.read_lib(loader), key)
|
||||
except ValueError:
|
||||
# Quest 3 is everyone's default: an APK converted with another OVRPort runtime (e.g. an already converted
|
||||
# copy) keeps OVRPort's Quest 2 answers instead of failing its build
|
||||
ctx.checks.append((self.id, None, "This OVRPort dispatcher isn't a known build; the game keeps seeing a "
|
||||
"Quest 2 (only FrameBridge's systemName override applies)."))
|
||||
return False
|
||||
ws.put(ws.lib(loader), fixed)
|
||||
ctx.notes.append(f"{loader}: systemName={SYSTEM_NAMES[key].decode()}, HeadsetId={UUIDS[key].hex()}")
|
||||
return True
|
||||
|
||||
@@ -152,8 +152,9 @@ class UnrealNoReserve(Patch):
|
||||
if not lib or ws.abi != "arm64-v8a":
|
||||
return []
|
||||
todo, done = mmap_sites(ws.read_lib(lib))
|
||||
return [("Unreal memory reservations use MAP_NORESERVE", bool(done) and not todo,
|
||||
f"{len(done)} rewritten, {len(todo)} left")]
|
||||
# nothing to rewrite (an engine build without these reservations, e.g. Lies Beneath, GitHub #393) passes
|
||||
return [("Unreal memory reservations use MAP_NORESERVE", not todo,
|
||||
f"{len(done)} rewritten, {len(todo)} left" if todo or done else "none in this build")]
|
||||
|
||||
|
||||
register(UnrealNoReserve)
|
||||
@@ -51,12 +51,20 @@ 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_quads", "int", 1, "Show cube layers",
|
||||
"Copies cube-map loading screens into six panels around the viewer (GLES and Vulkan). Mixed projection frames "
|
||||
"are skipped because the Frame draws panels above the scene. Requires cube_standin."),
|
||||
("cube_standin", "int", 1, "Stand-in cube swapchains",
|
||||
"The Frame's runtime has no cube-map layers and refuses their swapchains; OVRPlugin then crashes when it submits "
|
||||
"the next frame (for example Budget Cuts Ultimate). FrameBridge serves such a swapchain itself (a GL cube map, "
|
||||
"GLES games) and drops its layers, so the game runs on without that layer."),
|
||||
"the next frame (for example Budget Cuts Ultimate). FrameBridge serves such a swapchain itself on GLES and "
|
||||
"Vulkan; cube_quads displays its loading screens."),
|
||||
("surface_native", "int", 0, "Native video surfaces (Batman renderer)",
|
||||
"Plays the game's Android video surface through GPU-shared buffers and composes it as a stereo panorama (Batman: "
|
||||
"Arkham Shadow's cutscenes, see docs/SURFACE_VIDEO.md). Specific to that game's renderer: set by its recipe, not "
|
||||
@@ -240,6 +248,16 @@ SETTINGS = [
|
||||
"Vulkan shim (frame.vk_sanitize): logs every failing graphics/compute pipeline creation with its shader stages "
|
||||
"(named like the vk_shader_dump files), structure types and any sampler Y'CbCr conversion involved, to find the "
|
||||
"pipeline behind Unreal's \"Shader compilation failures are Fatal.\" No effect on the game."),
|
||||
("vk_no_fossilize", "int", 0, "Vulkan shim: without Lepton's Fossilize layer",
|
||||
"Vulkan shim (frame.vk_sanitize): takes Lepton's Fossilize shader-cache layer out of the game's Vulkan layers "
|
||||
"(Android's debug layer list) when the engine loads its Vulkan library. With Fossilize loaded every graphics "
|
||||
"pipeline of Transformers Beyond Reality failed (VK_ERROR_OUT_OF_HOST_MEMORY), which Unreal treats as fatal "
|
||||
"(GitHub #349). Lepton has no switch for it; instances created before the engine loads the shim keep it."),
|
||||
("vk_pipeline_fallback", "int", 0, "Vulkan shim: blank shaders for refused pipelines",
|
||||
"Vulkan shim (frame.vk_sanitize): a graphics pipeline the Frame's driver refuses is created again with a fragment "
|
||||
"shader that writes zero, then with a vertex shader that only sets the position as well, so the draw shows "
|
||||
"nothing instead of Unreal ending with \"Shader compilation failures are Fatal.\" (for example a few multiview "
|
||||
"vertex shaders in Transformers Beyond Reality, GitHub #349). Logged as \"retried with a blank\"."),
|
||||
("vk_validation", "int", 0, "Vulkan shim: validation layer",
|
||||
"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."),
|
||||
@@ -300,7 +318,7 @@ REFRESH_CHOICES = [(0.0, "Game's choice"), *((float(hz), f"{hz} Hz") for hz in (
|
||||
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",
|
||||
"level": 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."),
|
||||
@@ -317,6 +335,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")),
|
||||
@@ -441,6 +463,12 @@ UI: dict[str, dict] = {
|
||||
help="Shows each eye's picture to the other eye. For testing.", control=("switch",)),
|
||||
"vk_pipeline_log": dict(group="troubleshooting", level="advanced", label="Vulkan pipeline failures (log)",
|
||||
help="Logs graphics pipelines the Frame's driver refuses. No effect on the game.", control=("switch",)),
|
||||
"vk_no_fossilize": dict(group="troubleshooting", level="advanced", label="Without Steam's shader cache layer",
|
||||
help="Some games can't create any graphics with it (for example Transformers Beyond Reality).",
|
||||
control=("switch",)),
|
||||
"vk_pipeline_fallback": dict(group="troubleshooting", level="advanced", label="Skip refused graphics",
|
||||
help="When the Frame's driver refuses one of the game's shaders, that object is left out instead of "
|
||||
"the game ending.", 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",
|
||||
@@ -489,6 +517,9 @@ UI: dict[str, dict] = {
|
||||
"ovrp_hold_physics": dict(group="troubleshooting", level="advanced", label="Steadier hands (older Unity)",
|
||||
help="For older Unity games whose hands trail the controllers (for example Sniper Elite "
|
||||
"VR). In others the hands lag.", control=("switch",)),
|
||||
"cube_quads": dict(group="troubleshooting", level="advanced", label="Show cube loading screens",
|
||||
help="Shows 360° cube loading screens as six panels around you. Requires stand-in cube layers. "
|
||||
"Skipped while a 3D scene is submitted.", control=("switch",)),
|
||||
"cube_standin": dict(group="troubleshooting", level="advanced", label="Stand in for cube layers",
|
||||
help="Keeps games running that use a 360° cube layer the Frame lacks (for example Budget Cuts "
|
||||
"Ultimate). Leave it on.", control=("switch",)),
|
||||
@@ -508,6 +539,8 @@ UI: dict[str, dict] = {
|
||||
# they don't work for 32-bit games on the bridge. surface_native: Batman's native video renderer hooks Vulkan
|
||||
# functions OVRPort's dispatcher doesn't know
|
||||
XRSHIM_SETTINGS = ("hide_fb_foveation", "controller_models", "haptic_fix", "surface_native")
|
||||
# shown with an "experimental" pill (Game settings, game page, CLI): not proven in the headset on many games yet
|
||||
EXPERIMENTAL_SETTINGS = ("quest_buttons",)
|
||||
|
||||
|
||||
class AdapterSetting(Patch):
|
||||
@@ -523,6 +556,7 @@ class AdapterSetting(Patch):
|
||||
self.default = default
|
||||
self.revision = REVISIONS.get(key, 1)
|
||||
self.a32 = key not in XRSHIM_SETTINGS
|
||||
self.experimental = key in EXPERIMENTAL_SETTINGS
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
@@ -609,6 +643,8 @@ class AdapterSetting(Patch):
|
||||
"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",
|
||||
"vk_no_fossilize": lambda a: a.engine in ("Unreal", "Other") and "arm64-v8a" in a.abis,
|
||||
"vk_pipeline_fallback": lambda a: a.engine in ("Unreal", "Other") and "arm64-v8a" in a.abis,
|
||||
# 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,
|
||||
@@ -629,10 +665,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]
|
||||
@@ -809,23 +846,33 @@ def foveation_options(params):
|
||||
mode = params.get("mode") or ""
|
||||
if mode not in ("", "tracked", "fixed", "off"):
|
||||
raise ValueError("Unknown foveation mode")
|
||||
radius = float(params.get("radius") or 0)
|
||||
if not math.isfinite(radius) or (radius != 0 and not 0.05 <= radius <= 0.45):
|
||||
raise ValueError("Full-resolution radius must be Default (0), or 0.05–0.45")
|
||||
level = params.get("level", "")
|
||||
if "level" not in params and params.get("radius"):
|
||||
# Old radius exports never reached the layer. Preserve intent with a supported
|
||||
# preset; unknown legacy values restore Default. No numeric FOVE_LEVEL values.
|
||||
try:
|
||||
radius = float(params["radius"])
|
||||
except (ValueError, TypeError):
|
||||
radius = 0
|
||||
if math.isfinite(radius) and 0.05 <= radius <= 0.45:
|
||||
level = min(((.30, "lo"), (.25, "med"), (.20, "hi")),
|
||||
key=lambda pair: abs(pair[0] - radius))[1]
|
||||
if level not in ("", "lo", "med", "hi"):
|
||||
raise ValueError("Unknown foveation profile")
|
||||
test = params.get("test", False)
|
||||
if not isinstance(test, bool):
|
||||
raise ValueError("Foveation test mode must be a boolean")
|
||||
if (test or radius) and mode not in ("tracked", "fixed"):
|
||||
if (test or level) and mode not in ("tracked", "fixed"):
|
||||
raise ValueError("Choose Eye-tracked or Fixed before configuring foveation size or test mode")
|
||||
return mode, radius, test
|
||||
return mode, level, test
|
||||
|
||||
|
||||
class Foveation(Patch):
|
||||
id = "device.foveation"
|
||||
title = "Foveated rendering (Valve)"
|
||||
description = ("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 "
|
||||
"Default keeps the runtime profile unchanged. Low has the largest full-resolution center; "
|
||||
"medium and high use smaller centers and stronger peripheral detail reduction. 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. "
|
||||
@@ -840,13 +887,13 @@ class Foveation(Patch):
|
||||
experimental = True
|
||||
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("level", "str", "", "Runtime profile: lo, med, hi (empty = Default)"),
|
||||
Param("test", "bool", False, "Darken lower-resolution regions (diagnostic)")]
|
||||
CHOICES = (("", "Default"), ("tracked", "Eye-tracked"),
|
||||
("fixed", "Fixed (doesn't follow the eyes)"), ("off", "Off"))
|
||||
RADIUS_CHOICES = (("0", "Default (30% radius)"), ("0.1", "10% radius"), ("0.15", "15% radius"),
|
||||
("0.2", "20% radius"), ("0.25", "25% radius"), ("0.3", "30% radius"),
|
||||
("0.35", "35% radius"), ("0.4", "40% radius"), ("0.45", "45% radius"))
|
||||
# Valve's layer starts at lo when FOVE_LEVEL is unset, so Default is the large area
|
||||
LEVEL_CHOICES = (("", "Default (large)"), ("lo", "Large"), ("med", "Medium (saves more)"),
|
||||
("hi", "Small (saves most)"))
|
||||
|
||||
def apply(self, ctx):
|
||||
from .frame.foveation import apply
|
||||
@@ -858,11 +905,13 @@ class Foveation(Patch):
|
||||
|
||||
def install(self, ctx: InstallContext) -> None:
|
||||
# runs after device.lepton_env (install_context), so this choice wins over a recipe's VK_INSTANCE_LAYERS
|
||||
mode, radius, test = foveation_options(ctx.params)
|
||||
mode, level, test = foveation_options(ctx.params)
|
||||
ctx.env.pop("FOVE_INNER", None)
|
||||
ctx.env.pop("FOVE_OUTER", None)
|
||||
if not mode:
|
||||
return
|
||||
for key in ("FDM_DEBUG", "ENABLE_VULKAN_FDM_INJECTION_LAYER",
|
||||
"LEPTON_ENV_DISABLE_VULKAN_FDM_INJECTION_LAYER", "FOVE_INNER", "FOVE_OUTER",
|
||||
"LEPTON_ENV_DISABLE_VULKAN_FDM_INJECTION_LAYER", "FOVE_INNER", "FOVE_OUTER", "FOVE_LEVEL",
|
||||
"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"):
|
||||
@@ -881,8 +930,8 @@ class Foveation(Patch):
|
||||
ctx.env.update(LEPTON_ENV_FRAMEPORT_CLIMB_STEREO_OFFSETS="1",
|
||||
LEPTON_ENV_FRAMEPORT_VRAPI_FDM_ISOLATE_MONO="1",
|
||||
LEPTON_ENV_FRAMEPORT_VRAPI_KEEP_FOCUSED_IMAGE="1")
|
||||
if radius:
|
||||
ctx.env.update(FOVE_INNER=f"{radius:g}", FOVE_OUTER=f"{2 * radius:g}")
|
||||
if level:
|
||||
ctx.env["FOVE_LEVEL"] = level
|
||||
if test:
|
||||
ctx.env["LEPTON_ENV_FRAMEPORT_FOVEATION_TEST"] = "1"
|
||||
|
||||
@@ -944,16 +993,20 @@ 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_DISABLE_VULKAN_FDM_INJECTION_LAYER", "VK_INSTANCE_LAYERS", "FOVE_INNER", "FOVE_OUTER", "FOVE_LEVEL",
|
||||
"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")}
|
||||
params = entry.get("foveation") if "foveation" in entry else (
|
||||
((entry.get("recipe") or {}).get("patches") or {}).get("device.foveation") or {})
|
||||
pref = {k: v for k, v in params.items() if k in ("mode", "level", "radius", "test")}
|
||||
if "radius" in pref:
|
||||
_, level, _ = foveation_options(pref)
|
||||
pref.pop("radius")
|
||||
pref["level"] = level
|
||||
return pref
|
||||
|
||||
|
||||
def foveation_env(package, params, settings=None, baseline=None):
|
||||
|
||||
@@ -1275,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():
|
||||
@@ -1769,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
|
||||
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ class FramePortApp:
|
||||
def __init__(self, page: ft.Page):
|
||||
from .views.library import load_filters
|
||||
|
||||
pop_dialog_skips_toasts()
|
||||
self.page = page
|
||||
self.target = None # FrameLeptonTarget when connected
|
||||
self.frame_info: dict | None = None
|
||||
@@ -2600,6 +2601,28 @@ class FramePortApp:
|
||||
"it.").format(title=game.get("title") or pkg), error=True,
|
||||
action=tr("Report a problem…"), on_action=lambda e: self.report_problem_dialog(pkg))
|
||||
|
||||
def _sync_save_snapshots(self) -> None:
|
||||
"""Connection/poll hook: disk and SSH work stay off rendering paths."""
|
||||
if getattr(self, "_save_snapshots_busy", False) or self.target is None:
|
||||
return
|
||||
target = self.target
|
||||
self._save_snapshots_busy = True
|
||||
|
||||
def work():
|
||||
from ..install import saves
|
||||
|
||||
try:
|
||||
saves.sync_snapshots(target.frame)
|
||||
self._save_snapshots_error = None
|
||||
except Exception as exc: # noqa: BLE001 - retry on the next connection poll
|
||||
applog.log.exception("syncing automatic save backups failed")
|
||||
if isinstance(exc, saves.SavesError) and str(exc) != getattr(self, "_save_snapshots_error", None):
|
||||
self._save_snapshots_error = str(exc)
|
||||
self.toast(tr("Couldn't sync save backups: {error}").format(error=str(exc)), error=True)
|
||||
finally:
|
||||
self._save_snapshots_busy = False
|
||||
self.run_bg(work)
|
||||
|
||||
def _check_sessions(self, info: dict | None) -> None:
|
||||
"""A FramePort game's play session ended since FramePort last looked (agent v70 `last_play`): fetch and triage
|
||||
its log on a background thread (pipeline.triage_session); the game page shows the result ("Last session")."""
|
||||
@@ -2753,6 +2776,7 @@ class FramePortApp:
|
||||
return False
|
||||
self.frame_info, self.frame_state = info, "connected"
|
||||
self._check_frame_restart(info)
|
||||
self._sync_save_snapshots()
|
||||
self._awake_at = 0.0 # the lock went away with the old connection's Frame session: take it again
|
||||
self.jobs.resume()
|
||||
self.toast(tr("Your Frame is back — continuing"))
|
||||
@@ -2828,6 +2852,7 @@ class FramePortApp:
|
||||
t.label = target.label
|
||||
save_target(target)
|
||||
old, self.target, self.frame_info, self.frame_state = self.target, t, info, "connected"
|
||||
self._sync_save_snapshots()
|
||||
self._check_frame_restart(info)
|
||||
self._remember_drives(t)
|
||||
if old is not None and old is not t and not self.jobs.current():
|
||||
@@ -2893,6 +2918,7 @@ class FramePortApp:
|
||||
changed = info.get("installed") != (self.frame_info or {}).get("installed") or \
|
||||
self.frame_state != "connected"
|
||||
self.frame_info, self.frame_state = info, "connected"
|
||||
self._sync_save_snapshots()
|
||||
self._check_frame_restart(info)
|
||||
self._check_sessions(info)
|
||||
if with_drives:
|
||||
@@ -3153,6 +3179,38 @@ def serialize_flet_updates() -> None:
|
||||
applog.log.warning("Flet's property writes couldn't be serialized (another Flet version?): %s", exc)
|
||||
|
||||
|
||||
def pop_dialog_skips_toasts() -> None:
|
||||
"""Make page.pop_dialog() close the topmost open pop-up, never a toast.
|
||||
|
||||
Flet 1.0 keeps toasts (SnackBars) in the same stack as dialogs and pop_dialog() closes the topmost open entry, so
|
||||
a toast that appeared while a pop-up was open was closed instead of the pop-up: an install link's question stayed
|
||||
on screen after "Download and install" (its job's "waiting for your answer" toast came just after it) while the
|
||||
download ran. Toasts close themselves or with their X. Hooks Flet internals like serialize_flet_updates: skipped
|
||||
(logged) if a Flet version moves them."""
|
||||
try:
|
||||
from flet.controls.base_page import BasePage
|
||||
|
||||
original_pop = BasePage.pop_dialog
|
||||
if getattr(original_pop, "_skips_toasts", False):
|
||||
return
|
||||
|
||||
def pop_dialog(self):
|
||||
try:
|
||||
shown = self._dialogs.controls # noqa: SLF001
|
||||
except AttributeError:
|
||||
return original_pop(self)
|
||||
dialog = next((d for d in reversed(shown) if d.open and not isinstance(d, ft.SnackBar)), None)
|
||||
if dialog is None:
|
||||
return None
|
||||
dialog.open = False
|
||||
dialog.update()
|
||||
return dialog
|
||||
pop_dialog._skips_toasts = True
|
||||
BasePage.pop_dialog = pop_dialog
|
||||
except (ImportError, AttributeError) as exc:
|
||||
applog.log.warning("pop_dialog() couldn't be kept off toasts (another Flet version?): %s", exc)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
import sys
|
||||
|
||||
|
||||
@@ -687,8 +687,11 @@ def card(content: ft.Control, padding: int | None = None, bgcolor: str | None =
|
||||
|
||||
|
||||
def section(heading: str, *controls: ft.Control, subtitle: str | None = None,
|
||||
action: ft.Control | None = None, help: str | None = None) -> ft.Column:
|
||||
head = ft.Row([ft.Column([with_help(h2(heading), help)] + ([meta(subtitle)] if subtitle else []), spacing=T.px(2),
|
||||
action: ft.Control | None = None, help: str | None = None, badge: ft.Control | None = None) -> ft.Column:
|
||||
title = with_help(h2(heading), help)
|
||||
if badge: # e.g. an "experimental" pill, right after the heading
|
||||
title = ft.Row([title, badge], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
head = ft.Row([ft.Column([title] + ([meta(subtitle)] if subtitle else []), spacing=T.px(2),
|
||||
expand=True)]
|
||||
+ ([action] if action else []), vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
return ft.Column([head, *controls], spacing=T.S3)
|
||||
|
||||
@@ -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."))
|
||||
@@ -148,6 +148,10 @@ HELP: dict[str, str] = _Translated({
|
||||
"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 |
@@ -268,9 +268,12 @@ class Updater:
|
||||
|
||||
def _restart_process(self) -> None:
|
||||
"""Source/wheel installs: start a fresh FramePort, then close this one."""
|
||||
# Windows: a hidden console (CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP), as the dev shortcut gives it.
|
||||
# DETACHED_PROCESS left it without one, so every console program it started (ssh, git) opened its own
|
||||
# visible terminal, and the Frame connection's ssh kept one open.
|
||||
subprocess.Popen([sys.executable, "-c", "from frameport.ui.app import main; main()"], close_fds=True,
|
||||
start_new_session=sys.platform != "win32",
|
||||
creationflags=0x00000008 if sys.platform == "win32" else 0)
|
||||
creationflags=0x08000000 | 0x00000200 if sys.platform == "win32" else 0)
|
||||
self._quit()
|
||||
|
||||
def _quit(self) -> None:
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 (
|
||||
@@ -20,12 +21,13 @@ from ...patches.settings import (
|
||||
SETTINGS,
|
||||
UI,
|
||||
Foveation,
|
||||
adapter_settings,
|
||||
foveation_options,
|
||||
foveation_preference,
|
||||
)
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
from ..controllers import diagram, diagram_source
|
||||
from ..help import HELP
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
@@ -57,20 +59,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:
|
||||
@@ -103,7 +108,7 @@ def show_adapter_dialog(app: FramePortApp, package: str) -> None:
|
||||
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),
|
||||
pref.update(level=env.get("FOVE_LEVEL") if env.get("FOVE_LEVEL") in ("lo", "med", "hi") else "",
|
||||
test=env.get("LEPTON_ENV_FRAMEPORT_FOVEATION_TEST") == "1")
|
||||
_open(app, package, None, vals, pref)
|
||||
app.run_bg(load)
|
||||
@@ -111,35 +116,41 @@ def show_adapter_dialog(app: FramePortApp, package: str) -> None:
|
||||
_open(app, package, g, recipe_values(g))
|
||||
|
||||
|
||||
def _open(app: FramePortApp, package: str, g: dict | None, values: dict, foveation_values=None) -> 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"):
|
||||
for name in ("level", "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))
|
||||
foveation_specs = (("mode", Foveation.CHOICES), ("level", Foveation.LEVEL_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}"
|
||||
value = str(foveation.get(key, ""))
|
||||
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))
|
||||
on_select=lambda e, k=key: foveation_change(k, e.control.value))
|
||||
else:
|
||||
control = C.switch(value=bool(foveation.get(key)),
|
||||
on_change=lambda e: foveation_change("test", bool(e.control.value)))
|
||||
@@ -156,7 +167,13 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict, foveati
|
||||
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])
|
||||
@@ -178,7 +195,11 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict, foveati
|
||||
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))
|
||||
|
||||
@@ -187,10 +208,16 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict, foveati
|
||||
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([
|
||||
ft.Column([C.body(label, T.TEXT, weight=ft.FontWeight.W_500), C.meta(help_text, T.TEXT_2)],
|
||||
title = C.body(label, T.TEXT, weight=ft.FontWeight.W_500)
|
||||
if base.get(f"adapter.{key}").experimental:
|
||||
title = ft.Row([title, C.pill(tr("experimental"), T.WARN, tooltip=C.tip(HELP["experimental"]))],
|
||||
spacing=T.S2, wrap=True)
|
||||
line = ft.Row([
|
||||
ft.Column([title, 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]
|
||||
|
||||
@@ -226,19 +253,20 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict, foveati
|
||||
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("level", None)
|
||||
foveation.pop("test", None)
|
||||
foveation_options(foveation)
|
||||
app.page.pop_dialog()
|
||||
if g is not None:
|
||||
library.set_fields(package, foveation=dict(foveation))
|
||||
save_to_recipe(package, values)
|
||||
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"))
|
||||
|
||||
@@ -384,8 +384,9 @@ class FrameView:
|
||||
for _ in range(600): # up to 20 minutes
|
||||
if app._usb_setup is not token or app.route[0] != "frame":
|
||||
return
|
||||
if usb.PC_USB_IP in usb.pc_usb_addresses(usb.FRAME_USB_IP) and server_key(usb.FRAME_USB_IP,
|
||||
timeout=3):
|
||||
# the Frame answering over the cable is what counts: this PC's address on it can be listed
|
||||
# differently (or not at all) by ipconfig, which kept the wait going forever (GitHub #398)
|
||||
if server_key(usb.FRAME_USB_IP, timeout=3):
|
||||
break
|
||||
time.sleep(2)
|
||||
else:
|
||||
|
||||
@@ -169,6 +169,10 @@ class GameView:
|
||||
buttons.append(C.secondary(tr("Add videos"), ft.Icons.VIDEO_LIBRARY_OUTLINED,
|
||||
lambda e: app.go("files", pkg), False,
|
||||
tr("Upload videos into this player's storage on the Frame.")))
|
||||
if g.get("kind") not in ("rift", "linux") and app.frame_state == "connected" and \
|
||||
C.install_state(g, app.frame_info) in ("installed", "outdated"):
|
||||
buttons.append(C.secondary(tr("Back up saves"), ft.Icons.SAVE_ROUNDED,
|
||||
lambda e: app.backup_saves(pkg)))
|
||||
more = C.menu_button(C.menu_items(app.game_actions(pkg, quick=False)), icon=ft.Icons.MORE_HORIZ_ROUNDED,
|
||||
icon_color=T.TEXT_2, tooltip=tr("More actions"))
|
||||
# the same menu, opened from code (Library right-click → "More actions…", app.open_game_menu): a
|
||||
|
||||
@@ -30,6 +30,8 @@ def when(t: float) -> str:
|
||||
def backup_line(b: saves.Backup) -> str:
|
||||
""""12 files · 3.4 MB of saves · version 1.2" (+ large files left out)."""
|
||||
parts = [tr_n("{n} file", "{n} files", b.files), tr("{size} of saves").format(size=fmt_size(b.bytes))]
|
||||
if b.automatic:
|
||||
parts.append(tr("automatic"))
|
||||
if b.version:
|
||||
parts.append(tr("version {version}").format(version=b.version))
|
||||
if b.skipped:
|
||||
|
||||
@@ -14,6 +14,7 @@ from ...recommend import catalog
|
||||
from .. import components as C
|
||||
from .. import glyphs as G
|
||||
from .. import theme as T
|
||||
from ..help import HELP
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
@@ -24,14 +25,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")}
|
||||
|
||||
@@ -172,6 +173,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."""
|
||||
@@ -253,9 +295,40 @@ class SettingsView:
|
||||
options=[ft.DropdownOption(str(n), str(n))
|
||||
for n in sorted({*saves.KEEP_CHOICES, saves.keep_count()})],
|
||||
on_select=changed)
|
||||
return ft.Row([keep, C.help_icon("save_backups"),
|
||||
C.ghost(tr("Open save backups"), ft.Icons.FOLDER_OPEN_ROUNDED, open_root)],
|
||||
spacing=T.S2, wrap=True, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
folder_label = C.meta(str(saves.root_dir()), selectable=True)
|
||||
|
||||
def choose_folder(folder):
|
||||
def work():
|
||||
try:
|
||||
dest = saves.set_folder(folder)
|
||||
folder_label.value = str(dest)
|
||||
C.update(folder_label)
|
||||
except saves.SavesError as exc:
|
||||
self.app.toast(str(exc), error=True)
|
||||
self.app.run_bg(work)
|
||||
|
||||
async def pick_folder(e):
|
||||
folder = await ft.FilePicker().get_directory_path(dialog_title=tr("Choose save backup folder"))
|
||||
if folder:
|
||||
choose_folder(folder)
|
||||
|
||||
def auto_changed(e):
|
||||
library.set_setting("saves.auto", bool(e.control.value))
|
||||
self.app._sync_save_snapshots()
|
||||
|
||||
return ft.Column([
|
||||
ft.Row([keep, C.help_icon("save_backups"),
|
||||
C.ghost(tr("Open save backups"), ft.Icons.FOLDER_OPEN_ROUNDED, open_root)],
|
||||
spacing=T.S2, wrap=True),
|
||||
folder_label,
|
||||
ft.Row([C.secondary(tr("Choose backup folder…"), ft.Icons.FOLDER_OPEN_ROUNDED, pick_folder),
|
||||
C.ghost(tr("Use the default"), ft.Icons.RESTORE_ROUNDED, lambda e: choose_folder(None))],
|
||||
spacing=T.S2, wrap=True),
|
||||
C.meta(tr("Existing backups stay in their original folder and remain available.")),
|
||||
C.switch(tr("Back up saves when a game closes"), value=bool(library.setting("saves.auto", False)),
|
||||
on_change=auto_changed),
|
||||
C.meta(tr("Keeps three snapshots per game on the Frame and copies new ones here when connected.")),
|
||||
], spacing=T.S2)
|
||||
|
||||
def ssh_keys(self) -> ft.Control:
|
||||
"""Settings → Installing: private key files FramePort logs in with besides its own (frame.connection
|
||||
@@ -653,6 +726,9 @@ 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",
|
||||
badge=C.pill(tr("experimental"), T.WARN, tooltip=C.tip(HELP["experimental"]))),
|
||||
"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,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
#define _GNU_SOURCE
|
||||
#include <assert.h>
|
||||
#include <errno.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <sys/mman.h>
|
||||
static _Alignas(4096) unsigned char entry[4096],arena[4096];
|
||||
static unsigned maps,unmaps,protects,logs,fail_at;
|
||||
static int outside,missing,restore_fails;
|
||||
static uintptr_t hints[3];
|
||||
static char last_log[1024];
|
||||
static void *slot;
|
||||
static const uint32_t expected[4]={0xd10243ff,0xa9065ffe,0xa90757f6,0xa9084ff4};
|
||||
static void *fake_mmap(void *hint,size_t size,int prot,int flags,int fd,off_t offset) {
|
||||
(void)size;(void)prot;(void)fd;(void)offset;
|
||||
assert(flags & 0x100000);
|
||||
if(!missing)assert(hint==arena);
|
||||
assert(maps<3);hints[maps]=(uintptr_t)hint;
|
||||
maps++;
|
||||
if(missing){errno=maps==1?EEXIST:maps==2?EINVAL:ENOMEM;return MAP_FAILED;}
|
||||
return outside?(void*)((uintptr_t)entry+0x10000000):arena;
|
||||
}
|
||||
static int fake_munmap(void *p,size_t size) {
|
||||
(void)p;assert(size==4096);unmaps++;return 0;
|
||||
}
|
||||
static int fake_mprotect(void *p,size_t size,int prot) {
|
||||
assert(size==4096);protects++;
|
||||
if(protects==1) {
|
||||
assert(p==arena && prot==(PROT_READ|PROT_EXEC));
|
||||
assert(!memcmp(entry,expected,16));
|
||||
}
|
||||
if(protects==3) {
|
||||
assert(p==entry && prot==(PROT_READ|PROT_EXEC));
|
||||
assert(slot==arena+16); // original is available when entry goes live
|
||||
assert(*(uint32_t*)entry!=expected[0]);
|
||||
assert(!memcmp(entry+4,expected+1,12));
|
||||
}
|
||||
if(protects==4)assert(!memcmp(entry,expected,16));
|
||||
if(protects==fail_at || (restore_fails && protects>=3)){errno=EACCES;return -1;}
|
||||
return 0;
|
||||
}
|
||||
static FILE *fake_fopen(const char *path,const char *mode) {
|
||||
(void)path;(void)mode;
|
||||
FILE *f=tmpfile();assert(f);
|
||||
fprintf(f,"0-%llx r-xp\n%llx-ffffffffffffffff r-xp\n",
|
||||
(unsigned long long)(uintptr_t)arena,
|
||||
(unsigned long long)((uintptr_t)arena+(missing?12288:4096)));
|
||||
rewind(f);return f;
|
||||
}
|
||||
static void test_log(const char *format,...) {
|
||||
char message[1024];va_list ap;va_start(ap,format);
|
||||
vsnprintf(message,sizeof(message),format,ap);va_end(ap);
|
||||
assert(strstr(message,"target="));strcpy(last_log,message);logs++;
|
||||
}
|
||||
#define fopen fake_fopen
|
||||
#define mmap fake_mmap
|
||||
#define munmap fake_munmap
|
||||
#define mprotect fake_mprotect
|
||||
#define LOG(...) test_log(__VA_ARGS__)
|
||||
#include "audio_detour.h"
|
||||
static void reset(void) {
|
||||
maps=unmaps=protects=0;fail_at=0;outside=missing=restore_fails=0;slot=NULL;
|
||||
memcpy(entry,expected,16);memset(arena,0,sizeof(arena));mx_audio_page=4096;
|
||||
}
|
||||
static void gap_test(const char *text,uintptr_t target,size_t expected_count,uintptr_t first) {
|
||||
FILE *f=tmpfile();assert(f);fputs(text,f);rewind(f);
|
||||
struct mx_audio_candidate *c=NULL;size_t n=0;
|
||||
const char *failure=NULL;
|
||||
assert(mx_audio_candidates(f,target,&c,&n,&failure));fclose(f);
|
||||
assert(n==expected_count);if(n)assert(c[0].address==first);
|
||||
for(size_t i=0;i<n;i++){uint32_t w;assert(mx_audio_branch(target,c[i].address,&w));assert(mx_audio_branch(c[i].address+20,target+4,&w));}
|
||||
free(c);
|
||||
}
|
||||
int main(void) {
|
||||
mx_audio_page=4096;
|
||||
gap_test("0-10000000 r-xp\n10003000-ffffffffffffffff r-xp\n",0x10001000,3,0x10001000);
|
||||
gap_test("0-10000000 r-xp\n10001000-10002000 r-xp\n10002000-ffffffffffffffff r-xp\n",0x10002000,1,0x10000000);
|
||||
gap_test("0-20000000 r-xp\n20001000-ffffffffffffffff r-xp\n",0x10000000,0,0);
|
||||
gap_test("0-10000000 r-xp\n10000000-ffffffffffffffff r-xp\n",0x10000000,0,0);
|
||||
|
||||
gap_test("0-18000000 r-xp\n18001000-ffffffffffffffff r-xp\n",0x10000004,0,0);
|
||||
uint32_t b;
|
||||
assert(mx_audio_branch(0x10000000,0x10000000,&b) && b==0x14000000);
|
||||
assert(mx_audio_branch(0x10000000,0x17fffffc,&b) && b==0x15ffffff);
|
||||
assert(mx_audio_branch(0x10000000,0x08000000,&b) && b==0x16000000);
|
||||
assert(mx_audio_branch(0x10000004,0x10000000,&b) && b==0x17ffffff);
|
||||
assert(!mx_audio_branch(0x10000000,0x18000000,&b));
|
||||
assert(!mx_audio_branch(0x10000004,0x08000000,&b));
|
||||
assert(!mx_audio_branch(1,4,&b) && !mx_audio_branch(4,1,&b));
|
||||
reset();
|
||||
assert(mx_audio_detour(entry,(void*)0x123456789abcdef0ULL,expected,&slot)==arena+16);
|
||||
uint32_t jump[2]={0x58000050,0xd61f0200};uintptr_t destination;
|
||||
assert(!memcmp(arena,jump,8));memcpy(&destination,arena+8,8);
|
||||
assert(destination==0x123456789abcdef0ULL);
|
||||
assert(*(uint32_t*)(arena+16)==expected[0]);
|
||||
assert(mx_audio_branch((uintptr_t)arena+20,(uintptr_t)entry+4,&b));
|
||||
assert(*(uint32_t*)(arena+20)==b && maps==1 && !unmaps && protects==3);
|
||||
for(unsigned failure=1;failure<=3;failure++) {
|
||||
reset();fail_at=failure;
|
||||
assert(!mx_audio_detour(entry,(void*)16,expected,&slot));
|
||||
assert(strstr(last_log,failure==1?"mprotect stub RX":failure==2?"mprotect entry RWX":"mprotect entry RX"));
|
||||
assert(strstr(last_log,"errno=13"));
|
||||
assert(!memcmp(entry,expected,16));
|
||||
assert(unmaps==(failure<3?1u:0u));
|
||||
assert(slot==(failure<3?NULL:arena+16));
|
||||
assert(protects==(failure==3?4:failure));
|
||||
}
|
||||
reset();restore_fails=1;
|
||||
assert(!mx_audio_detour(entry,(void*)16,expected,&slot));
|
||||
assert(!memcmp(entry,expected,16) && !unmaps && protects==4 && slot==arena+16);
|
||||
reset();outside=1;
|
||||
assert(!mx_audio_detour(entry,(void*)16,expected,&slot));
|
||||
assert(maps==1 && unmaps==maps && !protects && !memcmp(entry,expected,16));
|
||||
reset();missing=1;
|
||||
assert(!mx_audio_detour(entry,(void*)16,expected,&slot));
|
||||
assert(maps==3 && !unmaps && !protects && !memcmp(entry,expected,16));
|
||||
for(unsigned i=1;i<3;i++) {
|
||||
uintptr_t t=(uintptr_t)entry;
|
||||
uintptr_t a=hints[i-1]>t?hints[i-1]-t:t-hints[i-1];
|
||||
uintptr_t b=hints[i]>t?hints[i]-t:t-hints[i];
|
||||
assert(a<=b && hints[i]!=hints[i-1]);
|
||||
}
|
||||
assert(strstr(last_log,"errno=12 attempts=3"));
|
||||
reset();entry[4]^=1;
|
||||
assert(!mx_audio_detour(entry,(void*)16,expected,&slot) && !maps);
|
||||
assert(strstr(last_log,"16-byte guard mismatch"));
|
||||
reset();uint32_t bad[4]={0x10000000,0,0,0};memcpy(entry,bad,16);
|
||||
assert(!mx_audio_detour(entry,(void*)16,bad,&slot) && !maps);
|
||||
assert(strstr(last_log,"unsupported replay instruction"));
|
||||
assert(logs==8);
|
||||
puts("PASS: maps gaps, outward NOREPLACE retries, branch limits, stub/trampoline, publication order, failure diagnostics/cleanup/rollback, guards");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
#define _GNU_SOURCE
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <time.h>
|
||||
#include <link.h>
|
||||
#include <string.h>
|
||||
#define LOG(...) ((void)0)
|
||||
static uintptr_t mx_audio_base;
|
||||
static unsigned detours,scans;
|
||||
static void *mx_audio_detour(unsigned char *address,void *replacement,const uint32_t expected[4],void *slot) {
|
||||
(void)slot;assert(replacement && !memcmp(address,expected,16));detours++;return (void*)1;
|
||||
}
|
||||
static int fake_iterate(int (*callback)(struct dl_phdr_info*,size_t,void*),void *data) {
|
||||
(void)callback;(void)data;scans++;return 0;
|
||||
}
|
||||
#define dl_iterate_phdr fake_iterate
|
||||
// Exercise the AArch64-only installer as C without executing ARM instructions.
|
||||
#ifndef __aarch64__
|
||||
#define __aarch64__ 1
|
||||
#endif
|
||||
#include "batman_audio_geometry.h"
|
||||
int main(void) {
|
||||
mx_batman_geometry_initialize();assert(!scans && !detours); // unverified Meta SDK
|
||||
mx_audio_base=1;
|
||||
mx_batman_geometry_initialize();assert(!scans && !detours && mx_geometry_checked); // another package
|
||||
unsigned char *base=calloc(1,0x2e63a28+32);assert(base);
|
||||
Elf64_Phdr phdr={.p_type=PT_NOTE,.p_vaddr=256,.p_memsz=36};
|
||||
struct dl_phdr_info info={.dlpi_addr=(uintptr_t)base,.dlpi_name="/lib/libil2cpp.so",
|
||||
.dlpi_phdr=&phdr,.dlpi_phnum=1};
|
||||
Elf64_Nhdr note={.n_namesz=4,.n_descsz=20,.n_type=NT_GNU_BUILD_ID};
|
||||
memcpy(base+256,¬e,sizeof(note));memcpy(base+268,"GNU",4);
|
||||
memcpy(base+272,mx_geometry_il2cpp_id,20);
|
||||
memcpy(base+0x2e61bb4,mx_geometry_queue_entry,16);
|
||||
memcpy(base+0x2e63a28,mx_geometry_worker_entry,16);
|
||||
base[272]^=1;mx_geometry_find(&info,sizeof(info),NULL);assert(!detours); // other build
|
||||
base[272]^=1;base[0x2e63a28]^=1;
|
||||
mx_geometry_find(&info,sizeof(info),NULL);assert(!detours); // changed worker
|
||||
base[0x2e63a28]^=1;base[0x2e61bb4]^=1;
|
||||
mx_geometry_find(&info,sizeof(info),NULL);assert(!detours); // changed queue
|
||||
base[0x2e61bb4]^=1;
|
||||
mx_geometry_find(&info,sizeof(info),NULL);assert(detours==2); // only the exact pair is hooked
|
||||
free(base);
|
||||
puts("PASS: other package/SDK/build/entries skipped; matching binary hooks both boundaries");
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <pthread.h>
|
||||
#define LOG(...) ((void)0)
|
||||
static int fail_allocation;
|
||||
static _Atomic unsigned allocations,releases;
|
||||
static void *test_alloc(size_t size) {
|
||||
if(fail_allocation)return NULL;
|
||||
allocations++;return malloc(size);
|
||||
}
|
||||
static void test_free(void *p) {releases++;free(p);}
|
||||
#define malloc test_alloc
|
||||
#define free test_free
|
||||
#include "audio_geometry_owned.h"
|
||||
#undef malloc
|
||||
#undef free
|
||||
#include "batman_audio_geometry_target.h"
|
||||
|
||||
typedef struct {
|
||||
unsigned char header[16];
|
||||
const void *data;
|
||||
uint64_t length;
|
||||
int immediate;
|
||||
const void *source;
|
||||
} Closure;
|
||||
static void original_worker(void *context,void *method) {
|
||||
Closure *closure=context;
|
||||
assert(method==(void*)42 && closure->length==UINT64_C(0x123400000040));
|
||||
assert(closure->data!=closure->source);
|
||||
for(unsigned i=0;i<64;i++)assert(((const unsigned char*)closure->data)[i]==(unsigned char)i);
|
||||
}
|
||||
static void original_queue(void *object,const void *data,uint64_t length,void *method) {
|
||||
Closure *closure=object;
|
||||
assert(method==(void*)42);
|
||||
closure->data=data;closure->length=length;
|
||||
if(closure->immediate)mx_geometry_worker(closure,method);
|
||||
}
|
||||
static void *concurrent_load(void *unused) {
|
||||
(void)unused;
|
||||
for(unsigned n=0;n<100;n++) {
|
||||
unsigned char source[64];for(unsigned i=0;i<64;i++)source[i]=(unsigned char)i;
|
||||
Closure closure={.immediate=1,.source=source};
|
||||
mx_geometry_queue(&closure,source,UINT64_C(0x123400000040),(void*)42);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
int main(void) {
|
||||
mx_geometry_queue_original=original_queue;mx_geometry_worker_original=original_worker;
|
||||
unsigned char source[64];for(unsigned i=0;i<64;i++)source[i]=(unsigned char)i;
|
||||
Closure closure={.source=source};
|
||||
mx_geometry_queue(&closure,source,UINT64_C(0x123400000040),(void*)42);
|
||||
assert(mx_geometry_owned_head && closure.data!=source && allocations==1 && releases==0);
|
||||
memset(source,0xff,sizeof(source)); // download handler's source goes away before the task runs
|
||||
mx_geometry_worker(&closure,(void*)42);
|
||||
assert(!mx_geometry_owned_head && allocations==releases);
|
||||
for(unsigned i=0;i<64;i++)source[i]=(unsigned char)i;
|
||||
closure.immediate=1;
|
||||
mx_geometry_queue(&closure,source,UINT64_C(0x123400000040),(void*)42);
|
||||
assert(!mx_geometry_owned_head && allocations==releases); // task finishes before queue() returns
|
||||
fail_allocation=1;closure.immediate=0;
|
||||
mx_geometry_queue(&closure,source,64,(void*)42);
|
||||
assert(closure.data==source && !mx_geometry_owned_head); // preserve original call on allocation failure
|
||||
fail_allocation=0;
|
||||
mx_geometry_queue(&closure,NULL,0,(void*)42);
|
||||
assert(!closure.data && !mx_geometry_owned_head);
|
||||
mx_geometry_queue(&closure,source,UINT32_MAX,(void*)42); // negative NativeArray length: unchanged
|
||||
assert(closure.data==source && !mx_geometry_owned_head);
|
||||
pthread_t threads[4];
|
||||
for(unsigned i=0;i<4;i++)assert(!pthread_create(&threads[i],NULL,concurrent_load,NULL));
|
||||
for(unsigned i=0;i<4;i++)assert(!pthread_join(threads[i],NULL));
|
||||
assert(!mx_geometry_owned_head && mx_geometry_queued==mx_geometry_finished);
|
||||
assert(mx_geometry_matches_id(mx_geometry_il2cpp_id,20));
|
||||
unsigned char wrong_id[20];memcpy(wrong_id,mx_geometry_il2cpp_id,20);wrong_id[0]^=1;
|
||||
assert(!mx_geometry_matches_id(wrong_id,20) && !mx_geometry_matches_id(wrong_id,19));
|
||||
assert(mx_geometry_matches_entries(mx_geometry_queue_entry,mx_geometry_worker_entry));
|
||||
uint32_t wrong_entry[4];memcpy(wrong_entry,mx_geometry_queue_entry,16);wrong_entry[3]^=1;
|
||||
assert(!mx_geometry_matches_entries(wrong_entry,mx_geometry_worker_entry));
|
||||
assert(!mx_geometry_matches_entries(mx_geometry_queue_entry,wrong_entry));
|
||||
puts("PASS: deferred/inline/concurrent ownership, cleanup, ABI arguments, fallback and target guards");
|
||||
}
|
||||
@@ -1596,7 +1596,7 @@ def test_second_launch_while_starting_is_ignored(monkeypatch, tmp_path):
|
||||
text = a.LAUNCH_SH.format(title="T", pkg="com.x.y", app_dir=f"app_dir={base}", appid=1, lepton_q=str(lepton),
|
||||
extra_env="", cloud_dirs="true",
|
||||
watchdog=a.WATCHDOG, dashboard="true", logcat="true", single=a.SINGLE_LINE,
|
||||
plays_start="true", plays_end="true", video_codec="",
|
||||
plays_start="true", plays_end="true", save_snapshot="true", video_codec="",
|
||||
restart=a.restart_block("com.x.y", str(lepton)))
|
||||
launcher = tmp_path / "launch.sh"
|
||||
launcher.write_text(text)
|
||||
@@ -2087,7 +2087,8 @@ def test_launcher_starts_lepton_again_once_when_the_game_restarts_itself(monkeyp
|
||||
appid=2821789967, lepton_q=str(lepton), extra_env="", cloud_dirs="true",
|
||||
watchdog=a.WATCHDOG, dashboard="true",
|
||||
logcat="true", single=a.SINGLE_LINE, plays_start="true", plays_end="true",
|
||||
video_codec="", restart=a.restart_block("com.Armature.SportsScramble", str(lepton)))
|
||||
save_snapshot="true", video_codec="",
|
||||
restart=a.restart_block("com.Armature.SportsScramble", str(lepton)))
|
||||
text = text.replace("python3 " + os.path.abspath(a.__file__) + " _dashboard_worker", "true")
|
||||
launcher = tmp_path / "launch.sh"
|
||||
launcher.write_text(text)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Batman audio ownership and the scope of the adapter update."""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", ["audio_geometry_test.c", "audio_geometry_target_test.c", "audio_detour_test.c"])
|
||||
def test_audio_geometry_native(tmp_path, fixture):
|
||||
cc = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
|
||||
if not cc or os.name == "nt":
|
||||
pytest.skip("Native ownership regression requires a POSIX C compiler with pthreads")
|
||||
if fixture == "audio_geometry_target_test.c" and sys.platform != "linux":
|
||||
pytest.skip("Installer regression models Linux ELF loading")
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
binary = tmp_path / "audio-geometry-test"
|
||||
subprocess.run([cc, "-std=c11", "-Wall", "-Wextra", "-Werror", "-pthread",
|
||||
"-I", str(root / "native/adapter"),
|
||||
str(root / "tests/fixtures/src" / fixture), "-o", str(binary)], check=True)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
|
||||
|
||||
def test_audio_geometry_update_outdates_batman_only(monkeypatch):
|
||||
from frameport.patches.base import get, recipe_fingerprint
|
||||
|
||||
patch = get("frame.adapter")
|
||||
recipe = {"patches": {"frame.adapter": {}}}
|
||||
packages = ("com.camouflaj.manta", "com.Armature.VR4", "com.crytek.climb2", "com.example.other")
|
||||
current = {p: recipe_fingerprint(recipe, p) for p in packages}
|
||||
assert patch.revision_for(packages[0]) == 3
|
||||
assert all(patch.revision_for(p) == 2 for p in packages[1:])
|
||||
monkeypatch.setattr(patch, "package_revisions", {})
|
||||
assert current[packages[0]] != recipe_fingerprint(recipe, packages[0])
|
||||
assert all(current[p] == recipe_fingerprint(recipe, p) for p in packages[1:])
|
||||
|
||||
|
||||
def test_audio_geometry_allocation_triage_is_specific_to_batman():
|
||||
from frameport.validate.triage import triage
|
||||
|
||||
log = ("package com.camouflaj.manta\n"
|
||||
"E Unity : Could not allocate memory: System out of memory!\n"
|
||||
"E Unity : Trying to allocate: 26388238103B with 16 alignment. MemoryLabel: NativePlugin\n"
|
||||
"E Unity : Allocation happened at: MetaXRAudioUnityMemoryAllocator.cpp\n"
|
||||
"F libc : Fatal signal 5 (SIGTRAP)\n")
|
||||
result = triage(log, "EXITED")
|
||||
finding = next(f for f in result.findings if f.id == "batman-audio-geometry-allocation")
|
||||
assert finding.severity == "fatal" and finding.suggest == ["frame.adapter"]
|
||||
assert "native-crash" not in {f.id for f in result.findings}
|
||||
for ordinary in (log.replace("com.camouflaj.manta", "com.example.other"),
|
||||
log.replace("26388238103B", "4096B"),
|
||||
log.replace("MetaXRAudioUnityMemoryAllocator.cpp", "OtherAllocator.cpp")):
|
||||
assert "batman-audio-geometry-allocation" not in {f.id for f in triage(ordinary, "EXITED").findings}
|
||||
@@ -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)
|
||||
@@ -34,6 +34,21 @@ def test_link_forms_name_the_manifest(link):
|
||||
assert deeplink.parse(link).manifest_url == MANIFEST
|
||||
|
||||
|
||||
def test_github_file_pages_become_raw_addresses():
|
||||
raw = "https://raw.githubusercontent.com/o/r/main/dir/game.json"
|
||||
for page in ("https://github.com/o/r/blob/main/dir/game.json", "https://github.com/o/r/raw/main/dir/game.json"):
|
||||
assert deeplink.raw_github_url(page) == raw
|
||||
assert deeplink.parse(page).manifest_url == raw
|
||||
assert deeplink.parse(f"https://frameport.app/install?manifest={quote(page, safe='')}").manifest_url == raw
|
||||
apk = "https://github.com/o/r/blob/v2/Game.apk"
|
||||
assert deeplink.parse(f"frameport://install?url={quote(apk, safe='')}").file_url == \
|
||||
"https://raw.githubusercontent.com/o/r/v2/Game.apk"
|
||||
# release downloads, raw addresses and other hosts stay as they are
|
||||
for other in ("https://github.com/o/r/releases/latest/download/game.json", raw,
|
||||
"https://example.com/o/r/blob/main/game.json"):
|
||||
assert deeplink.raw_github_url(other) == other
|
||||
|
||||
|
||||
def test_direct_file_links():
|
||||
url = "https://github.com/x/y/releases/download/v1/Game-arm64.apk"
|
||||
assert deeplink.parse(f"framedrop://install?url={quote(url, safe='')}").file_url == url
|
||||
|
||||
@@ -45,8 +45,8 @@ def test_injection_keeps_swapchains_ordinary():
|
||||
def test_custom_size_and_fixed_mode_and_reset():
|
||||
assert context({"mode": "tracked", "test": True}).env["LEPTON_ENV_FRAMEPORT_FOVEATION_TEST"] == "1"
|
||||
assert "LEPTON_ENV_FRAMEPORT_FOVEATION_TEST" not in context({"mode": "tracked"}).env
|
||||
assert context({"mode": "tracked", "radius": 0.35}).env["FOVE_INNER"] == "0.35"
|
||||
assert context({"mode": "tracked", "radius": 0.35}).env["FOVE_OUTER"] == "0.7"
|
||||
assert context({"mode": "tracked", "radius": 0.35}).env["FOVE_LEVEL"] == "lo"
|
||||
assert context({"mode": "tracked", "level": "hi"}).env["FOVE_LEVEL"] == "hi"
|
||||
assert context({"mode": "fixed"}).env["FDM_DEBUG"] == "enable,disable_offsets"
|
||||
assert context({"mode": "off"}).env == {
|
||||
"VK_INSTANCE_LAYERS": "", "LEPTON_ENV_DISABLE_VULKAN_FDM_INJECTION_LAYER": "1"}
|
||||
@@ -57,9 +57,8 @@ def test_custom_size_and_fixed_mode_and_reset():
|
||||
|
||||
@pytest.mark.parametrize("params", [
|
||||
{"mode": "bad"}, {"mode": "off", "test": True}, {"mode": "", "radius": 0.2},
|
||||
{"mode": "tracked", "radius": float("nan")}, {"mode": "tracked", "radius": float("inf")},
|
||||
{"mode": "tracked", "radius": -1}, {"mode": "tracked", "radius": 0.01},
|
||||
{"mode": "tracked", "radius": 0.5}, {"mode": "tracked", "test": "false"}])
|
||||
{"mode": "tracked", "level": "1"}, {"mode": "tracked", "level": 2},
|
||||
{"mode": "off", "level": "lo"}, {"mode": "tracked", "test": "false"}])
|
||||
def test_invalid_configuration_refused(params):
|
||||
with pytest.raises(ValueError):
|
||||
foveation_options(params)
|
||||
|
||||
@@ -87,10 +87,11 @@ def test_catalog_defaults_survive_and_explicit_choice_wins():
|
||||
|
||||
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["FOVE_LEVEL"] == "hi"
|
||||
assert env["FOVE_INNER"] is None and env["FOVE_OUTER"] is None
|
||||
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
|
||||
assert off["FOVE_LEVEL"] is None and off["LEPTON_ENV_FRAMEPORT_FOVEATION_TEST"] is None
|
||||
|
||||
|
||||
def test_resolution_label_explains_actual_effect():
|
||||
@@ -129,8 +130,9 @@ def test_game_dialog_saves_personal_choice_without_touching_recipe(monkeypatch):
|
||||
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")))
|
||||
any(o.key == "med" for o in c.options))
|
||||
assert [(o.key, o.text) for o in radius.options] == list(ad.Foveation.LEVEL_CHOICES)
|
||||
radius.on_select(SimpleNamespace(control=SimpleNamespace(value="med")))
|
||||
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))
|
||||
@@ -145,7 +147,7 @@ def test_game_dialog_saves_personal_choice_without_touching_recipe(monkeypatch):
|
||||
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["foveation"] == {"mode": "fixed", "level": "med", "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
|
||||
@@ -160,3 +162,42 @@ 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": {}})]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("radius,level", [(0, ""), (.1, "hi"), (.2, "hi"), (.25, "med"),
|
||||
(.3, "lo"), (.45, "lo"), ("unknown", ""), (float("nan"), ""), (.9, "")])
|
||||
def test_legacy_radius_read_migrates_without_mutating_storage(radius, level):
|
||||
entry = {"foveation": {"mode": "tracked", "radius": radius}}
|
||||
before = dict(entry["foveation"])
|
||||
assert foveation_preference(entry) == {"mode": "tracked", "level": level}
|
||||
assert entry["foveation"] == before
|
||||
env = foveation_env("test.game", entry["foveation"])
|
||||
assert env["FOVE_LEVEL"] == (level or None)
|
||||
assert env["FOVE_INNER"] is None and env["FOVE_OUTER"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("level", ["", "lo", "med", "hi"])
|
||||
def test_supported_level_exports_and_explicit_level_wins(level):
|
||||
env = foveation_env("test.game", {"mode": "tracked", "level": level, "radius": .2})
|
||||
assert env["FOVE_LEVEL"] == (level or None)
|
||||
assert env["FDM_DEBUG"] == "enable"
|
||||
|
||||
|
||||
def test_default_removes_obsolete_radius_exports_and_legacy_recipe_reads():
|
||||
env = foveation_env("test.game", {"mode": ""}, baseline={"FOVE_INNER": ".4", "FOVE_OUTER": ".8"})
|
||||
assert env["FOVE_INNER"] is None and env["FOVE_OUTER"] is None
|
||||
entry = {"recipe": {"patches": {"device.foveation": {"mode": "fixed", "radius": .25}}}}
|
||||
assert foveation_preference(entry) == {"mode": "fixed", "level": "med"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("level,expected", [("hi", "hi"), ("1", ""), (None, "")])
|
||||
def test_installed_only_dialog_reads_supported_level(monkeypatch, level, expected):
|
||||
from frameport.ui.views import adapter_dialog as ad
|
||||
monkeypatch.setattr(library, "game", lambda package: None)
|
||||
received = []
|
||||
monkeypatch.setattr(ad, "_open", lambda *args: received.append(args[-1]))
|
||||
target = SimpleNamespace(set_settings=lambda *args: {"settings": {}, "foveation_env": {
|
||||
"ENABLE_VULKAN_FDM_INJECTION_LAYER": "1", "FDM_DEBUG": "enable", "FOVE_LEVEL": level}})
|
||||
app = SimpleNamespace(target=target, run_bg=lambda fn: fn())
|
||||
ad.show_adapter_dialog(app, "test.game")
|
||||
assert received == [{"mode": "tracked", "level": expected, "test": False}]
|
||||
@@ -189,6 +189,22 @@ def test_dispatcher_rejects_unknown_and_partial_matches():
|
||||
assert patch_dispatcher(b'unknown runtime', 'quest2') == b'unknown runtime'
|
||||
|
||||
|
||||
def test_unknown_dispatcher_keeps_quest2_instead_of_failing_the_build():
|
||||
"""Quest 3 is the default for every Quest game: an APK converted with another OVRPort runtime (found with an
|
||||
already converted Batman copy) must still build, keeping OVRPort's Quest 2 answers."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from frameport.patches import base
|
||||
|
||||
base.load_all()
|
||||
put = []
|
||||
ws = SimpleNamespace(lib=lambda n: n, has=lambda p: True, read_lib=lambda n: b"another runtime",
|
||||
put=lambda p, d: put.append(p))
|
||||
ctx = SimpleNamespace(analysis=SimpleNamespace(vr_kind="quest", extra={}), params={}, ws=ws, checks=[], notes=[])
|
||||
assert base.get("device.headset_model").apply(ctx) is False
|
||||
assert not put and ctx.checks and "Quest 2" in ctx.checks[0][2]
|
||||
|
||||
|
||||
def test_real_dispatcher_when_available():
|
||||
import os
|
||||
|
||||
|
||||
@@ -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)",
|
||||
]
|
||||
|
||||