mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-16 23:01:35 +02:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aece86d6c | ||
|
|
5d4821195a | ||
|
|
c801c606b5 | ||
|
|
88de3e29c8 | ||
|
|
1785ae99be | ||
|
|
2299395438 | ||
|
|
08d2e4be97 | ||
|
|
95da758408 | ||
|
|
608c372a9e | ||
|
|
fd6def67ec | ||
|
|
78ad6b5397 | ||
|
|
5b5988a22e | ||
|
|
5fe7eef50b | ||
|
|
defd87239e | ||
|
|
48a9042e92 | ||
|
|
bd874fed13 | ||
|
|
00e71f0ec5 | ||
|
|
4e97a01738 | ||
|
|
eee93abc50 | ||
|
|
e8f739e3c0 | ||
|
|
132c972952 | ||
|
|
72b78f7d7e | ||
|
|
b31874271a | ||
|
|
8dfbab5886 | ||
|
|
8a33d1a4be | ||
|
|
46c6d24607 | ||
|
|
5fae6776b0 | ||
|
|
8d3b782ae5 | ||
|
|
f8672ff1b4 | ||
|
|
5841aa803b | ||
|
|
07e3958381 | ||
|
|
2cf1ea8911 | ||
|
|
9fd95c34e9 | ||
|
|
79a5af50fa | ||
|
|
e6f1041a26 | ||
|
|
420a51feed | ||
|
|
3867ecc71a | ||
|
|
16ac8a0979 | ||
|
|
4f63eb0702 | ||
|
|
0022c903fc | ||
|
|
2b0489d440 | ||
|
|
a77f90311d | ||
|
|
d2885ea26a | ||
|
|
0b88e509ae | ||
|
|
fc3824389a | ||
|
|
02aef886fa |
No files matched your search
@@ -19,8 +19,6 @@ CLAUDE.local.md
|
||||
# outputs of `frameport report` / `parity` / ruff
|
||||
REPORT.md
|
||||
.ruff_cache/
|
||||
.eval-home/
|
||||
.eval-results/
|
||||
.claude/worktrees/
|
||||
|
||||
# project page (site/)
|
||||
|
||||
@@ -146,6 +146,23 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
`launcher` field, else text swap), list_installed `drive`/`drive_missing` (install_state keeps such games
|
||||
"installed"). PC: `install/drives.py`, library setting `install.drive` (Frame page → Storage), game menu
|
||||
"Move to…" (job kind tool-frame), CLI `frame drives`/`frame move`/`install --dest`. Untested on the device.
|
||||
- **Quest games on this PC via AXRB** (experimental, branch `worktree-pc-android-axrb`, docs/PC_ANDROID.md): the
|
||||
"This PC" target (`PcReviveTarget`) installs APKs through `targets/pc_android.py` + `tools/axrb.py`. AXRB
|
||||
(Android-XR-Bridge/AXRB, Windows: x86_64 Android 16 emulator + libndk_translation + its own OpenXR runtime →
|
||||
`axrb-host-bridge.exe` → SteamVR) is downloaded only on first use (`AXRB-Setup-<v>.exe /S`, SHA256SUMS, per user
|
||||
→ `%LOCALAPPDATA%\Programs\axrb-launcher`), never bundled/modified (host code: AXRB Source-Available License).
|
||||
Its runtime setup lives only in AXRB's Electron JS, so FramePort repeats it (pins read from the installed
|
||||
`app.asar` `core/components.json`, AVD `axrb-managed-api36` port 5584, adb `-P 5038`, receipts `ready.json`/
|
||||
`license-acceptance.json`, so AXRB's own launcher shares it). Spike 2026-10-10 (headless): **unconverted APKs
|
||||
abort** (Meta platform loader) → PC build = OVRPort patches + `Patch.on_pc` APK fixes (`base.pc_selection`,
|
||||
`build(pc=True)`, library `build_pc`, `pc_recipe_fingerprint`); Lucky's Tale ran a full OpenXR session in AXRB's
|
||||
runtime; Pinball FX VR crashed in gfxstream vkAllocateMemory (AXRB's layer). SteamVR can't stand in for a headset
|
||||
(null driver: no D3D11 adapter; vrlink without a Frame: no instance) → frames need the owner. Play = Steam
|
||||
shortcut `powershell.exe -File %LOCALAPPDATA%\FramePort\axrb\fp-axrb-run.ps1 -Package …` → AXRB's
|
||||
`run_windows_game.ps1`; all such shortcuts share Exe, so agent v71 `upsert/remove_shortcut(options_key=
|
||||
"-Package <pkg> ")`. AXRB scripts start through WMI (`axrb.spawn_detached`): QEMU/adb inherit any handle, and
|
||||
even /dev/null through WSL interop kept the call hanging; PowerShell 5.1 `*>>` logs are UTF-16 (`read_log`). Triage
|
||||
`kind: axrb`. Full uninstall in AXRB deletes OBB + saves; a signature change needs one.
|
||||
- **PC VR repacks are pre-patched to run directly** (proven: Rick and Morty, Vader Immortal run when the exe is
|
||||
launched directly; Revive breaks them). So Rift games default to `as_is` = install the copy unchanged and launch
|
||||
the exe directly (`pcvr.xr_timefix` for the Frame OpenXR-1.1→1.0 fix, `pcvr.no_crash_reporter` for Unreal).
|
||||
@@ -223,13 +240,6 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
store matches and links then only update `auto_title`; a locked name gets no twin suffix); `sync_title` → Frame
|
||||
agent v77 `rename` (deployment.json title, same shortcut appid: upsert matches by Exe) + art + one Steam restart, PC
|
||||
`PcReviveTarget.rename`; an unreachable Frame → `steam_name_stale` (game page "Update name on Frame", next install).
|
||||
- Newer versions (GitHub #161): game menu "Update with a newer APK…" (`pipeline.replace_apk`, outdated via
|
||||
`build.source_version`) and, for Linux apps / PC VR games, "Update with a newer version…" (`replace_linux_app` /
|
||||
`replace_rift_game` via `add_linux_app(replace=)` / `add_rift_game(replace=)`, CLI `frameport new-version`): the
|
||||
entry keeps package, name, art, tags, switches, user recipe (a confirmed exe if the new folder has it); a new
|
||||
`source_id` goes into the deployment's `recipe` dict at install (no agent change) → `C.source_replaced` = outdated
|
||||
(Frame + `pc_outdated`); such an install re-sends same-size files changed after the last install (mtime/ctime,
|
||||
`installer.changed_since`); the old Rift folder is in `replaced_dirs` (rescans skip it). Untested on the device.
|
||||
- `Recipe.as_is` = install unchanged (pre-patched libraries): `pipeline.prepare_as_is`; auto for APKs that already
|
||||
contain FrameBridge (`frame_patched`). For Rift it changes nothing (the dump is never modified; the Frame copy
|
||||
still gets launch fixes like the crash-reporter rename — `-nocrashreports` alone doesn't stop UE 4.23) — it does
|
||||
@@ -1062,13 +1072,6 @@ GitHub identity + credentials are maintainer-local (`CLAUDE.local.md`): check `g
|
||||
GitHub write. **The owner approves before posting, labelling or closing anything.** Reply style: "I"/neutral, never
|
||||
we/our/us; never mention agent version numbers or internal tool versions; show the owner every draft in full with a
|
||||
short recap of the thread so far; read **all** comments on a thread first (a later one often has the zip or the answer).
|
||||
- **Every triage also sweeps for unanswered comments**, not just new threads: open AND recently closed issues, PRs
|
||||
(incl. merged/closed ones) and discussions (top-level comments and their replies) whose latest comment isn't the
|
||||
maintainer's, plus earlier user comments a later maintainer reply skipped. Users often ask follow-ups after a fix or
|
||||
after closing (#294: two questions after the reporter closed it; #249: a Q&A discussion with no reply for days;
|
||||
#161/#110: requests posted after the close). Quick check: `gh issue list --state all --search "updated:>=<date>"
|
||||
--json number,comments --jq '.[] | select(.comments[-1].author.login != "spoopyghosty0")'`; discussions via GraphQL
|
||||
(`discussions(orderBy:UPDATED_AT)` with `comments{replies}`).
|
||||
- **Issue templates & labels** (`.github/ISSUE_TEMPLATE/`): `[Game report]` → `game-compatibility` (a game that
|
||||
doesn't work right); `[Working recipe]` (`working-config.yml`) → `working-config` (a headset-verified config to add);
|
||||
`[App]` → `bug` (FramePort itself). Mislabel/relabel to match.
|
||||
@@ -1114,15 +1117,9 @@ MR-only (PASSTHROUGH required + BOUNDARYLESS_APP) → force_passthrough, + USE_S
|
||||
hand tracking required → controller_fix=0; OVRPlugin + ≥20 GiB → disable_space_warp; Unreal ≤4.21 or Unreal Meta XR
|
||||
Audio → nodebug; Oculus-OS class referenced by the Unreal audio build or ≥2 Meta libs → oculusos; legacy-VrApi Unity
|
||||
GLES with MSAA → unity_no_msaa; CryEngine → user.cfg r_variable_rate_shading=0; direct VrApi → bridge (+GL shim for
|
||||
GLAD/GLES); Unreal → alternate no-ForceQuit build. Scoring (docs/HEURISTICS_EVAL.md, 2026-10-15): analyse the dumps
|
||||
once (`scripts/heuristics_snapshot.py <out.json> "<VR CyberDeck Portable>" "<PATCHED>"`, ~30 min, keep out of git),
|
||||
then `scripts/eval_heuristics.py --snapshot <out.json> --split dev|holdout|all [--fit-report]` (catalog-only truth,
|
||||
per-patch precision/recall, a fixed 20% holdout to catch rules fitted to one game). Read the **dated** score: an
|
||||
"extra" on an entry verified before the patch existed is a catalog gap, and adapter/device picks are inherited by
|
||||
catalog recipes anyway. 2026-10-15: strict 5/65 exact, F1 73.8%; dated 39/65, F1 88.6%; remaining misses are
|
||||
one-game runtime fixes. Don't key a rule on one game's code fingerprint (an Unreal splash/depth "detect" matching
|
||||
only Star Wars Tales + FNAF HW was rejected: it would also have bumped ANALYSIS_VERSION for every user). Add a rule →
|
||||
re-run dev + holdout + `pytest -m games`.
|
||||
GLAD/GLES); Unreal → alternate no-ForceQuit build. Score changes with
|
||||
`python scripts/eval_heuristics.py "<dumps>"` (catalog off vs verified recipes; currently 33/34 exact — Phantom's
|
||||
"use the no-ForceQuit build" is only detectable at runtime via triage). Add a rule → re-run the eval + `pytest -m games`.
|
||||
|
||||
## Frame operations
|
||||
- Find/connect: `frameport frame discover` / `frame info` (the remembered Frame is in `<user data>/frames.json`).
|
||||
|
||||
@@ -111,7 +111,8 @@ Out of date? [Report a problem](https://github.com/spoopyghosty0/frameport/issue
|
||||
Most of the work is done by these projects:
|
||||
[OVRPort](https://github.com/Android-XR-Bridge/OVRPort) (Quest → OpenXR, originally
|
||||
[ovrport/app](https://github.com/ovrport/app)) · Valve Lepton, Proton and SteamVR ·
|
||||
[Revive](https://github.com/LibreVR/Revive) · Mesa (Zink) · [Khronos OpenXR SDK](https://github.com/KhronosGroup/OpenXR-SDK)
|
||||
[Revive](https://github.com/LibreVR/Revive) · [AXRB](https://github.com/Android-XR-Bridge/AXRB) (Quest games on a PC,
|
||||
experimental) · Mesa (Zink) · [Khronos OpenXR SDK](https://github.com/KhronosGroup/OpenXR-SDK)
|
||||
· Eclipse Temurin, Android apksigner and NDK · [Flet](https://flet.dev) · OculusDB and Steam store data.
|
||||
What FramePort adds: [Architecture](docs/ARCHITECTURE.md).
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ FramePort's own native code (the FrameBridge adapter, GL/Vulkan/OpenXR shims, th
|
||||
| Eclipse Temurin JRE | api.adoptium.net | GPL-2.0 with Classpath Exception |
|
||||
| apksigner (Android build-tools) | dl.google.com Android repository | Apache-2.0 |
|
||||
| Revive (portable copy, only if Revive isn't installed) | github.com/LibreVR/Revive releases | GPL-3.0 |
|
||||
| AXRB / Android XR Bridge (official installer, only when a Quest game is installed on this PC; run unmodified) | github.com/Android-XR-Bridge/AXRB releases | mixed: launcher GPL-3.0-or-later, host/clock code AXRB Source-Available License 1.0, other parts MIT (AXRB's `LICENSE`) |
|
||||
| Android Emulator, platform-tools, build-tools, Android 16 system image (pinned by AXRB) | dl.google.com Android repository | Android SDK License |
|
||||
|
||||
Valve's Lepton, Proton and SteamVR, and Meta's software, are used on your devices as installed by Steam / Meta;
|
||||
FramePort doesn't distribute them.
|
||||
@@ -44,7 +44,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 91
|
||||
AGENT_VERSION = 92 # v92: upsert/remove_shortcut options_key (the PC's Quest games share powershell.exe as Exe)
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -982,8 +982,14 @@ def vdf_encode(obj):
|
||||
return bytes(out) + bytes([TYPE_END])
|
||||
|
||||
|
||||
def _shortcut_matches(v, exe, options_key=None):
|
||||
"""A shortcut is the game's when its Exe matches; with options_key (games sharing one Exe, e.g. Quest games on a
|
||||
PC all start powershell.exe) its LaunchOptions must also contain that key."""
|
||||
return isinstance(v, dict) and v.get("Exe") == exe and (not options_key or options_key in v.get("LaunchOptions", ""))
|
||||
|
||||
|
||||
def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Frame", launch_options="", tags=None,
|
||||
openvr=True, write=True):
|
||||
openvr=True, write=True, options_key=None):
|
||||
"""Add/update a non-Steam shortcut (matched by Exe, so its appid never changes). `tags` (genres, the user's tags)
|
||||
are merged with tags already on the shortcut, so ones set in Steam are kept. write=False: change nothing, return
|
||||
(appid, whether shortcuts.vdf would change)."""
|
||||
@@ -997,7 +1003,7 @@ def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Fram
|
||||
def game_of(options): # the game's program in the injector's arguments (its flags may change)
|
||||
found = re.findall(r'"([^"]+\.exe)"|(\S+\.exe)', options or "", re.IGNORECASE)
|
||||
return (found[-1][0] or found[-1][1]).lower() if found else (options or "")
|
||||
entry = next((v for v in shortcuts.values() if isinstance(v, dict) and v.get("Exe") == exe
|
||||
entry = next((v for v in shortcuts.values() if _shortcut_matches(v, exe, options_key)
|
||||
and (not shared or game_of(v.get("LaunchOptions", "")) == game_of(launch_options))), None)
|
||||
ident = entry["appid"] if entry else shortcut_appid(exe, title)
|
||||
if entry is None:
|
||||
@@ -1092,12 +1098,12 @@ def grid_files(grid, appid):
|
||||
return []
|
||||
|
||||
|
||||
def remove_shortcut(vdf_path, exe):
|
||||
def remove_shortcut(vdf_path, exe, options_key=None):
|
||||
if not os.path.exists(vdf_path):
|
||||
return False
|
||||
root = vdf_decode(open(vdf_path, "rb").read())
|
||||
sc = root.get("shortcuts", {})
|
||||
keep = [v for v in sc.values() if not (isinstance(v, dict) and v.get("Exe") == exe)]
|
||||
keep = [v for v in sc.values() if not _shortcut_matches(v, exe, options_key)]
|
||||
if len(keep) == len(sc):
|
||||
return False
|
||||
root["shortcuts"] = {str(i): v for i, v in enumerate(keep)}
|
||||
|
||||
@@ -5,19 +5,20 @@ c9c3da5cac224c38e2210b781bd12b7f97e02bd6b0ebb0ef85543e4ea4b76671 ./arm64-v8a/li
|
||||
18d8d917e83a6d87008f41908eb478dff635e798844e125abd1e8a5cf31805e2 ./arm64-v8a/liba32stub.so
|
||||
d6337034fb0bdcca7ef9b7063d85b7586b8508b63b435959e8fffaba0691091c ./arm64-v8a/libfp_langpack.so
|
||||
69102b128e00fa1809489a7308e09c1505f563bf7091bbdd3fe5a58e4cc96c2c ./arm64-v8a/libfp_luajit.so
|
||||
321da502e0f8f46f0880aad39fe6b84bdef88f925164568814cf704a1b31a577 ./arm64-v8a/libfp_ovrp.so
|
||||
e2465e8960b4dd591d303e30c9d80b8a7c7c7eeff53736419c7105a697725732 ./arm64-v8a/libfp_ovrp.so
|
||||
40defdaddcda53bd2649eb48076fae1622bfc2bfc88e9e03c94fc8b45af2a4f2 ./arm64-v8a/libfp_ovrtrace.so
|
||||
0167c2fde5dfde62897f9d85afc4fc8e97784e988b5b8df6cea8c407240b3424 ./arm64-v8a/libfp_tkeys.so
|
||||
e8c0965c188665a4e565e4a3af983ef1866d1cf9225665cc90d8df286bc96ef3 ./arm64-v8a/libfp_vk.so
|
||||
7fa29014eed2881ee0d7ac486434de9c5fe46710e640b15f697030ad052acfc4 ./arm64-v8a/libfp_vkext.so
|
||||
383054f8b3b41dde76d062c71856cd3163655e50009c08454bc1793f393ead65 ./arm64-v8a/libfpg.so
|
||||
5432674a59d02f411cd853a5dc57e3fa547d1ac518f25bf433fc54c43677177a ./arm64-v8a/libfpglmv.so
|
||||
8a8f6b1da8952cb3933a5b53558424a5fc56043b2527a16bb6dd62a00d2de01a ./arm64-v8a/libframe_xrshim.so
|
||||
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
|
||||
0f058921e9c1f5b9ef5847cc9e6b80a23e75028c76c1d3bb02d1610b535f81c9 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
227626fcd96ea31a5c4fbd006e971b40f9b5a1e10d1bb30456f0a2792fa2c9e2 ./arm64-v8a/libglshim.so
|
||||
34ae4e6757adf83942d179a098ae91b4da187fead17e02a3a65dd6507daf2073 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
|
||||
9e8a7ce599b62a7c607b0d3637e4ec1b1ea1974c3373a7af3f5920ad2adc5e00 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
f7f8349b0d3e9a29305b4d2aa1eb3ec6b394feff4d3e2ea07b8a65f6df1d15f9 ./arm64-v8a/libvrapi.so
|
||||
71b3df219f331bcb42a483550e35371475b1aa886762ddcf5d0d739d74c51ead ./arm64-v8a/libvrapi_foveation.so
|
||||
c7dea9e3435f36e281341121a9e43102f008cc08bba08191c21bac14912e6b87 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
|
||||
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
|
||||
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,14 +7,8 @@ xr: OpenXR
|
||||
status: works
|
||||
notes: Uses Meta XR Audio via FMOD (not covered by patch_meta_xr_audio); if audio is missing, that is the likely
|
||||
cause.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 63fff470777f2753d55ad50f8355573ced8fa48bcaee0f5e6a0c10369f951a38
|
||||
updated: '2026-10-15'
|
||||
@@ -6,15 +6,9 @@ engine: Unity
|
||||
xr: VrApi
|
||||
status: issues
|
||||
notes: Right eye distorts during movement (unresolved; swap, tracking, Valve layers, depth and pacing ruled out).
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Legacy VrApi via OVRPlugin; overport swaps in an OpenXR OVRPlugin. Do not remove VrApi.
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 192ff250f5fbec38b9c4e898f0b8408edc25901f46bb4b227f7ca60bbd9f0bba
|
||||
updated: '2026-10-15'
|
||||
@@ -6,15 +6,9 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Needed the GLES/sRGB swapchain fallback.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Also available as a native PC SteamVR title.
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 158f7c4f5e8558029dc8cc5e8dcb4e67e846b261bf34e3e448fee7f657c74597
|
||||
updated: '2026-10-15'
|
||||
@@ -1,10 +0,0 @@
|
||||
package: com.FlatHeadStudio.DeadlyDelivery
|
||||
title: Deadly Delivery
|
||||
status: unsupported
|
||||
notes: "Quits after Meta's entitlement check fails: it needs Meta's online services."
|
||||
tested_version: 1.0.30
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
verified:
|
||||
date: '2026-10-15'
|
||||
issue: 322
|
||||
@@ -6,14 +6,8 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Recommended pilot title.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 2300f628ce04c1da60b52fed18335d6a31b88cc88313fd6d2c0e1a8e7785126f
|
||||
updated: '2026-10-15'
|
||||
@@ -5,14 +5,9 @@ source_hint: 'Sniper Elite VR- Winter Warrior'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
notes: ''
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 6b8170da5826b9a35326a20967a0e5a242d46fb569a0a3c669e1cbd368102dd3
|
||||
updated: '2026-10-15'
|
||||
@@ -6,14 +6,11 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Passthrough + Meta scene/room emulated from the Steam guardian (scene_emul=1).
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Mixed-reality-only; builds its level from the room model, which the adapter emulates from the Steam guardian.
|
||||
overport_extra:
|
||||
- patch_force_passthrough
|
||||
adapter:
|
||||
scene_emul: 1
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
@@ -21,5 +18,3 @@ verified:
|
||||
known_good_sha256: 9f900fc86fbb2663abe83c82ab79cc72bbf1f93fea54f7583fd274dd07e3d09c
|
||||
frame:
|
||||
- frame.meta_permissions
|
||||
- frame.unity_text_input
|
||||
updated: '2026-10-15'
|
||||
@@ -6,18 +6,12 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: issues
|
||||
notes: Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Hand-tracking-only game; Steam Frame has no optical hand tracking. Built with controller_fix=0 so hand
|
||||
data passes through if the runtime ever provides it.
|
||||
adapter:
|
||||
controller_fix: 0
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: a19b80db0c300d422cbba35a4924ab59211b39bcb6485e200d310e3e1372ddb7
|
||||
updated: '2026-10-15'
|
||||
@@ -7,15 +7,10 @@ xr: OpenXR
|
||||
status: works
|
||||
notes: FrameBridge turns its upside-down UI panels upright (flip emulation, in place since FramePort 1.2.0). Ubisoft
|
||||
login may not work.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Ubisoft online login may not work. Data folder holds raw assets (start0..2, .climb, bundles), not .obb
|
||||
files; install to obb as-is.
|
||||
overport_extra:
|
||||
- patch_ac_nexus_no_appsw_90
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
@@ -23,5 +18,5 @@ verified:
|
||||
known_good_sha256: 89644a12bf8df956fa3d3a9a40540ba9e1fb62f63494714a05c9f05984383ac8
|
||||
no_appsw_90: '2026-10-02, OVRPort 1.2.5 (owner: 90 Hz felt better than 72 Hz; same known issues)'
|
||||
flip_in_place: '2026-10-14, owner in the headset: launch warnings and tutorial pop-ups upright (GitHub #138)'
|
||||
updated: '2026-10-15'
|
||||
updated: '2026-10-14'
|
||||
min_app: 1.2.0
|
||||
@@ -2,7 +2,6 @@ package: com.camouflaj.manta
|
||||
title: 'Batman: Arkham Shadow'
|
||||
status: works
|
||||
notes: Large data set (~27 GiB) incl. all language packs.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: 'Uses Meta XR Audio (Wwise), handled by patch_meta_xr_audio. Cutscenes are 8K HEVC panoramas: frame.hw_video_decode
|
||||
decodes them on the Frame''s hardware, adapter.surface_native shows them in stereo (PRs #96, #128). Includes all
|
||||
language packs (~28 GB). sync_guard (to test, GitHub #102) - a reporter''s game quit to Steam''s waiting screen
|
||||
@@ -14,18 +13,15 @@ overport_extra:
|
||||
- patch_disable_space_warp
|
||||
frame:
|
||||
- frame.hw_video_decode
|
||||
- frame.unity_text_input
|
||||
adapter:
|
||||
sync_guard: 1
|
||||
surface_native: 1
|
||||
fade_quad: 1
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 796d1cab08f614d72c50d9702974c719c0908f5de91183cfe02d1fd5eb09551a
|
||||
updated: '2026-10-15'
|
||||
updated: '2026-10-13'
|
||||
min_app: 1.1.0
|
||||
source_hint: Batman- Arkham Shadow
|
||||
@@ -6,15 +6,9 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Co-op unavailable.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: 'Release notes: for DLC use an older version. Co-op likely unavailable.'
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 06428a6328a49b3ca769e070dd0d2bb55c2122c8fba5d07a69b1b206a532c212
|
||||
updated: '2026-10-15'
|
||||
@@ -6,17 +6,11 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Passthrough emulated with the Frame cameras (greyscale).
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Mixed-reality-only title; relies on passthrough (emulated).
|
||||
overport_extra:
|
||||
- patch_force_passthrough
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 65ca3a8f46cc9acfd268949418f9adeecb0cf9f59d11591ba95fe2ba9ea966d8
|
||||
updated: '2026-10-15'
|
||||
@@ -2,17 +2,10 @@ package: com.nexxr.nexplayer
|
||||
title: NEX Player
|
||||
status: works
|
||||
notes: 'Confirmed in the headset by the owner (2026-10-01): 72 fps, local 2D/360/3D videos play (it browses /sdcard/Movies
|
||||
itself). USB-drive browsing can''t work in Lepton. Typing (frame.unity_text_input + device.text_input_window,
|
||||
owner in the headset 2026-10-15): SteamVR''s keyboard button works and typed text reaches the fields; the keyboard
|
||||
doesn''t open by itself when a field is selected.'
|
||||
itself). USB-drive browsing can''t work in Lepton.'
|
||||
tested_version: 1.3.1.0
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
verified:
|
||||
date: '2026-10-01'
|
||||
source_hint: NEX - NEXt-Gen Video Player
|
||||
updated: '2026-10-15'
|
||||
@@ -6,14 +6,8 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Layers with failed swapchains are dropped by the adapter.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: c3f8cba1c22762231b6a6fbd881668db859d83224884ebeae0c1a3ba716f298c
|
||||
updated: '2026-10-15'
|
||||
@@ -6,15 +6,9 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Use VR mode.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Has VR and MR modes; use VR mode (passthrough not forced).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 5ba09a2bc0046c8cb587272ab81e8d32739334fef523b10ab26531a733d54f12
|
||||
updated: '2026-10-15'
|
||||
@@ -5,14 +5,9 @@ source_hint: Toy Master
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
notes: ''
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: e951595c67b6e448f3fb4afd07e6b83e98e589094bb3b73374cfb28aa01e96ee
|
||||
updated: '2026-10-15'
|
||||
@@ -7,9 +7,6 @@ xr: OpenXR
|
||||
status: works
|
||||
notes: Needs Oculus OS telemetry class stubs and the no-ForceQuit build (the normal build quits right after
|
||||
starting).
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-02' # owner-confirmed with the no-ForceQuit build
|
||||
overport_cli: 1.2.3
|
||||
@@ -17,8 +14,6 @@ verified:
|
||||
known_good_sha256: 31469d50b8ee292597291db7cc3bc3eca8e851f533cd92bba0d64214893580cc
|
||||
frame:
|
||||
- frame.oculusos
|
||||
- frame.unity_text_input
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
use_alt: true
|
||||
updated: '2026-10-15'
|
||||
@@ -5,14 +5,9 @@ source_hint: H.U.N.T
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
notes: ''
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: c35373d0f7c870ade148e3259abcc74098b6bc89fe2c2f3a2e55168df642e391
|
||||
updated: '2026-10-15'
|
||||
+133
-10
@@ -11,16 +11,6 @@ signatures:
|
||||
severity: error
|
||||
diagnosis: A DLC manager reports a Meta asset-file status error after VR starts (e.g. PixelRipped 1995). The asset-file check failed; the log does not identify the native call or asset ID. Try the experimental asset-file bypass (empty list, installed status with no file path); this does not supply DLC and has not been verified in the game.
|
||||
suggest: [frame.asset_files_stub]
|
||||
- id: meta-entitlement
|
||||
# Quest games (the pcvr oculus-entitlement signature covers Rift games). Only explicit failure results: a bare
|
||||
# ovr_PlatformInitialize error isn't matched. `error`, not fatal: offline games may log it and play on; an online
|
||||
# game that then quits (e.g. Deadly Delivery, GitHub #322) fails the test through its exit.
|
||||
pattern: '(?i)entitlement check failed|\bnot entitled to (use|play)|ovrPlatformInitialize_(NotEntitled|UnableToVerify|FileInvalid|SignatureInvalid)'
|
||||
severity: error
|
||||
diagnosis: The game's Meta licence check (entitlement) failed. It needs Meta's online login and licence check,
|
||||
which the Frame doesn't have; games that depend on it (often online games) quit or stay offline. FramePort
|
||||
doesn't get around such checks.
|
||||
suggest: []
|
||||
- id: lua-lightuserdata-pointer
|
||||
pattern: 'bad light userdata pointer'
|
||||
severity: fatal
|
||||
@@ -580,6 +570,132 @@ signatures:
|
||||
the video was decoded in software instead, which is slow for 4K/8K. Turn off hardware video decoding in Steam's
|
||||
settings, restart Steam, then start the game again.
|
||||
suggest: []
|
||||
# ---- Quest games on this PC (AXRB, docs/PC_ANDROID.md): AXRB's host.err/session.json + guest log
|
||||
- id: axrb-no-headset
|
||||
kind: axrb
|
||||
pattern: 'failed to find D3D11 adapter requested by OpenXR runtime|xrCreateInstance failed: XR_ERROR_RUNTIME_FAILURE'
|
||||
severity: fatal
|
||||
diagnosis: The PC's OpenXR runtime (SteamVR) has no headset to show the game on, so AXRB's bridge couldn't open its VR session. Connect the headset (Steam Link / cable) and let SteamVR show it before starting the game.
|
||||
suggest: []
|
||||
- id: axrb-host-not-ready
|
||||
kind: axrb
|
||||
pattern: 'Host pose server did not become ready|Host failed to start'
|
||||
severity: fatal
|
||||
diagnosis: AXRB's Windows bridge (axrb-host-bridge.exe) didn't start, so the game was never launched. Its reason is in host.err (diagnostics); a missing headset is the usual one.
|
||||
suggest: []
|
||||
report: true
|
||||
- id: axrb-gfxstream-crash
|
||||
kind: axrb
|
||||
pattern: 'vulkan\.ranchu\.so \(gfxstream::vk::'
|
||||
severity: fatal
|
||||
diagnosis: The game crashed inside the Android emulator's Vulkan driver (gfxstream), reached through AXRB's runtime layer (e.g. Pinball FX VR at its first Vulkan allocations). That's AXRB's GPU path, not FramePort's build - report it to AXRB with the diagnostics.
|
||||
suggest: []
|
||||
report: true
|
||||
- id: axrb-translation-crash
|
||||
kind: axrb
|
||||
pattern: 'libndk_translation\.so \(berberis::|DEBUG\s*:.*#00 pc \S+\s+/system/lib64/libndk_translation\.so'
|
||||
severity: fatal
|
||||
diagnosis: The game crashed in Android's ARM translation (libndk_translation), which runs Quest games' arm64 code on the PC's x86 CPU (known for some Unity games, AXRB issue #9). Nothing to patch in the game; AXRB plans another translator.
|
||||
suggest: []
|
||||
# AXRB's direct mode (axrb-direct.exe, no emulator): direct.log lines are "<s> <tid> E/<tag>: ..." from the runner
|
||||
# and "E/<tag>(<tid>): ..." from the game's Android log; direct.out has AppProcess's AXRB_AOSP_* lines
|
||||
- id: axrb-direct-incomplete
|
||||
kind: axrb
|
||||
pattern: 'Direct mode installation is incomplete|Direct mode is missing |AXRB Direct is incomplete|Preparing the AOSP application failed'
|
||||
severity: fatal
|
||||
diagnosis: AXRB's direct mode is missing part of itself or of its Android system root, so the game was never started. Reinstall AXRB-Emulatorless (or rebuild the local build FRAMEPORT_AXRB_DIRECT_DIR points at), then `frameport pc install-axrb --backend direct` sets the system root up again.
|
||||
suggest: []
|
||||
- id: axrb-direct-no-vulkan
|
||||
kind: axrb
|
||||
pattern: 'vulkan-1\.dll is not installed|E/vulkan: \S+ is not available on the host'
|
||||
severity: fatal
|
||||
diagnosis: AXRB's direct mode found no usable Vulkan driver on this PC (or the driver lacks a function the game calls). Install the graphics card's current driver.
|
||||
suggest: []
|
||||
- id: axrb-direct-no-gles
|
||||
kind: axrb
|
||||
pattern: 'eglInitialize\b.*(?:fail|0x3008)|Failed to initiali[sz]e EGL|Unable to initiali[sz]e (?:EGL|the OpenGL)|EGL_BAD_DISPLAY|Unable to retrieve the default EGL display'
|
||||
# Unreal Vulkan builds probe EGL at start too (eglInitialize -> EGL_BAD_DISPLAY in direct mode), then create
|
||||
# their Vulkan device: only a session without one is a GLES game (Deadpool VR, 2026-10-15)
|
||||
# ... and one that died first on something AXRB's direct mode lacks (a dialog window, a framework call) never
|
||||
# got that far: that is the blocker, not EGL (Of Lies and Rain, NOPE CHALLENGE, 2026-10-16)
|
||||
unless: '(?m)I/vulkan: vkCreateDevice\(.*\) = 0$|UnsupportedOperationException: addToDisplayAsUser|^AXRB_SERVICE_UNIMPLEMENTED '
|
||||
severity: fatal
|
||||
diagnosis: The game draws with OpenGL ES, and AXRB's direct mode only has a Vulkan renderer. Install it for AXRB's emulator instead (the "AXRB mode on this PC" option, pc.axrb_backend backend=emulator, or `frameport install <game> --to pc --axrb-backend emulator`).
|
||||
suggest: []
|
||||
- id: axrb-direct-no-native-window
|
||||
kind: axrb
|
||||
pattern: '(?m)^AXRB_AOSP_HOST_SURFACES 0'
|
||||
unless: 'APP_CMD_INIT_WINDOW|AXRB_AOSP_TAKEN_SURFACE|AXRB\.Space|session state='
|
||||
severity: warning
|
||||
diagnosis: The game's activity got no window in AXRB's direct mode. NativeActivity games (CryEngine, other own engines) take their surface with Window.takeSurface, which AXRB's direct mode doesn't serve yet (it only hands SurfaceViews one), so they wait for APP_CMD_INIT_WINDOW forever and never start VR (The Climb 2 stopped after "Loading plug-in CryHTTP"). That's AXRB's side; AXRB's emulator may run the game.
|
||||
suggest: []
|
||||
report: true
|
||||
- id: axrb-direct-vulkan-extensions
|
||||
kind: axrb
|
||||
pattern: 'Renderer initialization[\s\S]{0,600}?CrySystem initialization failed'
|
||||
unless: 'fp_vkext: listing'
|
||||
severity: fatal
|
||||
diagnosis: CryEngine found its Vulkan device without extensions every Quest has (VK_EXT_fragment_density_map, Android's external memory) and gave up before creating it; the PC's graphics driver doesn't have them. "Vulkan - list Android's external-memory extensions (this PC)" lists them for the game in builds for this PC, and AXRB leaves them out again when the device is created.
|
||||
suggest: [frame.vk_android_extensions]
|
||||
- id: axrb-direct-unhandled-instruction
|
||||
kind: axrb
|
||||
pattern: 'fatal signal 4 \(SIGILL\)|unhandled synchronous signal 4 '
|
||||
severity: fatal
|
||||
diagnosis: AXRB's direct mode met an arm64 instruction its translator can't run (Dynarmic, or Digitalis when chosen) and the game stopped. That's AXRB's side - report it to AXRB with the diagnostics (direct.log has the instruction's address); the other translator (pc.axrb_backend translator=digitalis / dynarmic) or AXRB's emulator may get further.
|
||||
suggest: []
|
||||
report: true
|
||||
- id: axrb-direct-guest-crash
|
||||
kind: axrb
|
||||
pattern: 'E/cpu: thread \d+ \(.*\): fatal signal (?:5|6|7|11) '
|
||||
severity: fatal
|
||||
diagnosis: The game's own arm64 code crashed in AXRB's direct mode (a SIGSEGV/SIGBUS/SIGABRT, or the SIGTRAP Unity raises on a fatal error; direct.log has the backtrace by library). Often a missing Android service or a translation bug rather than the game; try the other translator (pc.axrb_backend translator=digitalis) or AXRB's emulator, and report it to AXRB with the diagnostics.
|
||||
suggest: []
|
||||
report: true
|
||||
- id: axrb-direct-host-fault
|
||||
kind: axrb
|
||||
pattern: 'E/host-fault: |E/fatal: '
|
||||
severity: fatal
|
||||
diagnosis: axrb-direct.exe itself crashed (its translator or Linux/Binder layer, not the game). That's AXRB's side - report it to AXRB with the diagnostics.
|
||||
suggest: []
|
||||
report: true
|
||||
- id: axrb-direct-dialog-window
|
||||
kind: axrb
|
||||
pattern: 'UnsupportedOperationException: addToDisplayAsUser'
|
||||
severity: fatal
|
||||
diagnosis: The game opened a second Android window (a Dialog, for example Unreal's message box or a Unity plugin's dialog) and AXRB's direct mode can only show the game's own activity, so the game stopped ("Adding window failed"). Seen with Medieval Dynasty, Under Cover and Of Lies and Rain. That's AXRB's side; AXRB's emulator shows dialogs, so `--axrb-backend emulator` may get further (Vulkan games there go through gfxstream).
|
||||
suggest: []
|
||||
report: true
|
||||
- id: axrb-direct-unimplemented-service
|
||||
kind: axrb
|
||||
pattern: '(?m)^AXRB_SERVICE_UNIMPLEMENTED \S+'
|
||||
severity: error
|
||||
diagnosis: The game (or a library in it, often Google Play services) called an Android framework service function AXRB's direct mode doesn't implement yet (the line names it, e.g. IActivityManager.getMyMemoryState for NOPE CHALLENGE); the call throws, and an uncaught one ends the game. That's AXRB's side - report it to AXRB with the diagnostics; AXRB's emulator has the full framework.
|
||||
suggest: []
|
||||
report: true
|
||||
- id: axrb-direct-java-crash
|
||||
kind: axrb
|
||||
pattern: 'E/app: the application crashed|FATAL EXCEPTION: |Runtime aborting|JNI DETECTED ERROR IN APPLICATION'
|
||||
severity: fatal
|
||||
diagnosis: The game's Java side (or Android's ART runtime under it) ended with an uncaught exception or an abort in AXRB's direct mode. direct.log / direct.err have the stack; a framework service AXRB's direct mode doesn't provide yet (Meta/Play services, account, DRM) is the usual cause.
|
||||
suggest: []
|
||||
- id: axrb-direct-missing-function
|
||||
kind: axrb
|
||||
pattern: 'guest called unknown host function|unimplemented JNI function|call to unregistered host function'
|
||||
severity: error
|
||||
diagnosis: The game called an Android/JNI function AXRB's direct mode doesn't implement yet; it may fail right after. Report it to AXRB with the diagnostics.
|
||||
suggest: []
|
||||
- id: axrb-direct-unsupported-syscall
|
||||
kind: axrb
|
||||
pattern: 'W/syscall: unimplemented syscall|W/binder: unsupported (?:command|ioctl|object type)|execve\(.*\) refused: one process image only'
|
||||
severity: warning
|
||||
diagnosis: The game asked for a Linux system call or Binder feature (or a second process) AXRB's direct mode doesn't provide. Often harmless; when the game then fails, it's the first thing to report to AXRB.
|
||||
suggest: []
|
||||
- id: axrb-direct-slow-stop
|
||||
kind: axrb
|
||||
pattern: 'the game did not close within 25 seconds'
|
||||
severity: info
|
||||
diagnosis: The game didn't finish its activity shutdown when the session ended, so AXRB stopped it; anything it hadn't saved yet may be lost.
|
||||
suggest: []
|
||||
# Launch tests run without a worn headset. Some parts give up on tracking after a while and end the VR session; what
|
||||
# the game does after that (often a crash in the GPU driver while its renderer tears down) never happens in the
|
||||
# headset. In a launch test that already had paced frames before such a line, a later exit or crash still passes,
|
||||
@@ -588,6 +704,13 @@ headless_end:
|
||||
- label: the VrApi bridge's 30 s tracking deadline
|
||||
pattern: 'EnterVrMode: no tracked head pose before deadline'
|
||||
milestones: # progress markers, in order; the furthest one reached is reported
|
||||
- {id: axrb-host, kind: axrb, pattern: 'AXRB TCP: listening', label: AXRB bridge started}
|
||||
- {id: axrb-direct-art, kind: axrb, pattern: '(?m)^AXRB_AOSP_BOOTSTRAP', label: Android runtime (ART) started (direct mode)}
|
||||
- {id: axrb-direct-activity, kind: axrb, pattern: '(?m)^AXRB_AOSP_ACTIVITY_CREATED ', label: Game activity created (direct mode)}
|
||||
- {id: axrb-runtime, kind: axrb, pattern: 'AXRB\.PoseClient|Got runtime: package: com\.axrb\.openxrruntime', label: AXRB's OpenXR runtime loaded}
|
||||
- {id: axrb-instance, kind: axrb, pattern: 'xrCreateInstance\(\) succeeded|LoaderInstance::CreateInstance succeeded', label: OpenXR instance created}
|
||||
- {id: axrb-direct-vulkan, kind: axrb, pattern: '(?m)I/vulkan: vkCreateDevice\(.*\) = 0$', label: Vulkan device created on the PC's GPU (direct mode)}
|
||||
- {id: axrb-frames, kind: axrb, pattern: 'AXRB\.Perf end-frame', label: Submitting frames}
|
||||
- {id: pcvr-launch, kind: pcvr, pattern: 'FramePort: launching|Launched injector with', label: Launcher started}
|
||||
- {id: pcvr-oculus-hmd, kind: pcvr, pattern: 'FramePort oculushmd: OculusHMDConnected event ready', label: Oculus headset event provided}
|
||||
- {id: pcvr-inject, kind: pcvr, pattern: 'Succesfully injected|Successfully injected', label: Revive injected into the game}
|
||||
|
||||
+2
-19
@@ -14,12 +14,10 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Asgard's Wrath 2 | Quest | ✅ Works | |
|
||||
| Assassin's Creed Nexus | Quest | ✅ Works | |
|
||||
| Audica | Quest | ✅ Works | |
|
||||
| AugmentedEmpire | Quest | ✅ Works | |
|
||||
| BAM | Quest | ✅ Works | |
|
||||
| BARTENDER VR SIMULATOR | Quest | ✅ Works | |
|
||||
| Batman: Arkham Shadow | Quest | ✅ Works | |
|
||||
| BattleGlide | Quest | ✅ Works | |
|
||||
| Battlemarked | Quest | ✅ Works | |
|
||||
| BattleSisters | Quest | ✅ Works | |
|
||||
| Beat Saber | Quest | ✅ Works | |
|
||||
| Beat Saber | Quest | ✅ Works | |
|
||||
@@ -39,7 +37,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| cubism | Quest | ✅ Works | |
|
||||
| Dance Central | Quest | ✅ Works | |
|
||||
| Deep Cuts | Quest | ✅ Works | |
|
||||
| Demeo | Quest | ✅ Works | |
|
||||
| Demeter | Quest | ✅ Works | |
|
||||
| Dinosaur Island | Quest | ✅ Works | |
|
||||
| Doom3Quest | Quest | ✅ Works | |
|
||||
@@ -49,12 +46,10 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Eleven Table Tennis | Quest | ✅ Works | |
|
||||
| Eleven: Table Tennis VR | PC VR | ✅ Works | |
|
||||
| Espire 2 | Quest | ✅ Works | |
|
||||
| Eye of the Temple | Quest | ✅ Works | |
|
||||
| FrenzyVR | Quest | ✅ Works | |
|
||||
| Fruit Ninja | Quest | ✅ Works | |
|
||||
| Fruit Salon | Quest | ✅ Works | |
|
||||
| Genotype | Quest | ✅ Works | |
|
||||
| Ghost Town | Quest | ✅ Works | |
|
||||
| Ghostbusters: Rise of the Ghost Lord | Quest | ✅ Works | |
|
||||
| GORN2 | Quest | ✅ Works | |
|
||||
| Gravity Lab | Quest | ✅ Works | |
|
||||
@@ -66,7 +61,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| In Death: Unchained | Quest | ✅ Works | |
|
||||
| Into The Radius 2 | Quest | ✅ Works | |
|
||||
| Job Simulator | Quest | ✅ Works | |
|
||||
| Journey of the Gods | Quest | ✅ Works | |
|
||||
| Jurassic World Aftermath Collection | Quest | ✅ Works | |
|
||||
| Keep Talking and Nobody Explodes | Quest | ✅ Works | |
|
||||
| Kill It With Fire VR | Quest | ✅ Works | |
|
||||
@@ -74,23 +68,18 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Lambda1VR | Quest | ✅ Works | |
|
||||
| LEGO® Bricktales | Quest | ✅ Works | |
|
||||
| Lucky's Tale | Quest | ✅ Works | |
|
||||
| Maestro | Quest | ✅ Works | |
|
||||
| Marvel's Deadpool VR | Quest | ✅ Works | |
|
||||
| Marvel's Iron Man VR | Quest | ✅ Works | |
|
||||
| Max Mustard | Quest | ✅ Works | |
|
||||
| Medieval Dynasty New Settlement | Quest | ✅ Works | |
|
||||
| Metro Awakening | Quest | ✅ Works | |
|
||||
| Mini Motor Racing X | Quest | ✅ Works | |
|
||||
| Mobile Suit Gundam: Silver Phantom | Quest | ✅ Works | |
|
||||
| Moss | Quest | ✅ Works | |
|
||||
| Moss: Book II | Quest | ✅ Works | |
|
||||
| Munkr Funkr (The Legacy Days) | Quest | ✅ Works | |
|
||||
| Nano | Quest | ✅ Works | |
|
||||
| NEX Player | Quest | ✅ Works | |
|
||||
| NFL PRO ERA II | Quest | ✅ Works | |
|
||||
| NOPE CHALLENGE | Quest | ✅ Works | |
|
||||
| Ocean Rift | Quest | ✅ Works | |
|
||||
| Operation Wolf Returns: First Mission VR | Quest | ✅ Works | |
|
||||
| palazzo_santacruz | Quest | ✅ Works | |
|
||||
| Path of the Warrior | Quest | ✅ Works | |
|
||||
| Pistol Whip | Quest | ✅ Works | |
|
||||
@@ -108,11 +97,9 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Riven | Quest | ✅ Works | |
|
||||
| Robo Recall | Quest | ✅ Works | |
|
||||
| RUINSMAGUS | Quest | ✅ Works | |
|
||||
| Shadow Point | Quest | ✅ Works | |
|
||||
| Shoot The Zombirds VR | Quest | ✅ Works | |
|
||||
| Shores of Loci | Quest | ✅ Works | |
|
||||
| SilentSlayer | Quest | ✅ Works | |
|
||||
| Smash Drums | Quest | ✅ Works | |
|
||||
| Sniper Elite VR | Quest | ✅ Works | |
|
||||
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
|
||||
| Space Pirate Trainer Quest | Quest | ✅ Works | |
|
||||
@@ -125,7 +112,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| SUPERHOT VR | Quest | ✅ Works | |
|
||||
| SynthRiders | Quest | ✅ Works | |
|
||||
| Taskmaster | Quest | ✅ Works | |
|
||||
| Tea For God | Quest | ✅ Works | |
|
||||
| TetrisEffect | Quest | ✅ Works | |
|
||||
| The Boys VR | Quest | ✅ Works | |
|
||||
| The Climb 2 | Quest | ✅ Works | |
|
||||
@@ -142,7 +128,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Vader Immortal: Episode II | Quest | ✅ Works | |
|
||||
| Vader Immortal: Episode III | Quest | ✅ Works | |
|
||||
| Vampire Survivors | Quest | ✅ Works | |
|
||||
| Vendetta Forever | Quest | ✅ Works | |
|
||||
| VR HOT Quest | Quest | ✅ Works | |
|
||||
| VR4 | Quest | ✅ Works | |
|
||||
| Walkabout Mini Golf | Quest | ✅ Works | |
|
||||
@@ -159,15 +144,12 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| ExploreVR | Quest | ⚠️ Works with issues | The 3D world warps and shifts with head movement (wrong perspective). |
|
||||
| Fisherman | Quest | ⚠️ Works with issues | Loading screens have corruption, but everything works otherwise. |
|
||||
| Freedom | Quest | ⚠️ Works with issues | Volume can't be changed in the game. |
|
||||
| Homeworld: Vast Reaches | Quest | ⚠️ Works with issues | The game starts but after selecting to start a new game it seems to crash. |
|
||||
| MacacoVR | Quest | ⚠️ Works with issues | 2d loading screens are glitchy, otherwise main gameplay is fine |
|
||||
| Majora's Mask VR | Quest | ⚠️ Works with issues | Audio on the file select screen sounds crunchy. |
|
||||
| Myst | Quest | ⚠️ Works with issues | Minor graphical glitches on some objects. |
|
||||
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
|
||||
| Pinball FX VR | Quest | ⚠️ Works with issues | Plays; mixed reality mode not working yet. |
|
||||
| Plezy | Quest | ⚠️ Works with issues | Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding. |
|
||||
| Quantum Void | Quest | ⚠️ Works with issues | no hands |
|
||||
| Ragnarock | Quest | ⚠️ Works with issues | |
|
||||
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
|
||||
| Spunky Dungeon | Quest | ⚠️ Works with issues | Gear VR / Oculus Go game (32-bit only), runs through FramePort's 32-bit bridge (experimental). |
|
||||
| Swarm 2 | Quest | ⚠️ Works with issues | MSAA creates visible seams around edge geometry and also the skybox edge |
|
||||
@@ -176,8 +158,9 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Time Stall | Quest | ⚠️ Works with issues | Both eyes distort during movement (unresolved). |
|
||||
| Vermillion | Quest | ⚠️ Works with issues | Runs. |
|
||||
| WiiCompiled VR | Quest | ⚠️ Works with issues | To add a game: in FramePort's Files tab, upload your .wcgame file to this game's storage, folder Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR… |
|
||||
| Deadly Delivery | Quest | ❌ Doesn't run | Quits after Meta's entitlement check fails: it needs Meta's online services. |
|
||||
| Espire 1: VR Operative (Quest Edition) | Quest | ❌ Doesn't run | Mesa GL driver crash during texture upload. |
|
||||
| GOLF+ | Quest | ❌ Doesn't run | Black screen. |
|
||||
| HITMAN 3 VR: Reloaded | Quest | ❌ Doesn't run | Vulkan driver crash (freedreno), even without Valve layers. |
|
||||
| Journey of the Gods | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
|
||||
| Roblox | Quest | ❌ Doesn't run | Crashes on its first VR frame on the Frame. |
|
||||
| Shadow Point | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
|
||||
@@ -1,83 +0,0 @@
|
||||
# Evaluating patch heuristics
|
||||
|
||||
`scripts/eval_heuristics.py` compares `engine.suggest(analysis, use_catalog=False)` with the bundled
|
||||
`catalog/games/*.yaml` entries. It does not merge personal or remote catalog recipes into the benchmark.
|
||||
It supports the original dumps command, or a precomputed `scripts/heuristics_snapshot.py` snapshot:
|
||||
|
||||
```sh
|
||||
.venv/bin/python scripts/eval_heuristics.py '<dumps folder>' --verbose
|
||||
.venv/bin/python scripts/eval_heuristics.py --snapshot analyses.json --split all --json baseline.json
|
||||
.venv/bin/python scripts/eval_heuristics.py --snapshot analyses.json --fit-report --out fit-report.md
|
||||
```
|
||||
|
||||
Use a scratch `FRAMEPORT_HOME`, `FRAMEPORT_NO_CATALOG_UPDATE=1`, and an empty `FRAMEPORT_WINDOWS_DATA` for offline
|
||||
measurement. Snapshot mode reads no APKs or game data. Each snapshot read copies the source to a temporary file,
|
||||
then parses the copy, retrying incomplete JSON. To compare multiple splits against an actively growing snapshot,
|
||||
freeze one valid copy first and use that file for every run.
|
||||
|
||||
The default split is `dev`. `split_packages({package: analysis_engine})` sorts packages by SHA-256 within Unity,
|
||||
Unreal, and Other strata, then holds out the nearest 20% (half rounded up). Pass only packages with both an analysis
|
||||
and a catalog entry. The same input set always yields the same split regardless of iteration order; adding packages
|
||||
can move the cutoff. Very small strata can have no holdout. Unsupported entries participate in splitting.
|
||||
|
||||
## What is scored
|
||||
|
||||
Ground truth contains catalog choices and patch registry defaults, with no inherited heuristic adapter settings.
|
||||
Default expectations are determined per analysis, using each default patch's own detection in the same way as the
|
||||
engine's initial heuristic pass. Thus a conditional default that is not detected (e.g. LTW on a game without LTW)
|
||||
is not expected. Explicit catalog `overport_remove` and `frame_remove` entries remove expected defaults. An absent
|
||||
expected default is represented as `remove:<patch id>`; enabled defaults do not inflate patch scores.
|
||||
|
||||
Opt-in overport/frame/device/PC VR IDs, default removals, adapter values, device-file names, foveation parameters,
|
||||
and alternate-build patch IDs are compared. Alternate-build identities use `alt:<patch id>` so choosing one in a
|
||||
fallback is distinct from choosing it in the primary build. Device-file content is not compared. Adapter values
|
||||
must match: a wrong value contributes both an FP and an FN for `adapter.<key>`. Patch precision, recall and F1 are
|
||||
micro-averaged across games; an empty denominator is reported as zero. Exact-game accuracy also compares the
|
||||
unsupported status flag. Fields report matching games, including empty-field matches.
|
||||
|
||||
`RUNTIME_ONLY` lists choices excluded from the headline: `use_alt`, selected runtime adapter settings, Lepton
|
||||
environment variables, and config-sync settings. They have their own exact, field and patch metrics. Catalog
|
||||
entries with status `unsupported` are excluded from both headline and runtime supported-game metrics, and have
|
||||
separate metrics and per-game diffs. Converted analyses are included and marked. JSON contains all metrics per
|
||||
engine, all game signatures, split assignments, and missing/extra decisions.
|
||||
|
||||
## Dated score: catalog gaps vs. detection errors
|
||||
|
||||
The strict score treats the catalog as complete, but older entries were verified before many patches existed, and
|
||||
a catalog recipe inherits every heuristic adapter setting and device patch (except `device.text_input_window`)
|
||||
without listing them (`engine.suggest`), so the verified build had those. The dated score (`headline_dated`, printed
|
||||
under "Dated") sets aside, on both sides:
|
||||
|
||||
- patches first committed after the entry's `verified.date` (the verifier couldn't have chosen them), and
|
||||
- inherited adapter/device picks (`inherited_decisions`).
|
||||
|
||||
What remains are real disagreements: an extra on an entry verified after the patch existed, or a catalog choice
|
||||
the heuristics don't make. Gaps are counted per patch ("Catalog gaps by patch"). The dated score needs the intro
|
||||
date cache that `--fit-report` writes; without it only inherited picks are set aside.
|
||||
|
||||
Before tuning a rule to remove an "extra", check which kind it is: a gap on an old entry is a candidate for the
|
||||
catalog (fit report), not a detection error. Before adding a patch to a working entry, weigh its benefit (does it
|
||||
fix something the game hits?) against a rebuild prompt for everyone who installed it; adapter settings never need
|
||||
adding (inherited). Results 2026-10-15: strict 5/65 exact, F1 73.8%; dated 39/65, F1 88.6% (dev 90.5%, holdout
|
||||
79.5%). Remaining misses are one-game fixes found at runtime (e.g. Vader's gl_error_clear, BlazeRush's GL settings).
|
||||
|
||||
An evaluation writes JSON even when its headline has differences, then exits 1 for compatibility with the original
|
||||
script. It exits 0 when exact, or after producing a fit report.
|
||||
|
||||
## New patch fit
|
||||
|
||||
The fit report covers all catalog entries, independently of `--split`. Candidates come from the final heuristic
|
||||
recipe, preserving platform, flat-app, dependency, and 32-bit normalization rules. A candidate must also be opt-in,
|
||||
applicable and recommended by its own `detect()`. Entries already listing or explicitly removing the patch are
|
||||
excluded; a listed adapter key is excluded regardless of its value. This report proposes additions, not value edits.
|
||||
|
||||
Each candidate includes its reason, experimental/strict flags, first local git introduction date, catalog entry
|
||||
date (`verified.date`; `updated` only without one, since it also moves for unrelated edits), and whether the
|
||||
patch is newer. Unknown dates are marked unknown. Warnings
|
||||
come from the full catalog recipe plus that one candidate; they may include pre-existing conflicts. Missing-analysis
|
||||
entries appear as metadata-only rows. Intro dates, including unknown results, are cached in
|
||||
`patch-intro-dates.json` beside the snapshot (or dumps folder); subsequent runs only query uncached patch IDs.
|
||||
|
||||
`tests/test_eval_heuristics.py` uses synthetic data. The opt-in real-dump test in `tests/test_games.py` ratchets the
|
||||
supported all-split exact ratio and patch F1 against measured, rounded-down baseline constants. Machine-local
|
||||
`.eval-home/` and `.eval-results/` scratch outputs are ignored by git.
|
||||
@@ -167,10 +167,6 @@ FramePort downloads it, checks it, restarts and keeps your games and settings. *
|
||||
- Settings → **Updates** turns the check off or turns on **Install updates automatically**.
|
||||
- **Recipes** (the tested patches and settings for each game) update by themselves. A game whose recipe changed shows
|
||||
**Update on Frame**.
|
||||
- **A newer version of a game:** right-click it (or use **…** on its page) → **Update with a newer APK…** (Quest and
|
||||
Android games) or **Update with a newer version…** (Linux apps: the new AppImage, archive or folder; PC VR games:
|
||||
the new game folder). Its name, artwork, tags and your settings stay. Then click **Update on Frame** (or **on this
|
||||
PC**).
|
||||
- **Dev builds:** if you're asked to test a change before its release, use Settings → Updates → **Install the latest
|
||||
dev build…**. The next release then arrives as a normal update.
|
||||
|
||||
@@ -259,7 +255,6 @@ option. The main ones:
|
||||
|---|---|
|
||||
| `scan <folder>` / `list` / `show <game>` | add games, list the library, show a game's patches |
|
||||
| `add-linux <path> [--exe <program>]` | add a Linux app (AppImage, folder or archive) |
|
||||
| `new-version <game> <path>` | point a game at its newer version (APK, Linux app file/folder, PC VR game folder) |
|
||||
| `recipe <game> --enable/--disable <patch>` | change a game's patches (`patches` lists them all) |
|
||||
| `build <game>` / `install <game>` / `test <game>` | build, install on the Frame (`--to pc` for this PC), launch test |
|
||||
| `frame discover` / `frame connect` / `frame info` | find, connect to and describe the Frame |
|
||||
|
||||
+177
-100
@@ -2,7 +2,7 @@
|
||||
|
||||
# Patches
|
||||
|
||||
Every patch and game setting FramePort knows: 169 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
Every patch and game setting FramePort knows: 174 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
|
||||
Each entry says what the patch does, whether it changes the APK or only the install, when FramePort suggests it, its options, the catalog games whose recipe names it and the launch-test signatures that suggest it. Patches that are on by default are on for every game, so the catalog rarely names them.
|
||||
|
||||
@@ -11,9 +11,9 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
## Groups
|
||||
|
||||
- [Patches from OVRPort](#group-overport) (25)
|
||||
- [Steam Frame patches](#group-frame) (55)
|
||||
- [Game settings](#group-adapter) (68)
|
||||
- [Files and settings on the Frame](#group-device) (8)
|
||||
- [Steam Frame patches](#group-frame) (56)
|
||||
- [Game settings](#group-adapter) (71)
|
||||
- [Files and settings on the Frame](#group-device) (9)
|
||||
- [PC VR (Proton and Revive)](#group-pcvr) (13)
|
||||
|
||||
## Index
|
||||
@@ -82,7 +82,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [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 | 12 |
|
||||
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 28 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 102 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 85 |
|
||||
| [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 |
|
||||
@@ -96,6 +96,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Unreal: thumb follows the capacitive touches](#frame-unreal_thumb_touch) | `frame.unreal_thumb_touch` | Steam Frame patches | 7 |
|
||||
| [Vermillion: paint canvases in the scene](#frame-vermillion_scene_canvas) | `frame.vermillion_scene_canvas` | Steam Frame patches | 1 |
|
||||
| [Vivox voice chat: skip Android 12 audio routing](#frame-vivox_audio_route) | `frame.vivox_audio_route` | Steam Frame patches | 1 |
|
||||
| [Vulkan: list Android's external-memory extensions (this PC)](#frame-vk_android_extensions) | `frame.vk_android_extensions` | Steam Frame patches | |
|
||||
| [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 | 3 |
|
||||
| [Quiet Meta's VrApi loader (OpenXR games)](#frame-vrapi_stub) | `frame.vrapi_stub` | Steam Frame patches | 2 |
|
||||
@@ -136,17 +137,20 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Vulkan format workaround](#adapter-mutable_fix) | `adapter.mutable_fix` | Game settings | |
|
||||
| [Unity frame loop: wait for skipped frames](#adapter-ovrp_begin_gate) | `adapter.ovrp_begin_gate` | Game settings | 1 |
|
||||
| [Steadier hands (older Unity)](#adapter-ovrp_hold_physics) | `adapter.ovrp_hold_physics` | Game settings | 1 |
|
||||
| [Unity frame loop diagnostics (log)](#adapter-ovrp_trace) | `adapter.ovrp_trace` | Game settings | |
|
||||
| [See your room](#adapter-passthrough_emul) | `adapter.passthrough_emul` | Game settings | |
|
||||
| [Steadier head tracking](#adapter-pose_consistency) | `adapter.pose_consistency` | Game settings | 1 |
|
||||
| [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 | 14 |
|
||||
| [Treat newer Quest controllers as Touch](#adapter-profile_remap) | `adapter.profile_remap` | Game settings | |
|
||||
| [Main picture only](#adapter-projection_only) | `adapter.projection_only` | Game settings | |
|
||||
| [Animate fingers from touch](#adapter-proximity_emul) | `adapter.proximity_emul` | Game settings | |
|
||||
| [Keep pictures in bounds](#adapter-rect_clamp) | `adapter.rect_clamp` | Game settings | |
|
||||
| [Refresh rate](#adapter-refresh_rate) | `adapter.refresh_rate` | Game settings | 3 |
|
||||
| [Wait for the game's GPU](#adapter-release_wait) | `adapter.release_wait` | Game settings | |
|
||||
| [Re-create play space](#adapter-respace_kick) | `adapter.respace_kick` | Game settings | |
|
||||
| [Sharpness](#adapter-scale) | `adapter.scale` | Game settings | 4 |
|
||||
| [Cap the eye size](#adapter-scale_max) | `adapter.scale_max` | Game settings | |
|
||||
| [Room depth](#adapter-scene_depth) | `adapter.scene_depth` | Game settings | |
|
||||
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 9 |
|
||||
| [Room height](#adapter-scene_height) | `adapter.scene_height` | Game settings | |
|
||||
@@ -175,7 +179,8 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Hide Android's navigation bar](#device-hide_navbar) | `device.hide_navbar` | Files and settings on the Frame | |
|
||||
| [Extra Lepton settings](#device-lepton_env) | `device.lepton_env` | Files and settings on the Frame | 3 |
|
||||
| [Controllers as a gamepad (Steam Input)](#device-steam_gamepad) | `device.steam_gamepad` | Files and settings on the Frame | |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 105 |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 88 |
|
||||
| [AXRB mode on this PC](#pc-axrb_backend) | `pc.axrb_backend` | Files and settings on the Frame | |
|
||||
| [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) | |
|
||||
@@ -269,12 +274,12 @@ Removes a controller position adjustment, for games whose hands look misplaced.
|
||||
|
||||
Stops Meta's audio library from reporting to Meta (only matters on PC emulators).
|
||||
|
||||
- `patch_disable_meta_xr_audio_telemetry`
|
||||
- `patch_disable_meta_xr_audio_telemetry` · builds for this PC (AXRB) add it when needed
|
||||
- **Changes:** Changes the APK (during the OVRPort conversion).
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Source:** [patches/overport.py](../src/frameport/patches/overport.py)
|
||||
|
||||
> Skips Meta XR Audio's telemetry under x86_64 ARM translation (emulators). Not needed on the Frame, which runs games natively.
|
||||
> Skips Meta XR Audio's telemetry under x86_64 ARM translation (emulators). Not needed on the Frame, which runs games natively; added by itself to builds for this PC (AXRB) of games with Meta XR Audio.
|
||||
|
||||
### <a id="patch_disable_space_warp"></a>Disable application space warp if used
|
||||
|
||||
@@ -506,7 +511,7 @@ Runs a 32-bit game (Gear VR, Oculus Go, early Quest) on the Frame, which only ru
|
||||
|
||||
FramePort's layer between the game and the Steam Frame: fills in what the Frame lacks (controllers, room, passthrough). Needed for every converted game.
|
||||
|
||||
- `frame.adapter` · on by default
|
||||
- `frame.adapter` · on by default · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -523,7 +528,7 @@ FramePort's layer between the game and the Steam Frame: fills in what the Frame
|
||||
|
||||
Lets the game find extra content it keeps next to its data (for example Star Wars Tales' seasons).
|
||||
|
||||
- `frame.asset_files`
|
||||
- `frame.asset_files` · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** The game asks Meta's platform for asset files and its data has … content file(s) (*.pak) that OVRPort's loader doesn't report.
|
||||
- **Catalog games (1):** Star Wars: Tales from the Galaxy's Edge.
|
||||
@@ -535,7 +540,7 @@ Lets the game find extra content it keeps next to its data (for example Star War
|
||||
|
||||
Experimental bypass for a failed Meta asset-file check at startup. Reports an empty asset list and installed status without a file path; does not provide DLC. Experimental.
|
||||
|
||||
- `frame.asset_files_stub` · experimental
|
||||
- `frame.asset_files_stub` · experimental · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Suggested by launch-test signatures:**
|
||||
@@ -548,7 +553,7 @@ Experimental bypass for a failed Meta asset-file check at startup. Reports an em
|
||||
|
||||
Lets games start that would stop at Meta's avatars (for example BlazeRush). No Meta avatars.
|
||||
|
||||
- `frame.avatar_stub`
|
||||
- `frame.avatar_stub` · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Catalog games (1):** BlazeRush.
|
||||
@@ -576,7 +581,7 @@ Stops bHaptics from asking for a Bluetooth permission the Frame doesn't have (th
|
||||
|
||||
Makes BlazeRush's menus and controllers work without Meta's avatars, and lets its shader fixes take effect.
|
||||
|
||||
- `frame.blazerush`
|
||||
- `frame.blazerush` · also in builds for this PC (AXRB)
|
||||
- **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.
|
||||
- **Only for its game's build:** outside it the whole OVRPort run fails, so recipes only use it where it applies.
|
||||
@@ -589,7 +594,7 @@ Makes BlazeRush's menus and controllers work without Meta's avatars, and lets it
|
||||
|
||||
Stops Epic Online Services from retrying its login keychain forever (the game would never finish starting).
|
||||
|
||||
- `frame.eos_keychain_loop` · also for 2D apps
|
||||
- `frame.eos_keychain_loop` · also for 2D apps · also in builds for this PC (AXRB, experimental)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Suggested by launch-test signatures:**
|
||||
@@ -614,7 +619,7 @@ Experimental: shows HUDs, menus and other flat panels that stay black on the Fra
|
||||
|
||||
Corrects graphics code the Frame's drivers reject (black screen with sound).
|
||||
|
||||
- `frame.gl_shim` · experimental
|
||||
- `frame.gl_shim` · experimental · also in builds for this PC (AXRB, experimental)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** GLES engine resolving GL through eglGetProcAddress (GLAD) on the VrApi bridge.
|
||||
- **Requires:** [`frame.vrapi_bridge`](#frame-vrapi_bridge)
|
||||
@@ -654,7 +659,7 @@ Plays the game's videos on the Frame's video hardware, so 4K and 8K video runs s
|
||||
|
||||
Lets the game use language files already in its data instead of waiting for a download that never comes.
|
||||
|
||||
- `frame.langpacks` · experimental
|
||||
- `frame.langpacks` · experimental · also in builds for this PC (AXRB)
|
||||
- **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.
|
||||
- **Source:** [patches/frame/langpack.py](../src/frameport/patches/frame/langpack.py)
|
||||
@@ -665,7 +670,7 @@ Lets the game use language files already in its data instead of waiting for a do
|
||||
|
||||
Makes the game start from the Frame's Steam library.
|
||||
|
||||
- `frame.launcher` · on by default · also for 2D apps
|
||||
- `frame.launcher` · on by default · also for 2D apps · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Manifest uses category INFO only; Lepton needs LAUNCHER.
|
||||
- **Suggested (on):** Applied automatically if the manifest needs it.
|
||||
@@ -703,7 +708,7 @@ Lets games with a verified older Lua engine get past a startup crash on the Fram
|
||||
|
||||
Declares Meta's permissions some mixed-reality games ask for, so they don't stop.
|
||||
|
||||
- `frame.meta_permissions`
|
||||
- `frame.meta_permissions` · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Mixed-reality game that needs the room model: its 'use spatial data' permission must be granted (for example Demeter).
|
||||
- **Catalog games (2):** Arkio, Demeter.
|
||||
@@ -715,7 +720,7 @@ Declares Meta's permissions some mixed-reality games ask for, so they don't stop
|
||||
|
||||
Skips a Quest-only reporting step in Meta's audio library that crashes some games.
|
||||
|
||||
- `frame.metaxr_telemetry` · on by default
|
||||
- `frame.metaxr_telemetry` · on by default · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Meta XR Audio present; patched automatically if it has the telemetry lookup.
|
||||
- **Suggested by launch-test signatures:**
|
||||
@@ -728,7 +733,7 @@ Skips a Quest-only reporting step in Meta's audio library that crashes some game
|
||||
|
||||
Stops strict debugging checks that make some games quit.
|
||||
|
||||
- `frame.nodebug` · also for 2D apps
|
||||
- `frame.nodebug` · also for 2D apps · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unreal Engine ….…: older UE4 makes JNI calls CheckJNI rejects (for example Time Stall aborted on GetStringUTFChars(NULL)).
|
||||
- **Suggested (on):** Unreal build of Meta XR Audio: its telemetry lookup leaves a pending JNI exception that CheckJNI turns into an abort (for example NOPE Challenge).
|
||||
@@ -744,7 +749,7 @@ Stops strict debugging checks that make some games quit.
|
||||
|
||||
Provides stand-ins for Quest system calls some games make at start.
|
||||
|
||||
- `frame.oculusos`
|
||||
- `frame.oculusos` · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** The Unreal build of Meta XR Audio looks up com.oculus.os.AnalyticsEvent and aborts without it (for example NOPE Challenge).
|
||||
- **Suggested (on):** Several Meta SDK libraries look up com.oculus.os.AnalyticsEvent (…); games like this abort without the stub (for example Nano).
|
||||
@@ -760,7 +765,7 @@ Provides stand-ins for Quest system calls some games make at start.
|
||||
|
||||
Stops a crash a few seconds after the logo in games with voice chat.
|
||||
|
||||
- `frame.ovr_microphone`
|
||||
- `frame.ovr_microphone` · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** The game uses Meta's microphone API (ovr_Microphone_GetOutputBufferMaxSize), which crashes in OVRPort's library before the microphone is started.
|
||||
- **Suggested by launch-test signatures:**
|
||||
@@ -773,7 +778,7 @@ Stops a crash a few seconds after the logo in games with voice chat.
|
||||
|
||||
Keeps your progress in games that file their saves under your Meta user.
|
||||
|
||||
- `frame.ovr_stable_user` · on by default
|
||||
- `frame.ovr_stable_user` · on by default · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** The game uses Meta's platform library: OVRPort's version gives it a new user id at every start, which loses saves filed under that id.
|
||||
- **Source:** [patches/frame/ovr_stable_user.py](../src/frameport/patches/frame/ovr_stable_user.py)
|
||||
@@ -784,7 +789,7 @@ Keeps your progress in games that file their saves under your Meta user.
|
||||
|
||||
For troubleshooting: records the game's requests to Meta's online services.
|
||||
|
||||
- `frame.ovr_trace` · experimental · also for 2D apps
|
||||
- `frame.ovr_trace` · experimental · also for 2D apps · also in builds for this PC (AXRB)
|
||||
- **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.
|
||||
- **Source:** [patches/frame/ovr_trace.py](../src/frameport/patches/frame/ovr_trace.py)
|
||||
@@ -795,7 +800,7 @@ For troubleshooting: records the game's requests to Meta's online services.
|
||||
|
||||
Adds a small piece of Meta's platform library that some games need to start.
|
||||
|
||||
- `frame.ovrplatformcompat` · on by default
|
||||
- `frame.ovrplatformcompat` · on by default · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Applied automatically when the OVRPort build needs it.
|
||||
- **Suggested by launch-test signatures:**
|
||||
@@ -808,7 +813,7 @@ Adds a small piece of Meta's platform library that some games need to start.
|
||||
|
||||
Provides stand-ins for Meta store functions the game expects, so it doesn't crash at start.
|
||||
|
||||
- `frame.ovrstubs` · on by default
|
||||
- `frame.ovrstubs` · on by default · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Applied automatically when the OVRPort build needs it.
|
||||
- **Suggested by launch-test signatures:**
|
||||
@@ -875,7 +880,7 @@ Opens the app straight in VR, skipping its flat launcher screen. Turn off to use
|
||||
|
||||
Allows very large pictures (8K video, theaters) instead of quitting.
|
||||
|
||||
- `frame.swapchain_limit` · on by default
|
||||
- `frame.swapchain_limit` · on by default · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Video player (…): creates swapchains larger than 4096 px (8K video, theater textures), which OVRPort's dispatcher aborts on.
|
||||
- **Suggested (on):** Recommended for every game: OVRPort aborts on swapchains larger than 4096 px; the Frame's runtime handles them.
|
||||
@@ -915,7 +920,7 @@ Stops a gray or frozen screen in Unity games that turn on anti-aliasing while ru
|
||||
|
||||
Draws each eye separately. Try it when one eye shows a gray or broken picture.
|
||||
|
||||
- `frame.unity_multipass` · revision 2
|
||||
- `frame.unity_multipass` · also in builds for this PC (AXRB, experimental) · revision 2
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Catalog games (1):** I Am Cat.
|
||||
@@ -956,7 +961,7 @@ Stops a gray screen crash in some Unity games by skipping their overlay layers (
|
||||
|
||||
Lets older Unity games start VR without Meta's system apps (they'd stay on a 2D screen).
|
||||
|
||||
- `frame.unity_oculus_check` · revision 5
|
||||
- `frame.unity_oculus_check` · also in builds for this PC (AXRB) · revision 5
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity with built-in Oculus support: it checks for Meta's system apps before starting VR, else it runs as a 2D app (for example Accounting+, BattleSisters).
|
||||
- **Catalog games (12):** Accounting+, Audica, BattleSisters, Cosmodread, End Space, Eye of the Temple, Jurassic World Aftermath Collection, Mini Motor Racing X and 4 more.
|
||||
@@ -986,10 +991,10 @@ Keeps a smoothing setting off that the game turns on itself and that can freeze
|
||||
|
||||
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
|
||||
- `frame.unity_text_input` · also for 2D apps · also in builds for this PC (AXRB)
|
||||
- **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 (102):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Audica, AugmentedEmpire and 94 more.
|
||||
- **Catalog games (85):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, AugmentedEmpire, Battlemarked, BattleSisters and 77 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.
|
||||
@@ -998,9 +1003,8 @@ Lets you type into this app's text fields on the Frame (they'd close at once).
|
||||
|
||||
Lets Unity 6 games with many native libraries start when they run out of thread keys (they'd abort with "cannot create thread specific key"), for example Wander.
|
||||
|
||||
- `frame.unity_tls_key_pool` · also for 2D apps
|
||||
- `frame.unity_tls_key_pool` · also for 2D apps · also in builds for this PC (AXRB, experimental)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity 6 with Firebase Firestore and Gecko: these native runtimes can exhaust Android's 128 thread keys alongside the Frame's graphics and XR libraries. Pool Unity's keys to leave room for them.
|
||||
- **Offered (off):** Unity 6 with …: turn on if the game aborts at start with "cannot create thread specific key" (for example Wander).
|
||||
- **Catalog games (1):** Wander.
|
||||
- **Suggested by launch-test signatures:**
|
||||
@@ -1053,7 +1057,7 @@ Shows the full environment when its upper part is clipped but menu panels look c
|
||||
|
||||
Lets older Unreal games start VR on the Frame (for example Star Wars Pinball VR).
|
||||
|
||||
- `frame.unreal_ovrp_entrypoints`
|
||||
- `frame.unreal_ovrp_entrypoints` · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unreal looks up OVRPlugin function(s) OVRPort's OVRPlugin lacks: ……. Without them Unreal starts no VR.
|
||||
- **Catalog games (2):** Ghostbusters: Rise of the Ghost Lord, Star Wars Pinball VR.
|
||||
@@ -1065,7 +1069,7 @@ Lets older Unreal games start VR on the Frame (for example Star Wars Pinball VR)
|
||||
|
||||
Makes grip, trigger and touch controls reach the game's hands (it picked the empty Gear VR controls on the Frame, for example Vader Immortal).
|
||||
|
||||
- `frame.unreal_quest_keymap`
|
||||
- `frame.unreal_quest_keymap` · also in builds for this PC (AXRB)
|
||||
- **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 (3):** Vader Immortal: Episode I, Vader Immortal: Episode II, Vader Immortal: Episode III.
|
||||
@@ -1077,7 +1081,7 @@ Makes grip, trigger and touch controls reach the game's hands (it picked the emp
|
||||
|
||||
Lets the game leave its loading screen: it waited for a Quest-only step that never starts on the Frame (for example Vader Immortal).
|
||||
|
||||
- `frame.unreal_quest_precompile`
|
||||
- `frame.unreal_quest_precompile` · also in builds for this PC (AXRB)
|
||||
- **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 (3):** Vader Immortal: Episode I, Vader Immortal: Episode II, Vader Immortal: Episode III.
|
||||
@@ -1089,7 +1093,7 @@ Lets the game leave its loading screen: it waited for a Quest-only step that nev
|
||||
|
||||
Starts Unreal games without re-checking their data file (they'd seem to hang on an invisible check screen).
|
||||
|
||||
- `frame.unreal_skip_obb_check` · also for 2D apps
|
||||
- `frame.unreal_skip_obb_check` · also for 2D apps · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unreal checks the OBB's contents before starting (bVerifyOBBOnStartUp); on the Frame a failed or slow check can't be seen and the game seems to hang.
|
||||
- **Catalog games (5):** Dance Central, Kill It With Fire VR, MacacoVR, Maestro, Ragnarock.
|
||||
@@ -1103,7 +1107,7 @@ Starts Unreal games without re-checking their data file (they'd seem to hang on
|
||||
|
||||
Skips a separate data-file check when an Unreal game still hangs at startup with the content check off. Experimental; the game still needs its data.
|
||||
|
||||
- `frame.unreal_skip_obb_delivery` · experimental · also for 2D apps
|
||||
- `frame.unreal_skip_obb_delivery` · experimental · also for 2D apps · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Catalog games (1):** Medal of Honor: Above and Beyond.
|
||||
@@ -1131,7 +1135,7 @@ Lets the game finish loading when its separate loading splash stalls the render
|
||||
|
||||
Makes the hands' thumbs follow what your thumb touches (they always pointed up: the Frame doesn't sense a thumb hovering, for example Vader Immortal).
|
||||
|
||||
- `frame.unreal_thumb_touch`
|
||||
- `frame.unreal_thumb_touch` · also in builds for this PC (AXRB)
|
||||
- **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.
|
||||
@@ -1165,11 +1169,24 @@ Lets games with Vivox voice chat start on the Frame (they'd crash on an Android
|
||||
|
||||
> Vivox's voice-chat SDK (for example Green Hell VR) checks the audio route with Android 12 methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the game crashes at start with a NoSuchMethodError. Those Vivox methods return at once instead (an in-place edit of classes.dex); voice chat keeps the default audio route.
|
||||
|
||||
### <a id="frame-vk_android_extensions"></a>Vulkan: list Android's external-memory extensions (this PC)
|
||||
|
||||
On this PC only: lets games that draw with Vulkan themselves (for example The Climb 2) start in AXRB's direct mode.
|
||||
|
||||
- `frame.vk_android_extensions` · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `axrb-direct-vulkan-extensions` (fatal): CryEngine found its Vulkan device without extensions every Quest has (VK_EXT_fragment_density_map, Android's external memory) and gave up before creating it; the PC's graphics driver doesn't have them. "Vulkan - list Android's external-memory extensions (this PC)" lists them for the game in builds for this PC, and AXRB leaves them out again when the device is created.
|
||||
- **Source:** [patches/frame/vk_android_extensions.py](../src/frameport/patches/frame/vk_android_extensions.py)
|
||||
|
||||
> Only in builds for this PC (AXRB's direct mode). Direct mode gives the game the Windows graphics driver's Vulkan device, which lacks extensions every Quest has: Android's external memory (VK_KHR_external_memory_fd, VK_ANDROID_external_memory_android_hardware_buffer, VK_EXT_queue_family_foreign) and VK_EXT_fragment_density_map. Engines that call VrApi with Vulkan themselves, such as CryEngine in The Climb 2, refuse to create their device without them ("CrySystem initialization failed" right after "Renderer initialization"). The game's renderer gets them listed (libfp_vkext.so); AXRB leaves them out again when the device is created.
|
||||
|
||||
### <a id="frame-vk_sanitize"></a>Vulkan: drop invalid render-pass pointers
|
||||
|
||||
Cleans up graphics data that crashes some Unreal games on the Frame.
|
||||
|
||||
- `frame.vk_sanitize` · on by default
|
||||
- `frame.vk_sanitize` · on by default · also in builds for this PC (AXRB, experimental)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Own-engine game: applied when one of its libraries loads Vulkan by name (for example Roblox); keeps Lepton's Fossilize layer from crashing on uninitialized pointers.
|
||||
- **Suggested (on):** Unreal game: applied automatically when the engine loads Vulkan by name; keeps Lepton's Fossilize layer from crashing on uninitialized pointers (for example Deadpool VR).
|
||||
@@ -1185,7 +1202,7 @@ Cleans up graphics data that crashes some Unreal games on the Frame.
|
||||
|
||||
Lets games built on Meta's oldest VR interface run on the Frame.
|
||||
|
||||
- `frame.vrapi_bridge` · experimental
|
||||
- `frame.vrapi_bridge` · experimental · also in builds for this PC (AXRB)
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** The engine calls libvrapi.so directly; OVRPort can't translate it.
|
||||
- **Conflicts with:** [`frame.vrapi_stub`](#frame-vrapi_stub)
|
||||
@@ -1201,7 +1218,7 @@ Lets games built on Meta's oldest VR interface run on the Frame.
|
||||
|
||||
Stops Meta's old VR library from closing the game at start (for example Jurassic World Aftermath).
|
||||
|
||||
- `frame.vrapi_stub`
|
||||
- `frame.vrapi_stub` · also in builds for this PC (AXRB)
|
||||
- **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.
|
||||
- **Conflicts with:** [`frame.vrapi_bridge`](#frame-vrapi_bridge)
|
||||
@@ -1232,7 +1249,7 @@ Settings of FrameBridge, FramePort's OpenXR layer inside every converted Quest g
|
||||
|
||||
Moves where the pointer starts, forward or back.
|
||||
|
||||
- `adapter.aim_forward`
|
||||
- `adapter.aim_forward` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1246,7 +1263,7 @@ Moves where the pointer starts, forward or back.
|
||||
|
||||
If the pointer doesn't hit what you aim at, tilt it up or down.
|
||||
|
||||
- `adapter.aim_pitch`
|
||||
- `adapter.aim_pitch` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1260,7 +1277,7 @@ If the pointer doesn't hit what you aim at, tilt it up or down.
|
||||
|
||||
Turns the pointer left or right.
|
||||
|
||||
- `adapter.aim_yaw`
|
||||
- `adapter.aim_yaw` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1274,7 +1291,7 @@ Turns the pointer left or right.
|
||||
|
||||
Turn off only for games you play with your hands instead of controllers.
|
||||
|
||||
- `adapter.controller_fix`
|
||||
- `adapter.controller_fix` · also in builds for this PC (AXRB)
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
- **Suggested (on), value `0`:** Hand tracking is required by the game: pass hands through instead of reporting Touch controllers (for example Silhouette).
|
||||
- **Options:**
|
||||
@@ -1306,7 +1323,7 @@ For games that show the headset's own controllers. Takes effect after a reinstal
|
||||
|
||||
Keeps games running that use a 360° cube layer the Frame lacks (for example Budget Cuts Ultimate). Leave it on.
|
||||
|
||||
- `adapter.cube_standin`
|
||||
- `adapter.cube_standin` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1320,7 +1337,7 @@ Keeps games running that use a 360° cube layer the Frame lacks (for example Bud
|
||||
|
||||
The Frame can't show curved panels; this shows them as gently bent strips.
|
||||
|
||||
- `adapter.cylinder_strips`
|
||||
- `adapter.cylinder_strips` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1334,7 +1351,7 @@ The Frame can't show curved panels; this shows them as gently bent strips.
|
||||
|
||||
Video players' virtual theaters and 360° videos would otherwise stay black.
|
||||
|
||||
- `adapter.equirect_emul`
|
||||
- `adapter.equirect_emul` · also in builds for this PC (AXRB)
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
- **Suggested (on), value `1`:** The game draws 360° layers (for example a video player's theater or 360° videos), which the Frame's runtime can't show: show them as panels around you.
|
||||
- **Options:**
|
||||
@@ -1351,7 +1368,7 @@ Video players' virtual theaters and 360° videos would otherwise stay black.
|
||||
|
||||
Higher keeps more detail but uses more memory.
|
||||
|
||||
- `adapter.equirect_face`
|
||||
- `adapter.equirect_face` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1365,7 +1382,7 @@ Higher keeps more detail but uses more memory.
|
||||
|
||||
Only if 360° pictures show wrongly.
|
||||
|
||||
- `adapter.equirect_flip`
|
||||
- `adapter.equirect_flip` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1379,7 +1396,7 @@ Only if 360° pictures show wrongly.
|
||||
|
||||
Must be at least the video's own frame rate.
|
||||
|
||||
- `adapter.equirect_fps`
|
||||
- `adapter.equirect_fps` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1393,7 +1410,7 @@ Must be at least the video's own frame rate.
|
||||
|
||||
Higher is sharper but needs more power.
|
||||
|
||||
- `adapter.equirect_res`
|
||||
- `adapter.equirect_res` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1407,7 +1424,7 @@ Higher is sharper but needs more power.
|
||||
|
||||
Shows 3D 360° pictures flat if they look doubled.
|
||||
|
||||
- `adapter.equirect_stereo`
|
||||
- `adapter.equirect_stereo` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1421,7 +1438,7 @@ Shows 3D 360° pictures flat if they look doubled.
|
||||
|
||||
Logs details when one eye looks wrong. No effect on the game.
|
||||
|
||||
- `adapter.eye_debug`
|
||||
- `adapter.eye_debug` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1435,7 +1452,7 @@ Logs details when one eye looks wrong. No effect on the game.
|
||||
|
||||
For games whose fades to black don't show, so you see the level loading (for example Batman: Arkham Shadow).
|
||||
|
||||
- `adapter.fade_quad`
|
||||
- `adapter.fade_quad` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1450,7 +1467,7 @@ For games whose fades to black don't show, so you see the level loading (for exa
|
||||
|
||||
Saves a few UI panels before and after FrameBridge turns them upright, for testing without the headset.
|
||||
|
||||
- `adapter.flip_dump`
|
||||
- `adapter.flip_dump` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1464,7 +1481,7 @@ Saves a few UI panels before and after FrameBridge turns them upright, for testi
|
||||
|
||||
Turns menus and text the right way up.
|
||||
|
||||
- `adapter.flip_emul`
|
||||
- `adapter.flip_emul` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1478,7 +1495,7 @@ Turns menus and text the right way up.
|
||||
|
||||
Older way to turn upside-down panels; prefer Fix upside-down menus.
|
||||
|
||||
- `adapter.flip_quads`
|
||||
- `adapter.flip_quads` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1524,7 +1541,7 @@ Longer for headsets that briefly think they're off your head.
|
||||
|
||||
Hides a Quest-only way of saving GPU power that the Frame lacks. Leave it on.
|
||||
|
||||
- `adapter.foveation_fix`
|
||||
- `adapter.foveation_fix` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1538,7 +1555,7 @@ Hides a Quest-only way of saving GPU power that the Frame lacks. Leave it on.
|
||||
|
||||
For OpenGL ES games whose memory use keeps growing (e.g. Vader Immortal).
|
||||
|
||||
- `adapter.gl_error_clear`
|
||||
- `adapter.gl_error_clear` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1627,7 +1644,7 @@ Logs details when flat panels stay black. No effect on the game.
|
||||
|
||||
For games that freeze the Frame when a controller vibrates (for example Lucky's Tale). Needs a rebuild.
|
||||
|
||||
- `adapter.haptic_fix` · revision 2
|
||||
- `adapter.haptic_fix` · also in builds for this PC (AXRB) · revision 2
|
||||
- **Changes:** Game setting, stored in the build: needs a rebuild.
|
||||
- **Suggested (on), value `1`:** Meta's OVRPlugin vibrates controllers through OVRPort's loader, which turns some vibrations into gigabyte allocations that freeze the Frame (for example Lucky's Tale, BattleSisters): vibrations are converted before they reach it.
|
||||
- **Options:**
|
||||
@@ -1642,7 +1659,7 @@ For games that freeze the Frame when a controller vibrates (for example Lucky's
|
||||
|
||||
Turn it down if controller vibrations are too strong.
|
||||
|
||||
- `adapter.haptic_scale`
|
||||
- `adapter.haptic_scale` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1656,7 +1673,7 @@ Turn it down if controller vibrations are too strong.
|
||||
|
||||
For games whose picture flickers or smears (for example Into The Radius 2).
|
||||
|
||||
- `adapter.hide_space_warp`
|
||||
- `adapter.hide_space_warp` · also in builds for this PC (AXRB)
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
- **Offered (off), value `1`:** Unreal Engine 5 game with Meta's space warp: turn on if the picture flickers or smears (for example Into The Radius 2).
|
||||
- **Options:**
|
||||
@@ -1673,7 +1690,7 @@ For games whose picture flickers or smears (for example Into The Radius 2).
|
||||
|
||||
Logs details when buttons do nothing or the wrong thing. No effect on the game.
|
||||
|
||||
- `adapter.input_diag`
|
||||
- `adapter.input_diag` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1687,7 +1704,7 @@ Logs details when buttons do nothing or the wrong thing. No effect on the game.
|
||||
|
||||
Logs details for troubleshooting. No effect on the game.
|
||||
|
||||
- `adapter.layer_debug`
|
||||
- `adapter.layer_debug` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1701,7 +1718,7 @@ Logs details for troubleshooting. No effect on the game.
|
||||
|
||||
Skips picture layers the Frame would refuse. Leave it on.
|
||||
|
||||
- `adapter.layer_fix`
|
||||
- `adapter.layer_fix` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1715,7 +1732,7 @@ Skips picture layers the Frame would refuse. Leave it on.
|
||||
|
||||
Experimental. Leave it off unless asked to test it.
|
||||
|
||||
- `adapter.mutable_fix`
|
||||
- `adapter.mutable_fix` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1729,7 +1746,7 @@ Experimental. Leave it off unless asked to test it.
|
||||
|
||||
For older Unity games that freeze at the first scene change (for example Sniper Elite VR). Can make loading screens stutter.
|
||||
|
||||
- `adapter.ovrp_begin_gate`
|
||||
- `adapter.ovrp_begin_gate` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1744,7 +1761,7 @@ For older Unity games that freeze at the first scene change (for example Sniper
|
||||
|
||||
For older Unity games whose hands trail the controllers (for example Sniper Elite VR). In others the hands lag.
|
||||
|
||||
- `adapter.ovrp_hold_physics`
|
||||
- `adapter.ovrp_hold_physics` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1755,11 +1772,25 @@ For older Unity games whose hands trail the controllers (for example Sniper Elit
|
||||
|
||||
> Unity's built-in Oculus frame loop (frame.unity_oculus_check): don't pass Unity's physics-step pose update on, so the hands use the frame's display-time poses. Fixes hands trailing the controllers in some games (for example Sniper Elite VR); in others it makes the hands lag (for example BattleSisters).
|
||||
|
||||
### <a id="adapter-ovrp_trace"></a>Unity frame loop diagnostics (log)
|
||||
|
||||
Logs where an older Unity game's frame loop waits. No effect on the game.
|
||||
|
||||
- `adapter.ovrp_trace` · also in builds for this PC (AXRB)
|
||||
- **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 → "Unity frame loop diagnostics (log)" (advanced).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Unity's built-in Oculus frame loop (frame.unity_oculus_check): every 5 s, logs how many frame updates, waits, begins and ends went through and which one is in progress (thread, frame, for how long), to see where a game that stops after a few frames waits. No effect on the game.
|
||||
|
||||
### <a id="adapter-passthrough_emul"></a>See your room
|
||||
|
||||
Shows the Frame's cameras where the game expects passthrough.
|
||||
|
||||
- `adapter.passthrough_emul`
|
||||
- `adapter.passthrough_emul` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1775,7 +1806,7 @@ Shows the Frame's cameras where the game expects passthrough.
|
||||
|
||||
For games that judder or shimmer while you hold your head still (for example I Am Cat).
|
||||
|
||||
- `adapter.pose_consistency` · revision 2
|
||||
- `adapter.pose_consistency` · also in builds for this PC (AXRB) · revision 2
|
||||
- **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:**
|
||||
@@ -1790,7 +1821,7 @@ For games that judder or shimmer while you hold your head still (for example I A
|
||||
|
||||
Logs how game poses line up with the Frame's clock. No effect on the game.
|
||||
|
||||
- `adapter.pose_debug`
|
||||
- `adapter.pose_debug` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1804,7 +1835,7 @@ Logs how game poses line up with the Frame's clock. No effect on the game.
|
||||
|
||||
For games whose hands trail the controllers when you move them (for example BattleSisters).
|
||||
|
||||
- `adapter.pose_time_fix`
|
||||
- `adapter.pose_time_fix` · also in builds for this PC (AXRB)
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
- **Suggested (on), value `1`:** Unity's built-in Oculus support reads controller poses through OVRPlugin at its monotonic "now", seconds in the past on the Frame: they are located at the right time (for example BattleSisters, Sniper Elite VR).
|
||||
- **Suggested (on), value `1`:** Unreal 4's Oculus input of this OVRPlugin generation asks for controller poses at OVRPlugin's own clock, which is behind the Frame's: they are located at the right time (for example Vader Immortal).
|
||||
@@ -1820,7 +1851,7 @@ For games whose hands trail the controllers when you move them (for example Batt
|
||||
|
||||
For apps that only know Quest 3/Pro controllers: without it they get no buttons.
|
||||
|
||||
- `adapter.profile_remap`
|
||||
- `adapter.profile_remap` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1830,11 +1861,25 @@ For apps that only know Quest 3/Pro controllers: without it they get no buttons.
|
||||
|
||||
> Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead (components Touch lacks are dropped). Without it such apps get no controller input.
|
||||
|
||||
### <a id="adapter-projection_only"></a>Main picture only
|
||||
|
||||
Shows only the game's main picture, without menus or overlays drawn as extra layers.
|
||||
|
||||
- `adapter.projection_only` · also in builds for this PC (AXRB)
|
||||
- **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 → "Main picture only" (advanced).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Frames that carry more than the game's main picture (menus or overlays as extra layers) submit the main picture alone. For runtimes that refuse such frames: AXRB's emulator on this PC rejects every multi-layer OpenGL ES frame. The extra layers don't show; 2 (OpenGL ES games, the default on this PC) draws the flat panels that come after the main picture into it first, so menus and messages drawn as panels still show.
|
||||
|
||||
### <a id="adapter-proximity_emul"></a>Animate fingers from touch
|
||||
|
||||
For games whose hands' thumbs never move when you touch the buttons or thumbstick (for example Vader Immortal).
|
||||
|
||||
- `adapter.proximity_emul`
|
||||
- `adapter.proximity_emul` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1848,7 +1893,7 @@ For games whose hands' thumbs never move when you touch the buttons or thumbstic
|
||||
|
||||
Stops games from hanging when their eye picture is a little too big (for example PowerWash Simulator). Leave it on.
|
||||
|
||||
- `adapter.rect_clamp`
|
||||
- `adapter.rect_clamp` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1862,7 +1907,7 @@ Stops games from hanging when their eye picture is a little too big (for example
|
||||
|
||||
How often the picture updates. Higher is smoother if the game can keep up.
|
||||
|
||||
- `adapter.refresh_rate`
|
||||
- `adapter.refresh_rate` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1877,7 +1922,7 @@ How often the picture updates. Higher is smoother if the game can keep up.
|
||||
|
||||
For games where one eye flickers or jitters. Costs some speed.
|
||||
|
||||
- `adapter.release_wait`
|
||||
- `adapter.release_wait` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1891,7 +1936,7 @@ For games where one eye flickers or jitters. Costs some speed.
|
||||
|
||||
Re-creates the game's play space after the first frames. For testing.
|
||||
|
||||
- `adapter.respace_kick`
|
||||
- `adapter.respace_kick` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1905,7 +1950,7 @@ Re-creates the game's play space after the first frames. For testing.
|
||||
|
||||
Higher looks sharper but needs more power; lower runs smoother. 1.0× is the game's own.
|
||||
|
||||
- `adapter.scale`
|
||||
- `adapter.scale` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1916,11 +1961,25 @@ Higher looks sharper but needs more power; lower runs smoother. 1.0× is the gam
|
||||
|
||||
> Multiplies the recommended eye-buffer width and height (0.5–2.0). 1.5 ≈ 2.25× pixels.
|
||||
|
||||
### <a id="adapter-scale_max"></a>Cap the eye size
|
||||
|
||||
Keeps games from rendering larger than the resolution scale allows.
|
||||
|
||||
- `adapter.scale_max` · also in builds for this PC (AXRB)
|
||||
- **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 → "Cap the eye size" (advanced).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> With a resolution scale below 1, the largest eye size the game is told about is the scaled one too, for games that size their eye pictures from the maximum (for example Unity 2017 games). On by default on this PC for OpenGL ES games, whose pictures AXRB's emulator copies pixel by pixel.
|
||||
|
||||
### <a id="adapter-scene_depth"></a>Room depth
|
||||
|
||||
0 = the size of your play area.
|
||||
|
||||
- `adapter.scene_depth`
|
||||
- `adapter.scene_depth` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1934,7 +1993,7 @@ Higher looks sharper but needs more power; lower runs smoother. 1.0× is the gam
|
||||
|
||||
Mixed-reality games that build their level from your room get a simple room with walls.
|
||||
|
||||
- `adapter.scene_emul`
|
||||
- `adapter.scene_emul` · also in builds for this PC (AXRB)
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
- **Suggested (on), value `1`:** Mixed-reality game that builds its level from the room model: emulate a guardian-sized room (for example Demeter).
|
||||
- **Suggested (on), value `1`:** The game asks Meta's system for your room layout (scene), which the Frame doesn't have: emulate a room from the play area (for example VR HOT's room setup).
|
||||
@@ -1952,7 +2011,7 @@ Mixed-reality games that build their level from your room get a simple room with
|
||||
|
||||
Ceiling height of the pretend room.
|
||||
|
||||
- `adapter.scene_height`
|
||||
- `adapter.scene_height` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1966,7 +2025,7 @@ Ceiling height of the pretend room.
|
||||
|
||||
0 = the size of your play area.
|
||||
|
||||
- `adapter.scene_width`
|
||||
- `adapter.scene_width` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1980,7 +2039,7 @@ Ceiling height of the pretend room.
|
||||
|
||||
Saves what the game draws every few seconds, for testing without the headset.
|
||||
|
||||
- `adapter.snapshot`
|
||||
- `adapter.snapshot` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -1994,7 +2053,7 @@ Saves what the game draws every few seconds, for testing without the headset.
|
||||
|
||||
For games whose menus or screens jump to wherever you look.
|
||||
|
||||
- `adapter.stable_local`
|
||||
- `adapter.stable_local` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -2009,7 +2068,7 @@ For games whose menus or screens jump to wherever you look.
|
||||
|
||||
Diagnostics: removes layer color fades. Some fades no longer show.
|
||||
|
||||
- `adapter.strip_color_bias`
|
||||
- `adapter.strip_color_bias` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -2023,7 +2082,7 @@ Diagnostics: removes layer color fades. Some fades no longer show.
|
||||
|
||||
Removes depth information from the game's layers. For testing.
|
||||
|
||||
- `adapter.strip_depth`
|
||||
- `adapter.strip_depth` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -2037,7 +2096,7 @@ Removes depth information from the game's layers. For testing.
|
||||
|
||||
Shows videos that games play on a flat panel (for example an intro video).
|
||||
|
||||
- `adapter.surface_emul`
|
||||
- `adapter.surface_emul` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -2051,7 +2110,7 @@ Shows videos that games play on a flat panel (for example an intro video).
|
||||
|
||||
Shows Batman: Arkham Shadow's cutscenes; set by that game's recipe.
|
||||
|
||||
- `adapter.surface_native` · revision 4
|
||||
- `adapter.surface_native` · also in builds for this PC (AXRB) · revision 4
|
||||
- **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:**
|
||||
@@ -2066,7 +2125,7 @@ Shows Batman: Arkham Shadow's cutscenes; set by that game's recipe.
|
||||
|
||||
Shows each eye's picture to the other eye. For testing.
|
||||
|
||||
- `adapter.swap_eyes`
|
||||
- `adapter.swap_eyes` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -2080,7 +2139,7 @@ Shows each eye's picture to the other eye. For testing.
|
||||
|
||||
Lets OpenGL ES games start when the Frame refuses their picture format. Leave it on.
|
||||
|
||||
- `adapter.swapchain_fix`
|
||||
- `adapter.swapchain_fix` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -2113,7 +2172,7 @@ For games that crash right after you return to them (for example after the Steam
|
||||
|
||||
For Unreal Engine 5 games that crash on the Frame (for example Metro Awakening).
|
||||
|
||||
- `adapter.vk_hide_fdm`
|
||||
- `adapter.vk_hide_fdm` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -2131,7 +2190,7 @@ For Unreal Engine 5 games that crash on the Frame (for example Metro Awakening).
|
||||
|
||||
For Vulkan games whose models flicker or vanish while you look at them (for example Into The Radius 2). Needs a rebuild.
|
||||
|
||||
- `adapter.vk_query_slots` · revision 3
|
||||
- `adapter.vk_query_slots` · also in builds for this PC (AXRB) · revision 3
|
||||
- **Changes:** Game setting, stored in the build: needs a rebuild.
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:**
|
||||
@@ -2145,7 +2204,7 @@ For Vulkan games whose models flicker or vanish while you look at them (for exam
|
||||
|
||||
Saves the game's shaders to find one that hangs the GPU. No effect on the game.
|
||||
|
||||
- `adapter.vk_shader_dump`
|
||||
- `adapter.vk_shader_dump` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -2161,7 +2220,7 @@ Saves the game's shaders to find one that hangs the GPU. No effect on the game.
|
||||
|
||||
Corrects specific shaders that hang the game on the Frame. Comes from a game's recipe.
|
||||
|
||||
- `adapter.vk_shader_fix` · revision 2
|
||||
- `adapter.vk_shader_fix` · also in builds for this PC (AXRB) · revision 2
|
||||
- **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:**
|
||||
@@ -2176,7 +2235,7 @@ Corrects specific shaders that hang the game on the Frame. Comes from a game's r
|
||||
|
||||
For Unreal games whose models flicker or show through walls and windows on the Frame (for example Into The Radius 2). Needs a rebuild.
|
||||
|
||||
- `adapter.vk_spec_fixes` · revision 2
|
||||
- `adapter.vk_spec_fixes` · also in builds for this PC (AXRB) · revision 2
|
||||
- **Changes:** Game setting, stored in the build: needs a rebuild.
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:**
|
||||
@@ -2193,7 +2252,7 @@ For Unreal games whose models flicker or show through walls and windows on the F
|
||||
|
||||
Logs Vulkan mistakes the game makes. Needs a special test build.
|
||||
|
||||
- `adapter.vk_validation`
|
||||
- `adapter.vk_validation` · also in builds for this PC (AXRB)
|
||||
- **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:**
|
||||
@@ -2335,11 +2394,29 @@ 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 (105):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Audica and 97 more.
|
||||
- **Catalog games (88):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, AugmentedEmpire, Battlemarked and 80 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".
|
||||
|
||||
### <a id="pc-axrb_backend"></a>AXRB mode on this PC
|
||||
|
||||
For this PC only: run the game in AXRB's direct mode (no Android emulator) or in AXRB's Android emulator. Automatic uses direct mode for Vulkan games.
|
||||
|
||||
- `pc.axrb_backend` · also in builds for this PC (AXRB)
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:**
|
||||
- `backend`: str, default empty — direct or emulator (empty = automatic)
|
||||
- `translator`: str, default empty — dynarmic or digitalis (direct mode; empty = AXRB's default)
|
||||
- `precompose`: bool, default `False` — AXRB composes projection layers itself
|
||||
- `legacy_oculus_driver`: bool, default `False` — direct mode: provide com.oculus.systemdriver
|
||||
- `quest_identity`: bool, default `False` — direct mode: report a Quest 3 device
|
||||
- `controllers_only`: bool, default `False` — direct mode: hide hand tracking (needs legacy_oculus_driver)
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Only for installs on this PC (AXRB): which AXRB runs the game. Automatic picks AXRB's direct mode (AXRB-Emulatorless: the game's arm64 code runs in one Windows process, no Android emulator) for Vulkan games and the Android emulator for OpenGL ES games, since direct mode has no OpenGL ES renderer yet. The other parameters are direct mode's per-game options (AXRB's run_direct_game.ps1): translator (dynarmic, AXRB's default, or digitalis), precompose (AXRB composes the game's projection layers itself; also used by the emulator), and legacy_oculus_driver / quest_identity / controllers_only for unconverted APKs with older Oculus loaders (controllers_only needs legacy_oculus_driver). The Steam Frame ignores this.
|
||||
|
||||
## <a id="group-pcvr"></a>PC VR (Proton and Revive)
|
||||
|
||||
Options for PC VR games, which run through Proton on the Frame (or Revive on this PC).
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
# Quest games on this PC (AXRB)
|
||||
|
||||
FramePort's PC target ("This PC") already runs Oculus Rift games through Revive. For Quest/Android games it uses
|
||||
**AXRB** (Android XR Bridge, github.com/Android-XR-Bridge/AXRB): an Android 16 emulator (x86_64 Google APIs image,
|
||||
WHPX) that runs arm64 games through Android's ARM translation (`libndk_translation`), with AXRB's own OpenXR runtime
|
||||
inside Android and a Windows host process (`axrb-host-bridge.exe`) that opens a real OpenXR session on the PC's
|
||||
runtime (SteamVR, Virtual Desktop, ...). FramePort only drives it: it downloads AXRB's official release when a user
|
||||
first installs a Quest game on the PC, never bundles or modifies it (AXRB's host/clock code is under the AXRB
|
||||
Source-Available License 1.0, which allows running it and interoperating through its public interfaces).
|
||||
|
||||
Status: **experimental**. AXRB is young (v1.0.4, 2026-10-06) and many games still fail in it; Steam Frame
|
||||
controllers don't work in AXRB yet (AXRB issue #20).
|
||||
|
||||
## Two backends: the emulator (1.0.4) and direct mode (2.x, "AXRB-Emulatorless")
|
||||
|
||||
AXRB's main branch (2026-10-15, launcher 2.0.0) became **AXRB-Emulatorless**: a separate program (own installer,
|
||||
app id `org.axrb.emulatorless`, `%LOCALAPPDATA%\Programs\axrb-emulatorless-launcher`, runtime
|
||||
`%LOCALAPPDATA%\AXRB-Emulatorless Runtime`, profile `%APPDATA%\AXRB-Emulatorless`) whose **direct mode** runs the
|
||||
game without an emulator: `axrb-direct.exe` translates the game's arm64 code (Dynarmic JIT; Digitalis optional) and
|
||||
runs the real AOSP ART + framework from Google's Android 16 arm64 system image, with its own Linux syscall/Binder
|
||||
layer; Vulkan goes to the Windows driver, frames and tracking through the same `axrb-host-bridge.exe`. **Vulkan only:**
|
||||
its EGL is a stub, there is no GLES renderer. Both programs can be installed side by side; FramePort keeps one
|
||||
`axrb.Install` record per backend (`backend`, `version`, app folder, AXRB's root = `resources\runtime`, runtime
|
||||
folder) and picks per game:
|
||||
|
||||
- **Choice** (`targets/pc_android.choose_backend`): `--axrb-backend direct|emulator` on `frameport install --to pc`,
|
||||
else the recipe's `pc.axrb_backend` (device category, `on_pc`: part of the PC build's selection, no-op on the
|
||||
Frame), else **direct for `graphics` Vulkan, emulator for GLES / unknown**. Unreal games never declare
|
||||
`android.hardware.vulkan`; since analysis version 20 their manifest meta-data
|
||||
`com.epicgames.{ue4,unreal}.GameActivity.bSupportsVulkan` = true makes them "Vulkan (Unreal bSupportsVulkan)"
|
||||
(UE 4.25+ Quest builds, e.g. Deadpool VR, Pinball FX VR, Myst; false or missing = GLES: Espire 1, Vader Immortal). `deployment.json` records `backend`
|
||||
and the launcher `flags`; installs from before have none (= emulator). Switching leaves the other backend's copy
|
||||
and saves alone.
|
||||
- **Per-game options** (`pc.axrb_backend` params → `run_direct_game.ps1` switches): `translator` (`dynarmic` =
|
||||
AXRB's default, `digitalis`), `precompose` (`-PrecomposeProjectionLayers`, also for the emulator's
|
||||
`run_windows_game.ps1`; also taken from AXRB's own `core/game-compatibility.json` profile for the game, e.g. The
|
||||
Climb 2), `legacy_oculus_driver` / `quest_identity` / `controllers_only` (for unconverted store APKs with older
|
||||
Oculus loaders; off by default and never taken from AXRB's profiles: FramePort always hands AXRB an OVRPort build;
|
||||
`controllers_only` only works with the legacy driver, as `prepare_art_session.py` requires).
|
||||
- **Install/upgrade** (`tools/axrb.install`): `releases/latest` when it has the backend's installer
|
||||
(`AXRB-Setup-*.exe` / `AXRB-Emulatorless-Setup-*.exe`, SHA256SUMS checked, `/S`), else the known-good release
|
||||
(`KNOWN_GOOD = {"emulator": "1.0.4"}`, `releases/tags/v1.0.4`). An install older than the known-good one is
|
||||
upgraded on use; `frameport pc install-axrb --backend … --update` upgrades to the release on offer. No 2.x release
|
||||
is published yet: **`FRAMEPORT_AXRB_DIRECT_DIR`** names a local build (AXRB's source tree after
|
||||
`scripts\build\build_direct.ps1` + the host build, its `out\direct`, or a folder laid out like the installed
|
||||
`resources\runtime`): it needs the files of AXRB's `core/direct-runtime-files.cjs` (`out\direct\axrb-direct.exe`,
|
||||
`art\axrb-bootstrap.dex`, `art\lib64\*`, `guest\lib64\*`, `guest\axrb-systemdriver.apk`) plus
|
||||
`scripts\run\run_direct_game.ps1`, `scripts\run\stop_game.ps1`, `scripts\paths.ps1`,
|
||||
`direct\tools\make_sysroot.py`, `direct\tools\prepare_art_session.py`, `out\host\bin\Release\axrb-host-bridge.exe`
|
||||
(+ `openxr_loader.dll`, `out\gpu\Release` for the GPU-share layer) and ideally `tools\python` (else Python from
|
||||
PATH). A local build is set up but never replaced. `FRAMEPORT_AXRB_DIRECT_RUNTIME` overrides the runtime folder
|
||||
(default: the launcher's `library.json` `settings.managedDirectory`, else `%LOCALAPPDATA%\AXRB-Emulatorless Runtime`).
|
||||
- **Direct setup** (`axrb.setup_direct`, what `core/direct.mjs ensureDirectSystem` does): the pinned image
|
||||
(`arm64-v8a-36_r02.zip`, 810,736,863 bytes, SHA-1 `62ad6714…`; values read from the installed `core/direct.mjs`,
|
||||
only `dl.google.com`) into its own empty folder, AXRB's `make_sysroot.py <zip> <runtime>\direct\sysroot.new --art`
|
||||
with AXRB's embedded Python, receipt `.axrb-sysroot.json` `{"digest","format"}`, rename to `sysroot`, archive
|
||||
deleted; `license-acceptance.json` like AXRB's DirectSetup. Ready = receipt matches + the key files exist and
|
||||
aren't empty + AXRB's direct files complete. Requirements: ≥ 6 GB RAM, `vulkan-1.dll` (no hypervisor, no GPU
|
||||
vendor gate).
|
||||
- **Direct install** (no adb): the PC build is copied to `%LOCALAPPDATA%\FramePort\axrb\games\<pkg>\<pkg>.apk`
|
||||
(FramePort removes its converted copies after an install; `FRAMEPORT_AXRB_LAUNCHER_DIR` moves FramePort's `axrb`
|
||||
folder, for isolated `FRAMEPORT_HOME` runs), the launch activity comes from the APK's manifest
|
||||
(`axml.launcher_activity`, `pkg/fully.qualified.Class`), the data files become AXRB's content list `assets.txt`
|
||||
(`Android/obb/<pkg>/<file>\t<Windows path>`, read-only mounts of the files where they are; WSL-only folders, which
|
||||
Windows reads only through `\\wsl$`, are copied next to the APK). AXRB's `prepare_art_session.py` turns every
|
||||
content file into one `--mount-ro` argument of `axrb-direct.exe` (with the path resolved: junctions and 8.3 names
|
||||
don't shorten it), and Batman's 90 files from a long dump folder made a 32,876-character command line (Windows'
|
||||
limit is 32,767: "The filename or extension is too long"). A list over 25,000 characters therefore goes through
|
||||
hard links in `games\<pkg>\content` (no extra space; only on the drive FramePort's folder is on), else the install
|
||||
stops with a clear message. App data and saves: `<runtime>\direct\data\data\<pkg>` and
|
||||
`direct\sdcard\Android\data\<pkg>`.
|
||||
- **Play**: Steam shortcut → `%LOCALAPPDATA%\FramePort\axrb\fp-axrb-direct.ps1 -Package … -Activity … [flags]` →
|
||||
sets `AXRB_DATA_HOME=<runtime>\output` (as AXRB's launcher does) → AXRB's `run_direct_game.ps1 -Apk -Package
|
||||
-Activity -GameName -Executable <root>\out\direct\axrb-direct.exe -DataDirectory <runtime>\direct -Sysroot
|
||||
<runtime>\direct\sysroot [-AssetList] -Translator …`. Exit 4 = host lost.
|
||||
- **Launch test**: watches `direct.out` for `AXRB_AOSP_ACTIVITY_CREATED` / `AXRB_AOSP_LIFECYCLE` (5 min for ART's
|
||||
interpreted first start), runs, then stops through AXRB's `stop_game.ps1 -ParentPid` (closes the bridge window;
|
||||
the run script lets the game pause/stop/save). Logs `<runtime>\output\logs\game\{direct.out, direct.err,
|
||||
direct.log, host.err, host.log, session.json}` (diagnostics: `axrb/direct/*`); triage signatures
|
||||
`axrb-direct-*` and milestones ART started → activity created → OpenXR → Vulkan device (`catalog/triage.yaml`).
|
||||
|
||||
**Tested end to end with a local build** (2026-10-15, AXRB `windows-android16` @ 59626ac built on the dev PC,
|
||||
`FRAMEPORT_AXRB_DIRECT_DIR` = its staged `runtime\`, the staged sysroot reached through a junction
|
||||
`<FRAMEPORT_AXRB_DIRECT_RUNTIME>\direct\sysroot`; Monado's simulated HMD, see below): scan → PC build → direct
|
||||
install → `frameport test --to pc --sim --axrb-backend direct` → launcher → AXRB's run script → bridge + runner →
|
||||
milestones → AXRB's Stop. Still untested: the 2.x NSIS installer (no release yet), `setup_direct`'s download +
|
||||
`make_sysroot.py` path, the upgrade of an installed 1.0.x, AXRB's launcher seeing FramePort-set-up runtimes.
|
||||
|
||||
### Direct-mode results (2026-10-15, Monado simulated HMD at 72 Hz, Dynarmic, no headset)
|
||||
|
||||
PC build = the catalog recipe's PC selection (OVRPort's patches + `on_pc` fixes + FrameBridge with `focus_hold=0`),
|
||||
no `pc.axrb_backend` options. "fps" = AXRB.Perf `end-frame` (game) / `host-fresh-submit` (bridge → Monado), the
|
||||
median of the last summaries. What is drawn was judged from Monado's window and AXRB's layer captures.
|
||||
|
||||
| Game (engine, graphics) | Reached | fps | Picture | Blocker / notes |
|
||||
|---|---|---|---|---|
|
||||
| Batman: Arkham Shadow 1.4.1-350961 (Unity, Vulkan; 90 content files, 27 GB) | menu (SELECT REGION) in ~25 s | 63.7 / 62.9 | stereo menu over the Batcave scene, both eyes correct | none; needed the content-list fix (command line overflow) and hard links; stop clean (activities stopped) |
|
||||
| PowerWash Simulator VR 2.5.0 (Unity 2020.3, Vulkan) | in game: the garage hub (~3-4 min; at 90 s still its loading logo) | 70.5 / 69.8 | garage in both eyes, both hands drawn at the simulated controllers | none; Meta multiplayer callbacks throw NullReferenceExceptions (no Meta services, harmless so far) |
|
||||
| LEGO Bricktales 2.1 (Unity, Vulkan, URP) | scene `Diorama_Hub_Startup` loaded (~3 min) | 72 / 72 | intro text panel ("You want to free the simulation? Follow the white rabbit.") on black, both eyes | stop slow: the game didn't finish its activity shutdown in 25 s (`axrb-direct-slow-stop`); Meta achievements fail (no Meta services, harmless) |
|
||||
| Toy Master 1.1.2 (Unity 2022.3, **GLES**) | activity created, then crash in 18 s | - | - | not a Vulkan game: "Vulkan (Unity boot.config)" came from the Oculus plugin's `xr-vulkan-*` keys; Unity: "Unable to retrieve the default EGL display!" + SIGTRAP. Automatic choice now sends such games to the emulator |
|
||||
| Marvel's Deadpool VR 1.0.40 (UE 4.27, Vulkan; `frame.vk_sanitize` in the build) | first-run LANGUAGE menu (~3 min; at 90 s still loading at 1-2 fps) | 64 (FrameBridge pacing) | game-drawn stereo menu, both eyes | none; AXRB transcodes ASTC to BC on the GPU; stop slow (`axrb-direct-slow-stop`) |
|
||||
| Pinball FX VR 1.9 (UE 5.3, Vulkan, 7 GB OBB) | 3D lobby with the Terms & Conditions panel (~4 min) | ~60 (FrameBridge pacing) | arcade hall + panel in both eyes | none of the emulator's blockers (gfxstream `vkAllocateMemory` SIGSEGV, and Refract's FRSQRTE SIGILL / ASTC layout probe / promoted extension names) showed up in direct mode (no SIGILL under Dynarmic, no ASTC or device-loss failure; AXRB stores ASTC as BC); OVRPlugin's xrPerfSettingsSetPerformanceLevelEXT unsupported (harmless) |
|
||||
| Myst 3.3.0 (UE 5.3, Vulkan) | loading screen (book), then its starfield intro | 72 | stereo, both eyes | a white splash quad at first (texture not shown yet), then normal |
|
||||
| Metro Awakening 1.0-916944 (UE 5.2, Vulkan) | splash art, then the USER DATA COLLECTION consent screen with the laser pointer | 72 | stereo, both eyes | stop slow (`axrb-direct-slow-stop`) |
|
||||
|
||||
Unreal Vulkan games: before analysis version 20 every Unreal game was "GLES or unknown" (Unreal never declares
|
||||
`android.hardware.vulkan`), so the automatic choice sent them to the emulator; now `GameActivity.bSupportsVulkan`
|
||||
picks direct mode (Myst and Metro above were installed without `--axrb-backend`). Unreal probes EGL before it
|
||||
creates its Vulkan device, which tripped `axrb-direct-no-gles`; the signature now needs no Vulkan device. None of
|
||||
the four needed a recipe change for the PC.
|
||||
|
||||
Integration bugs found and fixed on the way: AXRB's trap message never reached FramePort's log; the content list
|
||||
overflowed the command line (Batman); Unity's EGL failure and SIGTRAP weren't triaged; long sessions lost their
|
||||
start-up milestones; the boot.config Vulkan label picked direct mode for GLES games. Open, AXRB's side: the content
|
||||
list should reach `axrb-direct.exe` as a file (FramePort's hard links only work when the data is on the drive of
|
||||
FramePort's folder; drafted as an upstream issue). Needs a headset: picture and latency through SteamVR, sound,
|
||||
controllers (the simulated ones hold still), Batman beyond its region menu.
|
||||
|
||||
### VrApi-direct games (2026-10-16, direct mode, Monado simulated HMD, Dynarmic, no headset)
|
||||
|
||||
Games whose engine calls VrApi itself (no OVRPlugin), so OVRPort can't translate them: they need a `libvrapi.so`
|
||||
that translates VrApi to OpenXR. Two candidates: FramePort's `frame.vrapi_bridge` (OVRPort's translator + FramePort's
|
||||
changes) and OVRPort's own `patch_vrapi_openxr` (the unpatched translator; only in experimental CLI builds made with
|
||||
`-PwithVrApi=true`, e.g. the `OVRPort-CLI-1.2.5-experimental-5e7df52.jar` AXRB's v1.0.0/1.0.1 releases attached;
|
||||
AXRB's own profile for The Climb 2 uses it with `-PrecomposeProjectionLayers`). Only converted copies of both games
|
||||
were at hand, so the comparison swapped the one file (OVRPort's = the v1.2.5 build from the 2026-10-02 Frame tests).
|
||||
|
||||
| Game (engine, graphics) | VrApi route | Reached | fps | Picture | Blocker / notes |
|
||||
|---|---|---|---|---|---|
|
||||
| The Climb 2 2.2 (CryEngine, Vulkan VrApi) | FramePort's bridge | first-start menu (gender/skin selection) in ~60 s | 69.8 / 69.8 | menu, both hands and controllers, both eyes (AXRB layer captures + Monado's window) | needs AXRB's HostWindow fix (below, AXRB's side) + `frame.vk_android_extensions`; crash only during AXRB's stop (forgiven by triage); no `-PrecomposeProjectionLayers` needed |
|
||||
| The Climb 2 2.2 | OVRPort's translator | the same menu | 70.2 / 70.2 | identical | same blockers; passes AXRB's AHB device-extension request through unchanged (worked only because `libfp_vkext.so` lists AHB) |
|
||||
| Path of the Warrior (own engine, **GLES** VrApi) | - | not run | - | - | GLES: direct mode can't run it; the emulator (1.0.4) had a copy signed with another key installed by another test, so it wasn't replaced. OVRPort's translator can't run it anywhere (no GLES path: `cannot locate symbol "vrapi_GetTextureSwapChainHandle"`, Frame test 2026-10-02); FramePort's bridge has one |
|
||||
|
||||
**Default for PC builds: FramePort's bridge** (`frame.vrapi_bridge`, now `on_pc = True`; recipes keep it as they
|
||||
are, nothing to switch). Both translators give the same picture and frame rate in direct mode, but OVRPort's needs
|
||||
an unpublished experimental CLI (stable jars list `patch_vrapi_openxr` but fail), has no GLES path (POTW, every GLES
|
||||
VrApi game on the emulator) and lacks the device-extension filter; FramePort's is the same code the Frame builds
|
||||
use, so one recipe serves both.
|
||||
|
||||
Blockers found on the way (in order):
|
||||
1. **No window for NativeActivity** (AXRB's side, drafted upstream): CryEngine's `EngineActivity` takes its surface
|
||||
with `Window.takeSurface`; direct mode's `HostWindow.attach` only bridges SurfaceViews (`AXRB_AOSP_HOST_SURFACES
|
||||
0`), so `APP_CMD_INIT_WINDOW` never comes and the game waits after `Loading plug-in CryHTTP` (triage
|
||||
`axrb-direct-no-native-window`). A ~40-line HostWindow patch (`mTakeSurfaceCallback` → `registerSurface` +
|
||||
`surfaceCreated/Changed`, bootstrap dex rebuilt with AXRB's `build_art_bootstrap.py` into a test copy of the
|
||||
local build) fixed it. Likely the same for every NativeActivity engine.
|
||||
2. **Quest Vulkan device extensions**: CryEngine checks its list before vkCreateDevice and gave up
|
||||
(`CrySystem initialization failed!`): AXRB's runtime asks for `VK_ANDROID_external_memory_android_hardware_buffer`
|
||||
also in direct mode, and the game requires `VK_EXT_fragment_density_map` (NVIDIA's Windows driver has neither).
|
||||
The bridge now leaves runtime extensions no device offers out; `frame.vk_android_extensions` lists the missing
|
||||
Quest ones to the renderer (AXRB drops them at vkCreateDevice). Triage `axrb-direct-vulkan-extensions`.
|
||||
3. **Simulator only**: Monado's head lacks POSITION_TRACKED, so AXRB's host never made its LOCAL origin and the
|
||||
bridge never got a tracked head (empty frames, `initial recenter: head flags=0x0`) → `--sim` sets AXRB's
|
||||
`AXRB_TEST_SYNTHETIC_HMD=2`.
|
||||
4. The analysis called The Climb 2 "GLES or unknown" (no manifest declaration) → analysis 20: engines importing
|
||||
`vrapi_CreateSystemVulkan` are "Vulkan (VrApi Vulkan system)", so it goes to direct mode automatically.
|
||||
|
||||
Debugging aid: `FRAMEPORT_AXRB_TEST_ENV="AXRB_DIRECT_TRACE=syscall,vulkan;AXRB_CAPTURE_FIRST_SEQUENCE=4500"` passes
|
||||
variables to a launch test's AXRB processes (`AXRB_DIRECT_DUMP` only works in AXRB's own VM mode, not with ART).
|
||||
Needs a headset: the picture through SteamVR, controllers past the first menu (the simulated ones hold still),
|
||||
climbing, sound; and a released AXRB-Emulatorless with the window fix.
|
||||
|
||||
### Library sweep 2026-10-16
|
||||
|
||||
Every other Quest game of the owner's library that `pipeline.pc_installable` accepts and whose APK still exists,
|
||||
installed with `frameport install <pkg> --to pc --no-library` (automatic backend, isolated `FRAMEPORT_HOME`, catalog
|
||||
recipe or heuristics, no per-game changes) and tested with `frameport test <pkg> --to pc --sim --seconds 150` (240 for
|
||||
Unreal) under Monado's simulated HMD at 72 Hz. Direct mode = the local AXRB build of 59626ac **with the HostWindow
|
||||
patch** (above; upstream had no newer commit or 2.x release on 2026-10-16); emulator = AXRB 1.0.4 with this branch's
|
||||
`adb install -g`, `projection_only=2` and qwerty-head fixes. "fps" = game submits / bridge to Monado (AXRB.Perf
|
||||
medians); "Picture" = Monado's window (both eyes) at the end of the run, plus AXRB's layer captures. Not run: the
|
||||
three 32-bit-only games (Sports Scramble, Shadow Point, Journey of the Gods: AXRB has no AArch32, and the PC build
|
||||
drops the 32-bit bridge), and the emulator games after NEX Player (disk, see the end).
|
||||
|
||||
| Game (engine) | Backend | Reached | fps | Picture | Blocker / notes |
|
||||
|---|---|---|---|---|---|
|
||||
| Arcsmith 1.128 (Unity) | direct | frames | 59 / 51.7 | in game: the workshop, both hands with tools, both eyes | none |
|
||||
| QuestCraft 6.0.0-RC4 (Unity) | direct | frames | 65.1 / 63.6 | launcher room, QuestCraft EULA panel, laser pointers | none (Minecraft itself is behind its Continue button) |
|
||||
| BattleGlide 0.4.1 (UE) | direct | frames | 62.2 / 54.8 | title screen (START), both hands | none |
|
||||
| In Death: Unchained 1.8.0 (UE) | direct | frames | 64.1 / 56.9 | sky + PRIVACY UPDATE panel | none |
|
||||
| The Boys VR 1.1.0 (Unity) | direct | frames | 62.4 / 59.9 | ANALYTICS DISCLAIMER panel over a lit floor, laser | slow stop |
|
||||
| Creed: Rise to Glory CE (UE) | direct | frames | 60.6 / 55.5 | IMPORTANT SAFETY INFORMATION panel, both hands, laser | none |
|
||||
| Wallace & Gromit: The Grand Getaway 1.0.34 (UE) | direct | frames | 61.5 / 53.5 | space scene (nebula, planets), both gloved hands | none |
|
||||
| VR4 2.3 (UE) | direct | frames | 71.9 / 72 | title scene: church interior, "resident evil 4" logo, both hands | none (slow stop) |
|
||||
| Demeter 1.1.0 (Unity, MR) | direct | frames, FrameBridge scene emulation active | 62.3 / 57.3 | "PERMISSION REQUIRED ... access to your spatial data" panel, controllers | AXRB's direct mode grants only 8 allow-listed permissions (`HostPackageManager.permission`): `com.oculus.permission.USE_SCENE` stays denied although the build declares it (`frame.meta_permissions`). AXRB's side |
|
||||
| Green Hell VR 2.5.2 (Unity) | direct | frames | 11.8 / 11.7 | loading screen at "50%" after 180 s | slow load |
|
||||
| TRIANGLE STRATEGY 1.0 (UE) | direct | frames | 44.6 / 40.2 | both controllers with a "Control Guide" label, rest black at 280 s | still loading? |
|
||||
| Riven 1.9.0 (UE) | direct | frames | 29.8 / 25.2 | white splash quad (AXRB captures), black window at 274 s | still loading (as Myst's white splash) |
|
||||
| Espire 2 1.7.13 (UE) | direct | frames | 72 / 71.8 | white splash quad low in both eyes at 289 s | splash texture never shown |
|
||||
| Carve Snowboarding 1.09 (UE) | direct | frames | 64.5 / 59.3 | both hands, rest black at 270 s | scene never appears; not diagnosed |
|
||||
| Asgard's Wrath 2 12.1 (UE, 33 GB) | direct | frames | 30.6 / 21.9 | black at 287 s | still loading |
|
||||
| Assassin's Creed Nexus 207706 (Unity) | direct | frames | 42.6 / 42.6 | black at 180 s (captures too) | loading or no visible layer yet |
|
||||
| BARTENDER VR SIMULATOR 1.9.2.7 (UE) | direct | frames (in a second process) | 62.7 / 53.6 | black for 260 s (window + captures all 0) | not diagnosed (no Unreal log lines in direct.log) |
|
||||
| Vermillion 1.7.2 (Unity) | direct | frames, OVRManager focused | 63.8 / 60.3 | black with its brush loading icon | stays there (Vuplex 3D WebView starts, then nothing) |
|
||||
| H.U.N.T 0.1.2 (Unity OpenXR) | direct | session created, no frame in 200 s | - | bridge splash | never submits a frame; not diagnosed |
|
||||
| WiiCompiled VR 0.4.0 | direct | 2D launcher activity, no OpenXR | - | bridge splash | its launcher imports games through a document picker; the VR part is a second activity |
|
||||
| Medieval Dynasty New Settlement 2.4 (Unity) | direct | OpenXR FOCUSED, exit(1) at 30 s | - | - | **opens an Android Dialog** (right after "initOculus Java!"); direct mode can't add a second window (`UnsupportedOperationException: addToDisplayAsUser`, "Adding window failed"). Triage `axrb-direct-dialog-window` |
|
||||
| Under Cover 1.1.3r2 (Unity) | direct | Vulkan device, exit(1) at 33 s | - | - | the same Dialog |
|
||||
| Of Lies and Rain 1.1.0 (UE) | direct | vkCreateInstance, exit(1) at 32 s | - | - | Unreal's `MessageBox01` (an error box before its Vulkan device; its text is lost with the window) |
|
||||
| NOPE CHALLENGE 1.0.964 (UE) | direct | ART, crash at 15 s | - | - | Google Play services call `IActivityManager.getMyMemoryState`: `AXRB_SERVICE_UNIMPLEMENTED` -> uncaught exception. Triage `axrb-direct-unimplemented-service` |
|
||||
| BAM 1.2 (Unity) | direct (manifest declares Vulkan) | activity, crash at 33 s | - | - | runs GLES: "Unable to retrieve the default EGL display!"; the manifest's Vulkan feature doesn't say which API Unity picks first. Emulator run not reached |
|
||||
| Mobile Suit Gundam: Silver Phantom 1.1.58 (Unity) | direct | install refused | - | - | 257 content files with ~80-character names don't fit axrb-direct.exe's command line even through hard links (the guest paths alone are too long); needs AXRB to read the list from a file or mount the folder. Emulator run not reached |
|
||||
| Silhouette 1.3.0 (Unity) | emulator | frames | 30.2 / 30.1 | title menu (Play / Options / Quit), both eyes | none headless |
|
||||
| Yupitergrad 2.6.1 (Unity) | emulator | frames | 13.8 / 14 | splash ("Loading...") in both eyes | still loading at 174 s |
|
||||
| 4XVR Video Player 2.0.22 | emulator | frames | 34.9 / 32.2 | black | gfxstream rejects its GL every frame: `glEnable(GL_FRAMEBUFFER_SRGB_EXT)`, a `glTexParameteri` value, vertex attribute index >= `CODEC_MAX_VERTEX_ATTRIBUTES` |
|
||||
| NEX Player 1.3.1 (Unity) | emulator | frames | 9.3 / 9.3 | black | not diagnosed |
|
||||
| Lambda1VR 1.7.3 (Team Beef) | emulator | Java crash at start | - | - | `libopenxr_loader_google.so not found`: TBXR ports load `openxr_loader_<Build.MANUFACTURER>`; `frame.tbxr_vendor` handles "valve" and isn't in PC builds; AXRB's Android says "google" (a PC variant: add that library and match "google" in the maker checks) |
|
||||
|
||||
Summary (31 games run): **8 reach a menu or the game in both eyes** in direct mode (Arcsmith in game; QuestCraft,
|
||||
BattleGlide, In Death, The Boys, Creed, Wallace & Gromit, VR4 at their first menu/panel) and 1 in the emulator
|
||||
(Silhouette); Demeter shows its own "permission required" panel; 12 submit frames but were still loading or black at
|
||||
the end of the run (big Unreal games at 30-45 fps while loading under Dynarmic); 9 never submit a frame. The most
|
||||
common blockers, all on AXRB's side:
|
||||
1. **No second window in direct mode** (3 games: Medieval Dynasty, Under Cover, Of Lies and Rain): any Android
|
||||
Dialog ends the game. Upstream candidate together with the permission allow-list (Demeter's `USE_SCENE`) and the
|
||||
unimplemented framework calls (Play services' `getMyMemoryState`, NOPE).
|
||||
2. **Long or loading-heavy starts**: under Dynarmic the large Unreal games need more than 4-5 minutes to their first
|
||||
picture (Riven, Asgard's Wrath 2, TRIANGLE STRATEGY, Espire 2's white splash); a longer `--seconds` would show more.
|
||||
3. **The content list on the command line** (Gundam): a list file or folder mounts in `axrb-direct.exe` would fix it
|
||||
for good (drafted upstream).
|
||||
4. GLES-in-direct-mode mislabels: a Unity manifest that declares Vulkan can still run GLES (BAM).
|
||||
|
||||
FramePort changes from the sweep: triage `axrb-direct-dialog-window` and `axrb-direct-unimplemented-service`
|
||||
(`axrb-direct-no-gles` no longer blames Unreal's EGL probe when one of those ended the game first); the
|
||||
command-line message suggests the emulator; and **direct-mode uninstalls and reinstalls remove the libraries AXRB's
|
||||
`prepare_art_session.py` extracts per APK** into `<runtime>\direct\installed\<identity>`, which AXRB never deletes
|
||||
(5 GB after 25 games; `pc_android.staged_libraries` computes AXRB's identity: the APK's Windows path + every
|
||||
`lib/arm64-v8a` entry's name, CRC and size; unit-tested, not yet seen on a real uninstall).
|
||||
|
||||
The emulator half stopped after NEX Player: the WSL disk and C: filled up (other jobs; C: 14 GB free, below the 60
|
||||
GB the sweep keeps), so the remaining emulator games were not run: The Room VR, Pixel Ripped 1995, Time Stall, Five
|
||||
Nights at Freddy's: HW, Star Wars Pinball VR, I Am Cat, Vader Immortal 1-3, Genotype, Phantom: Covert Ops, Sniper Elite
|
||||
VR (+ Winter Warrior), Robo Recall, Budget Cuts Ultimate, Espire 1, BodyCombat, Star Wars: Tales from the Galaxy's
|
||||
Edge, HITMAN 3, Jurassic World Aftermath, Nano, Wander, Stremio, Eleven Table Tennis, BlazeRush, plus BAM and Gundam
|
||||
in the emulator.
|
||||
|
||||
## Spike results (2026-10-10, Windows 11 26200, i7-14700K, RTX 4080 SUPER, 64 GB; headless)
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| AXRB install | `AXRB-Setup-<v>.exe /S` (electron-builder one-click NSIS, per user, no UAC) → `%LOCALAPPDATA%\Programs\axrb-launcher`, 462 MB. Release assets: Setup exe + `SHA256SUMS-<v>-setup.txt` + source zip (no portable zip since 1.0.4). |
|
||||
| Runtime setup | Not scriptable through AXRB (it lives in the Electron launcher's JS), so FramePort reproduces it: 4 pinned `dl.google.com` zips from AXRB's `core/components.json` (emulator 36.5.11, platform-tools r37.0.1, build-tools 36.1, `sys-img/google_apis/x86_64-36_r07`), hash-checked, into `%LOCALAPPDATA%\AXRB Runtime\sdk`, AVD `axrb-managed-api36` written as AXRB writes it, `ready.json` / `license-acceptance.json` receipts so AXRB's own launcher sees the same runtime as set up. 2.4 GB download, ~3 min on a fast line; 26 GB on disk after one 7 GB game. |
|
||||
| Emulator boot | AXRB's `scripts\emulator\windows_android_emulator.ps1 -Action Start` (headless QEMU, AXRB clock adapter + GPU layer). First cold boot ~6 min (TSC-corrected clock cold-boots every time). ABIs `x86_64,arm64-v8a`. QEMU (`Start-Process`) and the adb server inherit the caller's handles: a pipe, a file through WSL interop, even `/dev/null` through interop (its relay stays open) all left the FramePort call hanging after the script had ended → FramePort starts AXRB scripts through WMI (`Win32_Process.Create`, hidden, nothing inherited), polls the PID and reads an `FP_EXIT <code>` line from the log. Windows PowerShell 5.1's `*>>` writes UTF-16. |
|
||||
| AXRB runtime | `adb install --no-incremental --force-queryable -r axrb-openxr-runtime-debug.apk` (0.3 s); found by the OpenXR loader through the system runtime broker. |
|
||||
| Data | `adb push` of OBB/pak files: 25-35 MB/s (7.2 GB Pinball FX VR in 5.5 min). A full uninstall (or a signature change: `INSTALL_FAILED_UPDATE_INCOMPATIBLE`) deletes the OBB folder and the saves → FramePort's stable per-game keys matter here too. |
|
||||
| **Original APK** (Pinball FX VR) | No launcher activity (Meta's VR category only), started explicitly: **SIGABRT 0.3 s after loading Meta's `libovrplatformloader.so`**. Unconverted Quest APKs don't run. |
|
||||
| **OVRPort-only build** (Lucky's Tale, Unity 2019 GLES) | OVRPort's loader → AXRB runtime → instance + session, frames submitted (2 s each without a host: the image send times out), no crash in 40 s. |
|
||||
| **OVRPort-only build** (Pinball FX VR, UE5 Vulkan) | OVRPlugin pre-init OK (AXRB reports "Oculus 67.522.0"), then exits (ForceQuit) / with the no-ForceQuit build SIGSEGV in `gfxstream vkAllocateMemory` called from AXRB's runtime Vulkan layer. Not decided headless (no host attached, see below). |
|
||||
| Host bridge | Needs an OpenXR runtime with a D3D11 adapter. Headless options all failed: SteamVR's null driver (`failed to find D3D11 adapter requested by OpenXR runtime`), `vrlink` without a headset (`xrCreateInstance` -2), Meta XR Simulator (binary download needs a Meta developer login). → frames-to-SteamVR need a real headset. |
|
||||
|
||||
**Build decision: FramePort hands AXRB an OVRPort-only build** (the recipe's OVRPort patches, no Steam Frame fixes,
|
||||
signed with the game's FramePort key). The original APK crashes; FramePort's Frame fixes (FrameBridge adapter, Lepton
|
||||
and Frame-runtime workarounds) target the Frame. The recipe's alternate (no-ForceQuit) build is used when the recipe
|
||||
says so. (Since 2026-10-15 the PC build also carries the runtime-independent APK fixes and FrameBridge, see below.)
|
||||
|
||||
## Which patches reach the build for this PC
|
||||
|
||||
One recipe serves both builds; `patches/base.pc_selection(patches, analysis)` picks the PC part (used by
|
||||
`build.build(pc=True)`, `pipeline.pc_recipe_fingerprint`, the PC deployment record and `ui/components.pc_outdated`).
|
||||
`docs/PATCHES.md` marks each kept patch "also in builds for this PC (AXRB)".
|
||||
|
||||
- **All of OVRPort's patches** (category `overport`).
|
||||
- **APK fixes that don't depend on Lepton or the Frame's runtime** (`Patch.on_pc = True`): they fix the game's own
|
||||
code or its Meta libraries, which misbehave the same way in AXRB's Android (no Meta services, not a Quest device
|
||||
model, no Horizon app): `frame.ovrplatformcompat`, `frame.ovrstubs`, `frame.swapchain_limit` (OVRPort's
|
||||
dispatcher), `frame.ovr_stable_user`, `frame.unreal_ovrp_entrypoints`, `frame.avatar_stub`, `frame.vrapi_stub`,
|
||||
`frame.metaxr_telemetry`, `frame.oculusos`, `frame.ovr_microphone`, `frame.langpacks`, `frame.asset_files`,
|
||||
`frame.asset_files_stub`, `frame.ovr_trace`, `frame.blazerush`, `frame.unity_oculus_check` (with its ovrpshim),
|
||||
`frame.unity_text_input`, `frame.unreal_quest_precompile`, `frame.unreal_quest_keymap`,
|
||||
`frame.unreal_thumb_touch`, `frame.unreal_skip_obb_check`, `frame.unreal_skip_obb_delivery`, `frame.nodebug`,
|
||||
`frame.meta_permissions`, `frame.launcher`, and `frame.vrapi_bridge` (VrApi → OpenXR through OVRPort's loader,
|
||||
so AXRB's runtime; seen working in direct mode with The Climb 2, see "VrApi-direct games" below; the bridge
|
||||
doesn't need the GL shim, but GLES games keep `frame.gl_shim` for gfxstream, see "GLES games in the emulator").
|
||||
- **Experimental on the PC, for GLES games in the emulator**: `frame.gl_shim` (only with a GLES analysis,
|
||||
`Patch.pc_keeps`; Vulkan VrApi games never get it), `frame.unity_multipass` (also added by `pc_detect`), and the
|
||||
GLES FrameBridge defaults `scale` ≤ 0.5, `scale_max=1`, `projection_only=1`.
|
||||
- **Experimental on the PC** (`on_pc = "experimental"`, kept but not yet seen working in AXRB): `frame.vk_sanitize`
|
||||
(the Vulkan shim; gfxstream / the host driver instead of Lepton's Fossilize), `frame.unity_tls_key_pool`,
|
||||
`frame.eos_keychain_loop`.
|
||||
- **PC-only APK fix** `frame.vk_android_extensions` (`pc_detect`: direct-VrApi games whose engine creates VrApi's
|
||||
Vulkan system, analysis `graphics` "Vulkan (VrApi Vulkan system)"): the renderer's
|
||||
`vkEnumerateDeviceExtensionProperties` import goes to `libfp_vkext.so`, which also lists the Quest device
|
||||
extensions the PC's driver lacks (VK_EXT_fragment_density_map, Android external memory); AXRB's vkCreateDevice
|
||||
leaves them out again.
|
||||
- **FrameBridge** (`frame.adapter`) with its settings in `lib/<abi>/libframe_settings.so`: it wraps OVRPort's loader
|
||||
the same way in AXRB (scale, haptic_fix, pose_time_fix, rect_clamp, profile_remap, ...). Left out
|
||||
(`settings.PC_DENY`): `sync_guard` (a race in the Frame's vrclient.so), `focus_hold` / `focus_hold_ms` (the
|
||||
Frame's flickering wear sensor; FrameBridge's built-in default is on, so the PC build writes `focus_hold=0`,
|
||||
`settings.PC_VALUES`), `controller_models` (models the agent converts from the Frame's SteamVR at install) and the
|
||||
settings only the GL shim / Zink shader layer read (`gl_*`, `zink_*`). `controller_fix` stays: it is the game's
|
||||
choice (0 = hand tracking) and its Touch mapping of `/valve/` and generic profiles is runtime-independent.
|
||||
FrameBridge finds `libframe_settings.so` next to its own path (dladdr); on the Frame the agent also passes the
|
||||
settings as files, in AXRB the library is the only source, so the PC build turns `extractNativeLibs` on
|
||||
(`build.extract_native_libs`; apps with `extractNativeLibs="false"` would map their libraries from base.apk).
|
||||
Without OVRPort (a 32-bit recipe) the PC build drops FrameBridge: there is no loader to wrap.
|
||||
- **PC-only:** OVRPort's `patch_disable_meta_xr_audio_telemetry` (for x86_64 ARM translation, which AXRB is) is
|
||||
never in a Steam Frame recipe (`applies()` is False); `pc_selection` adds it, given the analysis, to games with
|
||||
Meta XR Audio (`Patch.pc_detect`; AXRB's own profile turns it on for North Star).
|
||||
- **Off on the PC:** Lepton/Android 11 workarounds (clipboard, Vivox audio route, start activity, ...), the Zink/Mesa
|
||||
GL shims and shader layer, pac_hints/luajit, hardware video decoding, every `device.*` option, the 32-bit bridge.
|
||||
- TODO: `device.files` (files written into the game's storage at install) could be reproduced with `adb push` in
|
||||
`targets/pc_android.py`.
|
||||
|
||||
### FramePort end to end (same day, headless, this branch)
|
||||
|
||||
`frameport install com.playful.LuckysTale --to pc --no-library` (isolated `FRAMEPORT_HOME`): PC build (19 patches:
|
||||
OVRPort's + `frame.ovrplatformcompat`/`frame.ovrstubs`/`frame.swapchain_limit`; static checks pass without the
|
||||
FrameBridge check) → AXRB found, requirements met → emulator → AXRB runtime → `adb install` → 3.7 GB OBB in ~95 s
|
||||
(39 MB/s) → `deployment.json` + launcher script. `frameport test com.playful.LuckysTale --to pc`: launcher → AXRB run
|
||||
script → host bridge → triage `axrb-no-headset` (expected without a headset). The Steam shortcut write (needs a Steam
|
||||
restart) is unit-tested only.
|
||||
|
||||
## How FramePort drives AXRB
|
||||
|
||||
- `tools/axrb.py`: find (env `FRAMEPORT_AXRB_DIR` > installed AXRB) / install (Setup exe, SHA256SUMS checked, `/S`) /
|
||||
runtime setup (above) / requirements (WHPX via AXRB's `check_windows.ps1`, ≥ 12 GB RAM, x64).
|
||||
- Environment for every AXRB script (the installed build sets none of it itself): `AXRB_DATA_HOME=<Runtime>\output`,
|
||||
`ANDROID_AVD_HOME=<Runtime>\avd`, `ANDROID_USER_HOME=<Runtime>\android`, `ANDROID_HOME`/`ANDROID_SDK_ROOT=<Runtime>\sdk`,
|
||||
`ANDROID_ADB_SERVER_PORT=5038`, `ADB_USB_LEGACY=1`, `ADB_LOCAL_TRANSPORT_MAX_PORT=5683`, AXRB's embedded Python
|
||||
(`resources\runtime\tools\python`) first on PATH; arguments `-Sdk <Runtime>\sdk -Avd axrb-managed-api36 -Port 5584`.
|
||||
- Install: start the emulator if needed (and stop it again if FramePort started it), install the AXRB runtime APK,
|
||||
`adb install -r` the build, push OBB/data to `/sdcard/Android/obb|data/<pkg>/`, record `<data>/pc/<pkg>/deployment.json`
|
||||
(`kind: "android"`).
|
||||
- Play: a Steam shortcut runs FramePort's small launcher script (`%LOCALAPPDATA%\FramePort\axrb\fp-axrb-run.ps1`),
|
||||
which sets the environment above and calls AXRB's `scripts\run\run_windows_game.ps1` (boots the emulator when
|
||||
needed and then owns it, registers a SteamVR app identity, starts the host bridge, `am start`, waits; exit codes
|
||||
0 ok, 1 error, 3 game lost, 4 host lost; `logs\game\session.json`, `host.err`).
|
||||
- Logs for diagnostics: `<Runtime>\output\logs\game\{host.log,host.err,session.json,guest.log}`,
|
||||
`logs\emulator\emulator.std{out,err}.log`.
|
||||
|
||||
## Headset results (2026-10-10, Steam Frame streaming to the PC through Steam Link / vrlink, 96 Hz)
|
||||
|
||||
AXRB's bridge opened a SteamVR session on the Frame each time (session state up to FOCUSED, both hands active), started
|
||||
through FramePort's launcher with the emulator booted by AXRB's run script (~1 min), and shut down cleanly.
|
||||
|
||||
- **Lucky's Tale (Unity, GLES): black.** GLES games go through AXRB's raw image path (eye readback → TCP), sized
|
||||
by SteamVR's recommended resolution: 3412×3412 per eye at the owner's 250 % supersampling = 93 MB per frame,
|
||||
~10 s per frame; at a lowered resolution 2644×2644 = 56 MB, 6.6 s per frame (~8.5 MB/s). AXRB's zero-copy GPU
|
||||
path covers Vulkan games only. The ~8.5 MB/s was the transport, not the readback: see "GLES games in the
|
||||
emulator" below (adb reverse: ~100 MB/s, half-size eyes, multipass: Lucky's Tale draws at 30 fps).
|
||||
- **Pinball FX VR (UE5, Vulkan): crash 4 s after start**, the same gfxstream `vkAllocateMemory` SIGSEGV from AXRB's
|
||||
runtime layer as headless (so not a headless artefact). AXRB's side.
|
||||
|
||||
Not reached: sound, controllers. Next candidates: a Vulkan game AXRB reports playable (Deadpool VR, Batman: Arkham
|
||||
Shadow); an AXRB issue for the Pinball crash.
|
||||
|
||||
## GLES games in the emulator (2026-10-16, headless under Monado's simulated HMD)
|
||||
|
||||
AXRB 1.0.4's emulator is the only backend with GLES (direct mode's EGL is a stub). A GLES frame is read back with
|
||||
`glReadPixels` on the game's thread inside `xrEndFrame` (`runtime/src/frame_transport.cpp`), sent as raw RGBA to the
|
||||
host (two eyes per frame) and uploaded there; `xrEndFrame` returns only after the send. Measured with
|
||||
`AXRB.Perf eye-readback` / `image-send` (guest log) and `host-image-arrival` (host.err):
|
||||
|
||||
| Eye size (per eye) | Bytes/frame | Route | Send | Frame rate |
|
||||
|---|---|---|---|---|
|
||||
| 896×1007 (Monado's recommendation) | 7.2 MB | default: TCP to 10.0.2.2 (QEMU user-mode network) | 850-950 ms | 1.1 fps |
|
||||
| 448×504 (`scale=0.5`) | 1.8 MB | default | 200-230 ms | 4.4 fps |
|
||||
| 896×1008 | 7.2 MB | **adb reverse** (`debug.axrb.image_transport=adb`) | 87 ms (~83 MB/s) | 9.4 fps |
|
||||
| 640×720 (`scale=0.7`) | 3.7 MB | adb reverse | 38 ms (~96 MB/s) | 17.6 fps |
|
||||
| 448×512 (`scale=0.5`) | 1.8 MB | adb reverse | 14.5 ms (~125 MB/s) | 30.5-30.8 fps |
|
||||
|
||||
The readback itself is 1-3 ms. The default route caps at ~8 MB/s (the same rate as in the headset run above); AXRB's
|
||||
adb reverse route (AXRB sets up `adb reverse tcp:38491` anyway) is 10-15x faster. For SteamVR's resolutions the math
|
||||
stays hard: 2016² per eye = 32.5 MB/frame (~3 fps), at half size 8.1 MB (~10 fps); the owner's 2644² at half size
|
||||
14 MB (~6 fps). Lowering SteamVR's resolution for the game is what helps further (AXRB has a minimum-extent setting,
|
||||
`AXRB_MIN_EYE_EXTENT`, but no maximum).
|
||||
|
||||
What FramePort does for GLES games on this PC (all in the PC build / launcher, nothing on the Frame changes):
|
||||
- FramePort's launcher (`fp-axrb-run.ps1`) starts Android itself when it isn't running (AXRB's Start action, as the run
|
||||
script would; the run script then gets `-OwnsEmulator` and shuts it down), sets `debug.axrb.image_transport=adb`
|
||||
(`FRAMEPORT_AXRB_IMAGE_TRANSPORT=default` leaves AXRB's choice) and `debug.wm.disable_deprecated_target_sdk_dialog=1`.
|
||||
- `pc_selection` with a GLES analysis: FrameBridge `scale` at most 0.5 (FrameBridge's minimum), `scale_max=1` (the
|
||||
maximum eye size reported is the scaled one too), `projection_only=2`: AXRB's emulator refuses every GLES frame with
|
||||
more than one layer (`xrEndFrame` -2), so FrameBridge submits the projection layer alone after drawing the quad
|
||||
layers that follow it into its eye images (`native/adapter/quad_compose_gl.c`, on the game's GL context, per eye at
|
||||
the eye's view and FOV, blending by the quad's flags); `projection_only=1` only drops them.
|
||||
- Game installs use `adb install -g` (runtime permissions granted, as Lepton does on the Frame): Android's permission
|
||||
dialog over the game's activity pauses Unity for good (BattleSisters asks for the microphone in its menu).
|
||||
- The VrApi bridge copies the game's eye textures into the OpenXR images with `glBlitFramebuffer` when the context
|
||||
has no `glCopyImageSubData` (the emulator's GLES translator is "OpenGL ES 3.1" without `GL_EXT_copy_image`; its stub
|
||||
only logs `libEGL: called unimplemented OpenGL ES API`, so every eye reached the runtime black).
|
||||
- `frame.unity_multipass` (experimental on the PC, `pc_detect` for Unity GLES + Oculus XR Plugin): gfxstream lists
|
||||
`GL_OVR_multiview2` but its encoder finds a multiview framebuffer incomplete and drops every draw
|
||||
(`GL2Encoder.cpp:s_glDrawElements … GL error 0x506 checkFramebufferCompleteness`): both eyes black.
|
||||
- `frame.gl_shim` (experimental on the PC, GLES only, `Patch.pc_keeps`): gfxstream has no
|
||||
`GL_EXT_disjoint_timer_query`; the shim lists it and hands out stand-ins ("available, 0 ns") so GLAD engines that
|
||||
call the timer queries unconditionally don't jump to NULL. Its multiview hiding also suits gfxstream.
|
||||
- FrameBridge settings without a rebuild: `frameport settings <pkg> --to pc key=value` writes
|
||||
`/sdcard/Android/data/<pkg>/files/framebridge.conf` (emulator: adb push, Android started for it if needed; direct
|
||||
mode: the file in AXRB's runtime folder); an empty value removes a key.
|
||||
|
||||
Per game (AXRB 1.0.4 emulator, Monado simulated HMD at 72 Hz, FramePort's PC build; Windows 11, RTX 4080 SUPER):
|
||||
|
||||
| Game | Build / settings | Furthest | Frame rate / time | Blocker |
|
||||
|---|---|---|---|---|
|
||||
| Lucky's Tale 1.0.55 (Unity 2019.4 + Oculus XR Plugin, GLES) | PC build + `frame.unity_multipass`, `scale=0.5`, adb route | **title scene drawn in both eyes** (Monado window captured) | 30.5-30.8 fps, image-send 14.5 ms | none headless; needs the headset (picture through SteamVR at its resolution, controllers) |
|
||||
| Accounting+ 1.24.35 (Unity 2017.4 built-in Oculus, GLES) | PC build (`frame.unity_oculus_check`), `projection_only`, old-SDK dialog off | **stance selection screen drawn in both eyes** | 10 fps, image-send 82 ms: its OVRPlugin makes 896×1024 eye buffers whatever FrameBridge's scale/max say | its quads (OVROverlay) were dropped by `projection_only=1` (AXRB rejects every multi-layer GLES frame with -2); rebuilt with `projection_only=2` (2026-10-16, qwerty head): same screen at 9.9 fps, no regression (its controller pointers are missing there because qwerty's controllers are inactive); eye size only shrinks with the PC runtime's resolution |
|
||||
| Path of the Warrior 21.11.10 (VrApi-direct GLES, own engine; from the `PATCHED/` copy) | VrApi bridge (blit copies) + GL shim (timer-query stand-ins), `scale=0.5`, `projection_only=2`, `--sim` with Monado's qwerty head | **its first screen drawn in both eyes**: the "Please ensure that your Oculus Touch Controllers are properly connected" panel (a VrApi cylinder → quad, drawn into the projection) | 38 fps, image-send ~15 ms | the simulator's controllers: Monado's qwerty builder (needed for a tracked head here, see `--sim` notes) has WMR controllers, which AXRB's bindings don't cover. Found on the way: (1) the simulated head: no tracked pose, `EnterVrMode` gave up after 30 s; (2) every frame -2 (projection + the cylinder quad); (3) black eyes: no `glCopyImageSubData` in the emulator's GLES. Before the stand-ins: SIGSEGV at pc 0 in `EgHalGrTimestampQueryGL` (NULL `glad_glGenQueriesEXT`). Needs a headset: controllers past this screen, gameplay |
|
||||
| BattleSisters 1.2.4 (Unity 2019.4 built-in VR, GLES) | PC build (ovrpshim frame loop), `scale=0.5`, installed with `-g`, `--sim` with Monado's qwerty head | **main menu drawn in both eyes** (Continue / New game / Level select hub / Last Bastion / Settings) | 31 fps for 90 s, no stall | none headless. The "hang after 3 frames" was Android's permission dialog: a few seconds into its menu the game asks for a runtime permission (microphone), `GrantPermissionsActivity` became the resumed activity over the game, Unity paused (AAudio pause, no Update2/WaitToBeginFrame/BeginFrame/EndFrame in progress per `ovrp_trace`) and nobody can answer it; not the ovrpshim frame wait and not AXRB's pacing. Fixed by installing with `adb install -g` (as Lepton does). Needs a headset: controllers, gameplay |
|
||||
|
||||
`--sim` notes: AXRB 1.0.4's emulator host only reads `AXRB_TEST_SYNTHETIC_HMD=1` (direct mode's `=2` is newer) and
|
||||
applies it only to a head *without* valid flags; Monado's simulated head is valid but not tracked, so the host never
|
||||
sets its LOCAL origin and VrApi games never get a tracked head. For games on the emulator backend `frameport test
|
||||
--sim` therefore runs Monado's **qwerty** builder (`SIMULATED_ENABLE=0`, `QWERTY_ENABLE=1`: its head reports
|
||||
POSITION_TRACKED; host.err `AXRB OpenXR: LOCAL origin in tracking world=(0.000 1.600 0.000)`). Its controllers are
|
||||
WMR ones, which AXRB's suggested bindings (Touch/Index/Vive) don't cover: no hands. `FRAMEPORT_PC_XR_SIM_HEAD=simulated`
|
||||
keeps the simulated head (and its ml2 controllers), `=tracked` forces qwerty. Monado's `remote` builder (tracked head +
|
||||
Index controllers) would cover both but listens on 0.0.0.0:4242 (a firewall prompt), so it isn't used. FrameBridge's
|
||||
`ovrp_trace=1` (ovrpshim, Unity 2017-2019 legacy frame loop) logs every 5 s how many Update2/WaitToBeginFrame/
|
||||
BeginFrame/EndFrame calls went through and which one is in progress (thread, frame, how long): where a stalled game
|
||||
waits. AXRB's opt-in host capture (`AXRB_CAPTURE_PREFIX`) only starts at sequence 2000 in 1.0.4 (hard-coded;
|
||||
`AXRB_CAPTURE_FIRST_SEQUENCE` isn't read), which GLES games at 10-30 fps don't reach in a test: use the Monado
|
||||
window captures and FrameBridge `snapshot=N`. AXRB's guest log only keeps warnings plus AXRB's own tags: read
|
||||
`adb logcat -d` for FrameBridge/OVRPlugin lines. `debuggerd -b <pid>` works from `adb shell` (stacks).
|
||||
|
||||
## Open (needs the owner + a headset)
|
||||
|
||||
- A game in the headset through SteamVR (picture, audio, controllers; Steam Frame controllers: AXRB #20).
|
||||
- Pinball FX VR's Vulkan crash with the host attached happens in the emulator only; in direct mode it reaches its lobby
|
||||
headless. Needs the headset: picture/latency through SteamVR, controllers, a table played.
|
||||
- Play from the PC's Steam library (shortcut → launcher script → AXRB).
|
||||
|
||||
## Testing without a headset (Monado's simulated HMD, 2026-10-15)
|
||||
|
||||
AXRB's host bridge needs an OpenXR session with a D3D11 binding, which SteamVR doesn't give without a headset. For
|
||||
unattended tests FramePort can run the bridge against **Monado for Windows** with Monado's simulated HMD instead,
|
||||
without changing the system's OpenXR runtime or touching SteamVR:
|
||||
|
||||
- **Monado build:** an official CI artifact of `monado/monado` (gitlab.freedesktop.org, project 2685), job `windows`
|
||||
of a `main` pipeline (there is no Windows release asset; the job's `install/` folder is the portable runtime:
|
||||
`bin\monado-service.exe`, `bin\openxr_monado.dll`, `openxr_monado.json`, SDL2/Vulkan loader DLLs). Tested build:
|
||||
pipeline 1766968, job 112793105, commit 8b259763f (`v25.1.0-917-g8b259763f`). Unpacked by hand into
|
||||
`%LOCALAPPDATA%\FramePort-dev\monado\install` (or `FRAMEPORT_MONADO_DIR`): no installer, no UAC, no registry. Its
|
||||
`share\steamvr-monado` (a SteamVR driver) is never registered.
|
||||
- **Selection per process only:** the OpenXR loader's `XR_RUNTIME_JSON=<monado>\openxr_monado.json`. HKLM's
|
||||
`ActiveRuntime` stays SteamVR. AXRB's run script registers an app identity with SteamVR whenever HKLM names SteamVR
|
||||
(an OpenVR utility client, which starts SteamVR), so under the simulator FramePort's launcher call answers that one
|
||||
`python steamvr_app_identity.py` call "failed" (the script only warns).
|
||||
- **Service settings** (`tools/xr_sim.SERVICE_ENV`): `SIMULATED_ENABLE=1` (the simulated builder; the Windows build
|
||||
only has the qwerty/remote/simulated builders, so no real device is probed), `SIMULATED_LEFT/RIGHT=ml2` (the only
|
||||
simulated controllers whose bindings cover the Vive wand profile: AXRB suggests Touch/Index/Vive, so with `simple`
|
||||
or `wmr` its hands stay inactive), `XRT_COMPOSITOR_DEFAULT_FRAMERATE=72`. Headless variant: `XRT_COMPOSITOR_NULL=1`
|
||||
+ `XRT_COMPOSITOR_NULL_FPS=72` (no window, nothing to capture).
|
||||
- **D3D11 + adapter:** Monado's D3D11 client compositor works (timeline semaphores); it renders on the discrete GPU
|
||||
(Vulkan device 0, RTX 4080 SUPER here; `XRT_COMPOSITOR_FORCE_GPU_INDEX` would change it) and asks D3D11 clients for
|
||||
that adapter's LUID, which DXGI finds.
|
||||
- **Results:** a minimal D3D11 client (`native/xrsimprobe/xr_sim_probe.cpp`, built with VS Build Tools against the
|
||||
Khronos `openxr_loader_windows` 1.1.63 release) went IDLE → READY → SYNCHRONIZED → VISIBLE → FOCUSED in 0.01 s
|
||||
and submitted 72 frames/s until it asked to exit. AXRB's host bridge (`--serve-openxr 38490 0 <title>`, no
|
||||
emulator) reached `D3D11 graphics binding ready`, session state 5 (FOCUSED), `AXRB TCP: listening on
|
||||
0.0.0.0:38490`, both controllers active with valid poses, `host-end-frame: rate=72.0/s`, its splash in both eyes.
|
||||
No game was run yet.
|
||||
- **Seeing what is drawn:** the windowed compositor ("Monado (Windowed)", 1264×681, both eyes side by side without
|
||||
distortion) can be saved with `native/xrsimprobe/xr_sim_capture.cpp` (`PrintWindow`, works behind other windows;
|
||||
`xr_sim.capture(path)`), and `--sim` also sets AXRB's own opt-in `AXRB_CAPTURE_PREFIX` (4 PPM readbacks of the
|
||||
submitted layers, 5 s apart, from frame 300) into `<data>/logs/xr-sim/`. A launch test that reached RUNNING saves
|
||||
Monado's window there too, just before it stops the game (`<time>-<pkg>-headset.bmp`, `pc_android.sim_capture`),
|
||||
and every AXRB launch test reports the frame rate from AXRB's `AXRB.Perf` summaries (game submits / bridge). Monado itself has no screenshot function;
|
||||
`XRT_WINDOW_PEEK` and `XRT_DEBUG_GUI` exist but only show windows.
|
||||
- **Use:** `frameport test <pkg> --to pc --sim` (or `FRAMEPORT_PC_XR_SIM=1`): starts `monado-service.exe` hidden
|
||||
(WMI, like AXRB's scripts; ready when its pipe `\\.\pipe\<%TEMP%>\monado_comp_ipc` exists), passes the environment to
|
||||
the AXRB launch (`pc_android.launch_test(extra_env=…)`, default `xr_sim.active_env()`), stops the service afterwards
|
||||
(an already running Monado is used and left running). Service log: `<data>/logs/xr-sim-service.log`.
|
||||
- **Head per backend:** when every game tested is on AXRB's emulator backend, the service runs Monado's qwerty builder
|
||||
instead of the simulated one (a tracked head; AXRB 1.0.4's emulator host has no synthetic head for Monado's
|
||||
untracked one, see the GLES section's `--sim` notes); its controllers stay inactive in AXRB.
|
||||
`FRAMEPORT_PC_XR_SIM_HEAD=tracked|simulated` overrides the choice. An already running Monado is used as it is.
|
||||
- **Limits:** the simulated head wobbles slowly and the controllers hold still with no buttons pressed, so it proves
|
||||
startup, frame submission and what is drawn, not gameplay; the windowed compositor opens a visible window on the
|
||||
desktop; frame pacing is Monado's, not a headset's.
|
||||
+25
-1
@@ -24,7 +24,6 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| A Unity OpenXR game closes right after the OpenXR instance is created: `F VrApiLoader: vrapi_SetPropertyInt was called before vrapi_Initialize()!`, SIGABRT on UnityMain (GitHub #62, Jurassic World Aftermath) | the APK still ships Meta's VrApi loader; OVRPlugin (1.89, on OpenXR) calls it without starting VrApi and Meta's loader aborts. The VrApi bridge can't replace it (39 of the loader's 114 functions: OVRPlugin wouldn't link) | `frame.vrapi_stub` (same exports, each returns 0); triage `vrapi-before-init` |
|
||||
| A VrApi game exits at once: `UnsatisfiedLinkError … dlopen failed: cannot locate symbol "vrapi_PollEvent"` (GitHub #57, BlazeRush) | the engine imports a VrApi function the VrApi bridge didn't implement | the bridge has `vrapi_PollEvent`, `RecenterPose`, `SetDisplayRefreshRate`, `GetSystemPropertyFloatArray` (rebuild the game); the static check "VrApi functions resolvable" names any other missing one; triage `vrapi-symbol-missing` |
|
||||
| A Team Beef port (Lambda1VR, RTCWQuest, …) exits at once: `UnsatisfiedLinkError: dlopen failed: library "libopenxr_loader_valve.so" not found` (GitHub #59) | TBXR loads `openxr_loader_<Build.MANUFACTURER>` (Lepton: valve) and takes the Meta path only when `strstr(OPENXR_HMD, "meta")` matches, else Pico's | `frame.tbxr_vendor` (empty `libopenxr_loader_valve.so` + the "meta" literal → "alve"). Game data (`xash/`, …) goes to /sdcard: upload it with the Files tab to the game's storage. Black eyes with `TBXR: Incomplete frame buffer object: GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE`: TBXR always uses multisampled render-to-texture (even `--msaa 1`); `frame.tbxr_vendor` links the GL shim, which hands it single-sampled versions (log `GL shim: multisampled render-to-texture … drawn single-sampled`) |
|
||||
| A Quest game renders, then logs `Entitlement check failed` / `ovrPlatformInitialize_NotEntitled` (often with retries) and quits, e.g. `SIG: 9` after `[Photon] Disconnected` (GitHub #322, Deadly Delivery) | The game's Meta licence check (entitlement) needs Meta's online login, which the Frame doesn't have; online-only games quit (triage `meta-entitlement`) | None: FramePort doesn't get around entitlement checks. Offline games that log it and play on are fine |
|
||||
| Black screen after VR starts; `Asset status error (code 100)` in `DLC_Manager:AssetStatusCallback` (GitHub #203, e.g. PixelRipped 1995) | Meta asset-file status fails after entitlement succeeds; the log does not identify the native call or asset ID | Try `frame.asset_files_stub` (opt-in, triage-only): native/langpack answers GetList with an empty successful list and StatusById with installed status, an empty path and the requested ID. No DLC files are supplied; download requests retain their existing behavior. Disable with `FRAMEPORT_ASSET_FILES_STUB=0`. Host-tested only; whether this fixes startup needs a game/device test |
|
||||
| Missing sound or extra content in a game whose OBB folder has separate `*.pak` files (GitHub #61, Star Wars: Tales from the Galaxy's Edge: seasons + Wwise banks) | the game asks Meta's platform for these asset files (`ovr_AssetFile_GetList` / `DownloadById`); OVRPort's loader doesn't list them | copy every content/sound-bank PAK and use `frame.asset_files` (native/langpack lists the data's `*.pak` as installed `default` assets, IAP `free`, and answers DownloadById at once with the path; off: env FRAMEPORT_ASSET_FILES=0). Tales also needs the splash and depth-size fixes below |
|
||||
| A SideQuest backup has `apk/` and a sibling `obb/` with content/sound-bank PAKs, but the Frame only has the main OBB (GitHub #61, Tales from the Galaxy's Edge) | older scans treated that layout as loose expansion files and selected only the named OBBs; the asset-file patch then found no PAKs | the scanner chooses the OBB folder by version first, then keeps the whole sibling data folder when all its expansion files are selected for this APK; app updates repair affected library entries and refresh asset/language-pack detection. Reinstall to copy the missing files, or scan the backup again first. Folders with other packages or unselected OBB versions still send only the selected OBBs |
|
||||
@@ -268,3 +267,28 @@ Scan a folder with Rift game dumps (Windows game folders) like Quest dumps; they
|
||||
| Proton game hangs at start in a headless/SSH launch; log `no driver could be loaded` / `explorer process failed to start` | no display session (DISPLAY/GAMESCOPE_WAYLAND_DISPLAY) | the Proton launch.sh takes them from the running Steam (built in) |
|
||||
| Unreal game starts (window created) then `CrashReportClient` runs | the game crashed under Proton; often no VR runtime reached it (Revive off) | keep `pcvr.revive` on (repacks' bundled LibRevive64.dll isn't loaded by itself); read the game log + crash summary in the launch log |
|
||||
| `VK_ERROR_DEVICE_LOST` under DXVK | freedreno GPU hang | `pcvr.proton_log` to capture; PC mode |
|
||||
|
||||
## Quest games on this PC (AXRB, `docs/PC_ANDROID.md`)
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Unconverted Quest APK in AXRB: no launcher activity, SIGABRT ~0.3 s after `libovrplatformloader.so` loads | Meta's platform loader aborts outside a Quest | FramePort always gives AXRB its OVRPort build (the PC build: OVRPort + `on_pc` APK fixes, no Steam Frame fixes) |
|
||||
| `axrb-no-headset`: host.err `failed to find D3D11 adapter requested by OpenXR runtime` / `xrCreateInstance failed: XR_ERROR_RUNTIME_FAILURE` | SteamVR has no headset (null driver: no D3D11 adapter; vrlink with no Frame connected: no instance) | connect the headset and let SteamVR show it first; for unattended tests use Monado's simulated headset: `frameport test <pkg> --to pc --sim` (docs/PC_ANDROID.md "Testing without a headset") |
|
||||
| Testing a PC (AXRB) game without a headset: need a D3D11 OpenXR session, but SteamVR must stay untouched | SteamVR without a headset gives no D3D11 adapter / no instance | `--sim` (or `FRAMEPORT_PC_XR_SIM=1`): portable Monado for Windows, simulated HMD + `ml2` controllers, selected per process with `XR_RUNTIME_JSON` (the system runtime stays SteamVR); AXRB's SteamVR app registration is skipped; frames in `<data>/logs/xr-sim/` (AXRB_CAPTURE_PREFIX), Monado's window via `xr_sim.capture()`. Service log `<data>/logs/xr-sim-service.log` |
|
||||
| `axrb-gfxstream-crash`: SIGSEGV in `vulkan.ranchu.so (gfxstream::vk::ResourceTracker::on_vkAllocateMemory)` from `libVkLayer_AXRB_runtime.so` (Pinball FX VR, UE5 Vulkan, headless) | the emulator's Vulkan path through AXRB's runtime layer | report to AXRB with diagnostics (`axrb/*` logs); recheck with the host attached |
|
||||
| `axrb-translation-crash`: crash in `libndk_translation.so (berberis::…)` | Android's ARM translation (AXRB issue #9, some Unity games) | nothing to patch; wait for AXRB's other translator |
|
||||
| Reinstall fails `INSTALL_FAILED_UPDATE_INCOMPATIBLE`, or the game data must be copied again | a copy signed with another key was installed in AXRB (e.g. by AXRB's own launcher); a full uninstall deletes the OBB folder and saves | uninstall it in AXRB first; FramePort keeps one key per game, so its own updates keep data |
|
||||
| A FramePort call that runs an AXRB script never returns although the script ended (emulator already up) | QEMU (`Start-Process`) and the adb server inherit the caller's handles; through WSL interop even a file or /dev/null keeps the relay open | `axrb.spawn_detached` starts the script through WMI (`Win32_Process.Create`, nothing inherited) and polls its PID; exit code from the log's `FP_EXIT` line |
|
||||
| Which AXRB runs a game? (`deployment.json` `backend`) | AXRB 2.x (AXRB-Emulatorless) has a direct mode (`axrb-direct.exe`, no emulator) that only renders Vulkan; 1.0.4 is the emulator | automatic: direct for `graphics` Vulkan, emulator for GLES/unknown; `pc.axrb_backend` (backend / translator / precompose / legacy-driver options) or `frameport install <pkg> --to pc --axrb-backend direct\|emulator`; a switch leaves the other backend's copy and saves where they are |
|
||||
| `axrb-direct-no-gles`: direct.log `eglInitialize … EGL_BAD_DISPLAY` / `Unable to initialize EGL` / Unity's `Unable to retrieve the default EGL display!` | the game draws with OpenGL ES; direct mode's EGL is a stub (no GLES renderer). Not reported once a Vulkan device exists (Unreal Vulkan builds probe EGL first) | install it for the emulator (`pc.axrb_backend` backend=emulator); automatic choice already does this when the analysis says GLES |
|
||||
| `axrb-direct-incomplete`: `Direct mode installation is incomplete` / `Direct mode is missing …` / `Preparing the AOSP application failed` | a direct-mode file of AXRB is missing, or the sysroot (`<runtime>\direct\sysroot`, receipt `.axrb-sysroot.json` digest + format) isn't complete | reinstall AXRB-Emulatorless or rebuild the local build (`FRAMEPORT_AXRB_DIRECT_DIR`), then `frameport pc install-axrb --backend direct` (re-runs AXRB's `make_sysroot.py --art`) |
|
||||
| `axrb-direct-no-vulkan`: `vulkan-1.dll is not installed: the guest has no GPU` | no Windows Vulkan loader/driver | install the GPU's current driver |
|
||||
| `axrb-direct-unhandled-instruction`: `fatal signal 4 (SIGILL)` in direct.log | Dynarmic (or Digitalis) can't execute an arm64 instruction | report to AXRB with diagnostics (`axrb/direct/*`); try `pc.axrb_backend` translator=digitalis (or dynarmic) or the emulator |
|
||||
| `axrb-direct-guest-crash` (`E/cpu: thread … fatal signal 11/7/6/5`), `axrb-direct-host-fault` (`E/host-fault:` / `E/fatal:` = axrb-direct.exe itself), `axrb-direct-java-crash` (`FATAL EXCEPTION`, `Runtime aborting`) | a crash under direct mode's translator/ART; usually a missing Android service (Meta/Play/account/DRM), a translation bug or an unsupported syscall/Binder call (`axrb-direct-unsupported-syscall`, warning) | the other translator or the emulator; report to AXRB with the backtrace in direct.log |
|
||||
| `axrb-direct-no-native-window`: `AXRB_AOSP_HOST_SURFACES 0`, then nothing (The Climb 2: last line `Loading plug-in CryHTTP`, the main thread busy-polls its looper) | NativeActivity games take their surface with `Window.takeSurface`; AXRB's direct mode (`HostWindow.attach`) only bridges SurfaceViews, so `APP_CMD_INIT_WINDOW` never comes | AXRB's side (drafted upstream issue + a ~40-line HostWindow patch, tested); FramePort can't fix it in the APK. With the patched bootstrap the game gets its window (`AXRB_AOSP_TAKEN_SURFACE`) |
|
||||
| `axrb-direct-vulkan-extensions`: CryEngine `Renderer initialization` → `CrySystem initialization failed!` with no vkCreateDevice (The Climb 2, direct mode) | the PC's driver lacks Quest-only device extensions (VK_EXT_fragment_density_map is the one Climb 2 requires; Android external memory too); AXRB's runtime also asks for AHB in direct mode | `frame.vk_android_extensions` (PC builds only, added automatically for direct-VrApi Vulkan games): the renderer's `vkEnumerateDeviceExtensionProperties` import → `libfp_vkext.so`, which lists them; AXRB drops them again at vkCreateDevice. The VrApi bridge leaves runtime extensions no device has out of `vrapi_GetDeviceExtensionsVulkan` |
|
||||
| AXRB under Monado's simulator: VrApi games only submit empty frames; bridge `initial recenter: head flags=0x0`, `EnterVrMode: no tracked head pose before deadline` | Monado's simulated head lacks POSITION_TRACKED, so AXRB's host never sets up its LOCAL origin (`local_origin_flags` 0) and every pose in LOCAL has flags 0 | `--sim` sets AXRB's test-only `AXRB_TEST_SYNTHETIC_HMD=2` (fixed standing head + valid LOCAL; direct mode's host only: AXRB 1.0.4's emulator host only knows `=1`, which it ignores for a valid head) and, for games on the emulator backend, runs Monado's qwerty head instead (POSITION_TRACKED, the host sets LOCAL: `AXRB OpenXR: LOCAL origin in tracking world=(0.000 1.600 0.000)`; its WMR controllers stay inactive in AXRB, `FRAMEPORT_PC_XR_SIM_HEAD=simulated` keeps the simulated head); real headsets need neither |
|
||||
| AXRB emulator, GLES VrApi game (Path of the Warrior): frames submitted but both eyes pure black (FrameBridge `snapshot` of the eye images: all 0), guest log `libEGL: called unimplemented OpenGL ES API` about once a second | the emulator's GLES translator ("OpenGL ES 3.1") has no `glCopyImageSubData`: the VrApi bridge's copy of the game's eye texture into the OpenXR image did nothing | the bridge copies with `glBlitFramebuffer` when the context lacks ES 3.2 / `GL_EXT_copy_image` (log `GLES eye copies: glBlitFramebuffer, no glCopyImageSubData (OpenGL ES 3.1 …)`) |
|
||||
| AXRB emulator, GLES game with menus/messages as quad layers (VrApi cylinders, OVROverlay): `xrEndFrame failed: -2` on every frame, or with `projection_only=1` the menus missing | AXRB 1.0.4's emulator refuses GLES frames with more than one layer | FrameBridge `projection_only=2` (the PC default for GLES): the quads after the projection layer are drawn into its eye images on the game's GL context, then the projection layer goes alone (log `projection_only=2: 1 quad layer(s) drawn into the main picture`) |
|
||||
| A direct-mode launch test ran its full time but ends in `axrb-direct-guest-crash` | the game crashed during AXRB's own stop (after `AXRB_AOSP_ACTIVITIES_STOPPED`; The Climb 2 after `ovr_Shutdown finished`) | triage turns it into the info finding `axrb-direct-crash-at-stop`; the verdict passes |
|
||||
| Direct-mode launch test: "NEVER_STARTED" although AXRB's bridge ran | direct.out never printed `AXRB_AOSP_ACTIVITY_CREATED`/`AXRB_AOSP_LIFECYCLE` (ART's first start is interpreted, `-Xint`: slow); the run script stops when the bridge loses its headset | check host.err (`axrb-no-headset`), direct.out/err; the test waits 5 min for the activity and stops through AXRB's `stop_game.ps1` (closes the bridge window, the game pauses and saves) |
|
||||
+2
-1
@@ -13,12 +13,13 @@ embeds debug paths (`-g`) and the dex depends on the d8 version, so those differ
|
||||
|---|---|---|
|
||||
| `luajit/` | `arm64-v8a/libfp_luajit.so` | Pinned upstream LuaJIT with full-width lightuserdata (`frame.luajit_pointers`). Only the verified old Lua engine's shared public functions become imports; the original xLua API 105 bindings, LuaSocket helpers and `luaopen_perflib` remain in the game. No game library is bundled. Build: `python native/build.py --only luajit`; source revision + archive SHA-256 in `luajit/build.py`, MIT notices in `luajit/COPYRIGHT` and `artifacts/licenses/LuaJIT.txt`. `runtime.c` checks GC64 and the retained `xlua_gl` global-reference offset. `smoke.c` compares a real low-address allocation with the original library's own native static pointer; the unmodified library fails only the high-address case, the patched one round-trips both exactly. Startup verified on Frame; gameplay unverified. |
|
||||
| `adapter/` | `{arm64-v8a,armeabi-v7a}/libopenxr_loader_generic.so` | **FrameBridge**: replaces overport's generic loader (which is renamed `libopenxr_loader_original.so`) and fixes Steam Frame runtime gaps. Hooks xrCreateInstance, swapchains, xrEndFrame, spaces, xrPollEvent, … everything else is forwarded by `gen_forwarders.py`-generated tail calls. `scene_emu.c` = Meta scene/spatial-entity emulation; `flip_vk.c` = Vulkan blit for VERTICAL_FLIP quads. `session_fixes.c` = per-game session fixes, all off by default and hooked only when on (`layer_debug` diagnostics, `stable_local`, `focus_hold`, aim correction `aim_pitch/aim_yaw/aim_forward`, `refresh_rate`, `proximity_emul` = finger proximity (XR_FB_touch_controller_proximity, which the Frame lacks) from the capacitive touch inputs: OVRPlugin's `hand_thumb_proximity`/`hand_trigger_proximity` actions get extra Touch bindings); `input_diag.c` = controller-input diagnostics (setting `input_diag`, off by default and hooked only when on; logs suggested interaction profiles and the runtime's answers, each hand's current profile, functions the runtime lacks and failing input/haptics/perf calls, each line once; tested on the host by `tests/test_input_diag.py`); `layer_emul_gl.c` = `equirect_emul` (GLES sessions only): 360° equirect/equirect2 layers become one adapter projection layer, drawn by a worker thread with its own EGL context shared with the app's: each 360° image is converted to a cube map only when it changes, and the view is redrawn only after a 4° head turn (GL/EGL resolved with dlopen, so Vulkan games never load them); `layer_math.h` = their pose/face math, tested on the host by `tests/test_layer_emul.py`. `render_model.c` = XR_FB_render_model emulation (setting `controller_models`, off by default): serves `files/framebridge/controller_{left,right}.glb`, which the agent converts on the Frame from SteamVR's own Frame controller render models (never shipped by FramePort); tested on the host by `tests/test_render_model.py`. Settings: `libframe_settings.so` in the APK + `settings.conf`/`framebridge.conf` on the Frame. |
|
||||
| `vrapi-bridge/` | `arm64-v8a/libvrapi.so` | VrApi → OpenXR bridge from [Android-XR-Bridge/OVRPort](https://github.com/Android-XR-Bridge/OVRPort) `native/vrapi` at 5e7df52 (GPL-3.0, `LICENSE.upstream`; unchanged upstream as of OVRPort v1.2.5, which ships the unpatched code as its opt-in `patch_vrapi_openxr` in experimental CLI builds), with our changes in `upstream-patches/`: GLES sessions + GL texture swapchains, cylinder→quad layers, GL vertical flip, UNORM↔sRGB format twins, 30 s VR-mode deadline, VALID-only recenter, loading-icon layers skipped, `vrapi_PollEvent`/`RecenterPose`/`SetDisplayRefreshRate`/`GetSystemPropertyFloatArray` (BlazeRush imports them, GitHub #57), legacy compositor debug/volume frame flags accepted, full-rate Vulkan foveation images allocated as density attachments (The Climb, GitHub #238), diagnostics behind `-DOVP_GL_DIAG`. `upstream-patches/` reproduces these sources from the fork's `native/vrapi` (checked 2026-10-06 against its latest commit); upstream PRs Android-XR-Bridge/OVRPort#3-#8, tracked in GitHub #75. |
|
||||
| `vrapi-bridge/` | `arm64-v8a/libvrapi.so` | VrApi → OpenXR bridge from [Android-XR-Bridge/OVRPort](https://github.com/Android-XR-Bridge/OVRPort) `native/vrapi` at 5e7df52 (GPL-3.0, `LICENSE.upstream`; unchanged upstream as of OVRPort v1.2.5, which ships the unpatched code as its opt-in `patch_vrapi_openxr` in experimental CLI builds), with our changes in `upstream-patches/`: GLES sessions + GL texture swapchains, cylinder→quad layers, GL vertical flip, UNORM↔sRGB format twins, 30 s VR-mode deadline, VALID-only recenter, loading-icon layers skipped, runtime device extensions no Vulkan device offers left out of `vrapi_GetDeviceExtensionsVulkan` (AXRB's runtime asks for AHB also in its direct mode; not in `upstream-patches/` yet), `vrapi_PollEvent`/`RecenterPose`/`SetDisplayRefreshRate`/`GetSystemPropertyFloatArray` (BlazeRush imports them, GitHub #57), legacy compositor debug/volume frame flags accepted, full-rate Vulkan foveation images allocated as density attachments (The Climb, GitHub #238), diagnostics behind `-DOVP_GL_DIAG`. `upstream-patches/` reproduces these sources from the fork's `native/vrapi` (checked 2026-10-06 against its latest commit); upstream PRs Android-XR-Bridge/OVRPort#3-#8, tracked in GitHub #75. |
|
||||
| `platformcompat/` | `arm64-v8a/libovrplatformcompat.so` | Real `ovrMessageType_ToString` (same fork, `native/platform`). |
|
||||
| `langpack/` | `arm64-v8a/libfp_langpack.so` | Language packs for the Meta Platform SDK (patch `frame.langpacks`, opt-in). overport's platform loader is a dispatcher in front of Meta's own loader and answers `ovr_LanguagePack_GetCurrent/SetCurrent` with `return 0` (no reply ever). This library becomes the loader's first DT_NEEDED; the loader's own exports of the 31 functions it defines are made STB_LOCAL in `.dynsym` (`elf.hide_exports`, nothing moves), so symbol lookups reach the library first. It serves `<tag>.lang` files found in the game's OBB / app files (`$FRAMEPORT_LANGPACK_DIRS` overrides the search path) through `ovr_LanguagePack_*`, `ovr_AssetFile_GetList` (the loader's list + the packs) and `ovr_AssetFile_StatusById`; its own message queue is merged into `ovr_PopMessage`, and every handle that is not ours (an address-range check) goes to the loader's original function, whose address is read at run time from the loader's in-memory `.dynsym`. With patch `frame.asset_files` (marker byte `@FPASSETS@`, or env `FRAMEPORT_ASSET_FILES=1`) it also lists the data's `*.pak` content files as installed `default` asset files and answers `ovr_AssetFile_DownloadById` for them at once (a completed `DownloadUpdate`, then the result with the path). Host-tested against a stand-in loader (`tests/test_langpack.py`, plain `cc`, no NDK); the bionic/headset side is unverified. |
|
||||
| `glshim/` | `arm64-v8a/libglshim.so` | Mesa GLSL compatibility for GLAD engines (hooks eglGetProcAddress): comments out `#pragma` before `#extension`, enables `GL_EXT_shader_implicit_conversions`, hides GL_OVR_multiview (`gl_hide_multiview`, default 1) and logs failed shaders. `-DGLSHIM_TRACE` adds per-FBO draw/error tracing. |
|
||||
| `glmv/` | `arm64-v8a/libfpglmv.so` | Single-view draws of OVR_multiview programs (patch `frame.gl_multiview_fbo`, opt-in, GitHub #77). Mesa enforces OVR_multiview's rule that a draw's program declares as many views as the draw framebuffer has; engines that compile every shader with `num_views=2` and also draw into ordinary 2D framebuffers (Doom3Quest's HUD/PDA pool) lose those draws. The engine library gets it as first DT_NEEDED (direct `gl*` imports) and its `dlopen("libGLESv3.so")` string is rewritten to `libfpglmv.so` (same length; its `qgl*` dlsym table); linked `--no-as-needed` to libGLESv3 so dlsym on its handle finds everything it doesn't wrap. It records stage sources at glLinkProgram, caches the draw framebuffer's color-attachment view count per bind, and when a multiview program draws into a 0-view framebuffer it binds a lazily built twin (`glmv_rewrite.h`: num_views layout blanked, `gl_ViewID_OVR` -> `(0u)`; attribute locations + uniform block bindings copied, default-block uniforms copied before every such draw with a shadow to skip unchanged ones), draws, rebinds the original. Logcat tag `GLMV`; `gl_mv_debug=1`. The rewriter is host-tested on Doom3Quest's 19 shaders (`tests/test_gl_multiview_fbo.py`); untested on the device. |
|
||||
| `tlskeys/` | `arm64-v8a/libfp_tkeys.so` | Thread-key pool for Unity (patch `frame.unity_tls_key_pool`, opt-in, GitHub #198; approach and the first helper by Klownicle). Bionic allows 128 pthread keys per process; Unity 6 plus large native libraries (Firebase, Gecko) and the Frame's own libraries use them up (Wander: `cannot create thread specific key for __cxa_get_globals()`). libunity.so's four key imports are renamed to `fp_tkey_create/_delete/_get/_set` (`elf.rename_imports`, same `.dynstr` bytes, versions set global) and this library is added to its DT_NEEDED: keys without a destructor become slots (512, generation-tagged, `0x40000000 + slot`) in a per-thread table under one real key; keys with a destructor and all other libraries keep real keys. Lock-free get/set. Logs `fp_tkeys: pooling Unity's thread keys` once (tag FrameBridge). Host-tested (rename); not yet run on the device. |
|
||||
| `vkext/` | `arm64-v8a/libfp_vkext.so` | Quest Vulkan device extensions for builds for this PC (patch `frame.vk_android_extensions`, never on the Frame). AXRB's direct mode gives the game the Windows driver's device, which lacks `VK_EXT_fragment_density_map` and Android's external memory; CryEngine (The Climb 2) refuses to create its device without them. The renderer's `vkEnumerateDeviceExtensionProperties` import is renamed to `fpEnumerateDeviceExtensionProperties` (same length, `elf.rename_imports`) and this library added to its DT_NEEDED: it lists the real extensions plus the missing ones; AXRB's vkCreateDevice leaves those out again. Logs `fp_vkext: listing …` (tag FrameBridge). Seen working in direct mode (2026-10-16, headless). |
|
||||
| `zinkfix/` | `arm64-v8a/libVkLayer_fp_shaderfix.so` | Shader-fix Vulkan layer `VK_LAYER_FP_shader_fix` for OpenGL ES games (patch `frame.zink_shader_fix`): sits between Zink (Mesa's GL on Vulkan) and the driver and inserts the words of `zink_shader_fix` (same format as the Vulkan shim's `vk_shader_fix`: size + SHA-256 match) into SPIR-V modules; `zink_shader_dump=1` writes every distinct module to `files/fp_spirv/`. The engine library loads it first (DT_NEEDED); its constructor puts the layer in front of Android's GraphicsEnv debug layer list (private Android 11 `getDebugLayers`/`setDebugLayers`, found through libvulkan.so's dependencies) before EGL starts, so the loader picks it up from the app's lib dir. Exports only the enumeration functions and `VK_LAYER_FP_shader_fixGet*ProcAddr` (never plain `vk*` entry points). Vader Immortal's lightspeed shaders (Klownicle, GitHub #49). |
|
||||
| `xrlayer/` | `linux-arm64/libxr_frameport_timefix.so` + `XR_APILAYER_FRAMEPORT_timefix.json` | OpenXR API layer for Windows PC VR games under Proton on the Frame (glibc aarch64, built freestanding with the NDK's clang), on by default (patch `pcvr.xr_timefix`). Retries `xrCreateInstance` as an OpenXR 1.0 app when the runtime rejects 1.1 (the Frame's SteamVR runtime does, and Proton 11's VR helper asks for 1.1). Also emulates `xrConvertTimespecTimeToTimeKHR`/`xrConvertTimeToTimespecTimeKHR` if the runtime refuses them (the Frame's Android runtime does; its Linux runtime, used by Proton, supports them as of 2026-09-29 — so this part is only a fallback; Proton's wineopenxr needs them for `XR_KHR_win32_convert_performance_counter_time`, which Revive requires) with the adapter's xrWaitFrame-calibrated offset, and drops the extension from xrCreateInstance if the runtime rejects it. Enabled per game by the Proton launch.sh (`XR_API_LAYER_PATH`, `XR_ENABLE_API_LAYERS`). `tests/test_xrlayer.py` builds it for x86_64 and drives it via ctypes. |
|
||||
| `oculushmd/` | `win-x64/fp_oculushmd.exe` | PC VR counterpart of overport's `patch_oculus_unreal` (patch `pcvr.oculus_unreal`, default for Unreal Rift games on the Frame). Unreal's OculusHMD and LibOVR's `ovr_Detect` only start when the Windows named event `OculusHMDConnected` exists and is signalled (the Oculus service creates it). `fp_oculushmd.exe <command line>` creates it (manual reset, signalled), runs the command (Revive's injector) in a job object and keeps the event until every process in the job — the game and its children — has exited (fallback without job support: 3 minutes); returns the command's exit code and logs `FramePort oculushmd: …` to stderr (launch.log). Freestanding Windows x64 console exe (no CRT, no Windows SDK): the NDK's clang + `lld-link /Brepro` with a kernel32 import library generated by `llvm-dlltool`, so it's byte-reproducible. Tested on Windows from WSL with a build whose event name is `-DEVENT_NAME=L"…"` (the Oculus service owns the real event on a PC with the Oculus app). |
|
||||
|
||||
@@ -98,6 +98,15 @@ static int foveation_eye_enabled, foveation_eye_supported;
|
||||
// hide XR_FB_space_warp: games then render every frame themselves (UE5 Application SpaceWarp flickered on the Frame,
|
||||
// e.g. Into The Radius 2; OVRPort's patch_disable_space_warp doesn't reach UE5's OpenXR plugin)
|
||||
static int hide_space_warp = 0;
|
||||
// projection_only: frames with a projection layer and more layers submit the projection layer alone (AXRB 1.0.4's
|
||||
// emulator rejects every multi-layer GLES frame with -2: its GLES path has no layer batches; Accounting+'s OVROverlay
|
||||
// quads made almost every frame fail). The other layers (menus, overlays drawn as quads) are not shown; with
|
||||
// projection_only=2 (GLES) the quads after the projection layer are drawn into its eye images first (quad_compose_gl.c).
|
||||
static int projection_only = 0;
|
||||
// scale_max: the maximum eye size reported is the scaled recommended one too (games that size their eye buffers from
|
||||
// the maximum or scale the recommendation up: Accounting+'s Unity 2017 OVRPlugin made 896x1024 from a 448x504
|
||||
// recommendation). For AXRB's emulator, where every eye pixel is copied.
|
||||
static int scale_max = 0;
|
||||
static int strip_color_bias = 0;
|
||||
// fade_quad.c: draw colour-scale fades (fade to black) as a black quad over the picture; 2 = also remove the scale
|
||||
static int fade_quad = 0;
|
||||
@@ -175,6 +184,8 @@ static void read_settings(const char *path) {
|
||||
if (sscanf(line, "foveation_storage_fix=%f", &value) == 1) foveation_storage_fix = value != 0;
|
||||
if (sscanf(line, "foveation_eye=%f", &value) == 1) foveation_eye = value != 0;
|
||||
if (sscanf(line, "hide_space_warp=%f", &value) == 1) hide_space_warp = value != 0;
|
||||
if (sscanf(line, "projection_only=%f", &value) == 1) projection_only = value >= 2 ? 2 : value != 0;
|
||||
if (sscanf(line, "scale_max=%f", &value) == 1) scale_max = value != 0;
|
||||
if (sscanf(line, "strip_color_bias=%f", &value) == 1) strip_color_bias = (int)value;
|
||||
if (sscanf(line, "fade_quad=%f", &value) == 1) fade_quad = value > 0 ? (int)value : 0;
|
||||
if (sscanf(line, "frame_balance=%f", &value) == 1) frame_balance = value != 0;
|
||||
@@ -273,6 +284,9 @@ static void initialize(void) {
|
||||
if (proximity_emul) LOG("per-game: proximity_emul=%d (finger proximity from capacitive touch: %s)", proximity_emul,
|
||||
proximity_emul > 1 ? "thumb + index" : "thumb");
|
||||
if (hide_space_warp) LOG("per-game: hide_space_warp=1 (XR_FB_space_warp hidden, space warp info removed)");
|
||||
if (projection_only)
|
||||
LOG("per-game: projection_only=%d (frames with more layers submit the projection layer alone%s)", projection_only,
|
||||
projection_only == 2 ? ", quads drawn into it" : "");
|
||||
if (strip_color_bias) LOG("per-game: strip_color_bias=%d (layer color scale/bias removed)", strip_color_bias);
|
||||
if (fade_quad) LOG("per-game: fade_quad=%d (colour-scale fades drawn as a black quad%s)", fade_quad,
|
||||
fade_quad > 1 ? "; the layer's own scale removed" : "");
|
||||
@@ -459,7 +473,12 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateViewConfigurationViews(XrInstance inst
|
||||
if (h > views[i].maxImageRectHeight) h = views[i].maxImageRectHeight;
|
||||
views[i].recommendedImageRectWidth = w ? w : 1;
|
||||
views[i].recommendedImageRectHeight = h ? h : 1;
|
||||
LOG("view %u recommended %ux%u", i, views[i].recommendedImageRectWidth, views[i].recommendedImageRectHeight);
|
||||
if (scale_max && scale < 1.0f) {
|
||||
views[i].maxImageRectWidth = views[i].recommendedImageRectWidth;
|
||||
views[i].maxImageRectHeight = views[i].recommendedImageRectHeight;
|
||||
}
|
||||
LOG("view %u recommended %ux%u%s", i, views[i].recommendedImageRectWidth, views[i].recommendedImageRectHeight,
|
||||
scale_max && scale < 1.0f ? " (also the maximum)" : "");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -977,6 +996,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpace(XrSpace space) {
|
||||
#include "layer_emul_gl.c"
|
||||
#include "cube_standin.c"
|
||||
#include "snapshot_gl.c"
|
||||
#include "quad_compose_gl.c"
|
||||
#include "surface_swapchain.c"
|
||||
#include "fade_quad.c"
|
||||
|
||||
@@ -1607,6 +1627,21 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
|
||||
frames = 0; drift_sum = drift_max = 0;
|
||||
}
|
||||
}
|
||||
XrFrameEndInfo single;
|
||||
if (projection_only && info && info->layerCount > 1 && info->layers) {
|
||||
for (uint32_t i = 0; i < info->layerCount; ++i) {
|
||||
const XrCompositionLayerBaseHeader *l = info->layers[i];
|
||||
if (!l || l->type != XR_TYPE_COMPOSITION_LAYER_PROJECTION) continue;
|
||||
static int logged;
|
||||
if (!logged++) LOG("projection_only: %u layers -> the projection layer alone", info->layerCount);
|
||||
qc_compose(info, i);
|
||||
single = *info;
|
||||
single.layerCount = 1;
|
||||
single.layers = &info->layers[i];
|
||||
info = &single;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ((!layer_fix && !swap_eyes && !emulate_passthrough && !flip_quads && !flip_emul && !strip_depth && !fade_quad) || !info || !info->layerCount ||
|
||||
info->layerCount > 64)
|
||||
return fn(session, info);
|
||||
|
||||
@@ -126,4 +126,23 @@ static inline int lm_face_visible(int face, XrVector3f forward, float cos_limit)
|
||||
// Cosine of the angle between the face's centre direction and `forward` (unit): sort key, larger = closer to view.
|
||||
static inline float lm_face_score(int face, XrVector3f forward) { return lm_dot(lm_face_dir(face, 0, 0), forward); }
|
||||
|
||||
// Clip-space corners of a quad layer seen from one eye (projection_only=2 draws quads into the projection image):
|
||||
// `quad` = the quad's pose in the projection layer's space, `size` its extent in metres, `eye` + `fov` the view's
|
||||
// pose and angles in that space. out[0..3] = bottom-left, bottom-right, top-left, top-right (a triangle strip),
|
||||
// each x, y, z, w with w = depth in front of the eye (GL then interpolates the texture coordinates
|
||||
// perspective-correctly and clips what lies behind the eye); z = 0 (no depth test).
|
||||
static inline void lm_quad_clip(XrPosef quad, XrExtent2Df size, XrPosef eye, XrFovf fov, float out[4][4]) {
|
||||
const float tl = tanf(fov.angleLeft), tr = tanf(fov.angleRight), tu = tanf(fov.angleUp), td = tanf(fov.angleDown);
|
||||
const XrPosef to_eye = lm_pose_mul(lm_pose_inv(eye), quad);
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
XrVector3f c = {(i & 1 ? 0.5f : -0.5f) * size.width, (i & 2 ? 0.5f : -0.5f) * size.height, 0};
|
||||
XrVector3f r = lm_rotate(to_eye.orientation, c);
|
||||
float x = r.x + to_eye.position.x, y = r.y + to_eye.position.y, z = r.z + to_eye.position.z;
|
||||
out[i][0] = (2 * x + (tr + tl) * z) / (tr - tl);
|
||||
out[i][1] = (2 * y + (tu + td) * z) / (tu - td);
|
||||
out[i][2] = 0;
|
||||
out[i][3] = -z;
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,252 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Included by frame_adapter.c after snapshot_gl.c (whose swapchain image tracking it uses). projection_only=2 (GLES
|
||||
// sessions only): before a frame with a projection layer and more layers is reduced to the projection layer alone,
|
||||
// the quad layers submitted after the projection layer are drawn into its eye images, so menus and overlays drawn as
|
||||
// quads still show. For runtimes that refuse multi-layer GLES frames (AXRB 1.0.4's emulator: -2 for every one;
|
||||
// Path of the Warrior's menus are a VrApi cylinder the VrApi bridge turns into a quad, Accounting+ uses OVROverlay
|
||||
// quads).
|
||||
// * Drawn on the game's thread, in xrEndFrame, on its current GL context (the one the runtime reads the images back
|
||||
// from), with the eye image the game just released as the target: one textured quad per eye and quad layer, at
|
||||
// the eye's view and FOV (lm_quad_clip). No depth test: a quad is drawn over the scene (as a compositor does).
|
||||
// * Blending follows the quad's flags: XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT blends (premultiplied, or
|
||||
// straight alpha with UNPREMULTIPLIED_ALPHA_BIT), else the quad is opaque. Array quad swapchains are skipped.
|
||||
// * Every GL state it touches is restored and its errors are cleared. A failure (shader, framebuffer) is logged once
|
||||
// and turns the drawing off for the process; the frame still goes out as the projection layer alone.
|
||||
#define QC_GL_FUNCS(X) X(glCreateShader) X(glShaderSource) X(glCompileShader) X(glGetShaderiv) X(glGetShaderInfoLog) \
|
||||
X(glCreateProgram) X(glAttachShader) X(glLinkProgram) X(glGetProgramiv) X(glDeleteShader) X(glUseProgram) \
|
||||
X(glGetUniformLocation) X(glUniform4fv) X(glUniform1i) X(glUniform1f) X(glGenVertexArrays) X(glBindVertexArray) \
|
||||
X(glGenFramebuffers) X(glBindFramebuffer) X(glFramebufferTexture2D) X(glFramebufferTextureLayer) \
|
||||
X(glCheckFramebufferStatus) X(glViewport) X(glGetIntegerv) X(glGetBooleanv) X(glIsEnabled) X(glEnable) \
|
||||
X(glDisable) X(glBlendFuncSeparate) X(glBlendEquationSeparate) X(glColorMask) X(glActiveTexture) \
|
||||
X(glBindTexture) X(glGenSamplers) X(glSamplerParameteri) X(glBindSampler) X(glDrawArrays) X(glGetError)
|
||||
#define QC_DECLARE(fn) static __typeof__(&fn) q_##fn;
|
||||
QC_GL_FUNCS(QC_DECLARE)
|
||||
#undef QC_DECLARE
|
||||
|
||||
static int qc_state; // 0 untried, 1 ok, -1 off for this process
|
||||
static EGLContext qc_context; // the context the objects below belong to (VAOs and FBOs aren't shared)
|
||||
static GLuint qc_program, qc_vao, qc_fb, qc_sampler;
|
||||
static GLint qc_u_v, qc_u_tex, qc_u_opaque;
|
||||
|
||||
static const char *QC_VS =
|
||||
"#version 300 es\n"
|
||||
"uniform vec4 u_v[8];\n" // 0-3 clip-space corners (BL, BR, TL, TR), 4-7 their texture coordinates
|
||||
"out vec2 v_uv;\n"
|
||||
"void main() { gl_Position = u_v[gl_VertexID]; v_uv = u_v[4 + gl_VertexID].xy; }\n";
|
||||
static const char *QC_FS =
|
||||
"#version 300 es\n"
|
||||
"precision mediump float;\n"
|
||||
"uniform sampler2D u_tex;\n"
|
||||
"uniform float u_opaque;\n"
|
||||
"in vec2 v_uv;\n"
|
||||
"out vec4 o_color;\n"
|
||||
"void main() { vec4 c = texture(u_tex, v_uv); o_color = vec4(c.rgb, max(c.a, u_opaque)); }\n";
|
||||
|
||||
static void qc_off(const char *why) {
|
||||
qc_state = -1;
|
||||
LOG("projection_only=2: %s, quad layers are dropped instead of drawn into the main picture", why);
|
||||
}
|
||||
|
||||
static GLuint qc_shader(GLenum type, const char *src) {
|
||||
GLuint s = q_glCreateShader(type);
|
||||
q_glShaderSource(s, 1, &src, NULL);
|
||||
q_glCompileShader(s);
|
||||
GLint ok = 0;
|
||||
q_glGetShaderiv(s, GL_COMPILE_STATUS, &ok);
|
||||
if (!ok) {
|
||||
char log[256] = {0};
|
||||
q_glGetShaderInfoLog(s, sizeof(log) - 1, NULL, log);
|
||||
LOG("projection_only=2: shader: %s", log);
|
||||
q_glDeleteShader(s);
|
||||
return 0;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// GL entry points + this context's objects. 1 = ready.
|
||||
static int qc_setup(void) {
|
||||
if (qc_state < 0) return 0;
|
||||
if (!qc_state) {
|
||||
if (!snap_load_gl()) { qc_off("OpenGL ES not available"); return 0; }
|
||||
void *gles = dlopen("libGLESv3.so", RTLD_NOW | RTLD_LOCAL);
|
||||
int ok = gles != NULL;
|
||||
#define QC_RESOLVE(fn) ok &= gles && (q_##fn = (__typeof__(q_##fn))dlsym(gles, #fn)) != NULL;
|
||||
QC_GL_FUNCS(QC_RESOLVE)
|
||||
#undef QC_RESOLVE
|
||||
if (!ok) { qc_off("GL functions missing"); return 0; }
|
||||
qc_state = 1;
|
||||
}
|
||||
EGLContext ctx = s_eglGetCurrentContext();
|
||||
if (!ctx) return 0;
|
||||
if (ctx == qc_context && qc_program) return 1;
|
||||
// a new context (or the first): build the program and objects there (the old ones belonged to another context)
|
||||
GLint old_program = 0;
|
||||
q_glGetIntegerv(GL_CURRENT_PROGRAM, &old_program);
|
||||
GLuint vs = qc_shader(GL_VERTEX_SHADER, QC_VS), fs = qc_shader(GL_FRAGMENT_SHADER, QC_FS);
|
||||
GLuint program = 0;
|
||||
if (vs && fs) {
|
||||
program = q_glCreateProgram();
|
||||
q_glAttachShader(program, vs);
|
||||
q_glAttachShader(program, fs);
|
||||
q_glLinkProgram(program);
|
||||
GLint linked = 0;
|
||||
q_glGetProgramiv(program, GL_LINK_STATUS, &linked);
|
||||
if (!linked) program = 0;
|
||||
}
|
||||
if (vs) q_glDeleteShader(vs);
|
||||
if (fs) q_glDeleteShader(fs);
|
||||
if (!program) { while (q_glGetError() != GL_NO_ERROR) {} qc_off("shader program failed"); return 0; }
|
||||
qc_program = program;
|
||||
qc_u_v = q_glGetUniformLocation(program, "u_v");
|
||||
qc_u_tex = q_glGetUniformLocation(program, "u_tex");
|
||||
qc_u_opaque = q_glGetUniformLocation(program, "u_opaque");
|
||||
q_glGenVertexArrays(1, &qc_vao);
|
||||
q_glGenFramebuffers(1, &qc_fb);
|
||||
q_glGenSamplers(1, &qc_sampler);
|
||||
q_glSamplerParameteri(qc_sampler, GL_TEXTURE_MIN_FILTER, GL_LINEAR); // no mipmaps needed (and none required)
|
||||
q_glSamplerParameteri(qc_sampler, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
q_glSamplerParameteri(qc_sampler, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
q_glSamplerParameteri(qc_sampler, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
q_glUseProgram((GLuint)old_program);
|
||||
qc_context = ctx;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// The GL texture (and size) of the image the game last acquired (= just released) from a swapchain; 0 if unknown.
|
||||
static GLuint qc_image(XrSwapchain handle, uint32_t *w, uint32_t *h, uint32_t *array_size) {
|
||||
GLuint tex = 0;
|
||||
pthread_mutex_lock(&snap_lock);
|
||||
int i = snap_find(handle, 0);
|
||||
if (i >= 0 && snap_chains[i].count && snap_chains[i].last_acquired < snap_chains[i].count) {
|
||||
tex = snap_chains[i].images[snap_chains[i].last_acquired];
|
||||
*w = snap_chains[i].width, *h = snap_chains[i].height, *array_size = snap_chains[i].array_size;
|
||||
}
|
||||
pthread_mutex_unlock(&snap_lock);
|
||||
return tex;
|
||||
}
|
||||
|
||||
static void qc_compose(const XrFrameEndInfo *info, uint32_t proj_index) {
|
||||
if (projection_only != 2 || !info || !info->layers) return;
|
||||
const XrCompositionLayerProjection *proj = (const XrCompositionLayerProjection *)info->layers[proj_index];
|
||||
if (!proj || proj->viewCount < 2 || !proj->views) return;
|
||||
uint32_t quads = 0;
|
||||
for (uint32_t i = proj_index + 1; i < info->layerCount; ++i)
|
||||
if (info->layers[i] && info->layers[i]->type == XR_TYPE_COMPOSITION_LAYER_QUAD) ++quads;
|
||||
if (!quads || !qc_setup()) return;
|
||||
|
||||
// the game's state we change
|
||||
GLint draw_fb, viewport[4], program, vao, active_tex, tex2d, sampler, src_rgb, dst_rgb, src_a, dst_a, eq_rgb, eq_a;
|
||||
GLboolean color_mask[4];
|
||||
const GLenum caps[] = {GL_BLEND, GL_DEPTH_TEST, GL_STENCIL_TEST, GL_SCISSOR_TEST, GL_CULL_FACE,
|
||||
GL_SAMPLE_ALPHA_TO_COVERAGE, GL_RASTERIZER_DISCARD};
|
||||
GLboolean cap_on[sizeof(caps) / sizeof(caps[0])];
|
||||
q_glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &draw_fb);
|
||||
q_glGetIntegerv(GL_VIEWPORT, viewport);
|
||||
q_glGetIntegerv(GL_CURRENT_PROGRAM, &program);
|
||||
q_glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &vao);
|
||||
q_glGetIntegerv(GL_ACTIVE_TEXTURE, &active_tex);
|
||||
q_glActiveTexture(GL_TEXTURE0);
|
||||
q_glGetIntegerv(GL_TEXTURE_BINDING_2D, &tex2d);
|
||||
q_glGetIntegerv(GL_SAMPLER_BINDING, &sampler);
|
||||
q_glGetIntegerv(GL_BLEND_SRC_RGB, &src_rgb);
|
||||
q_glGetIntegerv(GL_BLEND_DST_RGB, &dst_rgb);
|
||||
q_glGetIntegerv(GL_BLEND_SRC_ALPHA, &src_a);
|
||||
q_glGetIntegerv(GL_BLEND_DST_ALPHA, &dst_a);
|
||||
q_glGetIntegerv(GL_BLEND_EQUATION_RGB, &eq_rgb);
|
||||
q_glGetIntegerv(GL_BLEND_EQUATION_ALPHA, &eq_a);
|
||||
q_glGetBooleanv(GL_COLOR_WRITEMASK, color_mask);
|
||||
for (size_t c = 0; c < sizeof(caps) / sizeof(caps[0]); ++c) {
|
||||
cap_on[c] = q_glIsEnabled(caps[c]);
|
||||
q_glDisable(caps[c]);
|
||||
}
|
||||
q_glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
|
||||
q_glBlendEquationSeparate(GL_FUNC_ADD, GL_FUNC_ADD);
|
||||
q_glUseProgram(qc_program);
|
||||
q_glBindVertexArray(qc_vao);
|
||||
q_glBindSampler(0, qc_sampler);
|
||||
q_glUniform1i(qc_u_tex, 0);
|
||||
q_glBindFramebuffer(GL_DRAW_FRAMEBUFFER, qc_fb);
|
||||
|
||||
PFN_xrLocateSpace locate = (PFN_xrLocateSpace)lookup(active_instance, "xrLocateSpace");
|
||||
int drawn = 0;
|
||||
for (uint32_t eye = 0; eye < 2; ++eye) {
|
||||
const XrCompositionLayerProjectionView *view = &proj->views[eye];
|
||||
uint32_t tw = 0, th = 0, tarray = 1;
|
||||
GLuint target = qc_image(view->subImage.swapchain, &tw, &th, &tarray);
|
||||
if (!target) continue;
|
||||
if (tarray > 1)
|
||||
q_glFramebufferTextureLayer(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, target, 0,
|
||||
(GLint)view->subImage.imageArrayIndex);
|
||||
else
|
||||
q_glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, target, 0);
|
||||
if (q_glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
|
||||
qc_off("eye image not drawable (framebuffer incomplete)");
|
||||
break;
|
||||
}
|
||||
const XrRect2Di *r = &view->subImage.imageRect;
|
||||
q_glViewport(r->offset.x, r->offset.y, r->extent.width, r->extent.height);
|
||||
for (uint32_t i = proj_index + 1; i < info->layerCount; ++i) {
|
||||
const XrCompositionLayerQuad *q = (const XrCompositionLayerQuad *)info->layers[i];
|
||||
if (!q || q->type != XR_TYPE_COMPOSITION_LAYER_QUAD) continue;
|
||||
if (q->eyeVisibility == XR_EYE_VISIBILITY_LEFT && eye != 0) continue;
|
||||
if (q->eyeVisibility == XR_EYE_VISIBILITY_RIGHT && eye != 1) continue;
|
||||
uint32_t sw = 0, sh = 0, sarray = 1;
|
||||
GLuint source = qc_image(q->subImage.swapchain, &sw, &sh, &sarray);
|
||||
if (!source || sarray > 1 || !sw || !sh) continue;
|
||||
XrPosef pose = q->pose; // the quad in the projection layer's space
|
||||
if (q->space != proj->space) {
|
||||
XrSpaceLocation loc = {XR_TYPE_SPACE_LOCATION};
|
||||
if (!locate || XR_FAILED(locate(q->space, proj->space, info->displayTime, &loc))) continue;
|
||||
pose = lm_pose_mul(loc.pose, q->pose);
|
||||
}
|
||||
float v[8][4];
|
||||
lm_quad_clip(pose, q->size, view->pose, view->fov, v);
|
||||
const XrRect2Di *s = &q->subImage.imageRect;
|
||||
float u0 = (float)s->offset.x / sw, u1 = (float)(s->offset.x + s->extent.width) / sw;
|
||||
float v0 = (float)s->offset.y / sh, v1 = (float)(s->offset.y + s->extent.height) / sh;
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
v[4 + k][0] = k & 1 ? u1 : u0;
|
||||
v[4 + k][1] = k & 2 ? v1 : v0; // GL textures: v grows upwards, the quad's top is +Y
|
||||
v[4 + k][2] = v[4 + k][3] = 0;
|
||||
}
|
||||
q_glUniform4fv(qc_u_v, 8, &v[0][0]);
|
||||
const int blend = (q->layerFlags & XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT) != 0;
|
||||
q_glUniform1f(qc_u_opaque, blend ? 0.0f : 1.0f);
|
||||
if (blend) {
|
||||
q_glEnable(GL_BLEND);
|
||||
if (q->layerFlags & XR_COMPOSITION_LAYER_UNPREMULTIPLIED_ALPHA_BIT)
|
||||
q_glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
|
||||
else
|
||||
q_glBlendFuncSeparate(GL_ONE, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
|
||||
} else {
|
||||
q_glDisable(GL_BLEND);
|
||||
}
|
||||
q_glBindTexture(GL_TEXTURE_2D, source);
|
||||
q_glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
||||
++drawn;
|
||||
}
|
||||
}
|
||||
|
||||
// the game's state back
|
||||
q_glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0);
|
||||
q_glBindFramebuffer(GL_DRAW_FRAMEBUFFER, (GLuint)draw_fb);
|
||||
q_glViewport(viewport[0], viewport[1], viewport[2], viewport[3]);
|
||||
q_glBindTexture(GL_TEXTURE_2D, (GLuint)tex2d);
|
||||
q_glBindSampler(0, (GLuint)sampler);
|
||||
q_glActiveTexture((GLenum)active_tex);
|
||||
q_glBindVertexArray((GLuint)vao);
|
||||
q_glUseProgram((GLuint)program);
|
||||
q_glBlendFuncSeparate((GLenum)src_rgb, (GLenum)dst_rgb, (GLenum)src_a, (GLenum)dst_a);
|
||||
q_glBlendEquationSeparate((GLenum)eq_rgb, (GLenum)eq_a);
|
||||
q_glColorMask(color_mask[0], color_mask[1], color_mask[2], color_mask[3]);
|
||||
for (size_t c = 0; c < sizeof(caps) / sizeof(caps[0]); ++c)
|
||||
if (cap_on[c]) q_glEnable(caps[c]); else q_glDisable(caps[c]);
|
||||
GLenum err, first = GL_NO_ERROR;
|
||||
while ((err = q_glGetError()) != GL_NO_ERROR) if (first == GL_NO_ERROR) first = err;
|
||||
static int logged;
|
||||
if (drawn && !logged++) LOG("projection_only=2: %u quad layer(s) drawn into the main picture", quads);
|
||||
if (first != GL_NO_ERROR) {
|
||||
static int errors;
|
||||
if (errors++ < 3) LOG("projection_only=2: GL error 0x%x while drawing quads", first);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ SNAP_GL_FUNCS(SNAP_DECLARE)
|
||||
#undef SNAP_DECLARE
|
||||
static EGLContext (*s_eglGetCurrentContext)(void);
|
||||
|
||||
// The swapchain image tracking also serves projection_only=2 (quad_compose_gl.c).
|
||||
#define snap_tracking() (snapshot || projection_only == 2)
|
||||
|
||||
static struct {
|
||||
XrSwapchain handle;
|
||||
uint32_t width, height, array_size, count, last_acquired;
|
||||
@@ -48,7 +51,7 @@ static int snap_find(XrSwapchain handle, int create) {
|
||||
}
|
||||
|
||||
static void snap_on_create(XrSwapchain handle, const XrSwapchainCreateInfo *info) {
|
||||
if (!snapshot || !info) return;
|
||||
if (!snap_tracking() || !info) return;
|
||||
pthread_mutex_lock(&snap_lock);
|
||||
int i = snap_find(handle, 1);
|
||||
if (i >= 0) {
|
||||
@@ -60,7 +63,7 @@ static void snap_on_create(XrSwapchain handle, const XrSwapchainCreateInfo *info
|
||||
}
|
||||
|
||||
static void snap_on_destroy(XrSwapchain handle) {
|
||||
if (!snapshot) return;
|
||||
if (!snap_tracking()) return;
|
||||
pthread_mutex_lock(&snap_lock);
|
||||
int i = snap_find(handle, 0);
|
||||
if (i >= 0) snap_chains[i].handle = XR_NULL_HANDLE;
|
||||
@@ -68,7 +71,7 @@ static void snap_on_destroy(XrSwapchain handle) {
|
||||
}
|
||||
|
||||
static void snap_on_enumerate(XrSwapchain handle, uint32_t count, const XrSwapchainImageBaseHeader *images) {
|
||||
if (!snapshot || !images || !count || images->type != (XrStructureType)1000024002) return; // OPENGL_ES_KHR
|
||||
if (!snap_tracking() || !images || !count || images->type != (XrStructureType)1000024002) return; // OPENGL_ES_KHR
|
||||
pthread_mutex_lock(&snap_lock);
|
||||
int i = snap_find(handle, 1);
|
||||
if (i >= 0) {
|
||||
@@ -81,7 +84,7 @@ static void snap_on_enumerate(XrSwapchain handle, uint32_t count, const XrSwapch
|
||||
}
|
||||
|
||||
static void snap_on_acquire(XrSwapchain handle, uint32_t index) {
|
||||
if (!snapshot) return;
|
||||
if (!snap_tracking()) return;
|
||||
pthread_mutex_lock(&snap_lock);
|
||||
int i = snap_find(handle, 0);
|
||||
if (i >= 0) snap_chains[i].last_acquired = index;
|
||||
|
||||
+12
-3
@@ -380,6 +380,15 @@ def build_tlskeys(tc: Path):
|
||||
"-Wl,-z,max-page-size=16384", "tlskeys.c", "-llog", "-o", ART / "arm64-v8a/libfp_tkeys.so"], cwd=src)
|
||||
|
||||
|
||||
def build_vkext(tc: Path):
|
||||
"""Android-only Vulkan device extensions listed for builds for this PC (see vkext/vkext.c, patch
|
||||
frame.vk_android_extensions)."""
|
||||
src = HERE / "vkext"
|
||||
run([exe(tc, "aarch64-linux-android29-clang"), "-shared", "-fPIC", "-O2", "-Wall", "-Wextra", "-Werror",
|
||||
"-fvisibility=hidden", f"-ffile-prefix-map={HERE}=native", "-Wl,-soname,libfp_vkext.so",
|
||||
"-Wl,-z,max-page-size=16384", "vkext.c", "-lvulkan", "-llog", "-o", ART / "arm64-v8a/libfp_vkext.so"], cwd=src)
|
||||
|
||||
|
||||
def build_luajit(tc: Path):
|
||||
run([sys.executable, HERE / "luajit/build.py", "--clang-dir", tc / "bin",
|
||||
"--out", ART / "arm64-v8a/libfp_luajit.so"])
|
||||
@@ -438,7 +447,7 @@ def write_sums():
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--only", default="adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,foveation,ovrpshim,tlskeys,luajit,vrsettings,venc,a32")
|
||||
ap.add_argument("--only", default="adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,foveation,ovrpshim,tlskeys,vkext,luajit,vrsettings,venc,a32")
|
||||
ap.add_argument("--ndk")
|
||||
args = ap.parse_args()
|
||||
parts = set(args.only.split(","))
|
||||
@@ -451,11 +460,11 @@ def main():
|
||||
"oculushmd": lambda: build_oculushmd(tc), "vkshim": lambda: build_vkshim(tc),
|
||||
"zinkfix": lambda: build_zinkfix(tc),
|
||||
"foveation": lambda: build_foveation(tc),
|
||||
"ovrpshim": lambda: build_ovrpshim(tc), "tlskeys": lambda: build_tlskeys(tc),
|
||||
"ovrpshim": lambda: build_ovrpshim(tc), "tlskeys": lambda: build_tlskeys(tc), "vkext": lambda: build_vkext(tc),
|
||||
"luajit": lambda: build_luajit(tc),
|
||||
"vrsettings": lambda: build_vrsettings(tc), "venc": lambda: build_venc(tc),
|
||||
"vroverlay_probe": lambda: build_vroverlay_probe(tc), "a32": lambda: build_a32(tc)}
|
||||
for name in ("adapter", "bridge", "compat", "langpack", "glshim", "eglfmt", "glmv", "ovrtrace", "xrshim", "vkshim", "zinkfix", "foveation", "ovrpshim", "tlskeys", "luajit", "dex", "xrlayer", "oculushmd", "vrsettings", "venc",
|
||||
for name in ("adapter", "bridge", "compat", "langpack", "glshim", "eglfmt", "glmv", "ovrtrace", "xrshim", "vkshim", "zinkfix", "foveation", "ovrpshim", "tlskeys", "vkext", "luajit", "dex", "xrlayer", "oculushmd", "vrsettings", "venc",
|
||||
"vroverlay_probe", "a32"):
|
||||
if name in parts:
|
||||
log(f"build {name}")
|
||||
|
||||
+72
-5
@@ -35,6 +35,7 @@ static const GLubyte *(*real_glGetStringi)(GLenum, GLuint);
|
||||
static void (*real_glGetIntegerv)(GLenum, GLint *);
|
||||
static __eglMustCastToProperFunctionPointerType (*real_eglGetProcAddress)(const char *);
|
||||
static pthread_once_t once = PTHREAD_ONCE_INIT;
|
||||
static int tq_used; // GPU timer-query stand-ins handed out (below): GL_GPU_DISJOINT_EXT reads answer 0
|
||||
|
||||
static int hide_multiview = -1; // -1: not set (Unity and Unreal keep multiview, everything else hides it)
|
||||
static int hide_msrtt = 1;
|
||||
@@ -628,6 +629,21 @@ static int is_hidden(const char *name) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// GL_EXT_disjoint_timer_query listed although the driver lacks it: GLAD loaders only look its functions up when the
|
||||
// extension is listed, and some engines call them unconditionally (Path of the Warrior on AXRB's emulator, whose
|
||||
// gfxstream eglGetProcAddress still returns pointers for the names); the lookups then get the stand-ins below
|
||||
// eglGetProcAddress. Decided once a context answers GL_NUM_EXTENSIONS, by whether the driver lists the extension.
|
||||
static const char TIMER_QUERY_EXT[] = "GL_EXT_disjoint_timer_query";
|
||||
static int added_timer_query = -1;
|
||||
static int add_timer_query(void) {
|
||||
if (added_timer_query < 0 && real_glGetIntegerv && real_glGetStringi) {
|
||||
GLint total = 0;
|
||||
real_glGetIntegerv(GL_NUM_EXTENSIONS, &total);
|
||||
if (total > 0) added_timer_query = !ext_supported(TIMER_QUERY_EXT);
|
||||
}
|
||||
return added_timer_query > 0;
|
||||
}
|
||||
|
||||
// Filtered index table for glGetStringi (built lazily, per process).
|
||||
static GLuint *visible;
|
||||
static GLint visible_count = -1;
|
||||
@@ -641,12 +657,17 @@ static void build_table(void) {
|
||||
const char *name = (const char *)real_glGetStringi(GL_EXTENSIONS, (GLuint)i);
|
||||
if (name && !is_hidden(name)) visible[visible_count++] = (GLuint)i;
|
||||
}
|
||||
LOG("extensions: %d of %d visible to the game", visible_count, total);
|
||||
LOG("extensions: %d of %d visible to the game%s", visible_count, total,
|
||||
add_timer_query() ? " (+ GL_EXT_disjoint_timer_query, stand-ins)" : "");
|
||||
}
|
||||
|
||||
__attribute__((visibility("default"))) void glGetIntegerv(GLenum pname, GLint *data) {
|
||||
pthread_once(&once, init);
|
||||
if (pname == GL_NUM_EXTENSIONS && data) { build_table(); if (visible_count >= 0) { *data = visible_count; return; } }
|
||||
if (pname == GL_NUM_EXTENSIONS && data) {
|
||||
build_table();
|
||||
if (visible_count >= 0) { *data = visible_count + add_timer_query(); return; }
|
||||
}
|
||||
if (pname == 0x8FBB /* GL_GPU_DISJOINT_EXT */ && tq_used && data) { *data = 0; return; }
|
||||
if (real_glGetIntegerv) real_glGetIntegerv(pname, data);
|
||||
}
|
||||
|
||||
@@ -654,7 +675,10 @@ __attribute__((visibility("default"))) const GLubyte *glGetStringi(GLenum name,
|
||||
pthread_once(&once, init);
|
||||
if (name == GL_EXTENSIONS) {
|
||||
build_table();
|
||||
if (visible_count >= 0) return index < (GLuint)visible_count ? real_glGetStringi(name, visible[index]) : NULL;
|
||||
if (visible_count >= 0) {
|
||||
if (index < (GLuint)visible_count) return real_glGetStringi(name, visible[index]);
|
||||
return index == (GLuint)visible_count && add_timer_query() ? (const GLubyte *)TIMER_QUERY_EXT : NULL;
|
||||
}
|
||||
}
|
||||
return real_glGetStringi ? real_glGetStringi(name, index) : NULL;
|
||||
}
|
||||
@@ -665,11 +689,12 @@ __attribute__((visibility("default"))) const GLubyte *glGetString(GLenum name) {
|
||||
if (name != GL_EXTENSIONS || !s) return s;
|
||||
static char *filtered;
|
||||
if (!filtered) {
|
||||
filtered = calloc(strlen((const char *)s) + 1, 1);
|
||||
filtered = calloc(strlen((const char *)s) + sizeof(TIMER_QUERY_EXT) + 2, 1);
|
||||
char *copy = strdup((const char *)s), *save = NULL;
|
||||
for (char *tok = strtok_r(copy, " ", &save); tok; tok = strtok_r(NULL, " ", &save))
|
||||
if (!is_hidden(tok)) { strcat(filtered, tok); strcat(filtered, " "); }
|
||||
free(copy);
|
||||
if (add_timer_query()) { strcat(filtered, TIMER_QUERY_EXT); strcat(filtered, " "); }
|
||||
}
|
||||
return (const GLubyte *)filtered;
|
||||
}
|
||||
@@ -794,13 +819,55 @@ EXPORT void glDrawRangeElements(GLenum m, GLuint s, GLuint e, GLsizei c, GLenum
|
||||
if (d.DrawRangeElements) d.DrawRangeElements(m, s, e, c, t, i);
|
||||
}
|
||||
|
||||
// GPU timer queries (GL_EXT_disjoint_timer_query) the driver doesn't have: GLAD engines call the function pointers
|
||||
// whether or not the extension is listed (Path of the Warrior: glad_glGenQueriesEXT NULL -> SIGSEGV at pc 0 in
|
||||
// EgHalGrTimestampQueryGL on AXRB's emulator, whose gfxstream GLES has no timer queries; Quest and the Frame have
|
||||
// them, so the stand-ins are only handed out when the real lookup fails). Every query is at once available and
|
||||
// measured 0 ns.
|
||||
static GLuint tq_next = 0x7f000000;
|
||||
static void tq_gen(GLsizei n, GLuint *ids) { for (GLsizei i = 0; ids && i < n; i++) ids[i] = __atomic_add_fetch(&tq_next, 1, __ATOMIC_RELAXED); }
|
||||
static void tq_delete(GLsizei n, const GLuint *ids) { (void)n; (void)ids; }
|
||||
static GLboolean tq_is(GLuint id) { return id > 0x7f000000; }
|
||||
static void tq_begin(GLenum target, GLuint id) { (void)target; (void)id; }
|
||||
static void tq_end(GLenum target) { (void)target; }
|
||||
static void tq_counter(GLuint id, GLenum target) { (void)id; (void)target; }
|
||||
static void tq_getiv(GLenum target, GLenum pname, GLint *v) { (void)target; (void)pname; if (v) *v = 0; }
|
||||
#define TQ_RESULT(v, pname) if (v) *(v) = (pname) == 0x8867 /* GL_QUERY_RESULT_AVAILABLE */ ? 1 : 0
|
||||
static void tq_objiv(GLuint id, GLenum pname, GLint *v) { (void)id; TQ_RESULT(v, pname); }
|
||||
static void tq_objuiv(GLuint id, GLenum pname, GLuint *v) { (void)id; TQ_RESULT(v, pname); }
|
||||
static void tq_obji64v(GLuint id, GLenum pname, GLint64 *v) { (void)id; TQ_RESULT(v, pname); }
|
||||
static void tq_objui64v(GLuint id, GLenum pname, GLuint64 *v) { (void)id; TQ_RESULT(v, pname); }
|
||||
static void tq_int64v(GLenum pname, GLint64 *v) { (void)pname; if (v) *v = 0; }
|
||||
static const struct { const char *name; void *fn; } timer_query_standins[] = {
|
||||
{"glGenQueriesEXT", (void *)tq_gen}, {"glDeleteQueriesEXT", (void *)tq_delete}, {"glIsQueryEXT", (void *)tq_is},
|
||||
{"glBeginQueryEXT", (void *)tq_begin}, {"glEndQueryEXT", (void *)tq_end},
|
||||
{"glQueryCounterEXT", (void *)tq_counter}, {"glGetQueryivEXT", (void *)tq_getiv},
|
||||
{"glGetQueryObjectivEXT", (void *)tq_objiv}, {"glGetQueryObjectuivEXT", (void *)tq_objuiv},
|
||||
{"glGetQueryObjecti64vEXT", (void *)tq_obji64v}, {"glGetQueryObjectui64vEXT", (void *)tq_objui64v},
|
||||
{"glGetInteger64vEXT", (void *)tq_int64v},
|
||||
};
|
||||
|
||||
static __eglMustCastToProperFunctionPointerType timer_query_standin(const char *name) {
|
||||
for (size_t i = 0; i < sizeof(timer_query_standins) / sizeof(timer_query_standins[0]); i++)
|
||||
if (!strcmp(name, timer_query_standins[i].name)) {
|
||||
if (!tq_used++) LOG("no GPU timer queries in this driver: %s and the other EXT query functions answer "
|
||||
"'available, 0 ns'", name);
|
||||
return (__eglMustCastToProperFunctionPointerType)timer_query_standins[i].fn;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
__attribute__((visibility("default"))) __eglMustCastToProperFunctionPointerType eglGetProcAddress(const char *name) {
|
||||
pthread_once(&once, init);
|
||||
if (name && !strcmp(name, "glGetStringi")) return (__eglMustCastToProperFunctionPointerType)glGetStringi;
|
||||
if (name && !strcmp(name, "glGetString")) return (__eglMustCastToProperFunctionPointerType)glGetString;
|
||||
if (name && !strcmp(name, "glGetIntegerv")) return (__eglMustCastToProperFunctionPointerType)glGetIntegerv;
|
||||
__eglMustCastToProperFunctionPointerType fn = real_eglGetProcAddress ? real_eglGetProcAddress(name) : NULL;
|
||||
if (!fn || !name) return fn;
|
||||
if (name && (!fn || added_timer_query > 0)) {
|
||||
__eglMustCastToProperFunctionPointerType standin = timer_query_standin(name);
|
||||
if (standin || !fn) return standin;
|
||||
}
|
||||
if (!name) return fn;
|
||||
#define WRAP(sym, real, w) if (!strcmp(name, sym)) { real = (void *)fn; return (__eglMustCastToProperFunctionPointerType)w; }
|
||||
if (hide_msrtt && (!strcmp(name, "glFramebufferTexture2DMultisampleEXT") ||
|
||||
!strcmp(name, "glRenderbufferStorageMultisampleEXT"))) {
|
||||
|
||||
@@ -34,6 +34,9 @@ static pthread_once_t once = PTHREAD_ONCE_INIT;
|
||||
// physics update back made the hands lag and the gate made loading screens stutter, so both are off unless the recipe
|
||||
// turns them on (ovrp_begin_gate=1, ovrp_hold_physics=1).
|
||||
static int begin_gate, hold_physics;
|
||||
// ovrp_trace=1 (diagnostics): every 5 s, how many frame-loop calls went through and which are in progress (thread,
|
||||
// frame index, for how long): shows where the main and render threads wait when a game stops after a few frames.
|
||||
static int trace;
|
||||
|
||||
static void read_conf_file(const char *path) {
|
||||
FILE *f = fopen(path, "r");
|
||||
@@ -42,6 +45,7 @@ static void read_conf_file(const char *path) {
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
if (!strncmp(line, "ovrp_begin_gate=", 16)) begin_gate = atoi(line + 16);
|
||||
if (!strncmp(line, "ovrp_hold_physics=", 18)) hold_physics = atoi(line + 18);
|
||||
if (!strncmp(line, "ovrp_trace=", 11)) trace = atoi(line + 11);
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
@@ -73,8 +77,56 @@ static void read_conf(void) {
|
||||
if (env && *env) read_conf_file(env);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ovrp_trace
|
||||
#include <unistd.h>
|
||||
static long long now_ns(void);
|
||||
enum { T_PHYSICS, T_RENDER, T_WAIT, T_BEGIN, T_END, T_COUNT };
|
||||
static const char *const trace_names[T_COUNT] = {"Update2(physics)", "Update2(render)", "WaitToBeginFrame",
|
||||
"BeginFrame", "EndFrame"};
|
||||
static struct { long long since; int frame, tid, last_frame, last_result; long calls; } traced[T_COUNT];
|
||||
|
||||
static void trace_enter(int k, int frame) {
|
||||
if (!trace) return;
|
||||
traced[k].frame = frame;
|
||||
traced[k].tid = gettid();
|
||||
__atomic_store_n(&traced[k].since, now_ns(), __ATOMIC_RELEASE);
|
||||
}
|
||||
|
||||
static void trace_exit(int k, int result) {
|
||||
if (!trace) return;
|
||||
__atomic_store_n(&traced[k].since, 0, __ATOMIC_RELEASE);
|
||||
traced[k].last_frame = traced[k].frame;
|
||||
traced[k].last_result = result;
|
||||
__atomic_add_fetch(&traced[k].calls, 1, __ATOMIC_RELAXED);
|
||||
}
|
||||
|
||||
static void *trace_watch(void *arg) {
|
||||
(void)arg;
|
||||
for (;;) {
|
||||
sleep(5);
|
||||
char line[700];
|
||||
size_t used = 0;
|
||||
long long now = now_ns();
|
||||
for (int k = 0; k < T_COUNT && used < sizeof(line) - 120; ++k) {
|
||||
long long since = __atomic_load_n(&traced[k].since, __ATOMIC_ACQUIRE);
|
||||
used += (size_t)snprintf(line + used, sizeof(line) - used, " %s %ld (last frame %d: %d)", trace_names[k],
|
||||
__atomic_exchange_n(&traced[k].calls, 0, __ATOMIC_RELAXED), traced[k].last_frame,
|
||||
traced[k].last_result);
|
||||
if (since)
|
||||
used += (size_t)snprintf(line + used, sizeof(line) - used, " [IN frame %d, tid %d, %lld ms]",
|
||||
traced[k].frame, traced[k].tid, (now - since) / 1000000);
|
||||
}
|
||||
LOG("ovrp trace 5s:%s", line);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void init(void) {
|
||||
read_conf();
|
||||
if (trace) {
|
||||
pthread_t watcher;
|
||||
if (!pthread_create(&watcher, NULL, trace_watch, NULL)) pthread_detach(watcher);
|
||||
}
|
||||
void *ovrp = dlopen("libOVRPlugin.so", RTLD_NOW | RTLD_NOLOAD);
|
||||
if (!ovrp) ovrp = dlopen("libOVRPlugin.so", RTLD_NOW);
|
||||
if (ovrp) {
|
||||
@@ -134,7 +186,9 @@ EXPORT int fpov_Update2(int step, int frame_index, double prediction_seconds) {
|
||||
if (step == STEP_RENDER && real_wait && frame_index != last_waited) {
|
||||
last_waited = frame_index;
|
||||
if (last_wait_begun(frame_index)) {
|
||||
trace_enter(T_WAIT, frame_index);
|
||||
int r = real_wait(frame_index);
|
||||
trace_exit(T_WAIT, r);
|
||||
if (logged++ < 3) LOG("ovrp frame loop shim: waited for frame %d: %d", frame_index, r);
|
||||
} else if (skipped++ < 20 || skipped % 1000 == 0) {
|
||||
LOG("ovrp frame loop shim: frame %d: the last waited frame wasn't begun within %d ms, not waiting "
|
||||
@@ -143,7 +197,10 @@ EXPORT int fpov_Update2(int step, int frame_index, double prediction_seconds) {
|
||||
mouse_click_frame();
|
||||
}
|
||||
if (hold_physics && step == STEP_PHYSICS && rendered) return last_result; // see above: keeps the display-time poses
|
||||
const int traced_step = step == STEP_RENDER ? T_RENDER : T_PHYSICS;
|
||||
trace_enter(traced_step, frame_index);
|
||||
int r = real_update2 ? real_update2(step, frame_index, prediction_seconds) : -1000; // ovrpFailure
|
||||
trace_exit(traced_step, r);
|
||||
if (step == STEP_RENDER) rendered = 1, last_result = r;
|
||||
return r;
|
||||
}
|
||||
@@ -173,7 +230,9 @@ EXPORT int fpov_BeginFrame(long frame, long b, long c, long d) {
|
||||
remap_from = remap_to = -1;
|
||||
}
|
||||
pthread_mutex_unlock(&begin_lock);
|
||||
trace_enter(T_BEGIN, (int)index);
|
||||
int r = real ? real(index, b, c, d) : -1000;
|
||||
trace_exit(T_BEGIN, r);
|
||||
if (index != frame && logged++ < 20)
|
||||
LOG("ovrp frame loop shim: begin frame %ld as waited frame %ld: %d", frame, index, r);
|
||||
pthread_mutex_lock(&begin_lock);
|
||||
@@ -186,11 +245,15 @@ EXPORT int fpov_BeginFrame(long frame, long b, long c, long d) {
|
||||
|
||||
EXPORT int fpov_EndFrame(long frame, long b, long c, long d) {
|
||||
static PFN_FrameCall real;
|
||||
pthread_once(&once, init);
|
||||
if (!real) real = ovrp_fn("ovrp_EndFrame");
|
||||
pthread_mutex_lock(&begin_lock);
|
||||
if ((int)frame == remap_from) frame = remap_to;
|
||||
pthread_mutex_unlock(&begin_lock);
|
||||
return real ? real(frame, b, c, d) : -1000;
|
||||
trace_enter(T_END, (int)frame);
|
||||
int r = real ? real(frame, b, c, d) : -1000;
|
||||
trace_exit(T_END, r);
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- input diagnostics (pass-through)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// libfp_vkext.so (patch frame.vk_android_extensions, builds for this PC only): Android-only Vulkan device
|
||||
// extensions for engines that refuse to start without them. AXRB's direct mode hands the guest the Windows driver's
|
||||
// device extensions, which lack Android's external-memory ones and the fragment density map Quest GPUs have;
|
||||
// CryEngine (The Climb 2) then fails "Renderer initialization" before it creates a device. The engine's import of
|
||||
// vkEnumerateDeviceExtensionProperties is renamed to fpEnumerateDeviceExtensionProperties (same length,
|
||||
// elf.rename_imports), which lists the real extensions plus the missing ones of EXTRA. AXRB's vkCreateDevice leaves out requested extensions the host GPU doesn't have, so nothing
|
||||
// else changes; on a device that has them (the Frame, where the patch is never applied) the list is the same.
|
||||
#include <android/log.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <vulkan/vulkan.h>
|
||||
|
||||
static const char* const EXTRA[] = {
|
||||
"VK_KHR_external_memory_fd",
|
||||
"VK_ANDROID_external_memory_android_hardware_buffer",
|
||||
"VK_EXT_queue_family_foreign",
|
||||
// Quest GPUs have it, desktop NVIDIA drivers don't: The Climb 2 requires it (no device without it; with it listed
|
||||
// and left out again by AXRB, the VrApi bridge's full-rate foveation stand-ins take over)
|
||||
"VK_EXT_fragment_density_map",
|
||||
};
|
||||
#define N_EXTRA (sizeof(EXTRA) / sizeof(EXTRA[0]))
|
||||
|
||||
__attribute__((visibility("default"))) VkResult fpEnumerateDeviceExtensionProperties(
|
||||
VkPhysicalDevice device, const char* layer, uint32_t* count, VkExtensionProperties* props) {
|
||||
if (layer || !count) return vkEnumerateDeviceExtensionProperties(device, layer, count, props);
|
||||
uint32_t n = 0;
|
||||
VkResult r = vkEnumerateDeviceExtensionProperties(device, NULL, &n, NULL);
|
||||
if (r != VK_SUCCESS) return r;
|
||||
VkExtensionProperties* all = calloc(n + N_EXTRA, sizeof(*all));
|
||||
if (!all) return VK_ERROR_OUT_OF_HOST_MEMORY;
|
||||
r = vkEnumerateDeviceExtensionProperties(device, NULL, &n, all);
|
||||
if (r < 0) {
|
||||
free(all);
|
||||
return r;
|
||||
}
|
||||
uint32_t total = n;
|
||||
static int logged;
|
||||
for (unsigned e = 0; e < N_EXTRA; ++e) {
|
||||
int have = 0;
|
||||
for (uint32_t i = 0; i < n && !have; ++i) have = !strcmp(all[i].extensionName, EXTRA[e]);
|
||||
if (have) continue;
|
||||
strncpy(all[total].extensionName, EXTRA[e], VK_MAX_EXTENSION_NAME_SIZE - 1);
|
||||
all[total].specVersion = 1;
|
||||
if (!logged) __android_log_print(ANDROID_LOG_INFO, "FrameBridge", "fp_vkext: listing %s (not on this GPU)", EXTRA[e]);
|
||||
++total;
|
||||
}
|
||||
logged = 1;
|
||||
if (!props) {
|
||||
*count = total;
|
||||
free(all);
|
||||
return VK_SUCCESS;
|
||||
}
|
||||
const uint32_t c = *count < total ? *count : total;
|
||||
memcpy(props, all, c * sizeof(*props));
|
||||
*count = c;
|
||||
free(all);
|
||||
return c < total ? VK_INCOMPLETE : VK_SUCCESS;
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <new>
|
||||
|
||||
@@ -752,6 +754,59 @@ bool acquireJobs(std::array<CopyJob, kMaxCopyJobs>& jobs, uint32_t count) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// glCopyImageSubData is OpenGL ES 3.2 (or GL_EXT/OES_copy_image). The Android emulator's GLES translator (AXRB's
|
||||
// emulator on a PC: "OpenGL ES 3.0"/"3.1" without either extension) only has a stub that logs "called unimplemented
|
||||
// OpenGL ES API" and copies nothing: every eye image reached the runtime black (Path of the Warrior). Without it the
|
||||
// copy is a glBlitFramebuffer (ES 3.0) per array layer: the same texels for the same formats (the twin RGBA8/sRGB
|
||||
// formats of ensureOutputGl would be converted by a blit; that combination only arises when the runtime lacks the
|
||||
// app's format).
|
||||
bool glCopyImageAvailable() {
|
||||
static int state = -1; // one driver per process
|
||||
if (state < 0) {
|
||||
const char* version = reinterpret_cast<const char*>(glGetString(GL_VERSION));
|
||||
const char* ext = reinterpret_cast<const char*>(glGetString(GL_EXTENSIONS));
|
||||
int major = 0, minor = 0;
|
||||
if (version) std::sscanf(version, "OpenGL ES %d.%d", &major, &minor);
|
||||
state = major > 3 || (major == 3 && minor >= 2) ||
|
||||
(ext && (std::strstr(ext, "GL_EXT_copy_image") || std::strstr(ext, "GL_OES_copy_image")));
|
||||
OVP_LOG("GLES eye copies: %s (%s)", state ? "glCopyImageSubData" : "glBlitFramebuffer, no glCopyImageSubData",
|
||||
version ? version : "no GL version");
|
||||
}
|
||||
return state == 1;
|
||||
}
|
||||
|
||||
void blitCopyGl(GLuint source, GLuint destination, GLenum target, GLsizei width, GLsizei height, GLsizei layers) {
|
||||
GLint readFbo = 0, drawFbo = 0;
|
||||
GLboolean mask[4] = {GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE};
|
||||
glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &readFbo);
|
||||
glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &drawFbo);
|
||||
glGetBooleanv(GL_COLOR_WRITEMASK, mask);
|
||||
const GLboolean scissor = glIsEnabled(GL_SCISSOR_TEST), discard = glIsEnabled(GL_RASTERIZER_DISCARD);
|
||||
glDisable(GL_SCISSOR_TEST);
|
||||
glDisable(GL_RASTERIZER_DISCARD);
|
||||
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
|
||||
GLuint fbo[2] = {0, 0}; // per call: framebuffers aren't shared between the app's contexts
|
||||
glGenFramebuffers(2, fbo);
|
||||
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo[0]);
|
||||
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo[1]);
|
||||
for (GLsizei layer = 0; layer < std::max<GLsizei>(layers, 1); ++layer) {
|
||||
if (target == GL_TEXTURE_2D_ARRAY) {
|
||||
glFramebufferTextureLayer(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, source, 0, layer);
|
||||
glFramebufferTextureLayer(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, destination, 0, layer);
|
||||
} else {
|
||||
glFramebufferTexture2D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, source, 0);
|
||||
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, destination, 0);
|
||||
}
|
||||
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);
|
||||
}
|
||||
glBindFramebuffer(GL_READ_FRAMEBUFFER, static_cast<GLuint>(readFbo));
|
||||
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, static_cast<GLuint>(drawFbo));
|
||||
glDeleteFramebuffers(2, fbo);
|
||||
glColorMask(mask[0], mask[1], mask[2], mask[3]);
|
||||
if (scissor) glEnable(GL_SCISSOR_TEST);
|
||||
if (discard) glEnable(GL_RASTERIZER_DISCARD);
|
||||
}
|
||||
|
||||
bool submitCopiesGl(std::array<CopyJob, kMaxCopyJobs>& jobs, uint32_t count) {
|
||||
ovp::Runtime& s = ovp::runtime();
|
||||
#ifdef OVP_GL_DIAG // build with -DOVP_GL_DIAG for app GL state + eye image readback probes
|
||||
@@ -791,12 +846,17 @@ bool submitCopiesGl(std::array<CopyJob, kMaxCopyJobs>& jobs, uint32_t count) {
|
||||
const GLuint destination = chain->glOutputImages[jobs[i].outputIndex].image;
|
||||
const GLenum target = chain->arrayLayers > 1 ? GL_TEXTURE_2D_ARRAY : GL_TEXTURE_2D;
|
||||
while (glGetError() != GL_NO_ERROR) {} // don't blame the copy for errors the app left behind
|
||||
glCopyImageSubData(source, target, 0, 0, 0, 0, destination, target, 0, 0, 0, 0,
|
||||
static_cast<GLsizei>(chain->width), static_cast<GLsizei>(chain->height),
|
||||
static_cast<GLsizei>(chain->arrayLayers));
|
||||
if (glCopyImageAvailable()) {
|
||||
glCopyImageSubData(source, target, 0, 0, 0, 0, destination, target, 0, 0, 0, 0,
|
||||
static_cast<GLsizei>(chain->width), static_cast<GLsizei>(chain->height),
|
||||
static_cast<GLsizei>(chain->arrayLayers));
|
||||
} else {
|
||||
blitCopyGl(source, destination, target, static_cast<GLsizei>(chain->width),
|
||||
static_cast<GLsizei>(chain->height), static_cast<GLsizei>(chain->arrayLayers));
|
||||
}
|
||||
const GLenum error = glGetError();
|
||||
static int errors;
|
||||
if (error != GL_NO_ERROR && errors++ < 5) OVP_ERROR("glCopyImageSubData failed: 0x%x", error);
|
||||
if (error != GL_NO_ERROR && errors++ < 5) OVP_ERROR("GL eye image copy failed: 0x%x", error);
|
||||
#ifdef OVP_GL_DIAG
|
||||
static int probes;
|
||||
if (probes < 4000 && (probes++ % 360) == 0) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <dlfcn.h>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace ovp {
|
||||
@@ -747,12 +748,68 @@ VRAPI ovrResult vrapi_GetInstanceExtensionsVulkan(char* names, uint32_t* size) {
|
||||
uint32_t capacity = names ? *size : 0;
|
||||
return xrOk(s.xr.xrGetVulkanInstanceExtensionsKHR(s.instance, s.system, capacity, size, names), "xrGetVulkanInstanceExtensionsKHR") ? Success : InvalidOperation;
|
||||
}
|
||||
// The runtime's device extensions that no Vulkan device here offers are left out (FramePort): engines like
|
||||
// CryEngine refuse to create a device when a requested extension is missing, and AXRB's runtime asks for its
|
||||
// emulator's VK_ANDROID_external_memory_android_hardware_buffer + VK_EXT_queue_family_foreign also in its direct mode,
|
||||
// where the Windows driver has neither ("CrySystem initialization failed"). Where every device has them (the Frame)
|
||||
// the list is unchanged.
|
||||
static std::string supportedDeviceExtensions(const std::string& wanted) {
|
||||
VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO};
|
||||
app.apiVersion = VK_API_VERSION_1_0;
|
||||
VkInstanceCreateInfo info{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO};
|
||||
info.pApplicationInfo = &app;
|
||||
VkInstance probe = VK_NULL_HANDLE;
|
||||
if (vkCreateInstance(&info, nullptr, &probe) != VK_SUCCESS || !probe) return wanted;
|
||||
std::vector<std::string> available;
|
||||
uint32_t devices = 0;
|
||||
vkEnumeratePhysicalDevices(probe, &devices, nullptr);
|
||||
std::vector<VkPhysicalDevice> list(devices);
|
||||
if (devices && vkEnumeratePhysicalDevices(probe, &devices, list.data()) >= VK_SUCCESS) {
|
||||
for (uint32_t d = 0; d < devices; ++d) {
|
||||
uint32_t count = 0;
|
||||
vkEnumerateDeviceExtensionProperties(list[d], nullptr, &count, nullptr);
|
||||
std::vector<VkExtensionProperties> props(count);
|
||||
if (count && vkEnumerateDeviceExtensionProperties(list[d], nullptr, &count, props.data()) >= VK_SUCCESS)
|
||||
for (uint32_t i = 0; i < count; ++i) available.emplace_back(props[i].extensionName);
|
||||
}
|
||||
}
|
||||
vkDestroyInstance(probe, nullptr);
|
||||
if (available.empty()) return wanted; // nothing to judge by: keep the runtime's list
|
||||
std::string out, dropped;
|
||||
size_t start = 0;
|
||||
while (start < wanted.size()) {
|
||||
size_t end = wanted.find(' ', start);
|
||||
if (end == std::string::npos) end = wanted.size();
|
||||
std::string name = wanted.substr(start, end - start);
|
||||
if (!name.empty()) {
|
||||
std::string& to = std::find(available.begin(), available.end(), name) != available.end() ? out : dropped;
|
||||
to += (to.empty() ? "" : " ") + name;
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
if (!dropped.empty()) OVP_LOG("Vulkan device extensions the runtime asks for but no device offers, left out: %s", dropped.c_str());
|
||||
return out;
|
||||
}
|
||||
VRAPI ovrResult vrapi_GetDeviceExtensionsVulkan(char* names, uint32_t* size) {
|
||||
LOCK_STATE;
|
||||
if (!s.instance) return NotInitialized;
|
||||
if (!size) return InvalidParameter;
|
||||
uint32_t capacity = names ? *size : 0;
|
||||
return xrOk(s.xr.xrGetVulkanDeviceExtensionsKHR(s.instance, s.system, capacity, size, names), "xrGetVulkanDeviceExtensionsKHR") ? Success : InvalidOperation;
|
||||
static std::string filtered;
|
||||
static bool haveFiltered = false;
|
||||
if (!haveFiltered) {
|
||||
uint32_t needed = 0;
|
||||
if (!xrOk(s.xr.xrGetVulkanDeviceExtensionsKHR(s.instance, s.system, 0, &needed, nullptr), "xrGetVulkanDeviceExtensionsKHR")) return InvalidOperation;
|
||||
std::vector<char> raw(needed + 1, '\0');
|
||||
if (needed && !xrOk(s.xr.xrGetVulkanDeviceExtensionsKHR(s.instance, s.system, needed, &needed, raw.data()), "xrGetVulkanDeviceExtensionsKHR")) return InvalidOperation;
|
||||
filtered = supportedDeviceExtensions(std::string(raw.data()));
|
||||
haveFiltered = true;
|
||||
}
|
||||
const uint32_t needed = static_cast<uint32_t>(filtered.size() + 1);
|
||||
if (!names) { *size = needed; return Success; }
|
||||
if (*size < needed) { *size = needed; return InvalidParameter; }
|
||||
std::memcpy(names, filtered.c_str(), needed);
|
||||
*size = needed;
|
||||
return Success;
|
||||
}
|
||||
VRAPI ovrResult vrapi_CreateSystemVulkan(ovrSystemCreateInfoVulkan* info) {
|
||||
LOCK_STATE;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// xr_sim_capture: save what Monado's compositor window shows (the simulated HMD's display = both eyes as
|
||||
// the headset would get them) to a BMP, so a headset-free test can see what a game draws.
|
||||
//
|
||||
// xr_sim_capture.exe <out.bmp> [window title, default "Monado"]
|
||||
//
|
||||
// Finds the first top-level window whose title starts with the given text (Monado's own window is
|
||||
// "Monado (Windowed)", its peek window "Monado peek") and copies it with PrintWindow(PW_RENDERFULLCONTENT),
|
||||
// which also works for a window behind others. Prints "capture: <w>x<h> <file>" or "capture: fail ...".
|
||||
//
|
||||
// Build: cl /nologo /O2 xr_sim_capture.cpp /link user32.lib gdi32.lib
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cwchar>
|
||||
#include <vector>
|
||||
|
||||
#ifndef PW_RENDERFULLCONTENT
|
||||
#define PW_RENDERFULLCONTENT 0x00000002
|
||||
#endif
|
||||
|
||||
struct Find {
|
||||
const wchar_t *prefix;
|
||||
HWND hwnd;
|
||||
};
|
||||
|
||||
static BOOL CALLBACK enum_cb(HWND h, LPARAM lp)
|
||||
{
|
||||
Find *f = (Find *)lp;
|
||||
wchar_t title[256];
|
||||
if (!IsWindowVisible(h) || GetWindowTextW(h, title, 256) <= 0)
|
||||
return TRUE;
|
||||
if (std::wcsncmp(title, f->prefix, std::wcslen(f->prefix)) == 0) {
|
||||
f->hwnd = h;
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
int wmain(int argc, wchar_t **argv)
|
||||
{
|
||||
if (argc < 2) {
|
||||
std::printf("usage: xr_sim_capture <out.bmp> [window title prefix]\n");
|
||||
return 1;
|
||||
}
|
||||
// Not DPI aware on purpose: Monado's window isn't either, so its client size in this process's
|
||||
// (scaled) coordinates is the size of its swapchain, which PrintWindow copies 1:1.
|
||||
Find f{argc > 2 ? argv[2] : L"Monado", nullptr};
|
||||
EnumWindows(enum_cb, (LPARAM)&f);
|
||||
if (!f.hwnd) {
|
||||
std::printf("capture: fail no window\n");
|
||||
return 2;
|
||||
}
|
||||
RECT r;
|
||||
GetClientRect(f.hwnd, &r);
|
||||
int w = r.right - r.left, h = r.bottom - r.top;
|
||||
if (w <= 0 || h <= 0) {
|
||||
std::printf("capture: fail empty window (minimised?)\n");
|
||||
return 2;
|
||||
}
|
||||
HDC wdc = GetDC(f.hwnd);
|
||||
HDC mdc = CreateCompatibleDC(wdc);
|
||||
HBITMAP bmp = CreateCompatibleBitmap(wdc, w, h);
|
||||
HGDIOBJ old = SelectObject(mdc, bmp);
|
||||
if (!PrintWindow(f.hwnd, mdc, PW_CLIENTONLY | PW_RENDERFULLCONTENT))
|
||||
BitBlt(mdc, 0, 0, w, h, wdc, 0, 0, SRCCOPY);
|
||||
SelectObject(mdc, old);
|
||||
|
||||
BITMAPINFOHEADER bi{sizeof(bi)};
|
||||
bi.biWidth = w;
|
||||
bi.biHeight = h; // bottom-up, as BMP files store it
|
||||
bi.biPlanes = 1;
|
||||
bi.biBitCount = 24;
|
||||
bi.biCompression = BI_RGB;
|
||||
int row = (w * 3 + 3) & ~3;
|
||||
std::vector<unsigned char> px((size_t)row * h);
|
||||
GetDIBits(mdc, bmp, 0, h, px.data(), (BITMAPINFO *)&bi, DIB_RGB_COLORS);
|
||||
DeleteObject(bmp);
|
||||
DeleteDC(mdc);
|
||||
ReleaseDC(f.hwnd, wdc);
|
||||
|
||||
FILE *out = _wfopen(argv[1], L"wb");
|
||||
if (!out) {
|
||||
std::printf("capture: fail cannot write\n");
|
||||
return 2;
|
||||
}
|
||||
BITMAPFILEHEADER fh{};
|
||||
fh.bfType = 0x4D42;
|
||||
fh.bfOffBits = sizeof(fh) + sizeof(bi);
|
||||
fh.bfSize = fh.bfOffBits + (DWORD)px.size();
|
||||
std::fwrite(&fh, sizeof(fh), 1, out);
|
||||
std::fwrite(&bi, sizeof(bi), 1, out);
|
||||
std::fwrite(px.data(), 1, px.size(), out);
|
||||
std::fclose(out);
|
||||
std::printf("capture: %dx%d %ls\n", w, h, argv[1]);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
// xr_sim_probe: the smallest D3D11 OpenXR client FramePort uses to check a headset-free OpenXR runtime
|
||||
// (Monado's simulated HMD, docs/PC_ANDROID.md "Testing without a headset") on Windows.
|
||||
//
|
||||
// Creates an instance with XR_KHR_D3D11_enable, picks the D3D11 adapter by the LUID the runtime asks for,
|
||||
// runs a session (begins it on READY), submits a projection layer every frame (each eye cleared to a
|
||||
// colour that cycles), and after <seconds> asks for the exit. Every step prints one "probe: ..." line;
|
||||
// the last line is "probe: result ok frames=N focused=0|1" or "probe: result fail ...".
|
||||
// Optional: --dump <file.bmp> saves the left eye image of frame 60 (what the client drew).
|
||||
//
|
||||
// Build (VS Build Tools x64 prompt), with the official OpenXR loader release (openxr_loader_windows):
|
||||
// cl /nologo /EHsc /O2 /I<loader>\include xr_sim_probe.cpp /link <loader>\x64\lib\openxr_loader.lib d3d11.lib dxgi.lib
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
#include <d3d11.h>
|
||||
#include <dxgi.h>
|
||||
#define XR_USE_PLATFORM_WIN32
|
||||
#define XR_USE_GRAPHICS_API_D3D11
|
||||
#include <openxr/openxr.h>
|
||||
#include <openxr/openxr_platform.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#define CHECK(call) \
|
||||
do { \
|
||||
XrResult r_ = (call); \
|
||||
if (XR_FAILED(r_)) { \
|
||||
std::printf("probe: result fail %s = %d\n", #call, (int)r_); \
|
||||
std::fflush(stdout); \
|
||||
std::exit(2); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static const char *state_name(XrSessionState s)
|
||||
{
|
||||
switch (s) {
|
||||
case XR_SESSION_STATE_IDLE: return "IDLE";
|
||||
case XR_SESSION_STATE_READY: return "READY";
|
||||
case XR_SESSION_STATE_SYNCHRONIZED: return "SYNCHRONIZED";
|
||||
case XR_SESSION_STATE_VISIBLE: return "VISIBLE";
|
||||
case XR_SESSION_STATE_FOCUSED: return "FOCUSED";
|
||||
case XR_SESSION_STATE_STOPPING: return "STOPPING";
|
||||
case XR_SESSION_STATE_LOSS_PENDING: return "LOSS_PENDING";
|
||||
case XR_SESSION_STATE_EXITING: return "EXITING";
|
||||
default: return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
static void save_bmp(ID3D11Device *dev, ID3D11DeviceContext *ctx, ID3D11Texture2D *tex, const char *path)
|
||||
{
|
||||
D3D11_TEXTURE2D_DESC d;
|
||||
tex->GetDesc(&d);
|
||||
d.Usage = D3D11_USAGE_STAGING;
|
||||
d.BindFlags = 0;
|
||||
d.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
|
||||
d.MiscFlags = 0;
|
||||
d.ArraySize = 1;
|
||||
d.MipLevels = 1;
|
||||
d.SampleDesc.Count = 1;
|
||||
ID3D11Texture2D *staging = nullptr;
|
||||
if (FAILED(dev->CreateTexture2D(&d, nullptr, &staging))) {
|
||||
std::printf("probe: dump failed (staging)\n");
|
||||
return;
|
||||
}
|
||||
ctx->CopySubresourceRegion(staging, 0, 0, 0, 0, tex, 0, nullptr);
|
||||
D3D11_MAPPED_SUBRESOURCE m;
|
||||
if (FAILED(ctx->Map(staging, 0, D3D11_MAP_READ, 0, &m))) {
|
||||
staging->Release();
|
||||
return;
|
||||
}
|
||||
FILE *f = std::fopen(path, "wb");
|
||||
if (f) {
|
||||
uint32_t w = d.Width, h = d.Height, row = w * 3, pad = (4 - row % 4) % 4;
|
||||
uint32_t size = 54 + (row + pad) * h;
|
||||
uint8_t hdr[54] = {'B', 'M'};
|
||||
std::memcpy(hdr + 2, &size, 4);
|
||||
hdr[10] = 54;
|
||||
hdr[14] = 40;
|
||||
std::memcpy(hdr + 18, &w, 4);
|
||||
std::memcpy(hdr + 22, &h, 4);
|
||||
hdr[26] = 1;
|
||||
hdr[28] = 24;
|
||||
std::fwrite(hdr, 1, 54, f);
|
||||
std::vector<uint8_t> line(row + pad, 0);
|
||||
for (int y = (int)h - 1; y >= 0; y--) {
|
||||
const uint8_t *src = (const uint8_t *)m.pData + (size_t)y * m.RowPitch;
|
||||
for (uint32_t x = 0; x < w; x++) { // RGBA/BGRA 8-bit formats only: good enough for a probe
|
||||
bool bgra = d.Format == DXGI_FORMAT_B8G8R8A8_UNORM || d.Format == DXGI_FORMAT_B8G8R8A8_UNORM_SRGB;
|
||||
line[x * 3 + 0] = src[x * 4 + (bgra ? 0 : 2)];
|
||||
line[x * 3 + 1] = src[x * 4 + 1];
|
||||
line[x * 3 + 2] = src[x * 4 + (bgra ? 2 : 0)];
|
||||
}
|
||||
std::fwrite(line.data(), 1, line.size(), f);
|
||||
}
|
||||
std::fclose(f);
|
||||
std::printf("probe: dumped left eye %ux%u to %s\n", w, h, path);
|
||||
}
|
||||
ctx->Unmap(staging, 0);
|
||||
staging->Release();
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
double seconds = 10.0;
|
||||
const char *dump = nullptr;
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (!std::strcmp(argv[i], "--dump") && i + 1 < argc)
|
||||
dump = argv[++i];
|
||||
else
|
||||
seconds = std::atof(argv[i]);
|
||||
}
|
||||
setvbuf(stdout, nullptr, _IONBF, 0);
|
||||
|
||||
const char *exts[] = {XR_KHR_D3D11_ENABLE_EXTENSION_NAME};
|
||||
XrInstanceCreateInfo ici{XR_TYPE_INSTANCE_CREATE_INFO};
|
||||
std::strcpy(ici.applicationInfo.applicationName, "xr_sim_probe");
|
||||
ici.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0);
|
||||
ici.enabledExtensionCount = 1;
|
||||
ici.enabledExtensionNames = exts;
|
||||
XrInstance inst = XR_NULL_HANDLE;
|
||||
CHECK(xrCreateInstance(&ici, &inst));
|
||||
XrInstanceProperties ip{XR_TYPE_INSTANCE_PROPERTIES};
|
||||
CHECK(xrGetInstanceProperties(inst, &ip));
|
||||
std::printf("probe: runtime %s %u.%u.%u\n", ip.runtimeName, XR_VERSION_MAJOR(ip.runtimeVersion),
|
||||
XR_VERSION_MINOR(ip.runtimeVersion), XR_VERSION_PATCH(ip.runtimeVersion));
|
||||
|
||||
XrSystemGetInfo sgi{XR_TYPE_SYSTEM_GET_INFO};
|
||||
sgi.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY;
|
||||
XrSystemId sys;
|
||||
CHECK(xrGetSystem(inst, &sgi, &sys));
|
||||
XrSystemProperties sp{XR_TYPE_SYSTEM_PROPERTIES};
|
||||
CHECK(xrGetSystemProperties(inst, sys, &sp));
|
||||
std::printf("probe: system %s\n", sp.systemName);
|
||||
|
||||
PFN_xrGetD3D11GraphicsRequirementsKHR getReq = nullptr;
|
||||
CHECK(xrGetInstanceProcAddr(inst, "xrGetD3D11GraphicsRequirementsKHR", (PFN_xrVoidFunction *)&getReq));
|
||||
XrGraphicsRequirementsD3D11KHR req{XR_TYPE_GRAPHICS_REQUIREMENTS_D3D11_KHR};
|
||||
CHECK(getReq(inst, sys, &req));
|
||||
|
||||
IDXGIFactory1 *factory = nullptr;
|
||||
CreateDXGIFactory1(__uuidof(IDXGIFactory1), (void **)&factory);
|
||||
IDXGIAdapter1 *adapter = nullptr;
|
||||
for (UINT i = 0; factory->EnumAdapters1(i, &adapter) != DXGI_ERROR_NOT_FOUND; i++) {
|
||||
DXGI_ADAPTER_DESC1 ad;
|
||||
adapter->GetDesc1(&ad);
|
||||
if (std::memcmp(&ad.AdapterLuid, &req.adapterLuid, sizeof(LUID)) == 0) {
|
||||
std::printf("probe: adapter %ls (luid %08lx:%08lx)\n", ad.Description, ad.AdapterLuid.HighPart,
|
||||
ad.AdapterLuid.LowPart);
|
||||
break;
|
||||
}
|
||||
adapter->Release();
|
||||
adapter = nullptr;
|
||||
}
|
||||
if (!adapter) {
|
||||
std::printf("probe: result fail no DXGI adapter with the runtime's LUID\n");
|
||||
return 2;
|
||||
}
|
||||
ID3D11Device *dev = nullptr;
|
||||
ID3D11DeviceContext *ctx = nullptr;
|
||||
D3D_FEATURE_LEVEL fl = D3D_FEATURE_LEVEL_11_0;
|
||||
if (FAILED(D3D11CreateDevice(adapter, D3D_DRIVER_TYPE_UNKNOWN, nullptr, 0, &fl, 1, D3D11_SDK_VERSION, &dev,
|
||||
nullptr, &ctx))) {
|
||||
std::printf("probe: result fail D3D11CreateDevice\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
XrGraphicsBindingD3D11KHR gb{XR_TYPE_GRAPHICS_BINDING_D3D11_KHR};
|
||||
gb.device = dev;
|
||||
XrSessionCreateInfo sci{XR_TYPE_SESSION_CREATE_INFO};
|
||||
sci.next = &gb;
|
||||
sci.systemId = sys;
|
||||
XrSession session;
|
||||
CHECK(xrCreateSession(inst, &sci, &session));
|
||||
std::printf("probe: session created\n");
|
||||
|
||||
XrReferenceSpaceCreateInfo rsci{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
|
||||
rsci.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
|
||||
rsci.poseInReferenceSpace.orientation.w = 1;
|
||||
XrSpace space;
|
||||
CHECK(xrCreateReferenceSpace(session, &rsci, &space));
|
||||
|
||||
uint32_t nviews = 0;
|
||||
CHECK(xrEnumerateViewConfigurationViews(inst, sys, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, 0, &nviews, nullptr));
|
||||
std::vector<XrViewConfigurationView> vcv(nviews, {XR_TYPE_VIEW_CONFIGURATION_VIEW});
|
||||
CHECK(xrEnumerateViewConfigurationViews(inst, sys, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, nviews, &nviews,
|
||||
vcv.data()));
|
||||
|
||||
uint32_t nfmt = 0;
|
||||
CHECK(xrEnumerateSwapchainFormats(session, 0, &nfmt, nullptr));
|
||||
std::vector<int64_t> fmts(nfmt);
|
||||
CHECK(xrEnumerateSwapchainFormats(session, nfmt, &nfmt, fmts.data()));
|
||||
int64_t fmt = fmts[0];
|
||||
for (int64_t f : fmts)
|
||||
if (f == DXGI_FORMAT_R8G8B8A8_UNORM_SRGB || f == DXGI_FORMAT_R8G8B8A8_UNORM) {
|
||||
fmt = f;
|
||||
break;
|
||||
}
|
||||
|
||||
struct Eye {
|
||||
XrSwapchain sc;
|
||||
std::vector<XrSwapchainImageD3D11KHR> imgs;
|
||||
std::vector<ID3D11RenderTargetView *> rtv;
|
||||
uint32_t w, h;
|
||||
};
|
||||
std::vector<Eye> eyes(nviews);
|
||||
for (uint32_t i = 0; i < nviews; i++) {
|
||||
Eye &e = eyes[i];
|
||||
e.w = vcv[i].recommendedImageRectWidth;
|
||||
e.h = vcv[i].recommendedImageRectHeight;
|
||||
XrSwapchainCreateInfo sc{XR_TYPE_SWAPCHAIN_CREATE_INFO};
|
||||
sc.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_TRANSFER_SRC_BIT;
|
||||
sc.format = fmt;
|
||||
sc.sampleCount = 1;
|
||||
sc.width = e.w;
|
||||
sc.height = e.h;
|
||||
sc.faceCount = 1;
|
||||
sc.arraySize = 1;
|
||||
sc.mipCount = 1;
|
||||
CHECK(xrCreateSwapchain(session, &sc, &e.sc));
|
||||
uint32_t n = 0;
|
||||
CHECK(xrEnumerateSwapchainImages(e.sc, 0, &n, nullptr));
|
||||
e.imgs.assign(n, {XR_TYPE_SWAPCHAIN_IMAGE_D3D11_KHR});
|
||||
CHECK(xrEnumerateSwapchainImages(e.sc, n, &n, (XrSwapchainImageBaseHeader *)e.imgs.data()));
|
||||
for (auto &img : e.imgs) {
|
||||
D3D11_RENDER_TARGET_VIEW_DESC rd{};
|
||||
rd.Format = (DXGI_FORMAT)fmt;
|
||||
rd.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D;
|
||||
ID3D11RenderTargetView *v = nullptr;
|
||||
dev->CreateRenderTargetView(img.texture, &rd, &v);
|
||||
e.rtv.push_back(v);
|
||||
}
|
||||
}
|
||||
std::printf("probe: %u views %ux%u format %lld\n", nviews, eyes[0].w, eyes[0].h, (long long)fmt);
|
||||
|
||||
bool running = false, quit = false, focused = false, exit_requested = false;
|
||||
uint64_t frames = 0, rendered = 0;
|
||||
auto t0 = std::chrono::steady_clock::now();
|
||||
while (!quit) {
|
||||
XrEventDataBuffer ev{XR_TYPE_EVENT_DATA_BUFFER};
|
||||
while (xrPollEvent(inst, &ev) == XR_SUCCESS) {
|
||||
if (ev.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
|
||||
auto *s = (XrEventDataSessionStateChanged *)&ev;
|
||||
double t = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
|
||||
std::printf("probe: state %s at %.2f s (frames %llu)\n", state_name(s->state), t,
|
||||
(unsigned long long)frames);
|
||||
if (s->state == XR_SESSION_STATE_FOCUSED)
|
||||
focused = true;
|
||||
if (s->state == XR_SESSION_STATE_READY) {
|
||||
XrSessionBeginInfo bi{XR_TYPE_SESSION_BEGIN_INFO};
|
||||
bi.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
|
||||
CHECK(xrBeginSession(session, &bi));
|
||||
running = true;
|
||||
} else if (s->state == XR_SESSION_STATE_STOPPING) {
|
||||
CHECK(xrEndSession(session));
|
||||
running = false;
|
||||
} else if (s->state == XR_SESSION_STATE_EXITING || s->state == XR_SESSION_STATE_LOSS_PENDING) {
|
||||
quit = true;
|
||||
}
|
||||
} else if (ev.type == XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING) {
|
||||
quit = true;
|
||||
}
|
||||
ev = {XR_TYPE_EVENT_DATA_BUFFER};
|
||||
}
|
||||
double elapsed = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
|
||||
if (!running) {
|
||||
if (elapsed > seconds + 20) { // never got READY, or never left
|
||||
std::printf("probe: result fail timeout (frames %llu)\n", (unsigned long long)frames);
|
||||
return 3;
|
||||
}
|
||||
Sleep(10);
|
||||
continue;
|
||||
}
|
||||
if (elapsed > seconds && !exit_requested) {
|
||||
std::printf("probe: requesting exit after %.2f s, frames %llu\n", elapsed, (unsigned long long)frames);
|
||||
xrRequestExitSession(session);
|
||||
exit_requested = true;
|
||||
}
|
||||
|
||||
XrFrameState fs{XR_TYPE_FRAME_STATE};
|
||||
CHECK(xrWaitFrame(session, nullptr, &fs));
|
||||
CHECK(xrBeginFrame(session, nullptr));
|
||||
std::vector<XrCompositionLayerProjectionView> pv(nviews, {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW});
|
||||
XrCompositionLayerProjection layer{XR_TYPE_COMPOSITION_LAYER_PROJECTION};
|
||||
std::vector<XrCompositionLayerBaseHeader *> layers;
|
||||
if (fs.shouldRender) {
|
||||
std::vector<XrView> views(nviews, {XR_TYPE_VIEW});
|
||||
XrViewLocateInfo li{XR_TYPE_VIEW_LOCATE_INFO};
|
||||
li.viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
|
||||
li.displayTime = fs.predictedDisplayTime;
|
||||
li.space = space;
|
||||
XrViewState vs{XR_TYPE_VIEW_STATE};
|
||||
uint32_t got = 0;
|
||||
CHECK(xrLocateViews(session, &li, &vs, nviews, &got, views.data()));
|
||||
for (uint32_t i = 0; i < nviews; i++) {
|
||||
Eye &e = eyes[i];
|
||||
uint32_t idx;
|
||||
CHECK(xrAcquireSwapchainImage(e.sc, nullptr, &idx));
|
||||
XrSwapchainImageWaitInfo wi{XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};
|
||||
wi.timeout = XR_INFINITE_DURATION;
|
||||
CHECK(xrWaitSwapchainImage(e.sc, &wi));
|
||||
float phase = (float)((frames % 120) / 120.0);
|
||||
float col[4] = {i == 0 ? phase : 0.1f, 0.2f, i == 1 ? phase : 0.1f, 1.0f};
|
||||
ctx->ClearRenderTargetView(e.rtv[idx], col);
|
||||
if (dump && i == 0 && rendered == 60)
|
||||
save_bmp(dev, ctx, e.imgs[idx].texture, dump);
|
||||
CHECK(xrReleaseSwapchainImage(e.sc, nullptr));
|
||||
pv[i].pose = views[i].pose;
|
||||
pv[i].fov = views[i].fov;
|
||||
pv[i].subImage.swapchain = e.sc;
|
||||
pv[i].subImage.imageRect = {{0, 0}, {(int32_t)e.w, (int32_t)e.h}};
|
||||
}
|
||||
layer.space = space;
|
||||
layer.viewCount = nviews;
|
||||
layer.views = pv.data();
|
||||
layers.push_back((XrCompositionLayerBaseHeader *)&layer);
|
||||
rendered++;
|
||||
}
|
||||
XrFrameEndInfo fe{XR_TYPE_FRAME_END_INFO};
|
||||
fe.displayTime = fs.predictedDisplayTime;
|
||||
fe.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE;
|
||||
fe.layerCount = (uint32_t)layers.size();
|
||||
fe.layers = layers.data();
|
||||
CHECK(xrEndFrame(session, &fe));
|
||||
frames++;
|
||||
if (frames % 120 == 0)
|
||||
std::printf("probe: frames %llu rendered %llu at %.2f s\n", (unsigned long long)frames,
|
||||
(unsigned long long)rendered, elapsed);
|
||||
}
|
||||
for (auto &e : eyes)
|
||||
xrDestroySwapchain(e.sc);
|
||||
xrDestroySpace(space);
|
||||
xrDestroySession(session);
|
||||
xrDestroyInstance(inst);
|
||||
std::printf("probe: result ok frames=%llu rendered=%llu focused=%d\n", (unsigned long long)frames,
|
||||
(unsigned long long)rendered, focused ? 1 : 0);
|
||||
return 0;
|
||||
}
|
||||
+38
-477
@@ -1,508 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Evaluate static patch suggestions against catalog-only ground truth (no catalog-assisted suggestions).
|
||||
"""Score the patch heuristics against the verified catalog: for every game dump that has a catalog recipe, suggest a
|
||||
recipe with the catalog switched off and compare the recipe-relevant choices.
|
||||
|
||||
eval_heuristics.py <dumps folder> [--verbose]
|
||||
eval_heuristics.py --snapshot analyses.json --split all --json baseline.json
|
||||
eval_heuristics.py --snapshot analyses.json --fit-report --out fit-report.md
|
||||
|
||||
Snapshots can be in progress: each read copies to a temporary file and retries incomplete JSON. The split is
|
||||
stratified by analysis engine and stable for a fixed set of packages. Converted inputs are included and labelled.
|
||||
Patch scores are micro-averaged across games, excluding enabled automatic defaults. Expected defaults are detected
|
||||
per analysis before applying explicit catalog removals; inapplicable defaults create no removal decisions. A wrong
|
||||
adapter value is both an FP and an FN. Removal decisions and alternate-build patches have their own identities
|
||||
("remove:<id>", "alt:<id>").
|
||||
Unsupported catalog entries and runtime-only choices are reported separately from the headline.
|
||||
python scripts/eval_heuristics.py "<folder with game dumps>" [--verbose]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from frameport.analysis.detect import analyze # noqa: E402
|
||||
from frameport.core.library import analysis_from_dict # noqa: E402
|
||||
from frameport.core.models import Recipe # noqa: E402
|
||||
from frameport.patches import base # noqa: E402
|
||||
from frameport.recommend import catalog, engine # noqa: E402
|
||||
from frameport.sources import quest_dump # noqa: E402
|
||||
|
||||
RUNTIME_ONLY = [
|
||||
"use_alt", "adapter.vk_shader_fix", "adapter.focus_hold", "adapter.focus_hold_ms", "adapter.sync_guard",
|
||||
"adapter.scale", "adapter.layer_debug", "adapter.refresh_rate", "adapter.pose_consistency",
|
||||
"lepton_env", "config_sync",
|
||||
]
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
DEVICE_FIELDS = {"device.files", "device.lepton_env", "device.config_sync", "device.foveation"}
|
||||
|
||||
|
||||
def expected_defaults(analysis) -> set[str]:
|
||||
"""Defaults recommended by their own detection, before recipe normalization/removals.
|
||||
|
||||
As in suggest's initial pass, default_on patches bypass the opt-in applies gate; their own detect() decides.
|
||||
This masks e.g. LTW defaults in games without LTW, without importing any heuristic opt-in/adapter choices.
|
||||
"""
|
||||
return {p.id for p in base.all_patches() if p.default_on and base.for_game(p, analysis)
|
||||
and (s := p.detect(analysis)) and s.recommended}
|
||||
|
||||
|
||||
def signature(recipe: Recipe, defaults: set[str] | None = None) -> dict:
|
||||
"""Recipe decisions, excluding enabled defaults but retaining explicit removal of defaults."""
|
||||
def signature(recipe) -> dict:
|
||||
"""The parts of a recipe that differ between games (defaults/automatic fixes are excluded)."""
|
||||
pats = recipe.patches
|
||||
rift = recipe.package.startswith("rift.")
|
||||
patches = [p for p in base.all_patches() if (p.category == "pcvr") == rift]
|
||||
out = {}
|
||||
for category in ("overport", "frame", "pcvr"):
|
||||
out[category] = sorted(p.id for p in patches if p.category == category and not p.default_on and p.id in pats)
|
||||
out[category + "_removed"] = sorted(p.id for p in patches
|
||||
if p.category == category and p.default_on and p.id not in pats
|
||||
and (defaults is None or p.id in defaults))
|
||||
out.update({
|
||||
return {
|
||||
"overport": sorted(p for p in pats if base.get(p).category == "overport" and not base.get(p).default_on),
|
||||
"overport_removed": sorted(p for p in (x.id for x in base.all_patches()
|
||||
if x.category == "overport" and x.default_on)
|
||||
if p not in pats),
|
||||
"frame": sorted(p for p in pats if base.get(p).category == "frame" and not base.get(p).default_on),
|
||||
"adapter": {p.split(".", 1)[1]: v.get("value") for p, v in sorted(pats.items()) if p.startswith("adapter.")},
|
||||
"device": sorted(p for p in pats if p.startswith("device.") and p not in DEVICE_FIELDS
|
||||
and not base.get(p).default_on),
|
||||
"device_files": sorted((pats.get("device.files") or {}).get("files", {})),
|
||||
"foveation": dict(pats.get("device.foveation") or {}),
|
||||
"lepton_env": dict((pats.get("device.lepton_env") or {}).get("env", {})),
|
||||
"config_sync": dict((pats.get("device.config_sync") or {}).get("files", {})),
|
||||
"alt": sorted(recipe.alt_patches),
|
||||
"use_alt": recipe.use_alt,
|
||||
"status_unsupported": recipe.status == "unsupported",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def catalog_recipe(entry: catalog.CatalogEntry, defaults: set[str] | None = None, analysis=None) -> Recipe:
|
||||
"""Materialize catalog choices plus expected defaults; never import heuristic opt-in choices. With the game's
|
||||
analysis, the engine's structural transforms apply as in suggest(): a 32-bit game goes through the bridge (its
|
||||
entry relies on that instead of listing what the bridge replaces), a 2D app keeps only its non-VR patches."""
|
||||
r = Recipe(entry.package, status=entry.status, as_is=entry.as_is,
|
||||
alt_patches=list(entry.alt_overport), use_alt=entry.use_alt)
|
||||
rift = entry.kind == "rift" or entry.package.startswith("rift.")
|
||||
r.patches = {p.id: {} for p in base.all_patches() if p.default_on and (p.category == "pcvr") == rift
|
||||
and (defaults is None or p.id in defaults)}
|
||||
for pid in (*entry.overport_extra, *entry.frame, *entry.device, *entry.pcvr):
|
||||
r.patches[pid] = {}
|
||||
for pid in (*entry.overport_remove, *entry.frame_remove, *entry.pcvr_remove):
|
||||
r.patches.pop(pid, None)
|
||||
for key, value in entry.adapter.items():
|
||||
r.patches[f"adapter.{key}"] = {"value": value}
|
||||
for pid, param, values in (("device.files", "files", entry.device_files),
|
||||
("device.lepton_env", "env", entry.lepton_env),
|
||||
("device.config_sync", "files", entry.config_sync)):
|
||||
if values:
|
||||
r.patches[pid] = {param: copy.deepcopy(values)}
|
||||
if entry.foveation:
|
||||
r.patches["device.foveation"] = {"mode": entry.foveation}
|
||||
if entry.foveation_radius:
|
||||
r.patches["device.foveation"]["radius"] = entry.foveation_radius
|
||||
if entry.foveation_test:
|
||||
r.patches["device.foveation"]["test"] = True
|
||||
if analysis is not None:
|
||||
if analysis.only_32bit and "frame.a32_bridge" not in entry.frame_remove:
|
||||
r.patches.setdefault("frame.a32_bridge", {})
|
||||
engine.a32_recipe(analysis, r, newly=False)
|
||||
elif analysis.vr_kind == "none":
|
||||
needed = {pid for pid in r.patches if not base.get(pid).needs_vr and pid != "frame.launcher"}
|
||||
needed |= {"frame.launcher"} if analysis.has_info_category else set()
|
||||
r.patches = {pid: v for pid, v in r.patches.items() if pid in needed}
|
||||
r.alt_patches = []
|
||||
return r
|
||||
|
||||
|
||||
def catalog_signature(entry: catalog.CatalogEntry, defaults: set[str] | None = None, analysis=None) -> dict:
|
||||
return signature(catalog_recipe(entry, defaults, analysis), defaults)
|
||||
|
||||
|
||||
def engine_group(name: str) -> str:
|
||||
return name if name in ("Unity", "Unreal") else "Other"
|
||||
|
||||
|
||||
def split_packages(engines: dict[str, str]) -> dict[str, str]:
|
||||
"""package -> dev/holdout; nearest 20% per engine (half rounded up), lowest sha256 first.
|
||||
|
||||
Only pass the intersection of catalog and analyses. This is stable under input reordering; adding packages can
|
||||
change membership at a stratum's cutoff. Tiny strata may have no holdout. Unsupported entries participate too.
|
||||
"""
|
||||
groups = defaultdict(list)
|
||||
for pkg, name in engines.items():
|
||||
groups[engine_group(name)].append(pkg)
|
||||
result = {}
|
||||
for packages in groups.values():
|
||||
ordered = sorted(packages, key=lambda p: (hashlib.sha256(p.encode()).digest(), p))
|
||||
n = (len(ordered) + 2) // 5
|
||||
result.update((p, "holdout" if i < n else "dev") for i, p in enumerate(ordered))
|
||||
return dict(sorted(result.items()))
|
||||
|
||||
|
||||
def _parts(sig: dict) -> tuple[dict, dict]:
|
||||
headline, runtime = {}, {}
|
||||
for key, value in sig.items():
|
||||
if key == "adapter":
|
||||
headline[key] = {k: v for k, v in value.items() if f"adapter.{k}" not in RUNTIME_ONLY}
|
||||
runtime[key] = {k: v for k, v in value.items() if f"adapter.{k}" in RUNTIME_ONLY}
|
||||
else:
|
||||
(runtime if key in RUNTIME_ONLY else headline)[key] = value
|
||||
return headline, runtime
|
||||
|
||||
|
||||
def _decisions(sig: dict) -> dict:
|
||||
"""Patch/action identities -> values. Status is a field metric, not a patch."""
|
||||
out = {}
|
||||
for field, value in sig.items():
|
||||
if field == "status_unsupported":
|
||||
continue
|
||||
if field in ("adapter", "lepton_env", "config_sync"):
|
||||
prefix = "adapter" if field == "adapter" else f"device.{field}"
|
||||
out.update((f"{prefix}.{k}", v) for k, v in value.items())
|
||||
elif field == "foveation":
|
||||
if value:
|
||||
out["device.foveation"] = value
|
||||
elif field == "use_alt":
|
||||
if value:
|
||||
out["use_alt"] = value
|
||||
else:
|
||||
prefix = ("remove:" if field.endswith("_removed") else "alt:" if field == "alt"
|
||||
else "device.files:" if field == "device_files" else "")
|
||||
out.update((prefix + p, True) for p in value)
|
||||
return out
|
||||
|
||||
|
||||
def _rates(tp: int, fp: int, fn: int) -> dict:
|
||||
# No positive decisions yields 0, rather than inflating scores for empty recipes/strata.
|
||||
return {"tp": tp, "fp": fp, "fn": fn, "precision": tp / (tp + fp) if tp + fp else 0.0,
|
||||
"recall": tp / (tp + fn) if tp + fn else 0.0,
|
||||
"f1": 2 * tp / (2 * tp + fp + fn) if 2 * tp + fp + fn else 0.0}
|
||||
|
||||
|
||||
def score_signatures(want: dict, got: dict, gaps: frozenset | set = frozenset()) -> dict:
|
||||
"""Compare one pair of signatures; value mismatches count as both missing and extra. `gaps` = decisions the
|
||||
catalog entry couldn't have made (the patch didn't exist when it was verified): left out of both sides."""
|
||||
result = {}
|
||||
for scope, w, g in zip(("headline", "runtime"), _parts(want), _parts(got), strict=True):
|
||||
wanted, actual = _decisions(w), _decisions(g)
|
||||
gap = {k: actual.pop(k) for k in list(actual) if k in gaps and k not in wanted}
|
||||
missing = {k: v for k, v in wanted.items() if k not in actual or actual[k] != v}
|
||||
extra = {k: v for k, v in actual.items() if k not in wanted or wanted[k] != v}
|
||||
per_patch = {k: _rates(int(k in wanted and k in actual and wanted[k] == actual[k]),
|
||||
int(k in extra), int(k in missing)) for k in sorted(wanted.keys() | actual.keys())}
|
||||
fields = {k: w.get(k) == g.get(k) for k in sorted(w.keys() | g.keys())}
|
||||
exact = all(fields.values()) if not gap else not missing and not extra
|
||||
result[scope] = {"exact": exact, "fields": fields, "missing": missing, "extra": extra,
|
||||
"per_patch": per_patch, "gap": gap}
|
||||
return result
|
||||
|
||||
|
||||
def _aggregate(rows: list[dict], scope: str, score_key: str = "score") -> dict:
|
||||
fields = defaultdict(lambda: {"matched": 0, "total": 0})
|
||||
counts = defaultdict(Counter)
|
||||
gaps = Counter()
|
||||
for row in rows:
|
||||
s = row[score_key][scope]
|
||||
gaps.update(s.get("gap") or {})
|
||||
for key, match in s["fields"].items():
|
||||
fields[key]["matched"] += int(match)
|
||||
fields[key]["total"] += 1
|
||||
for key, stats in s["per_patch"].items():
|
||||
counts[key].update({k: stats[k] for k in ("tp", "fp", "fn")})
|
||||
per_patch = {k: _rates(**v) for k, v in sorted(counts.items())}
|
||||
total = Counter()
|
||||
for stats in counts.values():
|
||||
total.update(stats)
|
||||
exact = sum(row[score_key][scope]["exact"] for row in rows)
|
||||
return {"games": len(rows), "exact": exact, "exact_ratio": exact / len(rows) if rows else 0.0,
|
||||
"fields": dict(sorted(fields.items())), "per_patch": per_patch, "gaps": dict(sorted(gaps.items())),
|
||||
"patches": _rates(*(total[k] for k in ("tp", "fp", "fn")))}
|
||||
|
||||
|
||||
def score_records(records: list[dict]) -> dict:
|
||||
"""Score {package, engine, status, converted, want, got, ...} records; no filesystem needed."""
|
||||
rows = [{**r, "score": score_signatures(r["want"], r["got"]),
|
||||
"score_dated": score_signatures(r["want"], r["got"], set(r.get("gaps") or ()))}
|
||||
for r in sorted(records, key=lambda r: r["package"])]
|
||||
|
||||
def metrics(group):
|
||||
supported = [r for r in group if r["status"] != "unsupported"]
|
||||
unsupported = [r for r in group if r["status"] == "unsupported"]
|
||||
return {"total_games": len(group), "converted_games": sum(bool(r.get("converted")) for r in group),
|
||||
"excluded_unsupported": len(unsupported),
|
||||
"headline": _aggregate(supported, "headline"), "runtime": _aggregate(supported, "runtime"),
|
||||
"headline_dated": _aggregate(supported, "headline", "score_dated"),
|
||||
"unsupported": {scope: _aggregate(unsupported, scope) for scope in ("headline", "runtime")}}
|
||||
|
||||
return {**metrics(rows), "by_engine": {name: metrics([r for r in rows if engine_group(r["engine"]) == name])
|
||||
for name in ("Unity", "Unreal", "Other")}, "games": rows}
|
||||
|
||||
|
||||
def read_snapshot(path: Path, attempts: int = 8) -> dict:
|
||||
"""Copy before parsing, even on retries; never write to the source or read APK/data paths in it."""
|
||||
with tempfile.TemporaryDirectory(prefix="frameport-eval-") as tmp:
|
||||
dest = Path(tmp) / "snapshot.json"
|
||||
for attempt in range(attempts):
|
||||
shutil.copyfile(path, dest)
|
||||
try:
|
||||
return json.loads(dest.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
if attempt == attempts - 1:
|
||||
raise
|
||||
time.sleep(0.25 * (attempt + 1))
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def read_dumps(path: Path) -> dict:
|
||||
records = {}
|
||||
for g in quest_dump.scan(path):
|
||||
a = analyze(g.apk, data_bytes=g.data_bytes())
|
||||
row = {"analysis": a.to_dict(), "converted": a.is_overport_output, "name": g.name}
|
||||
if a.package not in records or records[a.package]["converted"] and not row["converted"]:
|
||||
records[a.package] = row
|
||||
return records
|
||||
|
||||
|
||||
def gap_decisions(entry, intro_dates: dict | None) -> set[str]:
|
||||
"""Decision keys for patches introduced after the entry was verified: the catalog can't be wrong about them."""
|
||||
verified = str((entry.verified or {}).get("date") or "")
|
||||
if not intro_dates or not verified:
|
||||
return set()
|
||||
return {(f"alt:{pid}" if pid.startswith("patch_remove_unreal") else pid)
|
||||
for pid, date in intro_dates.items() if date and date > verified}
|
||||
|
||||
|
||||
def inherited_decisions() -> set[str]:
|
||||
"""Heuristic picks a catalog recipe keeps even when the entry doesn't name them (engine.suggest: adapter
|
||||
settings and device patches other than catalog.TOGGLED_DEVICE): the verified build had them, so an "extra" one
|
||||
isn't a detection error. A catalog value that differs still counts (the key is in both sides then)."""
|
||||
return {p.id for p in base.all_patches()
|
||||
if p.category == "adapter" or (p.category == "device" and p.id not in catalog.TOGGLED_DEVICE)}
|
||||
|
||||
|
||||
def evaluate(snapshot: dict, entries: dict, split: str = "dev", intro_dates: dict | None = None) -> dict:
|
||||
analyses = {pkg: analysis_from_dict(v["analysis"]) for pkg, v in snapshot.items() if pkg in entries}
|
||||
assignments = split_packages({pkg: a.engine for pkg, a in analyses.items()})
|
||||
records = []
|
||||
for pkg, a in sorted(analyses.items()):
|
||||
if split != "all" and assignments[pkg] != split:
|
||||
continue
|
||||
entry = entries[pkg]
|
||||
defaults = expected_defaults(a)
|
||||
records.append({"package": pkg, "title": entry.title, "engine": a.engine, "status": entry.status,
|
||||
"converted": bool(snapshot[pkg].get("converted", a.is_overport_output)),
|
||||
"analysis_version": snapshot[pkg].get("analysis_version"), "split": assignments[pkg],
|
||||
"want": catalog_signature(entry, defaults, a),
|
||||
"got": signature(engine.suggest(a, use_catalog=False), defaults),
|
||||
"gaps": sorted(gap_decisions(entry, intro_dates) | inherited_decisions())})
|
||||
return {"schema_version": 1, "split": split, "runtime_only": RUNTIME_ONLY, "snapshot_packages": len(snapshot),
|
||||
"default_policy": "per-analysis default detection, then explicit catalog removals",
|
||||
"catalog_packages": len(entries), "matched_packages": len(analyses),
|
||||
"split_assignments": assignments, **score_records(records)}
|
||||
|
||||
|
||||
def fit_candidates(analysis, entry: catalog.CatalogEntry) -> list[dict]:
|
||||
"""Use suggest's final heuristic pass, including platform filtering, non-VR and a32 normalization.
|
||||
|
||||
Requiring a recommended detect() as well excludes dependencies and synthetic recipe additions. No engine
|
||||
refactor is needed: suggest() remains untouched, and catalog data is used only for the candidate exclusion and
|
||||
the hypothetical warning recipe.
|
||||
"""
|
||||
suggested = engine.suggest(analysis, use_catalog=False)
|
||||
chosen = catalog_recipe(entry, expected_defaults(analysis), analysis)
|
||||
listed = set(chosen.patches) | set(entry.alt_overport)
|
||||
removed = set(entry.overport_remove) | set(entry.frame_remove) | set(entry.pcvr_remove)
|
||||
result = []
|
||||
for pid, params in sorted(suggested.patches.items()):
|
||||
p = base.get(pid)
|
||||
if (p.default_on or pid in listed or pid in removed or not base.for_game(p, analysis)
|
||||
or not p.applies(analysis)):
|
||||
continue
|
||||
s = p.detect(analysis)
|
||||
if not s or not s.recommended:
|
||||
continue
|
||||
trial = copy.deepcopy(chosen)
|
||||
trial.patches[pid] = dict(params)
|
||||
result.append({"patch": pid, "category": p.category, "experimental": p.experimental,
|
||||
"strict": bool(getattr(p, "strict", False)), "reason": s.reason,
|
||||
"warnings": engine.warnings(trial, analysis)})
|
||||
return result
|
||||
|
||||
|
||||
def patch_intro_dates(cache_path: Path) -> dict:
|
||||
"""One local git pickaxe per uncached patch (including missing-history results); never use the network."""
|
||||
try:
|
||||
cached = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
cached = {}
|
||||
changed = False
|
||||
for p in base.all_patches():
|
||||
if p.id in cached:
|
||||
continue
|
||||
needle = p.id.split(".", 1)[1] if p.category == "adapter" else p.id
|
||||
paths = ["src/frameport/patches/settings.py"] if p.category == "adapter" else ["src/frameport"]
|
||||
log = subprocess.run(["git", "log", f'-S"{needle}"', "--reverse", "--format=%ad", "--date=short", "--",
|
||||
*paths], cwd=REPO, text=True, capture_output=True, check=True)
|
||||
dates = log.stdout.splitlines()
|
||||
cached[p.id] = dates[0] if dates else None
|
||||
changed = True
|
||||
if changed:
|
||||
cache_path.write_text(json.dumps(cached, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return cached
|
||||
|
||||
|
||||
def entry_date(entry) -> str:
|
||||
"""When the recipe was verified; `updated` only when there is no verified date (it also moves for unrelated
|
||||
edits, e.g. a note, so it says nothing about which patches the verifier could pick)."""
|
||||
return str(entry.verified.get("date") or "") or str(entry.updated or "")
|
||||
|
||||
|
||||
def fit_report(snapshot: dict, entries: dict, intro_dates: dict) -> dict:
|
||||
games, metadata = [], []
|
||||
totals = Counter()
|
||||
for pkg, entry in sorted(entries.items()):
|
||||
common = {"package": pkg, "title": entry.title, "engine": entry.engine, "xr": entry.xr,
|
||||
"status": entry.status, "verified_date": str(entry.verified.get("date") or ""),
|
||||
"updated": str(entry.updated or ""), "entry_date": entry_date(entry)}
|
||||
if pkg not in snapshot:
|
||||
metadata.append(common)
|
||||
continue
|
||||
candidates = fit_candidates(analysis_from_dict(snapshot[pkg]["analysis"]), entry)
|
||||
for c in candidates:
|
||||
intro = intro_dates.get(c["patch"])
|
||||
c.update(intro_date=intro,
|
||||
newer_patch=bool(intro and common["entry_date"] and intro > common["entry_date"]))
|
||||
totals[c["patch"]] += 1
|
||||
games.append({**common, "converted": bool(snapshot[pkg].get("converted")), "candidates": candidates})
|
||||
return {"games": games, "metadata_only": metadata, "totals_by_patch": dict(sorted(totals.items())),
|
||||
"candidate_count": sum(totals.values()), "games_with_candidates": sum(bool(g["candidates"]) for g in games)}
|
||||
|
||||
|
||||
def _md(value) -> str:
|
||||
return str(value if value is not None else "unknown").replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def render_fit_report(report: dict) -> str:
|
||||
lines = ["# New patch fit", "", f"{len(report['games'])} catalog games with analyses; "
|
||||
f"{report['games_with_candidates']} with candidates; {report['candidate_count']} candidate additions. "
|
||||
f"{len(report['metadata_only'])} metadata-only entries.", "",
|
||||
"Candidates are opt-in recommended patches absent from the entry, after the heuristic engine's platform "
|
||||
"and a32 rules. A listed adapter key is excluded even if its value differs. Warnings are for the full "
|
||||
"catalog recipe plus that one patch; they can include pre-existing conflicts. Unknown history/date "
|
||||
"does not imply a newer patch. Default-on patches are not candidates. All splits and statuses included.",
|
||||
"", "## Totals by patch", "", "| Patch | Games |", "| --- | ---: |"]
|
||||
lines.extend(f"| {pid} | {n} |" for pid, n in
|
||||
sorted(report["totals_by_patch"].items(), key=lambda kv: (-kv[1], kv[0])))
|
||||
for game in report["games"]:
|
||||
lines += ["", f"## {_md(game['title'])} (`{game['package']}`)", "",
|
||||
f"{game['engine']} / {game['xr']}; status: {game['status']}; "
|
||||
f"entry date: {game['entry_date'] or 'unknown'}; converted: {game['converted']}.", ""]
|
||||
if not game["candidates"]:
|
||||
lines += ["No candidates."]
|
||||
continue
|
||||
lines += ["| Patch | Category | Experimental | Strict | Reason | Introduced | Newer patch | Warnings |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |"]
|
||||
for c in game["candidates"]:
|
||||
cells = [c["patch"], c["category"], c["experimental"], c["strict"], c["reason"], c["intro_date"],
|
||||
c["newer_patch"], "; ".join(c["warnings"]) or "none"]
|
||||
lines.append("| " + " | ".join(_md(v) for v in cells) + " |")
|
||||
lines += ["", "## Catalog entries without analyses (metadata only)", "",
|
||||
"| Package | Title | Engine | XR | Status | Verified | Updated | Entry date |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |"]
|
||||
for g in report["metadata_only"]:
|
||||
lines.append("| " + " | ".join(_md(g[k]) for k in
|
||||
("package", "title", "engine", "xr", "status", "verified_date", "updated", "entry_date")) + " |")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def print_metrics(report: dict, verbose: bool = False) -> None:
|
||||
print(f"Split: {report['split']}; catalog/analysis intersection: {report['matched_packages']}; "
|
||||
f"converted: {report['converted_games']}; unsupported excluded: {report['excluded_unsupported']}")
|
||||
print(f"{'Engine':10} {'Games':>5} {'Exact':>9} {'Fields':>11} {'Prec':>7} {'Recall':>7} {'F1':>7} {'Runtime':>9}")
|
||||
for name, metrics in [("All", report), *report["by_engine"].items()]:
|
||||
h, r = metrics["headline"], metrics["runtime"]
|
||||
fields = h["fields"].values()
|
||||
f = f"{sum(x['matched'] for x in fields)}/{sum(x['total'] for x in h['fields'].values())}"
|
||||
p = h["patches"]
|
||||
print(f"{name:10} {h['games']:5} {h['exact']:4}/{h['games']:<4} {f:>11} "
|
||||
f"{p['precision']:7.1%} {p['recall']:7.1%} {p['f1']:7.1%} {r['exact']:4}/{r['games']:<4}")
|
||||
print("\nDated (extras the entry couldn't have listed - patch newer than its verified date - or that catalog "
|
||||
"recipes inherit anyway - adapter/device picks - are not errors):")
|
||||
for name, metrics in [("All", report), *report["by_engine"].items()]:
|
||||
d = metrics["headline_dated"]
|
||||
p = d["patches"]
|
||||
print(f"{name:10} {d['games']:5} {d['exact']:4}/{d['games']:<4} {'':>11} "
|
||||
f"{p['precision']:7.1%} {p['recall']:7.1%} {p['f1']:7.1%}")
|
||||
gaps = report["headline_dated"]["gaps"]
|
||||
if gaps:
|
||||
print("Catalog gaps by patch: " + ", ".join(f"{k}={v}" for k, v in sorted(gaps.items(), key=lambda kv: -kv[1])))
|
||||
for name, metrics in [("All", report), *report["by_engine"].items()]:
|
||||
print(f"\n{name}: field matches (headline / runtime)")
|
||||
for scope in ("headline", "runtime"):
|
||||
matches = metrics[scope]["fields"].items()
|
||||
print(scope + ": " + ", ".join(f"{k}={v['matched']}/{v['total']}" for k, v in matches))
|
||||
print(f"{'Scope / patch':55} {'TP':>5} {'FP':>5} {'FN':>5} {'Prec':>7} {'Recall':>7} {'F1':>7}")
|
||||
for scope in ("headline", "runtime"):
|
||||
for pid, p in metrics[scope]["per_patch"].items():
|
||||
print(f"{scope + ' / ' + pid:55} {p['tp']:5} {p['fp']:5} {p['fn']:5} "
|
||||
f"{p['precision']:7.1%} {p['recall']:7.1%} {p['f1']:7.1%}")
|
||||
u = report["unsupported"]["headline"]
|
||||
print(f"\nUnsupported (excluded): {u['exact']}/{u['games']} exact; patch F1={u['patches']['f1']:.1%}")
|
||||
for game in report["games"]:
|
||||
s = game["score"]
|
||||
if verbose or not s["headline"]["exact"] or not s["runtime"]["exact"] or game["status"] == "unsupported":
|
||||
print(f"\n{game['package']} [{game['engine']}, {game['status']}, {game['split']}"
|
||||
+ (", converted" if game["converted"] else "") + "]")
|
||||
for scope in ("headline", "runtime"):
|
||||
for diff in ("missing", "extra"):
|
||||
if s[scope][diff]:
|
||||
print(f" {scope} {diff}: {json.dumps(s[scope][diff], sort_keys=True)}")
|
||||
if not s[scope]["fields"].get("status_unsupported", True):
|
||||
print(f" {scope} status_unsupported: catalog={game['want']['status_unsupported']} "
|
||||
f"heuristics={game['got']['status_unsupported']}")
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("dumps", type=Path, nargs="?")
|
||||
ap.add_argument("--snapshot", type=Path)
|
||||
ap.add_argument("--split", choices=("dev", "holdout", "all"), default="dev")
|
||||
ap.add_argument("--json", type=Path, dest="json_out")
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("dumps", type=Path)
|
||||
ap.add_argument("--verbose", action="store_true")
|
||||
ap.add_argument("--fit-report", action="store_true")
|
||||
ap.add_argument("--out", type=Path, help="Markdown destination for --fit-report (otherwise stdout)")
|
||||
args = ap.parse_args()
|
||||
if not args.snapshot and not args.dumps:
|
||||
ap.error("provide a dumps folder or --snapshot")
|
||||
if args.out and not args.fit_report:
|
||||
ap.error("--out requires --fit-report")
|
||||
snapshot = read_snapshot(args.snapshot) if args.snapshot else read_dumps(args.dumps)
|
||||
# Explicitly bundled entries: remote caches and personal recipes must not affect this benchmark.
|
||||
entries = catalog._load_dir(REPO / "catalog/games", "bundled")
|
||||
if args.fit_report:
|
||||
cache = (args.snapshot.parent if args.snapshot else args.dumps) / "patch-intro-dates.json"
|
||||
report = fit_report(snapshot, entries, patch_intro_dates(cache))
|
||||
markdown = render_fit_report(report)
|
||||
if args.out:
|
||||
args.out.write_text(markdown, encoding="utf-8")
|
||||
print(f"{report['candidate_count']} candidates in {report['games_with_candidates']} games -> {args.out}")
|
||||
else:
|
||||
print(markdown, end="")
|
||||
else:
|
||||
cache = (args.snapshot.parent if args.snapshot else args.dumps) / "patch-intro-dates.json"
|
||||
# dated scoring reads the cache --fit-report writes; a plain run never shells out for dates
|
||||
dates = json.loads(cache.read_text(encoding="utf-8")) if cache.is_file() else None
|
||||
report = evaluate(snapshot, entries, args.split, dates)
|
||||
print_metrics(report, args.verbose)
|
||||
if args.json_out:
|
||||
args.json_out.write_text(json.dumps(report, indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8")
|
||||
# Retain the legacy evaluator's nonzero exit for recipe differences; JSON is still written.
|
||||
return 0 if args.fit_report or report["headline"]["games"] == report["headline"]["exact"] else 1
|
||||
games = quest_dump.scan(args.dumps)
|
||||
total = matched_fields = games_ok = 0
|
||||
misses = []
|
||||
for g in games:
|
||||
a = analyze(g.apk, data_bytes=g.data_bytes())
|
||||
if not catalog.lookup(a.package):
|
||||
continue
|
||||
want = signature(engine.suggest(a, use_catalog=True))
|
||||
want["status_unsupported"] = (catalog.lookup(a.package).status == "unsupported"
|
||||
and "32-bit" in catalog.lookup(a.package).notes)
|
||||
got = signature(engine.suggest(a, use_catalog=False))
|
||||
diffs = {k: (want[k], got[k]) for k in want if want[k] != got[k]}
|
||||
total += len(want)
|
||||
matched_fields += len(want) - len(diffs)
|
||||
games_ok += not diffs
|
||||
if diffs:
|
||||
misses.append((a.package, diffs))
|
||||
if args.verbose or diffs:
|
||||
print(f"{'OK ' if not diffs else 'DIFF'} {a.package}")
|
||||
for k, (w, h) in diffs.items():
|
||||
print(f" {k}: catalog={w} heuristics={h}")
|
||||
n = games_ok + len(misses)
|
||||
print(f"\n{games_ok}/{n} games reproduced exactly; {matched_fields}/{total} recipe fields match")
|
||||
return 0 if not misses else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyse every Quest dump below the given folders once and save the analyses as JSON, so heuristics evaluations
|
||||
(`eval_heuristics.py --snapshot`) don't re-read gigabytes of APKs each run.
|
||||
|
||||
python scripts/heuristics_snapshot.py <out.json> <dumps folder> [<dumps folder> ...]
|
||||
|
||||
An original APK beats an already converted one (OVRPort output, e.g. PATCHED/ copies): converted inputs change what
|
||||
the heuristics suggest, so they are only kept when no original exists and are marked `converted`. The output holds
|
||||
local paths: keep it out of git.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from frameport.analysis.detect import ANALYSIS_VERSION, analyze # noqa: E402
|
||||
from frameport.sources import quest_dump # noqa: E402
|
||||
|
||||
|
||||
def _skip(game, roots: list[Path]) -> bool:
|
||||
"""Folders starting with "_" (known-good backups, test builds, keys) are not dumps."""
|
||||
for root in roots:
|
||||
try:
|
||||
rel = game.apk.relative_to(root)
|
||||
except ValueError:
|
||||
continue
|
||||
return any(part.startswith("_") for part in rel.parts[:-1])
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("out", type=Path)
|
||||
ap.add_argument("roots", type=Path, nargs="+")
|
||||
args = ap.parse_args()
|
||||
roots = [r.resolve() for r in args.roots]
|
||||
old = json.loads(args.out.read_text()) if args.out.is_file() else {}
|
||||
snap: dict[str, dict] = {}
|
||||
for root in roots:
|
||||
for g in quest_dump.scan(root):
|
||||
if _skip(g, roots):
|
||||
continue
|
||||
key = str(g.apk)
|
||||
cached = next((v for v in old.values() if v.get("apk") == key
|
||||
and v.get("analysis_version") == ANALYSIS_VERSION
|
||||
and v.get("apk_size") == g.apk.stat().st_size), None)
|
||||
t = time.time()
|
||||
if cached:
|
||||
entry = cached
|
||||
else:
|
||||
try:
|
||||
data_bytes = g.data_bytes()
|
||||
a = analyze(g.apk, data_bytes=data_bytes)
|
||||
except Exception as e: # noqa: BLE001 - one unreadable dump mustn't stop the snapshot
|
||||
print(f"FAIL {g.apk}: {e}", file=sys.stderr, flush=True)
|
||||
continue
|
||||
entry = {"analysis": dataclasses.asdict(a), "data_bytes": data_bytes, "apk": key,
|
||||
"apk_size": g.apk.stat().st_size, "name": g.name, "root": str(root),
|
||||
"converted": bool(a.is_overport_output), "analysis_version": ANALYSIS_VERSION}
|
||||
pkg = entry["analysis"]["package"]
|
||||
have = snap.get(pkg)
|
||||
# originals beat converted copies; otherwise the first root given wins
|
||||
if have is None or (have["converted"] and not entry["converted"]):
|
||||
snap[pkg] = entry
|
||||
print(f"{'cache' if cached else f'{time.time() - t:5.1f}s'} {pkg}"
|
||||
f"{' (converted)' if entry['converted'] else ''}", flush=True)
|
||||
args.out.write_text(json.dumps(snap, indent=1, default=str)) # partial results survive a crash
|
||||
print(f"{len(snap)} packages ({sum(v['converted'] for v in snap.values())} converted only) -> {args.out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -276,6 +276,10 @@ def entry(p: base.Patch, games: list[str], sigs: list[tuple[str, str, str]]) ->
|
||||
facts.append("experimental")
|
||||
if not p.needs_vr:
|
||||
facts.append("also for 2D apps")
|
||||
if p.on_pc and p.category != "overport": # OVRPort's patches are all in builds for this PC
|
||||
facts.append("also in builds for this PC (AXRB" + (", experimental)" if p.on_pc == "experimental" else ")"))
|
||||
elif getattr(p, "pc_added", False):
|
||||
facts.append("builds for this PC (AXRB) add it when needed")
|
||||
rev = p.revision
|
||||
if rev > 1:
|
||||
facts.append(f"revision {rev}")
|
||||
|
||||
+1
-3
@@ -150,8 +150,7 @@ def main() -> int:
|
||||
if args.fake_frame: # GitHub #90: the Move to… dialog (drives load in the background)
|
||||
steps.append(("move-dialog", lambda a: (a.page.pop_dialog(), a.move_game(game))))
|
||||
steps.append(("uninstall-dialog", lambda a: (a.page.pop_dialog(), a.uninstall(game))))
|
||||
# last: the menus stay open over whatever comes next (the page's "…" menu, then the right-click menu)
|
||||
steps.append(("game-menu", lambda a: (a.show_activity(False), a.open_game_menu(game))))
|
||||
# last: the right-click menu stays open over whatever comes next
|
||||
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
|
||||
a.library_view.open_menu(game))))
|
||||
if args.docs:
|
||||
@@ -279,7 +278,6 @@ def main() -> int:
|
||||
("linux-change-program", lambda a: a.choose_exe(LINUX_FOLDER)),
|
||||
("frame", lambda a: (a.page.pop_dialog(), a.navigate(1))),
|
||||
("linux-move", lambda a: a.move_game(LINUX_APPIMAGE) if args.fake_frame else None),
|
||||
("linux-game-menu", lambda a: (a.page.pop_dialog(), a.open_game_menu(LINUX_FOLDER))),
|
||||
("linux-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
|
||||
a.library_view.open_menu(LINUX_APPIMAGE)))]
|
||||
if args.links:
|
||||
|
||||
@@ -179,6 +179,11 @@
|
||||
"title": "Steadier hands (older Unity)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.ovrp_trace": {
|
||||
"anchor": "adapter-ovrp_trace",
|
||||
"title": "Unity frame loop diagnostics (log)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.passthrough_emul": {
|
||||
"anchor": "adapter-passthrough_emul",
|
||||
"title": "See your room",
|
||||
@@ -204,6 +209,11 @@
|
||||
"title": "Treat newer Quest controllers as Touch",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.projection_only": {
|
||||
"anchor": "adapter-projection_only",
|
||||
"title": "Main picture only",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.proximity_emul": {
|
||||
"anchor": "adapter-proximity_emul",
|
||||
"title": "Animate fingers from touch",
|
||||
@@ -234,6 +244,11 @@
|
||||
"title": "Sharpness",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.scale_max": {
|
||||
"anchor": "adapter-scale_max",
|
||||
"title": "Cap the eye size",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.scene_depth": {
|
||||
"anchor": "adapter-scene_depth",
|
||||
"title": "Room depth",
|
||||
@@ -634,6 +649,11 @@
|
||||
"title": "Vivox voice chat: skip Android 12 audio routing",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.vk_android_extensions": {
|
||||
"anchor": "frame-vk_android_extensions",
|
||||
"title": "Vulkan: list Android's external-memory extensions (this PC)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.vk_sanitize": {
|
||||
"anchor": "frame-vk_sanitize",
|
||||
"title": "Vulkan: drop invalid render-pass pointers",
|
||||
@@ -779,6 +799,11 @@
|
||||
"title": "VrApi → OpenXR adapter (OVRPort)",
|
||||
"group": "Patches from OVRPort"
|
||||
},
|
||||
"pc.axrb_backend": {
|
||||
"anchor": "pc-axrb_backend",
|
||||
"title": "AXRB mode on this PC",
|
||||
"group": "Files and settings on the Frame"
|
||||
},
|
||||
"pcvr.launch_args": {
|
||||
"anchor": "pcvr-launch_args",
|
||||
"title": "Launch options (SteamVR or OpenXR)",
|
||||
|
||||
@@ -43,7 +43,11 @@ IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
|
||||
# 17: headset (the Meta headset a VR app was made for: Gear VR / Go games run through frame.a32_bridge) (2026-10)
|
||||
# 18: headset "quest" also for 32-bit apps that require 6DoF head tracking (Santa Cruz builds, e.g. Sports
|
||||
# Scramble: Touch controllers on the a32 bridge) (2026-10)
|
||||
ANALYSIS_VERSION = 19
|
||||
# 19: unreal_obb_delivery also from obfuscated DownloaderActivity predicates (#159 Medal of Honor) (2026-10)
|
||||
# 20: Unreal's Vulkan RHI from the manifest meta-data GameActivity.bSupportsVulkan (Unreal games never declare
|
||||
# android.hardware.vulkan, so every Unreal Vulkan game was "GLES or unknown"; AXRB direct mode is Vulkan only),
|
||||
# and "Vulkan (VrApi Vulkan system)" for direct-VrApi engines that import vrapi_CreateSystemVulkan (The Climb 2)
|
||||
ANALYSIS_VERSION = 20
|
||||
|
||||
|
||||
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
|
||||
@@ -87,6 +91,18 @@ def cryengine_project(data: bytes) -> bool:
|
||||
UE_VERIFY_OBB_KEY = ".GameActivity.bVerifyOBBOnStartUp"
|
||||
|
||||
|
||||
UE_SUPPORTS_VULKAN_KEY = ".GameActivity.bSupportsVulkan"
|
||||
|
||||
|
||||
def unreal_vulkan(meta: dict) -> bool:
|
||||
"""Unreal packaged the game with its Vulkan RHI (Project Settings > Android > Support Vulkan): UnrealBuildTool
|
||||
writes `com.epicgames.{ue4,unreal}.GameActivity.bSupportsVulkan` into the manifest, and on a Vulkan device (every
|
||||
Quest) GameActivity starts the Vulkan RHI, GLES only as a fallback. GLES-only builds write false (UE 4.21+) or
|
||||
nothing (older). Only a real boolean true counts, as GameActivity reads it with Bundle.getBoolean."""
|
||||
return any(k.startswith("com.epicgames.") and k.endswith(UE_SUPPORTS_VULKAN_KEY) and v is True
|
||||
for k, v in meta.items())
|
||||
|
||||
|
||||
def unreal_verify_obb(meta: dict) -> bool:
|
||||
"""Unreal's DownloaderActivity checks every OBB entry's CRC at the first start (a full read; on a mismatch it waits
|
||||
on an error screen). Only a real boolean counts: GameActivity reads it with Bundle.getBoolean."""
|
||||
@@ -287,12 +303,17 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
direct_vrapi = any("libvrapi.so" in elf.needed(b) for n, b in lib_bytes.items()
|
||||
if elf.is_elf(b) and n not in ("libOVRPlugin.so", "libvrapi.so"))
|
||||
vulkan_declared = b"android.hardware.vulkan" in manifest or _uses_feature(manifest, "android.hardware.vulkan")
|
||||
meta = axml.meta_data(manifest)
|
||||
if vulkan_declared:
|
||||
graphics = "Vulkan (declared in manifest)"
|
||||
elif engine == "Unreal" and unreal_vulkan(meta):
|
||||
graphics = "Vulkan (Unreal bSupportsVulkan)"
|
||||
elif engine == "Unity" and "vulkan" in boot.lower():
|
||||
graphics = "Vulkan (Unity boot.config)"
|
||||
elif engine == "CryEngine" and re.search(r'(?im)^\s*r_Driver\s*=\s*"?vk"?\s*(?:;.*)?$', system_cfg):
|
||||
graphics = "Vulkan (CryEngine system.cfg)"
|
||||
elif direct_vrapi and vrapi_vulkan(lib_bytes):
|
||||
graphics = "Vulkan (VrApi Vulkan system)"
|
||||
else:
|
||||
graphics = "GLES or unknown (no Vulkan declaration)"
|
||||
|
||||
@@ -323,7 +344,6 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
manifest_strings = axml.Axml(manifest).strings()
|
||||
features = _features(manifest)
|
||||
used_perms, _ = axml.used_and_declared_permissions(manifest)
|
||||
meta = axml.meta_data(manifest)
|
||||
return Analysis(
|
||||
package=package,
|
||||
version=version,
|
||||
@@ -519,6 +539,14 @@ def _uses_feature(manifest: bytes, feature: str) -> bool:
|
||||
OVR_TO_STRING = re.compile(r"^ovr[A-Z][A-Za-z0-9]*_ToString$")
|
||||
|
||||
|
||||
def vrapi_vulkan(lib_bytes: dict[str, bytes]) -> bool:
|
||||
"""An engine library that calls VrApi directly creates VrApi's Vulkan system (vrapi_CreateSystemVulkan): the game
|
||||
renders with Vulkan even without a manifest declaration (The Climb 2's CryEngine). GLES VrApi games (Path of the
|
||||
Warrior) never import it. Only the game's own libraries count, not libvrapi.so or OVRPlugin."""
|
||||
return any(elf.is_elf(b) and "vrapi_CreateSystemVulkan" in elf.dyn_symbols(b, False)
|
||||
for n, b in lib_bytes.items() if n not in ("libvrapi.so", "libOVRPlugin.so"))
|
||||
|
||||
|
||||
def missing_ovr_symbols(lib_bytes: dict[str, bytes]) -> set[str]:
|
||||
"""ovr_* / ovrMessageType_* / ovr<Enum>_ToString functions the game imports that the platform loader (+compat/stub
|
||||
libs) lacks."""
|
||||
|
||||
@@ -361,6 +361,25 @@ def component_filters(manifest: bytes) -> list[tuple[str, str, list[tuple[str, i
|
||||
return out
|
||||
|
||||
|
||||
def launcher_activity(manifest: bytes) -> str | None:
|
||||
""""<package>/<fully qualified class>" of the activity Android's launcher starts (MAIN + LAUNCHER; an alias
|
||||
names its targetActivity), what `cmd package resolve-activity --brief` answers; None when there is none."""
|
||||
x = Axml(manifest)
|
||||
names = x.strings()
|
||||
package = next((x.attr_str(el, "package") for el in x.elements() if el.name == "manifest"), None)
|
||||
targets = {x.attr_str(el, "name"): x.attr_str(el, "targetActivity")
|
||||
for el in x.elements() if el.name == "activity-alias"}
|
||||
if not package:
|
||||
return None
|
||||
for el, name, items in component_filters(manifest):
|
||||
found = {names[i] for _, _, i in items if i < len(names)}
|
||||
if el in ("activity", "activity-alias") and name and MAIN in found and LAUNCHER in found:
|
||||
cls = (targets.get(name) or name) if el == "activity-alias" else name
|
||||
cls = package + cls if cls.startswith(".") else cls if "." in cls else f"{package}.{cls}"
|
||||
return f"{package}/{cls}"
|
||||
return None
|
||||
|
||||
|
||||
def vr_activity(manifest: bytes) -> str | None:
|
||||
"""The activity with Meta's VR category when it isn't the launcher activity (a 2D launcher that starts the VR
|
||||
part, e.g. WiiCompiled's LauncherActivity -> QuestActivity)."""
|
||||
|
||||
+37
-7
@@ -1,11 +1,12 @@
|
||||
"""Build stage: OVRPort → Frame fixes (apk-stage patches) → sign → static validation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .apk import sign
|
||||
from .apk import axml, sign
|
||||
from .apk.workspace import ApkWorkspace
|
||||
from .core.events import Reporter
|
||||
from .core.models import Analysis, BuildResult, Recipe, SourceGame
|
||||
@@ -42,9 +43,9 @@ def apk_patches(selection: dict) -> list[base.Patch]:
|
||||
|
||||
|
||||
def apply_frame_fixes(apk_in: Path, apk_out_unsigned: Path, analysis: Analysis, recipe: Recipe,
|
||||
reporter: Reporter) -> tuple[list[str], list[dict], dict[str, str]]:
|
||||
reporter: Reporter, pc: bool = False) -> tuple[list[str], list[dict], dict[str, str]]:
|
||||
"""Apply the recipe's apk-stage patches; returns (applied ids, checks, workarounds left out because OVRPort's
|
||||
output already has the upstream fix (patches/upstream.py))."""
|
||||
output already has the upstream fix (patches/upstream.py)). pc: the build for this PC (extract_native_libs)."""
|
||||
applied, checks = [], []
|
||||
with ApkWorkspace(apk_in) as ws:
|
||||
selection = upstream.resolve(ws, recipe.patches, reporter)
|
||||
@@ -56,10 +57,26 @@ def apply_frame_fixes(apk_in: Path, apk_out_unsigned: Path, analysis: Analysis,
|
||||
reporter.log(f"applied {patch.id}" + (f" ({'; '.join(ctx.notes)})" if ctx.notes else ""))
|
||||
for name, ok, detail in patch.validate(ctx):
|
||||
checks.append({"name": name, "ok": ok, "detail": detail})
|
||||
if pc and "frame.adapter" in selection.patches and extract_native_libs(ws):
|
||||
reporter.log("AndroidManifest.xml: extractNativeLibs on (FrameBridge reads its settings next to itself)")
|
||||
ws.write(apk_out_unsigned)
|
||||
return applied, checks, selection.superseded
|
||||
|
||||
|
||||
def extract_native_libs(ws: ApkWorkspace) -> bool:
|
||||
"""Builds for this PC: have Android unpack the native libraries. FrameBridge reads lib/<abi>/libframe_settings.so
|
||||
with fopen next to its own path (dladdr); an app with extractNativeLibs="false" maps its libraries straight from
|
||||
base.apk ("base.apk!/lib/..."), where fopen finds nothing. On the Frame the agent also hands the adapter the
|
||||
settings (settings.conf via FRAMEBRIDGE_CONFIG, framebridge.conf in the game's storage), so the Frame build stays
|
||||
unchanged; in AXRB the library is the only source."""
|
||||
manifest = ws.read("AndroidManifest.xml")
|
||||
changed = axml.set_bool_attr(manifest, "application", "extractNativeLibs", True)
|
||||
if not changed or changed == manifest:
|
||||
return False
|
||||
ws.put("AndroidManifest.xml", changed)
|
||||
return True
|
||||
|
||||
|
||||
def check_a32(analysis: Analysis, recipe: Recipe) -> None:
|
||||
"""A 32-bit-only game builds only through the 32-bit bridge, and without what the bridge replaces: OVRPort and
|
||||
the 64-bit fixes would fail deep inside a tool or give an APK the Frame refuses (INSTALL_FAILED_NO_MATCHING_ABIS).
|
||||
@@ -77,9 +94,17 @@ def check_a32(analysis: Analysis, recipe: Recipe) -> None:
|
||||
|
||||
|
||||
def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path, reporter: Reporter,
|
||||
keep_work: bool = False) -> BuildResult:
|
||||
keep_work: bool = False, pc: bool = False) -> BuildResult:
|
||||
"""pc: the build for this PC (AXRB): only OVRPort's patches and the on_pc APK fixes (base.pc_selection)."""
|
||||
pkg = analysis.package
|
||||
check_a32(analysis, recipe)
|
||||
if pc:
|
||||
patches = base.pc_selection(recipe.patches, analysis)
|
||||
if not recipe.overport: # FrameBridge wraps OVRPort's loader: nothing to wrap without OVRPort
|
||||
patches = {pid: p for pid, p in patches.items()
|
||||
if pid != "frame.adapter" and not pid.startswith("adapter.")}
|
||||
recipe = dataclasses.replace(recipe, patches=patches)
|
||||
else:
|
||||
check_a32(analysis, recipe)
|
||||
work = work_dir() / pkg
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
work.mkdir(parents=True)
|
||||
@@ -90,6 +115,9 @@ def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path,
|
||||
# Frame patches are applied on top. Its alternate build is the copy saved next to it, or (no copy) one more
|
||||
# OVRPort run for the alternate patches; the stand-ins patch relinks what that drops.
|
||||
converted = recipe.overport and analysis.is_overport_output
|
||||
if pc and converted:
|
||||
reporter.check("Original APK", None, "this copy was already converted for the Steam Frame and keeps its Frame "
|
||||
"fixes; add the game's original APK for a clean build for this PC")
|
||||
variants = [("primary", False)] + ([("alt", True)] if recipe.alt_patches and recipe.overport else [])
|
||||
alt_copy = Path(source.apk).with_name(f"{pkg}.alt-noforcequit.apk")
|
||||
results = {}
|
||||
@@ -108,7 +136,8 @@ def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path,
|
||||
shutil.copyfile(input_apk, patched)
|
||||
reporter.stage(f"Frame fixes ({variant})")
|
||||
unsigned = work / f"{pkg}.{variant}.unsigned.apk"
|
||||
applied, checks, left_out = apply_frame_fixes(patched, unsigned, analysis, recipe, reporter)
|
||||
applied, checks, left_out = apply_frame_fixes(patched, unsigned, analysis, recipe, reporter,
|
||||
**({"pc": True} if pc else {}))
|
||||
if variant == "primary":
|
||||
superseded = left_out
|
||||
patched.unlink(missing_ok=True)
|
||||
@@ -117,7 +146,8 @@ def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path,
|
||||
sign.sign(unsigned, final, pkg)
|
||||
unsigned.unlink(missing_ok=True)
|
||||
reporter.stage(f"validate ({variant})")
|
||||
checks += check_apk(final, pkg, expect_adapter=recipe.overport)
|
||||
checks += check_apk(final, pkg, expect_adapter=recipe.overport and "frame.adapter" in recipe.patches
|
||||
if pc else recipe.overport)
|
||||
for c in checks:
|
||||
reporter.check(c["name"], c["ok"], c["detail"])
|
||||
results[variant] = final
|
||||
|
||||
+77
-39
@@ -398,30 +398,6 @@ def rename(package: str = typer.Argument(..., help="the game (package or part of
|
||||
pipeline.sync_title(pkg, _target(None, to="pc"), rep)
|
||||
|
||||
|
||||
@app.command("new-version")
|
||||
def new_version(package: str = typer.Argument(..., help="the game (package or part of its title)"),
|
||||
path: Path = typer.Argument(..., exists=True, help="the newer version: an APK (Quest/Android games), "
|
||||
"an AppImage/archive/folder (Linux apps) or the game folder (PC VR)")):
|
||||
"""Point a game at a newer version of it (GitHub #161). Its name, artwork, tags and your own recipe stay; install
|
||||
it again (frameport install) to update it on the Frame."""
|
||||
[pkg] = _pkgs(package, False)
|
||||
try:
|
||||
res = pipeline.replace_source(pkg, path, printing_reporter(verbose=False))
|
||||
except pipeline.WrongPackage as exc:
|
||||
raise typer.BadParameter(f"that APK is a different app: {exc}") from None
|
||||
except ValueError as exc:
|
||||
raise typer.BadParameter(str(exc)) from None
|
||||
g = res["entry"]
|
||||
typer.echo(f"{pkg}: now {path}" + (f" (program {g.get('exe')})" if g.get("kind") in ("rift", "linux") else ""))
|
||||
if res.get("older"):
|
||||
typer.echo("Heads-up: that APK is an older version than the one before.", err=True)
|
||||
if res.get("different"):
|
||||
typer.echo(f"Heads-up: {res.get('name')} looks like a different app than {g.get('title')}.", err=True)
|
||||
if g.get("kind") == "rift" and g.get("exe_confirmed") is False:
|
||||
typer.echo(f"More than one program could start it; FramePort picked {g.get('exe')}. Another one: "
|
||||
f"frameport recipe {pkg} --exe <program>", err=True)
|
||||
|
||||
|
||||
@app.command()
|
||||
def patches():
|
||||
"""List every available patch."""
|
||||
@@ -484,6 +460,18 @@ def build(package: Optional[str] = typer.Argument(None), all_: bool = typer.Opti
|
||||
raise typer.Exit(1 if failed else 0)
|
||||
|
||||
|
||||
def _set_axrb_backend(target, backend: Optional[str]) -> None:
|
||||
if backend is None:
|
||||
return
|
||||
from .tools import axrb
|
||||
|
||||
if backend not in axrb.BACKENDS:
|
||||
raise typer.BadParameter(f"--axrb-backend: {' or '.join(axrb.BACKENDS)}")
|
||||
if getattr(target, "kind", "") != "pc":
|
||||
raise typer.BadParameter("--axrb-backend needs --to pc")
|
||||
target.axrb_backend = backend
|
||||
|
||||
|
||||
@app.command()
|
||||
def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Option(False, "--all", help=ALL_HELP),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
@@ -491,14 +479,19 @@ def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Op
|
||||
apk_only: bool = typer.Option(False, help="reuse game data already on the Frame"),
|
||||
force: bool = typer.Option(False, help="rebuild and reinstall even if the Frame is already current"),
|
||||
no_library: bool = typer.Option(False, help="don't add to the Steam library now"),
|
||||
to: str = typer.Option("frame", help="frame, or pc (PC VR games only: install on this PC)"),
|
||||
to: str = typer.Option("frame", help="frame, or pc (install on this PC: PC VR games through Revive, "
|
||||
"Quest games through AXRB, experimental)"),
|
||||
apk: Optional[Path] = typer.Option(None, help="install this APK instead of the last build (one game; "
|
||||
"for example a test build signed with the game's key)"),
|
||||
dest: Optional[str] = typer.Option(None, help="drive for new installs: 'internal' or a drive's path from "
|
||||
"'frameport frame drives' (default: the app's setting); "
|
||||
"installed games stay where they are")):
|
||||
"""Install games on the Frame (or PC VR games on this PC) and add them to the Steam library."""
|
||||
"installed games stay where they are"),
|
||||
axrb_backend: Optional[str] = typer.Option(None, help="with --to pc, Quest games: AXRB's 'direct' mode "
|
||||
"(no emulator, Vulkan games) or its Android 'emulator' "
|
||||
"(default: the recipe's choice, else by the game's graphics)")):
|
||||
"""Install games on the Frame (or on this PC) and add them to the Steam library."""
|
||||
target = _target(frame, password, to)
|
||||
_set_axrb_backend(target, axrb_backend)
|
||||
if dest is not None and hasattr(target, "dest"):
|
||||
target.dest = "" if dest == "internal" else dest
|
||||
pkgs = _pkgs(package, all_)
|
||||
@@ -516,21 +509,46 @@ def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Op
|
||||
target.add_to_library(shortcuts, printing_reporter(False))
|
||||
|
||||
|
||||
def _sim_wants_tracked_head(deployments: list[dict]) -> bool:
|
||||
"""--sim: Monado's tracked (qwerty) head when every game tested is an Android one on AXRB's emulator backend.
|
||||
Its host bridge (1.0.4) has no synthetic head for Monado's valid-but-untracked simulated one (direct mode has:
|
||||
AXRB_TEST_SYNTHETIC_HMD=2), so the guest's LOCAL space never gets an origin and VrApi games never see a tracked
|
||||
head (tools/xr_sim.TRACKED_HEAD_ENV)."""
|
||||
from .targets import pc_android
|
||||
|
||||
android = [d for d in deployments if d.get("kind") == pc_android.KIND]
|
||||
return bool(android) and len(android) == len(deployments) and all(
|
||||
pc_android.dep_backend(d) != pc_android.axrb.DIRECT for d in android)
|
||||
|
||||
|
||||
@app.command()
|
||||
def test(package: Optional[str] = typer.Argument(None), all_: bool = typer.Option(False, "--all", help=ALL_HELP),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
seconds: int = typer.Option(45, help="how long the game runs before it's stopped"),
|
||||
to: str = typer.Option("frame", help="frame, or pc (PC VR games installed on this PC)")):
|
||||
to: str = typer.Option("frame", help="frame, or pc (games installed on this PC)"),
|
||||
axrb_backend: Optional[str] = typer.Option(None, help="with --to pc: check that Quest games are installed "
|
||||
"for this AXRB backend (direct or emulator)"),
|
||||
sim: bool = typer.Option(False, "--sim", help="with --to pc: use Monado's simulated headset instead of the "
|
||||
"PC's OpenXR runtime (no headset needed; also env FRAMEPORT_PC_XR_SIM=1)")):
|
||||
"""Headless launch test on the Frame (or this PC) and log triage."""
|
||||
from .tools import xr_sim
|
||||
|
||||
if sim and to != "pc":
|
||||
raise typer.BadParameter("--sim only works with --to pc")
|
||||
target = _target(frame, to=to)
|
||||
installed = {g["package"] for g in target.installed()}
|
||||
for pkg in _pkgs(package, all_):
|
||||
if pkg not in installed:
|
||||
typer.echo(f"{pkg}: not installed on {target.label}", err=True)
|
||||
continue
|
||||
s = pipeline.test_game(pkg, target, printing_reporter(False), seconds)
|
||||
typer.echo(f"{pkg}: {s['state']} ({s['verdict']}) furthest: {s['milestone']}"
|
||||
+ (f"; suggestions: {', '.join(s['suggestions'])}" if s["suggestions"] else ""))
|
||||
_set_axrb_backend(target, axrb_backend)
|
||||
games = {g["package"]: g for g in target.installed()}
|
||||
installed = set(games)
|
||||
pkgs = _pkgs(package, all_)
|
||||
with xr_sim.running(to == "pc" and xr_sim.enabled(sim), reporter=printing_reporter(False),
|
||||
tracked=xr_sim.tracked_head(_sim_wants_tracked_head([games[p] for p in pkgs if p in games]))):
|
||||
for pkg in pkgs:
|
||||
if pkg not in installed:
|
||||
typer.echo(f"{pkg}: not installed on {target.label}", err=True)
|
||||
continue
|
||||
s = pipeline.test_game(pkg, target, printing_reporter(False), seconds)
|
||||
typer.echo(f"{pkg}: {s['state']} ({s['verdict']}) furthest: {s['milestone']}"
|
||||
+ (f"; suggestions: {', '.join(s['suggestions'])}" if s["suggestions"] else ""))
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -581,9 +599,11 @@ def triage(logfile: Path = typer.Argument(..., help="a launch.log"),
|
||||
|
||||
@app.command()
|
||||
def settings(package: str, values: list[str] = typer.Argument(..., help="key=value pairs"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
to: str = typer.Option("frame", help="frame, or pc (a Quest game in AXRB on this PC; an empty value "
|
||||
"removes a setting)")):
|
||||
"""Change FrameBridge settings of an installed game (no rebuild), for example scale=1.2."""
|
||||
target = _target(frame)
|
||||
target = _target(frame, to=to)
|
||||
kv = dict(v.split("=", 1) for v in values)
|
||||
typer.echo(json.dumps(target.set_settings(package, kv), indent=1))
|
||||
|
||||
@@ -783,10 +803,28 @@ def frame_proton(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
# ------------------------------------------------------------------------------------------ pc (Revive)
|
||||
@pc_app.command("info")
|
||||
def pc_info():
|
||||
"""Steam, SteamVR and Revive on this PC (JSON)."""
|
||||
"""Steam, SteamVR, Revive and AXRB (its emulator and its direct mode) on this PC (JSON)."""
|
||||
typer.echo(json.dumps(_target(None, to="pc").describe(), indent=1, default=str))
|
||||
|
||||
|
||||
@pc_app.command("install-axrb")
|
||||
def pc_install_axrb(backend: str = typer.Option("emulator", help="'emulator' (AXRB's Android emulator: Google's "
|
||||
"emulator pieces, about 2.5 GB) or 'direct' (AXRB-Emulatorless: "
|
||||
"Google's Android 16 arm64 system image, about 0.8 GB)"),
|
||||
update: bool = typer.Option(False, "--update", help="also update an installed AXRB to the "
|
||||
"newest release with an installer for this backend")):
|
||||
"""Install AXRB (Android XR Bridge, for Quest games on this PC) and set up its Android runtime. Normally done by
|
||||
the first `frameport install <quest game> --to pc`. Downloads AXRB's official installer plus Google's Android
|
||||
files. A local build (FRAMEPORT_AXRB_DIRECT_DIR / FRAMEPORT_AXRB_DIR) is set up but never replaced."""
|
||||
from .targets import pc_android
|
||||
from .tools import axrb
|
||||
|
||||
if backend not in axrb.BACKENDS:
|
||||
raise typer.BadParameter(f"--backend: {' or '.join(axrb.BACKENDS)}")
|
||||
pc_android.ensure_axrb(printing_reporter(False), backend, update=update)
|
||||
typer.echo(json.dumps(axrb.status(), indent=1, default=str))
|
||||
|
||||
|
||||
@pc_app.command("install-revive")
|
||||
def pc_install_revive():
|
||||
"""Download Revive's latest release and unpack it into FramePort's tools (no installer, no admin rights)."""
|
||||
|
||||
@@ -220,28 +220,6 @@ class PcvrPlan:
|
||||
art_lookup: str | None = None # package whose store art to use
|
||||
dest: str | None = None
|
||||
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
|
||||
source_id: str | None = None # which version of the game folder (GitHub #161: a newer one was added)
|
||||
changed_since: float | None = None # a newer version: game files changed after this are sent again (see below)
|
||||
|
||||
|
||||
def changed_since(path: Path, since: float | None) -> bool:
|
||||
"""A newer version of a Linux app / PC VR game (GitHub #161): uploads skip files the Frame has with the same
|
||||
size, so a changed file of the same size would stay old. Files written (or unpacked: ctime) after the last
|
||||
install are sent again; since=None (no newer version) never, 0 (last install unknown) always."""
|
||||
if since is None:
|
||||
return False
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return True
|
||||
return max(st.st_mtime, st.st_ctime) > since
|
||||
|
||||
|
||||
def _recipe_record(recipe: dict | None, source_id: str | None) -> dict | None:
|
||||
"""The deployment's recipe record, with the source version it was installed from (ui.components.source_replaced)."""
|
||||
if not source_id:
|
||||
return recipe
|
||||
return {**(recipe or {}), "source_id": source_id}
|
||||
|
||||
|
||||
def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
@@ -281,8 +259,7 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
manifests["helpers"] = {p.name: p.stat().st_size for p in sorted(helpers.iterdir()) if p.is_file()}
|
||||
sources["helpers"] = helpers
|
||||
to_send = [(t, rel) for t, m in manifests.items() for rel, size in m.items()
|
||||
if prep["existing"].get(t, {}).get(rel) != size
|
||||
or t == "game" and changed_since(plan.game_dir / rel, plan.changed_since)]
|
||||
if prep["existing"].get(t, {}).get(rel) != size]
|
||||
need = sum(manifests[t][rel] for t, rel in to_send)
|
||||
if need > prep["free_bytes"] - (1 << 30):
|
||||
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
|
||||
@@ -313,8 +290,7 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
game_args=game_args(plan.recipe), no_crash_reporter="pcvr.no_crash_reporter" in plan.recipe.patches,
|
||||
libovr_redirect="pcvr.libovr_redirect" in plan.recipe.patches,
|
||||
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version, vr=not is_flat_windows(plan.package),
|
||||
recipe=_recipe_record({"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, plan.source_id),
|
||||
overlay=plan.overlay,
|
||||
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, overlay=plan.overlay,
|
||||
)
|
||||
reporter.log(f"installed at {result['base']} (Proton {result['proton']}, Steam shortcut id {result['appid']})")
|
||||
log_overlay(result.get("overlay"), reporter)
|
||||
@@ -349,8 +325,6 @@ class LinuxPlan:
|
||||
desktop_entry: bool = True # an entry in Desktop Mode's menu and on its desktop (GitHub #84)
|
||||
desktop_icon: bool = True # with it, a copy on Desktop Mode's desktop (GitHub #212)
|
||||
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
|
||||
source_id: str | None = None # which version of the app (GitHub #161: a newer one was added)
|
||||
changed_since: float | None = None # a newer version: files changed after this are sent again
|
||||
|
||||
|
||||
def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
@@ -365,8 +339,7 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
else:
|
||||
manifest = local_data_manifest(plan.root)
|
||||
manifest = {k: v for k, v in manifest.items() if not k.startswith(".unpacked")}
|
||||
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size
|
||||
or changed_since(plan.root / rel, plan.changed_since)]
|
||||
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size]
|
||||
need = sum(manifest[rel] for rel in to_send)
|
||||
if need > prep["free_bytes"] - (1 << 30):
|
||||
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
|
||||
@@ -385,7 +358,7 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
appimage=plan.appimage, openxr=plan.openxr, x86_64=plan.x86_64, manifests={"app": manifest},
|
||||
executables=executables, tags=_tags(plan.package), dest=plan.dest,
|
||||
desktop_entry=plan.desktop_entry, desktop_icon=plan.desktop_icon, overlay=plan.overlay,
|
||||
recipe=_recipe_record(None, plan.source_id), timeout=900)
|
||||
timeout=900)
|
||||
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
|
||||
log_overlay(result.get("overlay"), reporter)
|
||||
apply_app_icon(frame, plan.package, prep.get("anchor"), result, reporter)
|
||||
|
||||
@@ -162,6 +162,7 @@
|
||||
"Android app": "",
|
||||
"Android app (APK)": "",
|
||||
"Android app without VR: installed unchanged and shown as a flat window": "",
|
||||
"Android emulator": "",
|
||||
"Android gives a process 128 thread keys (pthread keys, per-thread storage slots). Unity 6 takes about 40, and on the Frame the graphics and OpenXR libraries take more than on a Quest, so games with many other native libraries run out: Wander aborted at start (\"cannot create thread specific key\" in Firebase). Unity's keys without a clean-up function become slots behind one real key (libfp_tkeys.so); every other key and every other library keep real ones. Approach by Klownicle (GitHub #198).": "",
|
||||
"Android layer started": "",
|
||||
"Android VR": "",
|
||||
@@ -191,6 +192,9 @@
|
||||
"Automatic": "",
|
||||
"Automatic ({auto:.0%})": "",
|
||||
"Automatic SteamVR performance settings (this PC)": "",
|
||||
"AXRB mode on this PC": "",
|
||||
"AXRB {value} · Android emulator": "",
|
||||
"AXRB {value} · direct mode (no emulator)": "",
|
||||
"Back to library": "",
|
||||
"Back up the signing keys ({len}) to {shown_dir} first": "",
|
||||
"Banner": "",
|
||||
@@ -220,6 +224,7 @@
|
||||
"Cancel all": "",
|
||||
"canceled": "",
|
||||
"Canceled": "",
|
||||
"Cap the eye size": "",
|
||||
"Catalog": "",
|
||||
"Ceiling height for scene_emul.": "",
|
||||
"Ceiling height of the pretend room.": "",
|
||||
@@ -380,6 +385,7 @@
|
||||
"Diagnostics for frame.gl_multiview_fbo: checks for GL errors after every single-view draw of a multiview shader and logs counters every 5 seconds (tag GLMV in launch.log).": "",
|
||||
"Diagnostics: removes layer color fades. Some fades no longer show.": "",
|
||||
"Diagnostics: the Vulkan shim (frame.vk_sanitize) adds Khronos' validation layer to the game's instance; its findings go to launch.log. The layer library (libVkLayer_khronos_validation.so) must be in the APK.": "",
|
||||
"Direct mode (no emulator)": "",
|
||||
"Disable application space warp if used": "",
|
||||
"Disable controller tracking offset": "",
|
||||
"Disable Meta XR Audio telemetry": "",
|
||||
@@ -461,6 +467,7 @@
|
||||
"Experimental bypass for a failed Meta asset-file check at startup. Reports an empty asset list and installed status without a file path; does not provide DLC. Experimental.": "",
|
||||
"Experimental Vulkan mutable-format workaround.": "",
|
||||
"Experimental. Leave it off unless asked to test it.": "",
|
||||
"Experimental: runs the game on this PC in AXRB (Android XR Bridge), an Android emulator that shows Android VR games through SteamVR. The first install downloads AXRB and its Android (about 2.5 GB) and needs Windows Hypervisor Platform, an NVIDIA or AMD graphics card and 12 GB of memory. Many games don't work in AXRB yet.": "",
|
||||
"Experimental: shows HUDs, menus and other flat panels that stay black on the Frame (for example Doom3Quest's HUD and PDA).": "",
|
||||
"Extra diagnostics (log)": "",
|
||||
"Extra Lepton settings": "",
|
||||
@@ -528,6 +535,7 @@
|
||||
"For one eye looking wrong (for example a jittering right eye): logs each eye's submitted pose against a fresh one, the eye-to-eye relation and when each eye's image was released, also into framebridge.log in the game's storage (some games stop Android's log early). No effect on the game.": "",
|
||||
"For OpenGL ES games whose memory use keeps growing (e.g. Vader Immortal).": "",
|
||||
"For overlay apps (fpsVR, wrist watches and other SteamVR add-ons that draw over games instead of being a game): FramePort registers the app with the Frame's SteamVR, which draws its overlays over whatever you play there, Quest and PC VR games alike. With 'start with SteamVR' on, SteamVR starts it by itself (also after a restart); otherwise start it from the library. It isn't launch-tested (it shows nothing without a game). Ignored on this PC.": "",
|
||||
"For this PC only: run the game in AXRB's direct mode (no Android emulator) or in AXRB's Android emulator. Automatic uses direct mode for Vulkan games.": "",
|
||||
"For troubleshooting: records the game's requests to Meta's online services.": "",
|
||||
"For Unity games that crash when they turn on anti-aliasing (for example The Room VR).": "",
|
||||
"For Unreal Engine 5 games that crash on the Frame (for example Metro Awakening).": "",
|
||||
@@ -562,6 +570,7 @@
|
||||
"FramePort checks GitHub for a new version every few hours. Update now downloads and checks it, then restarts FramePort; your games and settings stay.": "",
|
||||
"FramePort downloads it and restarts as the new version. Your games and settings stay.": "",
|
||||
"FramePort found more than one program in this app. Pick the one that starts it, not a helper like a crash reporter or updater.": "",
|
||||
"FramePort installs AXRB from its official release (per user, no administrator rights) and Google's Android emulator files. AXRB is a separate program with its own licence; you can remove it later from Windows' app list.": "",
|
||||
"FramePort installs games on the Frame over your network": "",
|
||||
"FramePort installs games on the Frame over your network.": "",
|
||||
"FramePort installs the new build of {title} and leaves its game data (OBB files) on the Frame as it is, for example data you changed there. If the new version needs new game data, use a normal update instead.": "",
|
||||
@@ -594,6 +603,7 @@
|
||||
"FramePort's tools, library, artwork and your games' signing keys. Back it up: an update needs the same key, or the game must be reinstalled and loses its saves.": "",
|
||||
"frames per second": "",
|
||||
"Frames that are set up show up here by themselves.": "",
|
||||
"Frames that carry more than the game's main picture (menus or overlays as extra layers) submit the main picture alone. For runtimes that refuse such frames: AXRB's emulator on this PC rejects every multi-layer OpenGL ES frame. The extra layers don't show; 2 (OpenGL ES games, the default on this PC) draws the flat panels that come after the main picture into it first, so menus and messages drawn as panels still show.": "",
|
||||
"Free up space": "",
|
||||
"Free up space on the Frame": "",
|
||||
"Free up space?": "",
|
||||
@@ -664,7 +674,6 @@
|
||||
"Hardware video decoding (H.264, HEVC, VP9)": "",
|
||||
"Hardware video decoding on the Frame": "",
|
||||
"Heads-up: that APK is an older version than the one before.": "",
|
||||
"Heads-up: {name} looks like a different app than {title}.": "",
|
||||
"headset found": "",
|
||||
"Hi there!": "",
|
||||
"Hide Android's navigation bar": "",
|
||||
@@ -710,6 +719,7 @@
|
||||
"in your browser": "",
|
||||
"In your Steam library": "",
|
||||
"In your Steam library · launch settings changed — update it": "",
|
||||
"In your Steam library · patches changed — update it": "",
|
||||
"Install": "",
|
||||
"Install anyway": "",
|
||||
"Install anyway…": "",
|
||||
@@ -783,6 +793,7 @@
|
||||
"Keep the signing keys: without them, updating a game loses its saves.": "",
|
||||
"Keeps a smoothing setting off that the game turns on itself and that can freeze the Frame.": "",
|
||||
"Keeps every submitted image rect inside its swapchain. Unity can size the eye area a few pixels past the image on some Frames; SteamVR then rejects every frame (xrEndFrame -25, XR_ERROR_SWAPCHAIN_RECT_INVALID) and the game stops drawing (for example PowerWash Simulator stuck at \"Waiting\").": "",
|
||||
"Keeps games from rendering larger than the resolution scale allows.": "",
|
||||
"Keeps games running that use a 360° cube layer the Frame lacks (for example Budget Cuts Ultimate). Leave it on.": "",
|
||||
"Keeps one label so the title is stable.": "",
|
||||
"Keeps one name for the game, so it shows the same everywhere.": "",
|
||||
@@ -872,6 +883,7 @@
|
||||
"Logs every Meta Platform SDK request the game makes (user, entitlement, cloud saves, ...) and the messages that answer them, and every 10 s the requests that never got an answer (logcat tag fp_ovrtrace, in launch.log). For games that wait forever on a loading screen (for example Vader Immortal after its intro). Changes nothing: every call goes to the real function.": "",
|
||||
"Logs how game poses line up with the Frame's clock. No effect on the game.": "",
|
||||
"Logs Vulkan mistakes the game makes. Needs a special test build.": "",
|
||||
"Logs where an older Unity game's frame loop waits. No effect on the game.": "",
|
||||
"Longer for headsets that briefly think they're off your head.": "",
|
||||
"Look for the cable": "",
|
||||
"Looking at the app": "",
|
||||
@@ -883,6 +895,7 @@
|
||||
"Looking for your Frame…": "",
|
||||
"Looking up the store description and screenshots…": "",
|
||||
"Low": "",
|
||||
"Main picture only": "",
|
||||
"Make Unity text fields work": "",
|
||||
"Makes BlazeRush's menus and controllers work without Meta's avatars, and lets its shader fixes take effect.": "",
|
||||
"Makes grip, trigger and touch controls reach the game's hands (it picked the empty Gear VR controls on the Frame, for example Vader Immortal).": "",
|
||||
@@ -942,8 +955,6 @@
|
||||
"New recipe applied. Click Update on Frame to install it.": "",
|
||||
"Newer APK for {title}": "",
|
||||
"Newer APK of {title}": "",
|
||||
"Newer version for {title}": "",
|
||||
"Newer version of {title}": "",
|
||||
"Next": "",
|
||||
"Nice, it works! FramePort receives install links. (Demo: nothing was installed.)": "",
|
||||
"No": "",
|
||||
@@ -1024,13 +1035,16 @@
|
||||
"On the Frame, open Steam's Settings (Steam button → Settings).": "",
|
||||
"On the Frame, place Revive's runtime (FramePort's Revive, or the repack's bundled LibRevive) where the game's Oculus SDK looks for LibOVRRT so it can load a VR runtime (pure runtime substitution — what Revive's LoadLibrary hook does on Windows; that hook doesn't work under Proton on the Frame). It does NOT bypass the Oculus runtime signature check: a game that verifies the runtime's signature will still refuse it. Ignored on this PC (Revive's hook handles it there).": "",
|
||||
"On this PC": "",
|
||||
"On this PC only: lets games that draw with Vulkan themselves (for example The Climb 2) start in AXRB's direct mode.": "",
|
||||
"On this PC, launch the game through Revive's OpenVR backend, which talks straight to SteamVR — the original, most reliable Revive path. On by default for PC installs. Turn it off to use Revive's newer OpenXR backend instead. Ignored on the Frame (which always uses OpenXR).": "",
|
||||
"On this PC, runs Revive through SteamVR (works with more headsets).": "",
|
||||
"On your Frame": "",
|
||||
"on: {names}": "",
|
||||
"One PC game folder": "",
|
||||
"Only for installs on this PC (AXRB): which AXRB runs the game. Automatic picks AXRB's direct mode (AXRB-Emulatorless: the game's arm64 code runs in one Windows process, no Android emulator) for Vulkan games and the Android emulator for OpenGL ES games, since direct mode has no OpenGL ES renderer yet. The other parameters are direct mode's per-game options (AXRB's run_direct_game.ps1): translator (dynarmic, AXRB's default, or digitalis), precompose (AXRB composes the game's projection layers itself; also used by the emulator), and legacy_oculus_driver / quest_identity / controllers_only for unconverted APKs with older Oculus loaders (controllers_only needs legacy_oculus_driver). The Steam Frame ignores this.": "",
|
||||
"Only if 360° pictures show wrongly.": "",
|
||||
"Only if 360° pictures show wrongly: 1 = upside down, 2 = mirrored, 4 = turned around (add values to combine).": "",
|
||||
"Only in builds for this PC (AXRB's direct mode). Direct mode gives the game the Windows graphics driver's Vulkan device, which lacks extensions every Quest has: Android's external memory (VK_KHR_external_memory_fd, VK_ANDROID_external_memory_android_hardware_buffer, VK_EXT_queue_family_foreign) and VK_EXT_fragment_density_map. Engines that call VrApi with Vulkan themselves, such as CryEngine in The Climb 2, refuse to create their device without them (\"CrySystem initialization failed\" right after \"Renderer initialization\"). The game's renderer gets them listed (libfp_vkext.so); AXRB leaves them out again when the device is created.": "",
|
||||
"Only install software from sites you trust. FramePort downloads it from {host} and installs it on your Frame.": "",
|
||||
"Only internal storage": "",
|
||||
"Open": "",
|
||||
@@ -1176,7 +1190,6 @@
|
||||
"Reading the file list from the Frame…": "",
|
||||
"Reading the install link": "",
|
||||
"Reading the new APK": "",
|
||||
"Reading the new version": "",
|
||||
"ready": "",
|
||||
"Ready": "",
|
||||
"Ready to play": "",
|
||||
@@ -1208,6 +1221,7 @@
|
||||
"Remove": "",
|
||||
"Remove converted copies now": "",
|
||||
"Remove from library…": "",
|
||||
"Remove from this PC": "",
|
||||
"Remove localized app names": "",
|
||||
"Remove tag": "",
|
||||
"Remove this theme": "",
|
||||
@@ -1339,6 +1353,7 @@
|
||||
"Select games to install": "",
|
||||
"Set by you.": "",
|
||||
"Set up for FramePort": "",
|
||||
"Set up Quest games on this PC?": "",
|
||||
"Set up with a USB cable": "",
|
||||
"Set up your Frame": "",
|
||||
"Sets Unity QualitySettings antiAliasing to 0. Unity's multisampled render-to-texture path can hang the Frame's GL driver ('zink: DEVICE LOST', for example Sniper Elite VR). Try it for GLES Unity games that freeze or crash the GPU.": "",
|
||||
@@ -1377,6 +1392,7 @@
|
||||
"Shows Batman: Arkham Shadow's cutscenes; set by that game's recipe.": "",
|
||||
"Shows each eye's picture to the other eye. For testing.": "",
|
||||
"Shows models whose shapes come from bone data the Frame's graphics driver misreads (for example BlazeRush's cars). Comes from a game's recipe.": "",
|
||||
"Shows only the game's main picture, without menus or overlays drawn as extra layers.": "",
|
||||
"Shows the Frame's cameras where the game expects passthrough.": "",
|
||||
"Shows the full environment when its upper part is clipped but menu panels look correct.": "",
|
||||
"Shows the headset's view with sound, whatever is running. It plays here where possible, else in mpv or your browser. Stop it when you're done: it costs the game some speed.": "",
|
||||
@@ -1393,7 +1409,7 @@
|
||||
"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 Meta XR Audio's telemetry under x86_64 ARM translation (emulators). Not needed on the Frame, which runs games natively; added by itself to builds for this PC (AXRB) of games with Meta XR Audio.": "",
|
||||
"Skips picture layers the Frame would refuse. Leave it on.": "",
|
||||
"Sleep": "",
|
||||
"Smooth as butter": "",
|
||||
@@ -1437,6 +1453,7 @@
|
||||
"Starts a video stream on the Frame and opens it in an mpv window.": "",
|
||||
"Starts a video stream on the Frame and opens it in your browser.": "",
|
||||
"Starts a video stream on the Frame and shows it here.": "",
|
||||
"Starts the game in SteamVR on this PC, in AXRB's Android emulator.": "",
|
||||
"Starts the game in SteamVR on this PC.": "",
|
||||
"Starts the game on the Frame while nobody wears the headset and reads the log: did it start and draw frames. It can't check the picture.": "",
|
||||
"Starts the game on the Frame — put the headset on.": "",
|
||||
@@ -1600,11 +1617,13 @@
|
||||
"This Frame isn't set up for FramePort yet. Run the setup once.": "",
|
||||
"This Frame's SSH key changed since FramePort last connected (SteamOS was reset, or another device now has its address). If it's your Frame, choose Trust the new key on the Steam Frame page.": "",
|
||||
"This game can't run on the Steam Frame.": "",
|
||||
"This game draws with OpenGL ES. AXRB's emulator copies such pictures to the PC frame by frame, so FramePort renders them at half size: expect about 30 frames per second at best, less with a high SteamVR resolution. Vulkan games don't have this limit.": "",
|
||||
"This game is marked \"Can't run\" on the Steam Frame:": "",
|
||||
"This is a website in an app: it opens in Meta's browser, which the Frame doesn't have. If nothing opens, visit the website in a browser.": "",
|
||||
"This is the example button from frameport.app. Real buttons install real games.": "",
|
||||
"this PC": "",
|
||||
"This PC": "",
|
||||
"This PC (AXRB, experimental)": "",
|
||||
"This PC (for PC VR games)": "",
|
||||
"This PC (Steam and Revive)": "",
|
||||
"This PC's disk is full. Free some space and try again.": "",
|
||||
@@ -1664,9 +1683,11 @@
|
||||
"Uninstall. ": "",
|
||||
"Uninstalled {title}": "",
|
||||
"Uninstalled {title} and deleted its files on this PC ({size})": "",
|
||||
"Unity frame loop diagnostics (log)": "",
|
||||
"Unity frame loop: wait for skipped frames": "",
|
||||
"Unity's built-in Oculus frame loop (frame.unity_oculus_check): before each frame wait, wait up to 50 ms for the last waited frame to begin and skip the wait if it doesn't. Fixes a freeze at the first scene switch (for example Sniper Elite VR); in other games it can make loading screens stutter.": "",
|
||||
"Unity's built-in Oculus frame loop (frame.unity_oculus_check): don't pass Unity's physics-step pose update on, so the hands use the frame's display-time poses. Fixes hands trailing the controllers in some games (for example Sniper Elite VR); in others it makes the hands lag (for example BattleSisters).": "",
|
||||
"Unity's built-in Oculus frame loop (frame.unity_oculus_check): every 5 s, logs how many frame updates, waits, begins and ends went through and which one is in progress (thread, frame, for how long), to see where a game that stops after a few frames waits. No effect on the game.": "",
|
||||
"Unity's built-in Oculus support (Unity 2017–2019) only starts VR when Android has Meta's com.oculus.systemactivities package; without it the game runs as a 2D app (the Android home screen or a black window, for example Accounting+, BattleSisters). Points that package name in libunity.so at \"android\", which always exists. Games on Unity's built-in VR (2017–2018, and 2019 without the Oculus XR Plugin) also get the frame wait their legacy frame loop never makes (libfp_ovrp.so calls ovrp_WaitToBeginFrame before ovrp_Update2; without it no frame starts, the dashboard freezes or the GPU hangs). Per game, the settings ovrp_begin_gate (a freeze at the first scene switch) and ovrp_hold_physics (hands trailing the controllers) switch on two more frame-loop fixes (for example Sniper Elite VR). The shim also counts a newly pressed trigger or A/B/X/Y as a mouse click (Input.GetMouseButtonDown), which Go-era screens wait for (for example Accounting+'s motion warning) and which Lepton never delivers.": "",
|
||||
"Unity's Oculus XR Plugin asks OVRPlugin whether the headset is worn; on the Frame the answer turns to \"not worn\" a few seconds after start. Games that only move the player while the headset is worn then show a frozen body with no head tracking and no controls (for example BONELAB). Reports the headset as worn; taking it off still pauses the game through the session's focus.": "",
|
||||
"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.": "",
|
||||
@@ -1715,7 +1736,6 @@
|
||||
"Update the app only?": "",
|
||||
"Update tools": "",
|
||||
"Update with a newer APK…": "",
|
||||
"Update with a newer version…": "",
|
||||
"Updates": "",
|
||||
"Updates every game marked \"Update ready\", one after another.": "",
|
||||
"Updates the game, frame rate and battery every 5 seconds. Turn off to save the Frame's power.": "",
|
||||
@@ -1782,6 +1802,7 @@
|
||||
"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": "",
|
||||
"Vulkan: list Android's external-memory extensions (this PC)": "",
|
||||
"v{remote} (updates on the next action)": "",
|
||||
"Wait for the game's GPU": "",
|
||||
"Waiting": "",
|
||||
@@ -1822,6 +1843,7 @@
|
||||
"Windows' firewall blocks the Frame from reaching FramePort. Start the setup again and allow the change when Windows asks.": "",
|
||||
"Windows' firewall blocks the Frame on public networks. Set this network to Private in Windows' network settings.": "",
|
||||
"Windows' firewall for WSL blocks the Frame. Show the setup command again and allow the change when Windows asks.": "",
|
||||
"With a resolution scale below 1, the largest eye size the game is told about is the scaled one too, for games that size their eye pictures from the maximum (for example Unity 2017 games). On by default on this PC for OpenGL ES games, whose pictures AXRB's emulator copies pixel by pixel.": "",
|
||||
"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.": "",
|
||||
"Working": "",
|
||||
"Working…": "",
|
||||
@@ -1976,8 +1998,6 @@
|
||||
"{title}: name updated in Steam": "",
|
||||
"{title}: newer APK added": "",
|
||||
"{title}: newer APK added. Press Update on Frame to install it.": "",
|
||||
"{title}: newer version added": "",
|
||||
"{title}: newer version added. Press Update to install it.": "",
|
||||
"{title}: ready": "",
|
||||
"{title}: Steam artwork updated on the Frame": "",
|
||||
"{title}: {line}": "",
|
||||
|
||||
@@ -78,6 +78,9 @@ class Patch:
|
||||
needs_vr: bool = True # only matters for VR apps (hidden for Android apps without VR)
|
||||
# works on a 32-bit game through the a32 bridge (frame.a32_bridge); None = by category (works_on_a32)
|
||||
a32: bool | None = None
|
||||
# also in the build for this PC (AXRB): a fix of the APK itself, not of the Frame/Lepton; "experimental" = kept
|
||||
# there too but not yet seen working in AXRB (pc_selection treats both alike)
|
||||
on_pc: bool | str = False
|
||||
revision: int = 1 # bump when apply() writes something different: installed builds then show "Update on Frame"
|
||||
package_revisions: dict[str, int] = {} # narrower updates to otherwise shared patches
|
||||
|
||||
@@ -101,6 +104,16 @@ class Patch:
|
||||
|
||||
not_applicable_reason: str = ""
|
||||
|
||||
def pc_detect(self, analysis: Analysis) -> bool:
|
||||
"""True when the build for this PC (AXRB) needs the patch although the Steam Frame build doesn't (so it isn't
|
||||
in the recipe, which both builds share): pc_selection adds it."""
|
||||
return False
|
||||
|
||||
def pc_keeps(self, analysis: Analysis) -> bool:
|
||||
"""For an on_pc patch in the recipe: False when the build for this PC leaves it out for this game anyway
|
||||
(pc_selection, given the analysis)."""
|
||||
return True
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool: # apk stage; returns True when something changed
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -152,6 +165,40 @@ def works_on_a32(patch: Patch) -> bool:
|
||||
return patch.category not in ("overport", "frame")
|
||||
|
||||
|
||||
def pc_selection(patches: dict, analysis: Analysis | None = None) -> dict:
|
||||
"""The part of a Quest recipe that goes into the build for this PC (AXRB): OVRPort's patches, the APK fixes
|
||||
marked on_pc, FrameBridge (frame.adapter) with its settings except the Frame-only ones (settings.PC_DENY), the
|
||||
PC values of settings whose Frame default doesn't fit there (settings.PC_VALUES) and, given the analysis, the
|
||||
PC-only patches the game needs (Patch.pc_detect). The Steam Frame runtime's and Lepton's workarounds stay out;
|
||||
AXRB has its own Android and OpenXR runtime. Idempotent: a PC selection selects itself."""
|
||||
load_all()
|
||||
out = {pid: params for pid, params in patches.items()
|
||||
if pid in REGISTRY and (REGISTRY[pid].category == "overport" or REGISTRY[pid].on_pc)}
|
||||
if analysis is not None:
|
||||
out = {pid: params for pid, params in out.items() if REGISTRY[pid].pc_keeps(analysis)}
|
||||
for patch in all_patches():
|
||||
if patch.id not in out and patch.pc_detect(analysis):
|
||||
out[patch.id] = {}
|
||||
if "frame.adapter" in out:
|
||||
from .settings import PC_VALUES
|
||||
|
||||
for key, value in PC_VALUES.items():
|
||||
out[f"adapter.{key}"] = {"value": value}
|
||||
if analysis is not None and "GLES" in (analysis.graphics or ""):
|
||||
# AXRB's emulator sends GLES eye images as pixels (adb reverse, ~100 MB/s, synchronous in xrEndFrame):
|
||||
# Lucky's Tale at Monado's 896x1008 per eye 9 fps, at half size (FrameBridge's minimum) 30 fps
|
||||
scale = (out.get("adapter.scale") or {}).get("value")
|
||||
out["adapter.scale"] = {"value": min(float(scale), GLES_PC_SCALE) if scale else GLES_PC_SCALE}
|
||||
# ... and rejects every GLES frame with more than one layer (no layer batches without Vulkan): FrameBridge
|
||||
# submits the projection layer alone, with the quads after it drawn into it (projection_only=2)
|
||||
out["adapter.projection_only"] = {"value": 2}
|
||||
out["adapter.scale_max"] = {"value": 1} # also for games that size their eyes from the maximum
|
||||
return out
|
||||
|
||||
|
||||
GLES_PC_SCALE = 0.5 # eye-size scale for GLES games in the build for this PC (FrameBridge accepts 0.5-2.0)
|
||||
|
||||
|
||||
def all_patches() -> list[Patch]:
|
||||
load_all()
|
||||
return sorted(REGISTRY.values(), key=lambda p: (CATEGORIES.index(p.category), p.order, p.id))
|
||||
|
||||
@@ -21,6 +21,7 @@ def settings_text(recipe_patches: dict) -> bytes:
|
||||
|
||||
class FrameBridgeAdapter(Patch):
|
||||
id = "frame.adapter"
|
||||
on_pc = True
|
||||
a32 = True # also for 32-bit games on the bridge
|
||||
title = "FrameBridge OpenXR adapter"
|
||||
description = (
|
||||
|
||||
@@ -15,6 +15,7 @@ AVATAR = "libovravatarloader.so"
|
||||
|
||||
class AvatarStub(Patch):
|
||||
id = "frame.avatar_stub"
|
||||
on_pc = True
|
||||
title = "Skip Meta avatars"
|
||||
description = ("Meta's avatar library needs Meta's Horizon app, which the Frame doesn't have; it then stops the "
|
||||
"game at start (\"Failed to launch SystemActivities\", for example BlazeRush). Replaces it with a "
|
||||
|
||||
@@ -125,6 +125,7 @@ def patch_library(data: bytes) -> tuple[bytes, dict | None]:
|
||||
|
||||
class BlazeRush(Patch):
|
||||
id = "frame.blazerush"
|
||||
on_pc = True
|
||||
title = "BlazeRush: controllers without Meta avatars, own shader cache"
|
||||
description = ("BlazeRush 1.0.349 only (Klownicle's guide, GitHub #57). Its menus wait for Meta's avatar, which "
|
||||
"never comes on the Frame, before they create the game's own controllers: the second menu ignored "
|
||||
|
||||
@@ -26,6 +26,7 @@ def patch_dex(dex: Dex) -> list[str]:
|
||||
|
||||
class EosKeychainLoop(Patch):
|
||||
id = "frame.eos_keychain_loop"
|
||||
on_pc = "experimental"
|
||||
title = "Epic Online Services: stop the keychain retry loop"
|
||||
description = ("Epic Online Services (EOS) keeps its login keychain in the app's settings storage. When it can't "
|
||||
"write there, it resets the keychain and tries again without end (for example Beyond Sandbox: a "
|
||||
|
||||
@@ -68,6 +68,7 @@ EXPORTS = (
|
||||
|
||||
class LanguagePacks(Patch):
|
||||
id = "frame.langpacks"
|
||||
on_pc = True
|
||||
title = "Language packs from the game's files"
|
||||
description = ("OVRPort's platform loader answers the Meta Platform SDK's language-pack calls "
|
||||
"(ovr_LanguagePack_GetCurrent/SetCurrent) with 'no request', so a game that asks for its language "
|
||||
@@ -116,6 +117,7 @@ class LanguagePacks(Patch):
|
||||
|
||||
class AssetFiles(Patch):
|
||||
id = "frame.asset_files"
|
||||
on_pc = True
|
||||
title = "Content files from the game's data"
|
||||
description = ("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. "
|
||||
|
||||
@@ -9,6 +9,7 @@ MANIFEST = "AndroidManifest.xml"
|
||||
|
||||
class Launcher(Patch):
|
||||
id = "frame.launcher"
|
||||
on_pc = True
|
||||
a32 = True # also for 32-bit games on the bridge
|
||||
needs_vr = False
|
||||
title = "Launcher category (INFO → LAUNCHER)"
|
||||
@@ -35,6 +36,7 @@ class Launcher(Patch):
|
||||
|
||||
class NoDebuggable(Patch):
|
||||
id = "frame.nodebug"
|
||||
on_pc = True
|
||||
needs_vr = False
|
||||
title = "Clear android:debuggable"
|
||||
description = ("OVRPort marks apps debuggable; that turns on CheckJNI, which aborts some Unreal games on sloppy "
|
||||
@@ -68,6 +70,7 @@ class NoDebuggable(Patch):
|
||||
|
||||
class MetaPermissions(Patch):
|
||||
id = "frame.meta_permissions"
|
||||
on_pc = True
|
||||
title = "Declare Meta-only permissions"
|
||||
description = ("Declares com.oculus.permission.* / horizonos.permission.* that the app uses, so Android grants "
|
||||
"them at install (for example the 'use spatial data' permission of mixed-reality games like "
|
||||
|
||||
@@ -84,6 +84,7 @@ def guard_microphone(data: bytes) -> bytes | None:
|
||||
|
||||
class OvrMicrophone(Patch):
|
||||
id = "frame.ovr_microphone"
|
||||
on_pc = True
|
||||
title = "Fix crash when the game prepares the microphone"
|
||||
description = ("OVRPort's Meta platform library opens the microphone only when it's started, but answers a "
|
||||
"game's question about the microphone buffer size from that not-yet-opened stream: games that ask "
|
||||
|
||||
@@ -105,6 +105,7 @@ def _keystore(package: str) -> bytes:
|
||||
|
||||
class OvrStableUser(Patch):
|
||||
id = "frame.ovr_stable_user"
|
||||
on_pc = True
|
||||
title = "Keep the same Meta user between starts"
|
||||
description = ("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 "
|
||||
|
||||
@@ -23,6 +23,7 @@ def ovr_importers(ws) -> list[str]:
|
||||
|
||||
class OvrTrace(Patch):
|
||||
id = "frame.ovr_trace"
|
||||
on_pc = True
|
||||
title = "Trace Meta platform requests (diagnostics)"
|
||||
description = ("Logs every Meta Platform SDK request the game makes (user, entitlement, cloud saves, ...) and the "
|
||||
"messages that answer them, and every 10 s the requests that never got an answer (logcat tag "
|
||||
|
||||
@@ -29,6 +29,7 @@ class PlatformCompat(Patch):
|
||||
"is skipped.")
|
||||
order = 30
|
||||
default_on = True
|
||||
on_pc = True
|
||||
|
||||
def detect(self, a):
|
||||
return Suggestion(True, "Applied automatically when the OVRPort build needs it.")
|
||||
@@ -52,6 +53,7 @@ class OvrStubs(Patch):
|
||||
"'cannot locate symbol ovr_...'. Online/store features stay unavailable.")
|
||||
order = 31
|
||||
default_on = True
|
||||
on_pc = True # missing Meta platform symbols crash the game in any Android, not only on the Frame
|
||||
|
||||
def detect(self, a):
|
||||
return Suggestion(True, "Applied automatically when the OVRPort build needs it.")
|
||||
|
||||
@@ -45,6 +45,7 @@ class SwapchainLimit(Patch):
|
||||
"16384 px so the Frame's runtime (8192 px max) decides instead.")
|
||||
order = 45
|
||||
default_on = True
|
||||
on_pc = True # the guard is in OVRPort's dispatcher, which every OVRPort build carries
|
||||
|
||||
def detect(self, a):
|
||||
video = sorted(lib for lib in a.libs if lib.lower().startswith(VIDEO_LIBS))
|
||||
|
||||
@@ -51,6 +51,7 @@ def patch_metaxr_telemetry(data: bytes) -> bytes | None:
|
||||
|
||||
class MetaXrTelemetry(Patch):
|
||||
id = "frame.metaxr_telemetry"
|
||||
on_pc = True
|
||||
title = "Meta XR Audio: skip Quest telemetry class"
|
||||
description = ("Unreal builds of Meta XR Audio abort ('ClassNotFoundException com.oculus.os.AnalyticsEvent') on "
|
||||
"non-Quest devices. Patches that one JNI FindClass call so the library continues (for example NOPE "
|
||||
@@ -79,6 +80,7 @@ class MetaXrTelemetry(Patch):
|
||||
|
||||
class OculusOsStubs(Patch):
|
||||
id = "frame.oculusos"
|
||||
on_pc = True
|
||||
title = "Oculus OS telemetry class stubs"
|
||||
description = ("Adds no-op com.oculus.os.AnalyticsEvent / UnifiedTelemetryLogger classes for native code that "
|
||||
"looks them up through the app class loader (for example Nano, NOPE Challenge).")
|
||||
|
||||
@@ -73,6 +73,7 @@ def oculus_model_checks(data: bytes) -> tuple[bytes, int]:
|
||||
|
||||
class UnityOculusCheck(Patch):
|
||||
id = "frame.unity_oculus_check"
|
||||
on_pc = True
|
||||
title = "Unity: start VR without Meta's system apps"
|
||||
description = ("Unity's built-in Oculus support (Unity 2017–2019) only starts VR when Android has Meta's "
|
||||
"com.oculus.systemactivities package; without it the game runs as a 2D app (the Android home "
|
||||
@@ -112,7 +113,6 @@ class UnityOculusCheck(Patch):
|
||||
return a.engine == "Unity" and "libOVRPlugin.so" in a.libs and bool((a.extra or {}).get("unity_oculus_check"))
|
||||
|
||||
def detect(self, a):
|
||||
# not narrowed by libOculusXRPlugin.so: a verified game ships it and still needs this fix
|
||||
if self.applies(a):
|
||||
return Suggestion(True, "Unity with built-in Oculus support: it checks for Meta's system apps before "
|
||||
"starting VR, else it runs as a 2D app (for example Accounting+, BattleSisters).")
|
||||
|
||||
@@ -12,7 +12,7 @@ frame.unity_text_input).
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import Suggestion, register
|
||||
from .unity_text_input import RET_FALSE, Il2cppReturnPatch, can_rewrite_il2cpp
|
||||
from .unity_text_input import RET_FALSE, Il2cppReturnPatch
|
||||
|
||||
RET_ZERO = RET_FALSE # mov w0, #0 ; ret
|
||||
|
||||
@@ -40,8 +40,7 @@ class UnityRuntimeMsaa(Il2cppReturnPatch):
|
||||
return a.engine == "Unity" and "libil2cpp.so" in a.libs and not a.only_32bit
|
||||
|
||||
def detect(self, a):
|
||||
if (self.applies(a) and can_rewrite_il2cpp(a)
|
||||
and (a.extra or {}).get("ovr_runtime_msaa") and "GLES" in a.graphics):
|
||||
if self.applies(a) and (a.extra or {}).get("ovr_runtime_msaa") and "GLES" in a.graphics:
|
||||
return Suggestion(True, "GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled "
|
||||
"render-to-texture can hang the Frame's GPU.")
|
||||
return None
|
||||
@@ -72,6 +71,14 @@ class UnityMultiPass(Il2cppReturnPatch):
|
||||
def detect(self, a):
|
||||
return None # only for a game that shows the symptom (catalog recipe or the user's choice)
|
||||
|
||||
# AXRB's emulator (gfxstream GLES) lists GL_OVR_multiview2, but its encoder finds a multiview framebuffer
|
||||
# incomplete and drops every draw into it (logcat: "GL2Encoder.cpp:s_glDrawElements ... GL error 0x506
|
||||
# checkFramebufferCompleteness"; Lucky's Tale, 2026-10-15: both eyes black). Each eye in its own pass draws.
|
||||
on_pc = "experimental"
|
||||
|
||||
def pc_detect(self, a):
|
||||
return self.applies(a) and "GLES" in a.graphics and bool((a.extra or {}).get("oculus_xr_plugin"))
|
||||
|
||||
|
||||
class UnityNoOverlayCopy(Il2cppReturnPatch):
|
||||
id = "frame.unity_no_overlay_copy"
|
||||
|
||||
@@ -85,19 +85,6 @@ def patch_field_loads(lib: bytes, method: tuple[int, int], field_offset: int, va
|
||||
ALL_TARGETS: dict[str, list[str]] = {} # every IL2CPP patch's methods: one Cpp2IL run (and cache entry) serves all
|
||||
|
||||
|
||||
def can_rewrite_il2cpp(a) -> bool:
|
||||
"""Automatic return patches need AArch64 code and metadata Cpp2IL can read.
|
||||
|
||||
Unity 2017's metadata is unreadable by our Cpp2IL workflow (AGENTS.md, Accounting+). Unknown versions keep the
|
||||
existing fallback: apply() reads the version from the APK. This does not restrict manually selected patches.
|
||||
"""
|
||||
try:
|
||||
major = int(str((a.extra or {}).get("unity_version") or "0").split(".")[0])
|
||||
except ValueError:
|
||||
major = 0
|
||||
return "arm64-v8a" in a.abis and not 0 < major < 2018
|
||||
|
||||
|
||||
class Il2cppReturnPatch(Patch):
|
||||
"""Make IL2CPP methods return a constant (targets), found per game with Cpp2IL (analysis/il2cpp.py)."""
|
||||
targets: dict[str, dict[str, bytes]] = {}
|
||||
@@ -160,6 +147,7 @@ class Il2cppReturnPatch(Patch):
|
||||
|
||||
class UnityTextInput(Il2cppReturnPatch):
|
||||
id = "frame.unity_text_input"
|
||||
on_pc = True
|
||||
title = "Make Unity text fields work"
|
||||
description = ("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 "
|
||||
@@ -177,7 +165,7 @@ class UnityTextInput(Il2cppReturnPatch):
|
||||
return a.engine == "Unity" and "libil2cpp.so" in a.libs and bool((a.extra or {}).get("text_fields"))
|
||||
|
||||
def detect(self, a):
|
||||
if self.applies(a) and can_rewrite_il2cpp(a):
|
||||
if self.applies(a):
|
||||
kinds = " and ".join((a.extra or {}).get("text_fields"))
|
||||
return Suggestion(True, f"Unity app with text fields ({kinds}): they close at once on the Frame because "
|
||||
"it has no system keyboard.")
|
||||
|
||||
@@ -24,6 +24,7 @@ def heavy_native_libs(a) -> list[str]:
|
||||
|
||||
class UnityTlsKeyPool(Patch):
|
||||
id = "frame.unity_tls_key_pool"
|
||||
on_pc = "experimental"
|
||||
title = "Unity: share thread keys (games that run out of them)"
|
||||
description = (
|
||||
"Android gives a process 128 thread keys (pthread keys, per-thread storage slots). Unity 6 takes about 40, "
|
||||
@@ -40,13 +41,6 @@ class UnityTlsKeyPool(Patch):
|
||||
def detect(self, a):
|
||||
heavy = heavy_native_libs(a)
|
||||
if self.applies(a) and _unity_major(a) >= 6000 and heavy:
|
||||
# Firebase's Firestore and Gecko both consume keys alongside Unity 6 and the Frame's graphics/XR
|
||||
# runtime. This is the documented exhaustion case (AGENTS.md, #198), rather than just any Firebase
|
||||
# library: keep a lone SDK as an optional diagnostic fix below.
|
||||
if "libFirebaseCppFirestore.so" in heavy and "libxul.so" in heavy:
|
||||
return Suggestion(True, "Unity 6 with Firebase Firestore and Gecko: these native runtimes can "
|
||||
"exhaust Android's 128 thread keys alongside the Frame's graphics and XR "
|
||||
"libraries. Pool Unity's keys to leave room for them.")
|
||||
return Suggestion(False, f"Unity 6 with {', '.join(heavy)}: turn on if the game aborts at start with "
|
||||
"\"cannot create thread specific key\" (for example Wander).")
|
||||
return None
|
||||
|
||||
@@ -60,6 +60,7 @@ def patch_dex(dex: Dex, package: str) -> list[str]:
|
||||
|
||||
class UnrealSkipObbCheck(Patch):
|
||||
id = "frame.unreal_skip_obb_check"
|
||||
on_pc = True
|
||||
title = "Unreal: start without the OBB check"
|
||||
description = ("Unreal games can check their whole data file (.obb) before starting (bVerifyOBBOnStartUp). On "
|
||||
"the Frame that check's progress and error screens are invisible (Lepton runs VR games without "
|
||||
@@ -96,6 +97,7 @@ register(UnrealSkipObbCheck)
|
||||
|
||||
class UnrealSkipObbDelivery(Patch):
|
||||
id = "frame.unreal_skip_obb_delivery"
|
||||
on_pc = True
|
||||
title = "Unreal: skip the OBB delivery check"
|
||||
description = ("Unreal's Java OBB delivery check can leave a game on an invisible DownloaderActivity screen "
|
||||
"even with the manifest CRC check off (e.g. Into The Radius). Makes known Unreal "
|
||||
|
||||
@@ -49,6 +49,7 @@ def missing_lookups(lookups, plugin_exports) -> list[str]:
|
||||
|
||||
class UnrealOvrpEntrypoints(Patch):
|
||||
id = "frame.unreal_ovrp_entrypoints"
|
||||
on_pc = True
|
||||
title = "Unreal: stand-ins for OVRPlugin functions OVRPort lacks"
|
||||
description = ("Unreal's Oculus module looks up every OVRPlugin function it was built against and starts no VR "
|
||||
"when one is missing; OVRPort's OpenXR OVRPlugin lacks some old ones (for example ovrp_GetPTWNear, "
|
||||
|
||||
@@ -182,6 +182,7 @@ class _SantaCruzGate(Patch):
|
||||
|
||||
class QuestPrecompileDone(_SantaCruzGate):
|
||||
id = "frame.unreal_quest_precompile"
|
||||
on_pc = True
|
||||
title = "Unreal: don't wait for the Quest shader precompile"
|
||||
description = ("ILMxLAB's Unreal games (for example Vader Immortal) wait on their loading card until "
|
||||
"UVRUtils::GetQuestShaderPrecompilePercent reaches 100 %. The precompile only starts on a Quest "
|
||||
@@ -201,6 +202,7 @@ class QuestPrecompileDone(_SantaCruzGate):
|
||||
|
||||
class QuestKeyMap(_SantaCruzGate):
|
||||
id = "frame.unreal_quest_keymap"
|
||||
on_pc = True
|
||||
title = "Unreal: Quest controller bindings (RPOC key map)"
|
||||
description = ("ILMxLAB's Unreal games (for example Vader Immortal) bind their controls through URPOCKeyMapManager"
|
||||
"Component, which for an Oculus headset picks the Quest (\"SantaCruz\") or the Gear VR key set by "
|
||||
|
||||
@@ -138,6 +138,7 @@ def _engine(ws) -> str | None:
|
||||
|
||||
class UnrealThumbTouch(Patch):
|
||||
id = "frame.unreal_thumb_touch"
|
||||
on_pc = True
|
||||
title = "Unreal: thumb follows the capacitive touches"
|
||||
description = ("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 "
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Android-only Vulkan device extensions for builds for this PC (native/vkext): AXRB's direct mode, The Climb 2."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...analysis import elf
|
||||
from ..base import ApkContext, Patch, register
|
||||
from . import artifact
|
||||
|
||||
HELPER = "libfp_vkext.so"
|
||||
RENAMES = {"vkEnumerateDeviceExtensionProperties": "fpEnumerateDeviceExtensionProperties"}
|
||||
# not the game's renderer: XR/Meta/FramePort libraries that may import the function too
|
||||
NOT_ENGINE = ("libopenxr", "libOVR", "libovr", "libvrapi", "libfp_", "libframe", "libVkLayer", "libc++")
|
||||
|
||||
|
||||
class VulkanAndroidExtensions(Patch):
|
||||
id = "frame.vk_android_extensions"
|
||||
on_pc = True
|
||||
title = "Vulkan: list Android's external-memory extensions (this PC)"
|
||||
description = (
|
||||
"Only in builds for this PC (AXRB's direct mode). Direct mode gives the game the Windows graphics driver's "
|
||||
"Vulkan device, which lacks extensions every Quest has: Android's external memory "
|
||||
"(VK_KHR_external_memory_fd, VK_ANDROID_external_memory_android_hardware_buffer, "
|
||||
"VK_EXT_queue_family_foreign) and VK_EXT_fragment_density_map. Engines that call VrApi with Vulkan "
|
||||
"themselves, such as CryEngine in The Climb 2, refuse to create their device without them (\"CrySystem "
|
||||
"initialization failed\" right after \"Renderer initialization\"). The game's renderer gets them listed "
|
||||
"(libfp_vkext.so); AXRB leaves them out again when the device is created.")
|
||||
order = 73
|
||||
|
||||
def applies(self, a): # never in a Steam Frame recipe: its driver has these extensions
|
||||
return False
|
||||
|
||||
def pc_detect(self, a) -> bool:
|
||||
return a.direct_vrapi and a.graphics.startswith("Vulkan") and "arm64-v8a" in a.abis
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
if ws.abi != "arm64-v8a":
|
||||
return False
|
||||
changed = []
|
||||
for lib in ws.libs():
|
||||
if lib.startswith(NOT_ENGINE):
|
||||
continue
|
||||
data = ws.read_lib(lib)
|
||||
if not elf.is_elf(data) or not set(RENAMES) & elf.dyn_symbols(data, defined=False):
|
||||
continue
|
||||
patched, renamed = elf.rename_imports(data, RENAMES)
|
||||
if renamed:
|
||||
ws.put(ws.lib(lib), elf.add_needed(patched, HELPER))
|
||||
changed.append(lib)
|
||||
if not changed:
|
||||
return False
|
||||
ws.put(ws.lib(HELPER), artifact(ws.abi, HELPER))
|
||||
ctx.notes.append(f"{', '.join(changed)}: Vulkan device extensions through {HELPER}")
|
||||
return True
|
||||
|
||||
def validate(self, ctx: ApkContext):
|
||||
ws = ctx.ws
|
||||
if ws.abi != "arm64-v8a" or not ws.has(ws.lib(HELPER)):
|
||||
return []
|
||||
return [("Android Vulkan extensions listed", True, HELPER)]
|
||||
|
||||
|
||||
register(VulkanAndroidExtensions)
|
||||
@@ -30,6 +30,7 @@ def engine_libs(ws, analysis) -> list[str]:
|
||||
|
||||
class VulkanSanitize(Patch):
|
||||
id = "frame.vk_sanitize"
|
||||
on_pc = "experimental"
|
||||
title = "Vulkan: drop invalid render-pass pointers"
|
||||
description = ("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 "
|
||||
|
||||
@@ -10,6 +10,7 @@ SHIM = "libglshim.so"
|
||||
|
||||
class VrApiBridge(Patch):
|
||||
id = "frame.vrapi_bridge"
|
||||
on_pc = True # The Climb 2 renders in AXRB's direct mode (2026-10-16, headless)
|
||||
title = "VrApi → OpenXR bridge"
|
||||
description = (
|
||||
"Replaces libvrapi.so with a VrApi→OpenXR bridge (Android-XR-Bridge/OVRPort fork, GPL-3.0, patched for the "
|
||||
@@ -53,6 +54,13 @@ class GlShim(Patch):
|
||||
order = 61
|
||||
requires = ("frame.vrapi_bridge",)
|
||||
experimental = True
|
||||
# also in builds for this PC: its eglGetProcAddress wrapper hands out GPU timer-query stand-ins when the driver
|
||||
# has none (AXRB's emulator: Path of the Warrior crashed on a NULL glGenQueriesEXT), and hiding multiview helps
|
||||
# there too (gfxstream drops draws into multiview framebuffers); its GLSL fixes are additive
|
||||
on_pc = "experimental"
|
||||
|
||||
def pc_keeps(self, a):
|
||||
return "GLES" in a.graphics # Vulkan VrApi games (The Climb 2) go to AXRB's direct mode without it
|
||||
|
||||
def detect(self, a):
|
||||
if a.direct_vrapi and a.uses_glad_gl and "GLES" in a.graphics and "arm64-v8a" in a.abis:
|
||||
@@ -86,6 +94,7 @@ class GlShim(Patch):
|
||||
|
||||
|
||||
class UnityGlShim(GlShim):
|
||||
on_pc = False # its MSRTT stand-ins work around Zink; not tried in AXRB
|
||||
id = "frame.unity_gl_shim"
|
||||
title = "GL shim for Unity (no multisampled render-to-texture)"
|
||||
description = (
|
||||
@@ -109,6 +118,7 @@ class UnityGlShim(GlShim):
|
||||
|
||||
|
||||
class UnrealGlShim(GlShim):
|
||||
on_pc = False
|
||||
id = "frame.unreal_gl_shim"
|
||||
title = "GL shim for Unreal (no multisampled render-to-texture)"
|
||||
description = (
|
||||
|
||||
@@ -17,6 +17,7 @@ VRAPI = "libvrapi.so"
|
||||
|
||||
class VrApiStub(Patch):
|
||||
id = "frame.vrapi_stub"
|
||||
on_pc = True
|
||||
title = "Quiet Meta's VrApi loader (OpenXR games)"
|
||||
description = ("Some OpenXR games still ship Meta's VrApi loader and call it a few times without starting VrApi "
|
||||
"(for example Jurassic World Aftermath); Meta's loader then stops the game (\"vrapi_SetPropertyInt "
|
||||
|
||||
@@ -56,7 +56,7 @@ OVERPORT_PATCHES = [
|
||||
"builds (the stable CLI lists it but can't apply it)."),
|
||||
("patch_disable_meta_xr_audio_telemetry", "Disable Meta XR Audio telemetry", False,
|
||||
"Skips Meta XR Audio's telemetry under x86_64 ARM translation (emulators). Not needed on the Frame, which runs "
|
||||
"games natively."),
|
||||
"games natively; added by itself to builds for this PC (AXRB) of games with Meta XR Audio."),
|
||||
("patch_ac_nexus_no_appsw_72", "AC Nexus: no AppSW at 72 Hz", False,
|
||||
"Assassin's Creed Nexus (build 207706 only): turns off application space warp and runs at 72 Hz."),
|
||||
("patch_ac_nexus_no_appsw_90", "AC Nexus: no AppSW at 90 Hz", False,
|
||||
@@ -70,6 +70,8 @@ AC_NEXUS_BUILD = "MAIN.450412.207706.final" # OVRPort's AC Nexus patches check
|
||||
# patches that fail the whole overport run when used outside their game/build: a catalog recipe only gets them where
|
||||
# applies() is true (recommend/engine.py)
|
||||
STRICT = {"patch_ac_nexus_no_appsw_72", "patch_ac_nexus_no_appsw_90", "patch_vrapi_openxr"}
|
||||
# patches only builds for this PC (AXRB) get, added by base.pc_selection when the game needs them (pc_detect)
|
||||
PC_ADDED = {"patch_disable_meta_xr_audio_telemetry"}
|
||||
DEFAULT_OVERPORT = [pid for pid, _, default, _ in OVERPORT_PATCHES if default]
|
||||
|
||||
|
||||
@@ -81,6 +83,7 @@ class OverportPatch(Patch):
|
||||
self.id, self.title, self.default_on, self.description = pid, title, default, detail
|
||||
self.conflicts = CONFLICTS.get(pid, ())
|
||||
self.strict = pid in STRICT
|
||||
self.pc_added = pid in PC_ADDED
|
||||
|
||||
def detect(self, analysis: Analysis) -> Suggestion | None:
|
||||
from . import applicability as ap
|
||||
@@ -122,6 +125,13 @@ class OverportPatch(Patch):
|
||||
rule = rules.get(self.id)
|
||||
return rule(analysis) if rule else True
|
||||
|
||||
def pc_detect(self, analysis: Analysis) -> bool:
|
||||
"""The build for this PC: AXRB runs the game's arm64 code through translation on x86_64, where OVRPort's
|
||||
telemetry patch belongs (the Frame recipe never has it: applies() is False there)."""
|
||||
from . import applicability as ap
|
||||
|
||||
return self.pc_added and bool(ap.meta_audio_libs(analysis))
|
||||
|
||||
|
||||
STRINGS_URL = "https://raw.githubusercontent.com/ovrport/app/HEAD/composeApp/src/commonMain/composeResources/values/strings.xml"
|
||||
|
||||
|
||||
@@ -42,6 +42,15 @@ SETTINGS = [
|
||||
"Hides XR_FB_space_warp, so the game renders every frame itself instead of half of them plus motion vectors "
|
||||
"(Application SpaceWarp). For games whose picture flickers or smears on the Frame (for example Unreal Engine 5 "
|
||||
"games such as Into The Radius 2)."),
|
||||
("projection_only", "int", 0, "Main picture only",
|
||||
"Frames that carry more than the game's main picture (menus or overlays as extra layers) submit the main "
|
||||
"picture alone. For runtimes that refuse such frames: AXRB's emulator on this PC rejects every multi-layer "
|
||||
"OpenGL ES frame. The extra layers don't show; 2 (OpenGL ES games, the default on this PC) draws the flat "
|
||||
"panels that come after the main picture into it first, so menus and messages drawn as panels still show."),
|
||||
("scale_max", "int", 0, "Cap the eye size",
|
||||
"With a resolution scale below 1, the largest eye size the game is told about is the scaled one too, for games "
|
||||
"that size their eye pictures from the maximum (for example Unity 2017 games). On by default on this PC for "
|
||||
"OpenGL ES games, whose pictures AXRB's emulator copies pixel by pixel."),
|
||||
("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)."),
|
||||
@@ -232,6 +241,10 @@ SETTINGS = [
|
||||
"Unity's built-in Oculus frame loop (frame.unity_oculus_check): don't pass Unity's physics-step pose update on, "
|
||||
"so the hands use the frame's display-time poses. Fixes hands trailing the controllers in some games (for "
|
||||
"example Sniper Elite VR); in others it makes the hands lag (for example BattleSisters)."),
|
||||
("ovrp_trace", "int", 0, "Unity frame loop: diagnostics (log)",
|
||||
"Unity's built-in Oculus frame loop (frame.unity_oculus_check): every 5 s, logs how many frame updates, waits, "
|
||||
"begins and ends went through and which one is in progress (thread, frame, for how long), to see where a game "
|
||||
"that stops after a few frames waits. No effect on the game."),
|
||||
("gl_hide_multiview", "int", 1, "GL shim: hide multiview",
|
||||
"GL shim only: hide GL_OVR_multiview so all passes use single-view shaders. For GLES games whose multiview "
|
||||
"shaders fail on single-view render targets (for example Path of the Warrior)."),
|
||||
@@ -376,6 +389,12 @@ UI: dict[str, dict] = {
|
||||
"hide_space_warp": dict(group="troubleshooting", level="advanced", label="Turn off space warp",
|
||||
help="For games whose picture flickers or smears (for example Into The Radius 2).",
|
||||
control=("switch",)),
|
||||
"projection_only": dict(group="troubleshooting", level="advanced", label="Main picture only",
|
||||
help="Shows only the game's main picture, without menus or overlays drawn as extra layers.",
|
||||
control=("switch",)),
|
||||
"scale_max": dict(group="troubleshooting", level="advanced", label="Cap the eye size",
|
||||
help="Keeps games from rendering larger than the resolution scale allows.",
|
||||
control=("switch",)),
|
||||
"swapchain_fix": dict(group="troubleshooting", level="advanced", label="Retry refused picture formats",
|
||||
help="Lets OpenGL ES games start when the Frame refuses their picture format. Leave it on.",
|
||||
control=("switch",)),
|
||||
@@ -431,6 +450,8 @@ 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",)),
|
||||
"ovrp_trace": dict(group="troubleshooting", level="advanced", label="Unity frame loop diagnostics (log)",
|
||||
help="Logs where an older Unity game's frame loop waits. No effect on the game.", 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",)),
|
||||
@@ -450,6 +471,19 @@ 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 = ("controller_models", "haptic_fix", "surface_native")
|
||||
# Settings left out of the build for this PC (AXRB, base.pc_selection); every other one rides along in
|
||||
# libframe_settings.so, since FrameBridge wraps OVRPort's loader the same way there (scale, haptic_fix, pose_time_fix,
|
||||
# rect_clamp, ...). Left out: workarounds for the Steam Frame's own runtime or headset (sync_guard: a race in the
|
||||
# Frame's vrclient.so; focus_hold: the Frame's flickering wear sensor; controller_models: models the agent converts
|
||||
# from the Frame's SteamVR at install) and settings read only by libraries the PC build doesn't get (the GL shim and
|
||||
# the Zink shader layer, which fix Mesa/Zink on the Frame). Leaving them out also keeps a change to them from marking
|
||||
# the PC build outdated. controller_fix stays: it is the game's choice (0 = hand tracking), and its Touch mapping of
|
||||
# /valve/ and generic controller profiles helps wherever SteamVR controllers show up.
|
||||
PC_DENY = frozenset({"sync_guard", "focus_hold", "focus_hold_ms", "controller_models", "zink_shader_fix",
|
||||
"zink_shader_dump", "gl_hide_msrtt", "gl_hide_multiview", "gl_int_attribs", "gl_highp_markers",
|
||||
"gl_mv_debug"})
|
||||
# PC values of settings whose built-in default is for the Frame (FrameBridge's focus_hold is on unless set to 0)
|
||||
PC_VALUES = {"focus_hold": 0}
|
||||
|
||||
|
||||
class AdapterSetting(Patch):
|
||||
@@ -465,6 +499,7 @@ class AdapterSetting(Patch):
|
||||
self.default = default
|
||||
self.revision = REVISIONS.get(key, 1)
|
||||
self.a32 = key not in XRSHIM_SETTINGS
|
||||
self.on_pc = key not in PC_DENY
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
@@ -486,11 +521,6 @@ class AdapterSetting(Patch):
|
||||
"Tale, BattleSisters): vibrations are converted before they reach it.",
|
||||
{"value": 1})
|
||||
if (self.key == "pose_time_fix" and a.engine == "Unity" and "libOVRPlugin.so" in a.libs
|
||||
# Absence of the Oculus XR Plugin isn't proof of Unity's built-in VR: Unity OpenXR games can
|
||||
# bundle OVRPlugin too. The system-app check in libunity is positive evidence of built-in Oculus
|
||||
# (analysis/detect.py, frame/unity_oculus_check.py). Older analyses lack this signal; converted
|
||||
# inputs may have had the check rewritten to "android", so keep the legacy fallback for those.
|
||||
and (a.extra.get("unity_oculus_check", True) or a.is_overport_output)
|
||||
and not a.extra.get("oculus_xr_plugin") and "libOculusXRPlugin.so" not in a.libs):
|
||||
return Suggestion(True, "Unity's built-in Oculus support reads controller poses through OVRPlugin at its "
|
||||
"monotonic \"now\", seconds in the past on the Frame: they are located at the "
|
||||
@@ -542,6 +572,7 @@ class AdapterSetting(Patch):
|
||||
"gl_mv_debug": lambda a: bool((a.extra or {}).get("gl_multiview_libs")) and ap.is_gles(a),
|
||||
"ovrp_begin_gate": lambda a: a.engine == "Unity" and "libOVRPlugin.so" in a.libs,
|
||||
"ovrp_hold_physics": lambda a: a.engine == "Unity" and "libOVRPlugin.so" in a.libs,
|
||||
"ovrp_trace": lambda a: a.engine == "Unity" and "libOVRPlugin.so" in a.libs,
|
||||
"vk_shader_fix": lambda a: a.engine == "Unreal", # read by the Vulkan shim, which only Unreal games get
|
||||
"vk_hide_fdm": lambda a: a.engine == "Unreal", # Unreal's Vulkan isn't always detected (Metro Awakening)
|
||||
"vk_query_slots": lambda a: a.engine == "Unreal" and ap.is_vulkan(a),
|
||||
@@ -826,6 +857,33 @@ class TextInputWindow(Patch):
|
||||
ctx.flatscreen = True
|
||||
|
||||
|
||||
class AxrbBackend(Patch):
|
||||
id = "pc.axrb_backend"
|
||||
title = "AXRB mode on this PC"
|
||||
description = ("Only for installs on this PC (AXRB): which AXRB runs the game. Automatic picks AXRB's direct mode "
|
||||
"(AXRB-Emulatorless: the game's arm64 code runs in one Windows process, no Android emulator) for "
|
||||
"Vulkan games and the Android emulator for OpenGL ES games, since direct mode has no OpenGL ES "
|
||||
"renderer yet. The other parameters are direct mode's per-game options (AXRB's "
|
||||
"run_direct_game.ps1): "
|
||||
"translator (dynarmic, AXRB's default, or digitalis), precompose (AXRB composes the game's "
|
||||
"projection layers itself; also used by the emulator), and legacy_oculus_driver / quest_identity / "
|
||||
"controllers_only for unconverted APKs with older Oculus loaders (controllers_only needs "
|
||||
"legacy_oculus_driver). The Steam Frame ignores this.")
|
||||
category = "device"
|
||||
stage = "install"
|
||||
on_pc = True # read by targets/pc_android.py; nothing on the Frame
|
||||
params = [Param("backend", "str", "", "direct or emulator (empty = automatic)"),
|
||||
Param("translator", "str", "", "dynarmic or digitalis (direct mode; empty = AXRB's default)"),
|
||||
Param("precompose", "bool", False, "AXRB composes projection layers itself"),
|
||||
Param("legacy_oculus_driver", "bool", False, "direct mode: provide com.oculus.systemdriver"),
|
||||
Param("quest_identity", "bool", False, "direct mode: report a Quest 3 device"),
|
||||
Param("controllers_only", "bool", False, "direct mode: hide hand tracking (needs legacy_oculus_driver)")]
|
||||
CHOICES = (("", "Automatic"), ("direct", "Direct mode (no emulator)"), ("emulator", "Android emulator"))
|
||||
|
||||
def install(self, ctx: InstallContext) -> None:
|
||||
pass # a choice for this PC only
|
||||
|
||||
|
||||
for _spec in SETTINGS:
|
||||
register(AdapterSetting(*_spec))
|
||||
register(DeviceFiles)
|
||||
@@ -836,3 +894,4 @@ register(SteamGamepad)
|
||||
register(TextInputWindow)
|
||||
register(DisplayMode)
|
||||
register(Foveation)
|
||||
register(AxrbBackend)
|
||||
@@ -103,6 +103,8 @@ SUMMARIES = {
|
||||
"frame.avatar_stub": "Lets games start that would stop at Meta's avatars (for example BlazeRush). No Meta "
|
||||
"avatars.",
|
||||
"frame.vrapi_bridge": "Lets games built on Meta's oldest VR interface run on the Frame.",
|
||||
"frame.vk_android_extensions": "On this PC only: lets games that draw with Vulkan themselves (for example The "
|
||||
"Climb 2) start in AXRB's direct mode.",
|
||||
"frame.blazerush": "Makes BlazeRush's menus and controllers work without Meta's avatars, and lets its shader "
|
||||
"fixes take effect.",
|
||||
"frame.a32_bridge": "Runs a 32-bit game (Gear VR, Oculus Go, early Quest) on the Frame, which only runs "
|
||||
@@ -134,6 +136,8 @@ SUMMARIES = {
|
||||
"keeps working.",
|
||||
"device.display_mode": "Choose Flat window if a phone or tablet app shows nothing in the headset, VR if a VR app "
|
||||
"opens as a flat window.",
|
||||
"pc.axrb_backend": "For this PC only: run the game in AXRB's direct mode (no Android emulator) or in AXRB's "
|
||||
"Android emulator. Automatic uses direct mode for Vulkan games.",
|
||||
"device.steam_gamepad": "Lets the game use your controllers as a gamepad instead of a pointer, for 2D games with "
|
||||
"touch controls (for example SDL games).",
|
||||
"device.lepton_env": "Extra settings for the Android container (for testing).",
|
||||
|
||||
+76
-141
@@ -137,25 +137,18 @@ def _existing_rift(folder: Path) -> dict | None:
|
||||
|
||||
|
||||
def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe: str | None = None,
|
||||
force: bool = False, art: bool = False, replace: dict | None = None) -> dict | None:
|
||||
force: bool = False, art: bool = False) -> dict | None:
|
||||
"""Analyze one Rift game folder and store it. Unchanged folders (same program, size, date) are not analyzed again;
|
||||
the user's exe choice, recipe and tags survive rescans. Returns None for folders without a VR runtime (unless
|
||||
force). replace: the library entry this folder is a newer version of (replace_rift_game): it keeps its package and
|
||||
name."""
|
||||
force)."""
|
||||
from .analysis import rift
|
||||
from .recommend import catalog
|
||||
|
||||
folder = Path(folder)
|
||||
if replace is None and not force and any(str(folder) in (g.get("replaced_dirs") or []) for g in library.games()
|
||||
if g.get("kind") == "rift"):
|
||||
if reporter: # the older copy of a game updated from another folder: a rescan must not bring it back
|
||||
reporter.log(f"skipped {folder.name}: an older version of a game that was updated from another folder")
|
||||
return None
|
||||
old = replace or _existing_rift(folder)
|
||||
old = _existing_rift(folder)
|
||||
old_extra = ((old or {}).get("analysis") or {}).get("extra") or {}
|
||||
forced = exe is not None or replace is not None # set_exe / a newer version: always analyze
|
||||
if replace is None:
|
||||
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
|
||||
forced = exe is not None # set_exe: always analyze with the user's choice
|
||||
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
|
||||
if old and not forced and old_extra.get("fingerprint") and \
|
||||
old_extra["fingerprint"] == rift.fingerprint(folder, old.get("exe") or ""):
|
||||
if reporter:
|
||||
@@ -184,9 +177,7 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
|
||||
suggested=library.recipe_to_dict(recipe))
|
||||
if not old or (old.get("recipe") or {}).get("source") != "user":
|
||||
fields.update(recipe=library.recipe_to_dict(recipe), status=recipe.status)
|
||||
if replace is not None:
|
||||
pass # a newer version of the game: its name stays
|
||||
elif not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
|
||||
if not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
|
||||
fields["title"] = recipe.title or a.label # (store-matched titles are kept)
|
||||
elif old.get("title_locked") and old.get("art_source") not in ("oculusdb", "meta", "steam"):
|
||||
fields["auto_title"] = recipe.title or a.label # the user's name stays; a reset brings this one
|
||||
@@ -385,6 +376,24 @@ def is_linux(entry: dict) -> bool:
|
||||
return entry.get("kind") == "linux"
|
||||
|
||||
|
||||
def pc_installable(entry: dict) -> bool:
|
||||
"""Can go on this PC: Rift games (Revive) and Android OpenXR games (Quest / Khronos loader; AXRB, experimental);
|
||||
not Linux/Windows/2D apps."""
|
||||
if entry.get("kind") == "rift":
|
||||
return True
|
||||
if entry.get("kind"):
|
||||
return False
|
||||
return (((entry.get("analysis") or {}).get("extra") or {}).get("vr_kind") or "quest") in ("quest", "openxr")
|
||||
|
||||
|
||||
def pc_gles(entry: dict) -> bool:
|
||||
"""A Quest game that draws with OpenGL ES: on this PC it runs in AXRB's emulator at reduced resolution and frame
|
||||
rate (eye images copied as pixels; the PC build halves the eye size). Library fields only."""
|
||||
if entry.get("kind"):
|
||||
return False
|
||||
return "GLES" in ((entry.get("analysis") or {}).get("graphics") or "")
|
||||
|
||||
|
||||
def analysis_warnings(entry: dict) -> list[str]:
|
||||
"""Blockers read from a Quest/Android game's APK, for the CLI (the game page shows them as callouts)."""
|
||||
if is_rift(entry) or is_linux(entry) or not entry.get("analysis"):
|
||||
@@ -409,20 +418,14 @@ def missing_obb(entry: dict) -> bool:
|
||||
return bool(extra.get("expects_obb")) and not (entry.get("data_dir") and entry.get("data_bytes") != 0)
|
||||
|
||||
|
||||
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None,
|
||||
replace: str | None = None) -> dict:
|
||||
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None) -> dict:
|
||||
"""Add a Linux app to the library (no conversion: it's installed as it is): arm64, or x86_64 (run through FEX on
|
||||
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder). replace: the package of
|
||||
the library entry this is a newer version of (replace_linux_app): it keeps that package, its name and its art."""
|
||||
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder)."""
|
||||
from .analysis import linux
|
||||
from .core.models import Recipe
|
||||
|
||||
path = Path(path)
|
||||
info = linux.inspect(path)
|
||||
if replace and not exe and not info["files"]: # a folder: the program the app started before, if it's still there
|
||||
before = (library.game(replace) or {}).get("exe")
|
||||
if before and before != info["exe"] and (Path(info["root"]) / before).is_file():
|
||||
exe = before
|
||||
if exe:
|
||||
info["exe"] = exe
|
||||
x86 = info["machine"] == linux.EM_X86_64
|
||||
@@ -432,7 +435,7 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
|
||||
if x86 and reporter:
|
||||
reporter.log(f"{path.name} is an x86_64 build: the Frame runs it through FEX (x86 translation; an arm64 "
|
||||
"build runs faster if there is one)")
|
||||
package = replace or f"linux.{linux.slug(info['title'])}"
|
||||
package = f"linux.{linux.slug(info['title'])}"
|
||||
if reporter:
|
||||
reporter.log(f"{info['title']}: program {info['exe']}{' (AppImage)' if info['appimage'] else ''}"
|
||||
f"{', OpenXR (VR)' if info['openxr'] else ''}")
|
||||
@@ -454,8 +457,6 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
|
||||
if not old:
|
||||
fields.update(title=info["title"], recipe=library.recipe_to_dict(recipe), status="unknown")
|
||||
library.upsert_game(package, **fields)
|
||||
if replace: # a newer version: the artwork stays as it is
|
||||
return library.game(package)
|
||||
try:
|
||||
from .artwork import fetch
|
||||
|
||||
@@ -597,7 +598,7 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
|
||||
reporter, x86_64=bool(extra.get("x86_64")),
|
||||
desktop_entry=entry.get("desktop_entry", True) is not False,
|
||||
desktop_icon=entry.get("desktop_icon", True) is not False,
|
||||
overlay=linux_overlay(entry), **_source_args(entry, target.label))
|
||||
overlay=linux_overlay(entry))
|
||||
if extra.get("x86_64"):
|
||||
reporter.check("x86 translation", True, "runs through FEX on SteamOS's x86 system (its libraries come from "
|
||||
"there; not checked ahead)")
|
||||
@@ -608,23 +609,10 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
|
||||
reporter.check("Libraries on the Frame", True, "everything the app needs is there")
|
||||
if add_to_library:
|
||||
target.add_to_library([package], reporter)
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
|
||||
"source_id": entry.get("source_id")})
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
|
||||
return result
|
||||
|
||||
|
||||
def _source_args(entry: dict, label: str) -> dict:
|
||||
"""source_id (recorded with the install) and changed_since for a Linux app / PC VR game install: after a newer
|
||||
version was added (replace_linux_app/replace_rift_game), files changed since the last install there are sent
|
||||
again even when the Frame has a file of the same size (0 = the last install is unknown: every file)."""
|
||||
sid = entry.get("source_id")
|
||||
if not sid:
|
||||
return {}
|
||||
rec = (entry.get("installs") or {}).get(label) or {}
|
||||
return {"source_id": sid,
|
||||
"changed_since": None if rec.get("source_id") == sid else float(rec.get("time") or 0)}
|
||||
|
||||
|
||||
def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
|
||||
if reporter:
|
||||
reporter.log(f"analyzing {src.apk.name}")
|
||||
@@ -692,85 +680,6 @@ def replace_apk(package: str, apk: Path, reporter: Reporter | None = None) -> di
|
||||
"new_version": new_vc}
|
||||
|
||||
|
||||
def _looks_different(before: str | None, now: str | None) -> bool:
|
||||
"""A newer version whose name has little in common with the old one's (maybe another app): the caller warns."""
|
||||
import difflib
|
||||
|
||||
a, b = _norm_title(before or ""), _norm_title(now or "")
|
||||
if not a or not b or a in b or b in a:
|
||||
return False
|
||||
return difflib.SequenceMatcher(None, a, b).ratio() < 0.5
|
||||
|
||||
|
||||
def _mark_source_replaced(package: str, **fields) -> dict:
|
||||
"""A Linux app or PC VR game got a newer version: a new source id, which installs record (in the deployment's
|
||||
recipe); an install without it shows the game as outdated (ui.components.source_replaced)."""
|
||||
import uuid
|
||||
|
||||
return library.upsert_game(package, source_id=uuid.uuid4().hex[:12], source_updated=time.time(), **fields)
|
||||
|
||||
|
||||
def replace_linux_app(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
|
||||
"""A newer version of a Linux app already in the library (GitHub #161): an AppImage/archive or a folder, analysed
|
||||
like "Add a Linux app…" (program, arm64/x86_64, AppImage, OpenXR). The app keeps its package (so its Steam
|
||||
shortcut on the Frame), name, artwork, tags and switches (Desktop Mode entry, overlay app); a folder keeps the
|
||||
program chosen before if it's still there. Returns {"entry", "different": its name looks like another app's,
|
||||
"name": the new version's own name}."""
|
||||
entry = library.game(package)
|
||||
if entry is None or not is_linux(entry):
|
||||
raise ValueError("only Linux apps can get a newer version this way")
|
||||
before = ((entry.get("analysis") or {}).get("label")) or entry.get("title")
|
||||
old_source = ((entry.get("analysis") or {}).get("extra") or {}).get("source")
|
||||
if reporter:
|
||||
reporter.log(f"reading {Path(path).name}")
|
||||
stored = add_linux_app(path, reporter, replace=package)
|
||||
name = (stored.get("analysis") or {}).get("label")
|
||||
stored = _mark_source_replaced(package, replaced_source=old_source)
|
||||
return {"entry": stored, "different": _looks_different(before, name), "name": name}
|
||||
|
||||
|
||||
def replace_rift_game(package: str, folder: Path | str, reporter: Reporter | None = None) -> dict:
|
||||
"""A newer version of a PC VR (Rift) game already in the library (GitHub #161): its new game folder, analysed like
|
||||
a scanned one (programs, VR runtimes, fingerprint). The game keeps its package, name, artwork, tags and the user's
|
||||
own recipe; a program the user confirmed stays if the new folder has it at the same place, else FramePort picks
|
||||
one again (exe_confirmed False when that's unclear: the caller asks). The old folder is left out of later scans.
|
||||
Returns {"entry", "different", "name", "exe_changed"}."""
|
||||
entry = library.game(package)
|
||||
if entry is None or not is_rift(entry):
|
||||
raise ValueError("only PC VR games can get a newer version folder")
|
||||
folder = Path(folder)
|
||||
if not folder.is_dir():
|
||||
raise ValueError(f"{folder} isn't a folder")
|
||||
other = _existing_rift(folder)
|
||||
if other and other["package"] != package:
|
||||
raise ValueError(f"{folder.name} is already in the library as {other.get('title') or other['package']}")
|
||||
before = (entry.get("analysis") or {}).get("label") or entry.get("title")
|
||||
old_dir, old_exe = entry.get("game_dir"), entry.get("exe")
|
||||
keep = old_exe if entry.get("exe_confirmed") and old_exe and (folder / old_exe).is_file() else None
|
||||
stored = add_rift_game(folder, reporter, exe=keep, force=True, replace=entry)
|
||||
if stored is None:
|
||||
raise ValueError(f"no game program found in {folder.name}")
|
||||
replaced = [d for d in (entry.get("replaced_dirs") or []) + [old_dir] if d and d != str(folder)]
|
||||
# build=None: the next install checks the new folder first (prepare_rift); the exe's checksum comes from there
|
||||
stored = _mark_source_replaced(package, build=None, replaced_dirs=list(dict.fromkeys(replaced)))
|
||||
name = (stored.get("analysis") or {}).get("label")
|
||||
return {"entry": stored, "different": _looks_different(before, name), "name": name,
|
||||
"exe_changed": stored.get("exe") != old_exe}
|
||||
|
||||
|
||||
def replace_source(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
|
||||
"""A newer version of any game in the library: an APK (Quest/Android), an app file or folder (Linux) or a game
|
||||
folder (PC VR). See replace_apk, replace_linux_app, replace_rift_game."""
|
||||
entry = library.game(package)
|
||||
if entry is None:
|
||||
raise ValueError(f"{package} isn't in the library")
|
||||
if is_linux(entry):
|
||||
return replace_linux_app(package, path, reporter)
|
||||
if is_rift(entry):
|
||||
return replace_rift_game(package, path, reporter)
|
||||
return replace_apk(package, Path(path), reporter)
|
||||
|
||||
|
||||
def _analyze_entry(entry: dict, reporter: Reporter | None = None):
|
||||
src = source_of(entry)
|
||||
if reporter:
|
||||
@@ -1075,6 +984,22 @@ def _build_record(entry: dict, apk: Path | None = None, superseded=None) -> dict
|
||||
"overport_stat": converter_stat}
|
||||
|
||||
|
||||
def pc_recipe_fingerprint(recipe: dict, package: str = "", analysis=None) -> str:
|
||||
"""Fingerprint of what the build for this PC contains (Frame-only patch changes don't make it outdated).
|
||||
analysis (an Analysis or its dict): also the PC-only patches the game gets (Patch.pc_detect)."""
|
||||
from .patches.base import pc_selection
|
||||
|
||||
if isinstance(analysis, dict):
|
||||
analysis = library.analysis_from_dict(analysis)
|
||||
return recipe_fingerprint(dict(recipe, patches=pc_selection(recipe.get("patches") or {}, analysis)), package)
|
||||
|
||||
|
||||
def build_key(entry: dict, pc: bool) -> str:
|
||||
"""Where a Quest game's build is recorded: "build" (Steam Frame), "build_pc" (this PC, AXRB). Installed-as-is
|
||||
games have one build for both."""
|
||||
return "build_pc" if pc and not library.recipe_from_dict(entry["recipe"]).as_is else "build"
|
||||
|
||||
|
||||
def _build_inputs_changed(entry: dict) -> bool:
|
||||
build = entry.get("build") or {}
|
||||
if not build.get("build_inputs") or build["build_inputs"] != _build_inputs(entry):
|
||||
@@ -1088,7 +1013,8 @@ def _build_inputs_changed(entry: dict) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def build_game(package: str, reporter: Reporter, outdir: Path | None = None) -> dict:
|
||||
def build_game(package: str, reporter: Reporter, outdir: Path | None = None, pc: bool = False) -> dict:
|
||||
"""pc: the build for this PC (Quest games through AXRB): OVRPort's patches without the Steam Frame fixes."""
|
||||
entry = library.game(package)
|
||||
if not is_linux(entry) and not is_rift(entry):
|
||||
check_source(entry) # fail before reanalysis or tool setup when the original files have gone
|
||||
@@ -1106,16 +1032,19 @@ def build_game(package: str, reporter: Reporter, outdir: Path | None = None) ->
|
||||
recipe = library.recipe_from_dict(entry["recipe"])
|
||||
if engine.a32_recipe(a, recipe, newly=False): # saved before the bridge switched OVRPort & co. off itself
|
||||
set_recipe(package, recipe)
|
||||
out = outdir or (output_dir() / quest_dump.display_name(entry.get("name") or package))
|
||||
name = quest_dump.display_name(entry.get("name") or package)
|
||||
out = outdir or (output_dir() / (f"{name} (PC)" if pc else name))
|
||||
with _build_lock(package): # one build per game at a time: builds share the game's work folder
|
||||
res = builder.build(src, a, recipe, out, reporter)
|
||||
res = builder.build(src, a, recipe, out, reporter, **({"pc": True} if pc else {}))
|
||||
art, store_title = artwork.fetch(package, res.apk)
|
||||
build_info = {"apk": str(res.apk), "alt_apk": str(res.alt_apk) if res.alt_apk else None, "sha256": res.sha256,
|
||||
"alt_sha256": res.alt_sha256, "applied": res.applied, "checks": res.checks, "ok": res.ok,
|
||||
"overport": res.meta.get("overport"), "recipe_fp": recipe_fingerprint(entry["recipe"], package),
|
||||
"overport": res.meta.get("overport"),
|
||||
"recipe_fp": (pc_recipe_fingerprint(entry["recipe"], package, a) if pc
|
||||
else recipe_fingerprint(entry["recipe"], package)),
|
||||
"superseded": res.meta.get("superseded") or {}, "source_version": _source_version(entry),
|
||||
**_build_record(entry, res.apk, res.meta.get("superseded"))}
|
||||
library.upsert_game(package, build=build_info, title=entry.get("title") or store_title,
|
||||
library.upsert_game(package, **{build_key(entry, pc): build_info}, title=entry.get("title") or store_title,
|
||||
source_sha256=build_info["source_sha256"])
|
||||
return build_info
|
||||
|
||||
@@ -1199,14 +1128,15 @@ def install_game(package: str, target: Target, reporter: Reporter, apk_only: boo
|
||||
result["stage_seconds"] = reporter.stage_timings()
|
||||
_record_install(package, target.label, {"result": result, "time": time.time()})
|
||||
return result
|
||||
b = entry.get("build") or {}
|
||||
pc = getattr(target, "kind", "") == "pc" # Quest games on this PC (AXRB): their own build, no Frame fixes
|
||||
b = entry.get(build_key(entry, pc)) or {}
|
||||
recipe = library.recipe_from_dict(entry["recipe"])
|
||||
if not test_build and not b.get("apk"):
|
||||
build_game(package, reporter)
|
||||
if not test_build and not b.get("apk"): # never built yet (e.g. `frameport install` right after a scan)
|
||||
build_game(package, reporter, pc=pc)
|
||||
return install_game(package, target, reporter, apk_only, add_to_library)
|
||||
apk = Path(apk) if apk else Path(b["alt_apk"] if recipe.use_alt and b.get("alt_apk") else b["apk"])
|
||||
if not test_build and not apk.is_file():
|
||||
build_game(package, reporter)
|
||||
if not test_build and not apk.exists(): # the converted copy was removed after an earlier install: make it again
|
||||
build_game(package, reporter, pc=pc)
|
||||
return install_game(package, target, reporter, apk_only, add_to_library)
|
||||
if optimized and not update.rebuild:
|
||||
expected_sha = b.get("alt_sha256") if recipe.use_alt else b.get("sha256")
|
||||
@@ -1222,6 +1152,11 @@ def install_game(package: str, target: Target, reporter: Reporter, apk_only: boo
|
||||
data_dir = Path(entry["data_dir"]) if entry.get("data_dir") else None
|
||||
title = steam_title(entry)
|
||||
preserve_data = apk_only or (optimized and deployment is not None and not update.data and not force)
|
||||
if pc: # the deployment records what the build for this PC contains (pc_selection)
|
||||
from .patches.base import pc_selection
|
||||
|
||||
analysis = library.analysis_from_dict(entry["analysis"]) if entry.get("analysis") else None
|
||||
recipe = dataclasses.replace(recipe, patches=pc_selection(recipe.patches, analysis))
|
||||
result = target.install(package, title, apk, data_dir, recipe, reporter, preserve_data,
|
||||
data_files=entry.get("data_files"))
|
||||
if add_to_library: # also after a reinstall: the agent restarts Steam only when the entry changes
|
||||
@@ -1350,15 +1285,16 @@ def remove_converted_copies(package: str) -> int:
|
||||
game files the user added. Returns the bytes freed."""
|
||||
if library.setting("build.keep_copies", False):
|
||||
return 0
|
||||
b = (library.game(package) or {}).get("build") or {}
|
||||
g = library.game(package) or {}
|
||||
out, freed = output_dir().resolve(), 0
|
||||
for key in ("apk", "alt_apk"):
|
||||
p = Path(b[key]) if b.get(key) else None
|
||||
if p and p.exists() and out in p.resolve().parents:
|
||||
freed += p.stat().st_size
|
||||
p.unlink()
|
||||
if not any(p.parent.iterdir()):
|
||||
p.parent.rmdir()
|
||||
for b in (g.get("build") or {}, g.get("build_pc") or {}):
|
||||
for key in ("apk", "alt_apk"):
|
||||
p = Path(b[key]) if b.get(key) else None
|
||||
if p and p.exists() and out in p.resolve().parents:
|
||||
freed += p.stat().st_size
|
||||
p.unlink()
|
||||
if not any(p.parent.iterdir()):
|
||||
p.parent.rmdir()
|
||||
return freed
|
||||
|
||||
|
||||
@@ -1384,11 +1320,10 @@ def install_rift(package: str, target: Target, reporter: Reporter, add_to_librar
|
||||
result = target.install_pcvr(package, title, Path(entry["game_dir"]), entry["exe"], recipe, reporter,
|
||||
revive_dir=rdir, revive_version=revive.installed_version() if rdir else None,
|
||||
exe_sha256=entry["build"].get("sha256"), art_lookup=entry.get("quest_package"),
|
||||
overlay=rift_overlay(entry, recipe), **_source_args(entry, target.label))
|
||||
overlay=rift_overlay(entry, recipe))
|
||||
if add_to_library:
|
||||
target.add_to_library([package], reporter)
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
|
||||
"source_id": entry.get("source_id")})
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -36,8 +36,7 @@ class Target(ABC):
|
||||
raise NotImplementedError(f"{self.label} can't launch games")
|
||||
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
|
||||
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None,
|
||||
changed_since=None) -> dict:
|
||||
desktop_entry=True, overlay=None, desktop_icon=True) -> dict:
|
||||
"""Install a Linux app (only the Frame runs them; x86_64 ones through FEX)."""
|
||||
raise NotImplementedError(f"{self.label} can't install Linux apps")
|
||||
|
||||
|
||||
@@ -86,15 +86,14 @@ class FrameLeptonTarget(Target):
|
||||
def install_pcvr(self, package, title, game_dir, exe, recipe, reporter, **extra):
|
||||
plan = installer.PcvrPlan(package, title, Path(game_dir), exe, recipe, extra.get("revive_dir"),
|
||||
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"),
|
||||
dest=self.install_dest(), overlay=extra.get("overlay"),
|
||||
source_id=extra.get("source_id"), changed_since=extra.get("changed_since"))
|
||||
dest=self.install_dest(), overlay=extra.get("overlay"))
|
||||
return installer.install_pcvr(self.connect().frame, plan, reporter)
|
||||
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
|
||||
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None, changed_since=None):
|
||||
desktop_entry=True, overlay=None, desktop_icon=True):
|
||||
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64,
|
||||
dest=self.install_dest(), desktop_entry=desktop_entry, overlay=overlay,
|
||||
desktop_icon=desktop_icon, source_id=source_id, changed_since=changed_since)
|
||||
desktop_icon=desktop_icon)
|
||||
return installer.install_linux(self.connect().frame, plan, reporter)
|
||||
|
||||
def drives(self) -> list[dict]:
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
"""Quest games on this Windows PC through AXRB (tools/axrb.py): the "This PC" target's Android half.
|
||||
|
||||
Each game runs in one of AXRB's two backends (`choose_backend`): direct mode (AXRB-Emulatorless: axrb-direct.exe,
|
||||
no emulator, Vulkan only) for Vulkan games, AXRB's Android emulator for the rest; the recipe's `pc.axrb_backend`
|
||||
(or `frameport install --to pc --axrb-backend`) overrides it.
|
||||
|
||||
An install sets that AXRB up when needed (its installer + its Android runtime, downloaded only now) and records
|
||||
<data>/pc/<pkg>/deployment.json with kind "android" and the backend. The game's build for this PC is OVRPort's
|
||||
conversion without the Steam Frame fixes, signed with the game's key.
|
||||
- emulator: the emulator starts, the build is installed with adb, its OBB data pushed.
|
||||
- direct: no Android to install into: the build is copied to %LOCALAPPDATA%\\FramePort\\axrb\\games\\<pkg>, its
|
||||
data files are listed (Android path -> Windows file, mounted read-only by AXRB at the game's start) the way AXRB's
|
||||
launcher lists them, and the launch activity comes from the APK's manifest.
|
||||
Play goes through a Steam shortcut that runs FramePort's launcher script for the backend, which hands the game to
|
||||
AXRB's own run script (run_windows_game.ps1 / run_direct_game.ps1). See docs/PC_ANDROID.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from ..core import winhost
|
||||
from ..core.events import Reporter
|
||||
from ..core.models import Recipe, data_manifest
|
||||
from ..tools import axrb, xr_sim
|
||||
|
||||
KIND = "android"
|
||||
OBB_ROOT = "/sdcard/Android/obb"
|
||||
BACKEND_PATCH = "pc.axrb_backend"
|
||||
|
||||
|
||||
def powershell_win() -> str:
|
||||
return axrb.powershell_win()
|
||||
|
||||
|
||||
def shortcut_fields(dep: dict) -> tuple[str, str, str]:
|
||||
"""(Exe, StartDir, LaunchOptions) of the Steam shortcut: powershell.exe running FramePort's AXRB launcher."""
|
||||
launcher = dep["launcher_win"]
|
||||
start = '"' + launcher.rsplit("\\", 1)[0] + '\\"'
|
||||
return f'"{dep["powershell_win"]}"', start, axrb.launcher_args(launcher, dep["package"], dep["activity"],
|
||||
dep["title"], dep.get("flags") or ())
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ backend choice
|
||||
def backend_params(recipe: Recipe | None) -> dict:
|
||||
return dict(((recipe.patches if recipe else {}) or {}).get(BACKEND_PATCH) or {})
|
||||
|
||||
|
||||
def choose_backend(recipe: Recipe | None, graphics: str = "", override: str | None = None) -> str:
|
||||
"""Which AXRB runs the game: `override` (CLI), else the recipe's pc.axrb_backend, else direct mode for Vulkan
|
||||
games and the emulator for OpenGL ES ones (direct mode has no GLES renderer; "unknown" counts as GLES).
|
||||
"Vulkan (Unity boot.config)" counts as unknown: that label comes from the Oculus XR plugin's xr-vulkan-* keys,
|
||||
which GLES builds have too (all 9 such games in the dev library; Toy Master and I Am Cat run GLES, and Toy
|
||||
Master crashed in direct mode at "Unable to retrieve the default EGL display!")."""
|
||||
for choice in (override, backend_params(recipe).get("backend")):
|
||||
if choice:
|
||||
if choice not in axrb.BACKENDS:
|
||||
raise ValueError(f"unknown AXRB backend {choice!r} (direct or emulator)")
|
||||
return choice
|
||||
graphics = graphics or ""
|
||||
if not (graphics.startswith("Vulkan") and "boot.config" not in graphics):
|
||||
return axrb.EMULATOR
|
||||
# Vulkan: direct mode once it can be had (installed, a local build, or a published AXRB-Emulatorless release);
|
||||
# until then the emulator, which runs Vulkan games too (slower)
|
||||
return axrb.DIRECT if direct_obtainable() else axrb.EMULATOR
|
||||
|
||||
|
||||
def direct_obtainable() -> bool:
|
||||
"""Whether AXRB's direct mode is installed, built locally (FRAMEPORT_AXRB_DIRECT_DIR) or published."""
|
||||
if axrb.find(axrb.DIRECT):
|
||||
return True
|
||||
try:
|
||||
return bool(axrb.latest(axrb.DIRECT))
|
||||
except Exception: # noqa: BLE001 - offline: nothing to install it from
|
||||
return False
|
||||
|
||||
|
||||
def launch_flags(backend: str, params: dict, profile: dict | None = None) -> list[str]:
|
||||
"""The launcher switches for a game: the recipe's pc.axrb_backend parameters, plus AXRB's own compatibility
|
||||
profile's precomposition. AXRB's legacy-driver/Quest-identity options are only taken from the recipe (FramePort
|
||||
gives AXRB an OVRPort build, which doesn't need them)."""
|
||||
profile = profile or {}
|
||||
flags = []
|
||||
if backend == axrb.DIRECT:
|
||||
translator = params.get("translator") or ""
|
||||
if translator and translator not in axrb.TRANSLATORS:
|
||||
raise ValueError(f"unknown AXRB translator {translator!r} ({', '.join(axrb.TRANSLATORS)})")
|
||||
if translator and translator != axrb.TRANSLATORS[0]:
|
||||
flags += ["-Translator", translator]
|
||||
if params.get("precompose") or profile.get("precomposeProjectionLayers"):
|
||||
flags.append("-PrecomposeProjectionLayers")
|
||||
if backend == axrb.DIRECT:
|
||||
legacy = bool(params.get("legacy_oculus_driver"))
|
||||
if legacy:
|
||||
flags.append("-LegacyOculusDriver")
|
||||
if params.get("quest_identity"):
|
||||
flags.append("-QuestIdentity")
|
||||
if params.get("controllers_only") and legacy: # AXRB applies it through the legacy driver only
|
||||
flags.append("-ControllersOnly")
|
||||
return flags
|
||||
|
||||
|
||||
def _analysis(package: str) -> dict:
|
||||
try:
|
||||
from ..core import library
|
||||
|
||||
return (library.game(package) or {}).get("analysis") or {}
|
||||
except Exception: # noqa: BLE001 - no library (tests, a test build): automatic choice from the APK alone
|
||||
return {}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ emulator (adb)
|
||||
def _adb_ok(r: subprocess.CompletedProcess, what: str) -> str:
|
||||
text = (r.stdout + r.stderr).strip()
|
||||
if r.returncode != 0 or "Failure" in text or "Error" in text and "Success" not in text:
|
||||
if "INSTALL_FAILED_UPDATE_INCOMPATIBLE" in text:
|
||||
raise RuntimeError(f"{what}: a copy of this game signed with another key is installed in AXRB. "
|
||||
"Uninstall it there first (that deletes its Android saves), then install again.")
|
||||
raise RuntimeError(f"{what}: {text[-400:]}")
|
||||
return text
|
||||
|
||||
|
||||
def resolve_activity(package: str) -> str:
|
||||
r = axrb.adb(["shell", "cmd", "package", "resolve-activity", "--brief", package], timeout=60)
|
||||
last = (r.stdout.strip().splitlines() or [""])[-1].strip()
|
||||
if not re.fullmatch(r"[\w.]+/[\w.$]+", last):
|
||||
raise RuntimeError(f"{package} has no launcher activity in Android (is it an OVRPort build?)")
|
||||
return last
|
||||
|
||||
|
||||
def remote_sizes(package: str) -> dict[str, int]:
|
||||
"""Sizes of the files already in the game's OBB folder in AXRB's Android (a re-install sends only the rest)."""
|
||||
r = axrb.adb(["shell", f"cd {OBB_ROOT}/{package} 2>/dev/null && find . -type f -exec stat -c '%s %n' {{}} +"],
|
||||
timeout=120)
|
||||
out = {}
|
||||
for line in r.stdout.splitlines():
|
||||
size, _, name = line.strip().partition(" ")
|
||||
if size.isdigit() and name.startswith("./"):
|
||||
out[name[2:]] = int(size)
|
||||
return out
|
||||
|
||||
|
||||
def push_data(package: str, data_dir: Path, files: list[str] | None, reporter: Reporter) -> int:
|
||||
manifest = data_manifest(data_dir, files)
|
||||
if not manifest:
|
||||
return 0
|
||||
have = remote_sizes(package)
|
||||
todo = {rel: size for rel, size in manifest.items() if have.get(rel) != size}
|
||||
total, done = sum(todo.values()) or 1, 0
|
||||
reporter.log(f"game data: {len(todo)} of {len(manifest)} files to copy ({total / 2**30:.1f} GiB)")
|
||||
for rel, size in todo.items():
|
||||
reporter.check_cancel()
|
||||
reporter.progress(done / total, f"copying game data: {rel}")
|
||||
r = axrb.adb(["push", winhost.to_windows(data_dir / rel), f"{OBB_ROOT}/{package}/{rel}"],
|
||||
timeout=600 + size / 4e6)
|
||||
_adb_ok(r, f"copying {rel}")
|
||||
done += size
|
||||
reporter.progress(1.0, "game data copied")
|
||||
return len(todo)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ AXRB setup
|
||||
def ensure_axrb(reporter: Reporter, backend: str = axrb.EMULATOR, update: bool = False) -> axrb.Install:
|
||||
"""AXRB for one backend and its Android runtime, installed/set up (or upgraded, see axrb.install) on first use."""
|
||||
label = "AXRB direct mode" if backend == axrb.DIRECT else "AXRB"
|
||||
have = axrb.find(backend)
|
||||
if not have or update or axrb.older(have.version, axrb.KNOWN_GOOD.get(backend)):
|
||||
reporter.stage(f"{'Update' if have else 'Download'} {label} (Android XR Bridge)")
|
||||
inst = axrb.install(lambda f: reporter.progress(f, f"downloading {label}"), reporter.check_cancel,
|
||||
backend=backend, update=update)
|
||||
reporter.check(label, True, f"{inst.version or 'local build'} at {winhost.to_windows(inst.app)}")
|
||||
if backend == axrb.DIRECT:
|
||||
info = axrb.direct_requirements()
|
||||
problems = axrb.direct_requirement_problems(info)
|
||||
if problems:
|
||||
raise RuntimeError("This PC can't run Quest games in AXRB's direct mode: " + "; ".join(problems))
|
||||
reporter.check("PC requirements (AXRB direct mode)", True,
|
||||
f"Vulkan, {info.get('memoryGB') or '?'} GB")
|
||||
state = axrb.direct_setup_state(inst)
|
||||
if state["missing_files"]:
|
||||
raise RuntimeError("AXRB's direct mode is incomplete (" + ", ".join(state["missing_files"][:4])
|
||||
+ "): reinstall or rebuild it")
|
||||
if not state["ready"]:
|
||||
reporter.stage(f"Set up AXRB's direct mode ({state['download_bytes'] / 2**30:.1f} GiB download)")
|
||||
axrb.setup_direct(inst, lambda f, name: reporter.progress(f, name), reporter.check_cancel)
|
||||
return inst
|
||||
info = axrb.requirements(inst.app)
|
||||
problems = axrb.requirement_problems(info)
|
||||
if problems:
|
||||
raise RuntimeError("This PC can't run Quest games through AXRB: " + "; ".join(problems))
|
||||
reporter.check("PC requirements (AXRB)", True, f"{info.get('gpu')}, {info.get('memoryGB')} GB")
|
||||
state = axrb.setup_state(app=inst.app)
|
||||
if state["components"] or not state["avd"]:
|
||||
reporter.stage(f"Set up AXRB's Android ({state['download_bytes'] / 2**30:.1f} GiB download)")
|
||||
axrb.setup_files(lambda f, name: reporter.progress(f, f"downloading {name}"), reporter.check_cancel,
|
||||
app=inst.app)
|
||||
return inst
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ install
|
||||
def apk_launcher_activity(apk: Path) -> str:
|
||||
"""The launch activity from the APK's own manifest (direct mode has no Android to ask)."""
|
||||
from ..apk import axml
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(apk) as z:
|
||||
activity = axml.launcher_activity(z.read("AndroidManifest.xml"))
|
||||
except (OSError, KeyError, zipfile.BadZipFile, ValueError) as exc:
|
||||
raise RuntimeError(f"{apk.name}: the manifest could not be read ({exc})") from None
|
||||
if not activity:
|
||||
raise RuntimeError(f"{apk.name} has no launcher activity (is it an OVRPort build?)")
|
||||
return activity
|
||||
|
||||
|
||||
def _apk_versions(apk: Path) -> tuple[str | None, str | None]:
|
||||
"""(versionName, versionCode) for AXRB's compatibility profiles."""
|
||||
try:
|
||||
from pyaxmlparser import APK
|
||||
|
||||
a = APK(str(apk))
|
||||
return a.version_name, a.version_code
|
||||
except Exception: # noqa: BLE001 - only used to match AXRB's optional profiles
|
||||
return None, None
|
||||
|
||||
|
||||
CONTENT_LINKS = "content" # <game dir>\content: hard links to the game's data files (short paths)
|
||||
# axrb-direct.exe gets one "--mount-ro <guest>=<host>" argument per content file (prepare_art_session.py, which
|
||||
# resolves every path first: junctions and 8.3 names don't shorten it), and a Windows command line holds 32767
|
||||
# characters: Batman's 90 files from a long dump path didn't fit ("The filename or extension is too long": 32876
|
||||
# characters, 27028 of them content mounts). What is left for the content files next to AXRB's own arguments
|
||||
# (~5,900 characters with the dev PC's paths; more with longer runtime folders).
|
||||
MOUNT_ARGS_BUDGET = 25000
|
||||
|
||||
|
||||
def _windows_readable(path: Path) -> str | None:
|
||||
"""The Windows path of a local folder when Windows reads it directly (not through \\\\wsl$), else None."""
|
||||
try:
|
||||
w = winhost.to_windows(path)
|
||||
except ValueError:
|
||||
return None
|
||||
return None if w.startswith("\\\\") else w
|
||||
|
||||
|
||||
def mount_args_length(lines: list[str]) -> int:
|
||||
"""Characters the content files take on axrb-direct.exe's command line (quoted "--mount-ro" "<guest>=<host>")."""
|
||||
return sum(len(' "--mount-ro" ""') + len("/storage/emulated/0/") + len(line) for line in lines)
|
||||
|
||||
|
||||
def _hard_links(package: str, data_dir: Path, rels: list[str]) -> Path | None:
|
||||
"""Hard links to the data files in <game dir>\\content (no copies, no extra space), None when they can't be made
|
||||
(the data is on another drive than FramePort's folder)."""
|
||||
links = axrb.game_dir(package) / CONTENT_LINKS
|
||||
try:
|
||||
for rel in rels:
|
||||
src, dst = data_dir / rel, links / rel
|
||||
if dst.exists() and os.path.samefile(src, dst):
|
||||
continue
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
dst.unlink(missing_ok=True)
|
||||
os.link(src, dst)
|
||||
except OSError:
|
||||
shutil.rmtree(links, ignore_errors=True)
|
||||
return None
|
||||
return links
|
||||
|
||||
|
||||
def _line(package: str, rel: str, host_win: str) -> str:
|
||||
guest = f"Android/obb/{package}/{rel}"
|
||||
# prepare_art_session.py's rules: inside the app's storage, no empty/dot parts, no backslash; one line each
|
||||
if any(part in ("", ".", "..") for part in guest.split("/")) or "\\" in guest or \
|
||||
re.search(r"[\t\r\n]", guest + host_win):
|
||||
raise RuntimeError(f"unsupported content file name: {rel}")
|
||||
return f"{guest}\t{host_win}"
|
||||
|
||||
|
||||
def asset_lines(package: str, data_dir: Path | None, files: list[str] | None, reporter: Reporter) -> list[str]:
|
||||
"""AXRB's content-file list (core/direct.mjs writeAssetList): "Android/obb/<pkg>/<file>\\t<Windows path>" per
|
||||
data file. Files stay where they are when Windows can read them, else (a WSL-only folder: \\\\wsl$ is unreliable)
|
||||
they are copied next to the APK. A list too long for axrb-direct.exe's command line goes through hard links in
|
||||
<game dir>\\content instead (same drive only), else the install stops with a clear message."""
|
||||
manifest = list(data_manifest(data_dir, files) if data_dir else {})
|
||||
base = _windows_readable(data_dir) if manifest else None
|
||||
lines = []
|
||||
for rel in manifest:
|
||||
host = data_dir / rel
|
||||
if base:
|
||||
host_win = base + "\\" + rel.replace("/", "\\")
|
||||
else:
|
||||
copy = axrb.game_dir(package) / "obb" / rel
|
||||
if not copy.is_file() or copy.stat().st_size != host.stat().st_size:
|
||||
reporter.log(f"copying {rel} to where Windows can read it")
|
||||
copy.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(host, copy)
|
||||
host_win = winhost.to_windows(copy)
|
||||
lines.append(_line(package, rel, host_win))
|
||||
links = axrb.game_dir(package) / CONTENT_LINKS
|
||||
if base and mount_args_length(lines) > MOUNT_ARGS_BUDGET:
|
||||
made = _hard_links(package, data_dir, manifest)
|
||||
if made:
|
||||
reporter.log(f"{len(lines)} content files: listed through hard links in {made} (shorter paths)")
|
||||
short = winhost.to_windows(made)
|
||||
lines = [_line(package, rel, short + "\\" + rel.replace("/", "\\")) for rel in manifest]
|
||||
elif links.is_dir():
|
||||
shutil.rmtree(links, ignore_errors=True) # hard links of an earlier install, no longer needed
|
||||
if mount_args_length(lines) > MOUNT_ARGS_BUDGET:
|
||||
raise RuntimeError(f"{len(lines)} content files with paths this long don't fit on the command line of "
|
||||
"AXRB's direct mode; move the game's data to the drive FramePort is on, or to a folder "
|
||||
"with a shorter path, or install it for AXRB's emulator (--axrb-backend emulator)")
|
||||
return lines
|
||||
|
||||
|
||||
def _install_emulator(inst: axrb.Install, package: str, apk: Path, data_dir: Path | None, apk_only: bool,
|
||||
data_files: list[str] | None, reporter: Reporter) -> tuple[str, int, Path]:
|
||||
reporter.stage("Start Android (AXRB; the first start takes several minutes)")
|
||||
started = axrb.start_emulator(app=inst.app)
|
||||
try:
|
||||
axrb.ensure_runtime_apk(app=inst.app)
|
||||
reporter.stage("Install the game into AXRB's Android")
|
||||
# -g: runtime permissions granted at install, as Lepton does on the Frame. Without it a game asking for one
|
||||
# (BattleSisters: the microphone, a few seconds into its menu) opens Android's permission dialog over its
|
||||
# activity, which nobody sees or answers in the headset: Unity pauses for good.
|
||||
_adb_ok(axrb.adb(["install", "--no-incremental", "-r", "-g", winhost.to_windows(apk)], timeout=900),
|
||||
"installing the game")
|
||||
activity = resolve_activity(package)
|
||||
sent = 0 if apk_only or not data_dir else push_data(package, Path(data_dir), data_files, reporter)
|
||||
axrb.adb(["shell", "sync"], timeout=120)
|
||||
finally:
|
||||
if started:
|
||||
reporter.stage("Stop Android")
|
||||
axrb.stop_emulator(app=inst.app)
|
||||
return activity, sent, axrb.write_launcher(app=inst.app)
|
||||
|
||||
|
||||
def _install_direct(inst: axrb.Install, package: str, apk: Path, data_dir: Path | None, apk_only: bool,
|
||||
data_files: list[str] | None, reporter: Reporter) -> tuple[str, int, Path]:
|
||||
reporter.stage("Install the game for AXRB's direct mode")
|
||||
activity = apk_launcher_activity(apk)
|
||||
if activity.split("/")[0] != package:
|
||||
raise RuntimeError(f"{apk.name} is {activity.split('/')[0]}, not {package}")
|
||||
game = axrb.game_dir(package)
|
||||
game.mkdir(parents=True, exist_ok=True)
|
||||
target = game / f"{package}.apk"
|
||||
tmp = target.with_name(target.name + ".part")
|
||||
_drop_staged(target) # the libraries AXRB extracted from the build this one replaces
|
||||
shutil.copyfile(apk, tmp) # the converted copy in FramePort's output folder is removed after an install
|
||||
tmp.replace(target)
|
||||
listed = game / "assets.txt"
|
||||
if apk_only and listed.is_file():
|
||||
sent = 0
|
||||
else:
|
||||
lines = asset_lines(package, Path(data_dir) if data_dir else None, data_files, reporter)
|
||||
listed.write_text("".join(f"{line}\n" for line in lines), encoding="utf-8")
|
||||
sent = len(lines)
|
||||
return activity, sent, axrb.write_direct_launcher(inst)
|
||||
|
||||
|
||||
def install(package: str, title: str, apk: Path, data_dir: Path | None, recipe: Recipe, reporter: Reporter,
|
||||
apk_only: bool = False, data_files: list[str] | None = None, record_dir: Path | None = None,
|
||||
appid=None, backend: str | None = None) -> dict:
|
||||
"""`backend`: an explicit choice (CLI --axrb-backend), else choose_backend from the recipe and the analysis."""
|
||||
params = backend_params(recipe)
|
||||
why = "chosen for this install" if backend else "recipe" if params.get("backend") else "automatic"
|
||||
graphics = _analysis(package).get("graphics", "")
|
||||
backend = choose_backend(recipe, graphics, backend)
|
||||
reporter.log(f"AXRB backend: {backend} ({why}{', ' + graphics if why == 'automatic' else ''})")
|
||||
try:
|
||||
before = dep_backend(json.loads((record_dir / "deployment.json").read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
before = backend
|
||||
if before != backend: # the other backend's copy (and saves) stay where they are
|
||||
reporter.log(f"this game was installed for AXRB's {before} backend before; that copy and its saves stay "
|
||||
"there (separate from this one)")
|
||||
inst = ensure_axrb(reporter, backend)
|
||||
name, code = _apk_versions(apk)
|
||||
profile = axrb.compatibility_runtime(inst, package, name, code)
|
||||
flags = launch_flags(backend, params, profile)
|
||||
install_one = _install_direct if backend == axrb.DIRECT else _install_emulator
|
||||
activity, sent, launcher = install_one(inst, package, apk, data_dir, apk_only, data_files, reporter)
|
||||
from ..build import sha256
|
||||
from ..patches.base import pc_selection
|
||||
|
||||
dep = {"package": package, "kind": KIND, "backend": backend, "title": title, "activity": activity,
|
||||
"apk": str(apk), "sha256": sha256(apk), "launcher_win": winhost.to_windows(launcher),
|
||||
"powershell_win": powershell_win(), "flags": flags, "axrb_version": inst.version,
|
||||
"axrb_local": inst.local,
|
||||
"recipe": {"patches": sorted(pc_selection(recipe.patches)), "source": recipe.source}, "time": time.time()}
|
||||
if backend == axrb.DIRECT:
|
||||
dep["apk_win"] = winhost.to_windows(axrb.game_dir(package) / f"{package}.apk")
|
||||
dep["appid"] = appid(shortcut_fields(dep)[0], title) if appid else None
|
||||
if record_dir:
|
||||
record_dir.mkdir(parents=True, exist_ok=True)
|
||||
(record_dir / "deployment.json").write_text(json.dumps(dep, indent=2), encoding="utf-8")
|
||||
where = "AXRB's direct mode" if backend == axrb.DIRECT else "AXRB"
|
||||
verb = "listed" if backend == axrb.DIRECT else "copied"
|
||||
reporter.log(f"installed in {where} ({activity}); {sent} data files {verb}"
|
||||
+ (f"; launcher options {' '.join(flags)}" if flags else ""))
|
||||
return {"ok": True, "appid": dep["appid"], "activity": activity, "backend": backend}
|
||||
|
||||
|
||||
def dep_backend(dep: dict) -> str:
|
||||
return dep.get("backend") or axrb.EMULATOR # installs before direct mode were all emulator ones
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ FrameBridge settings
|
||||
SETTINGS_FILE = "framebridge.conf" # FrameBridge reads /sdcard/Android/data/<pkg>/files/framebridge.conf after
|
||||
SETTING_KEY = re.compile(r"[a-z_]+") # the build's libframe_settings.so (native/adapter/frame_adapter.c)
|
||||
SETTING_VALUE = re.compile(r"-?(?:[0-9]+\.?[0-9]*|\.[0-9]+)")
|
||||
|
||||
|
||||
def merge_settings(current: dict, changes: dict) -> dict:
|
||||
"""The agent's rules (cmd_set_settings): numbers only, checked before anything is written; an empty value
|
||||
removes the key (back to the build's value)."""
|
||||
out = dict(current)
|
||||
for k, v in changes.items():
|
||||
v = str(v).strip()
|
||||
if not SETTING_KEY.fullmatch(k) or (v and not SETTING_VALUE.fullmatch(v)):
|
||||
raise ValueError(f"bad setting {k}={v}")
|
||||
if v:
|
||||
out[k] = v
|
||||
else:
|
||||
out.pop(k, None)
|
||||
return out
|
||||
|
||||
|
||||
def settings_text(settings: dict) -> str:
|
||||
return "".join(f"{k}={v}\n" for k, v in settings.items())
|
||||
|
||||
|
||||
def set_settings(dep: dict, changes: dict) -> dict:
|
||||
"""Change FrameBridge settings of a game in AXRB without a rebuild: framebridge.conf in the game's external
|
||||
files folder overrides the build's settings at the next start. Direct mode: written into AXRB's runtime folder
|
||||
(direct\\sdcard\\Android\\data\\<pkg>\\files); emulator: pushed with adb (Android is started for it when it
|
||||
isn't running, and stopped again). `dep["settings"]` is updated; the caller saves the record."""
|
||||
package = dep["package"]
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.]+", package):
|
||||
raise ValueError(f"unexpected package name {package!r}")
|
||||
current = merge_settings(dep.get("settings") or {}, changes)
|
||||
text = settings_text(current)
|
||||
if dep_backend(dep) == axrb.DIRECT:
|
||||
root = axrb.direct_runtime_root()
|
||||
if not root:
|
||||
raise RuntimeError("AXRB's direct-mode runtime folder was not found")
|
||||
files = root / "direct" / "sdcard" / "Android" / "data" / package / "files"
|
||||
files.mkdir(parents=True, exist_ok=True)
|
||||
(files / SETTINGS_FILE).write_text(text, encoding="utf-8", newline="\n")
|
||||
where = str(files / SETTINGS_FILE)
|
||||
else:
|
||||
remote = f"/sdcard/Android/data/{package}/files"
|
||||
local = axrb.game_dir(package) / SETTINGS_FILE # a Windows path adb.exe can read
|
||||
local.parent.mkdir(parents=True, exist_ok=True)
|
||||
local.write_text(text, encoding="utf-8", newline="\n")
|
||||
started = axrb.start_emulator()
|
||||
try:
|
||||
axrb.adb(["shell", "mkdir", "-p", remote], timeout=60)
|
||||
if current:
|
||||
_adb_ok(axrb.adb(["push", winhost.to_windows(local), f"{remote}/{SETTINGS_FILE}"], timeout=120),
|
||||
"copying the settings")
|
||||
else:
|
||||
axrb.adb(["shell", "rm", "-f", f"{remote}/{SETTINGS_FILE}"], timeout=60)
|
||||
finally:
|
||||
if started:
|
||||
axrb.stop_emulator()
|
||||
where = f"{remote}/{SETTINGS_FILE}"
|
||||
dep["settings"] = current
|
||||
return {"settings": current, "file": where}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ logs + tests
|
||||
EMULATOR_LOGS = (("session.json", "game/session.json", 1 << 16), ("host.err", "game/host.err", 1 << 20),
|
||||
("host.log", "game/host.log", 1 << 20), ("guest.log", "game/guest.log", 2 << 20),
|
||||
("emulator.stderr.log", "emulator/emulator.stderr.log", 1 << 18),
|
||||
("frameport-emulator-start.log", "frameport-emulator-start.log", 1 << 18))
|
||||
# run_direct_game.ps1: the runner's stdout (AXRB_AOSP_* lifecycle lines, the game's Java output) and stderr, its
|
||||
# own log (AXRB_DIRECT_LOG: "<s> <tid> E/tag: ..."), the bridge, the session record
|
||||
DIRECT_LOGS = (("session.json", "game/session.json", 1 << 16), ("host.err", "game/host.err", 1 << 20),
|
||||
("host.log", "game/host.log", 1 << 20), ("direct.log", "game/direct.log", 4 << 20),
|
||||
("direct.out", "game/direct.out", 2 << 20), ("direct.err", "game/direct.err", 1 << 20),
|
||||
("frameport-launch-test.log", "frameport-launch-test.log", 1 << 16))
|
||||
|
||||
|
||||
HEAD_TOO = ("direct.out", "direct.log")
|
||||
|
||||
|
||||
def game_logs(backend: str | None = None) -> dict[str, str]:
|
||||
"""AXRB's session logs (newest session) for triage and diagnostics. Both backends when `backend` is None: the
|
||||
direct ones then under "direct/"."""
|
||||
out = {}
|
||||
for b, files in ((axrb.EMULATOR, EMULATOR_LOGS), (axrb.DIRECT, DIRECT_LOGS)):
|
||||
if backend not in (None, b):
|
||||
continue
|
||||
root = axrb.runtime_root() if b == axrb.EMULATOR else axrb.direct_runtime_root()
|
||||
if not root:
|
||||
continue
|
||||
logs = axrb.log_dir(root)
|
||||
prefix = "direct/" if b == axrb.DIRECT and backend is None else ""
|
||||
for name, sub, limit in files:
|
||||
p = logs / sub
|
||||
if p.is_file():
|
||||
# the runner's logs keep their start (milestones) as well as their end
|
||||
out[prefix + name] = axrb.read_log(p, limit, head=limit // 4 if name in HEAD_TOO else 0)
|
||||
return out
|
||||
|
||||
|
||||
def test_env(extra_env: dict | None = None) -> dict[str, str]:
|
||||
"""Environment for a launch test's processes: `extra_env`, else the simulated headset's while one runs, plus
|
||||
FRAMEPORT_AXRB_TEST_ENV ("NAME=value;NAME=value", e.g. AXRB_DIRECT_DUMP=10 for direct mode's periodic thread
|
||||
dumps or AXRB_DIRECT_TRACE=net,files) for debugging a game that stops somewhere."""
|
||||
env = dict(xr_sim.active_env() if extra_env is None else extra_env)
|
||||
for item in os.environ.get("FRAMEPORT_AXRB_TEST_ENV", "").split(";"):
|
||||
name, sep, value = item.strip().partition("=")
|
||||
if sep and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
|
||||
env[name] = value
|
||||
return env
|
||||
|
||||
|
||||
def launch_test(dep: dict, reporter: Reporter, seconds: int = 45, triage=None, extra_env: dict | None = None):
|
||||
"""Start the game through FramePort's launcher (= AXRB's run script), watch its Android process, stop it again.
|
||||
extra_env: environment for the launcher's processes; default = the simulated headset's while one runs
|
||||
(tools/xr_sim.py, `frameport test --to pc --sim`)."""
|
||||
if dep_backend(dep) == axrb.DIRECT:
|
||||
return _launch_test_direct(dep, reporter, seconds, triage, extra_env)
|
||||
reporter.stage("Launch test (this PC, AXRB)")
|
||||
package = dep["package"]
|
||||
if axrb.emulator_online() and _pid(package):
|
||||
raise RuntimeError(f"{dep['title']} is already running")
|
||||
log = axrb.log_dir() / "frameport-launch-test.log"
|
||||
log.parent.mkdir(parents=True, exist_ok=True)
|
||||
log.unlink(missing_ok=True)
|
||||
before = time.time()
|
||||
q = axrb._ps_quote
|
||||
# --- xr-sim hook (tools/xr_sim.py): env + SteamVR-registration shim before the launcher call
|
||||
sim_env = test_env(extra_env)
|
||||
pid = axrb.spawn_detached(xr_sim.launcher_prefix(sim_env) + axrb.logged(
|
||||
f"& {q(dep['launcher_win'])} -Package {q(package)} -Activity {q(dep['activity'])} "
|
||||
f"-GameName {q(dep['title'])} -CaptureGuestLog " + " ".join(dep.get("flags") or ()),
|
||||
winhost.to_windows(log)))
|
||||
seen, state, boot_deadline = False, "NEVER_STARTED", time.time() + 900 # the emulator boot comes first
|
||||
watch_end = None
|
||||
while time.time() < boot_deadline and axrb.pid_running(pid):
|
||||
reporter.check_cancel()
|
||||
time.sleep(3)
|
||||
running = axrb.emulator_online() and bool(_pid(package))
|
||||
if running and not seen:
|
||||
seen, watch_end = True, time.time() + seconds
|
||||
if seen and not running:
|
||||
state = "EXITED"
|
||||
break
|
||||
if watch_end and time.time() >= watch_end:
|
||||
state = "RUNNING"
|
||||
break
|
||||
if seen and state == "NEVER_STARTED":
|
||||
state = "EXITED"
|
||||
if state == "RUNNING":
|
||||
sim_capture(sim_env, package, reporter)
|
||||
if axrb.pid_running(pid):
|
||||
try: # AXRB's script sees the game stop, cleans up (and shuts Android down if it started it)
|
||||
axrb.adb(["shell", "am", "force-stop", package], timeout=30)
|
||||
except (axrb.AxrbError, OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
end = time.time() + 300
|
||||
while time.time() < end and axrb.pid_running(pid):
|
||||
time.sleep(3)
|
||||
logs = game_logs(axrb.EMULATOR)
|
||||
text = "\n".join(f"===== {name}\n{body}" for name, body in logs.items()
|
||||
if name != "frameport-emulator-start.log")
|
||||
text += "\n===== launcher\n" + axrb.read_log(log, 1 << 16)
|
||||
return _report(text, state, package, before, reporter, triage, "incl. Android start")
|
||||
|
||||
|
||||
# direct.out lines AppProcess prints once the game's activity exists / once it ended (AXRB's ART bootstrap)
|
||||
DIRECT_STARTED = re.compile(r"^AXRB_AOSP_(?:ACTIVITY_CREATED|LIFECYCLE) ", re.M)
|
||||
|
||||
|
||||
def direct_state(out_text: str, launcher_running: bool) -> str | None:
|
||||
"""RUNNING-so-far / EXITED / None (not started yet) from direct.out and whether the run script still runs."""
|
||||
if DIRECT_STARTED.search(out_text):
|
||||
return "RUNNING" if launcher_running else "EXITED"
|
||||
return None if launcher_running else "NEVER_STARTED"
|
||||
|
||||
|
||||
def sim_capture(env: dict, package: str, reporter: Reporter) -> Path | None:
|
||||
"""Under the simulated headset (env names Monado's runtime): save its window, both eyes as a headset gets them,
|
||||
to the xr-sim captures folder, so a test shows what the game drew at its end. None without the simulator or the
|
||||
capture tool; a failed capture never fails the test."""
|
||||
if "XR_RUNTIME_JSON" not in (env or {}) or not xr_sim.capture_exe():
|
||||
return None
|
||||
try:
|
||||
path = xr_sim.capture(xr_sim.capture_dir() / f"{time.strftime('%Y%m%d-%H%M%S')}-{package}-headset.bmp")
|
||||
except (xr_sim.XrSimError, OSError, subprocess.SubprocessError) as exc:
|
||||
reporter.log(f"simulated headset view not saved: {exc}")
|
||||
return None
|
||||
reporter.log(f"simulated headset view: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def _launch_test_direct(dep: dict, reporter: Reporter, seconds: int, triage, extra_env: dict | None = None):
|
||||
reporter.stage("Launch test (this PC, AXRB direct mode)")
|
||||
inst = axrb.find(axrb.DIRECT)
|
||||
if not inst:
|
||||
raise RuntimeError("AXRB's direct mode is not installed on this PC any more")
|
||||
package = dep["package"]
|
||||
logs = axrb.log_dir(inst.runtime)
|
||||
log = logs / "frameport-launch-test.log"
|
||||
log.parent.mkdir(parents=True, exist_ok=True)
|
||||
log.unlink(missing_ok=True)
|
||||
out = logs / "game" / "direct.out"
|
||||
before = time.time()
|
||||
q = axrb._ps_quote
|
||||
sim_env = test_env(extra_env)
|
||||
pid = axrb.spawn_detached(xr_sim.launcher_prefix(sim_env) + axrb.logged(
|
||||
f"& {q(dep['launcher_win'])} -Package {q(package)} -Activity {q(dep['activity'])} "
|
||||
f"-GameName {q(dep['title'])} " + " ".join(dep.get("flags") or ()), winhost.to_windows(log)))
|
||||
state, deadline, watch_end = "NEVER_STARTED", time.time() + 300, None # ART's first start is slow (no JIT)
|
||||
while time.time() < deadline:
|
||||
reporter.check_cancel()
|
||||
time.sleep(3)
|
||||
alive = axrb.pid_running(pid)
|
||||
fresh = out.is_file() and out.stat().st_mtime >= before - 5 # the run script rewrites it every session
|
||||
now = direct_state(axrb.read_log(out, 1 << 20, head=1 << 18) if fresh else "", alive)
|
||||
if now in ("EXITED", "NEVER_STARTED"):
|
||||
state = now
|
||||
break
|
||||
if now == "RUNNING" and not watch_end:
|
||||
watch_end, deadline = time.time() + seconds, time.time() + seconds + 5
|
||||
if watch_end and time.time() >= watch_end:
|
||||
state = "RUNNING"
|
||||
break
|
||||
if state == "RUNNING":
|
||||
sim_capture(sim_env, package, reporter)
|
||||
if axrb.pid_running(pid): # AXRB's own Stop: the bridge window closes, the game pauses, stops and saves
|
||||
try:
|
||||
axrb.stop_game(inst, pid)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
end = time.time() + 60
|
||||
while time.time() < end and axrb.pid_running(pid):
|
||||
time.sleep(3)
|
||||
text = "\n".join(f"===== {name}\n{body}" for name, body in game_logs(axrb.DIRECT).items()
|
||||
if name != "frameport-launch-test.log")
|
||||
text += "\n===== launcher\n" + axrb.read_log(log, 1 << 16)
|
||||
return _report(text, state, package, before, reporter, triage, "direct mode")
|
||||
|
||||
|
||||
# AXRB's runtime logs a summary of the game's frames every ~5 s: "AXRB.Perf end-frame: rate=63.7/s ..." (guest), and
|
||||
# the bridge one of what it hands the PC's OpenXR runtime: "AXRB.Perf host-fresh-submit: rate=62.9/s ..."
|
||||
PERF_RATE = re.compile(r"AXRB\.Perf (end-frame|host-fresh-submit): rate=([\d.]+)/s")
|
||||
|
||||
|
||||
def frame_rates(text: str, last: int = 6) -> dict[str, float]:
|
||||
"""{"game": fps, "host": fps}: the median of the last few AXRB.Perf summaries (the first ones include loading)."""
|
||||
seen: dict[str, list[float]] = {}
|
||||
for kind, rate in PERF_RATE.findall(text):
|
||||
seen.setdefault("game" if kind == "end-frame" else "host", []).append(float(rate))
|
||||
out = {}
|
||||
for name, rates in seen.items():
|
||||
tail = sorted(rates[-last:])
|
||||
out[name] = tail[len(tail) // 2]
|
||||
return out
|
||||
|
||||
|
||||
def _report(text: str, state: str, package: str, before: float, reporter: Reporter, triage, note: str):
|
||||
result = triage(text, state, package, kind="axrb") if triage else None
|
||||
reporter.check("Game process", state == "RUNNING", f"{state} ({round(time.time() - before)}s, {note})")
|
||||
rates = frame_rates(text)
|
||||
if rates:
|
||||
reporter.check("Frame rate", True, ", ".join(f"{k} {v:g} fps" for k, v in
|
||||
(("game submits", rates.get("game")),
|
||||
("bridge to the PC's runtime", rates.get("host"))) if v))
|
||||
if result:
|
||||
for m in result.milestones:
|
||||
reporter.check(m, True)
|
||||
for f in result.findings:
|
||||
reporter.check(f.id, None if f.severity in ("warning", "info") else False,
|
||||
f"{f.diagnosis} [{f.evidence[:160]}]")
|
||||
return result, text
|
||||
|
||||
|
||||
def _pid(package: str) -> str:
|
||||
try:
|
||||
return axrb.adb(["shell", "pidof", package], timeout=20).stdout.strip()
|
||||
except (axrb.AxrbError, OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
|
||||
|
||||
def uninstall(dep: dict, keep_data: bool = True) -> bool:
|
||||
"""Remove the game from AXRB (saves kept unless keep_data is False). False if AXRB isn't there."""
|
||||
if dep_backend(dep) == axrb.DIRECT:
|
||||
return _uninstall_direct(dep, keep_data)
|
||||
if not axrb.app_dir() or not axrb.adb_exe():
|
||||
return False
|
||||
started = axrb.start_emulator()
|
||||
try:
|
||||
args = ["shell", "pm", "uninstall", "-k", dep["package"]] if keep_data else ["uninstall", dep["package"]]
|
||||
r = axrb.adb(args, timeout=240)
|
||||
return "Success" in r.stdout
|
||||
finally:
|
||||
if started:
|
||||
axrb.stop_emulator()
|
||||
|
||||
|
||||
def staged_libraries(apk: Path) -> Path | None:
|
||||
"""The folder AXRB's prepare_art_session.py extracts the APK's arm64 libraries into at every Play
|
||||
(<runtime>\\direct\\installed\\<sha256 of the APK's path and its lib/arm64-v8a names, CRCs and sizes>), or None.
|
||||
AXRB never removes one: each uninstall or new build left 100-500 MB behind (5 GB after the 25 games of the
|
||||
2026-10-16 library sweep)."""
|
||||
root = axrb.direct_runtime_root()
|
||||
if not root or not apk.is_file():
|
||||
return None
|
||||
try:
|
||||
with zipfile.ZipFile(apk) as archive:
|
||||
manifest = [(e.filename, e.CRC, e.file_size) for e in archive.infolist()
|
||||
if e.filename.startswith("lib/arm64-v8a/") and not e.is_dir()]
|
||||
# the path as FramePort's launcher hands it over: Join-Path $PSScriptRoot "games\<pkg>" + "<pkg>.apk"
|
||||
identity = hashlib.sha256(json.dumps([winhost.to_windows(apk), manifest]).encode()).hexdigest()
|
||||
except (OSError, ValueError, zipfile.BadZipFile):
|
||||
return None
|
||||
return root / "direct" / "installed" / identity
|
||||
|
||||
|
||||
def _drop_staged(apk: Path) -> None:
|
||||
staged = staged_libraries(apk)
|
||||
if staged and staged.is_dir() and not staged.is_symlink():
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
|
||||
|
||||
def _uninstall_direct(dep: dict, keep_data: bool) -> bool:
|
||||
"""Direct mode: FramePort's copy of the APK and its content list; with keep_data False also the game's Android
|
||||
data in AXRB's runtime folder (direct\\data\\data\\<pkg> = /data/data, direct\\sdcard\\Android\\data\\<pkg>)."""
|
||||
package = dep["package"]
|
||||
try:
|
||||
_drop_staged(axrb.game_dir(package) / f"{package}.apk")
|
||||
shutil.rmtree(axrb.game_dir(package), ignore_errors=True) # content\ holds hard links: the data stays
|
||||
except axrb.AxrbError:
|
||||
return False
|
||||
root = axrb.direct_runtime_root()
|
||||
if not keep_data and root:
|
||||
direct = root / "direct"
|
||||
for d in (direct / "data" / "data" / package, direct / "sdcard" / "Android" / "data" / package):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
return True
|
||||
@@ -1,5 +1,6 @@
|
||||
"""PC VR target: Oculus Rift games run on this Windows PC through Revive (LibOVR -> OpenXR/SteamVR), with a non-Steam
|
||||
shortcut in the local Steam library (play on the Frame by streaming from Steam/SteamVR).
|
||||
"""PC target ("This PC"): Oculus Rift games run on this Windows PC through Revive (LibOVR -> OpenXR/SteamVR), Quest
|
||||
games through AXRB (targets/pc_android.py), each with a non-Steam shortcut in the local Steam library (play on the
|
||||
Frame by streaming from Steam/SteamVR).
|
||||
|
||||
Works on native Windows and from WSL (Windows programs via interop). Revive is FramePort's portable copy
|
||||
(tools/revive.py), so nothing is installed system-wide. Nothing is copied: the shortcut points at the game folder.
|
||||
@@ -18,7 +19,9 @@ from ..core.events import Reporter
|
||||
from ..core.models import Recipe
|
||||
from ..core.paths import agent_file, localize, user_data_dir
|
||||
from ..patches.pcvr import game_args
|
||||
from ..tools import axrb
|
||||
from ..validate.triage import triage
|
||||
from . import pc_android
|
||||
from .base import PC_LABEL, Target
|
||||
|
||||
TAG = "FramePort PC VR" # marks the Steam shortcuts FramePort made (for updates and cleanup)
|
||||
@@ -61,8 +64,20 @@ def pc_dir() -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def is_android(dep: dict) -> bool:
|
||||
"""A Quest game installed on this PC (AXRB), not a Rift game."""
|
||||
return dep.get("kind") == pc_android.KIND
|
||||
|
||||
|
||||
def options_key(dep: dict) -> str | None:
|
||||
"""Quest games on this PC share their shortcut Exe (powershell.exe): their shortcut is also matched by this."""
|
||||
return f"-Package {dep['package']} " if is_android(dep) else None
|
||||
|
||||
|
||||
def shortcut_fields(dep: dict) -> tuple[str, str, str]:
|
||||
"""(Exe, StartDir, LaunchOptions) for the Steam shortcut of an installed game."""
|
||||
if is_android(dep):
|
||||
return pc_android.shortcut_fields(dep)
|
||||
exe_win = dep["exe_win"]
|
||||
start = '"' + exe_win.rsplit("\\", 1)[0] + '\\"'
|
||||
extra = " ".join(dep.get("game_args") or [])
|
||||
@@ -87,6 +102,7 @@ def local_installs() -> dict[str, dict]:
|
||||
class PcReviveTarget(Target):
|
||||
kind = "pc"
|
||||
label = PC_LABEL
|
||||
axrb_backend: str | None = None # Quest games: AXRB's backend for this run (CLI --axrb-backend), None = per game
|
||||
|
||||
def __init__(self):
|
||||
if not winhost.available():
|
||||
@@ -106,6 +122,7 @@ class PcReviveTarget(Target):
|
||||
"steamvr": bool(root and winhost.app_installed(root, winhost.STEAMVR_APPID)),
|
||||
"revive": str(revive.revive_dir()) if revive.revive_dir() else None,
|
||||
"revive_version": revive.installed_version(),
|
||||
"axrb": axrb.status(),
|
||||
"installed": self.installed(),
|
||||
}
|
||||
|
||||
@@ -116,7 +133,7 @@ class PcReviveTarget(Target):
|
||||
dep = localize(json.loads(f.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
dep["apk_present"] = Path(dep.get("exe_local", "")).exists()
|
||||
dep["apk_present"] = True if is_android(dep) else Path(dep.get("exe_local", "")).exists()
|
||||
out.append(dep)
|
||||
return out
|
||||
|
||||
@@ -149,7 +166,8 @@ class PcReviveTarget(Target):
|
||||
log_path = (winhost.env_path("LOCALAPPDATA") or Path("/nonexistent")) / REVIVE_LOG
|
||||
if log_path.exists():
|
||||
files["ReviveInjector.txt"] = log_path.read_text(encoding="utf-8", errors="replace")[-(2 << 20):]
|
||||
out = {"host": {"steamvr_running": winhost.steamvr_running()}, "files": files}
|
||||
files.update({f"axrb/{name}": text for name, text in pc_android.game_logs().items()})
|
||||
out = {"host": {"steamvr_running": winhost.steamvr_running(), "axrb": axrb.status()}, "files": files}
|
||||
if package:
|
||||
try:
|
||||
files["deployment.json"] = json.dumps(self._dep(package), indent=1)
|
||||
@@ -160,7 +178,12 @@ class PcReviveTarget(Target):
|
||||
|
||||
# ------------------------------------------------------------------ install
|
||||
def install(self, package, title, apk, data_dir, recipe, reporter, apk_only=False, data_files=None):
|
||||
raise NotImplementedError("Quest (APK) games install on the Steam Frame, not on the PC")
|
||||
"""A Quest game (its build for this PC) into AXRB's Android emulator."""
|
||||
if not self.steam:
|
||||
raise RuntimeError("Steam for Windows was not found on this PC")
|
||||
return pc_android.install(package, title, Path(apk), Path(data_dir) if data_dir else None, recipe, reporter,
|
||||
apk_only, data_files, record_dir=pc_dir() / package,
|
||||
appid=_vdf().shortcut_appid, backend=self.axrb_backend)
|
||||
|
||||
def install_pcvr(self, package, title, game_dir, exe, recipe: Recipe, reporter: Reporter, **extra):
|
||||
from ..tools import revive
|
||||
@@ -191,9 +214,7 @@ class PcReviveTarget(Target):
|
||||
"tuning": dict(recipe.params("pcvr.steamvr_tuning"), enabled=True)
|
||||
if "pcvr.steamvr_tuning" in recipe.patches else None,
|
||||
"sha256": extra.get("exe_sha256"), "art_lookup": extra.get("art_lookup"),
|
||||
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source,
|
||||
**({"source_id": extra["source_id"]} if extra.get("source_id") else {})},
|
||||
"time": time.time()}
|
||||
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source}, "time": time.time()}
|
||||
exe_field, _, _ = shortcut_fields(dep)
|
||||
d = pc_dir() / package
|
||||
try: # a reinstall keeps the shortcut's appid: Steam's entry is matched by its Exe, whatever its name now
|
||||
@@ -229,7 +250,7 @@ class PcReviveTarget(Target):
|
||||
try:
|
||||
dep = self._dep(pkg)
|
||||
exe, start, opts = shortcut_fields(dep)
|
||||
entry = library.game(pkg) or {"kind": "rift"}
|
||||
entry = library.game(pkg) or ({"package": pkg} if is_android(dep) else {"kind": "rift"})
|
||||
if not sources.has_art(pkg):
|
||||
artwork.fetch(pkg, lookup=dep.get("art_lookup"))
|
||||
art = steam_set_for(pkg)
|
||||
@@ -240,13 +261,13 @@ class PcReviveTarget(Target):
|
||||
old.unlink(missing_ok=True)
|
||||
reporter.log(f"removed the old Steam entry for {dep['title']} (its launch command changed)")
|
||||
ident = dep.get("appid") or vdf_mod.shortcut_appid(exe, dep["title"])
|
||||
icon = dep["exe_win"]
|
||||
icon = dep.get("exe_win") or ""
|
||||
if "icon" in art: # Steam wants a local path for the shortcut icon
|
||||
icon_path = grid / f"{ident}_icon.png"
|
||||
shutil.copy(art["icon"], icon_path)
|
||||
icon = winhost.to_windows(icon_path)
|
||||
got = vdf_mod.upsert_shortcut(str(vdf), exe, dep["title"], start, icon, TAG, opts,
|
||||
tags=steam_tags(entry, "pc"))
|
||||
tags=steam_tags(entry, "pc"), options_key=options_key(dep))
|
||||
for kind, f in art.items():
|
||||
suffix = {"portrait": "p", "landscape": "", "hero": "_hero", "logo": "_logo"}.get(kind)
|
||||
if suffix is None:
|
||||
@@ -276,7 +297,7 @@ class PcReviveTarget(Target):
|
||||
if not root or not dep.get("appid"):
|
||||
raise RuntimeError("Steam or the game's Steam shortcut wasn't found on this PC")
|
||||
vr = winhost.start_steamvr(root) # Revive binds to SteamVR; without it the game falls back to flatscreen
|
||||
tuning = self._tune(root, dep) if vr else None
|
||||
tuning = self._tune(root, dep) if vr and not is_android(dep) else None
|
||||
winhost.start_detached(root / "steam.exe", [f"steam://rungameid/{(int(dep['appid']) << 32) | 0x02000000}"])
|
||||
return {"package": package, "title": dep.get("title"), "steamvr": vr, "tuning": tuning}
|
||||
|
||||
@@ -295,8 +316,13 @@ class PcReviveTarget(Target):
|
||||
return result
|
||||
|
||||
def launch_test(self, package, reporter, seconds=45):
|
||||
reporter.stage("Launch test (this PC)")
|
||||
dep = self._dep(package)
|
||||
if is_android(dep):
|
||||
if self.axrb_backend and self.axrb_backend != pc_android.dep_backend(dep):
|
||||
raise RuntimeError(f"{dep['title']} is installed for AXRB's {pc_android.dep_backend(dep)} backend: "
|
||||
f"install it again with --axrb-backend {self.axrb_backend} first")
|
||||
return pc_android.launch_test(dep, reporter, seconds, triage)
|
||||
reporter.stage("Launch test (this PC)")
|
||||
image = Path(dep["exe_local"]).name
|
||||
if winhost.process_running(image):
|
||||
raise RuntimeError(f"{dep['title']} is already running")
|
||||
@@ -337,7 +363,14 @@ class PcReviveTarget(Target):
|
||||
return result, log
|
||||
|
||||
def set_settings(self, package, settings):
|
||||
return {"settings": {}}
|
||||
"""Quest games in AXRB: FrameBridge settings without a rebuild (pc_android.set_settings). Rift games have
|
||||
no FrameBridge."""
|
||||
dep = self._dep(package)
|
||||
if not is_android(dep):
|
||||
return {"settings": {}}
|
||||
result = pc_android.set_settings(dep, settings)
|
||||
self._save_dep(package, dep)
|
||||
return result
|
||||
|
||||
def uninstall(self, package, keep_data=True):
|
||||
dep = self._dep(package)
|
||||
@@ -348,9 +381,11 @@ class PcReviveTarget(Target):
|
||||
vdf = root / "userdata" / user / "config" / "shortcuts.vdf"
|
||||
was_running = winhost.stop_steam(root)
|
||||
try:
|
||||
removed = _vdf().remove_shortcut(str(vdf), shortcut_fields(dep)[0])
|
||||
removed = _vdf().remove_shortcut(str(vdf), shortcut_fields(dep)[0], options_key(dep))
|
||||
finally:
|
||||
if was_running:
|
||||
winhost.start_steam(root)
|
||||
android_removed = pc_android.uninstall(dep, keep_data) if is_android(dep) else None
|
||||
shutil.rmtree(pc_dir() / package, ignore_errors=True)
|
||||
return {"removed": True, "shortcut_removed": removed, "kept_saves": True}
|
||||
return {"removed": True, "shortcut_removed": removed, "kept_saves": keep_data or not is_android(dep),
|
||||
**({"android_removed": android_removed} if is_android(dep) else {})}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,259 @@
|
||||
"""Headset-free OpenXR on this Windows PC for unattended tests: Monado's simulated HMD (docs/PC_ANDROID.md "Testing
|
||||
without a headset").
|
||||
|
||||
A portable Monado for Windows (an official CI build of monado/monado, unpacked by hand, no installer) runs as
|
||||
monado-service.exe with the simulated builder. Only processes FramePort starts get it, through the OpenXR loader's
|
||||
XR_RUNTIME_JSON: the system's active OpenXR runtime (SteamVR) and SteamVR itself are never changed or started.
|
||||
Monado's compositor accepts D3D11 clients (XR_KHR_D3D11_enable) and asks them for the GPU it renders on (the
|
||||
discrete one), which is what AXRB's host bridge needs.
|
||||
|
||||
Off unless asked for: `--sim` on `frameport test --to pc`, or env FRAMEPORT_PC_XR_SIM=1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from ..core import winhost
|
||||
|
||||
ENV_FLAG = "FRAMEPORT_PC_XR_SIM"
|
||||
PIPE = "monado_comp_ipc" # Monado's IPC pipe: \\.\pipe\<its runtime dir = %TEMP%>\monado_comp_ipc
|
||||
FPS = 72 # the Quest's usual rate
|
||||
|
||||
# Monado's own settings for the service. The simulated builder only runs when asked for; "ml2" controllers are the
|
||||
# simulated ones whose bindings cover the Vive wand profile, so OpenXR apps that suggest Touch/Index/Vive bindings
|
||||
# (AXRB does) get two tracked controllers ("simple" and "wmr" ones stay inactive for them).
|
||||
SERVICE_ENV = {"SIMULATED_ENABLE": "1", "SIMULATED_LEFT": "ml2", "SIMULATED_RIGHT": "ml2",
|
||||
"XRT_COMPOSITOR_DEFAULT_FRAMERATE": str(FPS)}
|
||||
# No window at all (Monado's "null" compositor): frames are paced and accepted, nothing is shown or capturable.
|
||||
HEADLESS_ENV = {"XRT_COMPOSITOR_NULL": "1", "XRT_COMPOSITOR_NULL_FPS": str(FPS)}
|
||||
# Monado's qwerty builder instead: its head reports POSITION_TRACKED (the simulated head is valid but never tracked),
|
||||
# so AXRB 1.0.4's host bridge (the emulator backend, which only knows AXRB_TEST_SYNTHETIC_HMD=1 and applies it only
|
||||
# to a head without valid flags) sets its LOCAL origin and the guest gets tracked head poses. Its controllers are
|
||||
# WMR ones, which AXRB's suggested bindings don't cover: the hands stay inactive.
|
||||
TRACKED_HEAD_ENV = {"SIMULATED_ENABLE": "0", "QWERTY_ENABLE": "1"}
|
||||
HEAD_ENV_FLAG = "FRAMEPORT_PC_XR_SIM_HEAD" # "tracked" / "simulated": overrides the automatic choice
|
||||
WINDOW_TITLE = "Monado" # its compositor window: "Monado (Windowed)" = the simulated HMD's display, both eyes
|
||||
|
||||
|
||||
NOT_FOUND = "Monado for Windows was not found (see docs/PC_ANDROID.md \"Testing without a headset\")"
|
||||
|
||||
|
||||
class XrSimError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def enabled(flag: bool | None = None) -> bool:
|
||||
"""`--sim` given, or FRAMEPORT_PC_XR_SIM=1."""
|
||||
return bool(flag) or os.environ.get(ENV_FLAG, "").strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _dev_dir() -> Path | None:
|
||||
local = winhost.env_path("LOCALAPPDATA") if winhost.available() else None
|
||||
return local / "FramePort-dev" if local else None
|
||||
|
||||
|
||||
def monado_dir() -> Path | None:
|
||||
"""The portable Monado (folder with openxr_monado.json and bin/monado-service.exe): FRAMEPORT_MONADO_DIR, else
|
||||
%LOCALAPPDATA%\\FramePort-dev\\monado (its `install` folder as unpacked from the CI artifact). Local path."""
|
||||
bases = [Path(os.environ["FRAMEPORT_MONADO_DIR"])] if os.environ.get("FRAMEPORT_MONADO_DIR") else []
|
||||
dev = _dev_dir()
|
||||
if not bases and dev:
|
||||
bases = [dev / "monado"]
|
||||
for base in bases:
|
||||
for d in (base, base / "install"):
|
||||
if (d / "openxr_monado.json").is_file() and (d / "bin" / "monado-service.exe").is_file():
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def capture_exe() -> Path | None:
|
||||
"""xr_sim_capture.exe (native/xrsimprobe, built by hand): FRAMEPORT_XR_SIM_CAPTURE, else next to the dev probe."""
|
||||
if os.environ.get("FRAMEPORT_XR_SIM_CAPTURE"):
|
||||
p = Path(os.environ["FRAMEPORT_XR_SIM_CAPTURE"])
|
||||
return p if p.is_file() else None
|
||||
dev = _dev_dir()
|
||||
p = dev / "xr-sim-probe" / "xr_sim_capture.exe" if dev else None
|
||||
return p if p and p.is_file() else None
|
||||
|
||||
|
||||
def client_env(monado: Path | None = None) -> dict[str, str]:
|
||||
"""What a process FramePort starts needs to use the simulator instead of the system's runtime."""
|
||||
monado = monado or monado_dir()
|
||||
if not monado:
|
||||
raise XrSimError(NOT_FOUND)
|
||||
# AXRB_TEST_SYNTHETIC_HMD=2 (AXRB's host bridge, test-only): a fixed standing head and a valid LOCAL origin.
|
||||
# Monado's simulated head has no POSITION_TRACKED bit, so the bridge never sets up its LOCAL origin and reports
|
||||
# every head pose in LOCAL with flags 0; VrApi games (The Climb 2) then never get a tracked head and only submit
|
||||
# empty frames.
|
||||
return {"XR_RUNTIME_JSON": winhost.to_windows(monado / "openxr_monado.json"), "AXRB_TEST_SYNTHETIC_HMD": "2"}
|
||||
|
||||
|
||||
def tracked_head(auto: bool = False) -> bool:
|
||||
"""Run Monado's qwerty head (TRACKED_HEAD_ENV): FRAMEPORT_PC_XR_SIM_HEAD=tracked|simulated, else `auto` (the
|
||||
caller's choice: games on AXRB's emulator backend, whose host bridge has no synthetic head for a valid pose)."""
|
||||
value = os.environ.get(HEAD_ENV_FLAG, "").strip().lower()
|
||||
if value in ("tracked", "qwerty"):
|
||||
return True
|
||||
if value in ("simulated", "sim"):
|
||||
return False
|
||||
return auto
|
||||
|
||||
|
||||
def service_env(headless: bool = False, tracked: bool = False) -> dict[str, str]:
|
||||
return {**SERVICE_ENV, **(TRACKED_HEAD_ENV if tracked else {}), **(HEADLESS_ENV if headless else {})}
|
||||
|
||||
|
||||
def _ps_quote(s: str) -> str:
|
||||
return "'" + str(s).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def ps_env(env: dict[str, str]) -> str:
|
||||
"""PowerShell statements that set `env` for the processes the rest of the command starts."""
|
||||
return "".join(f"$env:{k} = {_ps_quote(v)}; " for k, v in env.items())
|
||||
|
||||
|
||||
# AXRB's run script registers the game with SteamVR (steamvr_app_identity.py, an OpenVR utility client that starts
|
||||
# SteamVR) whenever the system's active OpenXR runtime is SteamVR, whatever XR_RUNTIME_JSON says. Under the simulator
|
||||
# that call is answered "failed" instead (the script only warns), so SteamVR is never started or written to.
|
||||
NO_STEAMVR_SHIM = ("function python { if (\"$args\" -match 'steamvr_app_identity\\.py') "
|
||||
"{ Write-Output 'FramePort xr-sim: SteamVR registration skipped'; "
|
||||
"$global:LASTEXITCODE = 1; return }; & python.exe @args }; ")
|
||||
|
||||
|
||||
def launcher_prefix(env: dict[str, str]) -> str:
|
||||
"""PowerShell to put before a PC launcher call so the processes it starts use the simulator (empty without)."""
|
||||
if not env:
|
||||
return ""
|
||||
return ps_env(env) + (NO_STEAMVR_SHIM if "XR_RUNTIME_JSON" in env else "")
|
||||
|
||||
|
||||
def service_command(monado: Path, log_win: str, headless: bool = False, tracked: bool = False) -> str:
|
||||
"""The PowerShell command that runs monado-service.exe and waits for it; its log (Monado logs to stderr) goes to
|
||||
log_win, stdout next to it."""
|
||||
exe = winhost.to_windows(monado / "bin" / "monado-service.exe")
|
||||
return (ps_env(service_env(headless, tracked))
|
||||
+ f"$p = Start-Process -FilePath {_ps_quote(exe)} -WindowStyle Hidden -PassThru -Wait "
|
||||
f"-RedirectStandardError {_ps_quote(log_win)} -RedirectStandardOutput {_ps_quote(log_win + '.out')}; "
|
||||
"exit $p.ExitCode")
|
||||
|
||||
|
||||
def pipe_open() -> bool:
|
||||
"""Monado's service is up and takes clients."""
|
||||
r = winhost.powershell(f"[bool]([System.IO.Directory]::GetFiles('\\\\.\\pipe\\') -like {_ps_quote('*' + PIPE)})",
|
||||
timeout=30)
|
||||
return r.stdout.strip().lower().endswith("true")
|
||||
|
||||
|
||||
def log_path() -> Path:
|
||||
from ..core.paths import user_data_dir
|
||||
|
||||
return user_data_dir() / "logs" / "xr-sim-service.log"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Service:
|
||||
pid: int | None # the hidden PowerShell that holds monado-service.exe; None = one we didn't start
|
||||
log: Path
|
||||
monado: Path
|
||||
tracked: bool = False # qwerty head (TRACKED_HEAD_ENV); False for a service we didn't start (unknown)
|
||||
capture_prefix: str | None = None # Windows path prefix for AXRB's host bridge frame captures
|
||||
|
||||
def env(self) -> dict[str, str]:
|
||||
env = client_env(self.monado)
|
||||
if self.capture_prefix: # AXRB's opt-in readback of the layers the game submits (4 PPMs, 5 s apart)
|
||||
env.update({"AXRB_CAPTURE_PREFIX": self.capture_prefix, "AXRB_CAPTURE_FIRST_SEQUENCE": "300"})
|
||||
return env
|
||||
|
||||
|
||||
_active: Service | None = None
|
||||
|
||||
|
||||
def active_env() -> dict[str, str]:
|
||||
"""XR_RUNTIME_JSON for Monado while a simulator started by `running()` is up in this process, else {}: the hook
|
||||
the PC launchers read (targets/pc_android.py)."""
|
||||
return _active.env() if _active else {}
|
||||
|
||||
|
||||
def start(headless: bool = False, wait: float = 60, tracked: bool = False) -> Service:
|
||||
"""Start Monado's service hidden (nothing inherited from FramePort) and wait until its pipe is open. A service
|
||||
that is already running is used as it is (and left running by stop())."""
|
||||
monado = monado_dir()
|
||||
if not monado:
|
||||
raise XrSimError(NOT_FOUND)
|
||||
log = log_path()
|
||||
log.parent.mkdir(parents=True, exist_ok=True)
|
||||
if pipe_open():
|
||||
return Service(None, log, monado)
|
||||
log.unlink(missing_ok=True)
|
||||
from . import axrb # its WMI start: the service must not hold FramePort's (or WSL's interop) pipes
|
||||
|
||||
pid = axrb.spawn_detached(service_command(monado, winhost.to_windows(log), headless, tracked))
|
||||
end = time.time() + wait
|
||||
while time.time() < end:
|
||||
if pipe_open():
|
||||
return Service(pid, log, monado, tracked)
|
||||
text = log.read_text(errors="replace") if log.is_file() else ""
|
||||
if re.search(r"Server exiting|ERROR \[p_create_system\]", text):
|
||||
break
|
||||
time.sleep(1)
|
||||
stop(Service(pid, log, monado))
|
||||
tail = (log.read_text(errors="replace") if log.is_file() else "").strip().splitlines()[-5:]
|
||||
raise XrSimError("Monado's service didn't start" + (": " + " | ".join(tail) if tail else ""))
|
||||
|
||||
|
||||
def stop(service: Service) -> None:
|
||||
"""End the service (with its PowerShell and cmd parents) if we started it."""
|
||||
if service.pid:
|
||||
winhost.run_win([winhost.system32("taskkill.exe"), "/PID", str(service.pid), "/T", "/F"], timeout=30)
|
||||
|
||||
|
||||
def capture_dir() -> Path:
|
||||
from ..core.paths import user_data_dir
|
||||
|
||||
return user_data_dir() / "logs" / "xr-sim"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def running(on: bool, headless: bool = False, reporter=None, captures: bool = True, tracked: bool = False):
|
||||
"""Monado up for the `with` block when `on` (callers decide with enabled()), else nothing; active_env() names it
|
||||
meanwhile. With captures, AXRB's host bridge also saves a few frames of what the game submits to capture_dir().
|
||||
`tracked`: Monado's qwerty head instead of the simulated one (see tracked_head())."""
|
||||
global _active
|
||||
if not on:
|
||||
yield None
|
||||
return
|
||||
if reporter:
|
||||
reporter.stage("Starting the simulated headset (Monado)")
|
||||
service = start(headless, tracked=tracked)
|
||||
if captures:
|
||||
capture_dir().mkdir(parents=True, exist_ok=True)
|
||||
service.capture_prefix = winhost.to_windows(capture_dir()) + "\\" + time.strftime("%Y%m%d-%H%M%S-")
|
||||
_active = service
|
||||
if reporter:
|
||||
reporter.log(f"Monado: {service.monado} (log {service.log})"
|
||||
+ ("; head: qwerty (tracked)" if service.tracked else "")
|
||||
+ (f"; frame captures: {capture_dir()}" if captures else ""))
|
||||
try:
|
||||
yield service
|
||||
finally:
|
||||
_active = None
|
||||
stop(service)
|
||||
|
||||
|
||||
def capture(out: Path, title: str = WINDOW_TITLE) -> Path:
|
||||
"""Save Monado's compositor window (the simulated HMD's display: both eyes as the headset gets them) as a BMP.
|
||||
Needs the windowed compositor (not headless) and xr_sim_capture.exe."""
|
||||
exe = capture_exe()
|
||||
if not exe:
|
||||
raise XrSimError("xr_sim_capture.exe was not found (native/xrsimprobe)")
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
r = winhost.run_win([str(exe), winhost.to_windows(out), title], timeout=30)
|
||||
if r.returncode != 0 or not out.is_file():
|
||||
raise XrSimError((r.stdout + r.stderr).strip() or "capture failed")
|
||||
return out
|
||||
+47
-69
@@ -880,7 +880,7 @@ class FramePortApp:
|
||||
self.toast(msg, error=True, action=tr("Details"),
|
||||
on_action=lambda e: self.show_activity(True))
|
||||
self.refresh_view()
|
||||
if job.kind in ("scan", "task") and self.exe_queue: # (a task: a newer PC VR game version)
|
||||
if job.kind == "scan" and self.exe_queue:
|
||||
self.page.run_thread(self.next_exe_choice)
|
||||
elif job and job.active and job.package and self.route[:2] == ("game", job.package):
|
||||
pkg, transit = self.hero_transit or (None, None)
|
||||
@@ -987,10 +987,11 @@ class FramePortApp:
|
||||
frame_opt = (frame_label, G.FRAME, lambda e: self.install(pkg, "frame"), False, None) \
|
||||
if connected else (tr("Connect your Frame"), ft.Icons.LINK_ROUNDED, lambda e: self.go("frame"), False,
|
||||
tr("Connect your Steam Frame first."))
|
||||
if g.get("kind") != "rift":
|
||||
if not pipeline.pc_installable(g):
|
||||
return [frame_opt]
|
||||
from ..core import winhost
|
||||
|
||||
rift = g.get("kind") == "rift"
|
||||
on_pc = pkg in self.pc_installs()
|
||||
stale = on_pc and C.pc_outdated(g, self.pc_installs()[pkg])
|
||||
pc_label = (tr("Update on this PC") if stale else tr("Reinstall on this PC") if on_pc
|
||||
@@ -998,8 +999,12 @@ class FramePortApp:
|
||||
pc_opt = (pc_label,
|
||||
G.PC, lambda e: self.install(pkg, "pc"), not winhost.available(),
|
||||
tr("The launch settings changed since the install — update the Steam shortcut.") if stale else
|
||||
None if winhost.available() else tr("Needs Windows (or WSL)."))
|
||||
return [frame_opt, pc_opt] if connected or not winhost.available() else [pc_opt, frame_opt]
|
||||
tr("Needs Windows (or WSL).") if not winhost.available() else
|
||||
None if rift else C.HELP["pc_android"] + (
|
||||
"\n\n" + C.HELP["pc_android_gles"] if pipeline.pc_gles(g) else ""))
|
||||
# Quest games: the Frame stays the main place (the PC path through AXRB is experimental)
|
||||
pc_first = not connected and winhost.available() and (rift or on_pc)
|
||||
return [pc_opt, frame_opt] if pc_first else [frame_opt, pc_opt]
|
||||
|
||||
def play_options(self, g: dict) -> list[tuple]:
|
||||
"""[(label, icon, on_click, disabled, tooltip)] for where the game is installed and can be started now."""
|
||||
@@ -1008,10 +1013,11 @@ class FramePortApp:
|
||||
if self.frame_state == "connected" and C.install_state(g, self.frame_info) in ("installed", "outdated"):
|
||||
out.append((tr("Play on Frame"), ft.Icons.PLAY_ARROW_ROUNDED, lambda e: self.play(pkg, "frame"), False,
|
||||
tr("Starts the game on the Frame — put the headset on.")))
|
||||
if g.get("kind") == "rift" and pkg in self.pc_installs() and \
|
||||
if pipeline.pc_installable(g) and pkg in self.pc_installs() and \
|
||||
not ((g.get("analysis") or {}).get("extra") or {}).get("flat"):
|
||||
out.append((tr("Play on this PC"), ft.Icons.PLAY_ARROW_ROUNDED, lambda e: self.play(pkg, "pc"), False,
|
||||
tr("Starts the game in SteamVR on this PC.")))
|
||||
tr("Starts the game in SteamVR on this PC.") if g.get("kind") == "rift"
|
||||
else tr("Starts the game in SteamVR on this PC, in AXRB's Android emulator.")))
|
||||
return out
|
||||
|
||||
PLAY_COOLDOWN = 20 # s: a second Play while Steam/Lepton still start the game only gets Steam's AppError_16
|
||||
@@ -1152,7 +1158,6 @@ class FramePortApp:
|
||||
"move": lambda e: self.move_game(pkg),
|
||||
"update_app": lambda e: self.update_app_only(pkg),
|
||||
"new_apk": lambda e: self.page.run_task(self.update_with_apk, pkg),
|
||||
"new_version": lambda e: self.page.run_task(self.update_with_version, pkg),
|
||||
"uninstall_frame": lambda e: self.uninstall(pkg, "frame"),
|
||||
"uninstall_pc": lambda e: self.uninstall(pkg, "pc"),
|
||||
"remove": lambda e: self.remove_from_library(pkg),
|
||||
@@ -1163,14 +1168,13 @@ class FramePortApp:
|
||||
st = MenuState(
|
||||
quick=quick, connected=connected, job=busy,
|
||||
on_frame=connected and C.install_state(g, self.frame_info) in ("installed", "outdated"),
|
||||
on_pc=rift and pkg in self.pc_installs(),
|
||||
on_pc=pipeline.pc_installable(g) and pkg in self.pc_installs(),
|
||||
plays=[] if busy or not quick else enabled(self.play_options(g)),
|
||||
installs=[] if busy or not quick else enabled(self.install_options(g)),
|
||||
settings=self.has_game_settings(g), programs=rift or (linux and bool(self.linux_programs(g))),
|
||||
selectable=lv is not None,
|
||||
media_button=C.is_media_player(g) and not rift,
|
||||
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux,
|
||||
new_version=rift or linux)
|
||||
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux)
|
||||
return menu_sections(g, st, handlers.__getitem__)
|
||||
|
||||
def open_game_menu(self, pkg: str) -> None:
|
||||
@@ -1269,7 +1273,7 @@ class FramePortApp:
|
||||
|
||||
games = [library.game(p) for p in pkgs]
|
||||
games = [g for g in games if g and not self.jobs.busy_with(g["package"])]
|
||||
skipped = [g for g in games if to == "pc" and g.get("kind") != "rift" or
|
||||
skipped = [g for g in games if to == "pc" and not pipeline.pc_installable(g) or
|
||||
not allow_blocked and g.get("kind") != "rift"
|
||||
and (g.get("recipe") or {}).get("status") == "unsupported"]
|
||||
games = [g for g in games if g not in skipped]
|
||||
@@ -1286,10 +1290,38 @@ class FramePortApp:
|
||||
else:
|
||||
ask_frame_oculus()
|
||||
|
||||
def ask_axrb():
|
||||
# Quest games on this PC: AXRB (+ its Android) is downloaded on first use only, after asking
|
||||
quest = [g for g in games if g.get("kind") != "rift"]
|
||||
if not quest:
|
||||
return ask_obb()
|
||||
from ..tools import axrb
|
||||
|
||||
try:
|
||||
ready = axrb.ready() # either backend set up: AXRB was already agreed to
|
||||
except Exception: # noqa: BLE001 - unknown: ask
|
||||
ready = False
|
||||
if ready:
|
||||
return ask_obb()
|
||||
pick = C.one_choice()
|
||||
|
||||
def ok(e):
|
||||
self.page.pop_dialog()
|
||||
ask_obb()
|
||||
self.page.show_dialog(C.dialog(
|
||||
tr("Set up Quest games on this PC?"),
|
||||
C.body(C.HELP["pc_android"] + "\n\n" +
|
||||
tr("FramePort installs AXRB from its official release (per user, no administrator rights) and "
|
||||
"Google's Android emulator files. AXRB is a separate program with its own licence; "
|
||||
"you can remove it later from Windows' app list.")),
|
||||
[C.ghost(tr("Cancel"), on_click=pick(cancel)),
|
||||
C.primary(tr("Download and install"), on_click=pick(ok))],
|
||||
modal=True, on_dismiss=pick(closed)))
|
||||
|
||||
def ask_frame_oculus():
|
||||
# Installing an Oculus/LibOVR Rift game on the Frame: warn that it needs Revive (which can't run there)
|
||||
if to != "frame":
|
||||
return ask_obb()
|
||||
return ask_axrb()
|
||||
oculus = [g for g in games if g.get("kind") == "rift"
|
||||
and "pcvr.revive" in (g.get("recipe") or {}).get("patches", {})]
|
||||
if not oculus:
|
||||
@@ -1543,7 +1575,7 @@ class FramePortApp:
|
||||
as_is = library.recipe_from_dict(library.game(pkg)["recipe"]).as_is
|
||||
rep.stage("Checking the game" if rift or as_is else "Patching the game")
|
||||
if rift or to != "frame":
|
||||
info = pipeline.build_game(pkg, rep)
|
||||
info = pipeline.build_game(pkg, rep, pc=to == "pc")
|
||||
if not info["ok"]:
|
||||
raise RuntimeError(tr("the game failed its checks (see the list above)"))
|
||||
rep.check_cancel()
|
||||
@@ -1623,65 +1655,11 @@ class FramePortApp:
|
||||
return tr("{title}: newer APK added").format(title=self._title(pkg))
|
||||
self.submit(tr("Newer APK for {title}").format(title=self._title(pkg)), run, pkg, "task")
|
||||
|
||||
async def update_with_version(self, pkg: str) -> None:
|
||||
"""Game menu "Update with a newer version…" for Linux apps and PC VR games (GitHub #161): pick the new app
|
||||
file or folder (how the app was added) / the new game folder; the game keeps its name, art, tags and switches,
|
||||
and its page then offers Update on Frame (or on this PC)."""
|
||||
from ..analysis import linux as linux_analysis
|
||||
|
||||
if self.jobs.busy_with(pkg):
|
||||
return
|
||||
g = library.game(pkg) or {}
|
||||
rift = g.get("kind") == "rift"
|
||||
title = tr("Newer version of {title}").format(title=self._title(pkg))
|
||||
if rift or self._linux_folder_app(g):
|
||||
path = await C.pick_dir(self.page, dialog_title=title)
|
||||
if not path:
|
||||
return
|
||||
else:
|
||||
paths = await C.pick_files(self.page, dialog_title=title)
|
||||
if not paths:
|
||||
return
|
||||
path = paths[0]
|
||||
if not linux_analysis.looks_like_linux_app(Path(path)):
|
||||
self.toast(tr("{name} isn't a Linux app (AppImage, Linux program or .zip/.tar archive)")
|
||||
.format(name=Path(path).name), error=True)
|
||||
return
|
||||
on_frame = self.frame_state == "connected" and \
|
||||
C.install_state(g, self.frame_info) in ("installed", "outdated")
|
||||
on_pc = rift and pkg in self.pc_installs()
|
||||
|
||||
def run(job: Job):
|
||||
job.reporter.stage(tr("Reading the new version"))
|
||||
res = pipeline.replace_source(pkg, Path(path), job.reporter)
|
||||
if res.get("different"):
|
||||
self.toast(tr("Heads-up: {name} looks like a different app than {title}.").format(
|
||||
name=res.get("name") or Path(path).name, title=self._title(pkg)))
|
||||
if rift and res["entry"].get("exe_confirmed") is False: # which program starts it? (asked after the job)
|
||||
self.exe_queue.append(pkg)
|
||||
self.open_game(pkg)
|
||||
if on_frame or on_pc:
|
||||
return tr("{title}: newer version added. Press Update to install it.").format(
|
||||
title=self._title(pkg))
|
||||
return tr("{title}: newer version added").format(title=self._title(pkg))
|
||||
self.submit(tr("Newer version for {title}").format(title=self._title(pkg)), run, pkg, "task")
|
||||
|
||||
@staticmethod
|
||||
def _linux_folder_app(g: dict) -> bool:
|
||||
"""A Linux app added from a folder (its newer version is a folder too), not from an AppImage/archive."""
|
||||
from ..analysis import linux as linux_analysis
|
||||
|
||||
extra = (g.get("analysis") or {}).get("extra") or {}
|
||||
source = Path(extra.get("source") or g.get("game_dir") or "")
|
||||
if source.exists():
|
||||
return source.is_dir()
|
||||
return not extra.get("files") and not source.name.lower().endswith(linux_analysis.ARCHIVES)
|
||||
|
||||
def updatable(self) -> list[tuple[str, str]]:
|
||||
"""[(package, "frame" | "pc")] installs with an update ready (a newer build or changed patch settings)."""
|
||||
out = []
|
||||
frame_ok = self.frame_state == "connected"
|
||||
pc = self.pc_installs() if any(g.get("kind") == "rift" for g in library.games()) else {}
|
||||
pc = self.pc_installs()
|
||||
for g in library.games():
|
||||
pkg = g["package"]
|
||||
if self.jobs.busy_with(pkg):
|
||||
@@ -2013,7 +1991,7 @@ class FramePortApp:
|
||||
|
||||
def _diag_target(self, pkg: str | None):
|
||||
g = library.game(pkg) if pkg else None
|
||||
if g and g.get("kind") == "rift" and pkg in self.pc_installs() and \
|
||||
if g and pipeline.pc_installable(g) and pkg in self.pc_installs() and \
|
||||
C.install_state(g, self.frame_info) not in ("installed", "outdated"):
|
||||
return self._target_for("pc")
|
||||
return self.target if self.frame_state == "connected" else None
|
||||
|
||||
@@ -178,8 +178,6 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
|
||||
# still installed (GitHub #90)
|
||||
if not dep or (dep.get("apk_present") is False and not dep.get("drive_missing")):
|
||||
return "missing"
|
||||
if source_replaced(game, dep):
|
||||
return "outdated" # a newer version of the Linux app / PC VR game was added since this install (GitHub #161)
|
||||
b = game.get("build") or {}
|
||||
if game.get("kind") not in ("linux", "rift") and b.get("apk_recipe_fp") and dep.get("configuration_fp"):
|
||||
from ..game_update import plan_update
|
||||
@@ -196,16 +194,6 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
|
||||
return "outdated" if settings_diff(game, frame_info) else "installed"
|
||||
|
||||
|
||||
def source_replaced(game: dict, dep: dict | None) -> bool:
|
||||
"""A Linux app or PC VR game got a newer version ("Update with a newer version…", GitHub #161) that this install
|
||||
(the Frame's or the PC's deployment record) doesn't have yet."""
|
||||
sid = game.get("source_id")
|
||||
if not sid or game.get("kind") not in ("linux", "rift"):
|
||||
return False
|
||||
rec = (dep or {}).get("recipe")
|
||||
return (rec.get("source_id") if isinstance(rec, dict) else None) != sid
|
||||
|
||||
|
||||
def recipe_changed(game: dict) -> bool:
|
||||
from ..patches.base import recipe_fingerprint, revised_since_unrecorded
|
||||
|
||||
@@ -218,9 +206,16 @@ def recipe_changed(game: dict) -> bool:
|
||||
|
||||
|
||||
def pc_outdated(g: dict, dep: dict) -> bool:
|
||||
"""A PC install whose launch settings (pcvr patches + their parameters) differ from the game's recipe now."""
|
||||
"""A PC install whose launch settings (pcvr patches + their parameters) differ from the game's recipe now. Quest
|
||||
games (AXRB): the patches of their build for this PC."""
|
||||
installed = set((dep.get("recipe") or {}).get("patches") or [])
|
||||
want = set((g.get("recipe") or {}).get("patches") or {})
|
||||
if dep.get("kind") == "android":
|
||||
from ..core.library import analysis_from_dict
|
||||
from ..patches.base import pc_selection
|
||||
|
||||
analysis = analysis_from_dict(g["analysis"]) if g.get("analysis") else None
|
||||
return installed != set(pc_selection({p: {} for p in want}, analysis))
|
||||
from ..core.library import recipe_from_dict
|
||||
from ..patches.pcvr import game_args
|
||||
|
||||
@@ -228,7 +223,7 @@ def pc_outdated(g: dict, dep: dict) -> bool:
|
||||
args = game_args(recipe_from_dict(g["recipe"]))
|
||||
except Exception: # noqa: BLE001
|
||||
args = dep.get("game_args") or []
|
||||
return installed != want or list(dep.get("game_args") or []) != args or source_replaced(g, dep)
|
||||
return installed != want or list(dep.get("game_args") or []) != args
|
||||
|
||||
|
||||
def one_choice():
|
||||
|
||||
@@ -130,6 +130,13 @@ HELP: dict[str, str] = _Translated({
|
||||
"next time it starts.",
|
||||
"frame_summary": "Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR "
|
||||
"games can run on the Frame.",
|
||||
"pc_android": "Experimental: runs the game on this PC in AXRB (Android XR Bridge), an Android emulator that shows "
|
||||
"Android VR games through SteamVR. The first install downloads AXRB and its Android (about 2.5 GB) "
|
||||
"and needs Windows Hypervisor Platform, an NVIDIA or AMD graphics card and 12 GB of memory. Many "
|
||||
"games don't work in AXRB yet.",
|
||||
"pc_android_gles": "This game draws with OpenGL ES. AXRB's emulator copies such pictures to the PC frame by frame, "
|
||||
"so FramePort renders them at half size: expect about 30 frames per second at best, less with "
|
||||
"a high SteamVR resolution. Vulkan games don't have this limit.",
|
||||
# ---- settings
|
||||
"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.",
|
||||
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user