mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-09 19:01:05 +02:00
Compare commits
63
Commits
ui-audit-polish
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa473c5ada | ||
|
|
1e6ce04e4f | ||
|
|
5cd5b119e7 | ||
|
|
186138cc3c | ||
|
|
98c7ca980a | ||
|
|
b2470b962a | ||
|
|
2a705bc6d1 | ||
|
|
5d44831757 | ||
|
|
456880501b | ||
|
|
63f7ae12e1 | ||
|
|
fa69ffefd2 | ||
|
|
f78bc82cc4 | ||
|
|
dfacf6a206 | ||
|
|
b2f14c1e2e | ||
|
|
03d7a311bf | ||
|
|
cd5119278b | ||
|
|
6ab3608a44 | ||
|
|
c0539af368 | ||
|
|
c6363140f4 | ||
|
|
187105b729 | ||
|
|
b983c0f11f | ||
|
|
c7cee33889 | ||
|
|
d9d73b8aec | ||
|
|
48455b9b72 | ||
|
|
af798c0bf3 | ||
|
|
ed714c9d65 | ||
|
|
832b401630 | ||
|
|
114b7cb4d6 | ||
|
|
a4d3ce5e00 | ||
|
|
b3095b110a | ||
|
|
fb284b6fc4 | ||
|
|
c0df52b981 | ||
|
|
a1b0a3f350 | ||
|
|
df864e9fed | ||
|
|
6595a31048 | ||
|
|
f4bcb658e1 | ||
|
|
e4f70144e9 | ||
|
|
020334b37b | ||
|
|
24a98ab263 | ||
|
|
58563b99a5 | ||
|
|
9013a7d13c | ||
|
|
f36a17b46a | ||
|
|
7335ff2ce5 | ||
|
|
21c039f9b3 | ||
|
|
3e8a480ea6 | ||
|
|
ea2cd893b4 | ||
|
|
a90a8c27dc | ||
|
|
9d0d72c40a | ||
|
|
56d2ac117f | ||
|
|
cb8202d891 | ||
|
|
029b0674d1 | ||
|
|
c852473a43 | ||
|
|
acf1ef1da2 | ||
|
|
5fe120e897 | ||
|
|
41624c8dbd | ||
|
|
e8ec39db86 | ||
|
|
936d4b188b | ||
|
|
e9ab1489cc | ||
|
|
1043eb1d1b | ||
|
|
0f444da595 | ||
|
|
fda644d036 | ||
|
|
fa0fbbd29a | ||
|
|
3446999109 |
No files matched your search
@@ -71,6 +71,14 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
uploads are interruptible (Cancel checked per MiB) and resumable (big files via SFTP `.part` append, small files
|
||||
streamed in tar batches; the agent counts files already in `incoming/`). Multi-select in the Library queues
|
||||
installs after asking every needed question up front. Failures end in one pop-up (Resume / Uninstall / log).
|
||||
- Other drives (GitHub #90, agent v63): anchors stay in ~/Applications/quest-frame, a game's files may live in
|
||||
`<mount>/FramePort/<pkg>` (`deployment.json` base). Agent `drives` (/proc/mounts, /run/media + Steam library
|
||||
drives; vfat/exfat/ntfs/read-only refused), prepare*/finalize_linux `dest` (unmounted = error, never a fallback;
|
||||
installed games keep their base), `move` (detached systemd-run + `move_status`; cp -a under podman unshare,
|
||||
count+bytes check, symlink retarget, launch.sh: Quest app_dir line / Linux+PC VR rewritten from the record's
|
||||
`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.
|
||||
- **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).
|
||||
@@ -83,7 +91,11 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
doesn't do). `VD.bat` is Virtual Desktop's launcher: ignore it except as an exe-location hint. Library migration
|
||||
`rift_run_direct` resets existing recipes.
|
||||
Auto launch-test is skipped on PC installs (it would start the game on the user's desktop). Launch tests collect the Unreal
|
||||
game log + crash summaries from the Proton prefix; triage `unreal-crash`. Lies Beneath via Proton without Revive
|
||||
game log + crash summaries from the Proton prefix; triage `unreal-crash`. Agent v67 adds Unity's logs (LocalLow/<Company>/<Product>/Player(-prev).log via `<Name>_Data/app.info`,
|
||||
`output_log.txt`, Temp/…/Crashes/*/error.log; `unity_logs`) to launch tests and diagnostics; triage `unity-vr-init` /
|
||||
`unity-crash`. A catalog recipe verified with another build (catalog `xr` ≠ the build's) keeps the build's own
|
||||
`pcvr.launch_args` (`engine._other_build`; GitHub #105: SUPERHOT VR's Oculus+OpenVR build needs `-vrmode OpenVR`,
|
||||
the catalog's is the OpenXR build); Electron launchers next to a game rank −30 (`rift.is_electron`). Lies Beneath via Proton without Revive
|
||||
crashed (UE 4.23 "Unhandled exception").
|
||||
- Rift scanning: `sources/rift_dump.scan` = the scanned folder's subfolders are games (one per folder; a folder is a
|
||||
game if all candidate exes sit under one child), recursing into collections; `analysis/rift.py` walks once,
|
||||
@@ -131,6 +143,11 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
OpenXR, source) instead of recipe/patches, `C.missing_libraries` callout (Frame deployment, else last install);
|
||||
no Analyze/Rebuild/recipe/share/Game settings actions, Frame only; platform "Linux" badge + library filter; Steam
|
||||
tags "Linux app on Frame"/"Linux"; `_follow_catalog` skips them; local files of a lone AppImage = the file only.
|
||||
Desktop Mode entries (GitHub #84, agent v63): finalize_linux writes `frameport-<slug>.desktop` to
|
||||
~/.local/share/applications (+ ~/Desktop if it exists; `X-FramePort-Package` marks ours), launch.sh with
|
||||
`FRAMEPORT_DESKTOP=1` skips the Steam-parent watchdog and Steam's display; ensure_host_fixes refreshes entries
|
||||
(older installs, stale ones removed), uninstall/purge remove them. Per app: library entry field `desktop_entry`
|
||||
(default on; patches don't apply to Linux apps) → game page switch → agent `desktop_entry`. Untested on device.
|
||||
- Quest/Rift twins stay separate entries, shown and named in Steam "Title (Quest)"/"(Rift)" (`core/titles.py`).
|
||||
- `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
|
||||
@@ -146,6 +163,10 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
- `patches/` — **the unit of modularity**. `base.py` (Patch interface, registry), `overport.py` (overport CLI patch ids,
|
||||
discovered dynamically via `overport patches`), `frame/*.py` (one module per Frame fix), `settings.py` (FrameBridge
|
||||
adapter keys + device files as patches). Add a patch = add a module that calls `register(...)`.
|
||||
`upstream.py`: upstream fixes that replace a workaround per build (a probe finds the fix in OVRPort's output →
|
||||
the build leaves the workaround out, `build.superseded`; recipes unchanged). Registered: `ovrport.haptic_envelope`
|
||||
(→ `adapter.haptic_fix`, `frame/haptic_envelope.py`) and `ovrport.microphone_stream` (→ `frame.ovr_microphone`),
|
||||
both fixed in OVRPort runtime 3.4.3-aa54c3f (ovrport/app#73; haptics owner-verified with Lucky's Tale 2026-10-07).
|
||||
- `analysis/` — APK/ELF inspection (`detect.py`), `elf.py` (pyelftools reads; own DT_NEEDED writer), `stubgen.py`
|
||||
(generates the ovr_* stub .so without a compiler).
|
||||
- `apk/` — `axml.py` (binary manifest editor), `workspace.py` (staged zip edits), `sign.py` (apksigner; it aligns too).
|
||||
@@ -520,7 +541,7 @@ recipe keeps it on). I Am Cat works with issues (judder; scale 0.8 no help). Rob
|
||||
libroblox, also with the Vulkan shim). Installs skip the Steam restart when the shortcut is unchanged and wait while a
|
||||
game runs (agent v37). New default fixes in FramePort's code reach games already in a library: after every app update
|
||||
`library._follow_catalog` re-derives each non-user recipe once (setting `recipes.app_version`; tests switch it off via
|
||||
`library.REFRESH_ON_UPDATE`); analysis fields added later still need a re-analysis. Troubleshooting techniques: docs/PLAYBOOK.md "Debugging techniques". Unity `boot.config` "vulkan" substring mislabels GLES games as Vulkan
|
||||
`library.REFRESH_ON_UPDATE`). Analysis fields added later: **bump `analysis/detect.ANALYSIS_VERSION`** (stored as `analysis.extra.analysis_version`); older entries whose APK is still there are analysed again at the GUI's start (background thread, not a job: ~8 s per 900 MB APK) and before a build (`pipeline.refresh_analyses`, GitHub #104); only `analysis`/`suggested` change, user recipes stay; an unreadable APK gets `analysis_failed` = the version (not retried until the next bump). Troubleshooting techniques: docs/PLAYBOOK.md "Debugging techniques". Unity `boot.config` "vulkan" substring mislabels GLES games as Vulkan
|
||||
(I Am Cat ran GLES); OVRPlugin's "Unavailable OpenXR extension: XR_FB_scene" is routine (no longer triaged).
|
||||
**Round 4 (2026-10-04):** XR_KHR_android_surface_swapchain is listed by the Frame's runtime but returns
|
||||
FUNCTION_UNSUPPORTED → adapter `surface_emul` (default on, `native/adapter/surface_swapchain.c`): an ordinary runtime
|
||||
@@ -597,6 +618,7 @@ FrameBridge `snapshot=N` (`snapshot_gl.c`, GLES): every N s the left-eye image t
|
||||
own context and saved as `files/fb_snap_0-7.ppm` (quarter size) — headless launches show a black headset view, this
|
||||
shows what the game draws. Vader: Lucasfilm logo (an OBB mp4: video works), then its loading card (portrait + segmented
|
||||
bar) that never advances; the async loader thread sleeps and OBB reads stop (~168 MB of a 2.7 GB pak).
|
||||
**Lepton's logcat mirror dies (agent v67, 2026-10-09):** occasionally launch.log ends with `logcat: Unexpected EOF!` right after the game starts (about 1 launch in 50, Lepton 2.8.14 and 3.0.5): no dashboard auto-hide, no launch-test result. launch.sh's `_logcat_keeper` then reads `podman exec lepton-steamlaunch-<appid> logcat` itself into launch.log (up to 5 restarts while the game runs).
|
||||
**Double launch (agent v57, 2026-10-06):** a second Play while Lepton still boots (~10 s with nothing to see) made
|
||||
the second Lepton stop the first one's container ("Waiting for steamlaunch-<appid> (PID …) to exit", exit 137
|
||||
"(starting)", "Clearing baked app data due to early exit") and both died (Vader, BattleSisters). launch.sh now takes
|
||||
@@ -624,6 +646,26 @@ head-pose deadline ends VR mode without a worn headset (not a game bug).
|
||||
|
||||
**Language packs (merged from PR #20, 2026-10-04):** overport's `libovrplatformloader.so` is a dispatcher that `dlopen`s Meta's own loader (`libovrplatformloader_meta.so` / `_meta_q1.so`, also `libpxrplatformloader.so`) and keeps its own message queue; `ovr_LanguagePack_GetCurrent/SetCurrent` are 8-byte `return 0` stubs in it, `ovr_AssetFile_GetList` forwards to Meta's loader. `frame.langpacks` (opt-in, `native/langpack`) serves `<tag>.lang` files from the game's data; `elf.hide_exports` marks the loader's exports STB_LOCAL (bionic and glibc only match GLOBAL/WEAK; verified for glibc, bionic's `is_symbol_global_and_defined` is from memory). `libfp_langpack.so` is built here (`python native/build.py --only langpack`) and committed. Owner-verified 2026-10-04: Deadpool VR with only `en.lang` and the patch on plays English dialogue and runs normally (headless launch tests show 2-6 fps while it loads: not a regression sign). A dispatcher answer that arrives after we answered the timed-out GetList is dropped (one answer per request); games already in a library get `lang_packs` filled after an app update (`library._refresh_data_fields`). The library logs to logcat under the tag `fp_langpack` (dirs looked in, packs found, every language-pack call), so it shows up in the game's `launch.log`; an `ovr_AssetFile_GetList` the dispatcher leaves unanswered for 1.5 s is answered with our packs alone. Deadpool VR (Unreal) accepts a pack only when its `Metadata` equals the game's own version string (`ULanguagePacksSubsystem` compares it with `%s.%s.%s.%s.%s` built from the build info, e.g. `1.0.40.356975.Quest` = versionName; found by disassembling `libUE4.so`): the patch writes the APK's versionName into the library (`@FPMETA@` slot, `with_metadata`), `FRAMEPORT_LANGPACK_META` overrides it. Deadpool VR (2026-10-04, headset): German became selectable with that Metadata, but dialogue stayed silent (even English once reported as a pack) while the path was spelled `/sdcard/Android/obb/<pkg>/x.lang`; with the `/storage/emulated/0/Android/obb/<pkg>/x.lang` spelling (Unreal's own, now listed first in `scan_dirs`) German dialogue plays. `FRAMEPORT_LANGPACK_SKIP=<tags>` or a file `fp_langpack_skip` in the obb folder leaves packs out of the list (experiments).
|
||||
|
||||
**Cube swapchains (GitHub #107, 2026-10-09):** the Frame's runtime refuses `faceCount=6` swapchains (-2,
|
||||
XR_ERROR_RUNTIME_FAILURE); OVRPlugin ignores that ("CreateSwapchain for eye 0: 0x0, 0 stages") and crashes in
|
||||
ovrp_EndFrame4 (memset). FrameBridge `cube_standin` (default on, `native/adapter/cube_standin.c`) serves a refused cube
|
||||
swapchain as one GL cube-map texture in the app's context (GLES only) and drops its layers. Verified headless with
|
||||
Budget Cuts Ultimate (2048² sRGB, 12 mips; runs on at ~70 fps); what the cube layer showed is simply missing.
|
||||
**Vivox API 31 (GitHub #101, 2026-10-09):** newer Vivox builds (Green Hell VR) call Android 12 AudioManager
|
||||
communication-device methods from `com.vivox.sdk.AudioChangeListener` with no SDK check → NoSuchMethodError on Lepton's
|
||||
Android 11. `frame.vivox_audio_route` (analysis `vivox_api31`, ANALYSIS_VERSION 4) makes every such method return at
|
||||
once (`Dex.return_early`: return-void / `const/4 v0,0; return v0`; nopping the invoke would leave a move-result the
|
||||
verifier rejects). Older Vivox (Eleven Table Tennis, BattleSisters) lacks that code and isn't matched. Verified
|
||||
headless: Vivox initialises, 150 s at ~65-70 fps.
|
||||
**Multiview programs on flat framebuffers (GitHub #77, Doom3Quest, 2026-10-09, prototype):** Mesa enforces OVR_multiview's
|
||||
"program num_views == draw framebuffer views" rule (`draw_validate.c`, the draw is dropped silently); Qualcomm doesn't.
|
||||
Doom3Quest compiles every VS with `layout(num_views=2) in;` and draws its HUD/PDA into 2D-texture FBOs → black. Opt-in
|
||||
`frame.gl_multiview_fbo` (analysis `gl_multiview_libs`, ANALYSIS_VERSION 6; own-engine GLES only): `native/glmv` =
|
||||
`libfpglmv.so` (12 chars = "libGLESv3.so": libdoom3.so's one `.rodata` dlopen string is rewritten in place, its qgl*
|
||||
table comes from dlsym on that handle; also first DT_NEEDED for its direct gl*/egl* imports). Such draws use a lazily
|
||||
built single-view twin (view 0, uniforms copied per draw); eglMakeCurrent resets the per-thread cache. Host-tested only
|
||||
(rewriter on all 19 Doom3Quest shaders + glmv.c against a stand-in GL, `tests/test_gl_multiview_fbo.py`); the host's
|
||||
Mesa 23.2 llvmpipe has no OVR_multiview, so no real-driver test yet. Not in the catalog until a headset test.
|
||||
**Unresolved (as of 2026-09-28):** Arcsmith (right-eye distortion) and Time Stall (both eyes) — swap, tracking, Valve
|
||||
layers, depth, pacing ruled out. Sniper Elite VR (DEVICE LOST), Espire 1 (Mesa GL upload crash), HITMAN 3 (freedreno
|
||||
crash): use PC versions.
|
||||
|
||||
+1034
-38
File diff suppressed because it is too large.
Load diff
@@ -1,14 +1,16 @@
|
||||
5becb96ee86fbda0ce98c0e0fd064f8ba092314aceaa400e3dcc785705dc3c23 ./arm64-v8a/libVkLayer_fp_shaderfix.so
|
||||
e7554c6343ee2989b0a273ee6230e65c25bfe6499eefd00de19f5f0fee58754f ./arm64-v8a/libfp_langpack.so
|
||||
98a99cefe93079268d9158b23fa8ad5c7ade8e54c0d31526892635625eb3ef04 ./arm64-v8a/libfp_ovrp.so
|
||||
321da502e0f8f46f0880aad39fe6b84bdef88f925164568814cf704a1b31a577 ./arm64-v8a/libfp_ovrp.so
|
||||
40defdaddcda53bd2649eb48076fae1622bfc2bfc88e9e03c94fc8b45af2a4f2 ./arm64-v8a/libfp_ovrtrace.so
|
||||
7522a7cfb236e48d3442d418c4820cd37760a9627c6bdef80fb0df82df5fd826 ./arm64-v8a/libfp_vk.so
|
||||
383054f8b3b41dde76d062c71856cd3163655e50009c08454bc1793f393ead65 ./arm64-v8a/libfpg.so
|
||||
06c8a17b37cb1513fdfeba87ece457a02097bb22dd0a3a3a8719fb8fea16b52a ./arm64-v8a/libframe_xrshim.so
|
||||
9f5ba793d1804728394a8fe9e83028e13f1bc939b558892e7beb5c3662317259 ./arm64-v8a/libglshim.so
|
||||
cc5b493cadefe76ad13fd096d3f8494a140ba593e0e1692db12d284bc548c1cf ./arm64-v8a/libopenxr_loader_generic.so
|
||||
5432674a59d02f411cd853a5dc57e3fa547d1ac518f25bf433fc54c43677177a ./arm64-v8a/libfpglmv.so
|
||||
8a8f6b1da8952cb3933a5b53558424a5fc56043b2527a16bb6dd62a00d2de01a ./arm64-v8a/libframe_xrshim.so
|
||||
fc559dbe0b87eecc3641256837293260cefb7c2ce7b21d808d10edd2a2dba9fc ./arm64-v8a/libglshim.so
|
||||
13137a4dea2567c3c43081a24c220e1070e4f0d8a52c8c936a4926e8edd05792 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
|
||||
aa4dc0020c77e12d41ef6ce80b23ad90c9cb7e882338eddf2261efe8645cf38d ./arm64-v8a/libvrapi.so
|
||||
c10412dd76a65a1d587b7ed226367d1a6f2bec238c733c97ee67eb8870c868e8 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
0ed9f3509498871d7ac5d9c10cd4063f7d02f70179fd5f0f7260c323938e783d ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
|
||||
1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json
|
||||
28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.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.
@@ -0,0 +1,19 @@
|
||||
package: com.CortopiaStudios.Graves
|
||||
title: GORN2
|
||||
status: works
|
||||
tested_version: 1.10.1
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-07'
|
||||
known_good_sha256: d8a7949edc90c19902eb89f888ad6a350475387d630991767b3097667ad16d4c
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
issue: 88
|
||||
source_hint: GORN 2
|
||||
@@ -0,0 +1,24 @@
|
||||
package: com.CyberneticWalrus.DoesitStackMeta
|
||||
title: Does it Stack?
|
||||
status: issues
|
||||
notes: Mixed reality mode does not work (works on Demeo), everything else seems to be OK
|
||||
tested_version: 1.02 (3683)
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
refresh_rate: 90.0
|
||||
verified:
|
||||
date: '2026-10-09'
|
||||
known_good_sha256: e9fce6ac812f1bb317a7a6959be89c5ff54e87f56af9ad4c42dff5fecad4efe8
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261006.6173745'
|
||||
agent: 59
|
||||
issue: 112
|
||||
source_hint: Does It Stack
|
||||
@@ -0,0 +1,20 @@
|
||||
package: com.ForwardXP.nuke
|
||||
title: Please Don't Touch Anything
|
||||
status: works
|
||||
notes: Seems functional
|
||||
tested_version: '2.2'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-08'
|
||||
known_good_sha256: 17460c8691b9ff82f6c7ca5597afd0aacf1a94aa30ebb6a6108802991b60f51b
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261006.6173745'
|
||||
agent: 59
|
||||
issue: 106
|
||||
source_hint: apks
|
||||
@@ -0,0 +1,21 @@
|
||||
package: com.FourPlayersStudio.Retronika
|
||||
title: Retronika
|
||||
status: works
|
||||
tested_version: 3.2.601
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-07'
|
||||
known_good_sha256: 290bfda3969547d5f1ff621ab5a8be9dd322fc9eee6b243c863576d4ff5b4b81
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261006.6173745'
|
||||
agent: 59
|
||||
issue: 97
|
||||
source_hint: Retronika
|
||||
@@ -0,0 +1,21 @@
|
||||
package: com.FunktronicLabs.TheLightBrigade
|
||||
title: The Light Brigade
|
||||
status: issues
|
||||
notes: Judder in weapons
|
||||
tested_version: '746'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-09'
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 114
|
||||
source_hint: The Light Brigade
|
||||
@@ -1,20 +1,36 @@
|
||||
package: com.ILMxLAB.VaderImmortal.ep1
|
||||
title: 'Vader Immortal: Episode I'
|
||||
status: issues
|
||||
notes: Starts in VR and plays the intro, then stays on the loading card (Vader's portrait with a progress bar).
|
||||
details: Unreal Engine 4 (GLES). After the Lucasfilm intro the game shows its loading card and never loads the next
|
||||
scene; it keeps rendering at 72 fps and ignores input. Ruled out so far - Meta platform requests (all answered),
|
||||
video playback (the intro is a video and plays), the no-ForceQuit build, frame and swapchain handling. The Frame's
|
||||
SteamVR runtime also leaks about 20 MB of memory a second while the game runs, which slows it down after a few
|
||||
minutes. Next steps - find what the loading code waits for in the game's engine library (no symbols) and report
|
||||
the runtime leak to Valve. GitHub issue 49.
|
||||
status: works
|
||||
notes: 'Plays (owner''s headset test): loading card, grip/trigger, hands and the lightspeed sequence fixed. Thumbs
|
||||
follow the touches with frame.unreal_thumb_touch (Klownicle''s engine fix).'
|
||||
details: Unreal Engine 4 (GLES). Found and verified in gameplay on a Frame by Klownicle (GitHub issue 49); FramePort
|
||||
reimplements them as patches that match the game's code exactly and change nothing when it differs. The engine's
|
||||
Quest checks (IsRunningOnSantaCruz) answer "not a Quest" on the Frame - the menu waited for a Quest-only shader
|
||||
precompile that never starts (frame.unreal_quest_precompile makes the non-Quest branch report 100 %), and the
|
||||
game's key map picked the empty Gear VR controls (frame.unreal_quest_keymap keeps the Quest set). Two lightspeed
|
||||
shaders, as the Frame's GL driver (Zink) compiles them, read loop counters and accumulators before setting them
|
||||
and hang the GPU; a Vulkan layer under Zink inserts the stores that zero them (frame.zink_shader_fix, zink_shader_fix;
|
||||
the captured modules are from Lepton 2.8.14 and stop matching if a Frame update changes the driver's output -
|
||||
the layer logs that). OVRPlugin asks for poses at its own clock (pose_time_fix). Unreal's Oculus input animates the
|
||||
thumbs from near-touch, which the Frame never reports (frame.unreal_thumb_touch reads the touches instead; binding
|
||||
the proximity to the touch inputs in FrameBridge didn't reach the game). The Frame's SteamVR runtime
|
||||
leaks about 20 MB a second while the game runs (reported to Valve).
|
||||
tested_version: 1.1.1+667256.cl.387770
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
frame:
|
||||
- frame.unreal_quest_precompile
|
||||
- frame.unreal_quest_keymap
|
||||
- frame.unreal_thumb_touch
|
||||
- frame.zink_shader_fix
|
||||
adapter:
|
||||
pose_time_fix: 1
|
||||
zink_shader_fix: 12016:b2919629761ad268e0b21c72ac8520f81b76e72c3adc26897f97eae9a3e70a66:3092:0x0003003e,166,82,0x0003003e,177,82,0x0003003e,183,82,0x0003003e,189,82,0x0003003e,256,82,0x0003003e,266,82,0x0003003e,272,82,0x0003003e,278,82,0x0003003e,318,82,0x0003003e,328,82,0x0003003e,334,82,0x0003003e,340,82;11436:6f18be49f2aaa2452f962eb16f7af744c5c22a7b25f37ce681e0d6399e35a869:2932:0x0003003e,164,80,0x0003003e,175,80,0x0003003e,181,80,0x0003003e,187,80,0x0003003e,254,80,0x0003003e,264,80,0x0003003e,270,80,0x0003003e,276,80,0x0003003e,316,80,0x0003003e,326,80,0x0003003e,332,80,0x0003003e,338,80
|
||||
verified:
|
||||
date: '2026-10-06'
|
||||
date: '2026-10-09'
|
||||
issue: 49
|
||||
updated: '2026-10-06'
|
||||
updated: '2026-10-09'
|
||||
min_app: 0.12.1
|
||||
source_hint: Vader Immortal- Episode I
|
||||
@@ -4,14 +4,25 @@ tested_version: '1.03'
|
||||
source_hint: Sniper Elite VR
|
||||
engine: Unity
|
||||
xr: VrApi
|
||||
status: unsupported
|
||||
notes: 'GPU hang (zink: DEVICE LOST) even with MSAA off.'
|
||||
details: Legacy VrApi via OVRPlugin.
|
||||
status: works
|
||||
notes: Plays with controllers that follow the hands (owner's headset test, Quest 1 build 35713).
|
||||
details: 'Unity 2019.4 on its built-in Oculus VR (no Oculus XR Plugin). Its legacy frame loop never waited for frames
|
||||
(GPU hang, zink: DEVICE LOST) and Unity reported only Go controllers: frame.unity_oculus_check. Its frame wait then
|
||||
deadlocked at the Init scene (Unity skipped beginning a frame) and the physics-step pose update located the
|
||||
controllers in the past (hands lagged): both fixed in that patch (revision 5), switched on for this game by ovrp_begin_gate and ovrp_hold_physics. Grabbing an object in the tutorial
|
||||
vibrates the controller; OVRPort''s envelope conversion then allocated ~1 GB/s until the memory cap ended the game:
|
||||
haptic_fix. The Quest 1 build has no arraysources_assets.hd, which the game loads on the Frame (it detects a Quest
|
||||
2) and then skips the .sd bundle: textures are missing unless the .sd file is also present under the .hd name in the
|
||||
game''s obb folder.'
|
||||
frame:
|
||||
- frame.unity_no_msaa
|
||||
- frame.unity_oculus_check
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
ovrp_begin_gate: 1
|
||||
ovrp_hold_physics: 1
|
||||
pcvr_alternative: Sniper Elite VR (Steam PC VR). Winter Warrior (Quest) works on the Frame.
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: e80883eb6945e267a375edf0ffd789268686440819add9d4dee9fdbdf466cbe4
|
||||
date: '2026-10-07'
|
||||
updated: '2026-10-07'
|
||||
min_app: 0.12.1
|
||||
@@ -0,0 +1,24 @@
|
||||
package: com.MightyCoconut.WalkaboutMiniGolf
|
||||
title: Walkabout Mini Golf
|
||||
status: works
|
||||
tested_version: '6.7'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
controller_models: 1
|
||||
haptic_fix: 1
|
||||
scale: 1.5
|
||||
verified:
|
||||
date: '2026-10-08'
|
||||
known_good_sha256: cf3b5cbe131af8a2baaa3f5c665b1c7001c8b299473abfcf98d90e3c02b08a2f
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 111
|
||||
source_hint: Walkabout Mini Golf
|
||||
@@ -1,12 +1,14 @@
|
||||
package: com.PixelToys.BattleSisters
|
||||
title: BattleSisters
|
||||
status: works
|
||||
notes: Starts in VR; controller buttons and vibration work.
|
||||
details: Unity 2019.4 with Unity's built-in Oculus support. frame.unity_oculus_check starts VR (Meta's system-app check),
|
||||
adds the frame wait its legacy loop never makes (without it the GPU hung, black screen) and lets Unity's Oculus
|
||||
input accept Lepton's device model (it only reported controllers on a device named "Oculus ...", so buttons were
|
||||
dead). haptic_fix stops the first controller vibration from freezing the Frame (OVRPort's loader read its duration
|
||||
in nanoseconds as seconds and the game ran out of memory).
|
||||
notes: Plays; controller buttons and vibration work, hands follow the controllers (pose_time_fix).
|
||||
details: Unity 2019.4 with Unity's built-in Oculus support. frame.unity_oculus_check starts VR (Meta's system-app
|
||||
check), adds the frame wait its legacy loop never makes (without it the GPU hung, black screen) and lets Unity's
|
||||
Oculus input accept Lepton's device model (it only reported controllers on a device named "Oculus ...", so buttons
|
||||
were dead). haptic_fix stops the first controller vibration from freezing the Frame (OVRPort's loader read its
|
||||
duration in nanoseconds as seconds and the game ran out of memory). With pose_time_fix the hands follow the controllers
|
||||
- OVRPlugin asked for hand poses at Android's monotonic "now", which on SteamOS 0.4.5 is 2.56 s behind the runtime's
|
||||
XrTime, so the hands lagged far behind.
|
||||
tested_version: 1.2.4
|
||||
engine: Unity
|
||||
xr: VrApi
|
||||
@@ -18,9 +20,10 @@ frame:
|
||||
- frame.unity_gl_shim
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
pose_time_fix: 1
|
||||
verified:
|
||||
date: '2026-10-06'
|
||||
date: '2026-10-08'
|
||||
issue: 48
|
||||
updated: '2026-10-06'
|
||||
min_app: 0.11.1
|
||||
updated: '2026-10-08'
|
||||
min_app: 0.12.1
|
||||
source_hint: Battle Sister
|
||||
@@ -1,20 +1,25 @@
|
||||
package: com.StressLevelZero.BONELAB
|
||||
title: BONELAB
|
||||
status: issues
|
||||
notes: Build 1.2068 plays (head tracking and controls work with frame.unity_user_presence); the newer build
|
||||
1.2974 crashes at start (Vulkan), no fix yet.
|
||||
details: The game runs (72 fps), but its player body stayed frozen - no head tracking, the controllers stuck to the
|
||||
status: works
|
||||
notes: Builds 1.2068 and 1.2974 play (owner's headset tests). One recipe serves both builds - 1.2068 needs frame.unity_user_presence (head tracking and controls); 1.2974 also needs
|
||||
frame.slz_vulkan_hooks (72 fps after about 35 s of shader prewarming), which changes nothing on 1.2068.
|
||||
details: Both builds - the game runs (72 fps), but its player body stayed frozen - no head tracking, the controllers stuck to the
|
||||
model, no buttons - although OpenXR input and OVRPlugin's poses were fine. A few seconds after start (when Unity
|
||||
switches XR loaders) OVRPlugin reports the worn headset as not worn (ovrp_GetUserPresent2 -> 0), and BONELAB's
|
||||
Marrow rig only follows the player while Unity's HMD device reports UserPresence (OpenControllerRig checks
|
||||
XRHMD.IsUserPresent every frame). frame.unity_user_presence reports the headset as worn. Build 1.2974 crashed
|
||||
earlier in vkCreateInstance from the game's own Vulkan hooks (libSLZQuestNative.so); not retested.
|
||||
tested_version: 1.2068.34555
|
||||
XRHMD.IsUserPresent every frame). frame.unity_user_presence reports the headset as worn. Build 1.2974 only - it adds Stress
|
||||
Level Zero's graphics plugin (libSLZQuestNative.so), which hooks Unity's Vulkan start-up (vkCreateInstance/
|
||||
vkCreateDevice) and vkCreateSampler; on the Frame its vkCreateInstance wrapper calls an invalid pointer right after
|
||||
OVRPlugin's pre-init instance is destroyed (a jump to 0, into an unloaded library, or a hang). frame.slz_vulkan_hooks
|
||||
switches its two registrations off, so Unity starts Vulkan itself; the plugin's pipeline cache isn't used.
|
||||
tested_version: 1.2974.57485
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_user_presence
|
||||
- frame.slz_vulkan_hooks
|
||||
verified:
|
||||
date: '2026-10-06'
|
||||
updated: '2026-10-06'
|
||||
date: '2026-10-08'
|
||||
updated: '2026-10-08'
|
||||
source_hint: BONELAB
|
||||
min_app: 0.12.1
|
||||
@@ -0,0 +1,22 @@
|
||||
package: com.ToastVR.Matilda
|
||||
title: Max Mustard
|
||||
status: works
|
||||
tested_version: 1.1.0
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-08'
|
||||
known_good_sha256: 9a67459fa36c3b9ac0d035e6c43b1809b39772699333e8cfd7d2a4d7f17926a7
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 110
|
||||
updated: '2026-10-09'
|
||||
source_hint: com.ToastVR.Matilda
|
||||
@@ -0,0 +1,22 @@
|
||||
package: com.ToastVR.RichiesPlankExperience
|
||||
title: Richie's Plank Experience
|
||||
status: works
|
||||
tested_version: 2.3.585
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-07'
|
||||
known_good_sha256: 7eb1366c2398e554624a5d66aefb0c731c64b1b6944bc1726d9187f123c37fb7
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20260922.6101926'
|
||||
agent: 59
|
||||
issue: 95
|
||||
source_hint: apks
|
||||
@@ -0,0 +1,23 @@
|
||||
package: com.VR_HOT.VR_HOT_Quest
|
||||
title: VR HOT Quest
|
||||
status: works
|
||||
tested_version: 0.8.8.1
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
controller_models: 1
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-08'
|
||||
known_good_sha256: c321606c536eba1673572a9f44fd9e20d2cb5bd9609d536189e6ad7057b9cd67
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20260922.6101926'
|
||||
agent: 59
|
||||
issue: 98
|
||||
source_hint: VR-HOT-Quest-0.8.8.1
|
||||
@@ -0,0 +1,25 @@
|
||||
package: com.YourCompany.SWP_VR
|
||||
title: Star Wars Pinball VR
|
||||
tested_version: '1.4'
|
||||
source_hint: Star Wars Pinball VR
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
status: works
|
||||
notes: Plays (owner's headset test); one short audio hiccup seen.
|
||||
details: 'Unreal 4.25 (GLES) built against OVRPlugin 1.44. Four Frame problems in a row: Zink crashed on Unreal''s
|
||||
multisampled render-to-texture (fault addr 0x10000 on the RHIThread): frame.unreal_gl_shim, which keeps Unreal''s
|
||||
multiview and draws those passes single-sampled; OVRPort''s OVRPlugin lacks ovrp_GetPTWNear, so Unreal''s Oculus
|
||||
module never started VR (then a null pointer in GetMultiViewSceneColor): frame.unreal_ovrp_entrypoints; the engine''s
|
||||
OpenSSL assembly has unpaired return-address checks the Frame''s CPU enforces (SIGILL on the HttpManager thread):
|
||||
frame.pac_hints. Needs its OBB (main.10765).'
|
||||
frame:
|
||||
- frame.unreal_gl_shim
|
||||
- frame.unreal_ovrp_entrypoints
|
||||
- frame.pac_hints
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-08'
|
||||
issue: 83
|
||||
updated: '2026-10-08'
|
||||
min_app: 0.12.1
|
||||
@@ -1,16 +1,21 @@
|
||||
package: com.camouflaj.manta
|
||||
title: 'Batman: Arkham Shadow'
|
||||
tested_version: 1.4.1-350961
|
||||
source_hint: 'Batman- Arkham Shadow'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Large data set (~27 GiB) incl. all language packs.
|
||||
details: Uses Meta XR Audio (Wwise), handled by patch_meta_xr_audio. Includes all language packs (~28 GB).
|
||||
details: 'Uses Meta XR Audio (Wwise), handled by patch_meta_xr_audio. Includes all language packs (~28 GB). sync_guard
|
||||
(to test, GitHub #102) - a reporter''s game quit to Steam''s waiting screen after the Frame runtime crashed in
|
||||
xrSyncActions (CVRInputLatest::UpdateActionState), the input race Myst had.'
|
||||
tested_version: 1.4.1-350961
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
overport_extra:
|
||||
- patch_disable_space_warp
|
||||
adapter:
|
||||
sync_guard: 1
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 796d1cab08f614d72c50d9702974c719c0908f5de91183cfe02d1fd5eb09551a
|
||||
updated: '2026-10-09'
|
||||
source_hint: Batman- Arkham Shadow
|
||||
@@ -0,0 +1,21 @@
|
||||
package: com.characterBank.ruinsmagus
|
||||
title: RUINSMAGUS
|
||||
status: works
|
||||
tested_version: 1.2.3
|
||||
engine: Unity
|
||||
xr: VrApi
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-07'
|
||||
known_good_sha256: 230e5625fbdfebb47c4d96aeb87f45270e4e6c79a33c08315c90626ab7465fe5
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261006.6173745'
|
||||
agent: 59
|
||||
issue: 89
|
||||
source_hint: RUINSMAGUS
|
||||
@@ -0,0 +1,22 @@
|
||||
package: com.cloudheadgames.pistolwhip
|
||||
title: Pistol Whip
|
||||
status: works
|
||||
notes: 120 HZ
|
||||
tested_version: 1.6.0.2
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-08'
|
||||
known_good_sha256: ebd97af54c59d59d473c98492c1bb26ce6059b7e59f27a08acfc5ada23d0b774
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261006.6173745'
|
||||
agent: 59
|
||||
issue: 100
|
||||
source_hint: Pistol Whip
|
||||
@@ -0,0 +1,22 @@
|
||||
package: com.curif.AgeOfJoy
|
||||
title: AgeOfJoy
|
||||
status: works
|
||||
tested_version: '1.0'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-08'
|
||||
known_good_sha256: 9780e71e417b86290147c67c83755305801d8957909734c717a519034edefdf9
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261006.6173745'
|
||||
agent: 59
|
||||
issue: 103
|
||||
source_hint: AgeOfJoy 05 rc30
|
||||
@@ -0,0 +1,27 @@
|
||||
package: com.ilmxlab.tales
|
||||
title: 'Star Wars: Tales from the Galaxy''s Edge'
|
||||
status: unknown
|
||||
notes: Not working yet - after the intro logos and a loading animation the picture goes black (GitHub issue 61). Gets
|
||||
the controller fixes found for Vader Immortal (same studio) to test.
|
||||
details: Unreal Engine 4 (GLES) by ILMxLAB, like Vader Immortal, but a different engine build - it has none of Vader's
|
||||
Quest-only branches (no GetQuestShaderPrecompilePercent or RPOC key map; it precompiles shaders through Unreal's own
|
||||
pipeline cache), so frame.unreal_quest_precompile/_keymap don't apply. Its seasons and Wwise banks are Meta platform
|
||||
asset files (frame.asset_files). Vader's OVRPlugin findings apply to it as well - poses asked for at OVRPlugin's own
|
||||
clock (pose_time_fix) and thumb proximity from an extension the Frame lacks (proximity_emul). Ruled out for the black
|
||||
picture so far (see the diagnostics in issue 61) - asset-file paks, Valve's foveation, GL errors; the game's own eye
|
||||
image reads back black.
|
||||
tested_version: 1.1.9+734762.cl.439699
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
frame:
|
||||
- frame.asset_files
|
||||
adapter:
|
||||
pose_time_fix: 1
|
||||
proximity_emul: 1
|
||||
verified:
|
||||
issue: 61
|
||||
updated: '2026-10-09'
|
||||
min_app: 0.12.1
|
||||
source_hint: Star Wars- Tales from the Galaxys Edge
|
||||
@@ -0,0 +1,24 @@
|
||||
package: com.markschramm.gravitylab
|
||||
title: Gravity Lab
|
||||
status: works
|
||||
notes: Works great, along with passthrough mode
|
||||
tested_version: '1.221'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.unity_runtime_msaa_off
|
||||
- frame.unity_gl_shim
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-09'
|
||||
known_good_sha256: 82c95ee7487a130b6e37b967dd97c8362bf4d7e873fcff823f2c7877a62ff99f
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 113
|
||||
source_hint: Gravity Lab
|
||||
@@ -0,0 +1,21 @@
|
||||
package: jp.co.amata.nvr
|
||||
title: The Tale of Onogoro
|
||||
status: works
|
||||
tested_version: 1.1.0248
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-08'
|
||||
known_good_sha256: d6258a22a0a6e4588bbec36170f126bffc44ed64784f99e37cf4b03ad212fb79
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261006.6173745'
|
||||
agent: 59
|
||||
issue: 108
|
||||
source_hint: The Tale of Onogoro
|
||||
@@ -0,0 +1,18 @@
|
||||
package: org.timecrisis.quest
|
||||
title: Time Crisis VR (Experimental)
|
||||
status: works
|
||||
notes: Fully playable at 120hz
|
||||
tested_version: 0.8.3
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.sdl_clipboard
|
||||
verified:
|
||||
date: '2026-10-06'
|
||||
known_good_sha256: 4e67411a8e9c0b5fbb19ca7908c4222aa5ad1b2d47a8f4b5018b4fe9c2f5324e
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.3
|
||||
frame_build: '20261005.6135832'
|
||||
agent: 59
|
||||
issue: 80
|
||||
source_hint: TimeCrisisVR-v0.8.3-quest
|
||||
@@ -1,11 +1,13 @@
|
||||
package: rift.superhot_vr
|
||||
title: SUPERHOT VR
|
||||
status: works
|
||||
notes: 'Supports SteamVR/OpenXR itself: runs directly, without Revive.'
|
||||
notes: 'Supports SteamVR/OpenXR itself: runs directly, without Revive. Older builds with both Oculus and SteamVR support
|
||||
(SUPERHOTVR.exe) start with -vrmode OpenVR.'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
kind: rift
|
||||
pcvr:
|
||||
- pcvr.launch_args
|
||||
- pcvr.xr_timefix
|
||||
- pcvr.steamvr_tuning
|
||||
pcvr_remove:
|
||||
@@ -17,4 +19,5 @@ verified:
|
||||
app: 0.6.3
|
||||
overport_cli: 1.2.5
|
||||
issue: 28
|
||||
updated: '2026-10-09'
|
||||
source_hint: SUPERHOT VR
|
||||
+83
-1
@@ -18,11 +18,38 @@ signatures:
|
||||
severity: fatal
|
||||
diagnosis: 32-bit-only APK. The Steam Frame has no AArch32; this cannot run. Use the PC (Rift) version via Revive.
|
||||
suggest: []
|
||||
- id: android-too-new
|
||||
pattern: '(NoClassDefFoundError|ClassNotFoundException|NoSuchMethodError)\b.*(Landroid/window/|android\.window\.|(storeStoreFence|loadLoadFence)\(\)V in class Ljava/lang/invoke/VarHandle)'
|
||||
severity: fatal
|
||||
diagnosis: The app calls Android 12/13+ classes (android.window.*, VarHandle fences) that the Frame's Android 11 (API 30) container doesn't have; it needs a newer Android (e.g. minSdk 34 apps). Nothing to patch - it can't run until Valve updates the Frame's Android.
|
||||
suggest: []
|
||||
- id: web-wrapper
|
||||
pattern: '(Creating TwaLauncher for com\.oculus\.browser|NameNotFoundException:? com\.oculus\.browser)'
|
||||
severity: fatal
|
||||
diagnosis: The app is a website in an Android wrapper (Trusted Web Activity) that opens Meta's browser (com.oculus.browser), which the Frame doesn't have, so nothing opens. There's no game to port - open the website (TWALauncherActivity's "Using URL" line) in a browser instead.
|
||||
suggest: []
|
||||
- id: unity-render-crash
|
||||
pattern: 'CRASH\s*:.*#\d+\s+pc [0-9a-f]+\s+\S*libgallium_dri\.so'
|
||||
severity: fatal
|
||||
diagnosis: Unity's render thread crashed inside the Frame's GL driver (Mesa/Zink); the picture freezes or stays grey. Known triggers - an OVROverlay copy (try "Unity - skip OVROverlay layers", e.g. The Room VR), MSAA switched on at runtime, a GPU hang (zink DEVICE LOST).
|
||||
suggest: [frame.unity_no_overlay_copy, frame.unity_runtime_msaa_off]
|
||||
- id: unreal-msrtt-crash
|
||||
pattern: 'fault addr 0x10000 in tid \d+ \((RHIThread|RenderThread)'
|
||||
severity: fatal
|
||||
diagnosis: The game's render thread jumped to address 0x10000 inside the Frame's GL driver (Mesa/Zink) a few seconds after start. Zink looks up a render-pass cache slot past its end when a game renders through multisampled render-to-texture (Unreal's mobile MSAA, e.g. Star Wars Pinball VR). Load the GL shim, which hides that extension.
|
||||
suggest: [frame.unreal_gl_shim, frame.unity_gl_shim]
|
||||
- id: pac-unpaired
|
||||
pattern: 'ILL_ILLOPN\), fault addr 0x[0-9a-f]+ \(\*pc=0xd50323bf\)'
|
||||
severity: fatal
|
||||
diagnosis: The game crashed on a return-address check (autiasp) its code never signed. The Quest's CPU ignores these checks, the Steam Frame's enforces them; some OpenSSL assembly in game engines has such unpaired checks (e.g. Star Wars Pinball VR on its first network connection, thread HttpManager).
|
||||
suggest: [frame.pac_hints]
|
||||
- id: cube-swapchain-refused
|
||||
pattern: 'xrCreateSwapchain \d+x\d+ .*faces=6 .*result=-\d+'
|
||||
unless: 'cube_standin: runtime refused'
|
||||
severity: fatal
|
||||
diagnosis: The game asked for a cube-map swapchain (e.g. an OVROverlay skybox or loading cube), which the Frame's runtime doesn't have. OVRPlugin goes on without images and crashes in ovrp_EndFrame4 (Unity's render thread SIGSEGV in memset, e.g. Budget Cuts Ultimate). FrameBridge from FramePort 0.12 serves such swapchains itself and drops their cube layers (cube_standin, GLES games) - rebuild and reinstall the game.
|
||||
suggest: [frame.adapter]
|
||||
supersedes: [unity-render-crash, native-crash]
|
||||
- id: swapchain-rect-invalid
|
||||
pattern: 'xrEndFrame failed -25\b'
|
||||
severity: fatal
|
||||
@@ -55,10 +82,26 @@ signatures:
|
||||
severity: error
|
||||
diagnosis: The Frame rejected the swapchain format (GLES games must use sRGB formats, no MSAA).
|
||||
suggest: [adapter.swapchain_fix]
|
||||
- id: zink-shader-fix-mismatch
|
||||
pattern: 'shader fix layer: a \d+-byte module differs from fix'
|
||||
severity: warning
|
||||
diagnosis: The game's OpenGL ES shader fix (zink_shader_fix) no longer matches the shader the Frame's GL driver (Zink)
|
||||
builds - a Frame or Lepton update changed the driver's output - so the shader it fixes (e.g. Vader Immortal's
|
||||
lightspeed jump) may hang the GPU again. Capture the new modules with zink_shader_dump=1 (files/fp_spirv/ in the
|
||||
game's storage) and update the fix.
|
||||
suggest: [adapter.zink_shader_dump]
|
||||
- id: zink-shader-layer-inactive
|
||||
pattern: 'shader fix layer: NOT active'
|
||||
severity: warning
|
||||
diagnosis: FramePort's shader-fix Vulkan layer couldn't add itself to the game's Vulkan layers (Android's
|
||||
GraphicsEnv functions it uses were not found or behaved differently, e.g. after a Lepton update), so the game's
|
||||
OpenGL ES shader fixes (zink_shader_fix) don't apply.
|
||||
suggest: []
|
||||
- id: zink-device-lost
|
||||
pattern: 'zink.*DEVICE[_ ]LOST|VK_ERROR_DEVICE_LOST'
|
||||
severity: fatal
|
||||
diagnosis: The GPU hung (Zink/Mesa). For GLES Unity games MSAA render-to-texture is the usual cause.
|
||||
diagnosis: The GPU hung (Zink/Mesa). For GLES Unity games MSAA render-to-texture is the usual cause; at one effect
|
||||
only, a shader reading undefined loop counters (e.g. Vader Immortal's lightspeed jump - frame.zink_shader_fix).
|
||||
suggest: [frame.unity_no_msaa, frame.unity_runtime_msaa_off]
|
||||
- id: unity-runtime-msaa
|
||||
pattern: 'recommended MSAA level is [248]\. Switching to the recommended level'
|
||||
@@ -84,6 +127,12 @@ signatures:
|
||||
diagnosis: Unity didn't start its Oculus VR device (older Unity checks for Meta's system apps first), so the game
|
||||
runs as a hidden 2D app.
|
||||
suggest: [frame.unity_oculus_check]
|
||||
- id: unity-frame-not-begun
|
||||
pattern: 'frame loop shim: frame \d+: the last waited frame wasn.t begun'
|
||||
severity: info
|
||||
diagnosis: Unity skipped beginning a frame (typically while it activates a scene); FramePort's frame wait didn't
|
||||
wait for that frame instead of blocking the game. A few at each scene load are normal.
|
||||
suggest: []
|
||||
- id: sdl-no-clipboard
|
||||
pattern: 'ClipboardManager\.addPrimaryClipChangedListener|SDLClipboardHandler\.<init>'
|
||||
severity: fatal
|
||||
@@ -107,6 +156,13 @@ signatures:
|
||||
severity: error
|
||||
diagnosis: Shaders written for Quest drivers fail on Mesa (GLSL strictness).
|
||||
suggest: [frame.gl_shim]
|
||||
- id: gl-multiview-twin-failed
|
||||
pattern: 'GLMV\s*: twin of program \d+(: stage| failed)'
|
||||
severity: warning
|
||||
diagnosis: The multiview interposer (frame.gl_multiview_fbo) couldn't build a single-view copy of one of the
|
||||
game's shader programs (the line has the compiler's message); draws of that program into flat panels (HUD,
|
||||
menus) still stay black. Please report it with the log; turning the patch off changes nothing else.
|
||||
suggest: []
|
||||
- id: vrapi-unsupported-layer
|
||||
pattern: 'OVRPortVrApi.*Unsupported VrApi layer type (\d+)'
|
||||
severity: error
|
||||
@@ -219,6 +275,18 @@ signatures:
|
||||
voice chat a few seconds after the logo).
|
||||
suggest: [frame.ovr_microphone]
|
||||
supersedes: [native-crash]
|
||||
- id: slz-vulkan-hook-crash
|
||||
pattern: 'SLZ Graphics plugin loading![\s\S]{0,20000}?E CRASH : .*pc 0000000000000000'
|
||||
severity: fatal
|
||||
diagnosis: Stress Level Zero's graphics plugin (libSLZQuestNative.so) hooks Unity's Vulkan start-up and calls an invalid function there on the Frame (e.g. BONELAB 1.2974); the game crashes or hangs before its first frame.
|
||||
suggest: [frame.slz_vulkan_hooks]
|
||||
supersedes: [java-crash, native-crash]
|
||||
- id: vivox-api31
|
||||
pattern: 'NoSuchMethodError: No virtual method \w*CommunicationDevice\w*\(.*Landroid/media/AudioManager'
|
||||
severity: fatal
|
||||
diagnosis: The game's Vivox voice-chat SDK calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes (e.g. Green Hell VR). The patch makes Vivox's audio-route check return early (voice chat keeps the default route).
|
||||
suggest: [frame.vivox_audio_route]
|
||||
supersedes: [java-crash]
|
||||
- id: native-crash
|
||||
pattern: 'Fatal signal (\d+) \(SIG[A-Z]+\)'
|
||||
severity: fatal
|
||||
@@ -243,6 +311,7 @@ signatures:
|
||||
suggest: []
|
||||
- id: container-not-started
|
||||
pattern: "is not a running context|OCI runtime error"
|
||||
unless: 'Boot complete!' # Lepton 3.x prints the error while its container is still starting, then boots fine
|
||||
severity: fatal
|
||||
diagnosis: The Lepton container failed to start (see the error above it in launch.log).
|
||||
suggest: []
|
||||
@@ -326,6 +395,19 @@ signatures:
|
||||
severity: fatal
|
||||
diagnosis: The game crashed (Unreal's crash reporter started). See the game log and crash summary in the log. Common causes are that no VR runtime reached the game (Revive off) or a GPU or driver problem under Proton.
|
||||
suggest: [pcvr.revive, pcvr.proton_log, pcvr.no_crash_reporter]
|
||||
- id: unity-vr-init
|
||||
kind: pcvr
|
||||
pattern: '(?i)OpenVR failed initialization|VRInitError_(?!None\b)\w+|Initialization of device \w+ failed|Failed to (load|initialize|start) (VR|XR|OpenVR|Oculus)\b'
|
||||
severity: error
|
||||
diagnosis: The game's Unity log shows that a VR device failed to start. Unity games built for both the Oculus and the SteamVR (OpenVR) runtime choose one with an argument; without it they may try Oculus, which the Frame doesn't have, and run without VR or quit. Start the game with -vrmode OpenVR (Game arguments).
|
||||
suggest: [pcvr.launch_args]
|
||||
- id: unity-crash
|
||||
kind: pcvr
|
||||
pattern: 'Crash!!!|caused an Access Violation|caused an Unhandled Exception'
|
||||
severity: fatal
|
||||
diagnosis: The game crashed (Unity's crash handler wrote a report; the Unity log in the launch log shows the stack). Common causes are that no VR runtime reached the game or a GPU or driver problem under Proton.
|
||||
suggest: [pcvr.proton_log]
|
||||
supersedes: [wine-crash]
|
||||
- id: wine-crash
|
||||
kind: pcvr
|
||||
pattern: 'Unhandled exception: page fault|wine: Unhandled|Backtrace:'
|
||||
|
||||
@@ -35,6 +35,11 @@
|
||||
## Extending
|
||||
- **New fix**: `patches/frame/<name>.py` with a `Patch` subclass (`detect`, `apply`, optional `validate`), plus a
|
||||
signature in `catalog/triage.yaml` and a PLAYBOOK row. Stages: `overport` | `apk` | `install`.
|
||||
- **Upstream fixed a bug we work around**: register an `UpstreamFix` (`patches/upstream.py`) in the workaround's
|
||||
module: a probe that finds the fix in OVRPort's output (True / False / None = can't tell). Each build runs the probes
|
||||
on the converted APK and leaves out the workarounds whose fix is there (log "not needed: …", `build.superseded`,
|
||||
left out of settings.conf at install; game page and `frameport show` say so). The recipe keeps asking for the fix,
|
||||
so builds made with an older runtime keep the workaround. `FRAMEPORT_KEEP_WORKAROUNDS=1` turns it off.
|
||||
- **New heuristic**: put it in the patch's `detect()` (and `applies()` for visibility), with the evidence in the
|
||||
reason text; check `scripts/eval_heuristics.py` still reproduces the catalog and add a test in
|
||||
`tests/test_heuristics.py`.
|
||||
|
||||
@@ -38,6 +38,8 @@ games/<pkg>/package/ stand-in for the game files (no content):
|
||||
games/<pkg>/target/ from the Frame (or the PC): launch.sh, settings.conf, deployment.json, launch.log,
|
||||
launch-test.log (PC VR), lepton-steamlaunch-<appid>.log, logcat-{main,crash,system,...}.log,
|
||||
ReviveInjector.txt / steam-<appid>.log / game-log-N.txt (PC VR), files.json (+ missing)
|
||||
(game-log-N.txt: Unreal Saved/Logs + crash summaries, Unity Player.log / Player-prev.log /
|
||||
output_log.txt + crash error.log from the Proton prefix, agent v67)
|
||||
```
|
||||
Each log keeps at most its last 4 MB (2 MB per file from the Frame).
|
||||
|
||||
|
||||
@@ -28,6 +28,23 @@
|
||||
work on Android 11: `policy_control` is gone, `cmd statusbar send-disable-flag home recents` has no `back` and
|
||||
moves the back button onto the app's controls, the `sysui_nav_bar` layout and disabling SystemUI had no effect.
|
||||
|
||||
## Where FramePort keeps games (internal storage, microSD)
|
||||
- Every game has an **anchor** on internal storage: `~/Applications/quest-frame/<pkg>/` with `launch.sh`,
|
||||
`deployment.json`, `artwork/` and `plays.log`. Steam's shortcut points at the anchor's `launch.sh`, so it never
|
||||
changes when the files move.
|
||||
- The game's files (`lepton-app/`, `lepton-data/` = saves, `lepton-shaders/`, `settings.conf`, `launch.log`; PC VR:
|
||||
`game/`, `revive/`, `compatdata/` = Proton prefix; Linux: `app/`) live in `deployment.json["base"]`: the anchor
|
||||
itself on internal storage, or `<mount>/FramePort/<pkg>` on another drive (GitHub #90, agent v63).
|
||||
- SteamOS mounts removable drives (microSD) under `/run/media/<user>/<label or uuid>`; the agent's `drives` reads
|
||||
`/proc/mounts` (plus the drives of Steam library folders) and refuses vfat/exfat/ntfs (Lepton's data and Proton
|
||||
prefixes need Unix owners, permissions and symlinks) and read-only mounts. A drive that isn't mounted is an error for
|
||||
new installs (never a silent fallback to internal storage); `list_installed` marks games on it `drive_missing` and
|
||||
their launchers stop with "storage not mounted?".
|
||||
- `move` copies with `cp -a` inside `podman unshare` (files Lepton's containers own belong to subordinate user ids),
|
||||
compares file count + bytes, retargets absolute symlinks into the old folder (the LibOVRRT → Revive redirect), points
|
||||
`launch.sh` at the new folder and only then deletes the old copy; on the same filesystem it renames instead.
|
||||
Moving a game whose Lepton data holds absolute paths, or a PC VR prefix, is not yet verified on the device.
|
||||
|
||||
## OpenXR runtime (as seen by games through overport's loader)
|
||||
- Instance extensions present include KHR_android_create_instance (must be enabled; the adapter adds it),
|
||||
KHR_vulkan_enable(2), KHR_opengl_es_enable, KHR_composition_layer_depth, FB_display_refresh_rate, EXT_hand_tracking
|
||||
@@ -59,6 +76,13 @@
|
||||
`device.foveation`: `fixed` = `FDM_DEBUG=disable_offsets`, `off` = `VK_INSTANCE_LAYERS=""`. Lepton passes `FDM`,
|
||||
`FDM_DEBUG`, `FOVE_LEVEL` and `FDM_SWAPCHAIN_SIZE` through to the container (liblepton/mounting.sh PASSTHROUGH_VARS);
|
||||
the layers are chosen on the host, so setting them inside the game does nothing.
|
||||
- How Lepton loads them: `liblepton/vulkan_layers.sh` mounts the chosen layers (only those in the OS image's
|
||||
`/usr/share/guestos/android/vendor/vulkan_layers`) into the app's lib dir and writes their names to Android's
|
||||
`settings global gpu_debug_layers` for `gpu_debug_app` = the game; Android's loader reads that list from GraphicsEnv
|
||||
at each vkCreateInstance and searches the app's lib dir (`/data/app/…/lib/arm64`). A layer bundled in the APK is
|
||||
found there too; FramePort's shader-fix layer (`frame.zink_shader_fix`) adds its own name to GraphicsEnv's list from
|
||||
inside the process (`android::GraphicsEnv::setDebugLayers`, exported by the guest's libgraphicsenv.so, Lepton 3.0.5).
|
||||
That is the only way to reach the Vulkan side of OpenGL ES games (Zink creates the instance inside Mesa).
|
||||
- GL ES: Zink (Mesa GL on Vulkan). Strict GLSL (see PLAYBOOK) and occasional `DEVICE LOST` with MSAA render-to-texture.
|
||||
|
||||
## Proton / Windows games (surveyed 2026-09-29; running a Rift game under it not yet verified)
|
||||
@@ -120,6 +144,24 @@
|
||||
- Processes started from Steam (Konsole, SSH sessions?) share steam.service's cgroup: use `systemd-run --user`.
|
||||
- SSH: `sshd` must be enabled (`sudo systemctl enable --now sshd`), which needs a user password (`passwd`).
|
||||
- mDNS: avahi-daemon runs by default; hostname `frame` → `frame.local`.
|
||||
- Desktop Mode menu entries (GitHub #84, agent v63): Linux apps get `~/.local/share/applications/frameport-<slug>.desktop`
|
||||
(+ an executable copy in `~/Desktop` when that folder exists; Plasma starts executable `.desktop` files there
|
||||
without a trust prompt), `Exec=env FRAMEPORT_DESKTOP=1 "<anchor>/launch.sh"`, marked `X-FramePort-Package=<pkg>`.
|
||||
Plasma's launcher exits right after starting the program, so with `FRAMEPORT_DESKTOP=1` the Linux launcher skips
|
||||
its "Steam parent gone → end the app" watchdog and keeps the desktop's DISPLAY/WAYLAND_DISPLAY instead of taking
|
||||
gamescope's from Steam. Not yet tried from the Frame's Desktop Mode.
|
||||
- Linux apps' own icons (GitHub #99, agent v64): finalize_linux (and every refresh of the menu entries) copies the
|
||||
app's icon to `<anchor>/artwork/app-icon.{png,svg}`: an AppImage's `squashfs-root/.DirIcon` (usually a symlink),
|
||||
else the `Icon=` of its top-level `.desktop` file (a folder app: the first `.desktop` within 3 levels) looked up
|
||||
next to it, in `(usr/)share/icons/hicolor/*/apps/` and `(usr/)share/pixmaps/`; the biggest PNG ≥128 px, else an
|
||||
SVG, else the biggest PNG. Symlinks are resolved and must stay inside the app folder; absolute and `../` names
|
||||
are ignored. Precedence for the menu entry's `Icon=` and the Steam shortcut's icon: the user's chosen/store icon
|
||||
(`artwork/.icon-source` = `custom`, written by the PC with every art upload) > the app's own (Steam: PNG only) >
|
||||
FramePort's placeholder (`artwork/icon.*`). `StartupWMClass=` is copied from the app's `.desktop` file (Plasma's
|
||||
task bar matches the window to the entry). A PNG ≤1 MiB goes back to the PC (finalize result `app_icon.png`,
|
||||
base64) and becomes the library's icon when the game has none and nothing was picked (`.app-icon` marker = its
|
||||
sha256, no `.picked`); folder apps get it on the PC at add time (`analysis/linux.find_icon`). Not yet seen in
|
||||
Desktop Mode on the device.
|
||||
|
||||
## Video of the headset view (surveyed 2026-10-05; used by the Live view tab)
|
||||
- `steamvr-v4l2cam.service` (user unit, part of gamescope-session.target, `Restart=always`) runs SteamVR's
|
||||
|
||||
+20
-3
@@ -8,6 +8,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
|---|---|---|---|
|
||||
| 4XVR Video Player | Quest | ✅ Works | |
|
||||
| Accounting+ | Quest | ✅ Works | |
|
||||
| AgeOfJoy | Quest | ✅ Works | |
|
||||
| AllInOneSports | Quest | ✅ Works | |
|
||||
| Asgard's Wrath 2 | Quest | ✅ Works | |
|
||||
| BAM | Quest | ✅ Works | |
|
||||
@@ -20,6 +21,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Beat Saber (co-existence build) | Quest | ✅ Works | |
|
||||
| Blade & Sorcery: Nomad | Quest | ✅ Works | |
|
||||
| BodyCombat | Quest | ✅ Works | |
|
||||
| BONELAB | Quest | ✅ Works | |
|
||||
| Carve Snowboarding | Quest | ✅ Works | |
|
||||
| Cook-Out | Quest | ✅ Works | |
|
||||
| Creed | Quest | ✅ Works | |
|
||||
@@ -27,6 +29,8 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Dinosaur Island | Quest | ✅ Works | |
|
||||
| Espire 2 | Quest | ✅ Works | |
|
||||
| Genotype | Quest | ✅ Works | |
|
||||
| GORN2 | Quest | ✅ Works | |
|
||||
| Gravity Lab | Quest | ✅ Works | |
|
||||
| H.U.N.T | Quest | ✅ Works | |
|
||||
| I Am Cat | Quest | ✅ Works | |
|
||||
| I Am Monkey | Quest | ✅ Works | |
|
||||
@@ -40,6 +44,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Lucky's Tale | 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 | |
|
||||
| Mobile Suit Gundam: Silver Phantom | Quest | ✅ Works | |
|
||||
@@ -48,13 +53,20 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| NOPE CHALLENGE | Quest | ✅ Works | |
|
||||
| palazzo_santacruz | Quest | ✅ Works | |
|
||||
| Path of the Warrior | Quest | ✅ Works | |
|
||||
| Pistol Whip | Quest | ✅ Works | |
|
||||
| Please Don't Touch Anything | Quest | ✅ Works | |
|
||||
| PowerWash Simulator VR | Quest | ✅ Works | |
|
||||
| QuestCraft | Quest | ✅ Works | |
|
||||
| Retronika | Quest | ✅ Works | |
|
||||
| Richie's Plank Experience | Quest | ✅ Works | |
|
||||
| Rick and Morty: Virtual Rick-ality | PC VR | ✅ Works | |
|
||||
| Riven | Quest | ✅ Works | |
|
||||
| Robo Recall | Quest | ✅ Works | |
|
||||
| RUINSMAGUS | Quest | ✅ Works | |
|
||||
| Sniper Elite VR | Quest | ✅ Works | |
|
||||
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
|
||||
| Space Pirate Trainer Quest | Quest | ✅ Works | |
|
||||
| Star Wars Pinball VR | Quest | ✅ Works | |
|
||||
| Stremio | Quest | ✅ Works | |
|
||||
| SUPERHOT VR | PC VR | ✅ Works | |
|
||||
| SUPERHOT VR | Quest | ✅ Works | |
|
||||
@@ -62,21 +74,27 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| The Boys VR | Quest | ✅ Works | |
|
||||
| The Climb 2 | Quest | ✅ Works | |
|
||||
| The Room VR | Quest | ✅ Works | |
|
||||
| The Tale of Onogoro | Quest | ✅ Works | |
|
||||
| Time Crisis VR (Experimental) | Quest | ✅ Works | |
|
||||
| Toy Master | Quest | ✅ Works | |
|
||||
| Under Cover | Quest | ✅ Works | |
|
||||
| Vader Immortal: Episode I | Quest | ✅ Works | |
|
||||
| VR HOT Quest | Quest | ✅ Works | |
|
||||
| VR4 | Quest | ✅ Works | |
|
||||
| Walkabout Mini Golf | Quest | ✅ Works | |
|
||||
| Wallace & Gromit in The Grand Getaway | Quest | ✅ Works | |
|
||||
| Waltz of the Wizard: Extended Edition | Quest | ✅ Works | |
|
||||
| Wander | Quest | ✅ Works | |
|
||||
| Arcsmith | Quest | ⚠️ Works with issues | Right eye distorts during movement (unresolved; swap, tracking, Valve layers, depth and pacing ruled out). |
|
||||
| Assassin's Creed Nexus | Quest | ⚠️ Works with issues | Some launch warning text is still upside down; the rest of the UI is fixed by flip emulation. |
|
||||
| BONELAB | Quest | ⚠️ Works with issues | Build 1.2068 plays (head tracking and controls work with frame.unity_user_presence); the newer build 1.2974 crashes at start (Vulkan), no fix yet. |
|
||||
| Does it Stack? | Quest | ⚠️ Works with issues | Mixed reality mode does not work (works on Demeo), everything else seems to be OK |
|
||||
| Doom3Quest | Quest | ⚠️ Works with issues | PDA shows black screen. |
|
||||
| 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. |
|
||||
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
|
||||
| The Light Brigade | Quest | ⚠️ Works with issues | Judder in weapons |
|
||||
| Time Stall | Quest | ⚠️ Works with issues | Both eyes distort during movement (unresolved). |
|
||||
| Vader Immortal: Episode I | Quest | ⚠️ Works with issues | Starts in VR and plays the intro, then stays on the loading card (Vader's portrait with a progress bar). |
|
||||
| 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… |
|
||||
| BlazeRush | Quest | ❌ Doesn't run | Starts and reaches the menu room, but the room shows no controllers and ignores all input (it all reaches the game); no fix yet. |
|
||||
| Espire 1: VR Operative (Quest Edition) | Quest | ❌ Doesn't run | Mesa GL driver crash during texture upload. |
|
||||
@@ -84,5 +102,4 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| 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. |
|
||||
| Sniper Elite VR | Quest | ❌ Doesn't run | GPU hang (zink: DEVICE LOST) even with MSAA off. |
|
||||
| Sports Scramble (Santa Cruz) | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
|
||||
@@ -85,6 +85,11 @@ which would end Desktop Mode). This is new: please report anything odd with **Re
|
||||
- If the Frame goes to sleep, turns off or leaves the Wi-Fi, the queue **waits** and continues once it's back; uploads
|
||||
pick up where they stopped. While installs run, FramePort keeps the Frame from going to sleep. Before a large batch
|
||||
it checks the Frame has enough free space.
|
||||
- **microSD card / other drives:** the **Steam Frame** page's **Storage** section lists the Frame's drives and sets
|
||||
where new games go (**Install new games to**). Games go into a `FramePort` folder on the card. To move a game that's
|
||||
installed already, right-click it → **Move to…** (the game must be closed; saves, settings and the Steam entry stay).
|
||||
A game on the card only starts while the card is inserted (FramePort then says "SD Card not inserted"). Cards
|
||||
formatted as FAT, exFAT or NTFS can't hold games: format the card in SteamOS first.
|
||||
- Your own game files are never changed. The converted copy is temporary: it's removed once the game is on the Frame
|
||||
(Settings → Installing: keep them, or remove all now).
|
||||
- **Game settings…** (game menu or the Steam Frame page): sharpness, refresh rate, controllers, menus, 360° video and
|
||||
@@ -217,6 +222,9 @@ work like for other games.
|
||||
- The app must bring the libraries SteamOS doesn't have (checked for arm64 builds; x86_64 builds use FEX's x86
|
||||
system, which has glibc and Mesa, and aren't checked ahead). If some are missing, the install reports them and the game
|
||||
page lists them: look for a build that includes them.
|
||||
- **Desktop Mode:** Linux apps also appear in Desktop Mode's application menu and as an icon on its desktop. Some
|
||||
apps work better there, with a mouse and keyboard, than in Gaming Mode (where Steam Input turns the controllers into
|
||||
a gamepad). Switch it off per app on the game page (**Desktop Mode**).
|
||||
- From the command line: `frameport add-linux <AppImage, folder or archive> [--exe <program>]`.
|
||||
|
||||
## Files on the Frame (videos, documents, mods, saves)
|
||||
@@ -261,6 +269,7 @@ and `frameport <command> --help` describe every option. The main ones:
|
||||
| `build <game>` / `install <game>` / `test <game>` | build, install on the Frame (`--to pc` for PC VR on this PC), launch test |
|
||||
| `frame discover` / `frame connect` / `frame info` | find, pair with and describe the Frame |
|
||||
| `frame send` / `frame storage` / `frame cleanup` | copy files to the Frame, show where they go, free space |
|
||||
| `frame drives` / `frame move <game> --to <drive>` / `install --dest <drive>` | the Frame's drives (microSD), move a game, install to a drive |
|
||||
| `tools status` / `tools install` | the tools FramePort downloads |
|
||||
| `open-link "<link>"` | install from an "Install with FrameDrop" button's address or a manifest/APK/zip link (`--yes`, `--no-install`) |
|
||||
| `diag report <game>` / `share-recipe <game>` | report a problem / share a working recipe |
|
||||
|
||||
+35
-2
@@ -13,6 +13,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
## Startup failures (visible in launch.log)
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| A launch test suggests a fix (e.g. triage `sdl-no-clipboard` → `frame.sdl_clipboard`) but the game page doesn't show it / lists it as not applicable; or a game added long ago lacks warnings newer ones get (Android version, web wrapper, missing OBB) (GitHub #104, Dramatic Shape) | the library entry was analysed by an older FramePort: the analysis field the patch reads (`sdl_java`, `min_sdk`, `web_wrapper`, `expects_obb`, …) is missing, so `applies()` is false | entries whose `analysis.extra.analysis_version` is below `detect.ANALYSIS_VERSION` are analysed again at the GUI's start (background) and before a build (`pipeline.refresh_analyses`); by hand: game menu → "Analyze again", then Update on Frame. An unreadable APK is marked `analysis_failed` (retried after the next version bump). Adding an analysis field: bump `ANALYSIS_VERSION` |
|
||||
| One eye (usually the right) shimmers or jitters in the 3D view while menus and panels look steady; pacing is clean (GitHub #69, e.g. RTCWQuest, Moss) | Valve's eye-tracked foveation layer (`VALVE_fdm_injection`) moves its low-density regions with each eye's gaze, and the right eye's gaze filter restarts often (`CEyePoseUKF R: Large dt … Bootstrapped` in `~/.local/share/Steam/logs/eyetracking.txt`) | Customize → "Eye-tracked foveation (Valve)" (`device.foveation`): **Fixed** (`FDM_DEBUG=disable_offsets`, keeps the GPU saving) or **Off** (`VK_INSTANCE_LAYERS=""`); catalog field `foveation:` |
|
||||
| A Unity OpenXR game closes right after the OpenXR instance is created: `F VrApiLoader: vrapi_SetPropertyInt was called before vrapi_Initialize()!`, SIGABRT on UnityMain (GitHub #62, Jurassic World Aftermath) | the APK still ships Meta's VrApi loader; OVRPlugin (1.89, on OpenXR) calls it without starting VrApi and Meta's loader aborts. The VrApi bridge can't replace it (39 of the loader's 114 functions: OVRPlugin wouldn't link) | `frame.vrapi_stub` (same exports, each returns 0); triage `vrapi-before-init` |
|
||||
| A VrApi game exits at once: `UnsatisfiedLinkError … dlopen failed: cannot locate symbol "vrapi_PollEvent"` (GitHub #57, BlazeRush) | the engine imports a VrApi function the VrApi bridge didn't implement | the bridge has `vrapi_PollEvent`, `RecenterPose`, `SetDisplayRefreshRate`, `GetSystemPropertyFloatArray` (rebuild the game); the static check "VrApi functions resolvable" names any other missing one; triage `vrapi-symbol-missing` |
|
||||
@@ -21,7 +22,11 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| `cannot locate symbol "ovrPeerConnectionState_ToString"` (or another `ovr<Enum>_ToString`) at start (BlazeRush, GitHub #57) | OVRPort's platform loader lacks several Platform SDK enum helpers | `frame.ovrstubs` now stubs them too; `*_ToString` stubs return "" (not NULL) |
|
||||
| Exits right after the OpenXR instance is created: `Failed to create XR session: -50` (`XR_ERROR_GRAPHICS_REQUIREMENTS_CALL_MISSING`, Lambda1VR) | the game skips `xrGet*GraphicsRequirementsKHR`; Meta's runtime tolerates it, SteamVR's doesn't. Lambda1VR's TBXR doesn't even enable `XR_KHR_opengl_es_enable` (only `XR_EXT_local_floor`) | FrameBridge adds `XR_KHR_opengl_es_enable` when an app enables no graphics API extension, asks for the requirements on the game's behalf and retries xrCreateSession once (logs `added XR_KHR_opengl_es_enable …`, `the app skipped …; asked for it`; every launch logs the final extension list `xrCreateInstance with N extension(s)`); triage `graphics-requirements-missing`. Verified headless: Lambda1VR runs at 72 fps |
|
||||
| Exits at start: `OVRAvatar-Loader: DisplayErrorAndExit: Failed to launch SystemActivities` after `ovrAvatar_Initialize: Failed to load AvatarSDK driver` (BlazeRush, GitHub #57) | Meta's avatar loader needs Horizon; it then tries Meta's error screen and aborts | `frame.avatar_stub` (same exports, do nothing); triage `avatar-driver-missing`. Games that ship the library but don't start avatars at launch (Lucky's Tale, BattleSisters, Arcsmith) don't need it |
|
||||
| Frame freezes or the game is killed (OOM) on the first controller vibration (e.g. Lucky's Tale's save slots, BattleSisters, Sniper Elite VR's tutorial grab: memory grows ~1 GB/s until the kill) | OVRPort runtimes up to 3.4.3-23204ea read the haptic envelope's duration (ns) as seconds and allocate gigabytes of samples (GitHub #9, ovrport/app#73); also reached from Unity's legacy `ovrp_SetControllerHaptics`; perf page faults all in `libopenxr_loader.so` `xrApplyHapticFeedback` | `adapter.haptic_fix` (envelope → one plain vibration in `native/xrshim`; PCM vibrations (`XrHapticPcmVibrationFB`) are converted the same way). Fixed in runtime 3.4.3-aa54c3f: builds made with it leave the workaround out by themselves (log "not needed: adapter.haptic_fix", `frameport show` "last build left out"); rebuild an older build to switch |
|
||||
| Controller vibrations much stronger than on a Quest (The Boys VR, Jurassic World, BONELAB, …) | OVRPlugin games send amplitude envelopes; `haptic_fix` turned each into one vibration at the envelope's *peak* for its whole duration (a short fading pulse became a long full-strength buzz) | `haptic_fix` now uses the envelope's RMS; Game settings → "Vibration strength" (`haptic_scale`, 0-1) scales every vibration in FrameBridge; launch.log shows the first requests (`haptic: …`). OVRPlugin stops a vibration with amplitude 0 (2 s duration): FrameBridge turns that into xrStopHapticFeedback (without it the last buzz ran its full 2 s) |
|
||||
| Play or a launch test fails at once: `Game files missing at <path> (storage not mounted?)` in launch.log, or "… files are on a drive that isn't inserted" (GitHub #90) | the game was installed or moved to a microSD card (`<mount>/FramePort/<pkg>`) that isn't inserted or mounted now | insert the card; or move the game back (game menu → Move to…, `frameport frame move <pkg> --to internal`) once the card is back. `frameport frame drives` shows what the agent sees (exFAT/NTFS cards are refused: format in SteamOS) |
|
||||
| A Linux app started from Desktop Mode's menu closes after ~2 s, or opens on the wrong display (GitHub #84) | its launcher predates agent v63: the "Steam parent gone" watchdog ended it when Plasma's launcher exited, and it took gamescope's DISPLAY from Steam | connect FramePort once (agent v63's `upgrade_launchers` adds the `FRAMEPORT_DESKTOP` checks; the menu entry runs `env FRAMEPORT_DESKTOP=1 <anchor>/launch.sh`). Game Mode controller problems in such apps are Steam Input's (a separate issue) |
|
||||
| A Linux app (AppImage) shows no/FramePort's placeholder icon in Desktop Mode or Steam (GitHub #99) | before agent v64 only FramePort's art set's icon was used, and a lone AppImage has no store art | connect FramePort once (agent v64 copies the app's `.DirIcon` / `.desktop` `Icon=` to `<anchor>/artwork/app-icon.*` and refreshes the menu entry); reinstall to also get it into the library + Steam shortcut. Still the placeholder: check `<base>/app/squashfs-root/.DirIcon` and the `.desktop` file's `Icon=` (an icon outside the app folder or only an XPM isn't used); a chosen icon (`artwork/.icon-source` = `custom`) always wins |
|
||||
| Every game suddenly fails to start: `crun: create keyring …: Disk quota exceeded`, `is not a running context` | rootless podman leaked a kernel keyring per launch; 200-key quota exhausted | `keyring = false` in `~/.config/containers/containers.conf` (FramePort agent does it), then reboot the Frame once |
|
||||
| A Unity app's text field shows a caret for a moment and loses focus; no keyboard appears (e.g. Stremio VR login) | Unity's TMP_InputField/InputField wait for Android's on-screen keyboard and close themselves without one (Lepton has none); headless VR apps also have no focused Android window, so no key press reaches them | `frame.unity_text_input` (Cpp2IL finds `TouchScreenKeyboardShouldBeUsed`/`isKeyboardUsingEvents`, rewritten to false/true) + `device.text_input_window` (lepton-show-flatscreen: Steam's keyboard and Type on Frame work) |
|
||||
| Play gives Steam's "Game configuration unavailable"; Steam's console_log: `GameAction [AppID <id>] … RequestingLicense → UpdatingAppInfo → LaunchApp failed with AppError_9` | this Frame's Steam never loaded FramePort's shortcuts.vdf entry (it treats the id as a store app); cause unknown (GitHub #21/#30) | automatic since agent v43: Play registers the game through Steam's devkit interface ("Devkit Game: …", `devkit_register`) and starts that; uninstall removes it. Agent v58: a devkit entry Steam forgot after a restart is added again live on Play (no NOT_IN_LIBRARY → no Steam restart), and installs/art updates of devkit games only copy art (no Steam restart, GitHub #41) |
|
||||
@@ -41,15 +46,21 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| PC VR game: `Failed to initialize Oculus API (-3001)` / `Unable to load LibOVRRT DLL` | Revive's LoadLibrary hook doesn't work under Proton arm64 (ARM64EC kernelbase); the game's LibOVR shim finds no runtime DLL | Symlink `LibOVRRT64_1.dll` → Revive's DLL in the prefix's system32 (manual so far) → then -3021 (runtime signature check; open) |
|
||||
| Uploads to the Frame are slow (~15 MB/s) | PC and Frame both on Wi-Fi through the home router | Connect the PC to the Frame's own hotspot (or a USB cable): FramePort uses the direct link automatically (~80-100 MB/s); job log line "Transfer link" |
|
||||
| PC VR game exits right away: `Unhandled Exception: 0xc06d007e` (after `FOnlineSubsystemOculus::InitWithWindowsPlatform`) | Delay-loaded `LibOVRPlatform64_1.dll` (Oculus Platform SDK, installed with the Oculus app) is missing | None on the Frame (entitlement check; FramePort doesn't replace it). PC mode with the Oculus app |
|
||||
| Unity PC VR game (Oculus + SteamVR build: `OVRPlugin.dll` and `openvr_api.dll` in `<Name>_Data/Plugins`) opens a window and quits after ~20 s; no VR Vulkan instance after the game's DXVK device in launch.log; Unity log (`unity log … Player.log`, agent v67): `OpenVR failed initialization` / `Initialization of device … failed` | Unity's built-in VR tries its SDKs in list order (Oculus first) unless `-vrmode` picks one; a catalog recipe verified with another build of the game (e.g. SUPERHOT VR's OpenXR build, GitHub #105) used to drop the build's own arguments | `pcvr.launch_args` = `-vrmode OpenVR` (detected for such builds; kept when the catalog's `xr` differs from the build's). A game's Electron launcher next to it (SHVR.exe) is ranked below the game |
|
||||
| Unreal PC VR game opens the crash reporter instead of closing | UE starts CrashReportClient.exe on a crash | `pcvr.no_crash_reporter` (default for Unreal Rift games): `-nocrashreports` + CrashReportClient.exe renamed `.disabled` in the Frame copy |
|
||||
| A backup's game was added without its data (library `data_dir` null, `data_bytes` 0) although the folder has .obb files | The OBBs sit in a layout the scan didn't know: `<game>/obb/<package>/`, `Android/obb/<package>/`, or SideQuest-style `<game>/apk/x.apk` + `<game>/obb/<package>/` | `sources/quest_dump.find_data_dir` finds a `<package>` folder with .obb files ≤3 levels below the APK's folder or ≤2 below its parent (skipping neighbouring folders with APKs: other games); an `apk`/`apks` folder counts as part of its game folder. Add the folder again (Analyze again doesn't look for data) |
|
||||
| Game hangs at start / never loads although its .obb files are right there: next to the APK, in an `obb` folder beside an `apk` folder inside a `<package>` folder, or a SideQuest backup (`<timestamp>_<versionCode>.apk`) (GitHub #85, #91) | No folder named after the package, so the folder-layout checks found nothing and no OBB was uploaded | `quest_dump.find_data` then looks for the files by name, `(main\|patch).<versionCode>.<package>.obb` (any case), in the APK's folder, its parent and up to 3 levels below each (folders with their own APKs are other games); prefers the folder and files of the APK's own versionCode, else the newest older, else any version. When that folder also holds other things (the APK itself, other games' OBBs), the library entry gets `data_files` and only those files are uploaded, counted and deleted with the game. Add the folder again |
|
||||
| Unreal game: `JNI_OnLoad` (OVRPlugin), then silence: no OpenXR instance, no crash, no frames (GitHub #85 TRIANGLE STRATEGY; launch-test finding `missing-obb`) | Installed without its OBB: the manifest says `com.epicgames.ue4.GameActivity.bHasOBBFiles` = true (UE5: `com.epicgames.unreal.…`) but the library has no data folder (`data_dir` null, `data_bytes` 0) | Put the .obb files in a `<package>` (or `obb/`) folder next to the APK, add the folder again, reinstall. Analysis `extra.expects_obb`; the game page, the install question and `frameport scan`/`install` warn; launch tests that sent no frames get the `missing-obb` finding from the library (the game logs nothing). Unity split-binary builds count too (`extra.unity_split`, `analysis/unity_split.py`): an XR-plugin build (Oculus XR Plugin / Unity OpenXR library) without `assets/bin/Data/UnitySubsystems/` in the APK, or BuildSettings listing more scenes than the APK has `levelN` files (globalgamemanagers loose or read from the head of data.unity3d); exact on the 48 Unity APKs of the dumps (13 split, 17 full games; OBBs of full builds are asset bundles or sound banks) |
|
||||
| `INSTALL_FAILED_NO_MATCHING_ABIS` | 32-bit-only APK; Frame has no AArch32 | None. PC/Rift version via Revive |
|
||||
| Crash at start: `NoClassDefFoundError: … Landroid/window/OnBackInvokedCallback;` or `NoSuchMethodError: No static method storeStoreFence()V in class Ljava/lang/invoke/VarHandle;` (GitHub #71/#72, triage `android-too-new`) | The APK needs Android 13+ (minSdk 33 or more; those were 34) and calls its classes at start; Lepton's Android is 11 (API 30). OVRPort lowers minSdk to 29, so it installs anyway. minSdk ≤ 32 (Quest's Android 12L) is fine: 16 working catalog games declare 32 | None (analysis `extra.min_sdk` > 32 → a warning on the game page/CLI and in the recipe notes; never unsupported from the manifest alone, the launch test's triage decides). Waits for Valve to update Lepton's Android |
|
||||
| Nothing opens; `TWALauncherActivity: Using URL from Manifest (https://…)`, `Creating TwaLauncher for com.oculus.browser`, `NameNotFoundException: com.oculus.browser` (GitHub #86 Mahjong Table VR, triage `web-wrapper`) | The APK is a Trusted Web Activity (Bubblewrap / Meta's PWA packaging): a website that opens in Meta's browser, which Lepton doesn't have | None: open the URL in a browser. Analysis `extra.web_wrapper` (manifest meta-data `android.support.customtabs.trusted.DEFAULT_URL`, resolved through resources.arsc, or androidbrowserhelper's LauncherActivity) → a warning with the URL (not unsupported: the manifest alone never decides) |
|
||||
| `UnsatisfiedLinkError` / `cannot locate symbol "ovr_…"` | overport's platform loader lacks Meta platform functions | `frame.ovrstubs` (automatic, generated stubs) |
|
||||
| missing `ovrMessageType_ToString` | same, but the game needs a real string | `frame.ovrplatformcompat` (automatic) |
|
||||
| The game stays in its built-in language or waits at start although its OBB / files hold language packs (`<tag>.lang`, e.g. `de.lang`) | overport's platform loader answers `ovr_LanguagePack_GetCurrent/SetCurrent` with request id 0, so the game never gets the pack's path | `frame.langpacks` (opt-in, experimental, shown for games whose data has `*.lang`; `native/langpack`, build it with `python native/build.py --only langpack`). Default pack: the one the game applies, else env `FRAMEPORT_LANGPACK=<tag>`, else the only pack; search path override `FRAMEPORT_LANGPACK_DIRS`. Unreal games (Deadpool VR) grey out a pack whose asset `Metadata` differs from the game's version string: the patch writes the APK's versionName as Metadata (env `FRAMEPORT_LANGPACK_META` overrides) and reports the pack under the `/storage/emulated/0/Android/obb/<pkg>/` spelling of its path; with `/sdcard/…` the text switched but the dialogue stayed silent (verified in the headset with Deadpool VR) |
|
||||
| `ClassNotFoundException com.oculus.os.AnalyticsEvent` → abort | Quest telemetry lookup in Meta XR Audio (Unreal build) or native code | `frame.metaxr_telemetry` + `frame.oculusos` (automatic) |
|
||||
| `JNI DETECTED ERROR`, `GetStringUTFChars … NULL` | CheckJNI is on because overport marks the app debuggable | `frame.nodebug` |
|
||||
| Unreal game quits a few seconds after start (`System.exit`) | ForceQuit after a failed Quest platform check | alternate build with `patch_remove_unreal_force_quit` (`use_alt`) |
|
||||
| Unreal game crashes a few seconds after the logo with no backtrace (Unreal's own signal handler hides it: logcat-crash.log empty, Zygote `exited due to signal 11`); the crashing thread is `GameThread`, pc in `libaaudio.so`, lr `libovrplatformloader.so (ovr_Microphone_GetOutputBufferMaxSize+0x10)`; the last platform call is `ovr_Microphone_Create` (e.g. The Walking Dead: Saints & Sinners Ch. 2) | OVRPort's platform loader opens the microphone's AAudio stream only in `ovr_Microphone_Start`, but `GetOutputBufferMaxSize` reads that stream unchecked; Unreal's Oculus voice asks for the size right after `Create` | `frame.ovr_microphone` (NULL check rewritten in place; size 0 until the microphone starts) |
|
||||
| Unreal game crashes a few seconds after the logo with no backtrace (Unreal's own signal handler hides it: logcat-crash.log empty, Zygote `exited due to signal 11`); the crashing thread is `GameThread`, pc in `libaaudio.so`, lr `libovrplatformloader.so (ovr_Microphone_GetOutputBufferMaxSize+0x10)`; the last platform call is `ovr_Microphone_Create` (e.g. The Walking Dead: Saints & Sinners Ch. 2) | OVRPort's platform loader opens the microphone's AAudio stream only in `ovr_Microphone_Start`, but `GetOutputBufferMaxSize` reads that stream unchecked; Unreal's Oculus voice asks for the size right after `Create` | `frame.ovr_microphone` (NULL check rewritten in place; size 0 until the microphone starts). OVRPort runtime 3.4.3-aa54c3f has the check itself: builds made with it leave the patch out (log "not needed: frame.ovr_microphone", `patches/upstream.py`) |
|
||||
| `xrCreateSwapchain` -26 / format unsupported (GLES) | Frame takes only sRGB formats, no MSAA | adapter `swapchain_fix` (default on) |
|
||||
| Frames rejected, "Waiting…" forever | a layer uses a failed swapchain or an extension that isn't enabled (e.g. equirect2) | adapter `layer_fix` (default on) |
|
||||
| `xrConvert…TimeKHR` FUNCTION_UNSUPPORTED spam; VrApi bridge stalls before recenter | runtime lacks timespec conversion | current adapter emulates it |
|
||||
@@ -68,24 +79,37 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Video player / app finds no local videos; MediaProvider "Requested path /home/steamos/... doesn't appear under ..." | Lepton's /sdcard is a symlink to a host path, so Android's media index rejects every file | upload in the Files tab (Videos = /sdcard/Movies) and browse folders in the app |
|
||||
| Batman: Arkham Shadow closes during smoke-bomb effects; later input-tree corruption | Meta XR Audio Wwise deletes queued metadata still referenced by the current audio frame, then writes through the stale pointer | FrameBridge automatically retires metadata after current-frame references disappear, for the verified AArch64 SDK build only; see [AUDIO_METADATA.md](AUDIO_METADATA.md) |
|
||||
| Game stays on a loading or "Waiting" box although it reached FOCUSED; log `FrameBridge: xrEndFrame failed -25` then no more `pacing:` lines (e.g. PowerWash Simulator on some Frames, GitHub #39) | The eye image rect ends a few pixels past its swapchain (Unity rounds the swapchain width, the runtime's recommended size differs per Frame); SteamVR rejects every frame with XR_ERROR_SWAPCHAIN_RECT_INVALID | FrameBridge clamps every projection/quad image rect to its swapchain (`rect_clamp`, default on since 0.11.0; log `rect_clamp: view N image rect clamped`): rebuild + reinstall the game |
|
||||
| Unity game (OVRPlugin, GLES) crashes ~10 s in: `FrameBridge: xrCreateSwapchain … faces=6 … result=-2`, OVRPlugin `CreateSwapchain for eye 0: 0x0, 0 stages`, then render-thread SIGSEGV in memset ← libOVRPlugin ← `ovrp_EndFrame4` (libgallium/ANR follow; e.g. Budget Cuts Ultimate, GitHub #107); triage `cube-swapchain-refused` | The game shows a cube-map layer (OVROverlay cubemap); the Frame's runtime has no cube layers and refuses cube swapchains (XR_ERROR_RUNTIME_FAILURE); OVRPlugin doesn't check and writes into the image list it never got | FrameBridge `cube_standin` (default on): a refused cube swapchain is served as a GL cube map in the game's context and its layers are dropped (log `cube_standin: runtime refused …`): rebuild + reinstall. GLES only; a Vulkan game keeps the error |
|
||||
| Game (Unity, GLES) freezes, `zink: DEVICE LOST` | multisampled render-to-texture hangs the GPU | `frame.unity_no_msaa`; if it persists: unfixable → PC version |
|
||||
| Unreal GLES game crashes ~3 s after start: `Fatal signal 11 … fault addr 0x10000 in tid … (RHIThread)`, `#01 … libgallium_dri.so` (GitHub #83 Star Wars Pinball VR, triage `unreal-msrtt-crash`) | Unreal's mobile MSAA renders through multisampled render-to-texture; Zink's `find_rp_state` then indexes `rendering_state_cache[6]` one past its end (sample count ≥ 32) and calls a junk hash function (0x10000). Mesa bug (no bounds check) | `frame.unreal_gl_shim` (GL shim with `gl_hide_msrtt`, multiview kept for Unreal: Unreal 4.25 only enables multiview with GL_OVR_multiview, GL_OVR_multiview2 **and** GL_OVR_multiview_multisampled_render_to_texture, so the shim keeps the last one visible for Unreal and maps glFramebufferTextureMultisampleMultiviewOVR to the single-sampled glFramebufferTextureMultiviewOVR; log `multiview multisampled render-to-texture (N samples) drawn single-sampled`) |
|
||||
| Crash with `SIGILL (ILL_ILLOPN)` and `*pc=0xd50323bf` (autiasp), e.g. on Unreal's HttpManager thread (GitHub #83 Star Wars Pinball VR, triage `pac-unpaired`) | The engine's OpenSSL ARMv8 assembly has autiasp without a matching paciasp (libUE4.so: 38 paciasp, 40 autiasp; Poly1305 NEON). Quest CPUs treat PAC hints as NOPs, the Frame's CPU checks them | `frame.pac_hints`: in a library with unpaired counts every paciasp/autiasp becomes a NOP (in place) |
|
||||
| launch.log ends with `logcat: Unexpected EOF!` right after `Waiting for app …`; the game runs, but Steam's "Resume game" menu isn't closed and the launch test shows nothing (Vader Immortal on Lepton 3.0.5, Under Cover on 2.8.14; about 1 launch in 50) | Lepton's logcat mirror died | Agent v67 `_logcat_keeper` (started by launch.sh, added to older launchers by `upgrade_launchers`) reads the container's logcat itself (`podman exec lepton-steamlaunch-<appid> logcat -v threadtime -T 2000`) and appends it to launch.log; log `<base>/logcat-keeper.log` |
|
||||
| Unreal game (OVRPlugin) never starts VR: launch.log has `OVRPlugin: JNI_OnLoad` but never `CompositorOpenXR::PreInitialize`, no OpenXR session, then a crash a few seconds in (e.g. Star Wars Pinball VR, UE 4.25: null pointer in `FSceneRenderer::GetMultiViewSceneColor`, GitHub #83) | Unreal's Oculus module looks up every `ovrp_*` function of the OVRPlugin it was built against (`InitializeOculusPluginWrapper`, all ANDed); OVRPort's OpenXR OVRPlugin lacks a few old ones (UE 4.25 / OVRPlugin 1.44: `ovrp_GetPTWNear`), so the wrapper fails and OculusHMD is never pre-initialised | `frame.unreal_ovrp_entrypoints` (suggested when analysis `unreal_ovrp_lookups` name functions the shipped OVRPlugin lacks): generated `libfp_ovrpstubs.so` (DT_NEEDED of libOVRPlugin.so; dlsym on the plugin's handle searches its dependencies) with a stand-in per missing name returning ovrpFailure (-1000); build note lists the names |
|
||||
| Only the Android home screen shows; the log has Unity's VR device as `None` (e.g. Accounting+, Unity 2017) | Unity 2017–2018 built-in Oculus support starts VR only when `com.oculus.systemactivities` is installed; Lepton has no Meta system apps, so Unity falls back to non-VR | `frame.unity_oculus_check`: that package name in libunity.so → `android`, plus `native/ovrpshim` (libfp_ovrp.so): Unity 2017's legacy frame loop (`ovrp_Update2`/`ovrp_BeginFrame`) never calls `ovrp_WaitToBeginFrame`, so without the shim no `xrWaitFrame` happens, `CompositorOpenXR::Update … outside of frame bounds` floods the log and the dashboard freezes. With it: log `ovrp frame loop shim: waited for frame N`, ~71 fps. Accounting+ then stops at "press any button" although its OVRInput gets clean input (`INPUT_PROBE` in the patch logs connected controllers/buttons/input focus per call) — unresolved |
|
||||
| Black flat window, then a GPU hang: kernel `hangcheck detected gpu lockup` (offending task the game), `zink: DEVICE LOST`, Unity render-thread crash; `outside of frame bounds` thousands of times a second (e.g. BattleSisters, Unity 2019.4) | Unity 2019 on its built-in VR (no `libOculusXRPlugin.so`) uses the same legacy frame loop as Unity 2017: no `xrWaitFrame`, the render thread floods the GPU | `frame.unity_oculus_check` adds the ovrpshim frame wait for these too (revision 2) |
|
||||
| Unity built-in VR game (with `frame.unity_oculus_check`) freezes at a scene switch: the log stops after `Boot: Activating Scene: …`, the process stays alive, every thread sleeps (e.g. Sniper Elite VR, Unity 2019.4) | The shim's `xrWaitFrame` blocks until the previously waited frame is begun; Unity skipped beginning it at the scene switch and its render thread then waited for the main thread (gdb: UnityMain in vrclient `CSxrSession::StartNextFrame` ← `ovrp_WaitToBeginFrame` ← `fpov_Update2`). OVRPlugin also only begins the frame index it waited for | `frame.unity_oculus_check` revision 5: libunity.so's `ovrp_BeginFrame`/`ovrp_EndFrame` go through the shim, which waits ≤ 50 ms for the last waited frame to begin, else skips the wait (log `the last waited frame wasn't begun`), and gives a begin/end of another index the waited one |
|
||||
| Unity built-in VR game: the hands/weapons trail the controllers by centimetres when moving (headset fine, 72 fps), e.g. Sniper Elite VR | Unity's physics-step `ovrp_Update2` (prediction 0) makes OVRPlugin locate the nodes at the monotonic clock's "now", the XrTime base on a Quest but 0.05–0.9 s behind the Frame's XrTime; the runtime extrapolates backwards and the render-step reads after it get those poses too. A larger prediction is capped by OVRPlugin (~0.07 s) | `frame.unity_oculus_check` revision 5 holds the physics-step update back: both steps read the render update's display-time poses |
|
||||
| OVRPlugin game: the hands lag behind the controllers (also with `ovrp_hold_physics` on or off; rendering fine), e.g. BattleSisters | OVRPlugin passes its monotonic-clock "now" on as the OpenXR time (OVRPort's dispatcher converts XR_KHR_convert_timespec_time 1:1: the Frame's runtime lacks it, and FrameBridge's emulation is never asked), but the Frame's XrTime runs ahead of CLOCK_MONOTONIC (2.56 s on SteamOS 0.4.5): with `pose_debug=1` the hand spaces show `time - predicted display time -2564 ms`; BattleSisters also asks for its head at XrTime 0.1 s every frame | `adapter.pose_time_fix` (FrameBridge, suggested for Unity built-in OVRPlugin games): a located time nearer the monotonic clock than XrTime's "now" is moved by the clock offset measured at xrWaitFrame, one more than 0.5 s before the display time goes to "now" (log `pose_time_fix: …`; `pose_debug=1` counts both per 5 s). Once a build has this FrameBridge it is toggled in settings.conf (no rebuild) The offset is the largest of the last ~2 s of xrWaitFrame samples (hitches made single samples dip by up to 2.5 s and pushed fixed times into the future); far-past requests get the frame's display time. Headless survey of 54 games (2026-10-09): no head/view request on the monotonic clock in any other game; UE4 OVRPlugin 1.89 games (Robo Recall, Vader, Phantom, Time Stall) and The Room VR ask for head/views at XrTime ≈ 0 every frame, which works today; hands only show in the headset. Suggested for Unity built-in Oculus games only. A time equal (±1 ms) to one of the last 6 predicted display times is never moved, and monotonic times are only told apart when the clocks are more than 4 display periods apart and the time is clearly nearer the monotonic "now" (GitHub #49: on a Frame with XrTime only ~68 ms ahead, a display-time request after a hitch was moved +67.7 ms) |
|
||||
| Unreal game stuck on a loading image after the intro, frames keep coming (72 fps), no crash (e.g. Vader Immortal) | suspect: a Meta Platform SDK request no message ever answers | diagnostics: `frame.ovr_trace` (opt-in) logs every `ovr_*` call, the answers `ovr_PopMessage` returns and every 10 s the unanswered requests (`fp_ovrtrace` in launch.log). Never fake an entitlement answer |
|
||||
| Own-engine game crashes in `libVkLayer_fossilize.so` (Fossilize's recording thread, e.g. Roblox) | same as Deadpool VR: uninitialised pointers in what Fossilize records | `frame.vk_sanitize` now also covers own-engine libraries that load `libvulkan.so` by name |
|
||||
| Crash right after the game gets focus, in `vrclient.so … UpdateActionStateInternal` / `sxr_xrSyncActions` (e.g. Myst) | a race in the Frame runtime's input code on the first sync after focus (not every launch) | adapter `sync_guard` (input syncs one at a time with event polling, paused 250 ms after FOCUSED) |
|
||||
| View jumps sideways / game pauses or freezes for a moment every few seconds (e.g. Blade & Sorcery, BattleGlide) | the Frame drops focus for ~0.5 s (FOCUSED→VISIBLE→SYNCHRONIZED); games pause or re-align the player (`OnVRPresence … Teleport`) | adapter `focus_hold` |
|
||||
| SDL / LÖVE app crashes at start: `NullPointerException … ClipboardManager.addPrimaryClipChangedListener` in `SDLClipboardHandler.<init>` (e.g. Dramatic Shape) | Lepton's Android has no clipboard service (`service check clipboard`: not found) | `frame.sdl_clipboard` (that call → nops in classes.dex, in place, `apk/dex.py`); reproduced and verified with LÖVE for Android 11.5 |
|
||||
| Game crashes at start: `java.lang.NoSuchMethodError: No virtual method getAvailableCommunicationDevices()… in class Landroid/media/AudioManager` at `com.vivox.sdk.AudioChangeListener.checkAudioRouteAndApplyChanges` (e.g. Green Hell VR, GitHub #101); triage `vivox-api31` | Newer Vivox (voice chat) builds call Android 12 audio-routing methods without a version check; Lepton is Android 11. Older Vivox builds (Eleven Table Tennis, BattleSisters) don't have that code | `frame.vivox_audio_route` (suggested by analysis `vivox_api31`): every AudioChangeListener method that calls one of them returns at once (`Dex.return_early`: return-void / `const/4 v0, 0; return v0`, in place); voice chat keeps the default audio route |
|
||||
| Controllers do nothing (e.g. stuck on a setup screen); log: `xrSuggestInteractionProfileBindings … XR_ERROR_PATH_UNSUPPORTED` | the game only suggests bindings for Meta's newer profiles (Touch Plus / Touch Pro); the Frame's Android runtime knows `oculus/touch_controller`, not those | adapter `profile_remap` (default on): the bindings are suggested again for Touch (log: `controller profile … -> oculus/touch_controller`) |
|
||||
| Some buttons do nothing or the wrong thing (e.g. a Quest game's X/Y on the Frame's left controller) | the game binds only Quest Touch and SteamVR maps Touch onto the Frame controllers with its own remap (Touch left X/Y land on the d-pad, right X and Y repeat B), or the runtime rejected a profile or an input call | turn on adapter `input_diag` (diagnostics, off by default; `frameport settings <pkg> input_diag=1`) and look for `input_diag:` lines: `bindings: <profile> accepted` or `unsupported: interaction profile <profile> -> <result>` with the rejected paths, `bindings: /user/hand/<hand> uses <profile>`, `unsupported: function <name>`, `unsupported: xrCreateAction -> <result> (<action>)` |
|
||||
| Unity game hangs or the whole Frame restarts (e.g. in a menu); log: `The current MSAA level is 0, but the recommended MSAA level is 4. Switching to the recommended level.` (e.g. Lucky's Tale) | Meta's OVRManager (`useRecommendedMSAALevel`) turns 4x MSAA on at runtime, past QualitySettings; multisampled render-to-texture on GLES/Zink hangs the GPU | `frame.unity_runtime_msaa_off` (Cpp2IL: `OVRDisplay.get_recommendedMSAALevel` → 0) |
|
||||
| Unity game: one eye shows only effects or grey, the other is fine (e.g. I Am Cat, Oculus XR Plugin on GLES) | the game's single-pass multiview rendering goes wrong for array slice 1 (the adapter submits both slices correctly) | `frame.unity_multipass` (Cpp2IL: `OculusSettings.GetStereoRenderingMode` → MultiPass); `swap_eyes=1` tells whether the game or the adapter is at fault |
|
||||
| Setup/intro loops every launch (Espire 2) | save folders created without write permission | launcher repairs permissions every 2 s (built in) |
|
||||
| VrApi bridge: never enters VR | Frame reaches FOCUSED later than Quest; tracking only when worn | bridge waits 30 s; test in the headset |
|
||||
| Unreal game (ILMxLAB, e.g. Vader Immortal) plays its intro, then stays on its loading card (portrait + progress bar) at 72 fps and ignores input (GitHub #49) | the menu waits for `UVRUtils::GetQuestShaderPrecompilePercent()` to reach 100 %; the precompile only starts when `IsRunningOnSantaCruz()` (a Quest), the other branch returns 0.0 | `frame.unreal_quest_precompile` (non-Quest branch returns 1.0; found by Klownicle); suggested when the engine exports that function (analysis `unreal_quest_gates`) |
|
||||
|
||||
## Picture problems (headset on)
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Unreal game (ILMxLAB, e.g. Vader Immortal): controllers track but grip/trigger do nothing, grabbing never works (Unreal's own input values move, the game's stay 0) | `URPOCKeyMapManagerComponent` picks the Quest or the Gear VR key set by `IsRunningOnSantaCruz()`; on the Frame it takes Gear VR, empty in a Quest build | `frame.unreal_quest_keymap` (the Oculus case keeps the Quest set, for action and axis mappings; found by Klownicle, GitHub #49) |
|
||||
| The hands' thumbs never move when you touch the thumbstick or buttons (Unreal `ThumbUp` stays 1, e.g. Vader Immortal) | OVRPlugin reports thumb proximity from XR_FB_touch_controller_proximity, which the Frame's runtime lacks; OVRPort's loader offers it anyway and the proximity actions never get a binding | adapter `proximity_emul=1` (thumb proximity from the touch inputs: thumbstick, face buttons, thumb rest; 2 = also the index finger from trigger touch); log `proximity_emul: finger proximity bound to touch`. **For UE4 Oculus games this didn't animate the thumbs** (Vader, GitHub #49): use `frame.unreal_thumb_touch` (found by Klownicle): `OculusInput::FOculusInput::SendControllerEvents` computes ThumbUp from NearTouches (masks 0x2/0x8); the patch makes it read Touches (the slot 4 bytes before) with masks 0x0f00 (X/Y/stick/thumb rest) / 0x000f (A/B/stick/thumb rest), keeping the inversion; three instructions, matched exactly (build note `OculusInput ThumbUp: from the capacitive touches`). Matches Vader Immortal Ep. I, Robo Recall, Phantom: Covert Ops, Time Stall, Star Wars: Tales from the Galaxy's Edge; newer UE4 builds (e.g. Asgard's Wrath 2, RE4, In Death) compile it differently (shared mask registers) and aren't matched |
|
||||
| OpenGL ES game: the GPU hangs at one effect (freeze/exit, compositor watchdog, e.g. Vader Immortal's lightspeed jump) | a shader (as Zink compiles it) reads loop counters/accumulators before setting them | capture the SPIR-V (adapter `zink_shader_dump=1` writes every module to `files/fp_spirv/`, or a GPU capture), then a `zink_shader_fix` entry (same format as `vk_shader_fix`) + `frame.zink_shader_fix` (Vulkan layer under Zink, activated through GraphicsEnv's debug layer list); log `shader fix layer: fixed shader module N`; triage `zink-shader-fix-mismatch` when a Frame update changed the driver's output |
|
||||
| 2D Android app: Steam says it's running but nothing shows | Lepton runs apps headless unless the app folder has `lepton-show-flatscreen` | FramePort adds it for apps without VR (agent v28+); reinstall apps installed before |
|
||||
| A phone/tablet app shows nothing in the headset, or a VR app opens as a flat window | FramePort's VR/2D guess (`vr_kind`) is wrong for this app | patch `device.display_mode` → **Flat window** or **VR** (Customize → "Show as VR or as a flat window"), then Update on Frame |
|
||||
| "Install with FrameDrop" button opens FrameDrop (or nothing) instead of FramePort | another program owns `framedrop://`, or the switch is off, or macOS (Flet can't receive links there) | Settings → Install links → **Use FramePort for these links**; else Add games → Install from a link… (paste the button's address) |
|
||||
@@ -93,6 +117,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Black screen, audio works, GLES engine with direct VrApi | Mesa rejects Quest-style GLSL | `frame.gl_shim` (logs `GLShim: SHADER COMPILE FAILED` + source lines) |
|
||||
| Black screen, audio works, log `Unsupported VrApi layer type N` | bridge drops frames containing that layer | extend the bridge (cylinder=3 is converted to a quad already) |
|
||||
| Only some draws visible (e.g. controllers) + `glGetError 0x502` | multiview shaders used on single-view FBOs | GL shim `gl_hide_multiview=1` (default) |
|
||||
| Own GLES engine (OpenXR, not VrApi): eye view fine, but HUD / menu / PDA panels black (e.g. Doom3Quest, GitHub #77; no error in the log) | every shader declares `layout(num_views=2) in;`, also the ones drawn into the engine's single-layer offscreen framebuffers (2D textures/renderbuffers); OVR_multiview makes such draws INVALID_OPERATION and Mesa drops them (`draw_validate.c`), Quest's driver doesn't | `frame.gl_multiview_fbo` (opt-in, experimental: `libfpglmv.so` interposes GLES and draws those cases with a single-view twin of the program; logs `GLMV: twin built: program N -> M`, per-5-s `single-view draws` counters; `gl_mv_debug=1` adds glGetError checks; triage `gl-multiview-twin-failed`). Alternative: the game's own fix (two-layer multiview pool, Xandrix's patch in #77) |
|
||||
| Upside-down image in a GL bridge game | GL images start at the bottom row | fixed in the bridge (swap angleUp/Down for GL chains) |
|
||||
| UI panels upside down (AC Nexus) | XrCompositionLayerImageLayoutFB VERTICAL_FLIP unsupported | adapter `flip_emul=1` (default); rotating quads makes them vanish |
|
||||
| Passthrough black (BAM) | XR_FB_passthrough missing | adapter `passthrough_emul=1` (default) + `patch_force_passthrough` for MR-only games |
|
||||
@@ -105,7 +130,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| View vibrates even when holding still after `frame.unity_multipass` (I Am Cat) | poses/times are consistent, but two passes on Zink make every frame one period late, and the game clamps its physics step to 10 ms | not fixed (scale 0.8 didn't help); next ideas: fix multiview instead, or the game's fixed timestep |
|
||||
| Game freezes / pauses for 0.5-2 s now and then, no flicker (e.g. Blade & Sorcery, Lucky's Tale) | the headset's wear sensor flickers off while worn (`logs/eyetracking.txt` `HMD off, stopping eye tracking` … `HMD on`), the Frame turns it into a focus loss and Quest games that pause on focus loss pause | adapter `focus_hold` (on by default since 2026-10-05, dips up to `focus_hold_ms` = 5000 ms are hidden; longer ones still pause) |
|
||||
| Minor glitches on some objects (Myst, Unreal Vulkan) | unknown; not application space warp (turning it off changed nothing and ran worse) | unresolved |
|
||||
| Graphics crash in vkCreateInstance with pc == fault address, from the game's own Vulkan hooks (BONELAB 1.2974 `libSLZQuestNative`) | a pointer into memory freed with OVRPlugin's pre-init OpenXR instance; not the runtime (vrclient.so) or Fossilize being unloaded (keeping both loaded with RTLD_NODELETE didn't help) | unresolved |
|
||||
| Unity game crashes or hangs right as Vulkan starts, after `SLZ Graphics plugin loading!` and OVRPlugin's pre-init `xrDestroyInstance`: SIGSEGV with pc 0 (or pc == fault address in an unloaded library), x16/x23 in `libSLZQuestNative.so` (BONELAB 1.2974) | Stress Level Zero's graphics plugin hooks Unity's Vulkan start-up (IUnityGraphicsVulkanV2 interception: vkCreateInstance/vkCreateDevice) and vkCreateSampler; its vkCreateInstance wrapper calls an invalid pointer on the Frame | `frame.slz_vulkan_hooks` (its two registrations become no-ops; Unity starts Vulkan itself; ~35 s of shader prewarming without its pipeline cache) |
|
||||
| Unity game shows a picture but the player body is frozen: no head tracking, the controllers stay on the model, no buttons, although OpenXR input works (BONELAB 1.2068, Oculus XR Plugin) | OVRPlugin reports the headset as not worn (`ovrp_GetUserPresent2` → 0) a few seconds after start, and the game's rig only follows a present user (Marrow `XRHMD.IsUserPresent` = Unity's HMD `UserPresence`) | `frame.unity_user_presence` (the Oculus XR Plugin's lookup → `libfp_ovrp.so`, reports present; log `user presence: OVRPlugin 0 …`) |
|
||||
| Unreal 5 game crashes at its first render pass, backtrace `vulkan.freedreno.so` ← `libVkLayer_fossilize.so` ← `libfp_vk.so (create_render_pass2)` (Metro Awakening) | a subpass without a depth attachment names a depth/stencil resolve attachment; the Frame's Turnip reads the missing depth attachment (NULL) | the Vulkan shim drops that resolve (always, FramePort after 0.11.0; log `dropped the depth resolve of subpass N (no depth attachment)`); triage `unreal-vulkan-driver-crash` |
|
||||
| Unreal 5 game crashes in `vulkan.freedreno.so` called straight from `libUnreal.so` right after its swapchains, or a few seconds into the first frames (Metro Awakening) | Unreal turns on fragment-density-map foveation because Turnip offers VK_EXT_fragment_density_map, then records barriers (to `FRAGMENT_DENSITY_MAP_OPTIMAL`) and image views (2D array R8G8) for a density map the Frame never provides (VK_NULL_HANDLE); Turnip dereferences it | adapter `vk_hide_fdm=1` (the game doesn't see the extension) + `vk_spec_fixes=1` (leaves out null-image barriers, refuses null-image views); triage `unreal-fdm-missing`. Found by disassembling the driver at the crash pc: the struct offsets (0x58 = VkSubpassDescription2, 0x48/0x60 = image barriers, viewType/format in registers) name the call |
|
||||
@@ -141,6 +166,14 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
touching new memory (`perf script -F comm,tid,ip,dso`); `-t <tid> -F 499` samples one thread. Map IL2CPP
|
||||
addresses with a full Cpp2IL diffable-cs dump (method `Offset` = address − the r-xp mapping of libil2cpp.so).
|
||||
Sample memory per mapping name from `/proc/<pid>/smaps` (`[anon:libc_malloc]` = native heap).
|
||||
- Hangs (every thread asleep): backtraces of all threads with gdb. Root inside the Lepton container may ptrace the
|
||||
game (the host's `ptrace_scope` 1 doesn't apply there) and the guest has `gdbserver64`; `debuggerd -b` fails (no
|
||||
tombstoned). Copy every mapped library to a local sysroot (`podman exec <container> cat /proc/<pid>/maps`, then
|
||||
`tar -chf - -T <list>` through `podman exec -i`; the app's own libs from the installed `lepton-app/game.apk` at their
|
||||
`/data/app/…/lib/arm64` path), then on the PC: `gdb -batch -ex "set sysroot <dir>" -ex "target remote | ssh
|
||||
steamos@<frame> podman exec -i lepton-steamlaunch-<appid> gdbserver64 - --attach <pid in the container>" -ex
|
||||
"thread apply all bt 25" -ex detach` (`set sysroot target:` makes gdbserver assert in linux_mntns_readlink).
|
||||
`/proc/<pid>/task/*/syscall` (read inside the container) shows each thread's syscall and pc without a debugger.
|
||||
- Unity catches native crashes itself: no debuggerd backtrace in Lepton's logcat-crash.log, but a
|
||||
`tombstone_00` in the game's `Android/data/<pkg>/files/` with the memory map (map pc/lr/registers to libraries).
|
||||
- Focus losses: the game sees FOCUSED→VISIBLE→SYNCHRONIZED (OVRPlugin `[XR_SESSION]`, FrameBridge `focus_hold:`
|
||||
|
||||
+3
-1
@@ -11,11 +11,13 @@ embeds debug paths (`-g`) and the dex depends on the d8 version, so those differ
|
||||
|
||||
| Dir | Artifact | What / why |
|
||||
|---|---|---|
|
||||
| `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`); `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. |
|
||||
| `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), 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. |
|
||||
| `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). |
|
||||
| `xrshim/` | `arm64-v8a/libframe_xrshim.so` | FrameBridge extension shim, only in builds with `controller_models=1`. overport's dispatcher (`libopenxr_loader.so`) answers `xrGetInstanceProcAddr` from a fixed table and refuses everything else (`overportOXR: Unknown proc addr: xrLoadRenderModelFB`), so the adapter never sees XR_FB_render_model lookups. Meta's OVRPlugin gets the function with `dlopen("libopenxr_loader.so")` + `dlsym`, so FramePort rewrites that `.rodata` string in `libOVRPlugin.so` (same length, in place) to `libframe_xrshim.so`. The shim returns the adapter's emulated functions (`framebridge_extension_proc`) and forwards every other name to overport. A DT_NEEDED interposer does not work here (dlsym on a handle searches that library first); tried and dropped. |
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Included by frame_adapter.c (after layer_emul_gl.c, whose GL entry points it shares). cube_standin (default on):
|
||||
// the Frame's runtime has no cube layers (XR_KHR_composition_layer_cube) and refuses cube swapchains (faceCount 6)
|
||||
// with XR_ERROR_RUNTIME_FAILURE. OVRPlugin doesn't check: it goes on with "CreateSwapchain for eye 0: 0x0, 0 stages"
|
||||
// and writes past its empty image list in the next ovrp_EndFrame4 (Unity's render thread SIGSEGV in memset, e.g.
|
||||
// Budget Cuts Ultimate's cube-map overlay, GitHub #107). Only when the runtime refused one, a cube swapchain is served
|
||||
// by the adapter instead: one GL cube-map texture (the format, size and mip levels asked for) in the app's current
|
||||
// GLES context, acquire/wait/release succeed at once. The app renders or copies into it as usual; layers that use it
|
||||
// are dropped in xrEndFrame (the runtime couldn't show them anyway). Vulkan sessions (no current EGL context) keep
|
||||
// the runtime's error.
|
||||
#define STANDIN_MAX 8
|
||||
static struct { XrSwapchain handle; GLuint tex; } standins[STANDIN_MAX];
|
||||
static pthread_mutex_t standin_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static int standin_find(XrSwapchain handle) {
|
||||
if (!handle) return -1;
|
||||
int slot = -1;
|
||||
pthread_mutex_lock(&standin_lock);
|
||||
for (int i = 0; i < STANDIN_MAX && slot < 0; ++i)
|
||||
if (standins[i].handle == handle) slot = i;
|
||||
pthread_mutex_unlock(&standin_lock);
|
||||
return slot;
|
||||
}
|
||||
|
||||
static int is_standin(XrSwapchain handle) { return cube_standin && standin_find(handle) >= 0; }
|
||||
|
||||
// After the runtime refused a swapchain: serve a cube stand-in if it was a cube swapchain in a GLES context.
|
||||
static XrResult standin_after_failure(const XrSwapchainCreateInfo *info, XrSwapchain *out, XrResult result) {
|
||||
if (XR_SUCCEEDED(result) || !cube_standin || !info || !out || info->faceCount != 6) return result;
|
||||
if (info->width != info->height || !info->width || info->arraySize > 1) return result;
|
||||
if (!p_eglCreateContext && !emul_load_gl()) { LOG("cube_standin: no GLES, the runtime's error stays"); return result; }
|
||||
if (p_eglGetCurrentContext() == EGL_NO_CONTEXT) {
|
||||
LOG("cube_standin: no current GLES context (Vulkan?), the runtime's error stays");
|
||||
return result;
|
||||
}
|
||||
int slot = -1;
|
||||
pthread_mutex_lock(&standin_lock);
|
||||
for (int i = 0; i < STANDIN_MAX && slot < 0; ++i)
|
||||
if (!standins[i].handle) slot = i;
|
||||
if (slot >= 0) standins[slot].handle = (XrSwapchain)1; // reserved
|
||||
pthread_mutex_unlock(&standin_lock);
|
||||
if (slot < 0) { LOG("cube_standin: too many cube swapchains, the runtime's error stays"); return result; }
|
||||
GLsizei levels = info->mipCount > 1 ? (GLsizei)info->mipCount : 1;
|
||||
for (int i = 0; i < 8 && p_glGetError() != GL_NO_ERROR; ++i) {}
|
||||
GLint previous = 0;
|
||||
p_glGetIntegerv(GL_TEXTURE_BINDING_CUBE_MAP, &previous);
|
||||
GLuint tex = 0;
|
||||
p_glGenTextures(1, &tex);
|
||||
p_glBindTexture(GL_TEXTURE_CUBE_MAP, tex);
|
||||
p_glTexStorage2D(GL_TEXTURE_CUBE_MAP, levels, (GLenum)info->format, (GLsizei)info->width, (GLsizei)info->height);
|
||||
GLenum err = p_glGetError();
|
||||
p_glBindTexture(GL_TEXTURE_CUBE_MAP, (GLuint)previous); // the app's binding, untouched
|
||||
if (err != GL_NO_ERROR) {
|
||||
p_glDeleteTextures(1, &tex);
|
||||
pthread_mutex_lock(&standin_lock);
|
||||
standins[slot].handle = XR_NULL_HANDLE;
|
||||
pthread_mutex_unlock(&standin_lock);
|
||||
LOG("cube_standin: couldn't make a %ux%u cube map (GL 0x%x), the runtime's error stays", info->width,
|
||||
info->height, err);
|
||||
return result;
|
||||
}
|
||||
XrSwapchain handle = FAKE_HANDLE(XrSwapchain);
|
||||
pthread_mutex_lock(&standin_lock);
|
||||
standins[slot].handle = handle;
|
||||
standins[slot].tex = tex;
|
||||
pthread_mutex_unlock(&standin_lock);
|
||||
*out = handle;
|
||||
LOG("cube_standin: runtime refused a %ux%u cube swapchain (result=%d): served by FrameBridge (GL cube map %u, "
|
||||
"%d levels); its cube layers are dropped", info->width, info->height, result, tex, (int)levels);
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
|
||||
static XrResult standin_enumerate(XrSwapchain handle, uint32_t capacity, uint32_t *count, XrSwapchainImageBaseHeader *images) {
|
||||
if (!count) return XR_ERROR_VALIDATION_FAILURE;
|
||||
*count = 1;
|
||||
if (!capacity) return XR_SUCCESS;
|
||||
if (!images) return XR_ERROR_VALIDATION_FAILURE;
|
||||
if (images->type != XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR) return XR_ERROR_VALIDATION_FAILURE;
|
||||
int slot = standin_find(handle);
|
||||
((emul_gles_image *)images)[0].image = slot >= 0 ? standins[slot].tex : 0;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
|
||||
static void standin_destroy(XrSwapchain handle) {
|
||||
pthread_mutex_lock(&standin_lock);
|
||||
for (int i = 0; i < STANDIN_MAX; ++i)
|
||||
if (standins[i].handle == handle) {
|
||||
if (p_eglGetCurrentContext && p_eglGetCurrentContext() != EGL_NO_CONTEXT) p_glDeleteTextures(1, &standins[i].tex);
|
||||
standins[i].handle = XR_NULL_HANDLE;
|
||||
standins[i].tex = 0;
|
||||
}
|
||||
pthread_mutex_unlock(&standin_lock);
|
||||
}
|
||||
|
||||
// A layer that shows a stand-in: never passed to the runtime (whatever layer_fix says).
|
||||
static int layer_uses_standin(const XrCompositionLayerBaseHeader *layer) {
|
||||
if (!cube_standin || !layer) return 0;
|
||||
switch (layer->type) {
|
||||
case XR_TYPE_COMPOSITION_LAYER_CUBE_KHR:
|
||||
return is_standin(((const XrCompositionLayerCubeKHR *)layer)->swapchain);
|
||||
case XR_TYPE_COMPOSITION_LAYER_QUAD:
|
||||
return is_standin(((const XrCompositionLayerQuad *)layer)->subImage.swapchain);
|
||||
case XR_TYPE_COMPOSITION_LAYER_CYLINDER_KHR:
|
||||
return is_standin(((const XrCompositionLayerCylinderKHR *)layer)->subImage.swapchain);
|
||||
case XR_TYPE_COMPOSITION_LAYER_EQUIRECT_KHR:
|
||||
return is_standin(((const XrCompositionLayerEquirectKHR *)layer)->subImage.swapchain);
|
||||
case XR_TYPE_COMPOSITION_LAYER_EQUIRECT2_KHR:
|
||||
return is_standin(((const XrCompositionLayerEquirect2KHR *)layer)->subImage.swapchain);
|
||||
case XR_TYPE_COMPOSITION_LAYER_PROJECTION: {
|
||||
const XrCompositionLayerProjection *p = (const XrCompositionLayerProjection *)layer;
|
||||
for (uint32_t v = 0; v < p->viewCount; ++v)
|
||||
if (is_standin(p->views[v].subImage.swapchain)) return 1;
|
||||
return 0;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// XR_FB_swapchain_update_state on a stand-in: nothing to update (only hooked when the runtime has the function).
|
||||
static PFN_xrUpdateSwapchainFB real_update_swapchain;
|
||||
static PFN_xrGetSwapchainStateFB real_get_swapchain_state;
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL standin_update_swapchain(XrSwapchain swapchain, const XrSwapchainStateBaseHeaderFB *state) {
|
||||
if (is_standin(swapchain)) return XR_SUCCESS;
|
||||
return real_update_swapchain ? real_update_swapchain(swapchain, state) : XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
}
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL standin_get_swapchain_state(XrSwapchain swapchain, XrSwapchainStateBaseHeaderFB *state) {
|
||||
if (is_standin(swapchain)) return XR_ERROR_VALIDATION_FAILURE; // no state to report (apps treat it as "unsupported")
|
||||
return real_get_swapchain_state ? real_get_swapchain_state(swapchain, state) : XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
}
|
||||
@@ -40,6 +40,7 @@ static XRAPI_ATTR XrResult XRAPI_CALL eye_hook_xrReleaseSwapchainImage(XrSwapcha
|
||||
const XrSwapchainImageReleaseInfo *info) {
|
||||
PFN_xrReleaseSwapchainImage fn = (PFN_xrReleaseSwapchainImage)lookup(active_instance, "xrReleaseSwapchainImage");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
if (is_standin(swapchain)) return XR_SUCCESS;
|
||||
if (release_wait_active()) eye_wait_for_gpu();
|
||||
XrResult result = fn(swapchain, info);
|
||||
if (eye_debug) {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
// * drops requested instance extensions the runtime lacks (e.g. XR_FB_passthrough);
|
||||
// * retries rejected swapchains with 1 sample / a supported format (swapchain_fix, overport #71);
|
||||
// * drops composition layers that reference swapchains that failed to create (layer_fix);
|
||||
// * serves cube swapchains the runtime refuses as GL cube maps and drops their layers (cube_standin, cube_standin.c);
|
||||
// * optionally requests mutable-format swapchain images (mutable_fix, off by default);
|
||||
// * optionally swaps left/right images of stereo projection layers (swap_eyes, off by default);
|
||||
// * emulates XR_FB_passthrough with XR_ENVIRONMENT_BLEND_MODE_ALPHA_BLEND when the runtime lacks it
|
||||
@@ -103,8 +104,11 @@ static int frame_open;
|
||||
static int sc_acquires, sc_waits, sc_releases, sc_wait_fails; // per pacing period (layer_debug) // drop XrCompositionLayerColorScaleBiasKHR (the runtime's color pass)
|
||||
static int controller_fix = 1;
|
||||
static int swapchain_fix = 1;
|
||||
static int cube_standin = 1; // cube swapchains the runtime refuses: served by the adapter (cube_standin.c)
|
||||
static int rect_clamp = 1;
|
||||
static int pose_consistency = 0; // repeat xrLocateViews queries for one display time get the same poses (GitHub #8) // clamp submitted image rects to their swapchain (SteamVR rejects a 1 px overrun)
|
||||
static int pose_time_fix; // poses asked for at CLOCK_MONOTONIC "now" or far in the past: at XrTime "now" (pose_time.c)
|
||||
static int pose_debug; // diagnostics: requested pose times vs the predicted display time (pose_time.c)
|
||||
static int layer_fix = 1;
|
||||
static int mutable_fix = 0;
|
||||
static int swap_eyes = 0;
|
||||
@@ -127,6 +131,8 @@ static int controller_models; // serve Frame controller models via XR_FB_render
|
||||
// Per-game settings, all off by default (session_fixes.c, layer_emul_gl.c).
|
||||
static int sync_guard; // xrSyncActions one at a time with xrPollEvent, paused briefly after focus returns
|
||||
static int profile_remap = 1; // Meta's newer controller profiles (rejected by the Frame) -> oculus/touch_controller
|
||||
static int proximity_emul; // finger proximity from capacitive touch: 1 thumb, 2 thumb + index (session_fixes.c)
|
||||
static int runtime_has_proximity; // the runtime has XR_FB_touch_controller_proximity itself
|
||||
static int layer_debug; // diagnostics: layers, swapchains, session states, spaces, aim/grip, refresh rates
|
||||
static int input_diag; // diagnostics: controller profiles, bindings, missing functions, failing input calls
|
||||
static int stable_local; // keep every LOCAL space the app creates on the session-start origin
|
||||
@@ -158,8 +164,11 @@ static void read_settings(const char *path) {
|
||||
if (sscanf(line, "snapshot=%f", &value) == 1) snapshot = value > 0 ? (int)value : 0;
|
||||
if (sscanf(line, "controller_fix=%f", &value) == 1) controller_fix = value != 0;
|
||||
if (sscanf(line, "swapchain_fix=%f", &value) == 1) swapchain_fix = value != 0;
|
||||
if (sscanf(line, "cube_standin=%f", &value) == 1) cube_standin = value != 0;
|
||||
if (sscanf(line, "rect_clamp=%f", &value) == 1) rect_clamp = value != 0;
|
||||
if (sscanf(line, "pose_consistency=%f", &value) == 1) pose_consistency = value != 0;
|
||||
if (sscanf(line, "pose_debug=%f", &value) == 1) pose_debug = value != 0;
|
||||
if (sscanf(line, "pose_time_fix=%f", &value) == 1) pose_time_fix = value != 0;
|
||||
if (sscanf(line, "layer_fix=%f", &value) == 1) layer_fix = value != 0;
|
||||
if (sscanf(line, "mutable_fix=%f", &value) == 1) mutable_fix = value != 0;
|
||||
if (sscanf(line, "swap_eyes=%f", &value) == 1) swap_eyes = value != 0;
|
||||
@@ -180,6 +189,7 @@ static void read_settings(const char *path) {
|
||||
if (sscanf(line, "eye_debug=%f", &value) == 1) eye_debug = value != 0;
|
||||
if (sscanf(line, "release_wait=%f", &value) == 1) release_wait = (int)value;
|
||||
if (sscanf(line, "profile_remap=%f", &value) == 1) profile_remap = value != 0;
|
||||
if (sscanf(line, "proximity_emul=%f", &value) == 1 && value >= 0 && value <= 2) proximity_emul = (int)value;
|
||||
if (sscanf(line, "sync_guard=%f", &value) == 1) sync_guard = value != 0;
|
||||
if (sscanf(line, "stable_local=%f", &value) == 1) stable_local = value != 0;
|
||||
if (sscanf(line, "focus_hold=%f", &value) == 1) focus_hold = value != 0;
|
||||
@@ -240,6 +250,8 @@ static void initialize(void) {
|
||||
log_file = fopen(path, "a");
|
||||
}
|
||||
if (input_diag) LOG("per-game: input_diag=1 (controller-input diagnostics)");
|
||||
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 (strip_color_bias) LOG("per-game: strip_color_bias=%d (layer color scale/bias removed)", strip_color_bias);
|
||||
if (frame_balance) LOG("per-game: frame_balance=1 (an open frame is ended before the next one begins)");
|
||||
@@ -359,6 +371,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
}
|
||||
for (uint32_t j = 0; available && j < available_count; ++j)
|
||||
if (!strcmp(available[j].extensionName, "XR_FB_composition_layer_image_layout")) runtime_has_image_layout = 1;
|
||||
else if (!strcmp(available[j].extensionName, "XR_FB_touch_controller_proximity")) runtime_has_proximity = 1;
|
||||
// Layer types are only valid if their extension ends up enabled; some apps submit them regardless.
|
||||
int has_equirect = 0, has_equirect2 = 0, has_cylinder = 0, has_cube = 0;
|
||||
for (uint32_t i = 0; i < kept; ++i) {
|
||||
@@ -499,6 +512,10 @@ static int emul_is_virtual(XrSwapchain handle);
|
||||
static void emul_virtual_destroy(XrSwapchain handle);
|
||||
static XrResult emul_virtual_enumerate(XrSwapchain handle, uint32_t capacity, uint32_t *count, XrSwapchainImageBaseHeader *images);
|
||||
static void emul_on_acquire(XrSwapchain handle, uint32_t index);
|
||||
static XrResult standin_after_failure(const XrSwapchainCreateInfo *info, XrSwapchain *out, XrResult result);
|
||||
static int is_standin(XrSwapchain handle);
|
||||
static void standin_destroy(XrSwapchain handle);
|
||||
static XrResult standin_enumerate(XrSwapchain handle, uint32_t capacity, uint32_t *count, XrSwapchainImageBaseHeader *images);
|
||||
|
||||
static int known_swapchain(XrSwapchain handle) {
|
||||
int found = 0;
|
||||
@@ -539,7 +556,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
|
||||
snap_on_create(*swapchain, &fixed);
|
||||
return result;
|
||||
}
|
||||
if (!swapchain_fix) return result;
|
||||
if (!swapchain_fix) return standin_after_failure(&fixed, swapchain, result);
|
||||
|
||||
// Frame's runtime rejects some GLES formats (GL_RGBA8) and MSAA swapchains (overport #71).
|
||||
if (fixed.sampleCount > 1) {
|
||||
@@ -562,7 +579,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
|
||||
emul_on_create_swapchain(*swapchain, &fixed);
|
||||
snap_on_create(*swapchain, &fixed);
|
||||
}
|
||||
return result;
|
||||
return standin_after_failure(&fixed, swapchain, result);
|
||||
}
|
||||
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrDestroySwapchain(XrSwapchain swapchain) {
|
||||
@@ -572,6 +589,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrDestroySwapchain(XrSwapchain swapchain) {
|
||||
static int logged;
|
||||
if (logged++ < 200) LOG("layer_debug: xrDestroySwapchain %p", (void *)swapchain);
|
||||
}
|
||||
if (is_standin(swapchain)) { standin_destroy(swapchain); return XR_SUCCESS; }
|
||||
forget_swapchain(swapchain);
|
||||
surf_on_destroy(swapchain);
|
||||
flip_on_destroy(swapchain);
|
||||
@@ -753,6 +771,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetSystemProperties(XrInstance instance, XrSyst
|
||||
}
|
||||
|
||||
#include "flip_vk.c"
|
||||
#include "pose_time.c"
|
||||
|
||||
// XR_KHR_convert_timespec_time for runtimes that lack it (Frame): XrTime is derived from the monotonic clock
|
||||
// using the offset measured at the last xrWaitFrame. The runtime's own implementation is preferred.
|
||||
@@ -762,10 +781,14 @@ static PFN_xrConvertTimeToTimespecTimeKHR runtime_time_to_timespec;
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL emu_timespec_to_time(XrInstance instance, const struct timespec *ts, XrTime *time) {
|
||||
if (runtime_timespec_to_time) {
|
||||
XrResult r = runtime_timespec_to_time(instance, ts, time);
|
||||
if (r != XR_ERROR_FUNCTION_UNSUPPORTED) return r;
|
||||
if (r != XR_ERROR_FUNCTION_UNSUPPORTED) {
|
||||
if (XR_SUCCEEDED(r) && time) pose_time_note("xrConvertTimespecTimeToTimeKHR (runtime)", 0, 0, *time);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
if (!ts || !time) return XR_ERROR_VALIDATION_FAILURE;
|
||||
*time = (XrTime)((int64_t)ts->tv_sec * 1000000000ll + ts->tv_nsec + (xr_time_calibrated ? xr_time_offset : 0));
|
||||
pose_time_note("xrConvertTimespecTimeToTimeKHR", 0, 0, *time);
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL emu_time_to_timespec(XrInstance instance, XrTime time, struct timespec *ts) {
|
||||
@@ -781,6 +804,8 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_time_to_timespec(XrInstance instance,
|
||||
}
|
||||
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrLocateSpace(XrSpace space, XrSpace baseSpace, XrTime time, XrSpaceLocation *location) {
|
||||
time = pose_time_fixed(time);
|
||||
pose_time_note("xrLocateSpace", (uint64_t)(uintptr_t)space, (uint64_t)(uintptr_t)baseSpace, time);
|
||||
if (emulate_scene && location) {
|
||||
XrPosef pose; XrSpaceLocationFlags flags;
|
||||
if (locate_fake(space, baseSpace, time, &pose, &flags)) {
|
||||
@@ -796,6 +821,14 @@ XRAPI_ATTR XrResult XRAPI_CALL xrLocateSpace(XrSpace space, XrSpace baseSpace, X
|
||||
static XrResult locate_spaces_common(const char *name, XrSession session, const XrSpacesLocateInfo *info,
|
||||
XrSpaceLocations *locations) {
|
||||
int any_fake = 0;
|
||||
XrSpacesLocateInfo moved;
|
||||
if (info && pose_time_fix) {
|
||||
moved = *info;
|
||||
moved.time = pose_time_fixed(info->time);
|
||||
info = &moved;
|
||||
}
|
||||
if (info) pose_time_note(name, info->spaceCount ? (uint64_t)(uintptr_t)info->spaces[0] : 0,
|
||||
(uint64_t)(uintptr_t)info->baseSpace, info->time);
|
||||
if (emulate_scene && info && locations) {
|
||||
any_fake = fake_space_index(info->baseSpace) >= 0;
|
||||
for (uint32_t i = 0; i < info->spaceCount && !any_fake; ++i) any_fake = fake_space_index(info->spaces[i]) >= 0;
|
||||
@@ -829,6 +862,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpace(XrSpace space) {
|
||||
#include "input_diag.c"
|
||||
#include "session_fixes.c"
|
||||
#include "layer_emul_gl.c"
|
||||
#include "cube_standin.c"
|
||||
#include "snapshot_gl.c"
|
||||
#include "surface_swapchain.c"
|
||||
#include "eye_debug.c"
|
||||
@@ -938,6 +972,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSwapchainImages(XrSwapchain swapchain,
|
||||
XrSwapchainImageBaseHeader *images) {
|
||||
PFN_xrEnumerateSwapchainImages fn = (PFN_xrEnumerateSwapchainImages)lookup(active_instance, "xrEnumerateSwapchainImages");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
if (is_standin(swapchain)) return standin_enumerate(swapchain, capacity, count, images);
|
||||
XrResult result = equirect_emul && emul_is_virtual(swapchain) ? emul_virtual_enumerate(swapchain, capacity, count, images)
|
||||
: fn(swapchain, capacity, count, images);
|
||||
if (XR_SUCCEEDED(result) && images && capacity && count) {
|
||||
@@ -952,6 +987,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSwapchainImages(XrSwapchain swapchain,
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrWaitSwapchainImage(XrSwapchain swapchain, const XrSwapchainImageWaitInfo *info) {
|
||||
PFN_xrWaitSwapchainImage fn = (PFN_xrWaitSwapchainImage)lookup(active_instance, "xrWaitSwapchainImage");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
if (is_standin(swapchain)) return XR_SUCCESS;
|
||||
XrResult result = fn(swapchain, info);
|
||||
__atomic_add_fetch(&sc_waits, 1, __ATOMIC_RELAXED);
|
||||
if (result != XR_SUCCESS) __atomic_add_fetch(&sc_wait_fails, 1, __ATOMIC_RELAXED);
|
||||
@@ -961,6 +997,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrWaitSwapchainImage(XrSwapchain swapchain, const
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrReleaseSwapchainImage(XrSwapchain swapchain, const XrSwapchainImageReleaseInfo *info) {
|
||||
PFN_xrReleaseSwapchainImage fn = (PFN_xrReleaseSwapchainImage)lookup(active_instance, "xrReleaseSwapchainImage");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
if (is_standin(swapchain)) return XR_SUCCESS;
|
||||
__atomic_add_fetch(&sc_releases, 1, __ATOMIC_RELAXED);
|
||||
return fn(swapchain, info);
|
||||
}
|
||||
@@ -969,6 +1006,11 @@ XRAPI_ATTR XrResult XRAPI_CALL xrAcquireSwapchainImage(XrSwapchain swapchain, co
|
||||
uint32_t *index) {
|
||||
PFN_xrAcquireSwapchainImage fn = (PFN_xrAcquireSwapchainImage)lookup(active_instance, "xrAcquireSwapchainImage");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
if (is_standin(swapchain)) { // adapter-served cube stand-in: one image
|
||||
if (!index) return XR_ERROR_VALIDATION_FAILURE;
|
||||
*index = 0;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
if (equirect_emul && emul_is_virtual(swapchain)) { // adapter-served write-once picture: one image
|
||||
if (!index) return XR_ERROR_VALIDATION_FAILURE;
|
||||
*index = 0;
|
||||
@@ -1079,6 +1121,10 @@ XRAPI_ATTR XrResult XRAPI_CALL xrWaitFrame(XrSession session, const XrFrameWaitI
|
||||
// predictedDisplayTime is about one display period ahead of "now".
|
||||
xr_time_offset = (int64_t)(state->predictedDisplayTime - state->predictedDisplayPeriod) - mono;
|
||||
xr_time_calibrated = 1;
|
||||
last_display_period = state->predictedDisplayPeriod;
|
||||
pose_time_sample(xr_time_offset);
|
||||
pose_time_display(state->predictedDisplayTime);
|
||||
pose_time_note_offset((long long)(state->predictedDisplayTime - mono));
|
||||
if (layer_debug) debug_aim_vs_grip(state->predictedDisplayTime);
|
||||
}
|
||||
return result;
|
||||
@@ -1143,6 +1189,13 @@ XRAPI_ATTR XrResult XRAPI_CALL xrLocateViews(XrSession session, const XrViewLoca
|
||||
uint32_t capacity, uint32_t *count, XrView *views) {
|
||||
PFN_xrLocateViews fn = (PFN_xrLocateViews)lookup(active_instance, "xrLocateViews");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
XrViewLocateInfo moved;
|
||||
if (info && pose_time_fix) {
|
||||
moved = *info;
|
||||
moved.displayTime = pose_time_fixed(info->displayTime);
|
||||
info = &moved;
|
||||
}
|
||||
if (info) pose_time_note("xrLocateViews", (uint64_t)(uintptr_t)info->space, 0, info->displayTime);
|
||||
int cacheable = pose_consistency && info && state && count && views && capacity > 0;
|
||||
if (cacheable && pose_cache_lookup(session, info, state, capacity, count, views)) return XR_SUCCESS;
|
||||
XrResult result = fn(session, info, state, capacity, count, views);
|
||||
@@ -1257,6 +1310,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
|
||||
__atomic_exchange_n(&input_sync_ok, 0, __ATOMIC_RELAXED), input_last_sync_result,
|
||||
__atomic_exchange_n(&input_bool_reads, 0, __ATOMIC_RELAXED),
|
||||
__atomic_exchange_n(&input_bool_true, 0, __ATOMIC_RELAXED));
|
||||
pose_time_report();
|
||||
frames = 0; drift_sum = drift_max = 0;
|
||||
}
|
||||
}
|
||||
@@ -1348,6 +1402,12 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
|
||||
++dropped; // an emulated video surface without a frame yet
|
||||
continue;
|
||||
}
|
||||
if (layer_uses_standin(layer)) { // a cube layer whose swapchain the runtime refused (cube_standin.c)
|
||||
static int logged;
|
||||
if (logged++ < 3) LOG("cube_standin: dropped a layer (type=%d) that shows a stand-in swapchain", layer->type);
|
||||
++dropped;
|
||||
continue;
|
||||
}
|
||||
if (!layer || (layer_fix && !layer_usable(layer))) { ++dropped; continue; }
|
||||
if (layer->type == XR_TYPE_COMPOSITION_LAYER_QUAD) {
|
||||
const XrCompositionLayerQuad *flipped = flip_quad(session, (const XrCompositionLayerQuad *)layer, &quads[count]);
|
||||
@@ -1738,14 +1798,27 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
|
||||
}
|
||||
if (surface_emul) HOOK_AS(xrCreateSwapchainAndroidSurfaceKHR, hook_xrCreateSwapchainAndroidSurfaceKHR)
|
||||
if (eye_debug || release_wait) HOOK_AS(xrReleaseSwapchainImage, eye_hook_xrReleaseSwapchainImage)
|
||||
if (layer_debug) { // counters only; every other wait/release hook above takes precedence
|
||||
if (layer_debug || cube_standin) { // counters + cube stand-ins; every other wait/release hook above takes precedence
|
||||
HOOK(xrWaitSwapchainImage)
|
||||
HOOK(xrReleaseSwapchainImage)
|
||||
}
|
||||
if (cube_standin && (!strcmp(name, "xrUpdateSwapchainFB") || !strcmp(name, "xrGetSwapchainStateFB"))) {
|
||||
XrResult result = next_gipa(instance, name, function); // only wrapped where the runtime has it
|
||||
if (XR_FAILED(result) || !*function) return result;
|
||||
if (!strcmp(name, "xrUpdateSwapchainFB")) {
|
||||
real_update_swapchain = (PFN_xrUpdateSwapchainFB)*function;
|
||||
*function = (PFN_xrVoidFunction)standin_update_swapchain;
|
||||
} else {
|
||||
real_get_swapchain_state = (PFN_xrGetSwapchainStateFB)*function;
|
||||
*function = (PFN_xrVoidFunction)standin_get_swapchain_state;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (sync_guard || layer_debug || input_diag) HOOK_AS(xrSyncActions, hook_xrSyncActions)
|
||||
if (layer_debug) HOOK_AS(xrGetActionStateBoolean, hook_xrGetActionStateBoolean)
|
||||
if (layer_debug || aim_correction_on() || profile_remap || input_diag)
|
||||
if (layer_debug || aim_correction_on() || profile_remap || input_diag || proximity_emul)
|
||||
HOOK_AS(xrSuggestInteractionProfileBindings, hook_xrSuggestInteractionProfileBindings)
|
||||
if (proximity_emul) HOOK_AS(xrCreateAction, hook_xrCreateAction)
|
||||
if (layer_debug || aim_correction_on()) {
|
||||
HOOK_AS(xrCreateActionSpace, hook_xrCreateActionSpace)
|
||||
}
|
||||
|
||||
@@ -304,6 +304,7 @@ static void emul_on_destroy_swapchain(XrSwapchain handle) {
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrReleaseSwapchainImage(XrSwapchain swapchain, const XrSwapchainImageReleaseInfo *info) {
|
||||
PFN_xrReleaseSwapchainImage fn = (PFN_xrReleaseSwapchainImage)lookup(active_instance, "xrReleaseSwapchainImage");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
if (is_standin(swapchain)) return XR_SUCCESS;
|
||||
pthread_mutex_lock(&emul_lock);
|
||||
emul_source *s = emul_active() ? emul_find_source(swapchain, 0) : NULL;
|
||||
int index = s && s->acquired_count ? (int)s->acquired[0] : -1;
|
||||
@@ -335,6 +336,7 @@ static XRAPI_ATTR XrResult XRAPI_CALL hook_xrReleaseSwapchainImage(XrSwapchain s
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrWaitSwapchainImage(XrSwapchain swapchain, const XrSwapchainImageWaitInfo *info) {
|
||||
PFN_xrWaitSwapchainImage fn = (PFN_xrWaitSwapchainImage)lookup(active_instance, "xrWaitSwapchainImage");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
if (is_standin(swapchain)) return XR_SUCCESS;
|
||||
XrResult result = emul_is_virtual(swapchain) ? XR_SUCCESS : fn(swapchain, info);
|
||||
pthread_mutex_lock(&emul_lock);
|
||||
emul_source *s = emul_active() ? emul_find_source(swapchain, 0) : NULL;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// Included by frame_adapter.c: the times games locate poses at (pose_time_fix, pose_debug).
|
||||
//
|
||||
// pose_debug (diagnostics): every ~5 s, per located space (xrLocateSpace/xrLocateSpaces) and for xrLocateViews, the
|
||||
// number of calls and the min/max of (requested time - the last xrWaitFrame's predictedDisplayTime); for
|
||||
// xrConvertTimespecTimeToTimeKHR the same for its answers, plus the spread of the XrTime-vs-CLOCK_MONOTONIC offset
|
||||
// measured at xrWaitFrame.
|
||||
|
||||
static XrDuration last_display_period;
|
||||
|
||||
#define PT_SLOTS 12
|
||||
typedef struct {
|
||||
const char *kind;
|
||||
uint64_t space, base;
|
||||
int count;
|
||||
long long min, max; // ns relative to the last predicted display time
|
||||
} pt_stat;
|
||||
static pt_stat pt_stats[PT_SLOTS];
|
||||
static int pt_overflow;
|
||||
static long long pt_offset_min, pt_offset_max;
|
||||
static int pt_offset_count;
|
||||
static pthread_mutex_t pt_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static void pose_time_note(const char *kind, uint64_t space, uint64_t base, XrTime time) {
|
||||
if (!pose_debug || !last_predicted_time) return;
|
||||
long long d = (long long)(time - last_predicted_time);
|
||||
pthread_mutex_lock(&pt_lock);
|
||||
int i = 0;
|
||||
while (i < PT_SLOTS && pt_stats[i].count &&
|
||||
!(pt_stats[i].kind == kind && pt_stats[i].space == space && pt_stats[i].base == base))
|
||||
++i;
|
||||
if (i == PT_SLOTS) {
|
||||
++pt_overflow;
|
||||
} else {
|
||||
pt_stat *s = &pt_stats[i];
|
||||
if (!s->count) s->kind = kind, s->space = space, s->base = base, s->min = s->max = d;
|
||||
if (d < s->min) s->min = d;
|
||||
if (d > s->max) s->max = d;
|
||||
++s->count;
|
||||
}
|
||||
pthread_mutex_unlock(&pt_lock);
|
||||
}
|
||||
|
||||
// The offset sampled at xrWaitFrame dips (by up to 2.5 s) whenever xrWaitFrame returns late after a hitch, which put
|
||||
// corrected times up to 2 s in the future (headless survey, 2026-10-09). The steady value is the largest recent sample:
|
||||
// pose_time_fix uses the maximum of the last PT_RING samples (~2 s at 72 Hz; the offset drifts only ~0.14 s per hour).
|
||||
#define PT_RING 144
|
||||
static int64_t pt_ring[PT_RING];
|
||||
static int pt_ring_n, pt_ring_i;
|
||||
|
||||
static void pose_time_sample(int64_t offset) { // XrTime "now" minus CLOCK_MONOTONIC, from xrWaitFrame
|
||||
pthread_mutex_lock(&pt_lock);
|
||||
pt_ring[pt_ring_i] = offset;
|
||||
pt_ring_i = (pt_ring_i + 1) % PT_RING;
|
||||
if (pt_ring_n < PT_RING) ++pt_ring_n;
|
||||
pthread_mutex_unlock(&pt_lock);
|
||||
}
|
||||
|
||||
// The last few predicted display times (xrWaitFrame): a pose asked for at one of them is the game rendering a frame
|
||||
// and is never moved. On a Frame whose XrTime runs only ~70 ms ahead of the monotonic clock (GitHub #49), such a
|
||||
// request could look nearer the monotonic "now" than XrTime's and was moved 68 ms into the future.
|
||||
#define PT_DISPLAY_RING 6
|
||||
#define PT_DISPLAY_MATCH 1000000ll // 1 ms
|
||||
static XrTime pt_display[PT_DISPLAY_RING];
|
||||
static int pt_display_i;
|
||||
|
||||
static void pose_time_display(XrTime predicted) { // from xrWaitFrame
|
||||
pthread_mutex_lock(&pt_lock);
|
||||
pt_display[pt_display_i] = predicted;
|
||||
pt_display_i = (pt_display_i + 1) % PT_DISPLAY_RING;
|
||||
pthread_mutex_unlock(&pt_lock);
|
||||
}
|
||||
|
||||
static int pose_time_is_display(XrTime time) {
|
||||
int found = 0;
|
||||
pthread_mutex_lock(&pt_lock);
|
||||
for (int i = 0; i < PT_DISPLAY_RING && !found; i++) {
|
||||
long long d = (long long)(time - pt_display[i]);
|
||||
found = pt_display[i] && d <= PT_DISPLAY_MATCH && d >= -PT_DISPLAY_MATCH;
|
||||
}
|
||||
pthread_mutex_unlock(&pt_lock);
|
||||
return found;
|
||||
}
|
||||
|
||||
static int64_t pose_time_offset(void) {
|
||||
pthread_mutex_lock(&pt_lock);
|
||||
int64_t best = pt_ring_n ? pt_ring[0] : 0;
|
||||
for (int i = 1; i < pt_ring_n; i++)
|
||||
if (pt_ring[i] > best) best = pt_ring[i];
|
||||
pthread_mutex_unlock(&pt_lock);
|
||||
return best;
|
||||
}
|
||||
|
||||
static void pose_time_note_offset(long long offset) {
|
||||
if (!pose_debug) return;
|
||||
pthread_mutex_lock(&pt_lock);
|
||||
if (!pt_offset_count || offset < pt_offset_min) pt_offset_min = offset;
|
||||
if (!pt_offset_count || offset > pt_offset_max) pt_offset_max = offset;
|
||||
++pt_offset_count;
|
||||
pthread_mutex_unlock(&pt_lock);
|
||||
}
|
||||
|
||||
// pose_time_fix: Meta's OVRPlugin takes "now" from CLOCK_MONOTONIC and passes it on as an XrTime unchanged (no
|
||||
// conversion reaches FrameBridge: OVRPort's dispatcher answers XR_KHR_convert_timespec_time itself, 1:1, since the
|
||||
// Frame's runtime lacks it). On a Quest XrTime is the monotonic clock; on the Frame it runs ahead (2.56 s measured on
|
||||
// SteamOS 0.4.5, 0.05-0.9 s seen on older builds), so every "now" pose (Unity's physics step, OVRInput's controller
|
||||
// poses) was located that far in the past and the hands trailed the controllers (BattleSisters, Sniper Elite VR).
|
||||
// A located time nearer the monotonic clock than XrTime's "now" is taken as a monotonic timestamp and moved to the
|
||||
// same moment in XrTime (offset measured at xrWaitFrame); a time more than PT_PAST_LIMIT before the predicted display
|
||||
// time that isn't one (BattleSisters asks for its head at XrTime 0.1 s; Robo Recall, Phantom, Vader, Time Stall and
|
||||
// The Room VR at ~0 every frame) is located at the predicted display time, the time the game renders with.
|
||||
// A monotonic timestamp is only told apart when the clocks are more than PT_MONO_PERIODS display periods apart, and the
|
||||
// time must be clearly nearer the monotonic "now" (at most a third of its distance to XrTime's "now"); a time equal to
|
||||
// a recent predicted display time is never moved (GitHub #49: XrTime only ~68 ms ahead, display-time requests moved).
|
||||
#define PT_PAST_LIMIT 500000000ll // 0.5 s: no runtime keeps a longer pose history; real past queries are far shorter
|
||||
#define PT_MIN_OFFSET 5000000ll // XrTime and the monotonic clock this close: nothing to tell apart or to fix
|
||||
#define PT_MONO_PERIODS 4
|
||||
#define PT_DEFAULT_PERIOD 13888889ll // 72 Hz, before xrWaitFrame reported a period
|
||||
static int pt_fixed_mono, pt_fixed_past;
|
||||
static long long pt_fixed_min;
|
||||
|
||||
static XrTime pose_time_fixed(XrTime time) {
|
||||
if (!pose_time_fix || !last_predicted_time || pt_ring_n < 8) return time; // a few frames of offset samples first
|
||||
int64_t offset = pose_time_offset(); // XrTime "now" minus the monotonic clock (robust, see above)
|
||||
if (offset < PT_MIN_OFFSET && offset > -PT_MIN_OFFSET) return time;
|
||||
if (pose_time_is_display(time)) return time; // the frame (or a recent one) the game renders
|
||||
long long period = last_display_period > 0 ? (long long)last_display_period : PT_DEFAULT_PERIOD;
|
||||
long long abs_offset = offset < 0 ? -offset : offset;
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
long long mono = (long long)ts.tv_sec * 1000000000ll + ts.tv_nsec;
|
||||
long long d_mono = (long long)time - mono, d_xr = (long long)time - (mono + offset);
|
||||
long long abs_mono = d_mono < 0 ? -d_mono : d_mono, abs_xr = d_xr < 0 ? -d_xr : d_xr;
|
||||
XrTime fixed;
|
||||
int kind;
|
||||
if (abs_offset > PT_MONO_PERIODS * period && abs_mono < PT_PAST_LIMIT && 3 * abs_mono < abs_xr) {
|
||||
fixed = (XrTime)(time + offset), kind = 0; // a monotonic timestamp
|
||||
} else if ((long long)(time - last_predicted_time) < -PT_PAST_LIMIT) {
|
||||
fixed = last_predicted_time, kind = 1; // far in the past: the frame's display time
|
||||
} else {
|
||||
return time;
|
||||
}
|
||||
if (pose_debug) {
|
||||
long long d = (long long)(time - last_predicted_time);
|
||||
pthread_mutex_lock(&pt_lock);
|
||||
if (!(pt_fixed_mono + pt_fixed_past) || d < pt_fixed_min) pt_fixed_min = d;
|
||||
++*(kind ? &pt_fixed_past : &pt_fixed_mono);
|
||||
pthread_mutex_unlock(&pt_lock);
|
||||
}
|
||||
static int logged[2];
|
||||
if (logged[kind]++ < 3)
|
||||
LOG("pose_time_fix: pose asked for at %.1f ms from the predicted display time (%s) located at %.1f ms",
|
||||
(long long)(time - last_predicted_time) / 1e6, kind ? "far in the past" : "a monotonic-clock time",
|
||||
(long long)(fixed - last_predicted_time) / 1e6);
|
||||
return fixed;
|
||||
}
|
||||
|
||||
// called from the pacing statistics in xrEndFrame
|
||||
static void pose_time_report(void) {
|
||||
if (!pose_debug) return;
|
||||
pthread_mutex_lock(&pt_lock);
|
||||
LOG("pose_debug: period %.2f ms; XrTime - monotonic at xrWaitFrame (%d): %.2f..%.2f ms", last_display_period / 1e6,
|
||||
pt_offset_count, pt_offset_min / 1e6, pt_offset_max / 1e6);
|
||||
for (int i = 0; i < PT_SLOTS && pt_stats[i].count; ++i)
|
||||
LOG("pose_debug: %s space=0x%llx base=0x%llx: %d calls, time - predicted display time %.2f..%.2f ms",
|
||||
pt_stats[i].kind, (unsigned long long)pt_stats[i].space, (unsigned long long)pt_stats[i].base,
|
||||
pt_stats[i].count, pt_stats[i].min / 1e6, pt_stats[i].max / 1e6);
|
||||
if (pt_overflow) LOG("pose_debug: %d calls not counted (more than %d spaces)", pt_overflow, PT_SLOTS);
|
||||
if (pt_fixed_mono + pt_fixed_past)
|
||||
LOG("pose_debug: pose_time_fix moved %d monotonic-clock times and %d far-past times (earliest asked %.2f ms)",
|
||||
pt_fixed_mono, pt_fixed_past, pt_fixed_min / 1e6);
|
||||
memset(pt_stats, 0, sizeof(pt_stats));
|
||||
pt_overflow = pt_offset_count = pt_fixed_mono = pt_fixed_past = 0;
|
||||
pthread_mutex_unlock(&pt_lock);
|
||||
}
|
||||
@@ -285,6 +285,90 @@ static int touch_has(const char *path) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- finger proximity from touch (proximity_emul)
|
||||
// Meta's OVRPlugin reports a thumb or index finger resting near the controller through XR_FB_touch_controller_proximity
|
||||
// (.../thumb_fb/proximity_fb, .../trigger/proximity_fb). The Frame's runtime doesn't have it; OVRPort's loader offers
|
||||
// it to the game anyway, and the proximity actions never get a working binding: "near" stays false, so games that
|
||||
// animate the hands from it show a thumb that never moves (Unreal's ThumbUp axis is "no thumb proximity", e.g. Vader
|
||||
// Immortal; found by Klownicle, GitHub #49). With proximity_emul those actions are bound to the capacitive touch inputs
|
||||
// instead (OpenXR combines several bindings of one boolean action with OR): thumb = thumbstick, face buttons or thumb
|
||||
// rest touched; index (proximity_emul=2) = trigger touched. Only when the runtime lacks the extension itself.
|
||||
static XrAction thumb_proximity_action, trigger_proximity_action;
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrCreateAction(XrActionSet set, const XrActionCreateInfo *info,
|
||||
XrAction *action) {
|
||||
PFN_xrCreateAction fn = (PFN_xrCreateAction)lookup(active_instance, "xrCreateAction");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
XrResult r = fn(set, info, action);
|
||||
if (XR_SUCCEEDED(r) && info && action) { // OVRPlugin's action names
|
||||
if (!strcmp(info->actionName, "hand_thumb_proximity")) thumb_proximity_action = *action;
|
||||
else if (!strcmp(info->actionName, "hand_trigger_proximity")) trigger_proximity_action = *action;
|
||||
else return r;
|
||||
LOG("proximity_emul: game action %s found", info->actionName);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static const char *const THUMB_TOUCH_LEFT[] = {"x/touch", "y/touch", "thumbstick/touch", "thumbrest/touch"};
|
||||
static const char *const THUMB_TOUCH_RIGHT[] = {"a/touch", "b/touch", "thumbstick/touch", "thumbrest/touch"};
|
||||
|
||||
static int add_binding(XrInstance instance, PFN_xrStringToPath to_path, XrActionSuggestedBinding *b, uint32_t *n,
|
||||
XrAction action, const char *hand, const char *input) {
|
||||
char path[XR_MAX_PATH_LENGTH];
|
||||
snprintf(path, sizeof(path), "/user/hand/%s/input/%s", hand, input);
|
||||
XrPath p;
|
||||
if (XR_FAILED(to_path(instance, path, &p))) return 0;
|
||||
for (uint32_t i = 0; i < *n; ++i)
|
||||
if (b[i].action == action && b[i].binding == p) return 0;
|
||||
b[*n].action = action;
|
||||
b[(*n)++].binding = p;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Suggests Touch bindings with finger proximity bound to the touch inputs (proximity_emul), falling back to the
|
||||
// bindings as given when the runtime refuses them.
|
||||
static XrResult suggest_touch(XrInstance instance, PFN_xrSuggestInteractionProfileBindings fn,
|
||||
PFN_xrPathToString to_string, const XrInteractionProfileSuggestedBinding *suggested) {
|
||||
PFN_xrStringToPath to_path = (PFN_xrStringToPath)lookup(instance, "xrStringToPath");
|
||||
if (!proximity_emul || runtime_has_proximity || !to_path || !to_string) return fn(instance, suggested);
|
||||
XrAction thumb = thumb_proximity_action, index = proximity_emul > 1 ? trigger_proximity_action : XR_NULL_HANDLE;
|
||||
uint32_t n = suggested->countSuggestedBindings, kept = 0, dropped = 0, added = 0;
|
||||
XrActionSuggestedBinding *b = calloc(n + 2 * 5, sizeof(*b));
|
||||
if (!b) return fn(instance, suggested);
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
char path[XR_MAX_PATH_LENGTH];
|
||||
uint32_t size = 0;
|
||||
const XrActionSuggestedBinding *s = &suggested->suggestedBindings[i];
|
||||
if (XR_SUCCEEDED(to_string(instance, s->binding, sizeof(path), &size, path)) && strstr(path, "/proximity_")) {
|
||||
// the game's own proximity binding, which the runtime doesn't know: its action is bound to touch below
|
||||
if (strstr(path, "/thumb")) { if (!thumb) thumb = s->action; }
|
||||
else if (proximity_emul > 1 && !index) index = s->action;
|
||||
++dropped;
|
||||
continue;
|
||||
}
|
||||
b[kept++] = *s;
|
||||
}
|
||||
for (int hand = 0; hand < 2; ++hand) {
|
||||
const char *name = hand ? "right" : "left";
|
||||
const char *const *thumb_inputs = hand ? THUMB_TOUCH_RIGHT : THUMB_TOUCH_LEFT;
|
||||
for (int c = 0; thumb && c < 4; ++c) added += add_binding(instance, to_path, b, &kept, thumb, name, thumb_inputs[c]);
|
||||
if (index) added += add_binding(instance, to_path, b, &kept, index, name, "trigger/touch");
|
||||
}
|
||||
XrResult r;
|
||||
if (!added && !dropped) r = fn(instance, suggested);
|
||||
else {
|
||||
XrInteractionProfileSuggestedBinding copy = *suggested;
|
||||
copy.countSuggestedBindings = kept;
|
||||
copy.suggestedBindings = b;
|
||||
r = fn(instance, ©);
|
||||
LOG("proximity_emul: finger proximity bound to touch (%u binding(s) added, %u proximity path(s) replaced): %d",
|
||||
added, dropped, r);
|
||||
if (XR_FAILED(r)) r = fn(instance, suggested);
|
||||
}
|
||||
free(b);
|
||||
return r;
|
||||
}
|
||||
|
||||
static XrResult remap_to_touch(XrInstance instance, PFN_xrSuggestInteractionProfileBindings fn,
|
||||
PFN_xrPathToString to_string, const XrInteractionProfileSuggestedBinding *suggested,
|
||||
const char *profile) {
|
||||
@@ -305,7 +389,7 @@ static XrResult remap_to_touch(XrInstance instance, PFN_xrSuggestInteractionProf
|
||||
copy.interactionProfile = touch;
|
||||
copy.countSuggestedBindings = kept;
|
||||
copy.suggestedBindings = b;
|
||||
XrResult r = kept ? fn(instance, ©) : XR_ERROR_PATH_UNSUPPORTED;
|
||||
XrResult r = kept ? suggest_touch(instance, fn, to_string, ©) : XR_ERROR_PATH_UNSUPPORTED;
|
||||
free(b);
|
||||
LOG("controller profile %s -> oculus/touch_controller (%u bindings, %u dropped): %d", profile + 22, kept,
|
||||
n - kept, r);
|
||||
@@ -325,12 +409,14 @@ static XRAPI_ATTR XrResult XRAPI_CALL hook_xrSuggestInteractionProfileBindings(X
|
||||
if (XR_SUCCEEDED(to_string(instance, suggested->suggestedBindings[i].binding, sizeof(path), &size, path)))
|
||||
note_pose_binding(suggested->suggestedBindings[i].action, path);
|
||||
}
|
||||
XrResult r = fn(instance, suggested);
|
||||
input_diag_suggested(instance, suggested, r, to_string); // the runtime's answer to the game's own suggestion
|
||||
if (!profile_remap || !suggested || !to_string) return r;
|
||||
char profile[XR_MAX_PATH_LENGTH] = {0};
|
||||
uint32_t size = 0;
|
||||
if (XR_FAILED(to_string(instance, suggested->interactionProfile, sizeof(profile), &size, profile))) return r;
|
||||
int known = suggested && to_string &&
|
||||
XR_SUCCEEDED(to_string(instance, suggested->interactionProfile, sizeof(profile), &size, profile));
|
||||
XrResult r = known && !strcmp(profile, TOUCH_PROFILE) ? suggest_touch(instance, fn, to_string, suggested)
|
||||
: fn(instance, suggested);
|
||||
input_diag_suggested(instance, suggested, r, to_string); // the runtime's answer to the game's own suggestion
|
||||
if (!profile_remap || !known) return r;
|
||||
if (!strcmp(profile, TOUCH_PROFILE)) {
|
||||
if (XR_SUCCEEDED(r)) app_suggested_touch = 1; // the app's own Touch bindings win over a remap
|
||||
return r;
|
||||
|
||||
+25
-5
@@ -5,12 +5,12 @@ Everything is fetched on demand into native/.cache (git-ignored):
|
||||
- Android NDK r27c (27.2.12479018) from Google's repository (checksummed via repository2-3.xml)
|
||||
- OpenXR headers at the commits each component was written against (KhronosGroup/OpenXR-SDK)
|
||||
- Temurin JDK 21 (javac) and Android build-tools (d8) for the Java stub classes
|
||||
Then builds: FrameBridge adapter (arm64 + arm32), VrApi bridge, platform compat, language packs, GL shim, oculusos stub dex, the
|
||||
Then builds: FrameBridge adapter (arm64 + arm32), VrApi bridge, platform compat, language packs, GL shim, multiview twin interposer (glmv), oculusos stub dex, the
|
||||
timefix OpenXR layer for Proton games (linux-arm64, glibc; the NDK's clang builds it freestanding), the
|
||||
OculusHMDConnected helper for Rift games under Proton (win-x64 PE; the NDK's clang + lld-link, no Windows SDK), the
|
||||
live view's hardware H.264 encoder fp_venc (linux-arm64-bin, static freestanding executable), and rewrites artifacts/SHA256SUMS. Run `frameport parity` afterwards to see which games change.
|
||||
|
||||
python native/build.py [--only adapter,bridge,compat,langpack,glshim,eglfmt,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,ovrpshim,vrsettings,venc] [--ndk PATH]
|
||||
python native/build.py [--only adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,ovrpshim,vrsettings,venc] [--ndk PATH]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -275,6 +275,17 @@ def build_eglfmt(tc: Path):
|
||||
"-Wl,--no-as-needed", "-lEGL", "-Wl,--as-needed", "-ldl", "-llog", "-o", ART / "arm64-v8a/libfpg.so"], cwd=src)
|
||||
|
||||
|
||||
def build_glmv(tc: Path):
|
||||
"""Single-view draws of OVR_multiview programs (see glmv/glmv.c, frame.gl_multiview_fbo). Named libfpglmv.so: as
|
||||
long as "libGLESv3.so", whose dlopen string in the engine is rewritten in place. Linked to libGLESv3 so a dlsym on
|
||||
its handle still finds every GL function it doesn't wrap."""
|
||||
src = HERE / "glmv"
|
||||
run([exe(tc, "aarch64-linux-android29-clang"), "-shared", "-fPIC", "-O2", "-Wall", "-Wextra", "-Werror",
|
||||
"-fvisibility=hidden", f"-ffile-prefix-map={src}=native/glmv", "-Wl,-soname,libfpglmv.so",
|
||||
"-Wl,-z,max-page-size=16384", "glmv.c", "-Wl,--no-as-needed", "-lGLESv3", "-Wl,--as-needed", "-lEGL", "-ldl",
|
||||
"-llog", "-o", ART / "arm64-v8a/libfpglmv.so"], cwd=src)
|
||||
|
||||
|
||||
def build_ovrtrace(tc: Path):
|
||||
"""Meta Platform SDK tracer (see ovrtrace/ovrtrace.c): diagnostics, the game's engine library loads it first."""
|
||||
src = HERE / "ovrtrace"
|
||||
@@ -300,6 +311,14 @@ def build_vkshim(tc: Path):
|
||||
cwd=src)
|
||||
|
||||
|
||||
def build_zinkfix(tc: Path):
|
||||
"""Shader-fix Vulkan layer for OpenGL ES games (see zinkfix/zinkfix.c): between Zink and the driver."""
|
||||
src = HERE / "zinkfix"
|
||||
run([exe(tc, "aarch64-linux-android29-clang"), "-shared", "-fPIC", "-O2", "-Wall", "-Wextra", "-Werror",
|
||||
"-fvisibility=hidden", "-Wl,-soname,libVkLayer_fp_shaderfix.so", "-Wl,-z,max-page-size=16384", "zinkfix.c",
|
||||
"-ldl", "-llog", "-o", ART / "arm64-v8a/libVkLayer_fp_shaderfix.so"], cwd=src)
|
||||
|
||||
|
||||
def build_ovrpshim(tc: Path):
|
||||
"""OVRPlugin frame-loop shim (see ovrpshim/ovrpshim.c) for Unity 2017-2018 built-in Oculus support."""
|
||||
src = HERE / "ovrpshim"
|
||||
@@ -352,7 +371,7 @@ def write_sums():
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--only", default="adapter,bridge,compat,langpack,glshim,eglfmt,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,ovrpshim,vrsettings,venc")
|
||||
ap.add_argument("--only", default="adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,ovrpshim,vrsettings,venc")
|
||||
ap.add_argument("--ndk")
|
||||
args = ap.parse_args()
|
||||
parts = set(args.only.split(","))
|
||||
@@ -361,11 +380,12 @@ def main():
|
||||
tc = clang_dir(ndk(args.ndk)) if parts - {"dex"} else None
|
||||
steps = {"adapter": lambda: build_adapter(tc), "bridge": lambda: build_bridge(tc), "compat": lambda: build_compat(tc),
|
||||
"langpack": lambda: build_langpack(tc),
|
||||
"glshim": lambda: build_glshim(tc), "eglfmt": lambda: build_eglfmt(tc), "ovrtrace": lambda: build_ovrtrace(tc), "xrshim": lambda: build_xrshim(tc), "dex": build_dex, "xrlayer": lambda: build_xrlayer(tc),
|
||||
"glshim": lambda: build_glshim(tc), "eglfmt": lambda: build_eglfmt(tc), "glmv": lambda: build_glmv(tc), "ovrtrace": lambda: build_ovrtrace(tc), "xrshim": lambda: build_xrshim(tc), "dex": build_dex, "xrlayer": lambda: build_xrlayer(tc),
|
||||
"oculushmd": lambda: build_oculushmd(tc), "vkshim": lambda: build_vkshim(tc),
|
||||
"zinkfix": lambda: build_zinkfix(tc),
|
||||
"ovrpshim": lambda: build_ovrpshim(tc),
|
||||
"vrsettings": lambda: build_vrsettings(tc), "venc": lambda: build_venc(tc)}
|
||||
for name in ("adapter", "bridge", "compat", "langpack", "glshim", "eglfmt", "ovrtrace", "xrshim", "vkshim", "ovrpshim", "dex", "xrlayer", "oculushmd", "vrsettings", "venc"):
|
||||
for name in ("adapter", "bridge", "compat", "langpack", "glshim", "eglfmt", "glmv", "ovrtrace", "xrshim", "vkshim", "zinkfix", "ovrpshim", "dex", "xrlayer", "oculushmd", "vrsettings", "venc"):
|
||||
if name in parts:
|
||||
log(f"build {name}")
|
||||
steps[name]()
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// libfpglmv.so: single-view draws of OVR_multiview programs (frame.gl_multiview_fbo, GitHub #77).
|
||||
//
|
||||
// OVR_multiview says a draw is INVALID_OPERATION when the number of views of the draw framebuffer differs from the
|
||||
// num_views the program's vertex shader declares. Qualcomm's Quest driver tolerates it; Mesa (the Frame's GL, Zink)
|
||||
// enforces it (src/mesa/main/draw_validate.c) and drops the draw silently. Engines that compile every shader with
|
||||
// `layout(num_views=2) in;` and also draw into ordinary 2D framebuffers (Doom3Quest's HUD/PDA pool: glTexImage2D
|
||||
// textures + renderbuffers) then get black panels.
|
||||
//
|
||||
// This library sits between such an engine and libGLESv3: the patch puts it first in the engine library's DT_NEEDED
|
||||
// (direct gl* imports bind here) and points the engine's dlopen("libGLESv3.so") at it (same-length name, so its
|
||||
// qgl* table from dlsym binds here too); every function it doesn't define resolves to libGLESv3 through its own
|
||||
// dependency (linked --no-as-needed). When a multiview program draws into a framebuffer whose color attachment has no
|
||||
// views (2D texture, texture layer, renderbuffer, FBO 0), the draw uses a lazily built single-view twin of the program
|
||||
// (glmv_rewrite.h: num_views layout removed, gl_ViewID_OVR -> 0u), with the original's attribute locations, uniform
|
||||
// block bindings and default-block uniform values (copied before every such draw), then the original is bound again.
|
||||
//
|
||||
// Logcat tag GLMV. Setting gl_mv_debug=1 (libframe_settings.so / framebridge.conf / FRAMEBRIDGE_CONFIG, as the GL
|
||||
// shim): glGetError after every twin draw, and per-5-s counters.
|
||||
#define _GNU_SOURCE
|
||||
#include <EGL/egl.h>
|
||||
#include <GLES3/gl32.h>
|
||||
#include <android/log.h>
|
||||
#include <dlfcn.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "glmv_rewrite.h"
|
||||
|
||||
#define TAG "GLMV"
|
||||
#define LOG(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
|
||||
#define EXPORT __attribute__((visibility("default")))
|
||||
#ifndef GLMV_GLES_LIB // (the host test points these at a stand-in GL library)
|
||||
#define GLMV_GLES_LIB "libGLESv3.so"
|
||||
#define GLMV_EGL_LIB "libEGL.so"
|
||||
#endif
|
||||
#ifndef GL_FRAMEBUFFER_ATTACHMENT_TEXTURE_NUM_VIEWS_OVR
|
||||
#define GL_FRAMEBUFFER_ATTACHMENT_TEXTURE_NUM_VIEWS_OVR 0x9630
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------- real functions (only ever from libGLESv3's handle)
|
||||
#define REAL_LIST(X) \
|
||||
X(void, UseProgram, (GLuint)) \
|
||||
X(void, LinkProgram, (GLuint)) \
|
||||
X(void, DeleteProgram, (GLuint)) \
|
||||
X(void, BindFramebuffer, (GLenum, GLuint)) \
|
||||
X(void, DeleteFramebuffers, (GLsizei, const GLuint *)) \
|
||||
X(void, FramebufferTexture2D, (GLenum, GLenum, GLenum, GLuint, GLint)) \
|
||||
X(void, FramebufferRenderbuffer, (GLenum, GLenum, GLenum, GLuint)) \
|
||||
X(void, FramebufferTextureLayer, (GLenum, GLenum, GLuint, GLint, GLint)) \
|
||||
X(void, FramebufferTexture, (GLenum, GLenum, GLuint, GLint)) \
|
||||
X(void, DrawBuffers, (GLsizei, const GLenum *)) \
|
||||
X(void, DrawArrays, (GLenum, GLint, GLsizei)) \
|
||||
X(void, DrawElements, (GLenum, GLsizei, GLenum, const void *)) \
|
||||
X(void, DrawArraysInstanced, (GLenum, GLint, GLsizei, GLsizei)) \
|
||||
X(void, DrawElementsInstanced, (GLenum, GLsizei, GLenum, const void *, GLsizei)) \
|
||||
X(void, DrawRangeElements, (GLenum, GLuint, GLuint, GLsizei, GLenum, const void *)) \
|
||||
X(void, DrawArraysIndirect, (GLenum, const void *)) \
|
||||
X(void, DrawElementsIndirect, (GLenum, GLenum, const void *)) \
|
||||
X(void, DrawElementsBaseVertex, (GLenum, GLsizei, GLenum, const void *, GLint)) \
|
||||
X(void, DrawRangeElementsBaseVertex, (GLenum, GLuint, GLuint, GLsizei, GLenum, const void *, GLint)) \
|
||||
X(void, DrawElementsInstancedBaseVertex, (GLenum, GLsizei, GLenum, const void *, GLsizei, GLint)) \
|
||||
X(GLenum, GetError, (void)) \
|
||||
X(void, GetIntegerv, (GLenum, GLint *)) \
|
||||
X(void, GetFramebufferAttachmentParameteriv, (GLenum, GLenum, GLenum, GLint *)) \
|
||||
X(void, GetProgramiv, (GLuint, GLenum, GLint *)) \
|
||||
X(void, GetProgramInfoLog, (GLuint, GLsizei, GLsizei *, GLchar *)) \
|
||||
X(void, GetShaderiv, (GLuint, GLenum, GLint *)) \
|
||||
X(void, GetShaderInfoLog, (GLuint, GLsizei, GLsizei *, GLchar *)) \
|
||||
X(void, GetShaderSource, (GLuint, GLsizei, GLsizei *, GLchar *)) \
|
||||
X(void, GetAttachedShaders, (GLuint, GLsizei, GLsizei *, GLuint *)) \
|
||||
X(GLuint, CreateShader, (GLenum)) \
|
||||
X(void, ShaderSource, (GLuint, GLsizei, const GLchar *const *, const GLint *)) \
|
||||
X(void, CompileShader, (GLuint)) \
|
||||
X(void, DeleteShader, (GLuint)) \
|
||||
X(GLuint, CreateProgram, (void)) \
|
||||
X(void, AttachShader, (GLuint, GLuint)) \
|
||||
X(void, BindAttribLocation, (GLuint, GLuint, const GLchar *)) \
|
||||
X(void, GetActiveAttrib, (GLuint, GLuint, GLsizei, GLsizei *, GLint *, GLenum *, GLchar *)) \
|
||||
X(GLint, GetAttribLocation, (GLuint, const GLchar *)) \
|
||||
X(void, GetActiveUniform, (GLuint, GLuint, GLsizei, GLsizei *, GLint *, GLenum *, GLchar *)) \
|
||||
X(void, GetActiveUniformsiv, (GLuint, GLsizei, const GLuint *, GLenum, GLint *)) \
|
||||
X(GLint, GetUniformLocation, (GLuint, const GLchar *)) \
|
||||
X(void, GetUniformfv, (GLuint, GLint, GLfloat *)) \
|
||||
X(void, GetUniformiv, (GLuint, GLint, GLint *)) \
|
||||
X(void, GetUniformuiv, (GLuint, GLint, GLuint *)) \
|
||||
X(void, GetActiveUniformBlockName, (GLuint, GLuint, GLsizei, GLsizei *, GLchar *)) \
|
||||
X(void, GetActiveUniformBlockiv, (GLuint, GLuint, GLenum, GLint *)) \
|
||||
X(GLuint, GetUniformBlockIndex, (GLuint, const GLchar *)) \
|
||||
X(void, UniformBlockBinding, (GLuint, GLuint, GLuint)) \
|
||||
X(void, Uniform1fv, (GLint, GLsizei, const GLfloat *)) \
|
||||
X(void, Uniform2fv, (GLint, GLsizei, const GLfloat *)) \
|
||||
X(void, Uniform3fv, (GLint, GLsizei, const GLfloat *)) \
|
||||
X(void, Uniform4fv, (GLint, GLsizei, const GLfloat *)) \
|
||||
X(void, Uniform1iv, (GLint, GLsizei, const GLint *)) \
|
||||
X(void, Uniform2iv, (GLint, GLsizei, const GLint *)) \
|
||||
X(void, Uniform3iv, (GLint, GLsizei, const GLint *)) \
|
||||
X(void, Uniform4iv, (GLint, GLsizei, const GLint *)) \
|
||||
X(void, Uniform1uiv, (GLint, GLsizei, const GLuint *)) \
|
||||
X(void, Uniform2uiv, (GLint, GLsizei, const GLuint *)) \
|
||||
X(void, Uniform3uiv, (GLint, GLsizei, const GLuint *)) \
|
||||
X(void, Uniform4uiv, (GLint, GLsizei, const GLuint *)) \
|
||||
X(void, UniformMatrix2fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
|
||||
X(void, UniformMatrix3fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
|
||||
X(void, UniformMatrix4fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
|
||||
X(void, UniformMatrix2x3fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
|
||||
X(void, UniformMatrix3x2fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
|
||||
X(void, UniformMatrix2x4fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
|
||||
X(void, UniformMatrix4x2fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
|
||||
X(void, UniformMatrix3x4fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
|
||||
X(void, UniformMatrix4x3fv, (GLint, GLsizei, GLboolean, const GLfloat *))
|
||||
|
||||
#define DECL(ret, name, args) static ret(*r_##name) args;
|
||||
REAL_LIST(DECL)
|
||||
#undef DECL
|
||||
typedef void (*PFN_FTMV)(GLenum, GLenum, GLuint, GLint, GLint, GLsizei);
|
||||
typedef void (*PFN_FTMSMV)(GLenum, GLenum, GLuint, GLint, GLsizei, GLint, GLsizei);
|
||||
static PFN_FTMV r_ftmv;
|
||||
static PFN_FTMSMV r_ftmsmv;
|
||||
static __eglMustCastToProperFunctionPointerType (*r_eglGetProcAddress)(const char *);
|
||||
static EGLBoolean (*r_eglMakeCurrent)(EGLDisplay, EGLSurface, EGLSurface, EGLContext);
|
||||
|
||||
static pthread_once_t once = PTHREAD_ONCE_INIT;
|
||||
static int debug;
|
||||
|
||||
static void read_conf_file(const char *path) {
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return;
|
||||
char line[256];
|
||||
while (fgets(line, sizeof(line), f))
|
||||
if (!strncmp(line, "gl_mv_debug=", 12)) debug = atoi(line + 12);
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
// The GL shim's settings sources, later ones winning: libframe_settings.so next to this library, the game's
|
||||
// framebridge.conf, then FRAMEBRIDGE_CONFIG (Lepton's per-game settings.conf).
|
||||
static void read_conf(void) {
|
||||
Dl_info info;
|
||||
char path[600];
|
||||
if (dladdr((void *)read_conf, &info) && info.dli_fname) {
|
||||
const char *slash = strrchr(info.dli_fname, '/');
|
||||
if (slash && slash - info.dli_fname < 500) {
|
||||
snprintf(path, sizeof(path), "%.*s/libframe_settings.so", (int)(slash - info.dli_fname), info.dli_fname);
|
||||
read_conf_file(path);
|
||||
}
|
||||
}
|
||||
char pkg[256] = {0};
|
||||
FILE *f = fopen("/proc/self/cmdline", "r");
|
||||
if (f) {
|
||||
size_t n = fread(pkg, 1, sizeof(pkg) - 1, f);
|
||||
fclose(f);
|
||||
pkg[n] = 0;
|
||||
}
|
||||
char *colon = strchr(pkg, ':');
|
||||
if (colon) *colon = 0;
|
||||
if (*pkg && !strchr(pkg, '/')) {
|
||||
snprintf(path, sizeof(path), "/sdcard/Android/data/%s/files/framebridge.conf", pkg);
|
||||
read_conf_file(path);
|
||||
}
|
||||
const char *env = getenv("FRAMEBRIDGE_CONFIG");
|
||||
if (env && *env) read_conf_file(env);
|
||||
}
|
||||
|
||||
static void init(void) {
|
||||
read_conf();
|
||||
void *gles = dlopen(GLMV_GLES_LIB, RTLD_NOW | RTLD_LOCAL);
|
||||
void *egl = dlopen(GLMV_EGL_LIB, RTLD_NOW | RTLD_LOCAL);
|
||||
r_eglGetProcAddress = egl ? dlsym(egl, "eglGetProcAddress") : NULL;
|
||||
r_eglMakeCurrent = egl ? dlsym(egl, "eglMakeCurrent") : NULL;
|
||||
int missing = 0;
|
||||
#define LOAD(ret, name, args) \
|
||||
r_##name = gles ? (ret(*) args)dlsym(gles, "gl" #name) : NULL; \
|
||||
if (!r_##name && r_eglGetProcAddress) r_##name = (ret(*) args)r_eglGetProcAddress("gl" #name); \
|
||||
missing += !r_##name;
|
||||
REAL_LIST(LOAD)
|
||||
#undef LOAD
|
||||
if (r_eglGetProcAddress) {
|
||||
r_ftmv = (PFN_FTMV)r_eglGetProcAddress("glFramebufferTextureMultiviewOVR");
|
||||
r_ftmsmv = (PFN_FTMSMV)r_eglGetProcAddress("glFramebufferTextureMultisampleMultiviewOVR");
|
||||
}
|
||||
LOG("library active: single-view draws of multiview programs (gl_mv_debug=%d, %d GL functions missing)", debug,
|
||||
missing);
|
||||
}
|
||||
#define INIT() pthread_once(&once, init)
|
||||
|
||||
// ---------------------------------------------------------------- programs
|
||||
struct uni {
|
||||
GLenum type;
|
||||
GLint orig, var; // locations in the original and the twin
|
||||
GLuint shadow[16]; // last value given to the twin
|
||||
int valid;
|
||||
};
|
||||
struct block {
|
||||
GLuint orig_index, var_index;
|
||||
GLint binding; // last binding given to the twin
|
||||
};
|
||||
struct prog {
|
||||
GLuint id;
|
||||
int multiview; // the vertex shader declares num_views
|
||||
int nshaders;
|
||||
GLenum types[6];
|
||||
char *sources[6]; // stage sources at link time (only kept for multiview programs)
|
||||
GLuint twin; // single-view twin, 0 = not built yet
|
||||
int twin_failed;
|
||||
struct uni *unis;
|
||||
int nunis;
|
||||
struct block *blocks;
|
||||
int nblocks;
|
||||
};
|
||||
|
||||
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static struct prog **progs; // indexed by program name (GL names are small integers)
|
||||
static GLuint nprogs;
|
||||
|
||||
static struct prog *find_prog(GLuint id, int create) {
|
||||
struct prog *p = NULL;
|
||||
pthread_mutex_lock(&lock);
|
||||
if (id < nprogs) p = progs[id];
|
||||
if (!p && create && id < (1u << 24)) {
|
||||
if (id >= nprogs) {
|
||||
GLuint n = nprogs ? nprogs : 256;
|
||||
while (n <= id) n *= 2;
|
||||
struct prog **grown = realloc(progs, n * sizeof(*grown));
|
||||
if (grown) {
|
||||
memset(grown + nprogs, 0, (n - nprogs) * sizeof(*grown));
|
||||
progs = grown;
|
||||
nprogs = n;
|
||||
}
|
||||
}
|
||||
if (id < nprogs && (p = calloc(1, sizeof(*p)))) {
|
||||
p->id = id;
|
||||
progs[id] = p;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
return p;
|
||||
}
|
||||
|
||||
static void drop_twin(struct prog *p) {
|
||||
if (p->twin && r_DeleteProgram) r_DeleteProgram(p->twin);
|
||||
p->twin = 0;
|
||||
p->twin_failed = 0;
|
||||
free(p->unis);
|
||||
p->unis = NULL;
|
||||
p->nunis = 0;
|
||||
free(p->blocks);
|
||||
p->blocks = NULL;
|
||||
p->nblocks = 0;
|
||||
}
|
||||
|
||||
static void drop_sources(struct prog *p) {
|
||||
for (int i = 0; i < p->nshaders; ++i) free(p->sources[i]);
|
||||
p->nshaders = 0;
|
||||
p->multiview = 0;
|
||||
}
|
||||
|
||||
static char *shader_source(GLuint sh) {
|
||||
GLint len = 0;
|
||||
r_GetShaderiv(sh, GL_SHADER_SOURCE_LENGTH, &len);
|
||||
if (len <= 0) return NULL;
|
||||
char *src = malloc((size_t)len + 1);
|
||||
if (!src) return NULL;
|
||||
GLsizei got = 0;
|
||||
r_GetShaderSource(sh, len, &got, src);
|
||||
src[got > 0 && got <= len ? got : 0] = 0;
|
||||
return src;
|
||||
}
|
||||
|
||||
// Before the real link: note whether the program is multiview and keep its stage sources for a later twin.
|
||||
static void note_program(GLuint id) {
|
||||
struct prog *p = find_prog(id, 1);
|
||||
if (!p) return;
|
||||
drop_twin(p);
|
||||
drop_sources(p);
|
||||
GLuint shaders[6];
|
||||
GLsizei n = 0;
|
||||
r_GetAttachedShaders(id, 6, &n, shaders);
|
||||
for (GLsizei i = 0; i < n && i < 6; ++i) {
|
||||
GLint type = 0;
|
||||
r_GetShaderiv(shaders[i], GL_SHADER_TYPE, &type);
|
||||
char *src = shader_source(shaders[i]);
|
||||
if (!src) continue;
|
||||
if (type == GL_VERTEX_SHADER && glmv_has_num_views(src)) p->multiview = 1;
|
||||
p->types[p->nshaders] = (GLenum)type;
|
||||
p->sources[p->nshaders++] = src;
|
||||
}
|
||||
if (!p->multiview) drop_sources(p);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the single-view twin
|
||||
static int components(GLenum type, int *kind) { // kind: 0 float, 1 int/bool/sampler, 2 uint, 3 matrix
|
||||
switch (type) {
|
||||
case GL_FLOAT: *kind = 0; return 1;
|
||||
case GL_FLOAT_VEC2: *kind = 0; return 2;
|
||||
case GL_FLOAT_VEC3: *kind = 0; return 3;
|
||||
case GL_FLOAT_VEC4: *kind = 0; return 4;
|
||||
case GL_INT: case GL_BOOL: *kind = 1; return 1;
|
||||
case GL_INT_VEC2: case GL_BOOL_VEC2: *kind = 1; return 2;
|
||||
case GL_INT_VEC3: case GL_BOOL_VEC3: *kind = 1; return 3;
|
||||
case GL_INT_VEC4: case GL_BOOL_VEC4: *kind = 1; return 4;
|
||||
case GL_UNSIGNED_INT: *kind = 2; return 1;
|
||||
case GL_UNSIGNED_INT_VEC2: *kind = 2; return 2;
|
||||
case GL_UNSIGNED_INT_VEC3: *kind = 2; return 3;
|
||||
case GL_UNSIGNED_INT_VEC4: *kind = 2; return 4;
|
||||
case GL_FLOAT_MAT2: *kind = 3; return 4;
|
||||
case GL_FLOAT_MAT3: *kind = 3; return 9;
|
||||
case GL_FLOAT_MAT4: *kind = 3; return 16;
|
||||
case GL_FLOAT_MAT2x3: case GL_FLOAT_MAT3x2: *kind = 3; return 6;
|
||||
case GL_FLOAT_MAT2x4: case GL_FLOAT_MAT4x2: *kind = 3; return 8;
|
||||
case GL_FLOAT_MAT3x4: case GL_FLOAT_MAT4x3: *kind = 3; return 12;
|
||||
default: *kind = 1; return 1; // samplers and images: one int (the unit)
|
||||
}
|
||||
}
|
||||
|
||||
static GLuint compile(GLenum type, const char *src, GLuint prog_id) {
|
||||
GLuint sh = r_CreateShader(type);
|
||||
if (!sh) return 0;
|
||||
r_ShaderSource(sh, 1, &src, NULL);
|
||||
r_CompileShader(sh);
|
||||
GLint ok = 0;
|
||||
r_GetShaderiv(sh, GL_COMPILE_STATUS, &ok);
|
||||
if (!ok) {
|
||||
char log[600] = {0};
|
||||
r_GetShaderInfoLog(sh, sizeof(log) - 1, NULL, log);
|
||||
LOG("twin of program %u: stage 0x%x failed to compile: %s", prog_id, type, log);
|
||||
r_DeleteShader(sh);
|
||||
return 0;
|
||||
}
|
||||
return sh;
|
||||
}
|
||||
|
||||
static void copy_attrib_locations(GLuint orig, GLuint twin) {
|
||||
GLint n = 0;
|
||||
r_GetProgramiv(orig, GL_ACTIVE_ATTRIBUTES, &n);
|
||||
for (GLint i = 0; i < n; ++i) {
|
||||
char name[256];
|
||||
GLsizei len = 0;
|
||||
GLint size = 0;
|
||||
GLenum type = 0;
|
||||
r_GetActiveAttrib(orig, (GLuint)i, sizeof(name), &len, &size, &type, name);
|
||||
if (len <= 0 || !strncmp(name, "gl_", 3)) continue;
|
||||
GLint loc = r_GetAttribLocation(orig, name);
|
||||
if (loc >= 0) r_BindAttribLocation(twin, (GLuint)loc, name);
|
||||
}
|
||||
}
|
||||
|
||||
static void map_uniforms(struct prog *p) {
|
||||
GLint n = 0;
|
||||
r_GetProgramiv(p->twin, GL_ACTIVE_UNIFORMS, &n);
|
||||
int cap = 0;
|
||||
for (GLint i = 0; i < n; ++i) {
|
||||
char name[256];
|
||||
GLsizei len = 0;
|
||||
GLint size = 0;
|
||||
GLenum type = 0;
|
||||
GLuint index = (GLuint)i;
|
||||
GLint block = -1;
|
||||
r_GetActiveUniform(p->twin, index, sizeof(name) - 16, &len, &size, &type, name);
|
||||
r_GetActiveUniformsiv(p->twin, 1, &index, GL_UNIFORM_BLOCK_INDEX, &block);
|
||||
if (len <= 0 || block != -1 || !strncmp(name, "gl_", 3)) continue; // block members live in buffers
|
||||
char *bracket = len > 3 && !strcmp(name + len - 3, "[0]") ? name + len - 3 : NULL;
|
||||
if (bracket) *bracket = 0;
|
||||
for (GLint e = 0; e < (size > 0 ? size : 1); ++e) {
|
||||
char elem[300];
|
||||
if (bracket) snprintf(elem, sizeof(elem), "%s[%d]", name, e);
|
||||
else snprintf(elem, sizeof(elem), "%s", name);
|
||||
GLint lo = r_GetUniformLocation(p->id, elem), lt = r_GetUniformLocation(p->twin, elem);
|
||||
if (lo < 0 || lt < 0) continue;
|
||||
if (p->nunis == cap) {
|
||||
cap = cap ? cap * 2 : 16;
|
||||
struct uni *grown = realloc(p->unis, (size_t)cap * sizeof(*grown));
|
||||
if (!grown) return;
|
||||
p->unis = grown;
|
||||
}
|
||||
p->unis[p->nunis++] = (struct uni){.type = type, .orig = lo, .var = lt};
|
||||
}
|
||||
}
|
||||
GLint nb = 0;
|
||||
r_GetProgramiv(p->twin, GL_ACTIVE_UNIFORM_BLOCKS, &nb);
|
||||
p->blocks = nb > 0 ? calloc((size_t)nb, sizeof(*p->blocks)) : NULL;
|
||||
for (GLint i = 0; p->blocks && i < nb; ++i) {
|
||||
char name[256];
|
||||
GLsizei len = 0;
|
||||
r_GetActiveUniformBlockName(p->twin, (GLuint)i, sizeof(name), &len, name);
|
||||
GLuint oi = len > 0 ? r_GetUniformBlockIndex(p->id, name) : GL_INVALID_INDEX;
|
||||
if (oi == GL_INVALID_INDEX) continue;
|
||||
p->blocks[p->nblocks++] = (struct block){.orig_index = oi, .var_index = (GLuint)i, .binding = -1};
|
||||
}
|
||||
}
|
||||
|
||||
static int build_twin(struct prog *p) {
|
||||
GLuint twin = r_CreateProgram(), shaders[6];
|
||||
int ns = 0, layouts = 0, ids = 0;
|
||||
for (int i = 0; twin && i < p->nshaders; ++i) {
|
||||
int l = 0, v = 0;
|
||||
char *src = glmv_rewrite(p->sources[i], &l, &v);
|
||||
GLuint sh = src ? compile(p->types[i], src, p->id) : 0;
|
||||
free(src);
|
||||
if (!sh) { ns = -1; break; }
|
||||
layouts += l;
|
||||
ids += v;
|
||||
r_AttachShader(twin, sh);
|
||||
shaders[ns++] = sh;
|
||||
}
|
||||
if (twin && ns > 0) {
|
||||
copy_attrib_locations(p->id, twin);
|
||||
r_LinkProgram(twin);
|
||||
}
|
||||
for (int i = 0; i < ns; ++i) r_DeleteShader(shaders[i]); // flagged only; freed with the program
|
||||
GLint ok = 0;
|
||||
if (twin && ns > 0) r_GetProgramiv(twin, GL_LINK_STATUS, &ok);
|
||||
if (!ok) {
|
||||
char log[600] = {0};
|
||||
if (twin && ns > 0) r_GetProgramInfoLog(twin, sizeof(log) - 1, NULL, log);
|
||||
LOG("twin of program %u failed (%s): drawing it unchanged", p->id, ns > 0 ? log : "compile");
|
||||
if (twin) r_DeleteProgram(twin);
|
||||
p->twin_failed = 1;
|
||||
return 0;
|
||||
}
|
||||
p->twin = twin;
|
||||
map_uniforms(p);
|
||||
LOG("twin built: program %u -> %u (%d uniforms, %d blocks, %d num_views layouts and %d gl_ViewID_OVR replaced)",
|
||||
p->id, twin, p->nunis, p->nblocks, layouts, ids);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static unsigned long uniform_sets; // twin uniforms changed (statistics)
|
||||
|
||||
static void sync_uniforms(struct prog *p) {
|
||||
for (int i = 0; i < p->nunis; ++i) {
|
||||
struct uni *u = &p->unis[i];
|
||||
int kind, n = components(u->type, &kind);
|
||||
GLuint v[16] = {0};
|
||||
if (kind == 0 || kind == 3) r_GetUniformfv(p->id, u->orig, (GLfloat *)v);
|
||||
else if (kind == 2) r_GetUniformuiv(p->id, u->orig, v);
|
||||
else r_GetUniformiv(p->id, u->orig, (GLint *)v);
|
||||
if (u->valid && !memcmp(v, u->shadow, (size_t)n * sizeof(GLuint))) continue;
|
||||
memcpy(u->shadow, v, sizeof(v));
|
||||
u->valid = 1;
|
||||
++uniform_sets;
|
||||
const GLfloat *f = (const GLfloat *)v;
|
||||
const GLint *iv = (const GLint *)v;
|
||||
switch (u->type) {
|
||||
case GL_FLOAT: r_Uniform1fv(u->var, 1, f); break;
|
||||
case GL_FLOAT_VEC2: r_Uniform2fv(u->var, 1, f); break;
|
||||
case GL_FLOAT_VEC3: r_Uniform3fv(u->var, 1, f); break;
|
||||
case GL_FLOAT_VEC4: r_Uniform4fv(u->var, 1, f); break;
|
||||
case GL_UNSIGNED_INT: r_Uniform1uiv(u->var, 1, v); break;
|
||||
case GL_UNSIGNED_INT_VEC2: r_Uniform2uiv(u->var, 1, v); break;
|
||||
case GL_UNSIGNED_INT_VEC3: r_Uniform3uiv(u->var, 1, v); break;
|
||||
case GL_UNSIGNED_INT_VEC4: r_Uniform4uiv(u->var, 1, v); break;
|
||||
case GL_FLOAT_MAT2: r_UniformMatrix2fv(u->var, 1, GL_FALSE, f); break;
|
||||
case GL_FLOAT_MAT3: r_UniformMatrix3fv(u->var, 1, GL_FALSE, f); break;
|
||||
case GL_FLOAT_MAT4: r_UniformMatrix4fv(u->var, 1, GL_FALSE, f); break;
|
||||
case GL_FLOAT_MAT2x3: r_UniformMatrix2x3fv(u->var, 1, GL_FALSE, f); break;
|
||||
case GL_FLOAT_MAT3x2: r_UniformMatrix3x2fv(u->var, 1, GL_FALSE, f); break;
|
||||
case GL_FLOAT_MAT2x4: r_UniformMatrix2x4fv(u->var, 1, GL_FALSE, f); break;
|
||||
case GL_FLOAT_MAT4x2: r_UniformMatrix4x2fv(u->var, 1, GL_FALSE, f); break;
|
||||
case GL_FLOAT_MAT3x4: r_UniformMatrix3x4fv(u->var, 1, GL_FALSE, f); break;
|
||||
case GL_FLOAT_MAT4x3: r_UniformMatrix4x3fv(u->var, 1, GL_FALSE, f); break;
|
||||
case GL_INT_VEC2: case GL_BOOL_VEC2: r_Uniform2iv(u->var, 1, iv); break;
|
||||
case GL_INT_VEC3: case GL_BOOL_VEC3: r_Uniform3iv(u->var, 1, iv); break;
|
||||
case GL_INT_VEC4: case GL_BOOL_VEC4: r_Uniform4iv(u->var, 1, iv); break;
|
||||
default: r_Uniform1iv(u->var, 1, iv); break; // int, bool, samplers
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < p->nblocks; ++i) {
|
||||
struct block *b = &p->blocks[i];
|
||||
GLint binding = 0;
|
||||
r_GetActiveUniformBlockiv(p->id, b->orig_index, GL_UNIFORM_BLOCK_BINDING, &binding);
|
||||
if (binding != b->binding) {
|
||||
r_UniformBlockBinding(p->twin, b->var_index, (GLuint)binding);
|
||||
b->binding = binding;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- per-thread state (a GL context is per thread)
|
||||
#define FB_UNKNOWN (-1)
|
||||
#define FB_NO_COLOR (-2)
|
||||
static __thread struct prog *cur_prog;
|
||||
static __thread int fb_views = FB_UNKNOWN; // views of the draw framebuffer's first color attachment (cached)
|
||||
|
||||
static int draw_fb_views(void) {
|
||||
if (fb_views != FB_UNKNOWN) return fb_views;
|
||||
GLint fb = 0;
|
||||
r_GetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &fb);
|
||||
if (!fb) return fb_views = 0; // the window surface
|
||||
GLint type = GL_NONE;
|
||||
r_GetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
|
||||
GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE, &type);
|
||||
if (type == GL_NONE) return fb_views = FB_NO_COLOR; // depth-only: Mesa doesn't compare views
|
||||
if (type != GL_TEXTURE) return fb_views = 0; // renderbuffer
|
||||
GLint views = 0;
|
||||
r_GetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
|
||||
GL_FRAMEBUFFER_ATTACHMENT_TEXTURE_NUM_VIEWS_OVR, &views);
|
||||
return fb_views = views;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- statistics (per 5 s)
|
||||
static unsigned long twin_draws, mv_draws;
|
||||
static struct timespec last_stats;
|
||||
static void stats(void) {
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
if (!last_stats.tv_sec) last_stats = now;
|
||||
if (now.tv_sec - last_stats.tv_sec < 5) return;
|
||||
last_stats = now;
|
||||
if (twin_draws || debug)
|
||||
LOG("5s: %lu single-view draws of multiview programs, %lu multiview draws, %lu twin uniform updates",
|
||||
twin_draws, mv_draws, uniform_sets);
|
||||
twin_draws = mv_draws = uniform_sets = 0;
|
||||
}
|
||||
|
||||
// Before a draw: bind the twin when the program is multiview and the framebuffer has no views. 1 = twin bound.
|
||||
static int begin_draw(void) {
|
||||
struct prog *p = cur_prog;
|
||||
if (!p || !p->multiview || p->twin_failed) return 0;
|
||||
int views = draw_fb_views();
|
||||
if (views != 0) {
|
||||
++mv_draws;
|
||||
if (debug) stats();
|
||||
return 0;
|
||||
}
|
||||
if (!p->twin && !build_twin(p)) return 0;
|
||||
r_UseProgram(p->twin);
|
||||
sync_uniforms(p);
|
||||
if (debug) r_GetError(); // (debug only: this consumes an earlier error the game hasn't read)
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void end_draw(const char *what) {
|
||||
struct prog *p = cur_prog;
|
||||
if (debug) {
|
||||
static int logs;
|
||||
GLenum err = r_GetError();
|
||||
if (err && logs < 20) {
|
||||
++logs;
|
||||
LOG("%s with the twin of program %u: GL error 0x%x", what, p ? p->id : 0, err);
|
||||
}
|
||||
}
|
||||
r_UseProgram(p ? p->id : 0);
|
||||
++twin_draws;
|
||||
stats();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- exported GL functions
|
||||
EXPORT void glUseProgram(GLuint program) {
|
||||
INIT();
|
||||
r_UseProgram(program);
|
||||
cur_prog = program ? find_prog(program, 0) : NULL;
|
||||
}
|
||||
|
||||
EXPORT void glLinkProgram(GLuint program) {
|
||||
INIT();
|
||||
note_program(program);
|
||||
r_LinkProgram(program);
|
||||
}
|
||||
|
||||
EXPORT void glDeleteProgram(GLuint program) {
|
||||
INIT();
|
||||
struct prog *p = program ? find_prog(program, 0) : NULL;
|
||||
if (p) { // the record stays (pointers to it may be cached); a new program with this name relinks it
|
||||
drop_twin(p);
|
||||
drop_sources(p);
|
||||
}
|
||||
r_DeleteProgram(program);
|
||||
}
|
||||
|
||||
EXPORT void glBindFramebuffer(GLenum target, GLuint framebuffer) {
|
||||
INIT();
|
||||
r_BindFramebuffer(target, framebuffer);
|
||||
if (target != GL_READ_FRAMEBUFFER) fb_views = FB_UNKNOWN;
|
||||
}
|
||||
|
||||
EXPORT void glDeleteFramebuffers(GLsizei n, const GLuint *framebuffers) {
|
||||
INIT();
|
||||
r_DeleteFramebuffers(n, framebuffers);
|
||||
fb_views = FB_UNKNOWN;
|
||||
}
|
||||
|
||||
EXPORT void glFramebufferTexture2D(GLenum target, GLenum attachment, GLenum textarget, GLuint texture, GLint level) {
|
||||
INIT();
|
||||
r_FramebufferTexture2D(target, attachment, textarget, texture, level);
|
||||
fb_views = FB_UNKNOWN;
|
||||
}
|
||||
|
||||
EXPORT void glFramebufferRenderbuffer(GLenum target, GLenum attachment, GLenum rbtarget, GLuint renderbuffer) {
|
||||
INIT();
|
||||
r_FramebufferRenderbuffer(target, attachment, rbtarget, renderbuffer);
|
||||
fb_views = FB_UNKNOWN;
|
||||
}
|
||||
|
||||
EXPORT void glFramebufferTextureLayer(GLenum target, GLenum attachment, GLuint texture, GLint level, GLint layer) {
|
||||
INIT();
|
||||
r_FramebufferTextureLayer(target, attachment, texture, level, layer);
|
||||
fb_views = FB_UNKNOWN;
|
||||
}
|
||||
|
||||
EXPORT void glFramebufferTexture(GLenum target, GLenum attachment, GLuint texture, GLint level) {
|
||||
INIT();
|
||||
if (r_FramebufferTexture) r_FramebufferTexture(target, attachment, texture, level);
|
||||
fb_views = FB_UNKNOWN;
|
||||
}
|
||||
|
||||
EXPORT void glDrawBuffers(GLsizei n, const GLenum *bufs) {
|
||||
INIT();
|
||||
r_DrawBuffers(n, bufs);
|
||||
fb_views = FB_UNKNOWN;
|
||||
}
|
||||
|
||||
static void w_ftmv(GLenum target, GLenum attachment, GLuint texture, GLint level, GLint base, GLsizei views) {
|
||||
r_ftmv(target, attachment, texture, level, base, views);
|
||||
fb_views = FB_UNKNOWN;
|
||||
}
|
||||
|
||||
static void w_ftmsmv(GLenum target, GLenum attachment, GLuint texture, GLint level, GLsizei samples, GLint base,
|
||||
GLsizei views) {
|
||||
r_ftmsmv(target, attachment, texture, level, samples, base, views);
|
||||
fb_views = FB_UNKNOWN;
|
||||
}
|
||||
|
||||
#define DRAW(name, call) \
|
||||
INIT(); \
|
||||
if (!r_##name) return; \
|
||||
if (begin_draw()) { \
|
||||
r_##name call; \
|
||||
end_draw("gl" #name); \
|
||||
} else { \
|
||||
r_##name call; \
|
||||
}
|
||||
|
||||
EXPORT void glDrawArrays(GLenum mode, GLint first, GLsizei count) { DRAW(DrawArrays, (mode, first, count)) }
|
||||
EXPORT void glDrawElements(GLenum mode, GLsizei count, GLenum type, const void *indices) {
|
||||
DRAW(DrawElements, (mode, count, type, indices))
|
||||
}
|
||||
EXPORT void glDrawArraysInstanced(GLenum mode, GLint first, GLsizei count, GLsizei instances) {
|
||||
DRAW(DrawArraysInstanced, (mode, first, count, instances))
|
||||
}
|
||||
EXPORT void glDrawElementsInstanced(GLenum mode, GLsizei count, GLenum type, const void *indices, GLsizei instances) {
|
||||
DRAW(DrawElementsInstanced, (mode, count, type, indices, instances))
|
||||
}
|
||||
EXPORT void glDrawRangeElements(GLenum mode, GLuint start, GLuint end, GLsizei count, GLenum type,
|
||||
const void *indices) {
|
||||
DRAW(DrawRangeElements, (mode, start, end, count, type, indices))
|
||||
}
|
||||
EXPORT void glDrawArraysIndirect(GLenum mode, const void *indirect) { DRAW(DrawArraysIndirect, (mode, indirect)) }
|
||||
EXPORT void glDrawElementsIndirect(GLenum mode, GLenum type, const void *indirect) {
|
||||
DRAW(DrawElementsIndirect, (mode, type, indirect))
|
||||
}
|
||||
EXPORT void glDrawElementsBaseVertex(GLenum mode, GLsizei count, GLenum type, const void *indices, GLint base) {
|
||||
DRAW(DrawElementsBaseVertex, (mode, count, type, indices, base))
|
||||
}
|
||||
EXPORT void glDrawRangeElementsBaseVertex(GLenum mode, GLuint start, GLuint end, GLsizei count, GLenum type,
|
||||
const void *indices, GLint base) {
|
||||
DRAW(DrawRangeElementsBaseVertex, (mode, start, end, count, type, indices, base))
|
||||
}
|
||||
EXPORT void glDrawElementsInstancedBaseVertex(GLenum mode, GLsizei count, GLenum type, const void *indices,
|
||||
GLsizei instances, GLint base) {
|
||||
DRAW(DrawElementsInstancedBaseVertex, (mode, count, type, indices, instances, base))
|
||||
}
|
||||
|
||||
// A context made current on this thread (engines with a render thread move theirs between threads, e.g. Doom3Quest's
|
||||
// GLimp_ActivateContext): what this thread knew about the bound program and framebuffer may be another context's.
|
||||
EXPORT EGLBoolean eglMakeCurrent(EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx) {
|
||||
INIT();
|
||||
if (!r_eglMakeCurrent) return EGL_FALSE;
|
||||
EGLBoolean ok = r_eglMakeCurrent(dpy, draw, read, ctx);
|
||||
fb_views = FB_UNKNOWN;
|
||||
cur_prog = NULL;
|
||||
if (ok && ctx != EGL_NO_CONTEXT && r_GetIntegerv) {
|
||||
GLint program = 0;
|
||||
r_GetIntegerv(GL_CURRENT_PROGRAM, &program);
|
||||
cur_prog = program > 0 ? find_prog((GLuint)program, 0) : NULL;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Engines fetch the OVR multiview functions (and SDL-style loaders every function) through eglGetProcAddress.
|
||||
EXPORT __eglMustCastToProperFunctionPointerType eglGetProcAddress(const char *name) {
|
||||
INIT();
|
||||
__eglMustCastToProperFunctionPointerType fn = r_eglGetProcAddress ? r_eglGetProcAddress(name) : NULL;
|
||||
if (!fn || !name) return fn;
|
||||
if (!strcmp(name, "glFramebufferTextureMultiviewOVR")) {
|
||||
if (!r_ftmv) r_ftmv = (PFN_FTMV)fn;
|
||||
return (__eglMustCastToProperFunctionPointerType)w_ftmv;
|
||||
}
|
||||
if (!strcmp(name, "glFramebufferTextureMultisampleMultiviewOVR")) {
|
||||
if (!r_ftmsmv) r_ftmsmv = (PFN_FTMSMV)fn;
|
||||
return (__eglMustCastToProperFunctionPointerType)w_ftmsmv;
|
||||
}
|
||||
#define OWN(sym) if (!strcmp(name, #sym)) return (__eglMustCastToProperFunctionPointerType)sym;
|
||||
OWN(glUseProgram) OWN(glLinkProgram) OWN(glDeleteProgram) OWN(glBindFramebuffer) OWN(glDeleteFramebuffers)
|
||||
OWN(glFramebufferTexture2D) OWN(glFramebufferRenderbuffer) OWN(glFramebufferTextureLayer)
|
||||
OWN(glFramebufferTexture) OWN(glDrawBuffers) OWN(glDrawArrays) OWN(glDrawElements) OWN(glDrawArraysInstanced)
|
||||
OWN(glDrawElementsInstanced) OWN(glDrawRangeElements) OWN(glDrawArraysIndirect) OWN(glDrawElementsIndirect)
|
||||
OWN(glDrawElementsBaseVertex) OWN(glDrawRangeElementsBaseVertex) OWN(glDrawElementsInstancedBaseVertex)
|
||||
OWN(eglMakeCurrent)
|
||||
#undef OWN
|
||||
return fn;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// GLSL source rewrite for libfpglmv.so (see glmv.c): turns an OVR_multiview shader into its single-view twin.
|
||||
// - every `layout(... num_views ...) in;` statement is blanked out (the program then declares no views, which is
|
||||
// what Mesa requires for a draw into a framebuffer whose attachments aren't multiview)
|
||||
// - every gl_ViewID_OVR token becomes (0u): view 0 (the left eye's matrices in Doom3Quest)
|
||||
// #extension lines stay (the driver supports OVR_multiview; enabling it without a num_views layout is harmless).
|
||||
// Columns and line numbers are kept (blanks pad the replacements), so compile logs point at the same places.
|
||||
// Plain C, no GL: the host unit test (tests/fixtures/src/glmv_rewrite_test.c) compiles it too.
|
||||
#pragma once
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static int glmv_ident_char(char c) {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
|
||||
}
|
||||
|
||||
// `word` at p as a whole token (not part of a longer identifier)
|
||||
static int glmv_token_at(const char *start, const char *p, const char *word) {
|
||||
size_t n = strlen(word);
|
||||
if (strncmp(p, word, n) != 0) return 0;
|
||||
if (p > start && glmv_ident_char(p[-1])) return 0;
|
||||
return !glmv_ident_char(p[n]);
|
||||
}
|
||||
|
||||
static const char *glmv_skip_ws(const char *p) {
|
||||
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == '\f' || *p == '\v') ++p;
|
||||
return p;
|
||||
}
|
||||
|
||||
// If a `layout(...num_views...) in;` statement starts at p, returns a pointer to its ';', else NULL.
|
||||
static const char *glmv_num_views_stmt(const char *start, const char *p) {
|
||||
if (!glmv_token_at(start, p, "layout")) return NULL;
|
||||
const char *q = glmv_skip_ws(p + 6);
|
||||
if (*q != '(') return NULL;
|
||||
const char *open = q;
|
||||
int depth = 0;
|
||||
for (; *q; ++q) {
|
||||
if (*q == '(') ++depth;
|
||||
else if (*q == ')' && --depth == 0) break;
|
||||
}
|
||||
if (!*q) return NULL;
|
||||
const char *close = q;
|
||||
int has_views = 0;
|
||||
for (const char *r = open + 1; r < close; ++r)
|
||||
if (glmv_token_at(start, r, "num_views")) { has_views = 1; break; }
|
||||
if (!has_views) return NULL;
|
||||
q = glmv_skip_ws(close + 1);
|
||||
if (!glmv_token_at(start, q, "in")) return NULL;
|
||||
q = glmv_skip_ws(q + 2);
|
||||
return *q == ';' ? q : NULL;
|
||||
}
|
||||
|
||||
// 1 if the source declares multiview views (a vertex shader of an OVR_multiview program)
|
||||
static int glmv_has_num_views(const char *src) {
|
||||
for (const char *p = src; (p = strstr(p, "layout")); p += 6)
|
||||
if (glmv_num_views_stmt(src, p)) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The single-view twin of `src` (malloc'd, same length), or NULL when out of memory. Counts go to *layouts / *ids.
|
||||
static char *glmv_rewrite(const char *src, int *layouts, int *ids) {
|
||||
static const char id_tok[] = "gl_ViewID_OVR"; // 13 characters
|
||||
static const char zero[] = "(0u)";
|
||||
size_t n = strlen(src);
|
||||
char *out = (char *)malloc(n + 1);
|
||||
if (!out) return NULL;
|
||||
memcpy(out, src, n + 1);
|
||||
int nl = 0, ni = 0;
|
||||
for (char *p = out; (p = strstr(p, "layout"));) {
|
||||
const char *semi = glmv_num_views_stmt(out, p);
|
||||
if (!semi) { p += 6; continue; }
|
||||
for (char *b = p; b <= semi; ++b)
|
||||
if (*b != '\n' && *b != '\r') *b = ' ';
|
||||
++nl;
|
||||
p = (char *)semi + 1;
|
||||
}
|
||||
for (char *p = out; (p = strstr(p, id_tok));) {
|
||||
if (!glmv_token_at(out, p, id_tok)) { p += sizeof(id_tok) - 1; continue; }
|
||||
memcpy(p, zero, sizeof(zero) - 1);
|
||||
memset(p + sizeof(zero) - 1, ' ', sizeof(id_tok) - sizeof(zero));
|
||||
++ni;
|
||||
p += sizeof(id_tok) - 1;
|
||||
}
|
||||
if (layouts) *layouts = nl;
|
||||
if (ids) *ids = ni;
|
||||
return out;
|
||||
}
|
||||
+34
-7
@@ -30,8 +30,13 @@ static void (*real_glGetIntegerv)(GLenum, GLint *);
|
||||
static __eglMustCastToProperFunctionPointerType (*real_eglGetProcAddress)(const char *);
|
||||
static pthread_once_t once = PTHREAD_ONCE_INIT;
|
||||
|
||||
static int hide_multiview = -1; // -1: not set (Unity keeps multiview, everything else hides it)
|
||||
static int hide_multiview = -1; // -1: not set (Unity and Unreal keep multiview, everything else hides it)
|
||||
static int hide_msrtt = 1;
|
||||
// Unreal (4.25) only turns on its mobile multiview when GL_OVR_multiview, GL_OVR_multiview2 AND
|
||||
// GL_OVR_multiview_multisampled_render_to_texture are all there (FOpenGLES::ProcessExtensions). Hiding the last one
|
||||
// with the MSRTT extensions switched Unreal's multiview off, so for Unreal it stays visible and its function draws
|
||||
// single-sampled (glFramebufferTextureMultiviewOVR), like the EXT stand-ins below.
|
||||
static int unreal;
|
||||
|
||||
static void read_conf_file(const char *path) {
|
||||
FILE *f = fopen(path, "r");
|
||||
@@ -335,12 +340,21 @@ static void init(void) {
|
||||
real_glGetStringi = gles ? dlsym(gles, "glGetStringi") : NULL;
|
||||
real_glGetIntegerv = gles ? dlsym(gles, "glGetIntegerv") : NULL;
|
||||
real_eglGetProcAddress = egl ? dlsym(egl, "eglGetProcAddress") : NULL;
|
||||
if (hide_multiview < 0) { // Unity renders multiview itself and only needs MSRTT hidden
|
||||
void *unity = dlopen("libunity.so", RTLD_NOW | RTLD_NOLOAD);
|
||||
hide_multiview = unity ? 0 : 1;
|
||||
if (unity) dlclose(unity);
|
||||
static const char *const engines[] = {"libunity.so", "libUE4.so", "libUnreal.so"};
|
||||
int engine_found = 0;
|
||||
for (unsigned i = 0; i < sizeof engines / sizeof *engines && !engine_found; i++) {
|
||||
void *engine = dlopen(engines[i], RTLD_NOW | RTLD_NOLOAD);
|
||||
if (engine) {
|
||||
engine_found = 1;
|
||||
unreal = i > 0;
|
||||
dlclose(engine);
|
||||
}
|
||||
}
|
||||
LOG("GL shim active: hide_multiview=%d hide_msrtt=%d", hide_multiview, hide_msrtt);
|
||||
// Unity and Unreal render multiview themselves and only need MSRTT hidden
|
||||
if (hide_multiview < 0) hide_multiview = !engine_found;
|
||||
unreal = unreal && hide_msrtt && !hide_multiview; // only matters while MSRTT is hidden and multiview kept
|
||||
LOG("GL shim active: hide_multiview=%d hide_msrtt=%d%s", hide_multiview, hide_msrtt,
|
||||
unreal ? " (Unreal: multiview MSRTT kept, drawn single-sampled)" : "");
|
||||
}
|
||||
|
||||
static int is_hidden(const char *name) {
|
||||
@@ -349,7 +363,8 @@ static int is_hidden(const char *name) {
|
||||
if (!strcmp(name, hidden_multiview[i])) return 1;
|
||||
if (hide_msrtt)
|
||||
for (size_t i = 0; i < sizeof(hidden_msrtt) / sizeof(hidden_msrtt[0]); ++i)
|
||||
if (!strcmp(name, hidden_msrtt[i])) return 1;
|
||||
if (!strcmp(name, hidden_msrtt[i]))
|
||||
return !(unreal && !strcmp(name, "GL_OVR_multiview_multisampled_render_to_texture"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -416,6 +431,14 @@ static void plain_rbsms(GLenum target, GLsizei samples, GLenum format, GLsizei w
|
||||
(void)samples;
|
||||
if (r_plain_rbs) r_plain_rbs(target, format, width, height);
|
||||
}
|
||||
static PFN_FTMV r_plain_ftmv;
|
||||
static int mv_msrtt_logged;
|
||||
static void plain_ftmsmv(GLenum target, GLenum attachment, GLuint texture, GLint level, GLsizei samples,
|
||||
GLint base, GLsizei views) {
|
||||
if (!mv_msrtt_logged++)
|
||||
LOG("GL shim: multiview multisampled render-to-texture (%d samples) drawn single-sampled", (int)samples);
|
||||
if (r_plain_ftmv) r_plain_ftmv(target, attachment, texture, level, base, views);
|
||||
}
|
||||
|
||||
__attribute__((visibility("default"))) __eglMustCastToProperFunctionPointerType eglGetProcAddress(const char *name) {
|
||||
pthread_once(&once, init);
|
||||
@@ -432,6 +455,10 @@ __attribute__((visibility("default"))) __eglMustCastToProperFunctionPointerType
|
||||
return name[2] == 'F' ? (__eglMustCastToProperFunctionPointerType)plain_ft2dms
|
||||
: (__eglMustCastToProperFunctionPointerType)plain_rbsms;
|
||||
}
|
||||
if (unreal && !strcmp(name, "glFramebufferTextureMultisampleMultiviewOVR")) {
|
||||
if (!r_plain_ftmv) r_plain_ftmv = (PFN_FTMV)real_eglGetProcAddress("glFramebufferTextureMultiviewOVR");
|
||||
return (__eglMustCastToProperFunctionPointerType)plain_ftmsmv;
|
||||
}
|
||||
#ifdef GLSHIM_TRACE // build with -DGLSHIM_TRACE for eye-buffer / draw / error tracing
|
||||
WRAP("glFramebufferTextureMultiviewOVR", real_ftmv, w_ftmv)
|
||||
WRAP("glFramebufferTextureMultisampleMultiviewOVR", real_ftmsmv, w_ftmsmv)
|
||||
|
||||
+148
-7
@@ -9,10 +9,14 @@
|
||||
//
|
||||
// FramePort renames the "ovrp_Update2" lookup in libunity.so to "fpov_Update2" (same length) and adds this library to
|
||||
// libOVRPlugin.so's DT_NEEDED, so Unity's dlsym on the plugin finds this function: it waits for the frame first (once
|
||||
// per frame index, render step only), then calls the real ovrp_Update2.
|
||||
// per frame index, render step only), then calls the real ovrp_Update2 (render step; the physics step: see below).
|
||||
#include <android/log.h>
|
||||
#include <dlfcn.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#define TAG "FrameBridge"
|
||||
#define EXPORT __attribute__((visibility("default")))
|
||||
@@ -25,31 +29,168 @@ static PFN_Update2 real_update2;
|
||||
static PFN_WaitToBeginFrame real_wait;
|
||||
static pthread_once_t once = PTHREAD_ONCE_INIT;
|
||||
|
||||
// Per-game settings (the same sources as FrameBridge and the GL shim, later ones winning): the frame-begin gate and
|
||||
// the held-back physics update below were needed by Sniper Elite VR; in Unity 2019 games (BattleSisters) holding the
|
||||
// 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;
|
||||
|
||||
static void read_conf_file(const char *path) {
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return;
|
||||
char line[256];
|
||||
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);
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
static void read_conf(void) {
|
||||
Dl_info info;
|
||||
char path[600];
|
||||
if (dladdr((void *)read_conf, &info) && info.dli_fname) {
|
||||
const char *slash = strrchr(info.dli_fname, '/');
|
||||
if (slash && slash - info.dli_fname < 500) {
|
||||
snprintf(path, sizeof(path), "%.*s/libframe_settings.so", (int)(slash - info.dli_fname), info.dli_fname);
|
||||
read_conf_file(path);
|
||||
}
|
||||
}
|
||||
char pkg[256] = {0};
|
||||
FILE *f = fopen("/proc/self/cmdline", "r");
|
||||
if (f) {
|
||||
size_t n = fread(pkg, 1, sizeof(pkg) - 1, f);
|
||||
fclose(f);
|
||||
pkg[n] = 0;
|
||||
}
|
||||
char *colon = strchr(pkg, ':');
|
||||
if (colon) *colon = 0;
|
||||
if (*pkg && !strchr(pkg, '/')) {
|
||||
snprintf(path, sizeof(path), "/sdcard/Android/data/%s/files/framebridge.conf", pkg);
|
||||
read_conf_file(path);
|
||||
}
|
||||
const char *env = getenv("FRAMEBRIDGE_CONFIG");
|
||||
if (env && *env) read_conf_file(env);
|
||||
}
|
||||
|
||||
static void init(void) {
|
||||
read_conf();
|
||||
void *ovrp = dlopen("libOVRPlugin.so", RTLD_NOW | RTLD_NOLOAD);
|
||||
if (!ovrp) ovrp = dlopen("libOVRPlugin.so", RTLD_NOW);
|
||||
if (ovrp) {
|
||||
real_update2 = (PFN_Update2)dlsym(ovrp, "ovrp_Update2");
|
||||
real_wait = (PFN_WaitToBeginFrame)dlsym(ovrp, "ovrp_WaitToBeginFrame");
|
||||
}
|
||||
LOG("ovrp frame loop shim: ovrp_Update2 %s, ovrp_WaitToBeginFrame %s", real_update2 ? "OK" : "MISSING",
|
||||
real_wait ? "OK" : "MISSING");
|
||||
LOG("ovrp frame loop shim: ovrp_Update2 %s, ovrp_WaitToBeginFrame %s, begin gate %d, hold physics %d",
|
||||
real_update2 ? "OK" : "MISSING", real_wait ? "OK" : "MISSING", begin_gate, hold_physics);
|
||||
}
|
||||
|
||||
#define STEP_RENDER (-1) // ovrpStep_Render
|
||||
|
||||
static void mouse_click_frame(void); // below: controller presses as Unity mouse clicks
|
||||
|
||||
// xrWaitFrame blocks until the frame waited for before it has begun (xrBeginFrame). Unity begins frames on its render
|
||||
// thread, which sometimes skips one (scene activation) and then waits for the main thread: our next wait never
|
||||
// returned (Sniper Elite VR hung at its Init scene, main thread in SteamVR's CSxrSession::StartNextFrame, render
|
||||
// thread idle). FramePort also renames libunity.so's "ovrp_BeginFrame"/"ovrp_EndFrame" lookups to the functions below.
|
||||
// Once a begin has come through, the main thread first waits (≤ BEGIN_TIMEOUT_MS) for the last waited frame to begin
|
||||
// and skips this frame's wait if it doesn't (normally the render thread begins it within a frame: pacing unchanged).
|
||||
// OVRPlugin only begins the frame index it waited for (else "outside of frame bounds" and no xrBeginFrame, after which
|
||||
// the next wait blocked for good), so a begin of another index while a wait is outstanding becomes a begin of the
|
||||
// waited index, and the matching end follows it.
|
||||
#define BEGIN_TIMEOUT_MS 50
|
||||
static pthread_mutex_t begin_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static pthread_cond_t begin_cond = PTHREAD_COND_INITIALIZER;
|
||||
static int begin_hooked; // fpov_BeginFrame has been called: the gate is armed
|
||||
static int outstanding = -1; // frame index waited for and not begun yet, else -1 (under begin_lock)
|
||||
static int remap_from = -1, remap_to = -1; // the last begin that was given the waited index (for its end)
|
||||
|
||||
static int last_wait_begun(int frame_index) {
|
||||
if (!begin_gate) return 1;
|
||||
pthread_mutex_lock(&begin_lock);
|
||||
struct timespec until;
|
||||
clock_gettime(CLOCK_REALTIME, &until);
|
||||
until.tv_nsec += BEGIN_TIMEOUT_MS * 1000000L;
|
||||
if (until.tv_nsec >= 1000000000L) until.tv_sec++, until.tv_nsec -= 1000000000L;
|
||||
while (begin_hooked && outstanding >= 0)
|
||||
if (pthread_cond_timedwait(&begin_cond, &begin_lock, &until)) break;
|
||||
int ok = !begin_hooked || outstanding < 0;
|
||||
if (ok) outstanding = frame_index;
|
||||
pthread_mutex_unlock(&begin_lock);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Unity's physics step updates OVRPlugin's poses with prediction 0 ("now"). OVRPlugin takes "now" from the monotonic
|
||||
// clock, which is the XrTime base on a Quest but not on the Frame (its XrTime ran 0.05-0.9 s ahead), so it located
|
||||
// every node that much in the past (the runtime extrapolates backwards), and the render-step reads after it got those
|
||||
// poses too: the game's hands trailed the controllers by several centimetres whenever they moved (Sniper Elite VR). A
|
||||
// larger prediction doesn't reach (OVRPlugin caps it at ~0.07 s), so the physics update isn't passed on: OVRPlugin
|
||||
// keeps the render update's display-time poses for the whole frame (physics and render reads agree).
|
||||
#define STEP_PHYSICS 0
|
||||
|
||||
EXPORT int fpov_Update2(int step, int frame_index, double prediction_seconds) {
|
||||
pthread_once(&once, init);
|
||||
static int last_waited = -1, logged;
|
||||
static int last_waited = -1, rendered, logged, skipped, last_result;
|
||||
if (step == STEP_RENDER && real_wait && frame_index != last_waited) {
|
||||
last_waited = frame_index;
|
||||
int r = real_wait(frame_index);
|
||||
if (logged++ < 3) LOG("ovrp frame loop shim: waited for frame %d: %d", frame_index, r);
|
||||
if (last_wait_begun(frame_index)) {
|
||||
int r = real_wait(frame_index);
|
||||
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 "
|
||||
"(%d times)", frame_index, BEGIN_TIMEOUT_MS, skipped);
|
||||
}
|
||||
mouse_click_frame();
|
||||
}
|
||||
return real_update2 ? real_update2(step, frame_index, prediction_seconds) : -1000; // ovrpFailure
|
||||
if (hold_physics && step == STEP_PHYSICS && rendered) return last_result; // see above: keeps the display-time poses
|
||||
int r = real_update2 ? real_update2(step, frame_index, prediction_seconds) : -1000; // ovrpFailure
|
||||
if (step == STEP_RENDER) rendered = 1, last_result = r;
|
||||
return r;
|
||||
}
|
||||
|
||||
// Unity's legacy ovrp_BeginFrame(frameIndex) / ovrp_EndFrame(frameIndex) on the render thread; further arguments
|
||||
// (older plugins) are passed on as they are
|
||||
typedef int (*PFN_FrameCall)(long, long, long, long);
|
||||
|
||||
static PFN_FrameCall ovrp_fn(const char *name) {
|
||||
void *ovrp = dlopen("libOVRPlugin.so", RTLD_NOW | RTLD_NOLOAD);
|
||||
PFN_FrameCall fn = ovrp ? (PFN_FrameCall)dlsym(ovrp, name) : 0;
|
||||
LOG("ovrp frame loop shim: %s %s", name, fn ? "OK" : "MISSING");
|
||||
return fn;
|
||||
}
|
||||
|
||||
EXPORT int fpov_BeginFrame(long frame, long b, long c, long d) {
|
||||
static PFN_FrameCall real;
|
||||
static int logged;
|
||||
pthread_once(&once, init); // the settings (begin_gate) before the first begin
|
||||
if (!real) real = ovrp_fn("ovrp_BeginFrame");
|
||||
pthread_mutex_lock(&begin_lock);
|
||||
long index = frame;
|
||||
if (begin_gate && outstanding >= 0 && outstanding != (int)frame) {
|
||||
index = outstanding;
|
||||
remap_from = (int)frame, remap_to = outstanding;
|
||||
} else {
|
||||
remap_from = remap_to = -1;
|
||||
}
|
||||
pthread_mutex_unlock(&begin_lock);
|
||||
int r = real ? real(index, b, c, d) : -1000;
|
||||
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);
|
||||
begin_hooked = 1;
|
||||
outstanding = -1; // the waited frame (if any) is begun now
|
||||
pthread_cond_broadcast(&begin_cond);
|
||||
pthread_mutex_unlock(&begin_lock);
|
||||
return r;
|
||||
}
|
||||
|
||||
EXPORT int fpov_EndFrame(long frame, long b, long c, long d) {
|
||||
static PFN_FrameCall real;
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- input diagnostics (pass-through)
|
||||
|
||||
+20
-2
@@ -11,8 +11,9 @@
|
||||
// Haptics (GitHub #9, found by Klownicle in Lucky's Tale): overport's dispatcher converts an
|
||||
// XrHapticAmplitudeEnvelopeVibrationFB with its nanosecond duration taken as seconds, so one vibration allocates an
|
||||
// enormous sample buffer and the Frame runs out of memory and freezes. The shim hands OVRPlugin its own
|
||||
// xrApplyHapticFeedback, which turns such an envelope into a plain XrHapticVibration (same duration, its peak
|
||||
// amplitude) before the dispatcher sees it. Everything else passes through unchanged.
|
||||
// xrApplyHapticFeedback, which turns such an envelope into a plain XrHapticVibration (same duration, its RMS
|
||||
// amplitude) before the dispatcher sees it; PCM vibrations (XrHapticPcmVibrationFB) are converted the same way.
|
||||
// Everything else passes through unchanged.
|
||||
// Upstream: ovrport/app#73; tracked in FramePort GitHub #74.
|
||||
#include <openxr/openxr.h>
|
||||
#include <android/log.h>
|
||||
@@ -69,6 +70,23 @@ static XRAPI_ATTR XrResult XRAPI_CALL apply_haptic_feedback(XrSession session, c
|
||||
"vibration %.2f", env->amplitudeCount, (long long)env->duration, plain.amplitude);
|
||||
return overport_haptics(session, info, (const XrHapticBaseHeader *)&plain);
|
||||
}
|
||||
if (feedback && feedback->type == XR_TYPE_HAPTIC_PCM_VIBRATION_FB) {
|
||||
// the other buffered form goes through the same conversion: a plain vibration as long as the samples (all
|
||||
// consumed), with their RMS
|
||||
const XrHapticPcmVibrationFB *pcm = (const XrHapticPcmVibrationFB *)feedback;
|
||||
double sum = 0.0;
|
||||
for (uint32_t i = 0; pcm->buffer && i < pcm->bufferSize; ++i)
|
||||
sum += (double)pcm->buffer[i] * pcm->buffer[i];
|
||||
float rms = pcm->bufferSize ? (float)sqrt(sum / pcm->bufferSize) : 0.0f;
|
||||
XrDuration duration = pcm->sampleRate > 0 ? (XrDuration)(pcm->bufferSize / pcm->sampleRate * 1e9) : 0;
|
||||
XrHapticVibration plain = {XR_TYPE_HAPTIC_VIBRATION, NULL, duration > 0 ? duration : XR_MIN_HAPTIC_DURATION,
|
||||
XR_FREQUENCY_UNSPECIFIED, rms > 1.0f ? 1.0f : rms};
|
||||
if (pcm->samplesConsumed) *pcm->samplesConsumed = pcm->bufferSize;
|
||||
if (haptics_logged++ < 3)
|
||||
__android_log_print(ANDROID_LOG_INFO, TAG, "extension shim: haptic PCM (%u samples at %.0f Hz) -> "
|
||||
"vibration %.2f", pcm->bufferSize, pcm->sampleRate, plain.amplitude);
|
||||
return overport_haptics(session, info, (const XrHapticBaseHeader *)&plain);
|
||||
}
|
||||
return overport_haptics(session, info, feedback);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// FramePort shader-fix Vulkan layer for OpenGL ES games (libVkLayer_fp_shaderfix.so, layer VK_LAYER_FP_shader_fix).
|
||||
//
|
||||
// On the Frame, OpenGL ES runs on Zink (Mesa's GL on Vulkan): the game's GLSL becomes SPIR-V inside the driver, which
|
||||
// the Vulkan shim (vkshim, only in front of an engine's own Vulkan) never sees. Some of those modules read locals
|
||||
// before writing them (e.g. two lightspeed shaders of Vader Immortal: loop counters and accumulators undefined, the
|
||||
// GPU hangs; captured and fixed by Klownicle, GitHub #49). This layer sits between Zink and the driver and inserts
|
||||
// words into modules whose size and SHA-256 match a fix, exactly like vkshim's vk_shader_fix; every other module
|
||||
// passes through unchanged.
|
||||
// Settings (same sources as FrameBridge: libframe_settings.so next to this library, the game's framebridge.conf,
|
||||
// FRAMEBRIDGE_CONFIG):
|
||||
// zink_shader_fix=<size>:<sha256 hex>:<byte offset>:<word>,<word>,...[;<next fix>]
|
||||
// zink_shader_dump=1 writes every distinct module to Android/data/<pkg>/files/fp_spirv/<size>_<sha>.spv (capture
|
||||
// new variants after a driver update; diagnostics)
|
||||
//
|
||||
// Activation: Android's Vulkan loader only loads the layers GraphicsEnv names (Lepton fills that list from its own
|
||||
// layer set for the running app), from the app's native library directory among others. The engine library loads this
|
||||
// library (DT_NEEDED) before it starts EGL; its constructor puts this layer in front of GraphicsEnv's debug layer list
|
||||
// for this process only, through GraphicsEnv's (private, Android 11) functions found via libvulkan.so's dependencies.
|
||||
// Missing functions are logged and nothing changes.
|
||||
#define _GNU_SOURCE
|
||||
#include <vulkan/vulkan.h>
|
||||
#include <vulkan/vk_layer.h>
|
||||
#include <android/log.h>
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#define TAG "FrameBridge"
|
||||
#define EXPORT __attribute__((visibility("default")))
|
||||
#define LOG(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
|
||||
#define LAYER_NAME "VK_LAYER_FP_shader_fix"
|
||||
|
||||
// ---------------------------------------------------------------- settings
|
||||
|
||||
#define MAX_FIXES 16
|
||||
#define MAX_WORDS 64
|
||||
static struct {
|
||||
size_t size, offset;
|
||||
uint8_t sha[32];
|
||||
uint32_t words[MAX_WORDS];
|
||||
int nwords, applied;
|
||||
} fixes[MAX_FIXES];
|
||||
static int nfixes, dump;
|
||||
static char process[256];
|
||||
|
||||
static int hexval(char c) {
|
||||
return c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : c >= 'A' && c <= 'F' ? c - 'A' + 10 : -1;
|
||||
}
|
||||
|
||||
static void parse_fixes(char *v) {
|
||||
for (char *save = NULL, *item = strtok_r(v, ";", &save); item && nfixes < MAX_FIXES; item = strtok_r(NULL, ";", &save)) {
|
||||
char *f[4] = {0}, *save2 = NULL;
|
||||
int n = 0;
|
||||
for (char *t = strtok_r(item, ":", &save2); t && n < 4; t = strtok_r(NULL, ":", &save2)) f[n++] = t;
|
||||
if (n != 4 || strlen(f[1]) != 64) continue;
|
||||
int ok = 1;
|
||||
for (int i = 0; i < 32 && ok; i++) {
|
||||
int hi = hexval(f[1][2 * i]), lo = hexval(f[1][2 * i + 1]);
|
||||
if (hi < 0 || lo < 0) ok = 0; else fixes[nfixes].sha[i] = (uint8_t)(hi << 4 | lo);
|
||||
}
|
||||
fixes[nfixes].size = strtoul(f[0], NULL, 0);
|
||||
fixes[nfixes].offset = strtoul(f[2], NULL, 0);
|
||||
int nw = 0;
|
||||
for (char *save3 = NULL, *w = strtok_r(f[3], ",", &save3); w && nw < MAX_WORDS; w = strtok_r(NULL, ",", &save3))
|
||||
fixes[nfixes].words[nw++] = (uint32_t)strtoul(w, NULL, 0);
|
||||
fixes[nfixes].nwords = nw;
|
||||
fixes[nfixes].applied = 0;
|
||||
if (ok && nw && fixes[nfixes].size % 4 == 0 && fixes[nfixes].offset % 4 == 0 &&
|
||||
fixes[nfixes].offset >= 20 && fixes[nfixes].offset <= fixes[nfixes].size)
|
||||
nfixes++;
|
||||
}
|
||||
}
|
||||
|
||||
static void read_settings(const char *path) {
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return;
|
||||
char line[8192];
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
line[strcspn(line, "\r\n")] = 0;
|
||||
if (!strncmp(line, "zink_shader_dump=", 17)) dump = atoi(line + 17) != 0;
|
||||
if (!strncmp(line, "zink_shader_fix=", 16)) {
|
||||
nfixes = 0; // later sources override earlier ones (as for the adapter's settings)
|
||||
parse_fixes(line + 16);
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
static void read_all_settings(void) {
|
||||
char path[600];
|
||||
Dl_info info;
|
||||
if (dladdr((void *)read_all_settings, &info) && info.dli_fname) {
|
||||
const char *slash = strrchr(info.dli_fname, '/');
|
||||
if (slash && slash - info.dli_fname < 500) {
|
||||
snprintf(path, sizeof path, "%.*s/libframe_settings.so", (int)(slash - info.dli_fname), info.dli_fname);
|
||||
read_settings(path);
|
||||
}
|
||||
}
|
||||
FILE *f = fopen("/proc/self/cmdline", "r");
|
||||
if (f) {
|
||||
size_t n = fread(process, 1, sizeof process - 1, f);
|
||||
process[n] = 0;
|
||||
fclose(f);
|
||||
}
|
||||
char *colon = strchr(process, ':');
|
||||
if (colon) *colon = 0;
|
||||
if (*process && !strchr(process, '/')) {
|
||||
snprintf(path, sizeof path, "/sdcard/Android/data/%s/files/framebridge.conf", process);
|
||||
read_settings(path);
|
||||
}
|
||||
const char *env = getenv("FRAMEBRIDGE_CONFIG");
|
||||
if (env && *env) read_settings(env);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- SHA-256 (FIPS 180-4)
|
||||
|
||||
static const uint32_t K[64] = {
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01,
|
||||
0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
||||
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
|
||||
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08,
|
||||
0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
|
||||
#define ROR(x, n) (((x) >> (n)) | ((x) << (32 - (n))))
|
||||
|
||||
static void sha256_block(uint32_t h[8], const uint8_t *p) {
|
||||
uint32_t w[64];
|
||||
for (int i = 0; i < 16; i++) w[i] = (uint32_t)p[4 * i] << 24 | (uint32_t)p[4 * i + 1] << 16 | (uint32_t)p[4 * i + 2] << 8 | p[4 * i + 3];
|
||||
for (int i = 16; i < 64; i++) {
|
||||
uint32_t s0 = ROR(w[i - 15], 7) ^ ROR(w[i - 15], 18) ^ (w[i - 15] >> 3);
|
||||
uint32_t s1 = ROR(w[i - 2], 17) ^ ROR(w[i - 2], 19) ^ (w[i - 2] >> 10);
|
||||
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
|
||||
}
|
||||
uint32_t a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7];
|
||||
for (int i = 0; i < 64; i++) {
|
||||
uint32_t t1 = hh + (ROR(e, 6) ^ ROR(e, 11) ^ ROR(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i];
|
||||
uint32_t t2 = (ROR(a, 2) ^ ROR(a, 13) ^ ROR(a, 22)) + ((a & b) ^ (a & c) ^ (b & c));
|
||||
hh = g; g = f; f = e; e = d + t1; d = c; c = b; b = a; a = t1 + t2;
|
||||
}
|
||||
h[0] += a; h[1] += b; h[2] += c; h[3] += d; h[4] += e; h[5] += f; h[6] += g; h[7] += hh;
|
||||
}
|
||||
|
||||
static void sha256(const uint8_t *data, size_t len, uint8_t out[32]) {
|
||||
uint32_t h[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
|
||||
size_t i = 0;
|
||||
for (; i + 64 <= len; i += 64) sha256_block(h, data + i);
|
||||
uint8_t tail[128] = {0};
|
||||
size_t rest = len - i;
|
||||
memcpy(tail, data + i, rest);
|
||||
tail[rest] = 0x80;
|
||||
size_t blocks = rest + 9 > 64 ? 2 : 1;
|
||||
uint64_t bits = (uint64_t)len * 8;
|
||||
for (int b = 0; b < 8; b++) tail[blocks * 64 - 1 - b] = (uint8_t)(bits >> (8 * b));
|
||||
for (size_t b = 0; b < blocks; b++) sha256_block(h, tail + 64 * b);
|
||||
for (int b = 0; b < 8; b++) {
|
||||
out[4 * b] = h[b] >> 24; out[4 * b + 1] = h[b] >> 16; out[4 * b + 2] = h[b] >> 8; out[4 * b + 3] = h[b];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the fix and the dump
|
||||
|
||||
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
#define MAX_DUMPED 4096
|
||||
static uint8_t dumped[MAX_DUMPED][8];
|
||||
static int ndumped, dump_dir_ok = -1;
|
||||
|
||||
static void dump_module(const uint32_t *code, size_t size, const uint8_t digest[32]) {
|
||||
pthread_mutex_lock(&lock);
|
||||
int seen = 0;
|
||||
for (int i = 0; i < ndumped && !seen; i++) seen = !memcmp(dumped[i], digest, 8);
|
||||
if (!seen && ndumped < MAX_DUMPED) memcpy(dumped[ndumped++], digest, 8);
|
||||
pthread_mutex_unlock(&lock);
|
||||
if (seen || !*process || strchr(process, '/')) return;
|
||||
char path[512];
|
||||
snprintf(path, sizeof path, "/sdcard/Android/data/%s/files/fp_spirv", process);
|
||||
if (dump_dir_ok < 0) {
|
||||
dump_dir_ok = mkdir(path, 0775) == 0 || errno == EEXIST;
|
||||
LOG("shader fix layer: dumping SPIR-V modules to %s (%s)", path, dump_dir_ok ? "ok" : strerror(errno));
|
||||
}
|
||||
if (!dump_dir_ok) return;
|
||||
char hex[65];
|
||||
for (int i = 0; i < 32; i++) snprintf(hex + 2 * i, 3, "%02x", digest[i]);
|
||||
snprintf(path + strlen(path), sizeof path - strlen(path), "/%zu_%s.spv", size, hex);
|
||||
FILE *f = fopen(path, "wb");
|
||||
if (!f) return;
|
||||
fwrite(code, 1, size, f);
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
// The fixed copy of `code` (caller frees) and its size, or NULL when no fix matches.
|
||||
static uint32_t *fixed_shader(const uint32_t *code, size_t size, size_t *out_size) {
|
||||
uint8_t digest[32];
|
||||
int hashed = 0;
|
||||
if (dump) {
|
||||
sha256((const uint8_t *)code, size, digest);
|
||||
hashed = 1;
|
||||
dump_module(code, size, digest);
|
||||
}
|
||||
for (int i = 0; i < nfixes; i++) {
|
||||
if (fixes[i].size != size) continue;
|
||||
if (!hashed) sha256((const uint8_t *)code, size, digest), hashed = 1;
|
||||
if (memcmp(digest, fixes[i].sha, 32)) {
|
||||
static int near_logged;
|
||||
if (near_logged++ < 8) LOG("shader fix layer: a %zu-byte module differs from fix %d (another build?)", size, i + 1);
|
||||
continue;
|
||||
}
|
||||
size_t extra = (size_t)fixes[i].nwords * 4;
|
||||
uint32_t *copy = malloc(size + extra);
|
||||
if (!copy) return NULL;
|
||||
memcpy(copy, code, fixes[i].offset);
|
||||
memcpy((uint8_t *)copy + fixes[i].offset, fixes[i].words, extra);
|
||||
memcpy((uint8_t *)copy + fixes[i].offset + extra, (const uint8_t *)code + fixes[i].offset, size - fixes[i].offset);
|
||||
*out_size = size + extra;
|
||||
if (fixes[i].applied++ < 3)
|
||||
LOG("shader fix layer: fixed shader module %d (%zu -> %zu bytes)", i + 1, size, size + extra);
|
||||
return copy;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- dispatch (per instance / device, by dispatch key)
|
||||
|
||||
#define MAX_HANDLES 16
|
||||
static struct { void *key; VkInstance instance; PFN_vkGetInstanceProcAddr gipa; } instances[MAX_HANDLES];
|
||||
static struct { void *key; PFN_vkGetDeviceProcAddr gdpa; PFN_vkCreateShaderModule create_shader; } devices[MAX_HANDLES];
|
||||
|
||||
static void *key_of(const void *handle) { return handle ? *(void *const *)handle : NULL; }
|
||||
|
||||
static PFN_vkGetInstanceProcAddr instance_gipa(const void *handle, VkInstance *instance) {
|
||||
void *key = key_of(handle);
|
||||
PFN_vkGetInstanceProcAddr found = NULL, last = NULL;
|
||||
pthread_mutex_lock(&lock);
|
||||
for (int i = 0; i < MAX_HANDLES; i++) {
|
||||
if (!instances[i].gipa) continue;
|
||||
last = instances[i].gipa;
|
||||
if (instance) *instance = instances[i].instance;
|
||||
if (instances[i].key == key) { found = instances[i].gipa; break; }
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
return found ? found : last; // a physical device: its instance (or the only one there is)
|
||||
}
|
||||
|
||||
static VKAPI_ATTR VkResult VKAPI_CALL layer_CreateInstance(const VkInstanceCreateInfo *ci, const VkAllocationCallbacks *a,
|
||||
VkInstance *instance) {
|
||||
VkLayerInstanceCreateInfo *link = (VkLayerInstanceCreateInfo *)ci->pNext;
|
||||
while (link && !(link->sType == VK_STRUCTURE_TYPE_LOADER_INSTANCE_CREATE_INFO && link->function == VK_LAYER_LINK_INFO))
|
||||
link = (VkLayerInstanceCreateInfo *)link->pNext;
|
||||
if (!link || !link->u.pLayerInfo) return VK_ERROR_INITIALIZATION_FAILED;
|
||||
PFN_vkGetInstanceProcAddr next = link->u.pLayerInfo->pfnNextGetInstanceProcAddr;
|
||||
link->u.pLayerInfo = link->u.pLayerInfo->pNext;
|
||||
PFN_vkCreateInstance create = (PFN_vkCreateInstance)next(VK_NULL_HANDLE, "vkCreateInstance");
|
||||
if (!create) return VK_ERROR_INITIALIZATION_FAILED;
|
||||
VkResult r = create(ci, a, instance);
|
||||
if (r != VK_SUCCESS) return r;
|
||||
pthread_mutex_lock(&lock);
|
||||
int slot = 0;
|
||||
for (int i = 0; i < MAX_HANDLES; i++)
|
||||
if (!instances[i].gipa || instances[i].key == key_of(*instance)) { slot = i; break; }
|
||||
instances[slot].key = key_of(*instance);
|
||||
instances[slot].instance = *instance;
|
||||
instances[slot].gipa = next;
|
||||
pthread_mutex_unlock(&lock);
|
||||
static int logged;
|
||||
if (logged++ < 4) LOG("shader fix layer: active on instance %p (%d fix(es))", (void *)*instance, nfixes);
|
||||
return r;
|
||||
}
|
||||
|
||||
static VKAPI_ATTR VkResult VKAPI_CALL layer_CreateDevice(VkPhysicalDevice pd, const VkDeviceCreateInfo *ci,
|
||||
const VkAllocationCallbacks *a, VkDevice *device) {
|
||||
VkLayerDeviceCreateInfo *link = (VkLayerDeviceCreateInfo *)ci->pNext;
|
||||
while (link && !(link->sType == VK_STRUCTURE_TYPE_LOADER_DEVICE_CREATE_INFO && link->function == VK_LAYER_LINK_INFO))
|
||||
link = (VkLayerDeviceCreateInfo *)link->pNext;
|
||||
if (!link || !link->u.pLayerInfo) return VK_ERROR_INITIALIZATION_FAILED;
|
||||
PFN_vkGetInstanceProcAddr next_gipa = link->u.pLayerInfo->pfnNextGetInstanceProcAddr;
|
||||
PFN_vkGetDeviceProcAddr next_gdpa = link->u.pLayerInfo->pfnNextGetDeviceProcAddr;
|
||||
link->u.pLayerInfo = link->u.pLayerInfo->pNext;
|
||||
VkInstance instance = VK_NULL_HANDLE;
|
||||
instance_gipa(pd, &instance);
|
||||
PFN_vkCreateDevice create = (PFN_vkCreateDevice)next_gipa(instance, "vkCreateDevice");
|
||||
if (!create) return VK_ERROR_INITIALIZATION_FAILED;
|
||||
VkResult r = create(pd, ci, a, device);
|
||||
if (r != VK_SUCCESS) return r;
|
||||
pthread_mutex_lock(&lock);
|
||||
int slot = 0;
|
||||
for (int i = 0; i < MAX_HANDLES; i++)
|
||||
if (!devices[i].gdpa || devices[i].key == key_of(*device)) { slot = i; break; }
|
||||
devices[slot].key = key_of(*device);
|
||||
devices[slot].gdpa = next_gdpa;
|
||||
devices[slot].create_shader = (PFN_vkCreateShaderModule)next_gdpa(*device, "vkCreateShaderModule");
|
||||
pthread_mutex_unlock(&lock);
|
||||
return r;
|
||||
}
|
||||
|
||||
static void *device_entry(VkDevice device, int shader) {
|
||||
void *key = key_of(device), *fn = NULL;
|
||||
pthread_mutex_lock(&lock);
|
||||
for (int i = 0; i < MAX_HANDLES && !fn; i++)
|
||||
if (devices[i].gdpa && devices[i].key == key) fn = shader ? (void *)devices[i].create_shader : (void *)devices[i].gdpa;
|
||||
pthread_mutex_unlock(&lock);
|
||||
return fn;
|
||||
}
|
||||
|
||||
static VKAPI_ATTR VkResult VKAPI_CALL layer_CreateShaderModule(VkDevice device, const VkShaderModuleCreateInfo *ci,
|
||||
const VkAllocationCallbacks *a, VkShaderModule *module) {
|
||||
PFN_vkCreateShaderModule next = (PFN_vkCreateShaderModule)device_entry(device, 1);
|
||||
if (!next) return VK_ERROR_INITIALIZATION_FAILED;
|
||||
size_t size = 0;
|
||||
uint32_t *copy = ci && ci->pCode && ci->codeSize >= 20 ? fixed_shader(ci->pCode, ci->codeSize, &size) : NULL;
|
||||
if (!copy) return next(device, ci, a, module);
|
||||
VkShaderModuleCreateInfo fixed = *ci;
|
||||
fixed.codeSize = size;
|
||||
fixed.pCode = copy;
|
||||
VkResult r = next(device, &fixed, a, module);
|
||||
free(copy);
|
||||
return r;
|
||||
}
|
||||
|
||||
static PFN_vkVoidFunction own(const char *name);
|
||||
|
||||
static VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL layer_GetDeviceProcAddr(VkDevice device, const char *name) {
|
||||
PFN_vkVoidFunction fn = name && (!strcmp(name, "vkCreateShaderModule") || !strcmp(name, "vkGetDeviceProcAddr"))
|
||||
? own(name) : NULL;
|
||||
if (fn) return fn;
|
||||
PFN_vkGetDeviceProcAddr next = (PFN_vkGetDeviceProcAddr)device_entry(device, 0);
|
||||
return next ? next(device, name) : NULL;
|
||||
}
|
||||
|
||||
static VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL layer_GetInstanceProcAddr(VkInstance instance, const char *name) {
|
||||
PFN_vkVoidFunction fn = own(name);
|
||||
if (fn) return fn;
|
||||
PFN_vkGetInstanceProcAddr next = instance ? instance_gipa(instance, NULL) : NULL;
|
||||
return next ? next(instance, name) : NULL;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- layer enumeration (what the loader asks for)
|
||||
|
||||
static const VkLayerProperties properties = {LAYER_NAME, VK_MAKE_VERSION(1, 1, 0), 1, "FramePort shader fixes"};
|
||||
|
||||
EXPORT VKAPI_ATTR VkResult VKAPI_CALL vkEnumerateInstanceLayerProperties(uint32_t *count, VkLayerProperties *props) {
|
||||
if (!props) { *count = 1; return VK_SUCCESS; }
|
||||
if (*count < 1) return VK_INCOMPLETE;
|
||||
props[0] = properties;
|
||||
*count = 1;
|
||||
return VK_SUCCESS;
|
||||
}
|
||||
|
||||
EXPORT VKAPI_ATTR VkResult VKAPI_CALL vkEnumerateInstanceExtensionProperties(const char *layer, uint32_t *count,
|
||||
VkExtensionProperties *props) {
|
||||
(void)props;
|
||||
if (!layer || strcmp(layer, LAYER_NAME)) return VK_ERROR_LAYER_NOT_PRESENT;
|
||||
*count = 0;
|
||||
return VK_SUCCESS;
|
||||
}
|
||||
|
||||
EXPORT VKAPI_ATTR VkResult VKAPI_CALL vkEnumerateDeviceLayerProperties(VkPhysicalDevice pd, uint32_t *count,
|
||||
VkLayerProperties *props) {
|
||||
(void)pd;
|
||||
return vkEnumerateInstanceLayerProperties(count, props);
|
||||
}
|
||||
|
||||
EXPORT VKAPI_ATTR VkResult VKAPI_CALL vkEnumerateDeviceExtensionProperties(VkPhysicalDevice pd, const char *layer,
|
||||
uint32_t *count, VkExtensionProperties *props) {
|
||||
(void)pd;
|
||||
return vkEnumerateInstanceExtensionProperties(layer, count, props);
|
||||
}
|
||||
|
||||
EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL VK_LAYER_FP_shader_fixGetInstanceProcAddr(VkInstance i, const char *n) {
|
||||
return layer_GetInstanceProcAddr(i, n);
|
||||
}
|
||||
|
||||
EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL VK_LAYER_FP_shader_fixGetDeviceProcAddr(VkDevice d, const char *n) {
|
||||
return layer_GetDeviceProcAddr(d, n);
|
||||
}
|
||||
|
||||
static PFN_vkVoidFunction own(const char *name) {
|
||||
if (!name) return NULL;
|
||||
#define OWN(fn, impl) if (!strcmp(name, fn)) return (PFN_vkVoidFunction)impl;
|
||||
OWN("vkGetInstanceProcAddr", layer_GetInstanceProcAddr)
|
||||
OWN("vkGetDeviceProcAddr", layer_GetDeviceProcAddr)
|
||||
OWN("vkCreateInstance", layer_CreateInstance)
|
||||
OWN("vkCreateDevice", layer_CreateDevice)
|
||||
OWN("vkCreateShaderModule", layer_CreateShaderModule)
|
||||
OWN("vkEnumerateInstanceLayerProperties", vkEnumerateInstanceLayerProperties)
|
||||
OWN("vkEnumerateInstanceExtensionProperties", vkEnumerateInstanceExtensionProperties)
|
||||
OWN("vkEnumerateDeviceLayerProperties", vkEnumerateDeviceLayerProperties)
|
||||
#undef OWN
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- activation (GraphicsEnv debug layers)
|
||||
|
||||
// libc++ std::string as Android 11's platform libc++ lays it out (little endian, short-string flag in bit 0)
|
||||
struct cxx_string { size_t cap; size_t size; char *data; };
|
||||
|
||||
static const char *cxx_chars(const void *s, size_t *len) {
|
||||
const unsigned char *b = s;
|
||||
if (b[0] & 1) {
|
||||
const struct cxx_string *l = s;
|
||||
*len = l->size;
|
||||
return l->data;
|
||||
}
|
||||
*len = b[0] >> 1;
|
||||
return (const char *)b + 1;
|
||||
}
|
||||
|
||||
#define GET_INSTANCE "_ZN7android11GraphicsEnv11getInstanceEv"
|
||||
#define GET_LAYERS "_ZN7android11GraphicsEnv14getDebugLayersEv"
|
||||
#define SET_LAYERS "_ZN7android11GraphicsEnv14setDebugLayersENSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE"
|
||||
|
||||
__attribute__((constructor)) static void activate(void) {
|
||||
read_all_settings();
|
||||
if (!nfixes && !dump) return;
|
||||
void *vk = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL);
|
||||
void *(*get_instance)(void) = vk ? (void *(*)(void))dlsym(vk, GET_INSTANCE) : NULL;
|
||||
const void *(*get_layers)(void *) = vk ? (const void *(*)(void *))dlsym(vk, GET_LAYERS) : NULL;
|
||||
void (*set_layers)(void *, struct cxx_string *) = vk ? (void (*)(void *, struct cxx_string *))dlsym(vk, SET_LAYERS) : NULL;
|
||||
if (!get_instance || !get_layers || !set_layers) {
|
||||
LOG("shader fix layer: NOT active, Android's GraphicsEnv functions not found (%s%s%s)", get_instance ? "" : "getInstance ",
|
||||
get_layers ? "" : "getDebugLayers ", set_layers ? "" : "setDebugLayers");
|
||||
return;
|
||||
}
|
||||
void *env = get_instance();
|
||||
size_t len = 0;
|
||||
const char *current = env ? cxx_chars(get_layers(env), &len) : NULL;
|
||||
if (!env || (len && !current) || len > 4096) {
|
||||
LOG("shader fix layer: NOT active, unexpected GraphicsEnv state");
|
||||
return;
|
||||
}
|
||||
char old[4097];
|
||||
memcpy(old, current ? current : "", len);
|
||||
old[len] = 0;
|
||||
if (strstr(old, LAYER_NAME)) return; // loaded twice (the loader's own dlopen): already in the list
|
||||
size_t n = strlen(LAYER_NAME) + (len ? 1 + len : 0);
|
||||
size_t cap = (n + 16) & ~(size_t)15;
|
||||
char *data = malloc(cap);
|
||||
if (!data) return;
|
||||
snprintf(data, cap, "%s%s%s", LAYER_NAME, len ? ":" : "", old);
|
||||
// passed by value: the callee gets a pointer to our temporary and copies it; freed below unless it was moved
|
||||
struct cxx_string tmp = {cap | 1, n, data};
|
||||
set_layers(env, &tmp);
|
||||
if ((tmp.cap & 1) && tmp.data == data) free(data);
|
||||
const char *now = cxx_chars(get_layers(env), &len);
|
||||
LOG("shader fix layer: debug layers now \"%.*s\" (%d fix(es)%s)", (int)len, now ? now : "", nfixes,
|
||||
dump ? ", dumping modules" : "");
|
||||
}
|
||||
+39
-1
@@ -152,6 +152,40 @@ class FakeTarget:
|
||||
"apk_present": True, "recipe": None, "exe": g.get("exe"),
|
||||
"missing_libraries": ["libwebkit2gtk-4.1.so.0"]}
|
||||
for g in library.games() if g.get("kind") == "linux" and g["package"] == LINUX_APPIMAGE]
|
||||
# GitHub #90: most games on internal storage, the second one on a microSD card
|
||||
sd = {"internal": False, "path": "/run/media/steamos/SD Card", "label": "SD Card"}
|
||||
for i, g in enumerate(self.games):
|
||||
g["drive"] = sd if i == 1 else {"internal": True, "path": "/home/steamos", "label": "Internal storage"}
|
||||
g["drive_missing"] = False
|
||||
|
||||
def drives(self) -> list[dict]:
|
||||
sd_games = sum(1 for g in self.games if not g["drive"]["internal"])
|
||||
return [{"id": "internal", "path": "/home/steamos", "install_dir": "/home/steamos/Applications/quest-frame",
|
||||
"label": "Internal storage", "fstype": "ext4", "internal": True, "removable": False,
|
||||
"free_bytes": 312 * 2**30, "total_bytes": 460 * 2**30, "usable": True, "reason": "",
|
||||
"games": len(self.games) - sd_games},
|
||||
{"id": "/run/media/steamos/SD Card", "path": "/run/media/steamos/SD Card",
|
||||
"install_dir": "/run/media/steamos/SD Card/FramePort", "label": "SD Card", "fstype": "ext4",
|
||||
"internal": False, "removable": True, "free_bytes": 187 * 2**30, "total_bytes": 238 * 2**30,
|
||||
"usable": True, "reason": "", "games": sd_games},
|
||||
{"id": "/run/media/steamos/USB", "path": "/run/media/steamos/USB", "install_dir":
|
||||
"/run/media/steamos/USB/FramePort", "label": "USB", "fstype": "exfat", "internal": False,
|
||||
"removable": True, "free_bytes": 50 * 2**30, "total_bytes": 64 * 2**30, "usable": False,
|
||||
"reason": "exfat can't hold game data (no Unix permissions or symlinks); format the drive in "
|
||||
"SteamOS to install games on it", "games": 0}]
|
||||
|
||||
def move(self, package, dest, reporter) -> dict:
|
||||
reporter.stage("Move game files")
|
||||
for i in range(5):
|
||||
reporter.progress(i / 4, f"Copying {i / 2:.1f}/2.0 GiB")
|
||||
time.sleep(0.5)
|
||||
return {"state": "done", "package": package}
|
||||
|
||||
def set_desktop_entry(self, package, enabled) -> dict:
|
||||
for g in self.games:
|
||||
if g["package"] == package:
|
||||
g["desktop_entry"] = enabled
|
||||
return {"enabled": enabled}
|
||||
|
||||
def describe(self) -> dict:
|
||||
return {"hostname": "steamframe", "os": "SteamOS", "os_version": "3.8", "build_id": "20260922",
|
||||
@@ -405,6 +439,8 @@ def main() -> int:
|
||||
steps += [("share-dialog", lambda a: a.share_config_dialog(game)),
|
||||
("report-dialog", lambda a: (a.page.pop_dialog(), a.report_problem_dialog(game))),
|
||||
("custom-art-dialog", lambda a: (a.page.pop_dialog(), a.custom_artwork(game)))]
|
||||
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))))
|
||||
# last: the right-click menu stays open over whatever comes next
|
||||
steps.append(("library-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
|
||||
a.library_view.open_menu(game))))
|
||||
@@ -478,7 +514,9 @@ def main() -> int:
|
||||
("linux-folder-customize", lambda a: a.open_game(LINUX_FOLDER, advanced=True)),
|
||||
("linux-change-program", lambda a: a.choose_exe(LINUX_FOLDER)),
|
||||
("frame", lambda a: (a.page.pop_dialog(), a.navigate(1))),
|
||||
("linux-menu", lambda a: (a.navigate(0), time.sleep(3), a.library_view.open_menu(LINUX_APPIMAGE)))]
|
||||
("linux-move", lambda a: a.move_game(LINUX_APPIMAGE) if args.fake_frame else None),
|
||||
("linux-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
|
||||
a.library_view.open_menu(LINUX_APPIMAGE)))]
|
||||
if args.links:
|
||||
from frameport import deeplink
|
||||
from frameport.ui.views import link_dialog
|
||||
|
||||
@@ -9,19 +9,88 @@ from pathlib import Path
|
||||
from ..apk import axml
|
||||
from ..core.models import Analysis
|
||||
from . import elf
|
||||
from .unity_split import split_build as unity_split_build
|
||||
|
||||
logging.getLogger("pyaxmlparser").setLevel(logging.ERROR)
|
||||
|
||||
UNITY_GGM = "assets/bin/Data/globalgamemanagers"
|
||||
IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
|
||||
|
||||
# The version of what analyze() stores (Analysis.extra["analysis_version"]). BUMP IT whenever analyze() gains a field
|
||||
# or detects something differently that patches/heuristics read: library entries analysed by an older FramePort are
|
||||
# then analysed again in the background at the next start (pipeline.refresh_analyses), so their new patches are
|
||||
# offered (GitHub #104: entries from before `sdl_java` never got frame.sdl_clipboard). Entries without it are 0.
|
||||
# 1: sdl_java, min_sdk, web_wrapper, expects_obb, vr_activity, unity_version (2026-10)
|
||||
# 2: unity_split (a Unity split-binary build expects an OBB too: expects_obb) (2026-10)
|
||||
# 3: unreal_ovrp_lookups (the OVRPlugin functions Unreal's Oculus module looks up: frame.unreal_ovrp_entrypoints)
|
||||
# 4: vivox_api31 (Vivox's audio routing calls Android 12 AudioManager methods: frame.vivox_audio_route) (2026-10)
|
||||
# 5: unreal_quest_gates (Quest-only branches in ILMxLAB's Unreal: frame.unreal_quest_precompile/_keymap) (2026-10)
|
||||
# 6: gl_multiview_libs (own-engine libraries with OVR_multiview GLSL: frame.gl_multiview_fbo) (2026-10)
|
||||
# 7: unreal_thumb_touch (UE4 OculusInput's ThumbUp from near-touch, matched exactly: frame.unreal_thumb_touch)
|
||||
ANALYSIS_VERSION = 7
|
||||
|
||||
def _read_manifest_info(path: Path) -> tuple[str, str, str, str | None]:
|
||||
|
||||
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
|
||||
ANDROID_VERSIONS = {29: "10", 30: "11", 31: "12", 32: "12L", 33: "13", 34: "14", 35: "15", 36: "16", 37: "17"}
|
||||
FRAME_API = 30
|
||||
# Quest games declare up to API 32 (Quest's Android 12L) and run on the Frame (OVRPort lowers minSdk to 29; they
|
||||
# don't call newer Android classes). From API 33 on, apps call Android 13+ classes at start (GitHub #71/#72: minSdk
|
||||
# 34, NoClassDefFoundError android/window/OnBackInvokedCallback, NoSuchMethodError VarHandle.storeStoreFence).
|
||||
MAX_RUNNABLE_MIN_SDK = 32
|
||||
|
||||
|
||||
def android_version(api: int) -> str:
|
||||
"""'14' for API 34."""
|
||||
return ANDROID_VERSIONS.get(api, f"API {api}")
|
||||
|
||||
|
||||
def too_new_android(min_sdk: int | None) -> bool:
|
||||
"""The APK needs a newer Android than the Frame's Lepton (11): it crashes at start on missing Android classes."""
|
||||
return bool(min_sdk) and min_sdk > MAX_RUNNABLE_MIN_SDK
|
||||
|
||||
|
||||
TWA_URL_KEY = "android.support.customtabs.trusted.DEFAULT_URL"
|
||||
TWA_ACTIVITY = "com.google.androidbrowserhelper.trusted.LauncherActivity"
|
||||
UE_OBB_KEY = ".GameActivity.bHasOBBFiles" # com.epicgames.ue4.… (UE4) / com.epicgames.unreal.… (UE5)
|
||||
|
||||
|
||||
def expects_obb(meta: dict) -> bool:
|
||||
"""Unreal packaged the game's content as an OBB (expansion file) and opens it at start."""
|
||||
return any(k.endswith(UE_OBB_KEY) and v in (True, "true", "True") for k, v in meta.items())
|
||||
|
||||
|
||||
def _read_manifest_info(path: Path):
|
||||
"""(package, versionName, label, main activity, pyaxmlparser APK)."""
|
||||
from pyaxmlparser import APK
|
||||
|
||||
apk = APK(str(path))
|
||||
label = apk.application or apk.package
|
||||
return apk.package, apk.version_name or "", label, apk.get_main_activity()
|
||||
return apk.package, apk.version_name or "", label, apk.get_main_activity(), apk
|
||||
|
||||
|
||||
def _resolve_string(apk, value) -> str | None:
|
||||
"""A meta-data value: the string itself, or a @string/… reference resolved through resources.arsc."""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if apk is None or isinstance(value, bool) or not isinstance(value, int):
|
||||
return None
|
||||
try:
|
||||
res = apk.get_android_resources()
|
||||
for _config, v in (res.get_resolved_res_configs(value) if res else ()):
|
||||
if isinstance(v, str) and v:
|
||||
return v
|
||||
except Exception: # noqa: BLE001 - a broken resource table only loses the URL
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def web_wrapper(meta: dict, manifest_strings: list[str], apk=None) -> dict | None:
|
||||
"""A Trusted Web Activity (Bubblewrap, Meta's PWA packaging): the APK only opens a website in a browser app
|
||||
(Meta's com.oculus.browser on Quest), which Lepton doesn't have (GitHub #86). {"url": str | None} or None."""
|
||||
if TWA_URL_KEY not in meta and TWA_ACTIVITY not in manifest_strings:
|
||||
return None
|
||||
url = _resolve_string(apk, meta.get(TWA_URL_KEY))
|
||||
return {"url": url if url and url.startswith(("https://", "http://")) else None}
|
||||
|
||||
|
||||
def _unreal_version(z: zipfile.ZipFile, lib: str) -> str | None:
|
||||
@@ -94,8 +163,11 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
prefix = f"lib/{abi}/" if abi else None
|
||||
libs = sorted(n[len(prefix):] for n in names if prefix and n.startswith(prefix) and n.endswith(".so"))
|
||||
# SDL's Java side (SDL2 / LÖVE apps): crashes in Lepton without a clipboard service (frame.sdl_clipboard)
|
||||
sdl_java = any(b"Lorg/libsdl/app/SDLClipboardHandler;" in z.read(n) for n in names
|
||||
if n.startswith("classes") and n.endswith(".dex"))
|
||||
dexes = [z.read(n) for n in names if n.startswith("classes") and n.endswith(".dex")]
|
||||
sdl_java = any(b"Lorg/libsdl/app/SDLClipboardHandler;" in d for d in dexes)
|
||||
# Vivox voice chat calling Android 12 audio-routing methods: crashes in Lepton (Android 11)
|
||||
vivox_api31 = any(b"Lcom/vivox/sdk/AudioChangeListener;" in d and b"CommunicationDevice" in d for d in dexes)
|
||||
del dexes
|
||||
manifest = z.read("AndroidManifest.xml")
|
||||
lib_bytes = {}
|
||||
if deep and prefix:
|
||||
@@ -111,8 +183,10 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
if "assets/bin/Data/boot.config" in names else "")
|
||||
ggm = z.read(UNITY_GGM) if deep and UNITY_GGM in names else None
|
||||
il2cpp_meta = z.read(IL2CPP_METADATA) if deep and IL2CPP_METADATA in names and "libil2cpp.so" in libs else None
|
||||
# a Unity split build: the rest of the game is in a zip OBB (analysis/unity_split.py)
|
||||
unity_split = "libunity.so" in libs and unity_split_build(z, names, ggm, deep)
|
||||
|
||||
package, version, label, activity = _read_manifest_info(path)
|
||||
package, version, label, activity, apk_info = _read_manifest_info(path)
|
||||
libset = set(libs)
|
||||
engine = ("Unreal" if libset & {"libUE4.so", "libUnreal.so"} else "Unity" if "libunity.so" in libset
|
||||
else "CryEngine" if "libCrySystem.so" in libset else "Other")
|
||||
@@ -159,6 +233,7 @@ 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,
|
||||
@@ -178,6 +253,7 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
is_overport_output=is_overport,
|
||||
debuggable=bool(axml.Axml(manifest).get_bool("application", "debuggable")),
|
||||
extra={
|
||||
"analysis_version": ANALYSIS_VERSION,
|
||||
"frame_patched": "libframe_settings.so" in libset, # already has FramePort's FrameBridge adapter
|
||||
# Team Beef's TBXR ports pick their OpenXR path by headset maker (frame.tbxr_vendor): their libraries
|
||||
# the game asks Meta's platform for asset files (content shipped as separate files, frame.asset_files)
|
||||
@@ -210,16 +286,93 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
# Meta's OVRManager raises MSAA at runtime (frame.unity_runtime_msaa_off); Oculus XR Plugin (multiview)
|
||||
"ovr_runtime_msaa": bool(il2cpp_meta) and b"\0useRecommendedMSAALevel\0" in il2cpp_meta,
|
||||
"sdl_java": sdl_java,
|
||||
"vivox_api31": vivox_api31,
|
||||
"oculus_xr_plugin": bool(il2cpp_meta) and b"\0m_StereoRenderingModeAndroid\0" in il2cpp_meta,
|
||||
# Unity's built-in Oculus support checks for Meta's system apps before VR (frame.unity_oculus_check)
|
||||
"unity_oculus_check": b"\0com.oculus.systemactivities\0" in lib_bytes.get("libunity.so", b""),
|
||||
# a 2D launcher activity that starts a separate VR activity (frame.start_activity)
|
||||
"vr_activity": axml.vr_activity(manifest),
|
||||
"unity_version": unity_version(ggm, lib_bytes.get("libunity.so")) if engine == "Unity" else None,
|
||||
# the minimum Android version the APK declares (read from the original: OVRPort lowers it to 29)
|
||||
"min_sdk": axml.min_sdk(manifest),
|
||||
# a website in an Android wrapper (TWA): nothing to port
|
||||
"web_wrapper": web_wrapper(meta, manifest_strings, apk_info),
|
||||
# Unreal packaged its content as an OBB (expansion file), or a Unity split build keeps all but its first
|
||||
# scene in one: without it the game hangs at start (GitHub #85, #92)
|
||||
"expects_obb": expects_obb(meta) or unity_split,
|
||||
"unity_split": unity_split,
|
||||
# Unreal's Oculus module needs every OVRPlugin function it looks up (frame.unreal_ovrp_entrypoints)
|
||||
"unreal_ovrp_lookups": (ovrp_lookups(lib_bytes.get(engine_lib.rsplit("/", 1)[1], b""))
|
||||
if engine_lib and "libOVRPlugin.so" in libset else []),
|
||||
# Quest-only branches that leave ILMxLAB's Unreal games stuck on the Frame (frame.unreal_quest_*)
|
||||
"unreal_quest_gates": (unreal_quest_gates(lib_bytes.get(engine_lib.rsplit("/", 1)[1], b""))
|
||||
if engine_lib else []),
|
||||
# UE4's Oculus input animates the thumb from near-touch, which the Frame never reports
|
||||
# (frame.unreal_thumb_touch: True only where its exact code matches)
|
||||
"unreal_thumb_touch": (unreal_thumb_touch(lib_bytes.get(engine_lib.rsplit("/", 1)[1], b""))
|
||||
if engine_lib and engine_lib.startswith("lib/arm64-v8a/") else False),
|
||||
# own-engine libraries whose GLSL declares OVR_multiview views (frame.gl_multiview_fbo, e.g. Doom3Quest)
|
||||
"gl_multiview_libs": multiview_glsl_libs(lib_bytes) if engine == "Other" else [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Quest-only branches in ILMxLAB's Unreal (IsRunningOnSantaCruz) that leave the game stuck on the Frame: the exported
|
||||
# function each frame.unreal_quest_* patch rewrites
|
||||
UNREAL_QUEST_GATES = {
|
||||
"quest_precompile": "_ZN8UVRUtils31GetQuestShaderPrecompilePercentEv",
|
||||
"rpoc_keymap": "_ZN27URPOCKeyMapManagerComponent14AddAxisMappingERK15FRPOCKeyMappingR16FRPOCInputMapSet",
|
||||
}
|
||||
|
||||
|
||||
# libraries that hold GLSL but aren't the game's renderer
|
||||
NOT_GL_ENGINE = ("libopenxr", "libOVR", "libovr", "libvrapi", "libfp", "libframe", "libglshim", "libVkLayer", "libc++")
|
||||
|
||||
|
||||
def multiview_glsl_libs(lib_bytes: dict[str, bytes]) -> list[str]:
|
||||
"""Own-engine libraries with OVR_multiview shaders (`layout(num_views=…) in;` + gl_ViewID_OVR): such an engine may
|
||||
also draw them into ordinary framebuffers, which Mesa refuses (frame.gl_multiview_fbo, GitHub #77)."""
|
||||
return sorted(n for n, d in lib_bytes.items() if not n.startswith(NOT_GL_ENGINE) and elf.is_elf(d)
|
||||
and b"num_views" in d and b"gl_ViewID_OVR" in d)
|
||||
|
||||
|
||||
UNREAL_THUMB_TOUCH = "_ZN11OculusInput12FOculusInput20SendControllerEventsEv"
|
||||
|
||||
|
||||
def unreal_thumb_touch(data: bytes) -> bool:
|
||||
"""UE4's OculusInput sets ThumbUp from near-touch in exactly the code frame.unreal_thumb_touch rewrites (or
|
||||
already rewrote)."""
|
||||
if b"\0" + UNREAL_THUMB_TOUCH.encode() + b"\0" not in data:
|
||||
return False
|
||||
from ..patches.frame.unreal_thumb_touch import thumb_site
|
||||
|
||||
try:
|
||||
return thumb_site(data) is not None
|
||||
except Exception: # noqa: BLE001 - a malformed library: no suggestion
|
||||
return False
|
||||
|
||||
|
||||
def unreal_quest_gates(data: bytes) -> list[str]:
|
||||
"""The gate functions an Unreal engine library exports (a search of the symbol names, no ELF parsing)."""
|
||||
return sorted(k for k, sym in UNREAL_QUEST_GATES.items() if b"\0" + sym.encode() + b"\0" in data)
|
||||
|
||||
|
||||
def ovrp_lookups(data: bytes) -> list[str]:
|
||||
"""The OVRPlugin function names (`ovrp_*` strings) an engine library holds: Unreal's Oculus module looks each one
|
||||
up with dlsym in libOVRPlugin.so and gives up on VR when any is missing (FOculusHMDModule::
|
||||
InitializeOculusPluginWrapper)."""
|
||||
names, at = set(), data.find(b"\0ovrp_")
|
||||
while at >= 0:
|
||||
end = data.find(b"\0", at + 1)
|
||||
if end < 0:
|
||||
break
|
||||
name = data[at + 1:end]
|
||||
if re.fullmatch(rb"ovrp_[A-Za-z0-9_]+", name):
|
||||
names.add(name.decode())
|
||||
at = data.find(b"\0ovrp_", end)
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def unity_text_fields(metadata: bytes) -> list[str]:
|
||||
"""The Unity text field classes an IL2CPP game contains (names from its global-metadata.dat string table)."""
|
||||
out = []
|
||||
|
||||
@@ -59,6 +59,38 @@ def _dyn_symbols_cached(data: bytes, defined: bool) -> frozenset[str]:
|
||||
return frozenset(out)
|
||||
|
||||
|
||||
def find_symbols(data: bytes, names) -> dict[str, tuple[int, int]]:
|
||||
"""(address, size) of the defined dynamic symbols `names` (ELF64 little-endian). Looks the names up in .dynstr and
|
||||
their entries in .dynsym by byte search instead of reading every symbol: engine libraries (libUE4.so) have
|
||||
hundreds of thousands."""
|
||||
import struct
|
||||
|
||||
if not is_elf(data) or data[4] != 2 or data[5] != 1:
|
||||
return {}
|
||||
elf = _elf(data)
|
||||
dynsym, dynstr = elf.get_section_by_name(".dynsym"), elf.get_section_by_name(".dynstr")
|
||||
if dynsym is None or dynstr is None:
|
||||
return {}
|
||||
sym_off, sym_size = dynsym["sh_offset"], dynsym["sh_size"]
|
||||
str_off, str_end = dynstr["sh_offset"], dynstr["sh_offset"] + dynstr["sh_size"]
|
||||
out = {}
|
||||
for name in names:
|
||||
raw = name.encode() + b"\0"
|
||||
at = data.find(raw, str_off, str_end)
|
||||
while at >= 0 and name not in out:
|
||||
needle = struct.pack("<I", at - str_off)
|
||||
hit = data.find(needle, sym_off, sym_off + sym_size)
|
||||
while hit >= 0:
|
||||
if (hit - sym_off) % 24 == 0:
|
||||
_, _, _, shndx, value, size = struct.unpack_from("<IBBHQQ", data, hit)
|
||||
if shndx and value:
|
||||
out[name] = (value, size)
|
||||
break
|
||||
hit = data.find(needle, hit + 1, sym_off + sym_size)
|
||||
at = data.find(raw, at + 1, str_end)
|
||||
return out
|
||||
|
||||
|
||||
def is_64bit(data: bytes) -> bool:
|
||||
return is_elf(data) and data[4] == 2
|
||||
|
||||
|
||||
@@ -102,6 +102,60 @@ def _desktop_name(root: Path) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _png_width(path: Path) -> int | None:
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(24)
|
||||
except OSError:
|
||||
return None
|
||||
if head[:8] != b"\x89PNG\r\n\x1a\n" or len(head) < 24:
|
||||
return None
|
||||
return int.from_bytes(head[16:20], "big")
|
||||
|
||||
|
||||
def find_icon(root: Path) -> Path | None:
|
||||
"""The app's own icon in a folder (GitHub #99): the Icon= of its bundled .desktop file, looked up next to the
|
||||
file, in (usr/)share/icons/hicolor/<size>/apps and (usr/)share/pixmaps, or a top-level .DirIcon; the biggest PNG
|
||||
(the PC can't draw SVG icons; the Frame's agent uses those for Desktop Mode). Nothing outside `root` (symlinks
|
||||
are resolved and checked). AppImages are read on the Frame instead (their files are compressed)."""
|
||||
root = Path(root)
|
||||
try:
|
||||
real_root = root.resolve()
|
||||
except OSError:
|
||||
return None
|
||||
paths = [root / ".DirIcon"]
|
||||
desktops = [p for p in sorted(root.rglob("*.desktop"))[:5] if len(p.relative_to(root).parts) <= 4]
|
||||
for desktop in desktops:
|
||||
m = re.search(r"^Icon=(.+)$", desktop.read_text(encoding="utf-8", errors="replace"), re.M)
|
||||
name = m.group(1).strip() if m else ""
|
||||
if not name or name.startswith("/") or ".." in name.split("/"):
|
||||
continue
|
||||
stem = re.sub(r"\.(png|svg|xpm)$", "", name, flags=re.I)
|
||||
bases = [root] + [d for d in desktop.parents if d != root and real_root in d.resolve().parents]
|
||||
for base in bases:
|
||||
paths += [base / name, base / f"{stem}.png"]
|
||||
for share in (base / "usr" / "share", base / "share"):
|
||||
paths += sorted(share.glob(f"icons/hicolor/*/apps/{glob_escape(stem)}.png"))
|
||||
paths.append(share / "pixmaps" / f"{stem}.png")
|
||||
break
|
||||
best: tuple[int, Path] | None = None
|
||||
for p in paths:
|
||||
try:
|
||||
real = p.resolve()
|
||||
if real_root not in real.parents or not real.is_file() or real.stat().st_size > 4 << 20:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
width = _png_width(real)
|
||||
if width and (best is None or width > best[0]):
|
||||
best = (width, real)
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def glob_escape(text: str) -> str:
|
||||
return re.sub(r"([*?\[])", r"[\1]", text)
|
||||
|
||||
|
||||
def rank_programs(root: Path) -> list[tuple[Path, int]]:
|
||||
"""(ELF program, machine) candidates, best first: the .desktop Exec= name, then a name like the folder's, then
|
||||
shallow and big ones. Libraries (*.so*) are never programs."""
|
||||
|
||||
@@ -304,6 +304,15 @@ def _norm(s: str) -> str:
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ ranking
|
||||
def is_electron(exe: Path) -> bool:
|
||||
"""An Electron (Chromium) app: e.g. a game's own launcher next to the game (SUPERHOT VR's SHVR.exe, GitHub #105).
|
||||
Its files sit next to the exe; a Unity/Unreal game's own exe has its `<name>_Data` folder instead."""
|
||||
d = exe.parent
|
||||
if (d / f"{exe.stem}_Data").is_dir():
|
||||
return False
|
||||
return (d / "resources" / "app.asar").is_file() or ((d / "icudtl.dat").is_file() and any(d.glob("*.pak")))
|
||||
|
||||
|
||||
def rank_exes(folder: Path, cands: list[Path], manifest: dict | None = None,
|
||||
dlls: dict[str, Path] | None = None) -> list[dict]:
|
||||
"""Candidates best-first: {path (relative, /), score, reasons, size, machine}."""
|
||||
@@ -321,9 +330,13 @@ def rank_exes(folder: Path, cands: list[Path], manifest: dict | None = None,
|
||||
if stem.endswith("-win64-shipping") or stem.endswith("-win32-shipping"):
|
||||
score += 50
|
||||
reasons.append("Unreal game build")
|
||||
elif (p.parent / "UnityPlayer.dll").exists() or (p.parent / f"{p.stem}_Data").is_dir():
|
||||
elif (p.parent / f"{p.stem}_Data").is_dir() or ((p.parent / "UnityPlayer.dll").exists()
|
||||
and not is_electron(p)):
|
||||
score += 50
|
||||
reasons.append("Unity game (next to its data)")
|
||||
if is_electron(p):
|
||||
score -= 30
|
||||
reasons.append("Electron launcher (starts the game)")
|
||||
if list(p.parent.glob(f"*/Binaries/Win64/{p.stem}-Win64-Shipping.exe")):
|
||||
score -= 30
|
||||
reasons.append("Unreal launcher (starts the real game build)")
|
||||
|
||||
@@ -24,12 +24,21 @@ def _elf_hash(name: bytes) -> int:
|
||||
return h
|
||||
|
||||
|
||||
def build_stub_library(symbols: list[str], soname: str = "libovrstubs.so", abi: str = "arm64-v8a") -> bytes:
|
||||
def build_stub_library(symbols: list[str], soname: str = "libovrstubs.so", abi: str = "arm64-v8a",
|
||||
result: int = 0) -> bytes:
|
||||
"""`result`: what every function returns (a 32-bit int; arm64 only for values other than 0), e.g. OVRPlugin's
|
||||
ovrpFailure (-1000) for stand-ins whose callers must not trust untouched output arguments."""
|
||||
is64 = abi == "arm64-v8a"
|
||||
symbols = sorted(set(symbols))
|
||||
if result and not (is64 and -0x10000 <= result <= 0xFFFF):
|
||||
raise ValueError(f"stub result {result} not supported for {abi}")
|
||||
if is64:
|
||||
machine, ehsize, phentsize, shentsize, symsize, dynsize = 183, 64, 56, 64, 24, 16 # EM_AARCH64
|
||||
body = struct.pack("<II", 0xD2800000, 0xD65F03C0) # mov x0, #0 ; ret
|
||||
if result > 0: # movz w0, #result ; ret
|
||||
body = struct.pack("<II", 0x52800000 | result << 5, 0xD65F03C0)
|
||||
elif result < 0: # movn w0, #~result ; ret (w0 = result)
|
||||
body = struct.pack("<II", 0x12800000 | (~result & 0xFFFF) << 5, 0xD65F03C0)
|
||||
else:
|
||||
machine, ehsize, phentsize, shentsize, symsize, dynsize = 40, 52, 32, 40, 16, 8 # EM_ARM
|
||||
body = struct.pack("<II", 0xE3A00000, 0xE12FFF1E) # mov r0, #0 ; bx lr
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Unity "split application binary" builds: the APK keeps the first scene, the rest of the game is a zip OBB
|
||||
(main.<versionCode>.<package>.obb holding assets/bin/Data/...). Without it the game starts into nothing.
|
||||
|
||||
Two signs, either is enough (checked against 48 Unity APKs, 30 games: all 13 split builds flagged, none of the 17 full
|
||||
builds, whose OBBs, where they have one, are asset bundles or sound banks):
|
||||
A an XR-plugin build (Oculus XR Plugin / Unity OpenXR library) without assets/bin/Data/UnitySubsystems/ in the APK:
|
||||
the split moves the UnitySubsystems manifests into the OBB; full XR-plugin builds always have them (zip names only;
|
||||
the only sign for split builds with one scene, e.g. BONELAB)
|
||||
B the build settings list more scenes than the APK has level files (a split keeps level0 only). The scene list
|
||||
comes from globalgamemanagers (loose, or the node of data.unity3d: only the bundle's head and the blocks up to it
|
||||
are read, a few hundred KB of a file that can be hundreds of MB)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import lzma
|
||||
import re
|
||||
import struct
|
||||
import zipfile
|
||||
|
||||
DATA = "assets/bin/Data/"
|
||||
XR_PLUGIN_LIBS = ("libOculusXRPlugin.so", "libUnityOpenXR.so")
|
||||
LEVEL = re.compile(re.escape(DATA) + r"level(\d+)(?:\.|$)")
|
||||
MAX_NODE = 64 * 2**20 # globalgamemanagers is 0.1-3 MB; a larger node isn't read
|
||||
|
||||
|
||||
def xr_plugin_without_subsystems(names: list[str]) -> bool:
|
||||
"""Sign A (zip names only)."""
|
||||
xr_plugin = any(n.startswith("lib/") and n.rsplit("/", 1)[-1] in XR_PLUGIN_LIBS for n in names)
|
||||
return xr_plugin and not any(n.startswith(DATA + "UnitySubsystems/") for n in names)
|
||||
|
||||
|
||||
def _cstr(f) -> bytes:
|
||||
out = bytearray()
|
||||
while (c := f.read(1)) not in (b"\0", b""):
|
||||
out += c
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _decompress(data: bytes, kind: int, size: int) -> bytes:
|
||||
if kind == 0:
|
||||
return data
|
||||
if kind in (2, 3): # LZ4 / LZ4HC
|
||||
import lz4.block
|
||||
|
||||
return lz4.block.decompress(data, uncompressed_size=size)
|
||||
if kind == 1: # LZMA: 5 bytes of properties, then raw LZMA1
|
||||
props, dict_size = data[0], struct.unpack("<I", data[1:5])[0]
|
||||
dec = lzma.LZMADecompressor(lzma.FORMAT_RAW, filters=[{
|
||||
"id": lzma.FILTER_LZMA1, "dict_size": dict_size, "lc": props % 9, "lp": (props // 9) % 5,
|
||||
"pb": props // 45}])
|
||||
return dec.decompress(data[5:], size)
|
||||
raise ValueError(f"unknown compression {kind}")
|
||||
|
||||
|
||||
class _Counting:
|
||||
"""A forward-only stream that knows its position (zip member streams have no reliable tell())."""
|
||||
|
||||
def __init__(self, f):
|
||||
self.f, self.pos = f, 0
|
||||
|
||||
def read(self, n: int) -> bytes:
|
||||
b = self.f.read(n)
|
||||
self.pos += len(b)
|
||||
return b
|
||||
|
||||
def align(self, n: int = 16) -> None:
|
||||
if self.pos % n:
|
||||
self.read(n - self.pos % n)
|
||||
|
||||
|
||||
def bundle_node(stream, want: str = "globalgamemanagers") -> tuple[list[str], bytes | None]:
|
||||
"""(node names, bytes of node `want`) of a UnityFS bundle, reading only up to the end of that node. ([], None)
|
||||
for anything else (old formats, block info at the end of the file)."""
|
||||
f = _Counting(stream)
|
||||
if f.read(8) != b"UnityFS\0":
|
||||
return [], None
|
||||
version = struct.unpack(">I", f.read(4))[0]
|
||||
_cstr(f), _cstr(f)
|
||||
_size, csize, usize, flags = struct.unpack(">qIII", f.read(20))
|
||||
if version >= 7:
|
||||
f.align()
|
||||
if flags & 0x80: # block info at the end of the file
|
||||
return [], None
|
||||
info = _decompress(f.read(csize), flags & 0x3F, usize)
|
||||
off = 16 # data hash
|
||||
(nblocks,) = struct.unpack(">i", info[off:off + 4])
|
||||
off += 4
|
||||
blocks = []
|
||||
for _ in range(nblocks):
|
||||
blocks.append(struct.unpack(">IIH", info[off:off + 10]))
|
||||
off += 10
|
||||
(nnodes,) = struct.unpack(">i", info[off:off + 4])
|
||||
off += 4
|
||||
nodes = []
|
||||
for _ in range(nnodes):
|
||||
node_off, node_size, _flags = struct.unpack(">qqI", info[off:off + 20])
|
||||
off += 20
|
||||
end = info.index(b"\0", off)
|
||||
nodes.append((info[off:end].decode("utf-8", "replace"), node_off, node_size))
|
||||
off = end + 1
|
||||
if flags & 0x200: # padding before the first block
|
||||
f.align()
|
||||
names = [n[0] for n in nodes]
|
||||
target = next((n for n in nodes if n[0] == want), None)
|
||||
if target is None or target[2] > MAX_NODE:
|
||||
return names, None
|
||||
need = target[1] + target[2]
|
||||
out = bytearray()
|
||||
for u, c, bf in blocks:
|
||||
if len(out) >= need:
|
||||
break
|
||||
out += _decompress(f.read(c), bf & 0x3F, u)
|
||||
return names, bytes(out[target[1]:need])
|
||||
|
||||
|
||||
def build_scene_count(ggm: bytes) -> int | None:
|
||||
"""Number of scenes in the build settings (BuildSettings.scenes) of a globalgamemanagers file."""
|
||||
try:
|
||||
import UnityPy
|
||||
|
||||
for obj in UnityPy.load(ggm).objects:
|
||||
if obj.type.name == "BuildSettings":
|
||||
return len(obj.read_typetree().get("scenes") or [])
|
||||
except Exception: # noqa: BLE001 - unreadable/unknown version: no answer
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def more_scenes_than_levels(z: zipfile.ZipFile, names: list[str], ggm: bytes | None = None) -> bool:
|
||||
"""Sign B. ggm: the loose globalgamemanagers when already read."""
|
||||
nameset = set(names)
|
||||
levels: set[int] = set()
|
||||
if ggm is None and DATA + "globalgamemanagers" in nameset:
|
||||
ggm = z.read(DATA + "globalgamemanagers")
|
||||
if ggm is not None:
|
||||
levels = {int(m.group(1)) for n in names if (m := LEVEL.match(n))}
|
||||
elif DATA + "data.unity3d" in nameset:
|
||||
try:
|
||||
with z.open(DATA + "data.unity3d") as raw:
|
||||
nodes, ggm = bundle_node(raw)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
levels = {int(m.group(1)) for n in nodes if (m := re.fullmatch(r"level(\d+)", n))}
|
||||
if ggm is None:
|
||||
return False
|
||||
scenes = build_scene_count(ggm)
|
||||
return scenes is not None and scenes > len(levels)
|
||||
|
||||
|
||||
def split_build(z: zipfile.ZipFile, names: list[str], ggm: bytes | None = None, deep: bool = True) -> bool:
|
||||
"""The APK of a Unity split build (its OBB holds the rest of the game). Without `deep` only sign A."""
|
||||
return xr_plugin_without_subsystems(names) or (deep and more_scenes_than_levels(z, names, ggm))
|
||||
@@ -15,6 +15,9 @@ RES_XML_START_ELEMENT = 0x0102
|
||||
RES_XML_END_ELEMENT = 0x0103
|
||||
TYPE_STRING = 0x03
|
||||
TYPE_INT_BOOLEAN = 0x12
|
||||
TYPE_REFERENCE = 0x01
|
||||
TYPE_INT_DEC = 0x10
|
||||
TYPE_INT_HEX = 0x11
|
||||
ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
||||
LAUNCHER = "android.intent.category.LAUNCHER"
|
||||
INFO = "android.intent.category.INFO"
|
||||
@@ -214,6 +217,48 @@ def undeclared_meta_permissions(manifest: bytes) -> list[str]:
|
||||
return [p for p in used if p.startswith(("com.oculus.permission.", "horizonos.permission.")) and p not in declared]
|
||||
|
||||
|
||||
def min_sdk(manifest: bytes) -> int | None:
|
||||
"""<uses-sdk android:minSdkVersion> (None when absent or a preview codename)."""
|
||||
x = Axml(manifest)
|
||||
names = x.strings()
|
||||
for el in x.elements():
|
||||
if el.name != "uses-sdk":
|
||||
continue
|
||||
for a in el.attrs:
|
||||
if a.name < len(names) and names[a.name] == "minSdkVersion":
|
||||
if a.dtype in (TYPE_INT_DEC, TYPE_INT_HEX):
|
||||
return a.value
|
||||
s = x.attr_str(el, "minSdkVersion")
|
||||
return int(s) if s and s.isdigit() else None
|
||||
return None
|
||||
|
||||
|
||||
def meta_data(manifest: bytes) -> dict[str, str | int | bool | None]:
|
||||
"""<meta-data android:name android:value|resource> entries: a string, a bool, an int, or a resource id (int)
|
||||
for references (@string/…), None when the value isn't one of those."""
|
||||
x = Axml(manifest)
|
||||
names = x.strings()
|
||||
out: dict = {}
|
||||
for el in x.elements():
|
||||
if el.name != "meta-data":
|
||||
continue
|
||||
key = x.attr_str(el, "name")
|
||||
if not key:
|
||||
continue
|
||||
value = None
|
||||
for a in el.attrs:
|
||||
if a.name >= len(names) or names[a.name] not in ("value", "resource"):
|
||||
continue
|
||||
if a.raw < len(names) or a.dtype == TYPE_STRING:
|
||||
value = x.attr_str(el, names[a.name])
|
||||
elif a.dtype == TYPE_INT_BOOLEAN:
|
||||
value = a.value != 0
|
||||
elif a.dtype in (TYPE_INT_DEC, TYPE_INT_HEX, TYPE_REFERENCE):
|
||||
value = a.value
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def categories(manifest: bytes) -> set[str]:
|
||||
x = Axml(manifest)
|
||||
return {v for el in x.elements() if el.name == "category" for v in [x.attr_str(el, "name")] if v}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Minimal in-place dex edits: find one method's bytecode and turn a call in it into no-ops (same length), then fix the
|
||||
header's SHA-1 signature and Adler-32 checksum. Nothing moves, so the rest of classes.dex stays byte-identical (no
|
||||
smali round trip)."""
|
||||
"""Minimal in-place dex edits: find one method's bytecode and turn a call in it into no-ops, or make the method return
|
||||
at once (same length), then fix the header's SHA-1 signature and Adler-32 checksum. Nothing moves, so the rest of
|
||||
classes.dex stays byte-identical (no smali round trip)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -8,6 +8,29 @@ import struct
|
||||
import zlib
|
||||
|
||||
INVOKE_VIRTUAL, INVOKE_VIRTUAL_RANGE = 0x6E, 0x74 # 35c / 3rc: 3 code units, method index in the second
|
||||
INVOKES = frozenset(range(0x6E, 0x73)) | frozenset(range(0x74, 0x79)) # invoke-virtual..interface(/range)
|
||||
RETURN_VOID = 0x0E
|
||||
CONST4_V0_0, RETURN_V0 = 0x0012, 0x000F # const/4 v0, #0 ; return v0
|
||||
|
||||
|
||||
def _widths() -> list[int]:
|
||||
"""Code units of each Dalvik opcode (0 = unused/unknown)."""
|
||||
w = [0] * 256
|
||||
spans = [((0x00, 0x00), 1), ((0x01, 0x01), 1), ((0x02, 0x02), 2), ((0x03, 0x03), 3), ((0x04, 0x04), 1),
|
||||
((0x05, 0x05), 2), ((0x06, 0x06), 3), ((0x07, 0x07), 1), ((0x08, 0x08), 2), ((0x09, 0x09), 3),
|
||||
((0x0A, 0x12), 1), ((0x13, 0x13), 2), ((0x14, 0x14), 3), ((0x15, 0x16), 2), ((0x17, 0x17), 3),
|
||||
((0x18, 0x18), 5), ((0x19, 0x1A), 2), ((0x1B, 0x1B), 3), ((0x1C, 0x1C), 2), ((0x1D, 0x1E), 1),
|
||||
((0x1F, 0x20), 2), ((0x21, 0x21), 1), ((0x22, 0x23), 2), ((0x24, 0x26), 3), ((0x27, 0x28), 1),
|
||||
((0x29, 0x29), 2), ((0x2A, 0x2C), 3), ((0x2D, 0x3D), 2), ((0x44, 0x6D), 2), ((0x6E, 0x72), 3),
|
||||
((0x74, 0x78), 3), ((0x7B, 0x8F), 1), ((0x90, 0xAF), 2), ((0xB0, 0xCF), 1), ((0xD0, 0xE2), 2),
|
||||
((0xFA, 0xFB), 4), ((0xFC, 0xFD), 3), ((0xFE, 0xFF), 2)]
|
||||
for (lo, hi), n in spans:
|
||||
for op in range(lo, hi + 1):
|
||||
w[op] = n
|
||||
return w
|
||||
|
||||
|
||||
WIDTHS = _widths()
|
||||
|
||||
|
||||
def _uleb(data: bytes, pos: int) -> tuple[int, int]:
|
||||
@@ -43,11 +66,20 @@ class Dex:
|
||||
cls, _proto, name = struct.unpack_from("<HHI", self.data, self.methods_off + 8 * idx)
|
||||
return self.type_name(cls), self.string(name)
|
||||
|
||||
def return_type(self, idx: int) -> str:
|
||||
proto = struct.unpack_from("<HHI", self.data, self.methods_off + 8 * idx)[1]
|
||||
protos_off = struct.unpack_from("<I", self.data, 76)[0]
|
||||
return self.type_name(struct.unpack_from("<I", self.data, protos_off + 12 * proto + 4)[0])
|
||||
|
||||
def method_index(self, cls: str, name: str) -> list[int]:
|
||||
return [i for i in range(self.methods_n) if self.method(i) == (cls, name)]
|
||||
|
||||
def code_items(self, cls: str, name: str) -> list[int]:
|
||||
"""Offsets of the code items of the methods `name` in class `cls` (direct and virtual)."""
|
||||
return [code for _idx, code in self.class_methods(cls) if self.method(_idx)[1] == name]
|
||||
|
||||
def class_methods(self, cls: str) -> list[tuple[int, int]]:
|
||||
"""(method index, code item offset) of every method of class `cls` that has code."""
|
||||
out = []
|
||||
for c in range(self.classes_n):
|
||||
base = self.classes_off + 32 * c
|
||||
@@ -71,8 +103,8 @@ class Dex:
|
||||
_, pos = _uleb(self.data, pos)
|
||||
code, pos = _uleb(self.data, pos)
|
||||
idx += diff
|
||||
if code and self.method(idx)[1] == name:
|
||||
out.append(code)
|
||||
if code:
|
||||
out.append((idx, code))
|
||||
return out
|
||||
|
||||
def nop_calls(self, code_off: int, targets: set[int]) -> int:
|
||||
@@ -87,6 +119,60 @@ class Dex:
|
||||
done += 1
|
||||
return done
|
||||
|
||||
def invoked(self, code_off: int) -> set[int]:
|
||||
"""Method indices invoked (virtual/super/direct/static/interface, also /range) in one code item."""
|
||||
out: set[int] = set()
|
||||
for _pos, unit in self._insns(code_off):
|
||||
if unit[0] & 0xFF in INVOKES:
|
||||
out.add(unit[1])
|
||||
return out
|
||||
|
||||
def _insns(self, code_off: int):
|
||||
"""(position, code units) of each instruction, decoded in order; stops at payloads or unknown opcodes."""
|
||||
insns_n = struct.unpack_from("<I", self.data, code_off + 12)[0]
|
||||
units = struct.unpack_from(f"<{insns_n}H", self.data, code_off + 16)
|
||||
i = 0
|
||||
while i < insns_n:
|
||||
op = units[i] & 0xFF
|
||||
if op == 0 and units[i] >> 8: # packed-switch / sparse-switch / fill-array-data payload
|
||||
return
|
||||
n = WIDTHS[op]
|
||||
if not n or i + n > insns_n:
|
||||
return
|
||||
yield i, units[i:i + n]
|
||||
i += n
|
||||
|
||||
def return_void(self, code_off: int) -> bool:
|
||||
"""Make a void method return at once (see return_early)."""
|
||||
return self.return_early(code_off, "V")
|
||||
|
||||
def return_early(self, code_off: int, ret: str) -> bool:
|
||||
"""Make a method return at once: its first instruction(s) become `return-void` (ret "V") or `const/4 v0, 0;
|
||||
return v0` (ret Z/B/S/C/I: false / 0), padded with nops to the next instruction boundary. The rest of the code
|
||||
stays as it was (unreachable). False if the method already starts that way, the return type isn't supported
|
||||
(wide, objects) or its first instructions can't be decoded."""
|
||||
if ret == "V":
|
||||
new = [RETURN_VOID]
|
||||
elif ret in ("Z", "B", "S", "C", "I"):
|
||||
if not struct.unpack_from("<H", self.data, code_off)[0]: # registers_size: v0 must exist
|
||||
return False
|
||||
new = [CONST4_V0_0, RETURN_V0]
|
||||
else:
|
||||
return False
|
||||
start = code_off + 16
|
||||
insns_n = struct.unpack_from("<I", self.data, code_off + 12)[0]
|
||||
if list(struct.unpack_from(f"<{min(len(new), insns_n)}H", self.data, start)) == new:
|
||||
return False
|
||||
width = 0
|
||||
for pos, units in self._insns(code_off):
|
||||
width = pos + len(units)
|
||||
if width >= len(new):
|
||||
break
|
||||
if width < len(new):
|
||||
return False
|
||||
struct.pack_into(f"<{width}H", self.data, start, *new, *([0] * (width - len(new))))
|
||||
return True
|
||||
|
||||
def finish(self) -> bytes:
|
||||
self.data[12:32] = hashlib.sha1(bytes(self.data[32:])).digest()
|
||||
struct.pack_into("<I", self.data, 8, zlib.adler32(bytes(self.data[12:])) & 0xFFFFFFFF)
|
||||
|
||||
@@ -304,6 +304,68 @@ def apply_custom(package: str, kind: str, path: str | Path) -> Path:
|
||||
return out
|
||||
|
||||
|
||||
# A Linux app's own icon (GitHub #99: from its .desktop file / AppImage), applied automatically: no PICKED marker, and
|
||||
# APP_ICON records which icon.* it is, so a later pick (store art, the user's own) is told apart from it.
|
||||
APP_ICON = ".app-icon"
|
||||
APP_ICON_MIN_PX = 16
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def icon_source(package: str) -> str:
|
||||
"""What the game's icon artwork is: "custom" (the user's pick, store art or an install link's icon), "app" (the
|
||||
Linux app's own icon, applied automatically) or "generated" (no icon: Steam's is composed or a placeholder). The
|
||||
Frame's agent puts the app's own icon first unless this says "custom"."""
|
||||
d = fetch.artwork_dir(package)
|
||||
icon = next(iter(sorted(d.glob("icon.*"))), None)
|
||||
if icon is None:
|
||||
return "generated"
|
||||
marker = d / APP_ICON
|
||||
try:
|
||||
if marker.read_text(encoding="utf-8").strip() == _sha256(icon):
|
||||
return "app"
|
||||
except OSError:
|
||||
pass
|
||||
return "custom"
|
||||
|
||||
|
||||
def apply_app_icon(package: str, data: bytes) -> Path | None:
|
||||
"""Store a Linux app's own icon (PNG/JPEG bytes) as the game's icon when it has none and nothing was picked (the
|
||||
user's choice and store art always stay). Not marked as the user's pick. Returns the stored file, or None when
|
||||
nothing changed."""
|
||||
import io
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
d = fetch.artwork_dir(package)
|
||||
if (d / fetch.PICKED).exists() or icon_source(package) == "custom":
|
||||
return None
|
||||
try:
|
||||
with Image.open(io.BytesIO(data)) as im:
|
||||
im.load()
|
||||
im = im.copy()
|
||||
except (OSError, UnidentifiedImageError, Image.DecompressionBombError):
|
||||
return None
|
||||
if min(im.size) < APP_ICON_MIN_PX:
|
||||
return None
|
||||
if max(im.size) > 1024:
|
||||
im.thumbnail((1024, 1024), Image.LANCZOS)
|
||||
tmp = d / ".app-icon.tmp"
|
||||
im.convert("RGBA").save(tmp, "PNG", optimize=True)
|
||||
out = d / "icon.png"
|
||||
if out.exists() and out.read_bytes() == tmp.read_bytes():
|
||||
tmp.unlink()
|
||||
return None
|
||||
_drop_kind(d, "icon")
|
||||
tmp.replace(out)
|
||||
(d / APP_ICON).write_text(_sha256(out), encoding="utf-8")
|
||||
return out
|
||||
|
||||
|
||||
def remove_custom(package: str, kind: str) -> None:
|
||||
"""Remove one kind of artwork (the Steam set then composes it from the other kinds, or uses a placeholder)."""
|
||||
if kind not in CUSTOM_KINDS:
|
||||
|
||||
+15
-10
@@ -10,7 +10,7 @@ from .apk.workspace import ApkWorkspace
|
||||
from .core.events import Reporter
|
||||
from .core.models import Analysis, BuildResult, Recipe, SourceGame
|
||||
from .core.paths import work_dir
|
||||
from .patches import base
|
||||
from .patches import base, upstream
|
||||
from .patches.overport import OVERPORT_PATCHES
|
||||
from .tools import overport as overport_tool
|
||||
from .validate.static import check_apk
|
||||
@@ -36,25 +36,28 @@ def overport_ids(recipe: Recipe, alt: bool = False) -> list[str]:
|
||||
return ids
|
||||
|
||||
|
||||
def apk_patches(recipe: Recipe) -> list[base.Patch]:
|
||||
patches = [base.get(pid) for pid in recipe.patches if base.get(pid).stage == "apk"]
|
||||
def apk_patches(selection: dict) -> list[base.Patch]:
|
||||
patches = [base.get(pid) for pid in selection if base.get(pid).stage == "apk"]
|
||||
return sorted(patches, key=lambda p: p.order)
|
||||
|
||||
|
||||
def apply_frame_fixes(apk_in: Path, apk_out_unsigned: Path, analysis: Analysis, recipe: Recipe,
|
||||
reporter: Reporter) -> tuple[list[str], list[dict]]:
|
||||
reporter: Reporter) -> 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))."""
|
||||
applied, checks = [], []
|
||||
with ApkWorkspace(apk_in) as ws:
|
||||
for patch in apk_patches(recipe):
|
||||
selection = upstream.resolve(ws, recipe.patches, reporter)
|
||||
for patch in apk_patches(selection.patches):
|
||||
reporter.check_cancel()
|
||||
ctx = base.ApkContext(ws, analysis, recipe.params(patch.id), reporter, recipe.patches)
|
||||
ctx = base.ApkContext(ws, analysis, selection.patches.get(patch.id) or {}, reporter, selection.patches)
|
||||
if patch.apply(ctx):
|
||||
applied.append(patch.id)
|
||||
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})
|
||||
ws.write(apk_out_unsigned)
|
||||
return applied, checks
|
||||
return applied, checks, selection.superseded
|
||||
|
||||
|
||||
def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path, reporter: Reporter,
|
||||
@@ -73,7 +76,7 @@ def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path,
|
||||
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 = {}
|
||||
all_checks, applied = [], []
|
||||
all_checks, applied, superseded = [], [], {}
|
||||
try:
|
||||
for variant, alt in variants:
|
||||
input_apk = alt_copy if converted and alt and alt_copy.exists() else Path(source.apk)
|
||||
@@ -88,7 +91,9 @@ 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 = apply_frame_fixes(patched, unsigned, analysis, recipe, reporter)
|
||||
applied, checks, left_out = apply_frame_fixes(patched, unsigned, analysis, recipe, reporter)
|
||||
if variant == "primary":
|
||||
superseded = left_out
|
||||
patched.unlink(missing_ok=True)
|
||||
reporter.stage(f"sign ({variant})")
|
||||
final = outdir / (f"{pkg}.apk" if not alt else f"{pkg}.alt-noforcequit.apk")
|
||||
@@ -106,4 +111,4 @@ def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path,
|
||||
primary, alt_apk = results["primary"], results.get("alt")
|
||||
return BuildResult(pkg, primary, alt_apk, sha256(primary), sha256(alt_apk) if alt_apk else None, applied,
|
||||
all_checks, {"overport": overport_ids(recipe), "alt_overport": overport_ids(recipe, True)
|
||||
if alt_apk else None})
|
||||
if alt_apk else None, "superseded": superseded})
|
||||
+47
-1
@@ -229,6 +229,8 @@ def scan(path: Path = typer.Argument(..., help="a folder with game backups (APKs
|
||||
for g in pipeline.add_path(path, rep):
|
||||
r = g["recipe"]
|
||||
typer.echo(f"{g['package']:40} {g.get('title', '')[:34]:34} {r['status']:11} {r['source']}")
|
||||
for warning in pipeline.analysis_warnings(g):
|
||||
typer.echo(f" ! {warning}")
|
||||
|
||||
|
||||
@app.command("catalog-update")
|
||||
@@ -329,6 +331,9 @@ def show(package: str, as_json: bool = typer.Option(False, "--json", help=JSON_H
|
||||
typer.echo(f"{g.get('title')} ({pkg} {a['version']})\n engine {a['engine']}, XR {a['xr']}, {a['graphics']}, "
|
||||
f"ABIs {', '.join(a['abis'])}, direct VrApi: {a['direct_vrapi']}")
|
||||
typer.echo(f" status: {r['status']} recipe source: {r['source']} {r['notes']}")
|
||||
for warning in pipeline.analysis_warnings(g):
|
||||
if warning not in r["notes"]:
|
||||
typer.echo(f" ! {warning}")
|
||||
from .recommend.engine import visible_patches
|
||||
|
||||
shown, hidden = visible_patches(library.analysis_from_dict(a), library.recipe_from_dict(r))
|
||||
@@ -337,6 +342,8 @@ def show(package: str, as_json: bool = typer.Option(False, "--json", help=JSON_H
|
||||
params = r["patches"].get(p.id) or {}
|
||||
typer.echo(f" [{'x' if on else ' '}] {p.id:36} {p.title}{' ' + json.dumps(params) if params else ''}"
|
||||
f"{' — ' + r['reasons'][p.id] if on and p.id in r.get('reasons', {}) else ''}")
|
||||
for pid, fix in ((g.get("build") or {}).get("superseded") or {}).items():
|
||||
typer.echo(f" last build left out {pid}: fixed upstream ({fix})")
|
||||
if hidden and not all_:
|
||||
typer.echo(f" ({len(hidden)} patches hidden as not relevant for this game; --all to list them)")
|
||||
if r.get("alt_patches"):
|
||||
@@ -414,13 +421,20 @@ def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Op
|
||||
no_library: bool = typer.Option(False, help="don't add to the Steam library now"),
|
||||
to: str = typer.Option("frame", help="frame, or pc (PC VR games only: install on this PC)"),
|
||||
apk: Optional[Path] = typer.Option(None, help="install this APK instead of the last build (one game; e.g. "
|
||||
"a test build signed with the game's key)")):
|
||||
"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."""
|
||||
target = _target(frame, password, to)
|
||||
if dest is not None and hasattr(target, "dest"):
|
||||
target.dest = "" if dest == "internal" else dest
|
||||
pkgs = _pkgs(package, all_)
|
||||
if apk and len(pkgs) != 1:
|
||||
raise typer.BadParameter("--apk needs exactly one game")
|
||||
for pkg in pkgs:
|
||||
if not apk_only and pipeline.missing_obb(library.game(pkg) or {}):
|
||||
typer.echo(f"{pkg}: warning: " + pipeline.MISSING_OBB_NOTE.format(package=pkg), err=True)
|
||||
pipeline.install_game(pkg, target, printing_reporter(False), apk_only, add_to_library=False, apk=apk)
|
||||
if not no_library:
|
||||
target.add_to_library(pkgs, printing_reporter(False))
|
||||
@@ -553,6 +567,38 @@ def frame_storage(game: Optional[str] = typer.Option(None, help="also show this
|
||||
typer.echo(f"{t['id']:10} {t['android']:45} {t['path']}" + (" (every app)" if t["shared"] else ""))
|
||||
|
||||
|
||||
@frame_app.command("drives")
|
||||
def frame_drives(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
as_json: bool = typer.Option(False, "--json", help=JSON_HELP)):
|
||||
"""The Frame's drives (internal storage, a microSD card) games can be installed on or moved to."""
|
||||
from .install import drives
|
||||
|
||||
found = _target(frame).drives()
|
||||
if as_json:
|
||||
typer.echo(json.dumps(found, indent=1))
|
||||
return
|
||||
current = drives.install_dest()
|
||||
for d in found:
|
||||
free = drives.free_text(d) or "?"
|
||||
mark = "*" if (d["internal"] and not current) or current in (d["install_dir"], d["path"]) else " "
|
||||
state = f"{d.get('games', 0)} game(s)" if d.get("usable") else f"can't be used: {d.get('reason')}"
|
||||
typer.echo(f"{mark} {d['label']:20} {d.get('fstype') or '':6} {free:>16} {state}")
|
||||
if not d["internal"]:
|
||||
typer.echo(f" {'':20} {d['path']}")
|
||||
typer.echo("* = where new games go (frameport install --dest, or the app's Settings: Frame)")
|
||||
|
||||
|
||||
@frame_app.command("move")
|
||||
def frame_move(package: str = typer.Argument(..., help="an installed game"),
|
||||
to_: str = typer.Option(..., "--to", help="'internal' or a drive's path from 'frameport frame drives'"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""Move an installed game's files to another drive of the Frame (e.g. the microSD card) or back. Saves, the
|
||||
Steam library entry and settings stay as they are."""
|
||||
pkg = _pkgs(package, False)[0]
|
||||
st = _target(frame).move(pkg, to_, printing_reporter())
|
||||
typer.echo(f"moved to {st.get('base') or st.get('to')}")
|
||||
|
||||
|
||||
@frame_app.command("info")
|
||||
def frame_info(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""The Frame's SteamOS build, runtimes, storage and installed games (JSON)."""
|
||||
|
||||
@@ -120,9 +120,10 @@ def _refresh_data_fields(g: dict, a: dict) -> bool:
|
||||
return False
|
||||
from ..analysis import langpacks
|
||||
|
||||
data = g.get("data_dir") if g.get("data_files") is None else None # expansion files only: no packs
|
||||
try:
|
||||
extra.setdefault("lang_packs", langpacks.find_tags(g.get("data_dir")))
|
||||
extra.setdefault("asset_files", langpacks.find_content_files(g.get("data_dir")))
|
||||
extra.setdefault("lang_packs", langpacks.find_tags(data))
|
||||
extra.setdefault("asset_files", langpacks.find_content_files(data))
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -15,11 +15,21 @@ class SourceGame:
|
||||
data_dir: Path | None = None # folder whose contents go to Android/obb/<package>/
|
||||
origin: Path | None = None # the folder the game was found in
|
||||
alt_apks: list[Path] = field(default_factory=list)
|
||||
# only these files of data_dir are the game's data (expansion files found by name, e.g. next to the APK); None =
|
||||
# everything below data_dir
|
||||
data_files: list[str] | None = None
|
||||
|
||||
def data_bytes(self) -> int:
|
||||
if not self.data_dir or not self.data_dir.is_dir():
|
||||
return 0
|
||||
return sum(p.stat().st_size for p in self.data_dir.rglob("*") if p.is_file())
|
||||
return sum(data_manifest(self.data_dir, self.data_files).values())
|
||||
|
||||
|
||||
def data_manifest(data_dir: Path | None, files: list[str] | None = None) -> dict[str, int]:
|
||||
"""{path relative to data_dir: size} of a game's data: every file below data_dir, or only `files` (names in it)."""
|
||||
if not data_dir or not data_dir.is_dir():
|
||||
return {}
|
||||
if files is not None:
|
||||
return {name: (data_dir / name).stat().st_size for name in sorted(files) if (data_dir / name).is_file()}
|
||||
return {p.relative_to(data_dir).as_posix(): p.stat().st_size for p in sorted(data_dir.rglob("*")) if p.is_file()}
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Games on another drive of the Frame (a microSD card, GitHub #90): which drives there are, where new games go
|
||||
(library setting `install.drive`) and moving an installed game. No Flet here (the CLI and GUI share it).
|
||||
|
||||
The agent keeps each game's anchor (launch.sh, deployment.json, artwork) on internal storage and its files in
|
||||
`<drive>/FramePort/<package>`; a drive is given by its install dir (or mount point), internal storage by "internal"."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from ..core import library
|
||||
from ..core.events import Reporter
|
||||
from ..frame.connection import AgentFailed, Frame
|
||||
|
||||
SETTING = "install.drive"
|
||||
INTERNAL = "internal"
|
||||
|
||||
|
||||
def list_drives(frame: Frame) -> list[dict]:
|
||||
"""The agent's `drives`: [{id, path, install_dir, label, fstype, internal, removable, free_bytes, total_bytes,
|
||||
usable, reason, games}]."""
|
||||
return frame.agent("drives")["drives"]
|
||||
|
||||
|
||||
def install_dest() -> str | None:
|
||||
"""Where new games go (None = internal storage)."""
|
||||
value = library.setting(SETTING) or INTERNAL
|
||||
return None if value == INTERNAL else value
|
||||
|
||||
|
||||
def set_install_dest(install_dir: str | None) -> None:
|
||||
library.set_setting(SETTING, install_dir or INTERNAL)
|
||||
|
||||
|
||||
def free_text(d: dict) -> str:
|
||||
""""112.4 GiB free" ("" when unknown)."""
|
||||
from ..i18n import tr
|
||||
|
||||
free = d.get("free_bytes")
|
||||
return tr("{size:.1f} GiB free").format(size=free / 2**30) if isinstance(free, (int, float)) else ""
|
||||
|
||||
|
||||
def drive_text(d: dict) -> str:
|
||||
""""SD Card · 112.4 GiB free" (labels for menus and dropdowns)."""
|
||||
return " · ".join(filter(None, [d.get("label") or d.get("path"), free_text(d)]))
|
||||
|
||||
|
||||
def game_drive(dep: dict | None) -> dict | None:
|
||||
"""{"internal", "path", "label", "missing"} of an installed game (the agent's list_installed entry), or None for
|
||||
agents before v63."""
|
||||
if not dep or "drive" not in dep:
|
||||
return None
|
||||
return {**dep["drive"], "missing": bool(dep.get("drive_missing"))}
|
||||
|
||||
|
||||
def is_on(dep: dict | None, d: dict) -> bool:
|
||||
"""The game (list_installed entry) is on drive d (a `drives` entry)."""
|
||||
g = game_drive(dep)
|
||||
if not g:
|
||||
return d.get("internal", False)
|
||||
return bool(g["internal"]) if d.get("internal") else (not g["internal"] and g["path"] == d["path"])
|
||||
|
||||
|
||||
def move(frame: Frame, package: str, dest: str, reporter: Reporter, poll: float = 2.0,
|
||||
timeout: float = 6 * 3600, sleep=time.sleep) -> dict:
|
||||
"""Move an installed game to `dest` (a drive's install dir or "internal"). The copy runs on the Frame by itself;
|
||||
this follows its progress (a reconnect picks a running move of the same game up again). Cancel works until the
|
||||
copy has started."""
|
||||
reporter.stage("Move game files")
|
||||
st = frame.agent("move_status")
|
||||
if not (st.get("state") == "running" and st.get("package") == package):
|
||||
reporter.check_cancel()
|
||||
try:
|
||||
st = frame.agent("move", package=package, dest=dest)
|
||||
except AgentFailed as exc:
|
||||
# a job that lost the Frame runs again: the move it started may have finished meanwhile
|
||||
if "already there" in str(exc):
|
||||
reporter.check("Game files moved", True, "already there")
|
||||
return {"state": "done", "package": package}
|
||||
raise
|
||||
reporter.log(f"moving {st['from']} → {st['to']} ({st['total_bytes'] / 2**30:.1f} GiB"
|
||||
+ (", same drive" if st.get("same_drive") else "") + ")")
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
st = frame.agent("move_status")
|
||||
if st.get("state") in ("done", "failed"):
|
||||
break
|
||||
total, done = st.get("total_bytes") or 0, st.get("done_bytes") or 0
|
||||
phase = {"copying": "Copying", "verifying": "Checking the copy", "removing": "Removing the old copy"}.get(
|
||||
st.get("phase") or "", "Starting")
|
||||
reporter.progress(done / total if total else 0.0,
|
||||
f"{phase} {done / 2**30:.1f}/{total / 2**30:.1f} GiB" if total else phase)
|
||||
sleep(poll)
|
||||
else:
|
||||
raise RuntimeError("the move didn't finish in time; it may still be running on the Frame")
|
||||
if st.get("state") != "done":
|
||||
raise RuntimeError(f"moving failed: {st.get('error') or 'unknown error'} (the game stays where it was)")
|
||||
reporter.check("Game files moved", True, st.get("base") or st.get("to") or "")
|
||||
return st
|
||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
from ..artwork import fetch as artwork
|
||||
from ..build import sha256
|
||||
from ..core.events import Reporter
|
||||
from ..core.models import Recipe
|
||||
from ..core.models import Recipe, data_manifest
|
||||
from ..frame.connection import Frame, sh_quote
|
||||
from ..patches import base
|
||||
from ..patches.settings import adapter_settings
|
||||
@@ -25,6 +25,7 @@ class InstallPlan:
|
||||
recipe: Recipe
|
||||
apk_only: bool = False # reuse the data already on the Frame
|
||||
dest: str | None = None
|
||||
data_files: list[str] | None = None # only these files of data_dir (SourceGame.data_files); None = all of it
|
||||
|
||||
|
||||
def install_context(recipe: Recipe) -> base.InstallContext:
|
||||
@@ -38,10 +39,8 @@ def install_context(recipe: Recipe) -> base.InstallContext:
|
||||
return ctx
|
||||
|
||||
|
||||
def local_data_manifest(data_dir: Path | None) -> dict[str, int]:
|
||||
if not data_dir or not data_dir.is_dir():
|
||||
return {}
|
||||
return {p.relative_to(data_dir).as_posix(): p.stat().st_size for p in sorted(data_dir.rglob("*")) if p.is_file()}
|
||||
def local_data_manifest(data_dir: Path | None, files: list[str] | None = None) -> dict[str, int]:
|
||||
return data_manifest(data_dir, files)
|
||||
|
||||
|
||||
class Speed:
|
||||
@@ -74,7 +73,7 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
|
||||
if not prep.get("lepton"):
|
||||
raise RuntimeError("Lepton is not installed on the Frame (Setup → Install Lepton)")
|
||||
incoming = prep["incoming"]
|
||||
manifest = {} if plan.apk_only else local_data_manifest(plan.data_dir)
|
||||
manifest = {} if plan.apk_only else local_data_manifest(plan.data_dir, plan.data_files)
|
||||
existing = prep["existing_obb"]
|
||||
to_send = [rel for rel, size in manifest.items() if existing.get(rel) != size]
|
||||
need = sum(manifest[r] for r in to_send) + (0 if prep["same_apk"] else plan.apk.stat().st_size)
|
||||
@@ -228,6 +227,8 @@ class LinuxPlan:
|
||||
appimage: bool = False
|
||||
openxr: bool = False
|
||||
x86_64: bool = False # runs through FEX (installed on the Frame first, like Proton)
|
||||
dest: str | None = None # a drive's install dir for a new install (GitHub #90); None = internal storage
|
||||
desktop_entry: bool = True # an entry in Desktop Mode's menu and on its desktop (GitHub #84)
|
||||
|
||||
|
||||
def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
@@ -236,7 +237,7 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
if plan.x86_64:
|
||||
ensure_proton(frame, reporter, kind="linux_x86")
|
||||
reporter.stage("Prepare Frame")
|
||||
prep = frame.agent("prepare_linux", package=plan.package, title=plan.title)
|
||||
prep = frame.agent("prepare_linux", package=plan.package, title=plan.title, dest=plan.dest)
|
||||
if plan.files:
|
||||
manifest = {name: (plan.root / name).stat().st_size for name in plan.files}
|
||||
else:
|
||||
@@ -259,8 +260,13 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
executables = [rel for rel in manifest if rel != plan.exe and _is_elf(plan.root / rel)][:200]
|
||||
result = frame.agent("finalize_linux", package=plan.package, title=plan.title, exe=plan.exe,
|
||||
appimage=plan.appimage, openxr=plan.openxr, x86_64=plan.x86_64, manifests={"app": manifest},
|
||||
executables=executables, tags=_tags(plan.package), timeout=900)
|
||||
executables=executables, tags=_tags(plan.package), dest=plan.dest,
|
||||
desktop_entry=plan.desktop_entry, timeout=900)
|
||||
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
|
||||
apply_app_icon(frame, plan.package, prep.get("anchor"), result, reporter)
|
||||
if result.get("desktop_entry"):
|
||||
reporter.check("Desktop Mode entry", True, "in the application menu"
|
||||
+ (" and on the desktop" if result["desktop_entry"].get("desktop") else ""))
|
||||
return result
|
||||
|
||||
|
||||
@@ -426,9 +432,46 @@ def upload_steam_art(frame: Frame, package: str, base: str, reporter: Reporter)
|
||||
frame.run(f"rm -rf {sh_quote(remote_art)} && mkdir -p {sh_quote(remote_art)}")
|
||||
for kind, f in art.items():
|
||||
frame.put(f, posixpath.join(remote_art, f"{kind}{f.suffix}"), resume=False)
|
||||
send_icon_source(frame, package, remote_art)
|
||||
reporter.check("Steam artwork", bool(art) or None, ", ".join(sorted(art)) or "none found")
|
||||
|
||||
|
||||
def send_icon_source(frame: Frame, package: str, remote_art: str) -> None:
|
||||
"""Tell the agent what the art set's icon is (artwork/.icon-source, agent v64): a Linux app's own icon is used
|
||||
for its Desktop Mode entry and Steam shortcut unless the user chose one ("custom")."""
|
||||
from ..artwork.sources import icon_source
|
||||
|
||||
frame.run(f"printf %s {sh_quote(icon_source(package))} > {sh_quote(posixpath.join(remote_art, '.icon-source'))}")
|
||||
|
||||
|
||||
def apply_app_icon(frame: Frame, package: str, anchor: str | None, result: dict, reporter: Reporter) -> None:
|
||||
"""A Linux app's own icon the agent found in its files (GitHub #99) becomes the library's icon when the game has
|
||||
none (no user pick, no store icon); the Frame's art set is then sent again so Steam's art shows it too."""
|
||||
import base64
|
||||
|
||||
from ..artwork import sources, steam
|
||||
|
||||
icon = result.get("app_icon") or {}
|
||||
if icon:
|
||||
reporter.log(f"the app's own icon: {icon.get('file')}")
|
||||
if not icon.get("png") or not anchor:
|
||||
return
|
||||
try:
|
||||
stored = sources.apply_app_icon(package, base64.b64decode(icon["png"]))
|
||||
if not stored:
|
||||
return
|
||||
steam.ensure_cover(package)
|
||||
art = steam.steam_set_for(package)
|
||||
except Exception as exc: # noqa: BLE001 - artwork is optional
|
||||
reporter.log(f"the app's own icon wasn't used for the library: {exc}")
|
||||
return
|
||||
remote = posixpath.join(anchor, "artwork")
|
||||
for kind, f in art.items():
|
||||
frame.put(f, posixpath.join(remote, f"{kind}{f.suffix}"), resume=False)
|
||||
send_icon_source(frame, package, remote)
|
||||
reporter.check("App icon", True, "the app's own icon is used for the library")
|
||||
|
||||
|
||||
def update_steam_art(frame: Frame, package: str, reporter: Reporter) -> dict:
|
||||
"""Replace an installed game's Steam library art with the current artwork (after the user picked new art):
|
||||
the art set goes to the game's anchor, then the shortcut is rewritten (Steam restarts once)."""
|
||||
@@ -447,6 +490,7 @@ def update_steam_art(frame: Frame, package: str, reporter: Reporter) -> dict:
|
||||
frame.run(f"rm -rf {sh_quote(remote)} && mkdir -p {sh_quote(remote)}")
|
||||
for kind, f in art.items():
|
||||
frame.put(f, posixpath.join(remote, f"{kind}{f.suffix}"), resume=False)
|
||||
send_icon_source(frame, package, remote)
|
||||
reporter.check("Steam artwork", True, ", ".join(sorted(art)))
|
||||
return add_to_steam(frame, [package], reporter)
|
||||
|
||||
|
||||
@@ -19,9 +19,11 @@
|
||||
" · link": "",
|
||||
" · not installed yet": "",
|
||||
" · on the Frame: ": "",
|
||||
" · on {label}": "",
|
||||
" · restart the Frame soon to reset it": "",
|
||||
" · Revive provides the Oculus runtime": "",
|
||||
" · {human} in files": "",
|
||||
" · {label} not inserted": "",
|
||||
" · {queued} more queued": "",
|
||||
" · {value:.0f} MiB": "",
|
||||
" · {value:.1f} GiB": "",
|
||||
@@ -90,6 +92,7 @@
|
||||
"Add {name}": "",
|
||||
"Added automatically (engine, VR API or store genre)": "",
|
||||
"Added automatically when needed": "",
|
||||
"Added to Desktop Mode": "",
|
||||
"Added {n} game": [
|
||||
"",
|
||||
""
|
||||
@@ -101,6 +104,7 @@
|
||||
"Adds a small piece of Meta's platform library that some games need to start.": "",
|
||||
"Adds no-op com.oculus.os.AnalyticsEvent / UnifiedTelemetryLogger classes for native code that looks them up through the app class loader (e.g. Nano, NOPE Challenge).": "",
|
||||
"Adds OVRPort's OpenXR loader dispatcher and platform loader. Required: without it nothing is translated.": "",
|
||||
"Adds the app to Desktop Mode's application menu and puts an icon on its desktop. Some apps work better there, with a mouse and keyboard, than in Gaming Mode, where Steam Input turns the controllers into a gamepad.": "",
|
||||
"Adds the parts that let a Quest game talk to the Steam Frame. Needed for every Quest game.": "",
|
||||
"Adds the VR app metadata other launchers expect.": "",
|
||||
"Advanced": "",
|
||||
@@ -130,6 +134,7 @@
|
||||
"Android app (APK)": "",
|
||||
"Android app without VR: installed unchanged and shown as a flat window": "",
|
||||
"Android VR": "",
|
||||
"Animate fingers from touch": "",
|
||||
"Another app": "",
|
||||
"Answer the open install question first": "",
|
||||
"Any": "",
|
||||
@@ -166,6 +171,7 @@
|
||||
"Build page": "",
|
||||
"Cable connected.": "",
|
||||
"Camera": "",
|
||||
"can't hold games: {reason}": "",
|
||||
"Can't reach your Frame. Make sure it's on, awake and on the same network, then try again.": "",
|
||||
"Can't run": "",
|
||||
"Can't use that link: {reason}": "",
|
||||
@@ -235,6 +241,7 @@
|
||||
"Controllers": "",
|
||||
"Converts Quest games to OpenXR": "",
|
||||
"Copied": "",
|
||||
"Copies the game's files, data and saves to the other drive, checks the copy and then removes the old one. The Steam entry, settings and saves stay valid. The game must be closed. A game on a microSD card only starts while the card is inserted.": "",
|
||||
"Copy": "",
|
||||
"Copy all": "",
|
||||
"Copy failed; select the text instead": "",
|
||||
@@ -247,6 +254,7 @@
|
||||
"Copy PID {pid}": "",
|
||||
"Couldn't check this PC": "",
|
||||
"Couldn't connect: {exc}": "",
|
||||
"Couldn't list the Frame's drives: {error}": "",
|
||||
"Couldn't list the screenshots: {exc}": "",
|
||||
"Couldn't list {path}: {exc}": "",
|
||||
"couldn't load the full image: {exc}": "",
|
||||
@@ -286,9 +294,11 @@
|
||||
"Delete…": "",
|
||||
"Deselect": "",
|
||||
"Desktop": "",
|
||||
"Desktop Mode": "",
|
||||
"Details": "",
|
||||
"Dev builds let you test fixes before they're released. They're less tested than releases and may have bugs. You get the next release as a normal update (you have {version}).": "",
|
||||
"Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on your network and connect to it over SSH. The first-time setup command turns it on for you.": "",
|
||||
"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: 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.": "",
|
||||
"Didn't finish ({value}): {error}": "",
|
||||
"Disable application space warp if used": "",
|
||||
@@ -351,6 +361,7 @@
|
||||
"Environment variables exported by the launcher (e.g. VK_INSTANCE_LAYERS=\"\" to test without Valve's layers).": "",
|
||||
"Environment variables for the Proton launcher on the Frame (e.g. DXVK_HUD=fps), one KEY=value per line.": "",
|
||||
"Every 'local' play space the game creates lines up with the one at the start. For games whose menus or screens jump to where you look on the Frame.": "",
|
||||
"Every 5 s, logs per located space how far the requested times are from the frame's predicted display time, and the runtime clock's offset from the monotonic clock. No effect on the game.": "",
|
||||
"Every game start on the Frame used to leak one kernel key, and after about 200 launches every game fails to start. FramePort switches that leak off, but keys already used only come back after a restart of the Frame.": "",
|
||||
"Every N seconds, saves the left-eye image the game submits (a quarter of its size) as fb_snap_0-7.ppm in the game's files folder on the Frame. Launch tests run without anyone wearing the headset, so the headset view stays black; this shows what the game itself draws. OpenGL ES games only.": "",
|
||||
"Every uploaded file is present with the right size.": "",
|
||||
@@ -360,6 +371,7 @@
|
||||
"Executable": "",
|
||||
"experimental": "",
|
||||
"Experimental Vulkan mutable-format workaround.": "",
|
||||
"Experimental: shows HUD, menu and other flat panels that stay black because the Frame's graphics driver skips them (e.g. Doom3Quest's HUD and PDA).": "",
|
||||
"Extra diagnostics (log)": "",
|
||||
"Extra launch environment (Frame)": "",
|
||||
"Extra Lepton environment": "",
|
||||
@@ -387,6 +399,7 @@
|
||||
"Fix crash when the game prepares the microphone": "",
|
||||
"Fix depth flicker (Unreal Vulkan)": "",
|
||||
"Fix freezes when controllers vibrate": "",
|
||||
"Fix hands lagging behind controllers": "",
|
||||
"Fix launcher icon entry": "",
|
||||
"Fix minimal Android SDK": "",
|
||||
"Fix UE4 crash with Unity stub": "",
|
||||
@@ -408,6 +421,8 @@
|
||||
"For games that judder or shimmer while you hold your head still (e.g. I Am Cat).": "",
|
||||
"For games that pause or recenter when the headset briefly loses focus.": "",
|
||||
"For games that show the headset's own controllers. Takes effect after a reinstall.": "",
|
||||
"For games whose hands trail the controllers when you move them (e.g. BattleSisters).": "",
|
||||
"For games whose hands' thumbs never move when you touch the buttons or thumbstick (e.g. Vader Immortal).": "",
|
||||
"For games whose menus or screens jump to wherever you look.": "",
|
||||
"For heavy games that use application space warp: it causes artifacts or hangs on non-Quest runtimes (e.g. Asgard's Wrath 2, Batman: Arkham Shadow).": "",
|
||||
"For mixed-reality-only games. On the Frame, passthrough is emulated by the FrameBridge adapter (greyscale cameras).": "",
|
||||
@@ -472,6 +487,7 @@
|
||||
"Game config files": "",
|
||||
"Game configs are up to date": "",
|
||||
"Game configs from GitHub: last checked {when}": "",
|
||||
"Game data (.obb) not found": "",
|
||||
"game data (OBB)": "",
|
||||
"Game launch capacity": "",
|
||||
"Game not found": "",
|
||||
@@ -494,6 +510,7 @@
|
||||
"Gives older Quest games a newer version of Meta's VR plugin that can be converted.": "",
|
||||
"GL shim (Mesa GLSL compatibility)": "",
|
||||
"GL shim for Unity (no multisampled render-to-texture)": "",
|
||||
"GL shim for Unreal (no multisampled render-to-texture)": "",
|
||||
"GL shim only: hide GL_EXT_multisampled_render_to_texture(2) (Zink crashes rendering Unity's runtime MSAA eye buffer through it, e.g. The Room VR).": "",
|
||||
"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 (e.g. Path of the Warrior).": "",
|
||||
"GL shim: hide multisampled render-to-texture": "",
|
||||
@@ -534,7 +551,10 @@
|
||||
"If Windows asked whether FramePort may use the network, allow it (Private networks).": "",
|
||||
"If you know the Frame's address.": "",
|
||||
"Ignore focus dips up to": "",
|
||||
"ILMxLAB's Unreal games (e.g. Vader Immortal) bind their controls through URPOCKeyMapManagerComponent, which for an Oculus headset picks the Quest (\"SantaCruz\") or the Gear VR key set by IsRunningOnSantaCruz. On the Frame it picks Gear VR, which is empty in a Quest build: grip, trigger and touch reach Unreal but never the game's hands (grabbing does nothing). Removes the branch to the Gear VR set in the Oculus case of the key selector both action and axis mappings use. Found by Klownicle (GitHub #49).": "",
|
||||
"ILMxLAB's Unreal games (e.g. Vader Immortal) wait on their loading card until UVRUtils::GetQuestShaderPrecompilePercent reaches 100 %. The precompile only starts on a Quest (IsRunningOnSantaCruz); on any other device the function returns 0.0, so the loading card (a portrait with a progress bar) never ends. Makes that non-Quest branch return 1.0. The Quest branch and the game's normal shader compiling stay as they are. Found by Klownicle (GitHub #49).": "",
|
||||
"Image for the {label}": "",
|
||||
"In the menu and on the desktop": "",
|
||||
"In your Steam library": "",
|
||||
"In your Steam library · launch settings changed — update it": "",
|
||||
"Install": "",
|
||||
@@ -548,6 +568,7 @@
|
||||
"Install link: {host}": "",
|
||||
"Install links": "",
|
||||
"Install missing": "",
|
||||
"Install new games to": "",
|
||||
"Install on Frame": "",
|
||||
"Install on this PC": "",
|
||||
"Install Proton on the Frame": "",
|
||||
@@ -570,6 +591,7 @@
|
||||
"Installs the app as it is, unpacked on the Frame, with an entry in its Steam library": "",
|
||||
"Installs the app as it is, with an entry in the Frame's Steam library": "",
|
||||
"Installs the game exactly as it is: no patches (your copy is already patched)": "",
|
||||
"Internal storage": "",
|
||||
"into {value}": "",
|
||||
"It belongs to a running game, which may stop with it.": "",
|
||||
"It can't be reached (asleep, turned off or out of Wi-Fi). Wake it or turn it on: FramePort continues by itself, and uploads pick up where they stopped.": "",
|
||||
@@ -582,6 +604,9 @@
|
||||
"It will be closed without saving. Continue?": "",
|
||||
"It won't start on the Frame: SteamOS doesn't have these libraries and the app doesn't bring them. Look for a build of the app that includes them, then install that one.": "",
|
||||
"It works: share…": "",
|
||||
"it's here now": "",
|
||||
"its drive": "",
|
||||
"Its manifest asks for Android {version} (API {api}); the Frame's Android is 11 (API 30). Apps like this often crash at start, but not always: try it, a launch test tells.": "",
|
||||
"Java runtime": "",
|
||||
"Java, OVRPort and apksigner are ready.": "",
|
||||
"just now": "",
|
||||
@@ -619,15 +644,19 @@
|
||||
"Lepton's Android has no clipboard service, and SDL's Java code (SDL2 and LÖVE games, e.g. Dramatic Shape) registers a clipboard listener at start without checking, so the app crashes with a NullPointerException in SDLClipboardHandler. That call is skipped (an in-place edit of classes.dex).": "",
|
||||
"Lets games made for Oculus PCs run on SteamVR.": "",
|
||||
"Lets games start that would stop on Meta's avatar system (e.g. BlazeRush); no Meta avatars.": "",
|
||||
"Lets games with Vivox voice chat start on the Frame (they'd crash on an Android 12 audio call).": "",
|
||||
"Lets games written for newer OpenXR versions run on the Frame.": "",
|
||||
"Lets mixed-reality games start even though they insist on a camera view of your room.": "",
|
||||
"Lets older Unity games start VR without Meta's system apps (they'd stay on a 2D screen).": "",
|
||||
"Lets older Unreal games start VR although the converted VR plugin lacks a few functions they look for (e.g. Star Wars Pinball VR).": "",
|
||||
"Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboard).": "",
|
||||
"Lets Team Beef ports (e.g. Lambda1VR) start on the Frame and use their Meta Quest setup.": "",
|
||||
"Lets the game find content it keeps in separate files next to its data (e.g. Star Wars Tales' seasons).": "",
|
||||
"Lets the game find language files (like de.lang) that are already in its data, instead of waiting for a download that never comes.": "",
|
||||
"Lets the game leave its loading screen: it waited for a Quest-only step that never starts on the Frame (e.g. Vader Immortal).": "",
|
||||
"Lets typing reach this app: Steam's on-screen keyboard or your computer's keyboard (VR is unaffected).": "",
|
||||
"Lets Unity games accept a headset that isn't a Quest.": "",
|
||||
"Lets Unity start the graphics itself instead of Stress Level Zero's plugin, which crashes on the Frame (e.g. BONELAB 1.2974).": "",
|
||||
"Lets Unreal games accept a headset that isn't a Quest.": "",
|
||||
"Lets Unreal games accept a headset that isn't an Oculus Rift.": "",
|
||||
"Lets you read logs/attach. Some Unreal games abort under CheckJNI when debuggable; the Frame 'nodebug' fix undoes it.": "",
|
||||
@@ -644,13 +673,16 @@
|
||||
"Live view": "",
|
||||
"Loading…": "",
|
||||
"Loads FramePort's OpenXR layer under Proton on the Frame. The Frame's SteamVR runtime only accepts OpenXR 1.0 apps, but Proton's VR helper asks for 1.1, so without the layer VR never starts (the game shows as a flat window, or Revive fails with 'Unable to load LibOVRRT DLL'); the layer retries as 1.0. It also emulates xrConvertTimespecTimeToTimeKHR if a runtime refuses it. Ignored on this PC.": "",
|
||||
"Loads FramePort's shader-fix Vulkan layer into an OpenGL ES game. On the Frame, OpenGL ES runs on Zink (Mesa's GL on Vulkan); the layer sits between Zink and the driver and inserts the words of the adapter setting zink_shader_fix into the SPIR-V modules whose size and SHA-256 match (every other module is untouched), e.g. stores that set loop counters and accumulators a shader reads before writing, which hang the GPU (Vader Immortal's lightspeed jump; found by Klownicle, GitHub #49). Does nothing without zink_shader_fix (or zink_shader_dump) in the recipe. The layer adds itself to Android's Vulkan debug layer list for the game's process only.": "",
|
||||
"Loads libglshim.so ahead of libEGL in every engine library that fetches GL through eglGetProcAddress. Fixes Quest-only leniency that Mesa/Zink rejects: #pragma before #extension, implicit int/float conversions (enables GL_EXT_shader_implicit_conversions), and multiview shaders used on single-view framebuffers (hides GL_OVR_multiview; gl_hide_multiview=0 turns that off). Logs every failed shader with its source lines (logcat tag GLShim). Symptom: black screen with working audio in a GLES game.": "",
|
||||
"Loads the GL shim into a Unity GLES game and hides GL_EXT_multisampled_render_to_texture (and the multiview variant). Some Unity games switch their eye buffer to 4x MSAA at runtime; the Frame only gives single-sampled swapchains, Unity then renders through multisampled render-to-texture and Mesa/Zink crashes (SIGSEGV in libgallium_dri.so on the render thread: grey or frozen screen, e.g. The Room VR). Hidden, Unity uses an ordinary MSAA buffer and resolves it. Unity's multiview stays on (gl_hide_multiview defaults to 0 here).": "",
|
||||
"Loads the GL shim into an Unreal GLES game and hides GL_EXT_multisampled_render_to_texture. Unreal's mobile MSAA renders through multisampled render-to-texture, and the Frame's GL driver (Mesa/Zink) can crash on it: SIGSEGV with fault address 0x10000 in libgallium_dri.so on the RHIThread a few seconds after start (Zink looks up a render-pass cache slot past its end, e.g. Star Wars Pinball VR). Hidden, Unreal renders without that path. Unreal's multiview stays on: Unreal only uses it when GL_OVR_multiview_multisampled_render_to_texture is listed too, so for Unreal the shim keeps that one visible and draws its multiview MSAA attachments single-sampled (glFramebufferTextureMultiviewOVR).": "",
|
||||
"Locations": "",
|
||||
"Logo": "",
|
||||
"Logs details for debugging: composition layers and swapchains, focus changes, play-space creation, pointer vs grip poses, refresh rates. No effect on the game.": "",
|
||||
"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 (e.g. Vader Immortal after its intro). Changes nothing: every call goes to the real function.": "",
|
||||
"Longer for headsets that briefly think they're off your head.": "",
|
||||
"Looking for drives…": "",
|
||||
"Looking for Frames on your network…": "",
|
||||
"Looking for the Frame on a USB cable (Developer Mode on, USB-C port connected to this computer).": "",
|
||||
"Looking for your Frame…": "",
|
||||
@@ -658,9 +690,11 @@
|
||||
"Low": "",
|
||||
"macOS' firewall is on: allow incoming connections when macOS asks about FramePort (or System Settings → Network → Firewall → Options).": "",
|
||||
"Make Unity text fields work without a system keyboard": "",
|
||||
"Makes grip, trigger and touch controls reach the game's hands (it picked the empty Gear VR controls on the Frame, e.g. Vader Immortal).": "",
|
||||
"Makes Meta's 3D audio library work outside Quest headsets.": "",
|
||||
"Makes sure the game has an entry that can be started.": "",
|
||||
"Makes the game start from the Frame's Steam library.": "",
|
||||
"Makes the hands' thumbs follow what your thumb touches (they always pointed up: the Frame doesn't sense a thumb hovering, e.g. Vader Immortal).": "",
|
||||
"Makes Unity's Oculus checks pass on other runtimes.": "",
|
||||
"Makes Unreal's Oculus checks pass on other runtimes.": "",
|
||||
"Mark application as debuggable": "",
|
||||
@@ -677,6 +711,7 @@
|
||||
"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\", e.g. BlazeRush). Replaces it with a library that does nothing, so the game runs without Meta avatars.": "",
|
||||
"Meta's OVRManager switches Unity to the headset's recommended MSAA level (4x) while the game runs, whatever its quality settings say (log: \"Switching to the recommended level\"). Multisampled render-to-texture on GLES can hang the Frame's GPU, up to a restart of the whole headset (e.g. Lucky's Tale in a menu) or a crash of its GL driver (e.g. The Room VR, whose own code sets 4x). Rewrites OVRDisplay.recommendedMSAALevel -> 0 and makes QualitySettings.antiAliasing's setter do nothing in libil2cpp.so (found with Cpp2IL), so MSAA stays off.": "",
|
||||
"Meta's OVROverlay copies a texture into its own compositor layer every frame. For some games (e.g. The Room VR's screen fade, a 4x4 overlay) that copy crashes the Frame's GL driver: SIGSEGV in libgallium_dri.so on Unity's render thread right after a tiny xrCreateSwapchain, grey or frozen screen. Rewrites OVROverlay.PopulateLayer -> false in libil2cpp.so (found with Cpp2IL): overlays are skipped (fades become instant cuts, overlay splash screens don't show). Only for games with this crash: menus drawn as overlays would disappear too.": "",
|
||||
"Meta's OVRPlugin reports a thumb (or index finger) resting near the controller through XR_FB_touch_controller_proximity, which the Frame's runtime lacks: \"near\" stays false and games that animate the hands from it show a thumb that never moves (e.g. Vader Immortal; found by Klownicle, GitHub #49). Binds the game's proximity actions to the capacitive touch inputs instead: 1 = thumb (thumbstick, face buttons or thumb rest touched), 2 = also the index finger (trigger touched). Only when the runtime lacks the extension.": "",
|
||||
"MiB": "",
|
||||
"Minecraft asks for desktop OpenGL's GL_DEPTH_COMPONENT32 depth textures. Some builds of LTW, the OpenGL-to-OpenGL ES wrapper in Minecraft launchers such as QuestCraft, pass that on unchanged; Quest's driver accepts it, the Frame's Mesa rejects it (GL_INVALID_OPERATION in glTexImage2D), so the depth buffer never exists and the picture stays black. LTW then loads OpenGL ES through a small shim that asks for GL_DEPTH_COMPONENT32F (or 24-bit) instead.": "",
|
||||
"Mirrored": "",
|
||||
@@ -686,9 +721,14 @@
|
||||
"Monitor": "",
|
||||
"Monitor is using {pct} % of the Frame's CPU": "",
|
||||
"More actions": "",
|
||||
"Move": "",
|
||||
"Move to…": "",
|
||||
"Move {title} to {label}": "",
|
||||
"Move {title} to…": "",
|
||||
"Moves where the pointer starts, forward or back.": "",
|
||||
"Moves where the pointing ray starts forward (+) or back (−).": "",
|
||||
"Multiplies the recommended eye-buffer width and height (0.5–2.0). 1.5 ≈ 2.25× pixels.": "",
|
||||
"Multiview interposer: diagnostics": "",
|
||||
"Must be at least the video's own frame rate.": "",
|
||||
"Mutable swapchain fix": "",
|
||||
"Name": "",
|
||||
@@ -702,6 +742,7 @@
|
||||
"New folder": "",
|
||||
"New folder…": "",
|
||||
"New Frame? Run one command on it and FramePort does the rest. Did this once already? Your Frame appears under step 2.": "",
|
||||
"New games go to {label}": "",
|
||||
"New name": "",
|
||||
"Next": "",
|
||||
"No": "",
|
||||
@@ -734,6 +775,8 @@
|
||||
"Not enough space on the Frame ({detail}). Remove games you don't play or use Free up space on the Steam Frame page.": "",
|
||||
"Not from a FramePort game": "",
|
||||
"Not installed": "",
|
||||
"Not needed in the last build, OVRPort fixed it itself: {names}": "",
|
||||
"Not needed in the last build: OVRPort fixed this itself.": "",
|
||||
"Not now": "",
|
||||
"Not offered by Steam on this Frame yet": "",
|
||||
"Not recommended: breaks games that load VrApi through OVRPlugin.": "",
|
||||
@@ -748,6 +791,7 @@
|
||||
"Nothing running. Installs, launch tests and downloads show up here.": "",
|
||||
"Nothing to install": "",
|
||||
"Nothing to install on the Frame — those Oculus games need PC mode (SteamVR + Revive).": "",
|
||||
"Nothing to install: add the games' .obb files first": "",
|
||||
"Nothing to patch: it runs as is.": "",
|
||||
"Now {scale:.0%}.": "",
|
||||
"Now {scale:.0%}. Changes apply the next time FramePort starts.": "",
|
||||
@@ -756,6 +800,7 @@
|
||||
"Oculus game — needs Revive, which doesn't run on the Frame. Play it on this PC (SteamVR).": "",
|
||||
"Oculus OS telemetry class stubs": "",
|
||||
"Oculus XR Plugin games render both eyes in one pass (multiview) on Android. If one eye shows only effects or grey (e.g. I Am Cat), rendering each eye in its own pass can fix it, at some GPU cost. Rewrites OculusSettings.GetStereoRenderingMode and its inlined read in OculusLoader.Initialize -> MultiPass in libil2cpp.so (found with Cpp2IL). Try it when one eye is wrong.": "",
|
||||
"Off": "",
|
||||
"Off: these links open whatever app is set up for them, if any.": "",
|
||||
"off: {names}": "",
|
||||
"Offline": "",
|
||||
@@ -773,6 +818,7 @@
|
||||
"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 install software from sites you trust. FramePort downloads it from {host} and installs it on your Frame.": "",
|
||||
"Only internal storage": "",
|
||||
"Open": "",
|
||||
"Open \"Install with FrameDrop\" buttons (framedrop:// links) in FramePort": "",
|
||||
"Open frameport:// links in FramePort": "",
|
||||
@@ -781,6 +827,10 @@
|
||||
"Open viewer": "",
|
||||
"Open {scheme}:// links with FramePort?": "",
|
||||
"OpenGL ES games run on the Frame through Zink (OpenGL on top of Vulkan), which is stricter than Quest drivers. Vulkan games run natively.": "",
|
||||
"OpenGL ES shader dump (diagnostics)": "",
|
||||
"OpenGL ES shader fixes": "",
|
||||
"OpenGL ES: multiview shaders on flat panels (HUD, menus)": "",
|
||||
"OpenGL ES: shader fixes (Vulkan layer under Zink)": "",
|
||||
"Opens a prefilled GitHub issue with this game's recipe, so it can join the built-in catalog (no account token needed; you review and submit it on GitHub). No game files or personal data are sent.": "",
|
||||
"Opens the app straight in VR, skipping its flat launcher screen (turn off to use the launcher, e.g. to import new content).": "",
|
||||
"Opens this player's storage on the Frame (Files tab): upload videos into the folder it lists": "",
|
||||
@@ -845,6 +895,8 @@
|
||||
"Pointer start": "",
|
||||
"Pointer turn": "",
|
||||
"Points Oculus games at Revive on the Frame.": "",
|
||||
"Pose time diagnostics (log)": "",
|
||||
"Poses a game asks for at a CLOCK_MONOTONIC time, or more than 0.5 s before the frame's display time, are located at the same moment in the runtime's time (or now). Meta's OVRPlugin passes the monotonic clock on as the OpenXR time, which on the Frame runs seconds behind the runtime's clock, so its \"now\" poses (Unity's physics step, controller poses) came from the past: hands trailing the controllers (e.g. BattleSisters, Sniper Elite VR).": "",
|
||||
"Power": "",
|
||||
"Power ICs": "",
|
||||
"Prepare {title}": "",
|
||||
@@ -908,6 +960,7 @@
|
||||
"Remove VrApi library": "",
|
||||
"Remove XR_KHR_composition_layer_depth chains (diagnostic).": "",
|
||||
"Remove {title} from the library?": "",
|
||||
"Removed from Desktop Mode": "",
|
||||
"Removed the converted copies ({size})": "",
|
||||
"Removed {title}": "",
|
||||
"Removed {title} and deleted its files on this PC ({size})": "",
|
||||
@@ -928,6 +981,7 @@
|
||||
"Rename": "",
|
||||
"Rename {name}": "",
|
||||
"Rename…": "",
|
||||
"Repairs shaders that hang the graphics chip at certain effects, from the game's recipe (e.g. Vader Immortal's lightspeed jump).": "",
|
||||
"Repeat xrLocateViews queries for the same display time get the first, fully tracked answer again. Games that ask several times per frame got slightly different poses and rendered parts of the frame with different heads: judder (e.g. I Am Cat; proposed by Klownicle, GitHub #8).": "",
|
||||
"Replace application label and icon": "",
|
||||
"Replaces libvrapi.so with a VrApi→OpenXR bridge (Android-XR-Bridge/OVRPort fork, GPL-3.0, patched for the Frame: GLES sessions, cylinder→quad layers, sRGB format fallback, 30 s VR-mode deadline, emulated time conversion). Needed when the engine calls VrApi itself instead of through OVRPlugin (e.g. The Climb 2, Path of the Warrior); OVRPort cannot translate those.": "",
|
||||
@@ -1007,6 +1061,8 @@
|
||||
"Sets Unity QualitySettings antiAliasing to 0. Unity's multisampled render-to-texture path can hang the Frame's GL driver ('zink: DEVICE LOST', e.g. Sniper Elite VR). Try it for GLES Unity games that freeze or crash the GPU.": "",
|
||||
"Settings": "",
|
||||
"shader cache: makes later starts load faster; rebuilt if deleted": "",
|
||||
"Shader-fix Vulkan layer (frame.zink_shader_fix) for OpenGL ES games, whose GLSL the Frame's GL driver (Zink) turns into SPIR-V: modules matching size + SHA-256 get words inserted at a byte offset, e.g. stores that set loop counters and accumulators a shader reads before writing (Vader Immortal's lightspeed jump hung the GPU; found by Klownicle, GitHub #49). Same format as vk_shader_fix. Comes from a game's recipe; the driver's output can change with a Frame update, then the fix no longer matches (logged).": "",
|
||||
"Shader-fix Vulkan layer (frame.zink_shader_fix): writes every distinct SPIR-V module the GL driver creates to Android/data/<package>/files/fp_spirv/<size>_<sha256>.spv, to capture a shader for a new zink_shader_fix. No effect on the game.": "",
|
||||
"Share working config · {title}": "",
|
||||
"Share working config…": "",
|
||||
"Sharpness": "",
|
||||
@@ -1051,16 +1107,19 @@
|
||||
"software encoder": "",
|
||||
"Some apps open a flat Android launcher that then starts a separate VR activity (e.g. WiiCompiled). Shown with Lepton's flat window, that window stays in view after the VR part starts. This makes the VR activity the one Lepton starts (its LAUNCHER category), so the app opens in VR with no flat window. Everything only the launcher does (choosing or importing content) is skipped: turn it off for a run when you need the launcher, and turn off \"Show the app's Android window\".": "",
|
||||
"Some developers put an \"Install with FrameDrop\" button on their site (the protocol of the FrameDrop sideloader). FramePort reads the same links: it shows what the link offers, asks, downloads it, adds it to your library and installs it on the Frame. Only https links to public servers are used. Windows and Linux open these links in FramePort; on macOS, paste the link into Add games → Install from a link….": "",
|
||||
"Some engines contain assembly (e.g. OpenSSL's) that checks a return-address signature it never made. The Quest's CPU ignores those checks, the Steam Frame's enforces them: the game crashes with SIGILL as soon as it uses that code, e.g. on its first network connection (Star Wars Pinball VR). In a library with unpaired checks, the signing and checking instructions become no-ops.": "",
|
||||
"Some engines leave the pNext of unused Vulkan attachment references uninitialized. The Frame's driver never reads it, but Lepton always loads Steam's Fossilize shader-cache layer, which follows it and crashes on the first frame (SIGSEGV in libVkLayer_fossilize.so from FVulkanRenderPass, e.g. Deadpool VR). Loads Vulkan through a small shim that keeps valid pointers and drops only unreadable ones or ones pointing at the wrong structure type. It also drops a depth resolve named in a subpass without a depth attachment, which crashes the Frame's driver at the first render pass (e.g. Metro Awakening).": "",
|
||||
"Some game folders are already set up for SteamVR: a Revive copy (LibRevive64.dll) next to the game, started by a small loader DLL (e.g. xinput1_3.dll) that Windows loads from the game's folder. Then FramePort starts the game program directly — like double-clicking it — instead of through its own Revive (two Revives conflict; e.g. the Oculus Platform check then fails). On the Frame, Wine is told to use those loader DLLs from the game folder instead of its own.": "",
|
||||
"Some games keep content in separate files next to their OBB (e.g. Star Wars: Tales from the Galaxy's Edge: its seasons and sound banks) and ask Meta's platform for them as asset files. OVRPort's platform loader doesn't know them, so the game waits for content that never arrives (black screen after loading). Lists the content files shipped with the game's data (*.pak) as installed and answers their download request at once with the file's path; nothing is copied or downloaded. Shares the language-pack library.": "",
|
||||
"Some games need a second build with different OVRPort patches (for some Unreal games: one with Unreal's ForceQuit removed). Turn this on to install that build instead.": "",
|
||||
"Some OpenGL ES engines compile every shader for multiview (both eyes in one draw, `num_views=2`) and use the same shaders to draw into ordinary single-layer framebuffers, e.g. a HUD or PDA screen rendered to a texture. Quest's driver draws them anyway; the Frame's driver (Mesa) follows the OVR_multiview rule and silently drops every such draw, so those panels stay black (e.g. Doom3Quest's HUD and PDA). Loads OpenGL ES through a small interposer (libfpglmv.so) that draws those cases with a single-view copy of the shader program (left-eye view; uniforms copied before each such draw). Eye-buffer draws are unchanged. Logs under the tag GLMV; the setting gl_mv_debug adds error checks and counters.": "",
|
||||
"Some OpenXR games still ship Meta's VrApi loader and call it a few times without starting VrApi (e.g. Jurassic World Aftermath); Meta's loader then stops the game (\"vrapi_SetPropertyInt was called before vrapi_Initialize()\"). Replaces it with a library that has the same functions, each doing nothing. Only for games whose VR runs through OVRPlugin on OpenXR.": "",
|
||||
"Something not working? Collect a diagnostics zip (logs, settings, device info; personal data removed) and attach it to a GitHub issue. For one game, use its menu instead.": "",
|
||||
"Something went wrong": "",
|
||||
"Sort": "",
|
||||
"Source": "",
|
||||
"square, 256 × 256": "",
|
||||
"Stand-in cube swapchains": "",
|
||||
"Standard for every game": "",
|
||||
"Start live view": "",
|
||||
"Start straight in VR (skip the 2D launcher)": "",
|
||||
@@ -1091,7 +1150,9 @@
|
||||
"Stop": "",
|
||||
"Stop objects popping in and out": "",
|
||||
"Stopped: {why}": "",
|
||||
"Stops a crash a few seconds after start in Unreal games whose anti-aliasing the Frame's graphics driver can't handle (e.g. Star Wars Pinball VR).": "",
|
||||
"Stops a crash a few seconds after the logo in games with voice chat.": "",
|
||||
"Stops a crash on the Frame's CPU in games whose code has unpaired security checks the Quest ignores (e.g. Star Wars Pinball VR's first network connection).": "",
|
||||
"Stops a grey or frozen screen in Unity games that turn on anti-aliasing while running (e.g. The Room VR).": "",
|
||||
"Stops a grey screen crash in some Unity games by skipping their overlay layers (e.g. The Room VR's screen fades).": "",
|
||||
"Stops Meta's audio library from reporting to Meta (only matters on PC emulators).": "",
|
||||
@@ -1105,6 +1166,8 @@
|
||||
""
|
||||
],
|
||||
"Store details: {title}": "",
|
||||
"Stress Level Zero's graphics plugin (libSLZQuestNative.so, e.g. BONELAB 1.2974) hooks Unity's Vulkan start-up and its texture samplers. On the Frame the hook calls an invalid function while Vulkan starts, and the game crashes or hangs before the first frame. The plugin's two hooks are switched off, so Unity starts Vulkan itself. Its pipeline cache isn't used: the first start spends about half a minute prewarming shaders.": "",
|
||||
"Stress Level Zero: let Unity start Vulkan itself": "",
|
||||
"Strip depth layers": "",
|
||||
"Stub missing Meta platform functions": "",
|
||||
"Suggested after a launch test": "",
|
||||
@@ -1155,6 +1218,7 @@
|
||||
"The Frame's battery is at {pct} %: installs paused so it doesn't switch off mid-upload. Plug it in; the queue continues by itself.": "",
|
||||
"The Frame's CPU is arm64. Programs built for x86_64 PCs run through FEX, Valve's x86 translator, with the x86 libraries SteamOS ships for it (glibc, Mesa) - the way Steam on the Frame runs x86 Linux games. That works for many programs but is slower: use an arm64 build when there is one.": "",
|
||||
"The Frame's desktop password, only needed the first time so FramePort can add its own key. After that it connects with the key.": "",
|
||||
"The Frame's runtime has no cube-map layers and refuses their swapchains; OVRPlugin then crashes when it submits the next frame (e.g. Budget Cuts Ultimate). FrameBridge serves such a swapchain itself (a GL cube map, GLES games) and drops its layers, so the game runs on without that layer.": "",
|
||||
"The Frame's Steam couldn't start {title} (Steam error {code}). Please send a problem report so we can see why.": "",
|
||||
"The Frame's Steam didn't accept {title}'s library entry, so FramePort added it as \"Devkit Game: …\" instead. Starting it now — put the headset on.": "",
|
||||
"The Frame's Steam library still shows the old artwork.": "",
|
||||
@@ -1177,6 +1241,7 @@
|
||||
"The VR runtime games talk to. On the Frame that's SteamVR.": "",
|
||||
"These games can't run on the Steam Frame": "",
|
||||
"These games check their Oculus license": "",
|
||||
"These games keep their content in a data file (.obb), and none was found next to their APK. Without it they hang at start. Put the .obb files in a folder named like the game's package (or obb/) next to the APK and add the folder again. Tick any you still want to install.": "",
|
||||
"These installs need about {need} on the Frame, and it has {free} free. Remove games you don't play (or use Free up space on the Steam Frame page), or install fewer at once.": "",
|
||||
"These links open FramePort.": "",
|
||||
"These links will open FramePort instead of {app}. You can switch back in {app}, or by turning this setting off.": "",
|
||||
@@ -1185,12 +1250,14 @@
|
||||
"They use the Oculus Platform SDK, which comes with the Meta Horizon (Oculus) app — it isn't installed on this PC, so they may quit right after starting. FramePort doesn't change how a game checks its license. Untick the ones you'd rather skip.": "",
|
||||
"They use the Oculus Platform SDK, which comes with the Meta Horizon (Oculus) app. It doesn't exist on the Steam Frame, so there they crash right at startup (seen with Robo Recall, Lies Beneath and Lone Echo) — play them on this PC. Tick any you still want to try on the Frame.": "",
|
||||
"They're Oculus games that need Revive to reach VR, and Revive can't run on the Frame. Play them on this PC instead (Install on this PC — SteamVR + Revive). Tick any you still want to put on the Frame to experiment (they'll likely run flat or crash).": "",
|
||||
"This app looks like a website in an Android wrapper: it opens the site in Meta's browser, which the Frame doesn't have. If nothing opens, use the website in a browser instead.": "",
|
||||
"This computer's keyboard works as a keyboard on your Frame while this tab is open. In the headset, select a text field (in a game or app, in Steam or on the desktop), then type here.": "",
|
||||
"This deletes the {what} on the Frame. It can't be undone.": "",
|
||||
"This folder is empty. Upload files with the buttons above.": "",
|
||||
"This Frame isn't set up for FramePort yet. Run the first-time setup: Steam Frame → Show setup command.": "",
|
||||
"This game can't run on the Steam Frame.": "",
|
||||
"This game is marked \"Can't run\" on the Steam Frame:": "",
|
||||
"This game's data file (.obb) wasn't found next to the APK. Put the .obb files in a folder named {package} (or obb/) next to the APK and add the folder again; without it the game hangs at start.": "",
|
||||
"This game's folder has several programs and FramePort isn't sure which one starts the game. Open the game (or right-click → Change executable) to pick it.": "",
|
||||
"this PC": "",
|
||||
"This PC (for PC VR games)": "",
|
||||
@@ -1199,6 +1266,8 @@
|
||||
"This removes:": "",
|
||||
"This source checkout has uncommitted changes: commit or stash them, then update": "",
|
||||
"This system can't send web links to FramePort: use Add games → Install from a link…": "",
|
||||
"Thumbs": "",
|
||||
"Thumbs and index fingers": "",
|
||||
"Tilts the controllers' pointing ray up (+) or down (−), for games whose pointer doesn't hit what you aim at.": "",
|
||||
"To finish, close FramePort and delete its program folder.": "",
|
||||
"Today": "",
|
||||
@@ -1244,7 +1313,11 @@
|
||||
"Uninstall. ": "",
|
||||
"Uninstalled {title}": "",
|
||||
"Uninstalled {title} and deleted its files on this PC ({size})": "",
|
||||
"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, e.g. 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). 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 (e.g. Accounting+'s motion warning) and which Lepton never delivers.": "",
|
||||
"Unity frame loop: keep display-time poses": "",
|
||||
"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 (e.g. 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 (e.g. Sniper Elite VR); in others it makes the hands lag (e.g. BattleSisters).": "",
|
||||
"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, e.g. 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 (e.g. 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 (e.g. 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 (e.g. 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.": "",
|
||||
"Unity: disable MSAA": "",
|
||||
@@ -1254,8 +1327,15 @@
|
||||
"Unity: skip OVROverlay layers (fades, splash screens)": "",
|
||||
"Unity: start VR without Meta's system apps": "",
|
||||
"Unknown date": "",
|
||||
"Unpaired pointer-authentication checks": "",
|
||||
"Unreal 4's Oculus input sets the ThumbUp axis (games animate the hand's thumb with it) from Meta's near-touch sensing, which the Frame never reports: the thumb always points up. Uses the touches instead (left X/Y/stick/thumb rest, right A/B/stick/thumb rest). Changes three instructions in OculusInput::SendControllerEvents, only where the code matches exactly (UE4 Oculus games of one engine generation, e.g. Vader Immortal, Robo Recall). Found by Klownicle (GitHub #49).": "",
|
||||
"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 (e.g. NOPE Challenge).": "",
|
||||
"Unreal games start CrashReportClient when they crash, which leaves a crash dialog instead of simply closing. This passes -nocrashreports to the game and, on the Frame, renames the game's copy of CrashReportClient.exe so it can't start (your game files on this PC aren't changed).": "",
|
||||
"Unreal's 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 (e.g. ovrp_GetPTWNear, which Unreal 4.25 builds like Star Wars Pinball VR look up). The game then never starts an OpenXR session and crashes a few seconds in. Adds a generated library to libOVRPlugin.so with a stand-in for each missing function that reports failure (ovrpFailure), so Unreal's Oculus support starts. The functions OVRPort has are untouched.": "",
|
||||
"Unreal: don't wait for the Quest shader precompile": "",
|
||||
"Unreal: Quest controller bindings (RPOC key map)": "",
|
||||
"Unreal: stand-ins for OVRPlugin functions OVRPort lacks": "",
|
||||
"Unreal: thumb follows the capacitive touches": "",
|
||||
"Untested": "",
|
||||
"Update all": "",
|
||||
"Update all ({updates})": "",
|
||||
@@ -1313,6 +1393,8 @@
|
||||
"View": "",
|
||||
"View screenshot": "",
|
||||
"Viewer address on this PC: {url}": "",
|
||||
"Vivox voice chat: skip Android 12 audio routing": "",
|
||||
"Vivox's voice-chat SDK (e.g. 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.": "",
|
||||
"VR (OpenXR)": "",
|
||||
"VrApi → OpenXR adapter (OVRPort)": "",
|
||||
"VrApi → OpenXR bridge": "",
|
||||
@@ -1341,6 +1423,7 @@
|
||||
"What your Frame is doing right now": "",
|
||||
"What's new": "",
|
||||
"Where it's installed": "",
|
||||
"Where new games go on the Frame: its internal storage or another drive, such as a microSD card (in a FramePort folder on it). Games you installed already stay where they are; move one with right-click → Move to…. The launcher and Steam entry always stay on internal storage. Drives formatted as FAT, exFAT or NTFS can't hold games (Android and Proton need Linux file permissions): format the card in SteamOS first.": "",
|
||||
"Where the power goes": "",
|
||||
"Where this game's recipe came from: catalog (tested on a Frame), heuristics (suggested by FramePort) or user (changed by you).": "",
|
||||
"Which program starts {get}?": "",
|
||||
@@ -1402,6 +1485,7 @@
|
||||
"{human} · {files} files": "",
|
||||
"{int} h ago": "",
|
||||
"{int} min ago": "",
|
||||
"{label} (not inserted)": "",
|
||||
"{label} ({host}) isn't reachable. Make sure it's switched on and on the same network.": "",
|
||||
"{label} removed for {title}.": "",
|
||||
"{label} updated for {title}.": "",
|
||||
@@ -1441,6 +1525,10 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game was analyzed again for new fixes": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} install didn't finish: {names}. What was already copied is kept, so resuming continues where it stopped.": [
|
||||
"",
|
||||
""
|
||||
@@ -1477,6 +1565,7 @@
|
||||
"{on} on your Frame": "",
|
||||
"{rate}/s": "",
|
||||
"{remaining} more after this": "",
|
||||
"{size:.1f} GiB free": "",
|
||||
"{title} couldn't be added to the Frame's Steam library: {why}": "",
|
||||
"{title} ended.": "",
|
||||
"{title} failed: {error}": "",
|
||||
@@ -1484,6 +1573,7 @@
|
||||
"{title} is already starting — put the headset on": "",
|
||||
"{title} is still running.": "",
|
||||
"{title} isn't in the Frame's Steam library yet: adding it (Steam restarts once)": "",
|
||||
"{title} moved to {label}": "",
|
||||
"{title} will be in the Frame's Steam library when the game that's running is closed": "",
|
||||
"{title} will be in the Frame's Steam library when you're back in Gaming Mode: start it from there": "",
|
||||
"{title}: analyzed again": "",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Single-view draws of OVR_multiview programs into ordinary framebuffers (native/glmv, GitHub #77)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...analysis import elf
|
||||
from ...analysis.detect import multiview_glsl_libs
|
||||
from ..applicability import is_gles
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
from . import artifact
|
||||
|
||||
SHIM = "libfpglmv.so" # as long as "libGLESv3.so": the engine's dlopen string is rewritten in place
|
||||
GLES = "libGLESv3.so"
|
||||
|
||||
|
||||
def engine_libs(ws, analysis) -> list[str]:
|
||||
"""The game's libraries with OVR_multiview GLSL (from the analysis; scanned again when it predates the field)."""
|
||||
names = (analysis.extra or {}).get("gl_multiview_libs")
|
||||
if names is None:
|
||||
names = multiview_glsl_libs({n: ws.read_lib(n) for n in analysis.libs if ws.has(ws.lib(n))})
|
||||
return [n for n in names if ws.has(ws.lib(n))]
|
||||
|
||||
|
||||
class GlMultiviewFbo(Patch):
|
||||
id = "frame.gl_multiview_fbo"
|
||||
title = "OpenGL ES: multiview shaders on flat panels (HUD, menus)"
|
||||
description = (
|
||||
"Some OpenGL ES engines compile every shader for multiview (both eyes in one draw, `num_views=2`) and use the "
|
||||
"same shaders to draw into ordinary single-layer framebuffers, e.g. a HUD or PDA screen rendered to a texture. "
|
||||
"Quest's driver draws them anyway; the Frame's driver (Mesa) follows the OVR_multiview rule and silently drops "
|
||||
"every such draw, so those panels stay black (e.g. Doom3Quest's HUD and PDA). Loads OpenGL ES through a small "
|
||||
"interposer (libfpglmv.so) that draws those cases with a single-view copy of the shader program (left-eye "
|
||||
"view; uniforms copied before each such draw). Eye-buffer draws are unchanged. Logs under the tag GLMV; the "
|
||||
"setting gl_mv_debug adds error checks and counters.")
|
||||
order = 63
|
||||
experimental = True
|
||||
|
||||
def applies(self, a):
|
||||
return (a.engine == "Other" and is_gles(a) and "arm64-v8a" in a.abis
|
||||
and bool((a.extra or {}).get("gl_multiview_libs")))
|
||||
|
||||
def detect(self, a):
|
||||
if self.applies(a):
|
||||
return Suggestion(False, "Own OpenGL ES engine with multiview shaders: turn on if HUD, menu or other flat "
|
||||
"panels stay black on the Frame (e.g. Doom3Quest's HUD and PDA).")
|
||||
return None
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
if ws.abi != "arm64-v8a":
|
||||
return False
|
||||
changed = False
|
||||
for lib in engine_libs(ws, ctx.analysis):
|
||||
data = ws.read_lib(lib)
|
||||
if not elf.is_elf(data):
|
||||
continue
|
||||
patched = elf.add_needed(data, SHIM) # direct gl* imports bind to the interposer first
|
||||
patched, count = elf.replace_rodata_string(patched, GLES, SHIM) # dlopen + dlsym tables
|
||||
if patched == data:
|
||||
continue
|
||||
ws.put(ws.lib(lib), patched)
|
||||
ctx.notes.append(f"{lib} draws through {SHIM} ({count} libGLESv3.so load(s) redirected)")
|
||||
changed = True
|
||||
if changed:
|
||||
shim = artifact(ws.abi, SHIM)
|
||||
if not ws.has(ws.lib(SHIM)) or ws.read_lib(SHIM) != shim:
|
||||
ws.put(ws.lib(SHIM), shim)
|
||||
return changed
|
||||
|
||||
def validate(self, ctx: ApkContext):
|
||||
ws = ctx.ws
|
||||
users = [lib for lib in ((ctx.analysis.extra or {}).get("gl_multiview_libs") or [])
|
||||
if ws.has(ws.lib(lib)) and SHIM in elf.needed(ws.read_lib(lib))]
|
||||
return [("Multiview interposer present", ws.has(ws.lib(SHIM)), SHIM)] if users else []
|
||||
|
||||
|
||||
register(GlMultiviewFbo)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Detects OVRPort's fix for the haptic-envelope bug, which makes `adapter.haptic_fix` unnecessary.
|
||||
|
||||
OVRPort's dispatcher (libopenxr_loader.so) turns XrHapticAmplitudeEnvelopeVibrationFB into 100 Hz samples. Runtimes
|
||||
up to 3.4.3-23204ea used the envelope's duration (nanoseconds) as seconds when counting them: one vibration asked for
|
||||
gigabytes (Lucky's Tale froze the Frame, BattleSisters was killed for 11 GB). Runtime 3.4.3-aa54c3f converts it first
|
||||
(ovrport/app#73): right after the type check for the envelope (XR_TYPE_HAPTIC_AMPLITUDE_ENVELOPE_VIBRATION_FB =
|
||||
1000173001) it builds the constant 1e9 (`count * 1e9 / duration` = samples per second), which the buggy code never
|
||||
had. Verified in the headset with Lucky's Tale and haptic_fix off (2026-10-07). See patches/upstream.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from ..upstream import UpstreamFix, code_words, constants, find_constant_load, lib_probe, register
|
||||
|
||||
DISPATCHER = "libopenxr_loader.so"
|
||||
ENVELOPE_TYPE = 1000173001 # XR_TYPE_HAPTIC_AMPLITUDE_ENVELOPE_VIBRATION_FB
|
||||
WINDOW = 48 # instructions after the type check that hold the sample count (the fix: 7 after it)
|
||||
NS_PER_S = {struct.unpack("<Q", struct.pack("<d", v))[0] for v in (1e9, 1e-9)} | \
|
||||
{struct.unpack("<I", struct.pack("<f", v))[0] for v in (1e9, 1e-9)}
|
||||
|
||||
|
||||
def _literals(data: bytes, offsets: list[int], words: list[int]) -> list[int]:
|
||||
"""Values loaded PC-relative (LDR literal: GP w/x, SIMD s/d) by the given instructions."""
|
||||
out = []
|
||||
for off, w in zip(offsets, words, strict=True):
|
||||
if w & 0x3B000000 != 0x18000000: # LDR (literal), GP (V=0) or SIMD (V=1)
|
||||
continue
|
||||
size = {0: 4, 1: 8}.get(w >> 30) # opc: w / s = 4 bytes, x / d = 8 (q isn't a scalar constant)
|
||||
if size is None:
|
||||
continue
|
||||
imm19 = (w >> 5) & 0x7FFFF
|
||||
target = off + ((imm19 - (1 << 19)) if imm19 & (1 << 18) else imm19) * 4
|
||||
if 0 <= target <= len(data) - size:
|
||||
out.append(int.from_bytes(data[target:target + size], "little"))
|
||||
return out
|
||||
|
||||
|
||||
def envelope_duration_fixed(data: bytes) -> bool | None:
|
||||
"""True when the dispatcher converts the envelope duration from nanoseconds (OVRPort's fix), False when its
|
||||
envelope code has no such conversion (the bug), None when there's no envelope code to judge."""
|
||||
code = list(code_words(data))
|
||||
offsets, words = [o for o, _ in code], [w for _, w in code]
|
||||
seen = False
|
||||
start = 0
|
||||
while (i := find_constant_load(words[start:], ENVELOPE_TYPE)) is not None:
|
||||
i += start
|
||||
seen = True
|
||||
window = slice(i, i + WINDOW)
|
||||
if NS_PER_S & set(constants(words[window])) or NS_PER_S & set(_literals(data, offsets[window], words[window])):
|
||||
return True
|
||||
start = i + 1
|
||||
return False if seen else None
|
||||
|
||||
|
||||
register(UpstreamFix(
|
||||
id="ovrport.haptic_envelope",
|
||||
workaround="adapter.haptic_fix",
|
||||
title="OVRPort converts the vibration envelope's duration from nanoseconds",
|
||||
upstream="ovrport/app#73 (runtime 3.4.3-aa54c3f)",
|
||||
probe=lib_probe(DISPATCHER, envelope_duration_fixed),
|
||||
tracker="#74",
|
||||
))
|
||||
@@ -7,12 +7,16 @@ Walking Dead: Saints & Sinners Ch. 2) dereferences a NULL stream: SIGSEGV in lib
|
||||
seconds after the logo. Meta's own library allows that order. The function is rewritten in place (same size) to return
|
||||
0 while no stream is open and behave as before otherwise.
|
||||
|
||||
Upstream: Android-XR-Bridge/OVRPort#2, ovrport/app#73; tracked in GitHub #73 (drop this patch once fixed upstream).
|
||||
Upstream: Android-XR-Bridge/OVRPort#2, ovrport/app#73; tracked in GitHub #73. OVRPort runtime 3.4.3-aa54c3f checks
|
||||
the handle and the stream itself (returns 48000 without one): builds made with it leave this patch out
|
||||
(`microphone_checked`, patches/upstream.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...analysis import elf
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
from ..upstream import UpstreamFix, lib_probe
|
||||
from ..upstream import register as register_upstream_fix
|
||||
|
||||
LOADER = "libovrplatformloader.so"
|
||||
FUNC = "ovr_Microphone_GetOutputBufferMaxSize"
|
||||
@@ -31,9 +35,8 @@ def _words(data: bytes | bytearray, off: int, n: int) -> list[int]:
|
||||
return [int.from_bytes(data[off + 4 * i:off + 4 * i + 4], "little") for i in range(n)]
|
||||
|
||||
|
||||
def guard_microphone(data: bytes) -> bytes | None:
|
||||
"""The loader with a NULL-stream check in ovr_Microphone_GetOutputBufferMaxSize, or None if the function isn't
|
||||
OVRPort's unchecked 7-instruction version (already fixed, Meta's library, another OVRPort build)."""
|
||||
def _function_offset(data: bytes, size: int) -> int | None:
|
||||
"""File offset of FUNC in the loader (None: not an ELF, no such export, or shorter than `size` bytes)."""
|
||||
try:
|
||||
dynsym = elf._elf(data).get_section_by_name(".dynsym")
|
||||
except Exception: # noqa: BLE001
|
||||
@@ -41,9 +44,31 @@ def guard_microphone(data: bytes) -> bytes | None:
|
||||
found = dynsym.get_symbol_by_name(FUNC) if dynsym is not None else None
|
||||
if not found or not found[0]["st_value"]:
|
||||
return None
|
||||
vaddr = found[0]["st_value"]
|
||||
off = elf.vaddr_to_offset(elf.load_segments(data), vaddr)
|
||||
if off is None or off + 28 > len(data):
|
||||
off = elf.vaddr_to_offset(elf.load_segments(data), found[0]["st_value"])
|
||||
return off if off is not None and off + size <= len(data) else None
|
||||
|
||||
|
||||
def microphone_checked(data: bytes) -> bool | None:
|
||||
"""True when OVRPort's GetOutputBufferMaxSize tests something (a cbz/cbnz) before its first call, as the fixed
|
||||
runtime does; False when it is the unchecked version this patch rewrites; None when it can't tell."""
|
||||
if guard_microphone(data) is not None:
|
||||
return False
|
||||
off = _function_offset(data, 48)
|
||||
if off is None:
|
||||
return None
|
||||
for word in _words(data, off, 12):
|
||||
if word & BL_MASK == BL:
|
||||
return None
|
||||
if word & 0x7E000000 == 0x34000000: # CBZ / CBNZ (32 or 64 bit)
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
def guard_microphone(data: bytes) -> bytes | None:
|
||||
"""The loader with a NULL-stream check in ovr_Microphone_GetOutputBufferMaxSize, or None if the function isn't
|
||||
OVRPort's unchecked 7-instruction version (already fixed, Meta's library, another OVRPort build)."""
|
||||
off = _function_offset(data, 28)
|
||||
if off is None:
|
||||
return None
|
||||
w = _words(data, off, 7)
|
||||
if w[:3] != [STP, MOV_FP, LDR_STREAM] or w[3] & BL_MASK != BL or w[4:] != [SXTW, LDP, RET]:
|
||||
@@ -88,3 +113,11 @@ class OvrMicrophone(Patch):
|
||||
|
||||
|
||||
register(OvrMicrophone)
|
||||
register_upstream_fix(UpstreamFix(
|
||||
id="ovrport.microphone_stream",
|
||||
workaround=OvrMicrophone.id,
|
||||
title="OVRPort checks for an unopened microphone stream",
|
||||
upstream="ovrport/app#73 (runtime 3.4.3-aa54c3f)",
|
||||
probe=lib_probe(LOADER, microphone_checked),
|
||||
tracker="#73",
|
||||
))
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Unpaired pointer-authentication hints (PAC) in a game's native libraries.
|
||||
|
||||
paciasp signs the return address on function entry and autiasp checks it before returning. Both are HINT
|
||||
instructions: CPUs without pointer authentication (the Quest's) execute them as NOPs, the Steam Frame's CPU checks
|
||||
them and raises SIGILL (ILL_ILLOPN at an autiasp) when a check fails. Some OpenSSL ARMv8 assembly built into game
|
||||
engines has an autiasp without the matching paciasp (e.g. Star Wars Pinball VR's libUE4.so: 38 paciasp, 40 autiasp,
|
||||
the crash in the Poly1305 NEON code on Unreal's HttpManager thread), so the game dies as soon as it opens a TLS
|
||||
connection. Only libraries whose PAC hints come from hand-written assembly are changed: compiler-protected code
|
||||
signs almost every function and has several autiasp per paciasp where a function has several returns (OVRPort's
|
||||
libopenxr_loader_generic.so: 1084 paciasp, 1114 autiasp, 7092 ret; stripping it hung OVRPlugin's start-up), while an
|
||||
engine without branch protection has a few dozen hints among hundreds of thousands of returns (libUE4.so: 38 among
|
||||
342581). So a library is changed when its counts differ and its paciasp are under 0.2 % of its ret instructions; then
|
||||
every paciasp/autiasp becomes a NOP (the same 4 bytes in place) and the code behaves as on the Quest. The combined
|
||||
forms (retaa/retab, pacibsp/autibsp) aren't touched; a library using them is left alone."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...analysis import elf
|
||||
from ..base import ApkContext, Patch, register
|
||||
|
||||
PACIASP = (0xD503233F).to_bytes(4, "little")
|
||||
AUTIASP = (0xD50323BF).to_bytes(4, "little")
|
||||
NOP = (0xD503201F).to_bytes(4, "little")
|
||||
RET = (0xD65F03C0).to_bytes(4, "little")
|
||||
MAX_ASM_SHARE = 0.002 # paciasp per ret: above this the library is compiler-protected (left alone)
|
||||
COMBINED = tuple(w.to_bytes(4, "little") for w in (0xD65F0BFF, 0xD65F0FFF, 0xD503237F, 0xD50323FF)) # retaa/b, *ibsp
|
||||
|
||||
|
||||
def _exec_ranges(data: bytes) -> list[tuple[int, int]]:
|
||||
"""File ranges of the executable PT_LOAD segments."""
|
||||
import io
|
||||
|
||||
from elftools.elf.elffile import ELFFile
|
||||
|
||||
e = ELFFile(io.BytesIO(data))
|
||||
return [(s["p_offset"], s["p_offset"] + s["p_filesz"]) for s in e.iter_segments()
|
||||
if s["p_type"] == "PT_LOAD" and s["p_flags"] & 1]
|
||||
|
||||
|
||||
def _find(data: bytes, word: bytes, ranges) -> list[int]:
|
||||
out = []
|
||||
for lo, hi in ranges:
|
||||
i = data.find(word, lo, hi)
|
||||
while i >= 0:
|
||||
if i % 4 == 0:
|
||||
out.append(i)
|
||||
i = data.find(word, i + 1, hi)
|
||||
return out
|
||||
|
||||
|
||||
def unpaired(data: bytes) -> tuple[list[int], list[int]] | None:
|
||||
"""(paciasp offsets, autiasp offsets) when a library's counts differ, else None."""
|
||||
if not elf.is_elf(data):
|
||||
return None
|
||||
ranges = _exec_ranges(data)
|
||||
pac, aut = _find(data, PACIASP, ranges), _find(data, AUTIASP, ranges)
|
||||
if len(pac) == len(aut) or any(_find(data, w, ranges) for w in COMBINED):
|
||||
return None
|
||||
if len(pac) > MAX_ASM_SHARE * max(len(_find(data, RET, ranges)), 1):
|
||||
return None # compiler-protected (several returns per signed function), not hand-written assembly
|
||||
return pac, aut
|
||||
|
||||
|
||||
class PacHints(Patch):
|
||||
id = "frame.pac_hints"
|
||||
title = "Unpaired pointer-authentication checks"
|
||||
description = ("Some engines contain assembly (e.g. OpenSSL's) that checks a return-address signature it never "
|
||||
"made. The Quest's CPU ignores those checks, the Steam Frame's enforces them: the game crashes with "
|
||||
"SIGILL as soon as it uses that code, e.g. on its first network connection (Star Wars Pinball "
|
||||
"VR). In a library with unpaired checks, the signing and checking instructions become no-ops.")
|
||||
order = 47
|
||||
revision = 1
|
||||
|
||||
def applies(self, a):
|
||||
return "arm64-v8a" in a.abis
|
||||
|
||||
def detect(self, a):
|
||||
return None # reading every engine library at analysis time is too slow; triage (pac-unpaired) suggests it
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
if ws.abi != "arm64-v8a":
|
||||
return False
|
||||
changed = False
|
||||
for lib in ws.libs():
|
||||
data = ws.read_lib(lib)
|
||||
found = unpaired(data)
|
||||
if not found:
|
||||
continue
|
||||
pac, aut = found
|
||||
out = bytearray(data)
|
||||
for off in pac + aut:
|
||||
out[off:off + 4] = NOP
|
||||
ws.put(ws.lib(lib), bytes(out))
|
||||
ctx.notes.append(f"{lib}: {len(pac)} paciasp / {len(aut)} autiasp turned into NOPs (unpaired)")
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
register(PacHints)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""BONELAB 1.2974 (and other Stress Level Zero builds) ship libSLZQuestNative.so, a Unity native plugin that hooks
|
||||
Unity's Vulkan start-up (IUnityGraphicsVulkanV2 interception: its vkCreateInstance/vkCreateDevice wrappers raise the
|
||||
API version, enable device features and check its pipeline cache) and replaces Unity's vkCreateSampler (anisotropy
|
||||
<= 4, LOD bias -0.25). On the Frame its vkCreateInstance wrapper calls through an invalid pointer right after
|
||||
OVRPlugin's pre-init OpenXR instance is destroyed: a jump into an unloaded library, to address 0, or a hang, depending
|
||||
on the run. The plugin's two registrations with Unity are turned into no-ops (in place, 4 bytes each), so Unity runs
|
||||
Vulkan itself; the plugin still loads. Its sampler hook must go too: without the device hook it never learns the real
|
||||
vkCreateSampler."""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from ...analysis import elf
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
|
||||
LIB = "libSLZQuestNative.so"
|
||||
ON_LOAD = "RenderAPI_Vulkan_OnPluginLoad" # registers the Vulkan-init interception
|
||||
GFX_INIT = "_ZN8PluginVk12GfxEventInitEv" # PluginVk::GfxEventInit: InterceptVulkanAPI("vkCreateSampler", hook)
|
||||
SAMPLER = b"vkCreateSampler\0"
|
||||
NOP = 0xD503201F
|
||||
MOV_X1_XZR, MOV_W2_WZR = 0xAA1F03E1, 0x2A1F03E2 # AddInterceptInitialization(func, userdata = NULL, flags = 0)
|
||||
|
||||
|
||||
def _is_blr(insn: int) -> bool:
|
||||
return insn & 0xFFFFFC1F == 0xD63F0000
|
||||
|
||||
|
||||
def _x0_address(data: bytes, segs, at: int, back: int = 8) -> int | None:
|
||||
"""The address `adrp xN, page` + `add x0, xN, #imm` puts in x0 within `back` instructions before `at`."""
|
||||
for i in range(1, back):
|
||||
add_va = at - 4 * i
|
||||
add_off = elf.vaddr_to_offset(segs, add_va)
|
||||
if add_off is None:
|
||||
return None
|
||||
add = struct.unpack_from("<I", data, add_off)[0]
|
||||
if add & 0xFFC0001F != 0x91000000: # add x0, xN, #imm12 (no shift)
|
||||
continue
|
||||
rn, imm12 = (add >> 5) & 0x1F, (add >> 10) & 0xFFF
|
||||
for j in range(i + 1, back + 4):
|
||||
adrp_off = elf.vaddr_to_offset(segs, at - 4 * j)
|
||||
if adrp_off is None:
|
||||
return None
|
||||
adrp = struct.unpack_from("<I", data, adrp_off)[0]
|
||||
if adrp & 0x9F000000 == 0x90000000 and adrp & 0x1F == rn:
|
||||
imm = ((adrp >> 5) & 0x7FFFF) << 2 | (adrp >> 29) & 3
|
||||
if imm & (1 << 20):
|
||||
imm -= 1 << 21
|
||||
return ((at - 4 * j) & ~0xFFF) + (imm << 12) + imm12
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _function(data: bytes, name: str) -> tuple[int, int] | None:
|
||||
sec = elf._elf(data).get_section_by_name(".dynsym")
|
||||
for sym in (sec.get_symbol_by_name(name) or []) if sec else []:
|
||||
if sym["st_value"] and sym["st_size"]:
|
||||
return sym["st_value"], sym["st_size"]
|
||||
return None
|
||||
|
||||
|
||||
def find_registrations(data: bytes) -> list[int]:
|
||||
"""File offsets of the plugin's two `blr` registrations (interception, vkCreateSampler), [] when not this plugin."""
|
||||
segs = elf.load_segments(data)
|
||||
sampler = data.find(b"\0" + SAMPLER) + 1
|
||||
on_load, gfx_init = _function(data, ON_LOAD), _function(data, GFX_INIT)
|
||||
if not on_load or not gfx_init or sampler <= 0:
|
||||
return []
|
||||
out = []
|
||||
for (start, size), wanted in ((on_load, "intercept"), (gfx_init, "sampler")):
|
||||
for va in range(start, start + size, 4):
|
||||
off = elf.vaddr_to_offset(segs, va)
|
||||
if off is None or not _is_blr(struct.unpack_from("<I", data, off)[0]):
|
||||
continue
|
||||
prev = [struct.unpack_from("<I", data, o)[0] for k in range(1, 5)
|
||||
if va - 4 * k >= start and (o := elf.vaddr_to_offset(segs, va - 4 * k)) is not None]
|
||||
if wanted == "intercept" and MOV_X1_XZR in prev and MOV_W2_WZR in prev:
|
||||
out.append(off)
|
||||
break
|
||||
# the string lives in the first (read-only) segment, where file offset == address
|
||||
if wanted == "sampler" and _x0_address(data, segs, va) == sampler:
|
||||
out.append(off)
|
||||
break
|
||||
return out if len(out) == 2 else []
|
||||
|
||||
|
||||
class SlzVulkanHooks(Patch):
|
||||
id = "frame.slz_vulkan_hooks"
|
||||
title = "Stress Level Zero: let Unity start Vulkan itself"
|
||||
description = ("Stress Level Zero's graphics plugin (libSLZQuestNative.so, e.g. BONELAB 1.2974) hooks Unity's "
|
||||
"Vulkan start-up and its texture samplers. On the Frame the hook calls an invalid function while "
|
||||
"Vulkan starts, and the game crashes or hangs before the first frame. The plugin's two hooks are "
|
||||
"switched off, so Unity starts Vulkan itself. Its pipeline cache isn't used: the first start "
|
||||
"spends about half a minute prewarming shaders.")
|
||||
order = 47
|
||||
revision = 1
|
||||
|
||||
def applies(self, a):
|
||||
return "arm64-v8a" in a.abis and LIB in a.libs
|
||||
|
||||
def detect(self, a):
|
||||
if self.applies(a):
|
||||
return Suggestion(True, "Stress Level Zero's graphics plugin crashes Unity's Vulkan start-up on the Frame "
|
||||
"(e.g. BONELAB 1.2974).")
|
||||
return None
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
name = ws.lib(LIB)
|
||||
if ws.abi != "arm64-v8a" or not ws.has(name):
|
||||
return False
|
||||
data = ws.read(name)
|
||||
sites = find_registrations(data)
|
||||
if not sites:
|
||||
return False
|
||||
buf = bytearray(data)
|
||||
for off in sites:
|
||||
struct.pack_into("<I", buf, off, NOP)
|
||||
ws.put(name, bytes(buf))
|
||||
ctx.notes.append(f"{LIB}: Vulkan interception and vkCreateSampler hook switched off "
|
||||
f"({', '.join(hex(o) for o in sites)})")
|
||||
return True
|
||||
|
||||
def validate(self, ctx: ApkContext):
|
||||
ws = ctx.ws
|
||||
name = ws.lib(LIB)
|
||||
if not ws.has(name):
|
||||
return []
|
||||
left = find_registrations(ws.read(name))
|
||||
return [("SLZ Vulkan hooks off", not left, "" if not left else "still registered")]
|
||||
|
||||
|
||||
register(SlzVulkanHooks)
|
||||
@@ -16,6 +16,10 @@ ALWAYS_THERE = "android"
|
||||
# Unity's legacy frame loop never calls ovrp_WaitToBeginFrame: its ovrp_Update2 lookup goes to native/ovrpshim
|
||||
SHIM = "libfp_ovrp.so"
|
||||
UPDATE, SHIM_UPDATE = "ovrp_Update2", "fpov_Update2"
|
||||
# the shim follows Unity's frame begins/ends so its frame wait never blocks for a frame Unity didn't begin (Sniper Elite
|
||||
# VR deadlocked at a scene switch: xrWaitFrame waits for the previous frame's xrBeginFrame)
|
||||
BEGIN, SHIM_BEGIN = "ovrp_BeginFrame", "fpov_BeginFrame"
|
||||
END, SHIM_END = "ovrp_EndFrame", "fpov_EndFrame"
|
||||
# Unity creates its XR controller devices for the hand nodes OVRPlugin reports present (logged/fixed by the shim)
|
||||
NODE, SHIM_NODE = "ovrp_GetNodePresent", "fpov_GetNodePresent"
|
||||
# input diagnostics (off): the C# P/Invoke names in libil2cpp.so pointed at the shim's wrappers, which log what they
|
||||
@@ -76,13 +80,19 @@ class UnityOculusCheck(Patch):
|
||||
"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). 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 (e.g. Accounting+'s motion "
|
||||
"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 (e.g. 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 (e.g. Accounting+'s motion "
|
||||
"warning) and which Lepton never delivers.")
|
||||
order = 45
|
||||
# 2: frame wait also for Unity 2019 without the Oculus XR Plugin; 3: controller presses as mouse clicks
|
||||
# (ovrpshim); 4: Unity 2019's Oculus device-model checks (Touch controllers on Lepton)
|
||||
revision = 4
|
||||
# 5: frame begins gate the frame wait (no deadlock when Unity skips a frame); physics-step pose
|
||||
# updates kept from OVRPlugin (they located poses in the past: hands lagged); both only with the per-game settings
|
||||
# ovrp_begin_gate / ovrp_hold_physics (BattleSisters' hands lagged with them)
|
||||
revision = 5
|
||||
|
||||
@staticmethod
|
||||
def _major(a) -> int:
|
||||
@@ -117,6 +127,11 @@ class UnityOculusCheck(Patch):
|
||||
return False
|
||||
data, loops = elf.replace_rodata_string(data, UPDATE, SHIM_UPDATE) if self.legacy_loop(ctx.analysis) \
|
||||
else (data, 0)
|
||||
if loops:
|
||||
data, begins = elf.replace_rodata_string(data, BEGIN, SHIM_BEGIN)
|
||||
if begins: # the end goes with it: a begin the shim gave the waited frame's index ends with that index
|
||||
data, _ = elf.replace_rodata_string(data, END, SHIM_END)
|
||||
ctx.notes.append(f"libunity.so: {BEGIN}/{END} -> {SHIM_BEGIN}/{SHIM_END}")
|
||||
if loops and self._major(ctx.analysis) >= 2019: # Accounting+ (2017) reads OVRInput: works without
|
||||
data, models = oculus_model_checks(data)
|
||||
if models:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""OVRPlugin functions Unreal looks up that OVRPort's OVRPlugin lacks.
|
||||
|
||||
Unreal's Oculus module (FOculusHMDModule::InitializeOculusPluginWrapper) looks up every `ovrp_*` function of the
|
||||
OVRPlugin version it was built against with dlsym on libOVRPlugin.so's handle and ANDs the results: one missing name
|
||||
and the wrapper fails, OculusHMD never pre-initialises, no OpenXR session starts and the game later crashes without an
|
||||
HMD (e.g. Star Wars Pinball VR, UE 4.25 built against OVRPlugin 1.44: OVRPort's OpenXR OVRPlugin has no
|
||||
ovrp_GetPTWNear). OVRPort replaces the plugin, so the names come from the engine library and are compared with the
|
||||
plugin the build ships. Each missing one gets a stand-in that returns ovrpFailure (-1000), so callers take their "not
|
||||
supported" path and never read untouched output arguments. The stand-ins live in a generated library that
|
||||
libOVRPlugin.so loads (DT_NEEDED): dlsym on a library's handle also searches its dependencies, and the plugin's own
|
||||
functions are found first, so a later OVRPort that adds a function wins over its stand-in. The engine library stays
|
||||
byte-identical."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...analysis import elf
|
||||
from ...analysis.detect import ovrp_lookups
|
||||
from ...analysis.stubgen import build_stub_library
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
|
||||
PLUGIN = "libOVRPlugin.so"
|
||||
STUBS = "libfp_ovrpstubs.so"
|
||||
ENGINE_LIBS = ("libUE4.so", "libUnreal.so")
|
||||
OVRP_FAILURE = -1000 # ovrpResult ovrpFailure
|
||||
# fallback before OVRPort downloaded a runtime (nothing to compare with yet): names its OpenXR OVRPlugin (3.4.3) is
|
||||
# known to lack that Unreal builds look up. The build itself always compares with the plugin it ships.
|
||||
KNOWN_MISSING = ("ovrp_GetPTWNear",)
|
||||
|
||||
|
||||
def _shipped_exports(abi: str) -> set[str] | None:
|
||||
"""Exports of the newest OVRPort runtime's libOVRPlugin.so, cached per file version (None: none downloaded)."""
|
||||
from ...tools import overport
|
||||
|
||||
path = overport.runtime_lib(PLUGIN, abi)
|
||||
if not path:
|
||||
return None
|
||||
st = path.stat()
|
||||
key = (str(path), st.st_mtime_ns, st.st_size)
|
||||
if _cache.get("key") != key:
|
||||
_cache.update(key=key, exports=elf.dyn_symbols(path.read_bytes(), True))
|
||||
return _cache["exports"]
|
||||
|
||||
|
||||
_cache: dict = {}
|
||||
|
||||
|
||||
def missing_lookups(lookups, plugin_exports) -> list[str]:
|
||||
return sorted(set(lookups) - set(plugin_exports))
|
||||
|
||||
|
||||
class UnrealOvrpEntrypoints(Patch):
|
||||
id = "frame.unreal_ovrp_entrypoints"
|
||||
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 (e.g. ovrp_GetPTWNear, which "
|
||||
"Unreal 4.25 builds like Star Wars Pinball VR look up). The game then never starts an OpenXR "
|
||||
"session and crashes a few seconds in. Adds a generated library to libOVRPlugin.so with a stand-in "
|
||||
"for each missing function that reports failure (ovrpFailure), so Unreal's Oculus support starts. "
|
||||
"The functions OVRPort has are untouched.")
|
||||
order = 46
|
||||
|
||||
def applies(self, a):
|
||||
return a.engine == "Unreal" and PLUGIN in a.libs and "arm64-v8a" in a.abis
|
||||
|
||||
def detect(self, a):
|
||||
lookups = (a.extra or {}).get("unreal_ovrp_lookups") or []
|
||||
if not self.applies(a) or not lookups:
|
||||
return None
|
||||
exports = _shipped_exports("arm64-v8a")
|
||||
if exports is None:
|
||||
missing = sorted(set(lookups) & set(KNOWN_MISSING))
|
||||
else:
|
||||
missing = missing_lookups(lookups, exports)
|
||||
if not missing:
|
||||
return None
|
||||
return Suggestion(True, f"Unreal looks up OVRPlugin function(s) OVRPort's OVRPlugin lacks: "
|
||||
f"{', '.join(missing[:6])}{' …' if len(missing) > 6 else ''}. Without them Unreal "
|
||||
"starts no VR.")
|
||||
|
||||
def _missing(self, ctx: ApkContext) -> list[str]:
|
||||
ws = ctx.ws
|
||||
lookups = (ctx.analysis.extra or {}).get("unreal_ovrp_lookups")
|
||||
if not lookups: # analysed by an older FramePort: read the engine library itself
|
||||
engine = next((ws.lib(n) for n in ENGINE_LIBS if ws.has(ws.lib(n))), None)
|
||||
lookups = ovrp_lookups(ws.read(engine)) if engine else []
|
||||
return missing_lookups(lookups, elf.dyn_symbols(ws.read_lib(PLUGIN), True))
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
if ws.abi != "arm64-v8a" or not ws.has(ws.lib(PLUGIN)):
|
||||
return False
|
||||
missing = self._missing(ctx)
|
||||
if not missing:
|
||||
return False
|
||||
plugin = ws.read_lib(PLUGIN)
|
||||
stubs = build_stub_library(missing, STUBS, ws.abi, result=OVRP_FAILURE)
|
||||
changed = False
|
||||
if STUBS not in elf.needed(plugin):
|
||||
ws.put(ws.lib(PLUGIN), elf.add_needed(plugin, STUBS))
|
||||
changed = True
|
||||
if not ws.has(ws.lib(STUBS)) or ws.read_lib(STUBS) != stubs:
|
||||
ws.put(ws.lib(STUBS), stubs)
|
||||
changed = True
|
||||
if changed:
|
||||
ctx.notes.append(f"{len(missing)} OVRPlugin stand-in(s) returning ovrpFailure: {', '.join(missing)}")
|
||||
return changed
|
||||
|
||||
def validate(self, ctx):
|
||||
ws = ctx.ws
|
||||
if not ws.has(ws.lib(PLUGIN)):
|
||||
return []
|
||||
plugin = ws.read_lib(PLUGIN)
|
||||
exports = set(elf.dyn_symbols(plugin, True))
|
||||
if STUBS in elf.needed(plugin) and ws.has(ws.lib(STUBS)):
|
||||
exports |= elf.dyn_symbols(ws.read_lib(STUBS), True)
|
||||
lookups = (ctx.analysis.extra or {}).get("unreal_ovrp_lookups") or []
|
||||
unresolved = missing_lookups(lookups, exports)
|
||||
return [("OVRPlugin functions Unreal looks up resolvable", not unresolved, ", ".join(unresolved[:5]))]
|
||||
|
||||
|
||||
register(UnrealOvrpEntrypoints)
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Unreal games that pick Quest code paths with IsRunningOnSantaCruz() (ILMxLAB's Unreal 4 fork, e.g. Vader Immortal).
|
||||
|
||||
The engine answers "not a Quest" on the Frame (Lepton's Android isn't a Quest), so the game takes its Gear VR / PC
|
||||
paths. Two of them leave a Quest build stuck; each fix rewrites one instruction in the engine library at the branch that
|
||||
matters and changes nothing else (the device stays "not a Quest" everywhere else):
|
||||
|
||||
* GetQuestShaderPrecompilePercent: the Quest branch reports the progress of the shader precompile the game starts on a
|
||||
Quest; the other branch returns 0.0 although no precompile was ever started there. The menu waits for 100 %: the
|
||||
loading card (Vader's portrait with a progress bar) never ends. The other branch returns 1.0 instead.
|
||||
* URPOCKeyMapManagerComponent's key selector (shared by AddAxisMapping and AddActionMapping): for an OculusHMD it
|
||||
chooses the Quest ("SantaCruz") key set or the Gear VR one by IsRunningOnSantaCruz(). A Quest build's Gear VR set is
|
||||
empty, so grip/trigger/touch bindings never reach the game's hands (input arrives in Unreal, the game's axes stay 0).
|
||||
The branch to the Gear VR set is removed for the OculusHMD case only.
|
||||
|
||||
Both are located by their exported symbol and checked instruction by instruction (found by Klownicle in Vader Immortal:
|
||||
Episode I 1.1.1, GitHub #49); a library whose code doesn't match exactly is left alone. Already patched code counts as
|
||||
done."""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from ...analysis import elf
|
||||
from ...analysis.detect import UNREAL_QUEST_GATES
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
|
||||
ENGINE_LIBS = ("libUE4.so", "libUnreal.so")
|
||||
SANTACRUZ = ("_ZN15FSantaCruzUtils20IsRunningOnSantaCruzEv", "_ZN23UNTNinjaFunctionLibrary20IsRunningOnSantaCruzEv")
|
||||
PRECOMPILE = UNREAL_QUEST_GATES["quest_precompile"]
|
||||
ADD_AXIS = UNREAL_QUEST_GATES["rpoc_keymap"]
|
||||
ADD_ACTION = "_ZN27URPOCKeyMapManagerComponent16AddActionMappingERK15FRPOCKeyMappingR16FRPOCInputMapSet"
|
||||
|
||||
FMOV_S0_ZERO = 0x1E2703E0 # fmov s0, wzr
|
||||
FMOV_S0_ONE = 0x1E2E1000 # fmov s0, #1.0
|
||||
LDP_FP_LR = 0xA8C17BFD # ldp x29, x30, [sp], #0x10
|
||||
RET = 0xD65F03C0
|
||||
NOP = 0xD503201F
|
||||
SELECTOR_SCAN = 0x400 # bytes of the key selector searched for the branch
|
||||
|
||||
|
||||
def _words(data: bytes, segs, va: int, size: int) -> list[int] | None:
|
||||
off = elf.vaddr_to_offset(segs, va)
|
||||
if off is None or size <= 0 or off + size > len(data):
|
||||
return None
|
||||
return list(struct.unpack_from(f"<{size // 4}I", data, off))
|
||||
|
||||
|
||||
def _bl_target(va: int, w: int) -> int | None:
|
||||
if w & 0xFC000000 != 0x94000000:
|
||||
return None
|
||||
imm = w & 0x03FFFFFF
|
||||
return va + ((imm - (1 << 26) if imm & (1 << 25) else imm) << 2)
|
||||
|
||||
|
||||
def _tbz_w0_0_target(va: int, w: int) -> int | None: # tbz w0, #0, <target>
|
||||
if w & 0xFFF8001F != 0x36000000:
|
||||
return None
|
||||
imm = (w >> 5) & 0x3FFF
|
||||
return va + ((imm - (1 << 14) if imm & (1 << 13) else imm) << 2)
|
||||
|
||||
|
||||
def _is_add_x0_x19(w: int) -> bool: # add x0, x19, #imm
|
||||
return w & 0xFFC003FF == 0x91000260
|
||||
|
||||
|
||||
def _is_b(w: int) -> bool:
|
||||
return w & 0xFC000000 == 0x14000000
|
||||
|
||||
|
||||
def precompile_site(data: bytes) -> tuple[int, int] | None:
|
||||
"""(file offset, current word) of the non-Quest return value in GetQuestShaderPrecompilePercent: the target of
|
||||
`bl IsRunningOnSantaCruz; tbz w0, #0, L` where L is `fmov s0, wzr; ldp x29, x30, [sp], #16; ret` (or already
|
||||
`fmov s0, #1.0`)."""
|
||||
syms = elf.find_symbols(data, (PRECOMPILE, *SANTACRUZ))
|
||||
if PRECOMPILE not in syms:
|
||||
return None
|
||||
targets = {syms[s][0] for s in SANTACRUZ if s in syms}
|
||||
va, size = syms[PRECOMPILE]
|
||||
segs = elf.load_segments(data)
|
||||
words = _words(data, segs, va, min(size, 0x100))
|
||||
if not words or not targets:
|
||||
return None
|
||||
sites = []
|
||||
for i in range(len(words) - 1):
|
||||
if _bl_target(va + 4 * i, words[i]) not in targets:
|
||||
continue
|
||||
to = _tbz_w0_0_target(va + 4 * (i + 1), words[i + 1])
|
||||
j = (to - va) // 4 if to is not None else -1
|
||||
if 0 <= j < len(words) - 2 and words[j] in (FMOV_S0_ZERO, FMOV_S0_ONE) and words[j + 1] == LDP_FP_LR \
|
||||
and words[j + 2] == RET:
|
||||
sites.append((elf.vaddr_to_offset(segs, va + 4 * j), words[j]))
|
||||
return sites[0] if len(sites) == 1 else None
|
||||
|
||||
|
||||
def keymap_site(data: bytes) -> tuple[int, int] | None:
|
||||
"""(file offset, current word) of the `tbz w0, #0, <Gear VR keys>` after `bl IsRunningOnSantaCruz` in the key
|
||||
selector both URPOCKeyMapManagerComponent::AddAxisMapping and ::AddActionMapping call. The Quest side must be
|
||||
`add x0, x19, #field; b <return>` and the Gear VR side the same with another field; a nop there = already done."""
|
||||
syms = elf.find_symbols(data, (ADD_AXIS, ADD_ACTION, *SANTACRUZ))
|
||||
if ADD_AXIS not in syms or ADD_ACTION not in syms:
|
||||
return None
|
||||
targets = {syms[s][0] for s in SANTACRUZ if s in syms}
|
||||
segs = elf.load_segments(data)
|
||||
calls = []
|
||||
for name in (ADD_AXIS, ADD_ACTION):
|
||||
va, size = syms[name]
|
||||
words = _words(data, segs, va, min(size, 0x1000)) or []
|
||||
calls.append({_bl_target(va + 4 * i, w) for i, w in enumerate(words)} - {None})
|
||||
sites = []
|
||||
for sel in sorted(calls[0] & calls[1]):
|
||||
words = _words(data, segs, sel, SELECTOR_SCAN)
|
||||
if not words:
|
||||
continue
|
||||
for i in range(len(words) - 3):
|
||||
if _bl_target(sel + 4 * i, words[i]) not in targets:
|
||||
continue
|
||||
branch, quest, back = words[i + 1], words[i + 2], words[i + 3]
|
||||
if not (_is_add_x0_x19(quest) and _is_b(back)):
|
||||
continue
|
||||
if branch == NOP:
|
||||
sites.append((elf.vaddr_to_offset(segs, sel + 4 * (i + 1)), NOP))
|
||||
continue
|
||||
to = _tbz_w0_0_target(sel + 4 * (i + 1), branch)
|
||||
j = (to - sel) // 4 if to is not None else -1
|
||||
if 0 <= j < len(words) - 1 and _is_add_x0_x19(words[j]) and words[j] != quest and _is_b(words[j + 1]):
|
||||
sites.append((elf.vaddr_to_offset(segs, sel + 4 * (i + 1)), branch))
|
||||
return sites[0] if len(sites) == 1 else None
|
||||
|
||||
|
||||
def _engine(ws) -> str | None:
|
||||
return next((n for n in ENGINE_LIBS if ws.has(ws.lib(n))), None)
|
||||
|
||||
|
||||
class _SantaCruzGate(Patch):
|
||||
gate = ""
|
||||
done_word = 0
|
||||
order = 47
|
||||
|
||||
def site(self, data: bytes) -> tuple[int, int] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
def applies(self, a):
|
||||
if a.engine != "Unreal" or "arm64-v8a" not in a.abis:
|
||||
return False
|
||||
gates = (a.extra or {}).get("unreal_quest_gates")
|
||||
return gates is None or self.gate in gates # analysed by an older FramePort: maybe
|
||||
|
||||
def detect(self, a):
|
||||
if self.gate in ((a.extra or {}).get("unreal_quest_gates") or []) and "arm64-v8a" in a.abis:
|
||||
return Suggestion(True, self.reason)
|
||||
return None
|
||||
|
||||
reason = ""
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
lib = _engine(ws) if ws.abi == "arm64-v8a" else None
|
||||
if not lib:
|
||||
return False
|
||||
data = ws.read_lib(lib)
|
||||
found = self.site(data)
|
||||
if not found:
|
||||
ctx.notes.append(f"{self.id}: {lib} doesn't match the expected code, left unchanged")
|
||||
return False
|
||||
off, word = found
|
||||
if word == self.done_word:
|
||||
return False
|
||||
buf = bytearray(data)
|
||||
struct.pack_into("<I", buf, off, self.done_word)
|
||||
ws.put(ws.lib(lib), bytes(buf))
|
||||
ctx.notes.append(f"{self.note} ({lib} file offset {off:#x})")
|
||||
return True
|
||||
|
||||
def validate(self, ctx: ApkContext):
|
||||
ws = ctx.ws
|
||||
lib = _engine(ws)
|
||||
if not lib or ws.abi != "arm64-v8a":
|
||||
return []
|
||||
found = self.site(ws.read_lib(lib))
|
||||
return [(self.check, bool(found) and found[1] == self.done_word,
|
||||
"" if found else "code not found (different build)")]
|
||||
|
||||
|
||||
class QuestPrecompileDone(_SantaCruzGate):
|
||||
id = "frame.unreal_quest_precompile"
|
||||
title = "Unreal: don't wait for the Quest shader precompile"
|
||||
description = ("ILMxLAB's Unreal games (e.g. Vader Immortal) wait on their loading card until "
|
||||
"UVRUtils::GetQuestShaderPrecompilePercent reaches 100 %. The precompile only starts on a Quest "
|
||||
"(IsRunningOnSantaCruz); on any other device the function returns 0.0, so the loading card (a "
|
||||
"portrait with a progress bar) never ends. Makes that non-Quest branch return 1.0. The Quest "
|
||||
"branch and the game's normal shader compiling stay as they are. Found by Klownicle (GitHub #49).")
|
||||
gate = "quest_precompile"
|
||||
done_word = FMOV_S0_ONE
|
||||
note = "GetQuestShaderPrecompilePercent: non-Quest branch returns 1.0"
|
||||
check = "Quest shader precompile reported finished"
|
||||
reason = ("The menu waits for a Quest-only shader precompile that never starts on the Frame (stuck loading card, "
|
||||
"e.g. Vader Immortal).")
|
||||
|
||||
def site(self, data):
|
||||
return precompile_site(data)
|
||||
|
||||
|
||||
class QuestKeyMap(_SantaCruzGate):
|
||||
id = "frame.unreal_quest_keymap"
|
||||
title = "Unreal: Quest controller bindings (RPOC key map)"
|
||||
description = ("ILMxLAB's Unreal games (e.g. Vader Immortal) bind their controls through URPOCKeyMapManager"
|
||||
"Component, which for an Oculus headset picks the Quest (\"SantaCruz\") or the Gear VR key set by "
|
||||
"IsRunningOnSantaCruz. On the Frame it picks Gear VR, which is empty in a Quest build: grip, "
|
||||
"trigger and touch reach Unreal but never the game's hands (grabbing does nothing). Removes the "
|
||||
"branch to the Gear VR set in the Oculus case of the key selector both action and axis mappings "
|
||||
"use. Found by Klownicle (GitHub #49).")
|
||||
gate = "rpoc_keymap"
|
||||
done_word = NOP
|
||||
note = "RPOC key map: Oculus headsets get the Quest key set"
|
||||
check = "Quest key set selected for Oculus headsets"
|
||||
reason = ("The game's key map picks the empty Gear VR controls when the headset isn't a Quest (grip/trigger do "
|
||||
"nothing, e.g. Vader Immortal).")
|
||||
|
||||
def site(self, data):
|
||||
return keymap_site(data)
|
||||
|
||||
|
||||
register(QuestPrecompileDone)
|
||||
register(QuestKeyMap)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Unreal 4's Oculus input: the ThumbUp capacitive axis from the capacitive touches instead of near-touch.
|
||||
|
||||
UE4's stock OculusInput (FOculusInput::SendControllerEvents) sets each controller's ThumbUp axis ("thumb lifted", what
|
||||
games animate the hand's thumb with) from OVRPlugin's NearTouches (ovrpNearTouch_LThumbButtons 0x2 / RThumbButtons
|
||||
0x8): 1.0 when the thumb is near no button. The Frame's runtime has no XR_FB_touch_controller_proximity, so near-touch
|
||||
is never reported and the thumb always points up, whatever it touches (FrameBridge's proximity_emul binding didn't
|
||||
reach these games either). The fix uses the actual touches instead, with the same inversion: the left hand's
|
||||
X/Y/stick/thumb rest (0x0f00), the right hand's A/B/stick/thumb rest (0x000f).
|
||||
|
||||
Three instructions change in the capacitive-axis loop: the two mask constants the per-hand ThumbUp mask is selected
|
||||
from (`mov wA, #2; mov wB, #8; csel wM, wA, wB, <hand>` → 0xf00 / 0xf) and the load of NearTouches before
|
||||
`tst wN, wM; fcsel` (its stack slot → the Touches slot 4 bytes before it, which the other touch axes load). The code is
|
||||
located by its exported symbol and checked instruction by instruction; anything else is left alone. Found by Klownicle
|
||||
in Vader Immortal: Episode I 1.1.1 (GitHub #49); the same compiled code is in other UE4 Oculus games of that engine
|
||||
generation (e.g. Robo Recall, Phantom: Covert Ops, Time Stall, Star Wars: Tales from the Galaxy's Edge)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from ...analysis import elf
|
||||
from ...analysis.detect import UNREAL_THUMB_TOUCH
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
|
||||
ENGINE_LIBS = ("libUE4.so", "libUnreal.so")
|
||||
NEAR_LEFT, NEAR_RIGHT = 0x2, 0x8 # ovrpNearTouch_LThumbButtons / RThumbButtons
|
||||
TOUCH_LEFT, TOUCH_RIGHT = 0xF00, 0x00F # X|Y|LThumb|LThumbRest / A|B|RThumb|RThumbRest
|
||||
ORR_WZR = {0x321F03E0: 0x2, 0x321D03E0: 0x8} # orr wD, wzr, #imm (logical immediates the compiler used)
|
||||
SCAN_LIMIT = 0x4000 # bytes of the function searched
|
||||
|
||||
|
||||
def _const(w: int) -> tuple[int, int] | None:
|
||||
"""(register, value) of `orr wD, wzr, #2|#8` or `movz wD, #imm` (shift 0)."""
|
||||
if w & 0xFFFFFFE0 in ORR_WZR:
|
||||
return w & 31, ORR_WZR[w & 0xFFFFFFE0]
|
||||
if w & 0xFFE00000 == 0x52800000:
|
||||
return w & 31, (w >> 5) & 0xFFFF
|
||||
return None
|
||||
|
||||
|
||||
def _movz(reg: int, value: int) -> int:
|
||||
return 0x52800000 | value << 5 | reg
|
||||
|
||||
|
||||
def _csel(w: int) -> tuple[int, int, int] | None: # csel wD, wN, wM, cond -> (d, n, m)
|
||||
if w & 0xFFE00C00 != 0x1A800000:
|
||||
return None
|
||||
return w & 31, (w >> 5) & 31, (w >> 16) & 31
|
||||
|
||||
|
||||
def _tst(w: int) -> tuple[int, int] | None: # tst wN, wM (ands wzr, wN, wM) -> (n, m)
|
||||
if w & 0xFFE0FC1F != 0x6A00001F:
|
||||
return None
|
||||
return (w >> 5) & 31, (w >> 16) & 31
|
||||
|
||||
|
||||
def _is_fcsel(w: int) -> bool:
|
||||
return w & 0xFFE00C00 == 0x1E200C00
|
||||
|
||||
|
||||
def _load(w: int) -> tuple[int, int, int] | None:
|
||||
"""(Rt, base register, byte offset) of `ldr wT, [xN, #imm]` (unsigned offset) or `ldur wT, [xN, #simm]`."""
|
||||
if w & 0xFFC00000 == 0xB9400000:
|
||||
return w & 31, (w >> 5) & 31, ((w >> 10) & 0xFFF) * 4
|
||||
if w & 0xFFE00C00 == 0xB8400000:
|
||||
imm = (w >> 12) & 0x1FF
|
||||
return w & 31, (w >> 5) & 31, imm - 0x200 if imm & 0x100 else imm
|
||||
return None
|
||||
|
||||
|
||||
def _with_offset(w: int, offset: int) -> int | None:
|
||||
if w & 0xFFC00000 == 0xB9400000:
|
||||
return (w & ~(0xFFF << 10)) | (offset // 4) << 10 if 0 <= offset < 0x4000 and offset % 4 == 0 else None
|
||||
if w & 0xFFE00C00 == 0xB8400000:
|
||||
return (w & ~(0x1FF << 12)) | (offset & 0x1FF) << 12 if -256 <= offset < 256 else None
|
||||
return None
|
||||
|
||||
|
||||
def thumb_site(data: bytes) -> dict | None:
|
||||
"""The ThumbUp code in OculusInput::FOculusInput::SendControllerEvents: {"edits": [(file offset, old word, new
|
||||
word), ...], "done": bool}, or None when the code isn't the expected one (exactly one site must match)."""
|
||||
syms = elf.find_symbols(data, (UNREAL_THUMB_TOUCH,))
|
||||
if UNREAL_THUMB_TOUCH not in syms:
|
||||
return None
|
||||
va, size = syms[UNREAL_THUMB_TOUCH]
|
||||
segs = elf.load_segments(data)
|
||||
off = elf.vaddr_to_offset(segs, va)
|
||||
size = min(size, SCAN_LIMIT) & ~3
|
||||
if off is None or size <= 0 or off + size > len(data):
|
||||
return None
|
||||
words = list(struct.unpack_from(f"<{size // 4}I", data, off))
|
||||
loads = [_load(w) for w in words]
|
||||
sites = []
|
||||
for i in range(len(words) - 4):
|
||||
a, b = _const(words[i]), _const(words[i + 1])
|
||||
if not a or not b or a[0] == b[0]:
|
||||
continue
|
||||
pair = {a[1], b[1]}
|
||||
if pair not in ({NEAR_LEFT, NEAR_RIGHT}, {TOUCH_LEFT, TOUCH_RIGHT}):
|
||||
continue
|
||||
done = pair == {TOUCH_LEFT, TOUCH_RIGHT}
|
||||
# the csel picks one of the two per hand, at most one unrelated instruction in between
|
||||
sel, k = None, i + 2
|
||||
for k in (i + 2, i + 3):
|
||||
c = _csel(words[k])
|
||||
if c and {c[1], c[2]} == {a[0], b[0]}:
|
||||
sel = c
|
||||
break
|
||||
if any((words[k] >> s) & 31 in (a[0], b[0]) for s in (0, 5, 16)):
|
||||
break # something else uses the registers
|
||||
if not sel:
|
||||
continue
|
||||
mask = sel[0]
|
||||
for j in range(k + 1, len(words) - 2):
|
||||
t = _tst(words[j + 1])
|
||||
ld = loads[j]
|
||||
if not (t and ld and t[1] == mask and t[0] == ld[0] and _is_fcsel(words[j + 2])):
|
||||
continue
|
||||
base, at = ld[1], ld[2]
|
||||
touches = at if done else at - 4
|
||||
# the other touch axes load the Touches slot (NearTouches is the next field of ovrpControllerState)
|
||||
others = sum(1 for n, x in enumerate(loads) if n != j and x and x[1] == base and x[2] == touches)
|
||||
if others < 2:
|
||||
continue
|
||||
new_load = words[j] if done else _with_offset(words[j], touches)
|
||||
if new_load is None:
|
||||
continue
|
||||
new = {NEAR_LEFT: TOUCH_LEFT, NEAR_RIGHT: TOUCH_RIGHT}
|
||||
edits = [(off + 4 * i, words[i], _movz(a[0], new.get(a[1], a[1]))),
|
||||
(off + 4 * (i + 1), words[i + 1], _movz(b[0], new.get(b[1], b[1]))),
|
||||
(off + 4 * j, words[j], new_load)]
|
||||
sites.append({"edits": edits, "done": done})
|
||||
return sites[0] if len(sites) == 1 else None
|
||||
|
||||
|
||||
def _engine(ws) -> str | None:
|
||||
return next((n for n in ENGINE_LIBS if ws.has(ws.lib(n))), None)
|
||||
|
||||
|
||||
class UnrealThumbTouch(Patch):
|
||||
id = "frame.unreal_thumb_touch"
|
||||
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 "
|
||||
"touches instead (left X/Y/stick/thumb rest, right A/B/stick/thumb rest). Changes three "
|
||||
"instructions in OculusInput::SendControllerEvents, only where the code matches exactly (UE4 "
|
||||
"Oculus games of one engine generation, e.g. Vader Immortal, Robo Recall). Found by Klownicle "
|
||||
"(GitHub #49).")
|
||||
order = 47
|
||||
note = "OculusInput ThumbUp: from the capacitive touches (left 0x0f00, right 0x000f)"
|
||||
reason = ("Unreal's Oculus input animates the thumb from near-touch sensing the Frame lacks (the thumb always "
|
||||
"points up, e.g. Vader Immortal).")
|
||||
|
||||
def applies(self, a):
|
||||
if a.engine != "Unreal" or "arm64-v8a" not in a.abis:
|
||||
return False
|
||||
found = (a.extra or {}).get("unreal_thumb_touch")
|
||||
return found is None or bool(found) # None: analysed by an older FramePort, maybe
|
||||
|
||||
def detect(self, a):
|
||||
if (a.extra or {}).get("unreal_thumb_touch") and "arm64-v8a" in a.abis:
|
||||
return Suggestion(True, self.reason)
|
||||
return None
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
lib = _engine(ws) if ws.abi == "arm64-v8a" else None
|
||||
if not lib:
|
||||
return False
|
||||
data = ws.read_lib(lib)
|
||||
site = thumb_site(data)
|
||||
if not site:
|
||||
ctx.notes.append(f"{self.id}: {lib} doesn't match the expected code, left unchanged")
|
||||
return False
|
||||
if site["done"]:
|
||||
return False
|
||||
buf = bytearray(data)
|
||||
for at, _old, new in site["edits"]:
|
||||
struct.pack_into("<I", buf, at, new)
|
||||
ws.put(ws.lib(lib), bytes(buf))
|
||||
ctx.notes.append(f"{self.note} ({lib} file offsets " + ", ".join(f"{e[0]:#x}" for e in site["edits"]) + ")")
|
||||
return True
|
||||
|
||||
def validate(self, ctx: ApkContext):
|
||||
ws = ctx.ws
|
||||
lib = _engine(ws)
|
||||
if not lib or ws.abi != "arm64-v8a":
|
||||
return []
|
||||
site = thumb_site(ws.read_lib(lib))
|
||||
return [("ThumbUp reads the capacitive touches", bool(site) and site["done"],
|
||||
"" if site else "code not found (different build)")]
|
||||
|
||||
|
||||
register(UnrealThumbTouch)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Vivox voice chat (Unity's Vivox package, e.g. Green Hell VR, GitHub #101) crashes at start in Lepton: its Java
|
||||
audio-route listener (com.vivox.sdk.AudioChangeListener) calls Android 12 AudioManager methods
|
||||
(getAvailableCommunicationDevices, setCommunicationDevice, ...) without checking the Android version, and Lepton runs
|
||||
Android 11 (this Vivox build has no version checks at all) → java.lang.NoSuchMethodError. Every method of that class
|
||||
that calls one of them is made to return at once (in-place classes.dex edit: void methods return, boolean/int ones
|
||||
return false/0; methods returning objects are left alone). Voice chat then keeps Android's default audio route; nothing
|
||||
else changes. The calls themselves can't simply be skipped (the following move-result would fail Android's bytecode
|
||||
verifier), which is why whole methods return instead."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...apk.dex import Dex
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
|
||||
LISTENER = "Lcom/vivox/sdk/AudioChangeListener;"
|
||||
AUDIO_MANAGER = "Landroid/media/AudioManager;"
|
||||
API31 = ("getAvailableCommunicationDevices", "setCommunicationDevice", "clearCommunicationDevice",
|
||||
"getCommunicationDevice", "addOnCommunicationDeviceChangedListener",
|
||||
"removeOnCommunicationDeviceChangedListener")
|
||||
|
||||
|
||||
def patch_dex(dex: Dex) -> list[str]:
|
||||
"""Methods of Vivox's AudioChangeListener that now return at once; [] if nothing applies."""
|
||||
targets = {i for name in API31 for i in dex.method_index(AUDIO_MANAGER, name)}
|
||||
if not targets:
|
||||
return []
|
||||
done = []
|
||||
for idx, code in dex.class_methods(LISTENER):
|
||||
if not dex.invoked(code) & targets:
|
||||
continue
|
||||
if dex.return_early(code, dex.return_type(idx)):
|
||||
done.append(dex.method(idx)[1])
|
||||
return done
|
||||
|
||||
|
||||
class VivoxAudioRoute(Patch):
|
||||
id = "frame.vivox_audio_route"
|
||||
title = "Vivox voice chat: skip Android 12 audio routing"
|
||||
description = ("Vivox's voice-chat SDK (e.g. 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.")
|
||||
order = 44
|
||||
needs_vr = False
|
||||
|
||||
def applies(self, a):
|
||||
return bool((a.extra or {}).get("vivox_api31"))
|
||||
|
||||
def detect(self, a):
|
||||
if self.applies(a):
|
||||
return Suggestion(True, "Vivox voice chat: it calls Android 12 audio methods, which Lepton (Android 11) "
|
||||
"lacks, and crashes.")
|
||||
return None
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
changed = False
|
||||
for name in sorted(n for n in ctx.ws.names() if n.startswith("classes") and n.endswith(".dex")):
|
||||
data = ctx.ws.read(name)
|
||||
if LISTENER.encode() not in data:
|
||||
continue
|
||||
dex = Dex(data)
|
||||
done = patch_dex(dex)
|
||||
if done:
|
||||
ctx.ws.put(name, dex.finish())
|
||||
ctx.notes.append(f"{name}: Vivox AudioChangeListener returns at once in {', '.join(sorted(done))}")
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
register(VivoxAudioRoute)
|
||||
@@ -108,6 +108,31 @@ class UnityGlShim(GlShim):
|
||||
return a.engine == "Unity" and "GLES" in a.graphics and "arm64-v8a" in a.abis
|
||||
|
||||
|
||||
class UnrealGlShim(GlShim):
|
||||
id = "frame.unreal_gl_shim"
|
||||
title = "GL shim for Unreal (no multisampled render-to-texture)"
|
||||
description = (
|
||||
"Loads the GL shim into an Unreal GLES game and hides GL_EXT_multisampled_render_to_texture. Unreal's mobile "
|
||||
"MSAA renders through multisampled render-to-texture, and the Frame's GL driver (Mesa/Zink) can crash on it: "
|
||||
"SIGSEGV with fault address 0x10000 in libgallium_dri.so on the RHIThread a few seconds after start (Zink "
|
||||
"looks up a render-pass cache slot past its end, e.g. Star Wars Pinball VR). Hidden, Unreal renders without "
|
||||
"that path. Unreal's multiview stays on: Unreal only uses it when GL_OVR_multiview_multisampled_render_to_"
|
||||
"texture is listed too, so for Unreal the shim keeps that one visible and draws its multiview MSAA "
|
||||
"attachments single-sampled (glFramebufferTextureMultiviewOVR).")
|
||||
order = 62
|
||||
requires = ()
|
||||
experimental = False
|
||||
|
||||
def detect(self, a):
|
||||
# not suggested from the APK alone: most Unreal GLES games run without it; the launch test's triage
|
||||
# (unreal-msrtt-crash) and catalog recipes turn it on
|
||||
return None
|
||||
|
||||
def applies(self, a):
|
||||
return a.engine == "Unreal" and "GLES" in a.graphics and "arm64-v8a" in a.abis
|
||||
|
||||
|
||||
register(VrApiBridge)
|
||||
register(GlShim)
|
||||
register(UnityGlShim)
|
||||
register(UnrealGlShim)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Shader fixes for OpenGL ES games: a Vulkan layer between Zink and the Frame's driver (native/zinkfix).
|
||||
|
||||
On the Frame, OpenGL ES runs on Zink (Mesa's GL on Vulkan), which turns the game's GLSL into SPIR-V inside the driver.
|
||||
The Vulkan shim's vk_shader_fix only reaches engines that load Vulkan themselves, so GLES games get this layer instead:
|
||||
it inserts words into the SPIR-V modules whose size and SHA-256 match the adapter setting zink_shader_fix (same format
|
||||
as vk_shader_fix) and passes every other module through. The engine library loads the layer library first
|
||||
(DT_NEEDED); its constructor adds the layer to the process's Vulkan debug layer list (Android's GraphicsEnv) before
|
||||
the game starts EGL. Found for Vader Immortal's lightspeed shaders (loop counters and accumulators read before they
|
||||
are set hang the GPU) by Klownicle, GitHub #49."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...analysis import elf
|
||||
from ..base import ApkContext, Patch, register
|
||||
from . import artifact
|
||||
|
||||
LAYER = "libVkLayer_fp_shaderfix.so"
|
||||
ENGINE_LIBS = ("libUE4.so", "libUnreal.so", "libunity.so")
|
||||
SETTING = "adapter.zink_shader_fix"
|
||||
# not the engine: XR/Meta/FramePort libraries that may start EGL too
|
||||
NOT_ENGINE = ("libopenxr", "libOVR", "libovr", "libvrapi", "libfp_", "libframe", "libVkLayer", "libglshim", "libc++")
|
||||
|
||||
|
||||
def engine_lib(ws) -> str | None:
|
||||
"""The library that starts EGL: the engine's, else the first of the game's libraries that imports eglInitialize."""
|
||||
lib = next((n for n in ENGINE_LIBS if ws.has(ws.lib(n))), None)
|
||||
if lib:
|
||||
return lib
|
||||
for name in ws.libs():
|
||||
if name.startswith(NOT_ENGINE):
|
||||
continue
|
||||
data = ws.read_lib(name)
|
||||
if elf.is_elf(data) and "eglInitialize" in elf.dyn_symbols(data, False):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def configured(recipe_patches: dict) -> bool:
|
||||
params = recipe_patches.get(SETTING) or {}
|
||||
dump = (recipe_patches.get("adapter.zink_shader_dump") or {}).get("value")
|
||||
return bool(str(params.get("value") or "").strip()) or bool(dump)
|
||||
|
||||
|
||||
class ZinkShaderFix(Patch):
|
||||
id = "frame.zink_shader_fix"
|
||||
title = "OpenGL ES: shader fixes (Vulkan layer under Zink)"
|
||||
description = ("Loads FramePort's shader-fix Vulkan layer into an OpenGL ES game. On the Frame, OpenGL ES runs on "
|
||||
"Zink (Mesa's GL on Vulkan); the layer sits between Zink and the driver and inserts the words of "
|
||||
"the adapter setting zink_shader_fix into the SPIR-V modules whose size and SHA-256 match (every "
|
||||
"other module is untouched), e.g. stores that set loop counters and accumulators a shader reads "
|
||||
"before writing, which hang the GPU (Vader Immortal's lightspeed jump; found by Klownicle, GitHub "
|
||||
"#49). Does nothing without zink_shader_fix (or zink_shader_dump) in the recipe. The layer adds "
|
||||
"itself to Android's Vulkan debug layer list for the game's process only.")
|
||||
order = 73
|
||||
|
||||
def applies(self, a):
|
||||
return "GLES" in a.graphics and "arm64-v8a" in a.abis
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
if ws.abi != "arm64-v8a" or not configured(ctx.recipe_patches):
|
||||
return False
|
||||
lib = engine_lib(ws)
|
||||
if not lib:
|
||||
ctx.notes.append(f"{self.id}: no library that starts EGL found, layer not added")
|
||||
return False
|
||||
layer = artifact(ws.abi, LAYER)
|
||||
changed = False
|
||||
data = ws.read_lib(lib)
|
||||
if LAYER not in elf.needed(data):
|
||||
ws.put(ws.lib(lib), elf.add_needed(data, LAYER))
|
||||
changed = True
|
||||
if not ws.has(ws.lib(LAYER)) or ws.read_lib(LAYER) != layer:
|
||||
ws.put(ws.lib(LAYER), layer)
|
||||
changed = True
|
||||
if changed:
|
||||
ctx.notes.append(f"shader-fix Vulkan layer loaded by {lib}")
|
||||
return changed
|
||||
|
||||
def validate(self, ctx: ApkContext):
|
||||
ws = ctx.ws
|
||||
if ws.abi != "arm64-v8a" or not configured(ctx.recipe_patches):
|
||||
return []
|
||||
lib = engine_lib(ws)
|
||||
loaded = bool(lib) and LAYER in elf.needed(ws.read_lib(lib))
|
||||
return [("Shader-fix layer present and loaded", loaded and ws.has(ws.lib(LAYER)), lib or "no EGL library")]
|
||||
|
||||
|
||||
register(ZinkShaderFix)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Adapter settings end up in lib/<abi>/libframe_settings.so (build time) and in <install>/settings.conf +
|
||||
Android/data/<pkg>/files/framebridge.conf on the Frame (can be changed later without re-patching).
|
||||
The GL shim reads framebridge.conf too (gl_hide_multiview).
|
||||
The GL shim reads framebridge.conf too (gl_hide_multiview), as does the multiview interposer (gl_mv_debug).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -33,6 +33,10 @@ SETTINGS = [
|
||||
"tracking (e.g. Silhouette)."),
|
||||
("swapchain_fix", "int", 1, "Swapchain format fallback",
|
||||
"Retry rejected GLES formats/MSAA with sRGB, samples=1."),
|
||||
("cube_standin", "int", 1, "Stand-in cube swapchains",
|
||||
"The Frame's runtime has no cube-map layers and refuses their swapchains; OVRPlugin then crashes when it submits "
|
||||
"the next frame (e.g. Budget Cuts Ultimate). FrameBridge serves such a swapchain itself (a GL cube map, GLES "
|
||||
"games) and drops its layers, so the game runs on without that layer."),
|
||||
("layer_fix", "int", 1, "Drop invalid layers",
|
||||
"Drop layers whose swapchain failed or whose extension isn't enabled."),
|
||||
("passthrough_emul", "int", 1, "Emulate passthrough",
|
||||
@@ -129,6 +133,21 @@ SETTINGS = [
|
||||
"Repeat xrLocateViews queries for the same display time get the first, fully tracked answer again. Games that "
|
||||
"ask several times per frame got slightly different poses and rendered parts of the frame with different heads: "
|
||||
"judder (e.g. I Am Cat; proposed by Klownicle, GitHub #8)."),
|
||||
("pose_time_fix", "int", 0, "Locate poses at the right time",
|
||||
"Poses a game asks for at a CLOCK_MONOTONIC time, or more than 0.5 s before the frame's display time, are located "
|
||||
"at the same moment in the runtime's time (or now). Meta's OVRPlugin passes the monotonic clock on as the "
|
||||
"OpenXR time, which on the Frame runs seconds behind the runtime's clock, so its \"now\" poses (Unity's physics "
|
||||
"step, controller poses) came from the past: hands trailing the controllers (e.g. BattleSisters, Sniper Elite "
|
||||
"VR)."),
|
||||
("proximity_emul", "int", 0, "Finger proximity from touch",
|
||||
"Meta's OVRPlugin reports a thumb (or index finger) resting near the controller through "
|
||||
"XR_FB_touch_controller_proximity, which the Frame's runtime lacks: \"near\" stays false and games that animate "
|
||||
"the hands from it show a thumb that never moves (e.g. Vader Immortal; found by Klownicle, GitHub #49). Binds the "
|
||||
"game's proximity actions to the capacitive touch inputs instead: 1 = thumb (thumbstick, face buttons or thumb "
|
||||
"rest touched), 2 = also the index finger (trigger touched). Only when the runtime lacks the extension."),
|
||||
("pose_debug", "int", 0, "Pose time diagnostics (log)",
|
||||
"Every 5 s, logs per located space how far the requested times are from the frame's predicted display time, and "
|
||||
"the runtime clock's offset from the monotonic clock. No effect on the game."),
|
||||
("haptic_fix", "int", 0, "Fix controller vibration freezes",
|
||||
"Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into "
|
||||
"plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the "
|
||||
@@ -148,6 +167,16 @@ SETTINGS = [
|
||||
"e.g. stores that initialize locals a shader reads before writing (an undefined loop counter hung the GPU in "
|
||||
"VR4's campaign). Format: <size>:<sha256>:<byte offset>:<word>,<word>,...; several separated by ';'. Comes from "
|
||||
"a game's recipe."),
|
||||
("zink_shader_fix", "str", "", "OpenGL ES shader fixes",
|
||||
"Shader-fix Vulkan layer (frame.zink_shader_fix) for OpenGL ES games, whose GLSL the Frame's GL driver (Zink) "
|
||||
"turns into SPIR-V: modules matching size + SHA-256 get words inserted at a byte offset, e.g. stores that set "
|
||||
"loop counters and accumulators a shader reads before writing (Vader Immortal's lightspeed jump hung the GPU; "
|
||||
"found by Klownicle, GitHub #49). Same format as vk_shader_fix. Comes from a game's recipe; the driver's "
|
||||
"output can change with a Frame update, then the fix no longer matches (logged)."),
|
||||
("zink_shader_dump", "int", 0, "OpenGL ES shader dump (diagnostics)",
|
||||
"Shader-fix Vulkan layer (frame.zink_shader_fix): writes every distinct SPIR-V module the GL driver creates to "
|
||||
"Android/data/<package>/files/fp_spirv/<size>_<sha256>.spv, to capture a shader for a new zink_shader_fix. No "
|
||||
"effect on the game."),
|
||||
("vk_query_slots", "int", 0, "Vulkan shim: slots per occlusion query",
|
||||
"Vulkan shim (frame.vk_sanitize): on gives every occlusion query room for both eyes. In multiview passes the "
|
||||
"Frame's driver writes a zero result for the second eye into the next query, so engines that count on one slot "
|
||||
@@ -161,6 +190,9 @@ SETTINGS = [
|
||||
("vk_validation", "int", 0, "Vulkan shim: validation layer",
|
||||
"Diagnostics: the Vulkan shim (frame.vk_sanitize) adds Khronos' validation layer to the game's instance; its "
|
||||
"findings go to launch.log. The layer library (libVkLayer_khronos_validation.so) must be in the APK."),
|
||||
("gl_mv_debug", "int", 0, "Multiview interposer: diagnostics",
|
||||
"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)."),
|
||||
("gl_hide_msrtt", "int", 1, "GL shim: hide multisampled render-to-texture",
|
||||
"GL shim only: hide GL_EXT_multisampled_render_to_texture(2) (Zink crashes rendering Unity's runtime MSAA eye "
|
||||
"buffer through it, e.g. The Room VR)."),
|
||||
@@ -169,6 +201,14 @@ SETTINGS = [
|
||||
"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 "
|
||||
"(e.g. Metro Awakening). Valve's own foveation layer isn't affected."),
|
||||
("ovrp_begin_gate", "int", 0, "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 (e.g. "
|
||||
"Sniper Elite VR); in other games it can make loading screens stutter."),
|
||||
("ovrp_hold_physics", "int", 0, "Unity frame loop: keep display-time poses",
|
||||
"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 (e.g. Sniper "
|
||||
"Elite VR); in others it makes the hands lag (e.g. BattleSisters)."),
|
||||
("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 (e.g. Path of the Warrior)."),
|
||||
@@ -176,7 +216,8 @@ SETTINGS = [
|
||||
|
||||
|
||||
# settings that need a new build, not only new settings files: the Vulkan shim learned vk_shader_fix in 0.6.4 and
|
||||
# vk_query_slots and vk_spec_fixes in 0.10.0
|
||||
# vk_query_slots and vk_spec_fixes in 0.10.0. haptic_fix stays at 2 although the shim also converts PCM vibrations
|
||||
# since 0.12.1: no game is known to need that, and a bump would mark every OVRPlugin game's build outdated
|
||||
REVISIONS = {"vk_shader_fix": 2, "vk_query_slots": 3, "vk_spec_fixes": 2, "haptic_fix": 2, "pose_consistency": 2}
|
||||
|
||||
# How the "Game settings" dialog shows each setting to non-technical users: group, level (common settings are always
|
||||
@@ -201,6 +242,13 @@ UI: dict[str, dict] = {
|
||||
"pose_consistency": dict(group="picture", level="advanced", label="Steadier head tracking",
|
||||
help="For games that judder or shimmer while you hold your head still (e.g. I Am Cat).",
|
||||
control=("switch",)),
|
||||
"pose_time_fix": dict(group="controllers", level="advanced", label="Fix hands lagging behind controllers",
|
||||
help="For games whose hands trail the controllers when you move them (e.g. BattleSisters).",
|
||||
control=("switch",)),
|
||||
"proximity_emul": dict(group="controllers", level="advanced", label="Animate fingers from touch",
|
||||
help="For games whose hands' thumbs never move when you touch the buttons or thumbstick "
|
||||
"(e.g. Vader Immortal).",
|
||||
control=("choice", [(0, "Off"), (1, "Thumbs"), (2, "Thumbs and index fingers")])),
|
||||
"haptic_fix": dict(group="controllers", level="advanced", label="Fix freezes when controllers vibrate",
|
||||
help="For games that freeze the Frame when a controller vibrates (e.g. Lucky's Tale). Needs a "
|
||||
"rebuild.",
|
||||
@@ -279,9 +327,11 @@ UI: dict[str, dict] = {
|
||||
help="Shows 3D 360° pictures flat if they look doubled.",
|
||||
control=("choice", [(0, "As the game sends it"), (2, "Flat")])),
|
||||
**{key: dict(group="troubleshooting", level="advanced", control=("switch",)) for key in (
|
||||
"foveation_fix", "hide_space_warp", "swapchain_fix", "layer_fix", "gl_hide_multiview", "mutable_fix",
|
||||
"flip_quads", "swap_eyes", "vk_validation", "rect_clamp", "gl_hide_msrtt", "strip_color_bias", "snapshot",
|
||||
"strip_depth", "respace_kick", "layer_debug", "eye_debug", "input_diag", "release_wait", "vk_hide_fdm")},
|
||||
"foveation_fix", "hide_space_warp", "swapchain_fix", "cube_standin", "layer_fix", "gl_hide_multiview",
|
||||
"mutable_fix", "flip_quads", "swap_eyes", "vk_validation", "rect_clamp", "gl_hide_msrtt", "strip_color_bias",
|
||||
"snapshot", "strip_depth", "respace_kick", "layer_debug", "eye_debug", "input_diag", "release_wait",
|
||||
"vk_hide_fdm", "ovrp_begin_gate", "ovrp_hold_physics", "pose_debug",
|
||||
"zink_shader_dump", "gl_mv_debug")},
|
||||
}
|
||||
|
||||
|
||||
@@ -311,6 +361,11 @@ class AdapterSetting(Patch):
|
||||
return Suggestion(True, "Meta's OVRPlugin vibrates controllers through OVRPort's loader, which turns some "
|
||||
"vibrations into gigabyte allocations that freeze the Frame (e.g. Lucky's 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
|
||||
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 "
|
||||
"right time (e.g. BattleSisters, Sniper Elite VR).", {"value": 1})
|
||||
if self.key == "equirect_emul" and uses_equirect_layers(a):
|
||||
return Suggestion(True, "The game draws 360° layers (e.g. a video player's theatre or 360° videos), which "
|
||||
"the Frame's runtime can't show: show them as panels around you.", {"value": 1})
|
||||
@@ -340,11 +395,18 @@ class AdapterSetting(Patch):
|
||||
"swapchain_fix": ap.is_gles,
|
||||
"gl_hide_multiview": lambda a: a.direct_vrapi and ap.is_gles(a),
|
||||
"gl_hide_msrtt": ap.is_gles,
|
||||
"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,
|
||||
"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),
|
||||
"vk_validation": lambda a: a.engine == "Unreal",
|
||||
"zink_shader_fix": ap.is_gles,
|
||||
"zink_shader_dump": ap.is_gles,
|
||||
"haptic_fix": lambda a: "libOVRPlugin.so" in a.libs,
|
||||
"pose_time_fix": lambda a: "libOVRPlugin.so" in a.libs,
|
||||
"proximity_emul": lambda a: "libOVRPlugin.so" in a.libs,
|
||||
"vk_spec_fixes": lambda a: a.engine == "Unreal" and ap.is_vulkan(a),
|
||||
"controller_models": ap.may_use_render_models,
|
||||
**{k: ap.is_gles for k in ("equirect_emul", "equirect_face", "equirect_res", "equirect_flip",
|
||||
|
||||
@@ -40,10 +40,18 @@ SUMMARIES = {
|
||||
"frame.launcher": "Makes the game start from the Frame's Steam library.",
|
||||
"frame.start_activity": "Opens the app straight in VR, skipping its flat launcher screen (turn off to use the "
|
||||
"launcher, e.g. to import new content).",
|
||||
"frame.ltw_depth": "Fixes the black picture in Minecraft launchers (e.g. QuestCraft): uses depth formats the "
|
||||
"frame.gl_multiview_fbo": "Experimental: shows HUD, menu and other flat panels that stay black because the "
|
||||
"Frame's graphics driver skips them (e.g. Doom3Quest's HUD and PDA).",
|
||||
"frame.ltw_depth":"Fixes the black picture in Minecraft launchers (e.g. QuestCraft): uses depth formats the "
|
||||
"Frame's graphics driver accepts.",
|
||||
"frame.unity_gl_shim": "Stops a grey or frozen screen in Unity games that turn on anti-aliasing while running "
|
||||
"(e.g. The Room VR).",
|
||||
"frame.unreal_gl_shim": "Stops a crash a few seconds after start in Unreal games whose anti-aliasing the Frame's "
|
||||
"graphics driver can't handle (e.g. Star Wars Pinball VR).",
|
||||
"frame.unreal_ovrp_entrypoints": "Lets older Unreal games start VR although the converted VR plugin lacks a few "
|
||||
"functions they look for (e.g. Star Wars Pinball VR).",
|
||||
"frame.pac_hints": "Stops a crash on the Frame's CPU in games whose code has unpaired security checks the Quest "
|
||||
"ignores (e.g. Star Wars Pinball VR's first network connection).",
|
||||
"frame.unity_no_overlay_copy": "Stops a grey screen crash in some Unity games by skipping their overlay "
|
||||
"layers (e.g. The Room VR's screen fades).",
|
||||
"frame.nodebug": "Stops strict debugging checks that make some games quit.",
|
||||
@@ -54,12 +62,16 @@ SUMMARIES = {
|
||||
"waiting for a download that never comes.",
|
||||
"frame.ovr_trace": "For troubleshooting: records the game's requests to Meta's platform services and which ones "
|
||||
"never get an answer.",
|
||||
"frame.slz_vulkan_hooks": "Lets Unity start the graphics itself instead of Stress Level Zero's plugin, "
|
||||
"which crashes on the Frame (e.g. BONELAB 1.2974).",
|
||||
"frame.unity_user_presence": "Counts the headset as worn, so games that only move the player while it's worn "
|
||||
"get head tracking and controls (e.g. BONELAB).",
|
||||
"frame.unity_no_msaa": "Turns off a smoothing setting that crashes some older Unity games on the Frame.",
|
||||
"frame.unity_runtime_msaa_off": "Keeps a smoothing setting off that the game turns on itself and that can freeze "
|
||||
"the Frame.",
|
||||
"frame.sdl_clipboard": "Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboard).",
|
||||
"frame.vivox_audio_route": "Lets games with Vivox voice chat start on the Frame (they'd crash on an Android 12 "
|
||||
"audio call).",
|
||||
"frame.unity_oculus_check": "Lets older Unity games start VR without Meta's system apps (they'd stay on a 2D "
|
||||
"screen).",
|
||||
"frame.unity_multipass": "Draws each eye separately; try it when one eye shows a grey or broken picture.",
|
||||
@@ -77,6 +89,14 @@ SUMMARIES = {
|
||||
"frame.oculusos": "Provides stand-ins for Quest system reporting some games call at start.",
|
||||
"frame.unity_text_input": "Lets you type into this app's text fields on the Frame (they'd close at once).",
|
||||
"frame.vk_sanitize": "Cleans up graphics data that crashes some Unreal games on the Frame.",
|
||||
"frame.unreal_quest_precompile": "Lets the game leave its loading screen: it waited for a Quest-only step that "
|
||||
"never starts on the Frame (e.g. Vader Immortal).",
|
||||
"frame.unreal_quest_keymap": "Makes grip, trigger and touch controls reach the game's hands (it picked the "
|
||||
"empty Gear VR controls on the Frame, e.g. Vader Immortal).",
|
||||
"frame.unreal_thumb_touch": "Makes the hands' thumbs follow what your thumb touches (they always pointed up: the "
|
||||
"Frame doesn't sense a thumb hovering, e.g. Vader Immortal).",
|
||||
"frame.zink_shader_fix":"Repairs shaders that hang the graphics chip at certain effects, from the game's "
|
||||
"recipe (e.g. Vader Immortal's lightspeed jump).",
|
||||
# Files and environment on the Frame
|
||||
"device.files": "Puts settings files next to the game on the Frame (e.g. to turn off an unsupported effect).",
|
||||
"device.hide_navbar": "Hides Android's back/home/recents buttons, which cover the app's own buttons.",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Upstream fixes that make one of our workarounds unnecessary, applied per build.
|
||||
|
||||
Several patches work around bugs in what OVRPort puts into a converted APK (its closed-source runtime libraries, its
|
||||
CLI). When upstream fixes such a bug, users don't all get the fix at once: OVRPort downloads runtime builds into its
|
||||
workspace and each conversion uses whichever one it picks, so one library can hold games built with the old and the
|
||||
new runtime. A version number doesn't help either (runtime builds are named `<version>-<commit>`, the commit part is
|
||||
unordered). So each fix is detected in the converted APK itself: a probe looks at the bytes OVRPort produced, and when
|
||||
it finds the upstream fix, the build leaves the workaround out.
|
||||
|
||||
The recipe is never changed: it still asks for the fix (`adapter.haptic_fix` on), the build decides how to provide it.
|
||||
A build made with an old runtime keeps the workaround; the next build with a fixed runtime drops it by itself, and a
|
||||
build made with a later runtime that regressed would get the workaround back. Builds record what was left out
|
||||
(`build.superseded`), the installer leaves those out of settings.conf too, and the build log says why.
|
||||
|
||||
Add one: write a probe (True = the upstream fix is there, False = the bug is there, None = can't tell, e.g. the library
|
||||
is missing or looks different) and `register(UpstreamFix(...))` in the workaround's own module. Only True drops the
|
||||
workaround, so a probe that can't recognise newer code errs on the safe side. Then update the workaround's tracker
|
||||
issue (`upstream` label). FRAMEPORT_KEEP_WORKAROUNDS=1 keeps every workaround (to compare builds).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..apk.workspace import ApkWorkspace
|
||||
from ..core.events import Reporter
|
||||
|
||||
Probe = Callable[["ApkWorkspace"], "bool | None"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpstreamFix:
|
||||
id: str # e.g. "ovrport.haptic_envelope"
|
||||
workaround: str # the patch id this fix makes unnecessary, e.g. "adapter.haptic_fix"
|
||||
title: str # what upstream fixed, for the build log
|
||||
upstream: str # where it was fixed, e.g. "ovrport/app#73 (runtime 3.4.3-aa54c3f)"
|
||||
probe: Probe
|
||||
tracker: str = "" # our tracking issue, e.g. "#74"
|
||||
|
||||
|
||||
REGISTRY: dict[str, UpstreamFix] = {}
|
||||
|
||||
|
||||
def register(fix: UpstreamFix) -> UpstreamFix:
|
||||
if not fix.id or fix.id in REGISTRY:
|
||||
raise ValueError(f"bad or duplicate upstream fix id {fix.id!r}")
|
||||
REGISTRY[fix.id] = fix
|
||||
return fix
|
||||
|
||||
|
||||
def lib_probe(library: str, check: Callable[[bytes], bool | None]) -> Probe:
|
||||
"""A probe that runs `check` on one of the APK's native libraries (None when the APK doesn't have it)."""
|
||||
def probe(ws: ApkWorkspace) -> bool | None:
|
||||
return check(ws.read_lib(library)) if ws.abi and ws.has(ws.lib(library)) else None
|
||||
|
||||
return probe
|
||||
|
||||
|
||||
@dataclass
|
||||
class Resolution:
|
||||
patches: dict # the selection this build applies
|
||||
superseded: dict[str, str] = field(default_factory=dict) # workaround patch id -> upstream fix id
|
||||
found: dict[str, bool | None] = field(default_factory=dict) # upstream fix id -> probe result (for the log)
|
||||
|
||||
|
||||
def resolve(ws: ApkWorkspace, patches: dict, reporter: Reporter | None = None) -> Resolution:
|
||||
"""Leave out the workarounds whose upstream fix is in this (converted) APK."""
|
||||
from . import base
|
||||
|
||||
base.load_all()
|
||||
res = Resolution(dict(patches))
|
||||
if os.environ.get("FRAMEPORT_KEEP_WORKAROUNDS"):
|
||||
return res
|
||||
for fix in REGISTRY.values():
|
||||
if fix.workaround not in patches:
|
||||
continue
|
||||
try:
|
||||
found = fix.probe(ws)
|
||||
except Exception as e: # noqa: BLE001 (a probe that breaks must not break the build: keep the workaround)
|
||||
found = None
|
||||
if reporter:
|
||||
reporter.log(f"upstream fix check {fix.id} failed ({e}); keeping {fix.workaround}")
|
||||
res.found[fix.id] = found
|
||||
if found:
|
||||
res.patches.pop(fix.workaround, None)
|
||||
res.superseded[fix.workaround] = fix.id
|
||||
if reporter:
|
||||
reporter.log(f"not needed: {fix.workaround} ({fix.title}, fixed upstream in {fix.upstream})")
|
||||
return res
|
||||
|
||||
|
||||
def without_superseded(patches: dict, superseded) -> dict:
|
||||
"""A recipe's selection minus the workarounds a build left out (for the install of that build)."""
|
||||
return {pid: params for pid, params in patches.items() if pid not in (superseded or ())}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- arm64 helpers for probes
|
||||
|
||||
def code_words(data: bytes):
|
||||
"""(file offset, instruction word) for every word of the library's executable sections."""
|
||||
from ..analysis import elf
|
||||
|
||||
for sec in elf._elf(data).iter_sections():
|
||||
if sec["sh_type"] == "SHT_PROGBITS" and sec["sh_flags"] & 0x4: # SHF_EXECINSTR
|
||||
start, size = sec["sh_offset"], sec["sh_size"]
|
||||
for off in range(start, start + size - 3, 4):
|
||||
yield off, int.from_bytes(data[off:off + 4], "little")
|
||||
|
||||
|
||||
def move_wide(word: int) -> tuple[str, int, int, int, int] | None:
|
||||
"""Decode MOVZ/MOVK/MOVN: (kind, sf, rd, imm16, shift) or None."""
|
||||
kinds = {0x52800000: "movz", 0x72800000: "movk", 0x12800000: "movn"}
|
||||
kind = kinds.get(word & 0x7F800000)
|
||||
if kind is None:
|
||||
return None
|
||||
return kind, word >> 31, word & 31, (word >> 5) & 0xFFFF, ((word >> 21) & 3) * 16
|
||||
|
||||
|
||||
def constants(words) -> list[int]:
|
||||
"""Every value a register holds after each MOVZ/MOVK/MOVN in a run of instruction words (how compilers build
|
||||
64-bit constants such as doubles: movz + movk)."""
|
||||
regs: dict[int, int] = {}
|
||||
out = []
|
||||
for w in words:
|
||||
mw = move_wide(w)
|
||||
if mw is None:
|
||||
continue
|
||||
kind, sf, rd, imm, shift = mw
|
||||
mask = (1 << 64) - 1 if sf else (1 << 32) - 1
|
||||
if kind == "movz":
|
||||
regs[rd] = imm << shift
|
||||
elif kind == "movn":
|
||||
regs[rd] = ~(imm << shift) & mask
|
||||
else:
|
||||
regs[rd] = (regs.get(rd, 0) & ~(0xFFFF << shift) | imm << shift) & mask
|
||||
out.append(regs[rd])
|
||||
return out
|
||||
|
||||
|
||||
def find_constant_load(words: list[int], value: int) -> int | None:
|
||||
"""Index of the first MOVZ/MOVN that starts building `value` (32-bit) in its register, followed by the MOVK that
|
||||
completes it within the next few instructions."""
|
||||
for i, w in enumerate(words):
|
||||
mw = move_wide(w)
|
||||
if mw is None or mw[0] == "movk":
|
||||
continue
|
||||
if value in constants(words[i:i + 4]):
|
||||
return i
|
||||
return None
|
||||
+144
-17
@@ -9,6 +9,7 @@ PC (Revive + local Steam) or on the Frame (Proton + Revive).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -21,6 +22,7 @@ from .core import library
|
||||
from .core.events import Reporter
|
||||
from .core.models import Recipe, SourceGame
|
||||
from .core.paths import output_dir
|
||||
from .patches import upstream
|
||||
from .recommend import engine
|
||||
from .sources import quest_dump, rift_dump
|
||||
from .targets.base import Target
|
||||
@@ -284,6 +286,30 @@ def is_linux(entry: dict) -> bool:
|
||||
return entry.get("kind") == "linux"
|
||||
|
||||
|
||||
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"):
|
||||
return []
|
||||
out = engine.blocker_notes(library.analysis_from_dict(entry["analysis"]))
|
||||
if missing_obb(entry):
|
||||
out.append(MISSING_OBB_NOTE.format(package=entry.get("package", "<package>")))
|
||||
return out
|
||||
|
||||
|
||||
MISSING_OBB_NOTE = ("This game's data file (.obb) wasn't found next to the APK. Put the .obb files in a folder named "
|
||||
"{package} (or obb/) next to the APK and add the folder again; without it the game hangs at start.")
|
||||
|
||||
|
||||
def missing_obb(entry: dict) -> bool:
|
||||
"""A Quest game whose APK expects an OBB (analysis expects_obb: Unreal's bHasOBBFiles) but no data folder was
|
||||
found next to it (GitHub #85: TRIANGLE STRATEGY hung silently after OVRPlugin's JNI_OnLoad). Library fields
|
||||
only: no disk access (used by the game page)."""
|
||||
if is_rift(entry) or is_linux(entry):
|
||||
return False
|
||||
extra = (entry.get("analysis") or {}).get("extra") or {}
|
||||
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) -> 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)."""
|
||||
@@ -327,8 +353,11 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
|
||||
from .artwork import fetch
|
||||
|
||||
fetch.artwork_dir(package)
|
||||
from .artwork import steam
|
||||
from .artwork import sources, steam
|
||||
|
||||
icon = None if info["files"] else linux.find_icon(root) # a lone AppImage's icon comes from the Frame
|
||||
if icon:
|
||||
sources.apply_app_icon(package, icon.read_bytes())
|
||||
steam.ensure_cover(package) # placeholder art (name on a colour) until the user picks some
|
||||
except Exception: # noqa: BLE001 - artwork is optional
|
||||
pass
|
||||
@@ -426,7 +455,8 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
|
||||
extra = (entry.get("analysis") or {}).get("extra") or {}
|
||||
result = target.install_linux(package, steam_title(entry), Path(entry["game_dir"]), entry["exe"],
|
||||
extra.get("files"), bool(extra.get("appimage")), bool(extra.get("openxr")),
|
||||
reporter, x86_64=bool(extra.get("x86_64")))
|
||||
reporter, x86_64=bool(extra.get("x86_64")),
|
||||
desktop_entry=entry.get("desktop_entry", True) is not False)
|
||||
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)")
|
||||
@@ -445,12 +475,13 @@ def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
|
||||
if reporter:
|
||||
reporter.log(f"analyzing {src.apk.name}")
|
||||
a = analyze(src.apk, data_bytes=src.data_bytes())
|
||||
a.extra["lang_packs"] = langpacks.find_tags(src.data_dir)
|
||||
a.extra["asset_files"] = langpacks.find_content_files(src.data_dir)
|
||||
a.extra["lang_packs"] = langpacks.find_tags(_data_folder(src))
|
||||
a.extra["asset_files"] = langpacks.find_content_files(_data_folder(src))
|
||||
recipe = engine.suggest(a)
|
||||
return library.upsert_game(
|
||||
a.package, title=recipe.title or a.label, name=src.name, apk=str(src.apk),
|
||||
data_dir=str(src.data_dir) if src.data_dir else None, data_bytes=src.data_bytes(), origin=str(src.origin),
|
||||
data_dir=str(src.data_dir) if src.data_dir else None, data_files=src.data_files, data_bytes=src.data_bytes(),
|
||||
origin=str(src.origin),
|
||||
analysis=a.to_dict(), recipe=library.recipe_to_dict(recipe), suggested=library.recipe_to_dict(recipe),
|
||||
status=recipe.status,
|
||||
)
|
||||
@@ -460,25 +491,110 @@ def reanalyze(package: str, reporter: Reporter | None = None) -> dict:
|
||||
"""Read a Quest/Android game's APK again (e.g. after FramePort learned to detect something new). The suggestion is
|
||||
refreshed; the recipe too unless the user changed it (then their choices stay)."""
|
||||
entry = library.game(package)
|
||||
if entry is None or is_rift(entry):
|
||||
if entry is None or is_rift(entry) or is_linux(entry):
|
||||
raise ValueError("only Quest/Android games can be analyzed again")
|
||||
return _store_analysis(package, _analyze_entry(entry, reporter))
|
||||
|
||||
|
||||
def _analyze_entry(entry: dict, reporter: Reporter | None = None):
|
||||
src = source_of(entry)
|
||||
if reporter:
|
||||
reporter.log(f"analyzing {src.apk.name}")
|
||||
a = analyze(src.apk, data_bytes=src.data_bytes())
|
||||
a.extra["lang_packs"] = langpacks.find_tags(src.data_dir)
|
||||
a.extra["asset_files"] = langpacks.find_content_files(src.data_dir)
|
||||
a.extra["lang_packs"] = langpacks.find_tags(_data_folder(src))
|
||||
a.extra["asset_files"] = langpacks.find_content_files(_data_folder(src))
|
||||
return a
|
||||
|
||||
|
||||
def _store_analysis(package: str, a) -> dict:
|
||||
"""Replace only the entry's analysis and suggestion; the recipe follows unless it is the user's own (tags, art,
|
||||
title, builds and installs are untouched)."""
|
||||
suggested = engine.suggest(a)
|
||||
keep = library.recipe_from_dict(entry["recipe"]).source == "user"
|
||||
return library.upsert_game(package, analysis=a.to_dict(), suggested=library.recipe_to_dict(suggested),
|
||||
**({} if keep else {"recipe": library.recipe_to_dict(suggested),
|
||||
"status": suggested.status}))
|
||||
|
||||
def change(g: dict) -> None:
|
||||
g["analysis"] = a.to_dict()
|
||||
g["suggested"] = library.recipe_to_dict(suggested)
|
||||
g.pop("analysis_failed", None)
|
||||
# checked under the library lock: the user may have saved a recipe while the APK was read
|
||||
if library.recipe_from_dict(g.get("recipe") or {"package": package}).source != "user":
|
||||
g["recipe"] = library.recipe_to_dict(suggested)
|
||||
g["status"] = suggested.status
|
||||
return library.update_game(package, change)
|
||||
|
||||
|
||||
def analysis_outdated(entry: dict) -> bool:
|
||||
"""A Quest/Android entry analysed by an older FramePort (fields newer patches depend on are missing) whose APK is
|
||||
still there, and whose re-analysis didn't already fail for this analysis version. Rift/Linux entries: no."""
|
||||
from .analysis.detect import ANALYSIS_VERSION
|
||||
|
||||
a = entry.get("analysis")
|
||||
if not isinstance(a, dict) or not entry.get("apk") or is_rift(entry) or is_linux(entry):
|
||||
return False
|
||||
if str(a.get("package") or "").startswith("rift."):
|
||||
return False
|
||||
if ((a.get("extra") or {}).get("analysis_version") or 0) >= ANALYSIS_VERSION:
|
||||
return False
|
||||
if entry.get("analysis_failed") == ANALYSIS_VERSION:
|
||||
return False
|
||||
try:
|
||||
return Path(entry["apk"]).is_file() # on a drive that isn't connected now: tried again at a later start
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def outdated_analyses() -> list[str]:
|
||||
return [g["package"] for g in library.games() if analysis_outdated(g)]
|
||||
|
||||
|
||||
def refresh_analyses(packages: list[str] | None = None, reporter: Reporter | None = None) -> int:
|
||||
"""Analyse again the entries an older FramePort analysed (see detect.ANALYSIS_VERSION): runs in the background at
|
||||
start (GUI) and before a build (CLI and GUI). Only the APK analysis (no OVRPort, no Cpp2IL). An APK that can't be
|
||||
read leaves the entry as it was and is marked, so it isn't read again at every start. Returns how many changed."""
|
||||
from .analysis.detect import ANALYSIS_VERSION
|
||||
|
||||
todo = outdated_analyses() if packages is None else \
|
||||
[p for p in packages if analysis_outdated(library.game(p) or {})]
|
||||
done = 0
|
||||
for i, pkg in enumerate(todo):
|
||||
entry = library.game(pkg)
|
||||
if entry is None or not analysis_outdated(entry):
|
||||
continue
|
||||
if reporter:
|
||||
reporter.check_cancel()
|
||||
reporter.progress(i / len(todo), entry.get("title") or pkg)
|
||||
try:
|
||||
a = _analyze_entry(entry, reporter)
|
||||
except Exception as exc: # noqa: BLE001 - unreadable APK: keep the old analysis, don't retry every start
|
||||
if reporter:
|
||||
reporter.log(f"{pkg}: couldn't analyze the APK again: {exc}")
|
||||
library.update_game(pkg, lambda g: g.__setitem__("analysis_failed", ANALYSIS_VERSION))
|
||||
continue
|
||||
if a.package != pkg: # a different APK at that path now: leave the entry alone
|
||||
library.update_game(pkg, lambda g: g.__setitem__("analysis_failed", ANALYSIS_VERSION))
|
||||
continue
|
||||
_store_analysis(pkg, a)
|
||||
done += 1
|
||||
return done
|
||||
|
||||
|
||||
def _data_folder(src: SourceGame) -> Path | None:
|
||||
"""The data folder when all of it is the game's data (language packs and content files are looked for there);
|
||||
None when only expansion files found by name are (that folder also holds other things, e.g. the APK)."""
|
||||
return None if src.data_files is not None else src.data_dir
|
||||
|
||||
|
||||
def data_paths(entry: dict) -> list[Path]:
|
||||
"""A Quest game's data on this PC: its data folder, or only its expansion files when those were found by name."""
|
||||
if not entry.get("data_dir"):
|
||||
return []
|
||||
d = Path(entry["data_dir"])
|
||||
return [d / n for n in entry["data_files"]] if entry.get("data_files") is not None else [d]
|
||||
|
||||
|
||||
def source_of(entry: dict) -> SourceGame:
|
||||
return SourceGame(entry.get("name") or entry["package"], Path(entry["apk"]),
|
||||
Path(entry["data_dir"]) if entry.get("data_dir") else None,
|
||||
Path(entry["origin"]) if entry.get("origin") else None)
|
||||
Path(entry["origin"]) if entry.get("origin") else None, data_files=entry.get("data_files"))
|
||||
|
||||
|
||||
def set_recipe(package: str, recipe: Recipe) -> None:
|
||||
@@ -600,6 +716,8 @@ def _build_lock(package: str) -> threading.Lock:
|
||||
|
||||
|
||||
def build_game(package: str, reporter: Reporter, outdir: Path | None = None) -> dict:
|
||||
if analysis_outdated(library.game(package) or {}): # analysed by an older FramePort: new fields first
|
||||
refresh_analyses([package], reporter)
|
||||
entry = library.game(package)
|
||||
if is_linux(entry): # nothing to convert: installed as it is
|
||||
return {"ok": True, "linux": True}
|
||||
@@ -616,7 +734,8 @@ def build_game(package: str, reporter: Reporter, outdir: Path | None = None) ->
|
||||
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"])}
|
||||
"overport": res.meta.get("overport"), "recipe_fp": recipe_fingerprint(entry["recipe"]),
|
||||
"superseded": res.meta.get("superseded") or {}}
|
||||
library.upsert_game(package, build=build_info, title=entry.get("title") or store_title)
|
||||
return build_info
|
||||
|
||||
@@ -639,9 +758,12 @@ def install_game(package: str, target: Target, reporter: Reporter, apk_only: boo
|
||||
if not test_build and not apk.exists(): # the converted copy was removed after an earlier install: make it again
|
||||
build_game(package, reporter)
|
||||
return install_game(package, target, reporter, apk_only, add_to_library)
|
||||
if not test_build and b.get("superseded"): # workarounds this build left out (upstream fixed): not in settings.conf
|
||||
recipe = dataclasses.replace(recipe, patches=upstream.without_superseded(recipe.patches, b["superseded"]))
|
||||
data_dir = Path(entry["data_dir"]) if entry.get("data_dir") else None
|
||||
title = steam_title(entry)
|
||||
result = target.install(package, title, apk, data_dir, recipe, reporter, apk_only)
|
||||
result = target.install(package, title, apk, data_dir, recipe, reporter, apk_only,
|
||||
data_files=entry.get("data_files"))
|
||||
if add_to_library:
|
||||
target.add_to_library([package], reporter)
|
||||
_record_install(package, target.label, {"apk": str(apk), "result": result, "time": time.time()})
|
||||
@@ -675,13 +797,14 @@ def local_game_files(package: str) -> list[Path]:
|
||||
elif is_linux(g):
|
||||
paths += linux_local_files(g)
|
||||
else:
|
||||
paths += [Path(p) for p in (g.get("apk"), g.get("data_dir")) if p]
|
||||
paths += [Path(g["apk"])] if g.get("apk") else []
|
||||
paths += data_paths(g)
|
||||
b = g.get("build") or {}
|
||||
out = output_dir().resolve()
|
||||
paths += [Path(b[k]) for k in ("apk", "alt_apk") if b.get(k) and out in Path(b[k]).resolve().parents]
|
||||
others = [o for o in library.games() if o.get("package") != package]
|
||||
used = [Path(p).resolve() for o in others if not is_linux(o)
|
||||
for p in (o.get("apk"), o.get("data_dir"), o.get("game_dir")) if p]
|
||||
for p in (o.get("apk"), o.get("game_dir"), *data_paths(o)) if p]
|
||||
used += [p.resolve() for o in others if is_linux(o) for p in linux_local_files(o)] # (lone AppImages: the file)
|
||||
|
||||
def shared(p: Path) -> bool: # the same path, a path inside it, or a folder around it belongs to another game
|
||||
@@ -800,6 +923,10 @@ def install_rift(package: str, target: Target, reporter: Reporter, add_to_librar
|
||||
|
||||
def test_game(package: str, target: Target, reporter: Reporter, seconds: int = 45) -> dict:
|
||||
result, log = target.launch_test(package, reporter, seconds)
|
||||
if missing_obb(library.game(package) or {}):
|
||||
from .validate.triage import add_missing_obb
|
||||
|
||||
add_missing_obb(result)
|
||||
from .core.paths import user_data_dir
|
||||
|
||||
logs = user_data_dir() / "logs"
|
||||
|
||||
@@ -80,6 +80,7 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
|
||||
recipe.status = "unsupported"
|
||||
recipe.notes = f"No 64-bit ARM code ({', '.join(analysis.abis)}): the Steam Frame can't run it."
|
||||
if not rift:
|
||||
_static_blockers(analysis, recipe)
|
||||
_non_quest(analysis, recipe)
|
||||
if not rift and not entry and (analysis.extra or {}).get("frame_patched"):
|
||||
# already has FramePort's adapter (e.g. a PATCHED/ build): installing it unchanged is the safe default
|
||||
@@ -95,19 +96,36 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
|
||||
return recipe
|
||||
|
||||
|
||||
LAUNCH_PATCHES = ("pcvr.repack_launcher", "pcvr.launch_args") # how a Rift game starts (from its files)
|
||||
|
||||
|
||||
def _xr_set(xr: str) -> set[str]:
|
||||
return {x.strip().lower() for x in (xr or "").split("+") if x.strip() and x.strip() != "?"}
|
||||
|
||||
|
||||
def _other_build(entry, analysis: Analysis) -> bool:
|
||||
"""The catalog recipe was verified with a different build of the game (another VR API set), e.g. SUPERHOT VR's
|
||||
OpenXR build vs its older Oculus + SteamVR build (GitHub #105): the build's own launch arguments then stay."""
|
||||
mine, theirs = _xr_set(analysis.xr), _xr_set(entry.xr)
|
||||
return bool(mine and theirs and mine != theirs)
|
||||
|
||||
|
||||
def _rift_recipe(analysis: Analysis, recipe: Recipe, entry) -> None:
|
||||
extra = analysis.extra
|
||||
if entry:
|
||||
recipe.source = f"catalog ({entry.origin})"
|
||||
recipe.status, recipe.notes, recipe.title = entry.status, entry.notes, entry.title or recipe.title
|
||||
why = f"Known-good recipe for {entry.title} (tested {entry.verified.get('date', '?')})."
|
||||
other_build = _other_build(entry, analysis)
|
||||
for pid in entry.pcvr:
|
||||
if pid == "pcvr.launch_args" and pid not in recipe.patches:
|
||||
continue # the arguments come from the game files: none found (e.g. an OpenXR build) = none to add
|
||||
recipe.patches.setdefault(pid, {})
|
||||
recipe.reasons[pid] = why
|
||||
for pid in entry.pcvr_remove:
|
||||
recipe.patches.pop(pid, None)
|
||||
for pid in ("pcvr.repack_launcher", "pcvr.launch_args"): # a verified recipe decides how the game starts
|
||||
if pid not in entry.pcvr:
|
||||
for pid in LAUNCH_PATCHES: # a verified recipe decides how the game starts ...
|
||||
if pid not in entry.pcvr and not other_build: # ... unless it was verified with another build
|
||||
recipe.patches.pop(pid, None)
|
||||
recipe.reasons.pop(pid, None)
|
||||
if entry.proton_env:
|
||||
@@ -117,6 +135,11 @@ def _rift_recipe(analysis: Analysis, recipe: Recipe, entry) -> None:
|
||||
recipe.patches["pcvr.proton_tool"] = {"tool": entry.proton_tool}
|
||||
recipe.reasons["pcvr.proton_tool"] = why
|
||||
recipe.as_is = entry.as_is
|
||||
if other_build:
|
||||
args = recipe.params("pcvr.launch_args").get("args")
|
||||
recipe.notes = (recipe.notes + " " if recipe.notes else "") + \
|
||||
(f"The recipe was verified with another build of the game ({entry.xr}); this one uses "
|
||||
f"{analysis.xr}" + (f" and starts with {args}." if args else "."))
|
||||
# drop patches whose requirement the catalog removed (e.g. revive_openvr once revive is gone), so the
|
||||
# requirement pass in suggest() doesn't re-add it
|
||||
for pid in list(recipe.patches):
|
||||
@@ -177,6 +200,39 @@ def warnings(recipe: Recipe) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def android_version_note(analysis: Analysis) -> str | None:
|
||||
"""'Its manifest asks for Android 14 (API 34)…' when the APK's minimum Android is newer than the Frame's."""
|
||||
from ..analysis.detect import FRAME_API, android_version, too_new_android
|
||||
|
||||
min_sdk = (analysis.extra or {}).get("min_sdk")
|
||||
if not too_new_android(min_sdk):
|
||||
return None
|
||||
return (f"Its manifest asks for Android {android_version(min_sdk)} (API {min_sdk}); the Frame's Android is "
|
||||
f"{android_version(FRAME_API)} (API {FRAME_API}). Apps like this often crash at start on newer Android "
|
||||
"parts, but not always: a launch test tells.")
|
||||
|
||||
|
||||
def web_wrapper_note(analysis: Analysis) -> str | None:
|
||||
"""A Trusted Web Activity: the APK only opens a website in Meta's browser (analysis.detect.web_wrapper)."""
|
||||
ww = (analysis.extra or {}).get("web_wrapper")
|
||||
if not ww:
|
||||
return None
|
||||
where = f"open {ww['url']} in a browser instead" if ww.get("url") else "open the website in a browser instead"
|
||||
return (f"This app looks like a website in an Android wrapper (Trusted Web Activity): it opens the site in "
|
||||
f"Meta's browser, which the Frame doesn't have. If nothing opens, {where}.")
|
||||
|
||||
|
||||
def blocker_notes(analysis: Analysis) -> list[str]:
|
||||
"""Warnings read from the APK's manifest that it may not run on the Frame (whatever the recipe says). Only
|
||||
warnings: the manifest alone never marks a game unsupported (owner's rule); a launch test's triage decides."""
|
||||
return [n for n in (android_version_note(analysis), web_wrapper_note(analysis)) if n]
|
||||
|
||||
|
||||
def _static_blockers(analysis: Analysis, recipe: Recipe) -> None:
|
||||
for note in blocker_notes(analysis):
|
||||
recipe.notes = _add(recipe.notes, note)
|
||||
|
||||
|
||||
def _non_quest(analysis: Analysis, recipe: Recipe) -> None:
|
||||
"""Android apps that aren't Meta Quest apps (analysis.detect.vr_kind)."""
|
||||
kind = analysis.vr_kind
|
||||
|
||||
@@ -3,10 +3,17 @@
|
||||
Accepted layouts:
|
||||
<folder>/<something>.apk [+ <folder>/<package>/ (OBB or raw asset data)] e.g. common downloader layouts
|
||||
<folder>/<package>.apk + <folder>/obb/ FramePort/PATCHED output layout
|
||||
<folder>/x.apk + <folder>/obb/<package>/ or Android/obb/<package>/ backups with an obb folder
|
||||
<game>/apk/x.apk + <game>/obb/<package>/ SideQuest-style backups (one game)
|
||||
<folder>/x.apk + (main|patch).<versionCode>.<package>.obb anywhere nearby found by file name (next to the APK,
|
||||
<package>/apk/ + <package>/obb/, ...)
|
||||
a single .apk file
|
||||
(find_data has the details.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
@@ -22,13 +29,23 @@ def display_name(folder_name: str) -> str:
|
||||
return re.sub(r'[<>:"/\\|?*]', "_", TAG.sub("", folder_name).strip())
|
||||
|
||||
|
||||
def _package_of(apk: Path) -> str | None:
|
||||
def _apk_ids(apk: Path) -> tuple[str | None, int | None]:
|
||||
"""(package, versionCode) from the APK's manifest; None for what can't be read."""
|
||||
try:
|
||||
from pyaxmlparser import APK
|
||||
|
||||
return APK(str(apk)).package
|
||||
a = APK(str(apk))
|
||||
except Exception:
|
||||
return None
|
||||
return None, None
|
||||
try:
|
||||
vc = int(a.version_code)
|
||||
except (TypeError, ValueError):
|
||||
vc = None
|
||||
return a.package, vc
|
||||
|
||||
|
||||
def _package_of(apk: Path) -> str | None:
|
||||
return _apk_ids(apk)[0]
|
||||
|
||||
|
||||
def _is_apk(path: Path) -> bool:
|
||||
@@ -39,12 +56,185 @@ def _is_apk(path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
OBB_FOLDERS = ("obb", "obbs")
|
||||
|
||||
|
||||
# folder listings, kept while scan() runs: every game of a collection searches its neighbours (slow on NTFS drives)
|
||||
_LISTINGS: contextvars.ContextVar[dict | None] = contextvars.ContextVar("quest_dump_listings", default=None)
|
||||
|
||||
|
||||
def _list(d: Path) -> list[tuple[Path, bool]]:
|
||||
"""(entry, is a folder) in d, sorted; [] when it can't be read."""
|
||||
cache = _LISTINGS.get()
|
||||
if cache is not None and d in cache:
|
||||
return cache[d]
|
||||
try:
|
||||
with os.scandir(d) as it:
|
||||
out = sorted((Path(e.path), e.is_dir()) for e in it)
|
||||
except OSError:
|
||||
out = []
|
||||
if cache is not None:
|
||||
cache[d] = out
|
||||
return out
|
||||
|
||||
|
||||
def _subdirs(d: Path) -> list[Path]:
|
||||
return [p for p, is_dir in _list(d) if is_dir and not p.name.startswith(("_", "."))]
|
||||
|
||||
|
||||
def _has_apks(d: Path) -> bool:
|
||||
return any(p.suffix.lower() == ".apk" and not is_dir for p, is_dir in _list(d))
|
||||
|
||||
|
||||
def _child(d: Path, name: str) -> Path | None:
|
||||
"""d/name, matching the name's case loosely (backups come from Windows: 'OBB', a lower-case package folder)."""
|
||||
exact = d / name
|
||||
if exact.is_dir():
|
||||
return exact
|
||||
return next((p for p in _subdirs(d) if p.name.lower() == name.lower()), None)
|
||||
|
||||
|
||||
def _nonempty(d: Path | None) -> bool:
|
||||
try:
|
||||
return bool(d) and d.is_dir() and any(d.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _has_obb(d: Path) -> bool:
|
||||
return any(p.suffix.lower() == ".obb" and not is_dir for p, is_dir in _list(d))
|
||||
|
||||
|
||||
def _package_dir_below(d: Path, pkg: str, depth: int, skip: Path | None = None, other_games: bool = False) \
|
||||
-> Path | None:
|
||||
"""A folder named like the package holding .obb files, at most `depth` levels below d. other_games: skip
|
||||
folders with APKs of their own (a neighbouring game folder, e.g. another version of the same game)."""
|
||||
for sub in _subdirs(d):
|
||||
if skip is not None and sub == skip:
|
||||
continue
|
||||
if sub.name.lower() == pkg.lower():
|
||||
if _has_obb(sub):
|
||||
return sub
|
||||
continue
|
||||
if depth > 1 and not (other_games and _has_apks(sub)):
|
||||
found = _package_dir_below(sub, pkg, depth - 1)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _find_data_dir(apk_dir: Path, pkg: str | None) -> Path | None:
|
||||
"""The folder whose contents go to Android/obb/<package>/ (so the folder holding the .obb files themselves):
|
||||
<apk dir>/<package>/ downloader layouts
|
||||
<apk dir>/obb/<package>/ or <apk dir>/obb/ FramePort/PATCHED output, backups with an obb folder
|
||||
a <package> folder with .obb files up to 3 levels below the APK's folder (e.g. Android/obb/<package>/) or 2
|
||||
below its parent (SideQuest-style backups: <game>/apk/x.apk + <game>/obb/<package>/; the parent's folders
|
||||
with APKs of their own are other games and are skipped)."""
|
||||
own = _child(apk_dir, pkg) if pkg else None
|
||||
if _nonempty(own):
|
||||
return own
|
||||
for obb in (_child(apk_dir, name) for name in OBB_FOLDERS):
|
||||
if obb is None:
|
||||
continue
|
||||
inner = _child(obb, pkg) if pkg else None
|
||||
if _nonempty(inner):
|
||||
return inner
|
||||
if _nonempty(obb):
|
||||
return obb
|
||||
if not pkg:
|
||||
return None
|
||||
found = _package_dir_below(apk_dir, pkg, 3)
|
||||
if found is None and apk_dir.parent != apk_dir:
|
||||
found = _package_dir_below(apk_dir.parent, pkg, 2, skip=apk_dir, other_games=True)
|
||||
return found
|
||||
|
||||
|
||||
# Android's expansion file names: main.<versionCode>.<package>.obb, patch.<versionCode>.<package>.obb
|
||||
OBB_NAME = re.compile(r"^(main|patch)\.(\d+)\.(.+)\.obb$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _named_obbs(d: Path, pkg: str) -> list[tuple[str, int, Path]]:
|
||||
"""(kind, versionCode, file) of the package's expansion files directly in d."""
|
||||
out = []
|
||||
for f, is_dir in _list(d):
|
||||
m = OBB_NAME.match(f.name)
|
||||
if m and m.group(3).lower() == pkg.lower() and not is_dir:
|
||||
out.append((m.group(1).lower(), int(m.group(2)), f))
|
||||
return out
|
||||
|
||||
|
||||
def _obb_folders(apk_dir: Path, pkg: str, depth: int = 3) -> list[tuple[Path, list[tuple[str, int, Path]]]]:
|
||||
"""Folders holding the package's expansion files by name, nearest first: the APK's own folder, the folders below
|
||||
it, its parent, then the parent's other folders (each up to `depth` levels). Folders with APKs of their own are
|
||||
other games (e.g. another version of this one) and aren't searched."""
|
||||
found: list[tuple[Path, list]] = []
|
||||
|
||||
def visit(d: Path) -> None:
|
||||
obbs = _named_obbs(d, pkg)
|
||||
if obbs:
|
||||
found.append((d, obbs))
|
||||
|
||||
def below(d: Path, level: int, skip: Path | None = None) -> None:
|
||||
for sub in _subdirs(d):
|
||||
if sub == skip or _has_apks(sub):
|
||||
continue
|
||||
visit(sub)
|
||||
if level > 1:
|
||||
below(sub, level - 1)
|
||||
|
||||
visit(apk_dir)
|
||||
below(apk_dir, depth)
|
||||
parent = apk_dir.parent
|
||||
if parent != apk_dir:
|
||||
visit(parent)
|
||||
below(parent, depth, skip=apk_dir)
|
||||
return found
|
||||
|
||||
|
||||
def _pick_obbs(obbs: list[tuple[str, int, Path]], version_code: int | None) -> list[Path]:
|
||||
"""The expansion files for this APK: per kind (main, patch) the newest one not newer than the APK's versionCode
|
||||
(expansion files keep the versionCode they were introduced with, so that's its own one when there is one); only
|
||||
when there is none at all, the newest ones (another version of the game's files: better than nothing)."""
|
||||
usable = [o for o in obbs if version_code is not None and o[1] <= version_code] or obbs
|
||||
picked = []
|
||||
for kind in ("main", "patch"):
|
||||
same = sorted((vc, f) for k, vc, f in usable if k == kind)
|
||||
if same:
|
||||
picked.append(same[-1][1])
|
||||
return picked
|
||||
|
||||
|
||||
def find_data(apk_dir: Path, pkg: str | None, version_code: int | None = None) -> tuple[Path | None, list[str] | None]:
|
||||
"""(data folder, files) for the APK in apk_dir. files is None when the whole folder is the game's data (the
|
||||
layouts of _find_data_dir), else the names of the expansion files to send from it (found by file name, e.g.
|
||||
lying next to the APK: the APK and anything else in that folder stay on the PC)."""
|
||||
data = _find_data_dir(apk_dir, pkg)
|
||||
if data is not None or not pkg:
|
||||
return data, None
|
||||
folders = _obb_folders(apk_dir, pkg)
|
||||
if not folders:
|
||||
return None, None
|
||||
d, obbs = next((f for f in folders if any(vc == version_code for _, vc, _ in f[1])), folders[0])
|
||||
files = _pick_obbs(obbs, version_code)
|
||||
everything = {p.name for p, _ in _list(d)}
|
||||
names = [f.name for f in files]
|
||||
return d, (None if everything == set(names) else names)
|
||||
|
||||
|
||||
def find_data_dir(apk_dir: Path, pkg: str | None, version_code: int | None = None) -> Path | None:
|
||||
return find_data(apk_dir, pkg, version_code)[0]
|
||||
|
||||
|
||||
# a folder that only holds the APK inside a game folder (<game>/apk/x.apk + <game>/obb/…): the game is the parent
|
||||
APK_FOLDERS = ("apk", "apks")
|
||||
|
||||
|
||||
def from_path(path: Path) -> SourceGame | None:
|
||||
path = Path(path)
|
||||
if path.is_file() and path.suffix.lower() == ".apk":
|
||||
pkg = _package_of(path)
|
||||
data = next((d for d in (path.parent / (pkg or ""), path.parent / "obb") if pkg and d.is_dir()), None)
|
||||
return SourceGame(display_name(path.stem), path, data, path.parent)
|
||||
pkg, vc = _apk_ids(path)
|
||||
data, files = find_data(path.parent, pkg, vc)
|
||||
return SourceGame(display_name(path.stem), path, data, path.parent, data_files=files)
|
||||
if not path.is_dir():
|
||||
return None
|
||||
apks = sorted(p for p in path.glob("*.apk") if _is_apk(p))
|
||||
@@ -52,13 +242,10 @@ def from_path(path: Path) -> SourceGame | None:
|
||||
return None
|
||||
primary = [a for a in apks if ".alt-" not in a.name]
|
||||
apk = primary[0] if primary else apks[0]
|
||||
pkg = _package_of(apk)
|
||||
data = None
|
||||
for cand in ((path / pkg) if pkg else None, path / "obb"):
|
||||
if cand and cand.is_dir() and any(cand.iterdir()):
|
||||
data = cand
|
||||
break
|
||||
return SourceGame(display_name(path.name), apk, data, path, [a for a in apks if a != apk])
|
||||
pkg, vc = _apk_ids(apk)
|
||||
data, files = find_data(path, pkg, vc)
|
||||
game_dir = path.parent if path.name.lower() in APK_FOLDERS else path
|
||||
return SourceGame(display_name(game_dir.name), apk, data, game_dir, [a for a in apks if a != apk], files)
|
||||
|
||||
|
||||
def _no_quest_games_below(d: Path) -> bool:
|
||||
@@ -77,7 +264,8 @@ def _other_folders(d: Path, game: SourceGame) -> list[Path]:
|
||||
except OSError:
|
||||
return []
|
||||
own = {game.data_dir.resolve()} if game.data_dir else set()
|
||||
return [p for p in subs if p.resolve() not in own and p.name.lower() not in ("obb", "android")]
|
||||
own |= set(game.data_dir.resolve().parents) if game.data_dir else set() # e.g. Android/ of Android/obb/<pkg>
|
||||
return [p for p in subs if p.resolve() not in own and p.name.lower() not in (*OBB_FOLDERS, "android")]
|
||||
|
||||
|
||||
def scan(root: Path, depth: int = 5) -> list[SourceGame]:
|
||||
@@ -111,5 +299,9 @@ def scan(root: Path, depth: int = 5) -> list[SourceGame]:
|
||||
if root.is_file():
|
||||
g = from_path(root)
|
||||
return [g] if g else []
|
||||
walk(root, 0)
|
||||
token = _LISTINGS.set({})
|
||||
try:
|
||||
walk(root, 0)
|
||||
finally:
|
||||
_LISTINGS.reset(token)
|
||||
return found
|
||||
@@ -21,7 +21,7 @@ class Target(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def install(self, package: str, title: str, apk: Path, data_dir: Path | None, recipe: Recipe,
|
||||
reporter: Reporter, apk_only: bool = False) -> dict: ...
|
||||
reporter: Reporter, apk_only: bool = False, data_files: list[str] | None = None) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
def add_to_library(self, packages: list[str], reporter: Reporter) -> dict: ...
|
||||
@@ -33,7 +33,8 @@ class Target(ABC):
|
||||
"""Start an installed game for playing, through the target's Steam (Steam library shortcut)."""
|
||||
raise NotImplementedError(f"{self.label} can't launch games")
|
||||
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False) -> dict:
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
|
||||
desktop_entry=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")
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from ..core.events import Reporter
|
||||
from ..core.models import Recipe
|
||||
from ..frame.connection import Frame, FrameTarget, sh_quote
|
||||
from ..install import installer
|
||||
from ..install import drives, installer
|
||||
from ..validate import device
|
||||
from .base import Target
|
||||
|
||||
@@ -19,6 +19,12 @@ class FrameLeptonTarget(Target):
|
||||
self.target = target
|
||||
self.frame = Frame(target, password)
|
||||
self.label = target.label
|
||||
self.dest: str | None = None # where new games go; None = the library setting (drives.install_dest)
|
||||
|
||||
def install_dest(self) -> str | None:
|
||||
"""A drive's install dir for new installs (GitHub #90), None = internal storage. Reinstalls stay where the
|
||||
game is (the agent keeps an installed game's folder)."""
|
||||
return self.dest if self.dest is not None else drives.install_dest()
|
||||
|
||||
def connect(self) -> FrameLeptonTarget:
|
||||
if self.frame.client is not None and not self.frame.alive():
|
||||
@@ -33,8 +39,10 @@ class FrameLeptonTarget(Target):
|
||||
def installed(self) -> list[dict]:
|
||||
return self.connect().frame.agent("list_installed")["games"]
|
||||
|
||||
def install(self, package, title, apk: Path, data_dir, recipe: Recipe, reporter: Reporter, apk_only=False):
|
||||
plan = installer.InstallPlan(package, title, apk, data_dir, recipe, apk_only)
|
||||
def install(self, package, title, apk: Path, data_dir, recipe: Recipe, reporter: Reporter, apk_only=False,
|
||||
data_files=None):
|
||||
plan = installer.InstallPlan(package, title, apk, data_dir, recipe, apk_only, dest=self.install_dest(),
|
||||
data_files=data_files)
|
||||
return installer.install(self.connect().frame, plan, reporter)
|
||||
|
||||
def add_to_library(self, packages, reporter):
|
||||
@@ -60,13 +68,27 @@ 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"))
|
||||
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"),
|
||||
dest=self.install_dest())
|
||||
return installer.install_pcvr(self.connect().frame, plan, reporter)
|
||||
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False):
|
||||
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64)
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
|
||||
desktop_entry=True):
|
||||
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64,
|
||||
dest=self.install_dest(), desktop_entry=desktop_entry)
|
||||
return installer.install_linux(self.connect().frame, plan, reporter)
|
||||
|
||||
def drives(self) -> list[dict]:
|
||||
return drives.list_drives(self.connect().frame)
|
||||
|
||||
def move(self, package: str, dest: str, reporter: Reporter) -> dict:
|
||||
"""Move an installed game's files to another drive (or back to internal storage: dest "internal")."""
|
||||
return drives.move(self.connect().frame, package, dest, reporter)
|
||||
|
||||
def set_desktop_entry(self, package: str, enabled: bool) -> dict:
|
||||
"""A Linux app's Desktop Mode entry on or off (GitHub #84)."""
|
||||
return self.connect().frame.agent("desktop_entry", package=package, enabled=enabled)
|
||||
|
||||
def proton_status(self, tool: str | None = None) -> dict:
|
||||
return self.connect().frame.agent("proton_status", tool=tool)
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ class PcReviveTarget(Target):
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------------ install
|
||||
def install(self, package, title, apk, data_dir, recipe, reporter, apk_only=False):
|
||||
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")
|
||||
|
||||
def install_pcvr(self, package, title, game_dir, exe, recipe: Recipe, reporter: Reporter, **extra):
|
||||
|
||||
@@ -71,3 +71,23 @@ def patch(apk: Path, outdir: Path, name: str, patches: list[str], reporter: Repo
|
||||
if p.returncode or not target.exists():
|
||||
raise RuntimeError(f"OVRPort failed (exit {p.returncode}); see log")
|
||||
return target
|
||||
|
||||
|
||||
def runtime_lib(name: str, abi: str = "arm64-v8a") -> Path | None:
|
||||
"""A library of the newest OVRPort runtime in the workspace (what `patch --version=latest` ships, e.g.
|
||||
libOVRPlugin.so), or None before OVRPort downloaded one. Newest = first in the workspace's installed.json (the
|
||||
release tracker's order), else the most recently unpacked runtime folder."""
|
||||
import json
|
||||
|
||||
root = user_data_dir() / "overport-workspace" / "libraries"
|
||||
if not root.is_dir():
|
||||
return None
|
||||
order: list[str] = []
|
||||
try:
|
||||
releases = json.loads((root.parent / "installed.json").read_text()).get("releases") or []
|
||||
order = [str(r.get("version")) for r in releases if isinstance(r, dict)]
|
||||
except (OSError, ValueError, AttributeError):
|
||||
pass
|
||||
found = [d for d in root.iterdir() if (d / "lib" / abi / name).is_file()]
|
||||
found.sort(key=lambda d: (order.index(d.name) if d.name in order else len(order), -d.stat().st_mtime))
|
||||
return found[0] / "lib" / abi / name if found else None
|
||||
+114
-3
@@ -818,6 +818,8 @@ class FramePortApp:
|
||||
C.install_state(g, self.frame_info) in ("installed", "outdated"):
|
||||
out.append((tr("Update Steam art on Frame"), ft.Icons.WALLPAPER_ROUNDED,
|
||||
lambda e: self.update_steam_art(pkg)))
|
||||
if C.frame_drive(g, self.frame_info) is not None: # agent v63+: games can live on a microSD card
|
||||
out.append((tr("Move to…"), ft.Icons.DRIVE_FILE_MOVE_OUTLINED, lambda e: self.move_game(pkg)))
|
||||
out.append((tr("Refresh store details"), ft.Icons.SYNC_ROUNDED, lambda e: self.refresh_details(pkg)))
|
||||
if not job and not linux:
|
||||
out.append((tr("Rebuild only (no install)") if not rift else tr("Check game files"), ft.Icons.BUILD_ROUNDED,
|
||||
@@ -914,11 +916,11 @@ class FramePortApp:
|
||||
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_license()
|
||||
return ask_obb()
|
||||
oculus = [g for g in games if g.get("kind") == "rift"
|
||||
and "pcvr.revive" in (g.get("recipe") or {}).get("patches", {})]
|
||||
if not oculus:
|
||||
return ask_license()
|
||||
return ask_obb()
|
||||
boxes = {g["package"]: ft.Checkbox(label=self._title(g["package"]), value=False, active_color=T.ACCENT)
|
||||
for g in oculus}
|
||||
pick = C.one_choice()
|
||||
@@ -933,7 +935,7 @@ class FramePortApp:
|
||||
"those Oculus games need PC mode (SteamVR + Revive)."))
|
||||
finished()
|
||||
return
|
||||
ask_license()
|
||||
ask_obb()
|
||||
self.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(tr("These games can't run on the Steam Frame"), weight=ft.FontWeight.W_600),
|
||||
content=ft.Container(ft.Column([
|
||||
@@ -947,6 +949,39 @@ class FramePortApp:
|
||||
actions=[C.ghost(tr("Cancel"), on_click=pick(cancel)),
|
||||
C.primary(tr("Continue"), on_click=pick(ok))]))
|
||||
|
||||
def ask_obb():
|
||||
# the game expects a data file (.obb) that wasn't found next to its APK: it would hang at start (#85)
|
||||
missing = [g for g in games if pipeline.missing_obb(g)]
|
||||
if not missing:
|
||||
return ask_license()
|
||||
boxes = {g["package"]: ft.Checkbox(label=self._title(g["package"]), value=False, active_color=T.ACCENT)
|
||||
for g in missing}
|
||||
pick = C.one_choice()
|
||||
|
||||
def ok(e):
|
||||
nonlocal games
|
||||
self.page.pop_dialog()
|
||||
keep = {p for p, b in boxes.items() if b.value}
|
||||
games = [g for g in games if g not in missing or g["package"] in keep]
|
||||
if not games:
|
||||
self.toast(tr("Nothing to install: add the games' .obb files first"))
|
||||
finished()
|
||||
return
|
||||
ask_license()
|
||||
self.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(tr("Game data (.obb) not found"), weight=ft.FontWeight.W_600),
|
||||
content=ft.Container(ft.Column([
|
||||
C.body(tr("These games keep their content in a data file (.obb), and none was found next to "
|
||||
"their APK. Without it they hang at start. Put the .obb files in a folder named like "
|
||||
"the game's package (or obb/) next to the APK and add the folder again. Tick any you "
|
||||
"still want to install.")),
|
||||
*boxes.values()], spacing=T.S2, tight=True, scroll=ft.ScrollMode.AUTO), width=T.px(520),
|
||||
height=T.px(min(150 + 36 * len(boxes), 480))), # fits the list; scrolls when long
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
modal=True, on_dismiss=pick(closed),
|
||||
actions=[C.ghost(tr("Cancel"), on_click=pick(cancel)),
|
||||
C.primary(tr("Continue"), on_click=pick(ok))]))
|
||||
|
||||
def ask_license():
|
||||
from ..core import winhost
|
||||
|
||||
@@ -1235,6 +1270,69 @@ class FramePortApp:
|
||||
return tr("{title}: Steam artwork updated on the Frame").format(title=self._title(pkg))
|
||||
return self.submit(tr("Update Steam art: {title}").format(title=self._title(pkg)), run, pkg, "art")
|
||||
|
||||
def move_game(self, pkg: str) -> None:
|
||||
"""Move to…: pick another drive of the Frame (microSD card, internal storage) for an installed game; the copy
|
||||
runs as a Frame job (GitHub #90)."""
|
||||
from ..install import drives
|
||||
|
||||
title = self._title(pkg)
|
||||
here = C.frame_drive(library.game(pkg) or {"package": pkg}, self.frame_info)
|
||||
group = ft.RadioGroup(content=ft.Column([C.meta(tr("Looking for drives…"))], spacing=T.S2))
|
||||
go = C.primary(tr("Move"), ft.Icons.DRIVE_FILE_MOVE_OUTLINED, disabled=True)
|
||||
found: dict[str, dict] = {}
|
||||
|
||||
def picked(e):
|
||||
go.disabled = not group.value
|
||||
C.update(go)
|
||||
|
||||
group.on_change = picked
|
||||
|
||||
def fill():
|
||||
try:
|
||||
listed = self._target_for("frame").drives()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
group.content.controls = [C.meta(tr("Couldn't list the Frame's drives: {error}")
|
||||
.format(error=explain(exc)))]
|
||||
C.update(group)
|
||||
return
|
||||
rows = []
|
||||
for d in listed:
|
||||
key = drives.INTERNAL if d["internal"] else d["install_dir"]
|
||||
current = drives.is_on({"drive": here}, d) if here else d["internal"]
|
||||
found[key] = d
|
||||
note = tr("it's here now") if current else \
|
||||
(d.get("reason") or "") if not d.get("usable") else drives.free_text(d)
|
||||
rows.append(ft.Row([
|
||||
ft.Radio(value=key, active_color=T.ACCENT, disabled=current or not d.get("usable")),
|
||||
ft.Icon(ft.Icons.SD_CARD_ROUNDED if d.get("removable") else ft.Icons.STORAGE_ROUNDED,
|
||||
size=T.px(18), color=T.TEXT_2),
|
||||
ft.Column([C.body(tr("Internal storage") if d["internal"] else d["label"], T.TEXT),
|
||||
C.meta(note)], spacing=T.px(2), expand=True),
|
||||
], spacing=T.S2))
|
||||
group.content.controls = rows or [C.meta(tr("Only internal storage"))]
|
||||
C.update(group)
|
||||
|
||||
def start(e):
|
||||
dest = group.value
|
||||
if not dest:
|
||||
return
|
||||
self.page.pop_dialog()
|
||||
label = tr("Internal storage") if dest == drives.INTERNAL else found.get(dest, {}).get("label", dest)
|
||||
|
||||
def run(job: Job):
|
||||
self._target_for("frame").move(pkg, dest, job.reporter)
|
||||
return tr("{title} moved to {label}").format(title=title, label=label)
|
||||
self.submit(tr("Move {title} to {label}").format(title=title, label=label), run, pkg, "tool-frame")
|
||||
|
||||
go.on_click = start
|
||||
self.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(tr("Move {title} to…").format(title=title), weight=ft.FontWeight.W_600),
|
||||
content=ft.Container(ft.Column([C.body(C.HELP["move_game"]), group], spacing=T.S3, tight=True),
|
||||
width=T.px(460)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[C.ghost(tr("Cancel"), on_click=lambda e: self.page.pop_dialog()), go]))
|
||||
self.run_bg(fill)
|
||||
|
||||
def build_game(self, pkg: str) -> Job:
|
||||
def run(job: Job):
|
||||
info = pipeline.build_game(pkg, job.reporter)
|
||||
@@ -1697,6 +1795,7 @@ class FramePortApp:
|
||||
def _startup(self):
|
||||
from ..frame.connection import parse_target, saved_targets
|
||||
|
||||
self.run_bg(self._analysis_refresh)
|
||||
self._art_backfill()
|
||||
saved = saved_targets()
|
||||
if saved:
|
||||
@@ -1708,6 +1807,18 @@ class FramePortApp:
|
||||
self.connect(parse_target(f"{f.user}@{f.host}"), quiet=True)
|
||||
break
|
||||
|
||||
def _analysis_refresh(self) -> None:
|
||||
"""Games analysed by an older FramePort lack fields newer fixes depend on (GitHub #104): their APKs are read
|
||||
again once, in the background. Not a job: ~8 s per large APK, and the queue would hold the user's installs
|
||||
for minutes (a build of such a game analyses it first itself). Recipes the user didn't edit then follow."""
|
||||
if not pipeline.outdated_analyses():
|
||||
return
|
||||
n = pipeline.refresh_analyses()
|
||||
if n:
|
||||
self.refresh_view()
|
||||
self.toast(tr_n("{n} game was analyzed again for new fixes", "{n} games were analyzed again for new fixes",
|
||||
n))
|
||||
|
||||
def _art_backfill(self) -> None:
|
||||
"""Rift games added before automatic artwork (or while offline): fetch it once in the background."""
|
||||
from ..artwork import sources
|
||||
|
||||
@@ -29,6 +29,22 @@ def _deployment(game: dict, frame_info: dict | None) -> dict | None:
|
||||
return next((d for d in (frame_info or {}).get("installed", []) if d.get("package") == game.get("package")), None)
|
||||
|
||||
|
||||
def frame_drive(game: dict, frame_info: dict | None) -> dict | None:
|
||||
"""The drive the game is installed on ({internal, path, label, missing}), None if not installed / old agent."""
|
||||
from ..install.drives import game_drive
|
||||
|
||||
return game_drive(_deployment(game, frame_info))
|
||||
|
||||
|
||||
def drive_note(drive: dict | None) -> str:
|
||||
"""" · on SD Card" / " · SD Card not inserted" for a game on another drive, "" on internal storage."""
|
||||
if not drive or drive.get("internal"):
|
||||
return ""
|
||||
if drive.get("missing"):
|
||||
return tr(" · {label} not inserted").format(label=drive.get("label") or tr("its drive"))
|
||||
return tr(" · on {label}").format(label=drive.get("label") or "")
|
||||
|
||||
|
||||
def settings_diff(game: dict, frame_info: dict | None) -> tuple[list[str], list[str]] | None:
|
||||
"""(patches turned on, patches turned off) in the app since the Frame's copy was installed; None if the same (or
|
||||
unknown: installs from before FramePort recorded their recipe)."""
|
||||
@@ -37,7 +53,10 @@ def settings_diff(game: dict, frame_info: dict | None) -> tuple[list[str], list[
|
||||
if not isinstance(rec, dict) or "patches" not in rec:
|
||||
return None
|
||||
installed, now = set(rec["patches"]), set((game.get("recipe") or {}).get("patches") or {})
|
||||
added, removed = sorted(now - installed), sorted(installed - now)
|
||||
# patches a build left out because the OVRPort runtime already has the fix (patches/upstream.py) are not missing:
|
||||
# counting them kept "Update on Frame" up after every update (e.g. haptic_fix with runtime 3.4.3-aa54c3f)
|
||||
superseded = set((game.get("build") or {}).get("superseded") or {})
|
||||
added, removed = sorted(now - installed - superseded), sorted(installed - now)
|
||||
return (added, removed) if added or removed else None
|
||||
|
||||
|
||||
@@ -87,7 +106,9 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
|
||||
if frame_info is None:
|
||||
return None
|
||||
dep = _deployment(game, frame_info)
|
||||
if not dep or dep.get("apk_present") is False: # no record, or the game files are gone (half uninstalled)
|
||||
# no record, or the game files are gone (half uninstalled); a game on a microSD card that isn't inserted now is
|
||||
# still installed (GitHub #90)
|
||||
if not dep or (dep.get("apk_present") is False and not dep.get("drive_missing")):
|
||||
return "missing"
|
||||
b = game.get("build") or {}
|
||||
built = {b.get("sha256"), b.get("alt_sha256")} - {None}
|
||||
|
||||
@@ -129,6 +129,17 @@ HELP: dict[str, str] = _Translated({
|
||||
"Storage = 4XPlayer): upload into that folder in the game's storage.",
|
||||
"free_space": "Deletes the rollback copies kept from each game's previous install and leftover uploads. The "
|
||||
"games and saves stay.",
|
||||
"install_drive": "Where new games go on the Frame: its internal storage or another drive, such as a microSD card "
|
||||
"(in a FramePort folder on it). Games you installed already stay where they are; move one with "
|
||||
"right-click → Move to…. The launcher and Steam entry always stay on internal storage. Drives "
|
||||
"formatted as FAT, exFAT or NTFS can't hold games (Android and Proton need Linux file "
|
||||
"permissions): format the card in SteamOS first.",
|
||||
"move_game": "Copies the game's files, data and saves to the other drive, checks the copy and then removes the "
|
||||
"old one. The Steam entry, settings and saves stay valid. The game must be closed. A game on a "
|
||||
"microSD card only starts while the card is inserted.",
|
||||
"desktop_entry": "Adds the app to Desktop Mode's application menu and puts an icon on its desktop. Some apps work "
|
||||
"better there, with a mouse and keyboard, than in Gaming Mode, where Steam Input turns the "
|
||||
"controllers into a gamepad.",
|
||||
"adapter_settings": "Sharpness, refresh rate, controllers, menus and more for this game. Changes are used the "
|
||||
"next time it starts.",
|
||||
"frame_summary": "Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR "
|
||||
|
||||
@@ -7,7 +7,8 @@ from typing import TYPE_CHECKING
|
||||
import flet as ft
|
||||
|
||||
from ...core import library
|
||||
from ...i18n import tr
|
||||
from ...errors import explain
|
||||
from ...i18n import tr, tr_n
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
from ..battery import label as battery_label
|
||||
@@ -82,6 +83,64 @@ class FrameView:
|
||||
return C.section(tr("Ready to play"), C.card(ft.Column(rows, spacing=0),
|
||||
padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)))
|
||||
|
||||
def storage(self, info: dict) -> ft.Control:
|
||||
"""Drives games can go on (GitHub #90) and where new games go. The list comes from the Frame in the
|
||||
background."""
|
||||
from ...install import drives
|
||||
|
||||
app = self.app
|
||||
current = drives.install_dest()
|
||||
pick = ft.Dropdown(label=tr("Install new games to"), value=current or drives.INTERNAL, width=T.px(380),
|
||||
dense=True, border_color=T.BORDER, text_size=T.T_BODY, disabled=True,
|
||||
options=[ft.DropdownOption(key=drives.INTERNAL, text=tr("Internal storage"))])
|
||||
rows = ft.Column([C.meta(tr("Looking for drives…"))], spacing=T.px(6))
|
||||
|
||||
def choose(e):
|
||||
value = e.control.value or drives.INTERNAL
|
||||
drives.set_install_dest(None if value == drives.INTERNAL else value)
|
||||
label = next((o.text for o in pick.options if o.key == value), value)
|
||||
app.toast(tr("New games go to {label}").format(label=label))
|
||||
|
||||
pick.on_select = choose
|
||||
|
||||
def name(d: dict) -> str:
|
||||
return tr("Internal storage") if d["internal"] else d["label"]
|
||||
|
||||
def fill():
|
||||
try:
|
||||
found = app.target.drives() if app.target else []
|
||||
except Exception as exc: # noqa: BLE001 - shown in place of the list
|
||||
rows.controls = [C.meta(tr("Couldn't list the Frame's drives: {error}").format(error=explain(exc)))]
|
||||
C.update(rows)
|
||||
return
|
||||
opts, lines = [], []
|
||||
for d in found:
|
||||
free = drives.free_text(d)
|
||||
if d.get("usable"):
|
||||
opts.append(ft.DropdownOption(key=drives.INTERNAL if d["internal"] else d["install_dir"],
|
||||
text=" · ".join(filter(None, [name(d), free]))))
|
||||
state = tr_n("{n} game", "{n} games", d.get("games", 0)) if d.get("usable") else \
|
||||
tr("can't hold games: {reason}").format(reason=d.get("reason") or "")
|
||||
lines.append(ft.Row([
|
||||
ft.Icon(ft.Icons.SD_CARD_ROUNDED if d.get("removable") else ft.Icons.STORAGE_ROUNDED,
|
||||
size=T.px(18), color=T.TEXT_2 if d.get("usable") else T.TEXT_3),
|
||||
C.body(name(d), T.TEXT if d.get("usable") else T.TEXT_3, expand=True),
|
||||
C.meta(" · ".join(filter(None, [d.get("fstype"), free, state]))),
|
||||
], spacing=T.S2))
|
||||
if current and not any(o.key == current for o in opts): # the chosen card isn't inserted now
|
||||
label = current.rstrip("/").rsplit("/", 2)[-2] if current.rstrip("/").endswith("/FramePort") \
|
||||
else current
|
||||
opts.append(ft.DropdownOption(key=current, text=tr("{label} (not inserted)").format(label=label)))
|
||||
pick.options = opts or pick.options
|
||||
pick.disabled = len(opts) < 2
|
||||
rows.controls = lines or [C.meta(tr("Only internal storage"))]
|
||||
C.update(pick)
|
||||
C.update(rows)
|
||||
|
||||
app.run_bg(fill)
|
||||
return C.section(tr("Storage"), C.card(ft.Column([pick, rows], spacing=T.S3), padding=T.S4),
|
||||
help="install_drive")
|
||||
|
||||
def installed(self, info: dict) -> ft.Control:
|
||||
"""The list fills in the background (icon thumbnails may need creating the first time)."""
|
||||
items = info.get("installed") or []
|
||||
@@ -105,6 +164,7 @@ class FrameView:
|
||||
return spacer
|
||||
|
||||
from ...artwork import thumbs
|
||||
from ...install.drives import game_drive as drive_of
|
||||
from .files_dialog import show_files_dialog
|
||||
from .library import display_title, twins
|
||||
|
||||
@@ -121,7 +181,9 @@ class FrameView:
|
||||
sub = (tr("PC VR · Proton") + (tr(" + Revive") if d.get("revive") else "")) if pcvr else \
|
||||
(C.platform(games[pkg])[0] if in_lib else tr("Linux") if d.get("kind") == "linux" else tr("Quest"))
|
||||
sub += (tr(" · {value:.1f} GiB").format(value=size / 2**30) if size >= 2**30
|
||||
else tr(" · {value:.0f} MiB").format(value=size / 2**20))
|
||||
else tr(" · {value:.0f} MiB").format(value=size / 2**20)) if size or not d.get("drive_missing") \
|
||||
else ""
|
||||
sub += C.drive_note(drive_of(d))
|
||||
title = display_title(games[pkg], tw) if in_lib else (d.get("title") or pkg)
|
||||
rows.append(ft.Container(ft.Row([
|
||||
C.art_fill(art, radius=T.px(8), width=T.px(44), height=T.px(44)),
|
||||
@@ -391,5 +453,5 @@ class FrameView:
|
||||
return self.wizard()
|
||||
return ft.Column([
|
||||
app.top_bar(tr("Steam Frame"), tr("Your headset, what it's ready for and what's installed")),
|
||||
self.device_card(info), self.readiness(info), self.installed(info),
|
||||
self.device_card(info), self.readiness(info), self.storage(info), self.installed(info),
|
||||
], spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
|
||||
import flet as ft
|
||||
|
||||
from ... import pipeline
|
||||
from ...analysis.detect import android_version, too_new_android
|
||||
from ...artwork import thumbs
|
||||
from ...core import library
|
||||
from ...i18n import tr, tr_n
|
||||
@@ -168,6 +169,10 @@ class GameView:
|
||||
rift_platform = self.rift and (g["analysis"].get("extra") or {}).get("platform_sdk")
|
||||
frame_color = (T.TEXT_3 if (rift_oculus or rift_platform) and not frame_ok else
|
||||
T.OK if st == "installed" else T.WARN if st == "outdated" else T.TEXT_3)
|
||||
drive = C.frame_drive(g, app.frame_info) if frame_ok else None # a microSD card (GitHub #90)
|
||||
frame_line += C.drive_note(drive)
|
||||
if drive and drive.get("missing"):
|
||||
frame_color = T.WARN
|
||||
frame_sub = (tr("Oculus game — needs Revive, which doesn't run on the Frame. Play it on this PC (SteamVR).")
|
||||
if rift_oculus else
|
||||
tr("Needs the Oculus Platform (Meta Horizon app) for its license check, which the Frame doesn't "
|
||||
@@ -269,7 +274,32 @@ class GameView:
|
||||
C.body(tr("The Frame's Steam library still shows the old artwork."), T.TEXT, expand=True),
|
||||
C.secondary(tr("Update Steam art on Frame"), ft.Icons.IMAGE_OUTLINED,
|
||||
lambda e: self.app.update_steam_art(pkg))]), "info", ft.Icons.IMAGE_OUTLINED))
|
||||
if recipe.status == "unsupported":
|
||||
min_sdk = extra.get("min_sdk")
|
||||
too_new = not self.rift and not self.linux and too_new_android(min_sdk)
|
||||
if too_new:
|
||||
# whatever the recipe says: the manifest asks for a newer Android (a warning only, a launch test decides)
|
||||
out.append(C.callout(tr("Its manifest asks for Android {version} (API {api}); the Frame's Android is 11 "
|
||||
"(API 30). Apps like this often crash at start, but not always: try it, a "
|
||||
"launch test tells.").format(version=android_version(min_sdk), api=min_sdk),
|
||||
"warn", ft.Icons.PHONELINK_ERASE_ROUNDED))
|
||||
if pipeline.missing_obb(g):
|
||||
out.append(C.callout(tr("This game's data file (.obb) wasn't found next to the APK. Put the .obb files "
|
||||
"in a folder named {package} (or obb/) next to the APK and add the folder again; "
|
||||
"without it the game hangs at start.").format(package=pkg), "error",
|
||||
ft.Icons.FOLDER_OFF_ROUNDED))
|
||||
web = (extra.get("web_wrapper") or None) if not self.rift and not self.linux else None
|
||||
if web:
|
||||
out.append(C.callout(ft.Column([
|
||||
C.body(tr("This app looks like a website in an Android wrapper: it opens the site in Meta's "
|
||||
"browser, which the Frame doesn't have. If nothing opens, use the website in a browser "
|
||||
"instead."), T.TEXT)]
|
||||
+ ([C.meta(web["url"], T.TEXT_2, selectable=True)] if web.get("url") else []), spacing=T.px(4)),
|
||||
"warn", ft.Icons.LANGUAGE_ROUNDED))
|
||||
static = " ".join(engine.blocker_notes(library.analysis_from_dict(g["analysis"]))) \
|
||||
if (too_new or web) else ""
|
||||
if static and recipe.notes.strip() == static:
|
||||
pass # the recipe's note says the same (shown above)
|
||||
elif recipe.status == "unsupported":
|
||||
out.append(C.callout(recipe.notes or tr("This game can't run on the Steam Frame."), "error"))
|
||||
elif recipe.notes and not (self.rift and extra.get("platform_sdk")):
|
||||
out.append(C.callout(recipe.notes, "info"))
|
||||
@@ -436,12 +466,28 @@ class GameView:
|
||||
C.kv(tr("VR (OpenXR)"), tr("Yes: uses the Frame's OpenXR runtime") if vr else
|
||||
tr("No: a 2D app")),
|
||||
C.kv(tr("Source"), extra.get("source") or g.get("game_dir") or ""),
|
||||
C.kv(tr("Desktop Mode"), C.switch(tr("In the menu and on the desktop"),
|
||||
value=g.get("desktop_entry", True) is not False,
|
||||
on_change=self.set_desktop_entry), "desktop_entry"),
|
||||
]
|
||||
return C.section(tr("What FramePort will do"), C.card(ft.Column([
|
||||
ft.Row([ft.Icon(ft.Icons.TERMINAL_ROUNDED, color=T.PC, size=T.px(18)),
|
||||
C.body(lead, T.TEXT, weight=ft.FontWeight.W_500, expand=True)], spacing=T.S2),
|
||||
*rows], spacing=T.S3)), help="linux_app")
|
||||
|
||||
def set_desktop_entry(self, e) -> None:
|
||||
"""A Linux app's Desktop Mode entry (GitHub #84): kept with the game, applied on the Frame at once when it's
|
||||
installed there (else at the next install)."""
|
||||
app, pkg, on = self.app, self.package, bool(e.control.value)
|
||||
|
||||
def work():
|
||||
library.upsert_game(pkg, desktop_entry=on)
|
||||
if app.frame_state == "connected" and app.target and \
|
||||
C.install_state(self.g, app.frame_info) in ("installed", "outdated"):
|
||||
app.target.set_desktop_entry(pkg, on)
|
||||
app.toast(tr("Added to Desktop Mode") if on else tr("Removed from Desktop Mode"))
|
||||
app.run_bg(work)
|
||||
|
||||
def recipe_summary(self) -> ft.Control:
|
||||
if self.linux:
|
||||
return self.linux_summary()
|
||||
@@ -515,6 +561,9 @@ class GameView:
|
||||
state = {"recipe": recipe}
|
||||
warn = C.body("", T.WARN)
|
||||
technical = bool(library.setting("ui.patch_details", False)) # remembered for every game
|
||||
# workarounds the last build left out because OVRPort's output already had the fix (patches/upstream.py)
|
||||
superseded = (g.get("build") or {}).get("superseded") or {}
|
||||
upstream_note = tr("Not needed in the last build: OVRPort fixed this itself.")
|
||||
|
||||
def save(r):
|
||||
r.source = "user"
|
||||
@@ -574,12 +623,16 @@ class GameView:
|
||||
|
||||
vals = recipe_values(g)
|
||||
changed = sum(1 for k, v in vals.items() if v != default(k))
|
||||
fixed = [base.get(pid).title for pid in superseded if pid.startswith("adapter.")]
|
||||
fixed_note = [C.meta(tr("Not needed in the last build, OVRPort fixed it itself: {names}").format(
|
||||
names=", ".join(tr(t) for t in fixed)), T.TEXT_3)] if fixed else []
|
||||
sections.append(C.card(ft.Row([
|
||||
ft.Column([ft.Row([C.body(CATEGORY_TITLES[cat], T.TEXT, weight=ft.FontWeight.W_600),
|
||||
C.help_icon("cat_adapter")], spacing=T.S2),
|
||||
C.meta(tr_n("{n} setting changed from the default",
|
||||
"{n} settings changed from the default", changed) if changed
|
||||
else tr("All at their defaults"))], spacing=T.px(2), expand=True),
|
||||
else tr("All at their defaults")), *fixed_note], spacing=T.px(2),
|
||||
expand=True),
|
||||
C.secondary(tr("Change settings…"), ft.Icons.TUNE_ROUNDED,
|
||||
lambda e: app.settings_dialog(package)),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S4, T.S3, T.S4, T.S3)))
|
||||
@@ -596,6 +649,8 @@ class GameView:
|
||||
shown = tr(reason).replace("overport", "OVRPort") if technical else \
|
||||
plain_reason(reason, p.default_on)
|
||||
sub.insert(0, C.meta(shown, T.ACCENT))
|
||||
if on and p.id in superseded:
|
||||
sub.append(C.meta(upstream_note, T.TEXT_3))
|
||||
extra = None
|
||||
choices = getattr(p, "CHOICES", None)
|
||||
if choices: # a plain choice instead of a switch + text field (e.g. Proton: Experimental / Stable)
|
||||
|
||||
@@ -55,6 +55,9 @@ def database() -> dict:
|
||||
return _db
|
||||
|
||||
|
||||
LOGCAT_LINE = re.compile(r"\d\d-\d\d \d\d:\d\d:\d\d\.\d+ ")
|
||||
|
||||
|
||||
def game_lines(log: str, package: str | None = None) -> list[str]:
|
||||
"""Strip colour codes; when possible keep only lines of the game's process (plus Lepton's own lines)."""
|
||||
lines = [ANSI.sub("", ln) for ln in log.splitlines()]
|
||||
@@ -70,10 +73,11 @@ def game_lines(log: str, package: str | None = None) -> list[str]:
|
||||
out = []
|
||||
for ln in lines:
|
||||
f = ln.split()
|
||||
# logcat threadtime: date time pid tid level tag: msg
|
||||
if len(f) > 3 and (f[2] in pids or not f[2].isdigit()):
|
||||
# logcat threadtime: date time pid tid level tag: msg; everything else is Lepton's own output (e.g. its short
|
||||
# "Boot complete!", which the container-not-started signature checks for: it used to be dropped here)
|
||||
if not (len(f) > 3 and LOGCAT_LINE.match(ln) and f[2].isdigit()):
|
||||
out.append(ln)
|
||||
elif "lepton" in ln.lower() or "APP_ACTIVITY" in ln:
|
||||
elif f[2] in pids or "lepton" in ln.lower() or "APP_ACTIVITY" in ln:
|
||||
out.append(ln)
|
||||
return out
|
||||
|
||||
@@ -119,6 +123,21 @@ def triage(log: str, state: str = "UNKNOWN", package: str | None = None, crash:
|
||||
return res
|
||||
|
||||
|
||||
MISSING_OBB = ("The game keeps its content in a data file (.obb) and none was installed: FramePort found no .obb "
|
||||
"files next to its APK. Unreal games then stop right after start without an error (GitHub #85). Put "
|
||||
"the .obb files in a folder named like the package (or obb/) next to the APK, add the folder again "
|
||||
"and reinstall.")
|
||||
|
||||
|
||||
def add_missing_obb(res: TriageResult) -> TriageResult:
|
||||
"""A launch test of a game whose APK expects an OBB that wasn't installed (from the library, not the log: the
|
||||
game logs nothing about it). Only when it sent no frames: an OBB from an earlier install may still be there."""
|
||||
if res.fps is None and not any(f.id == "missing-obb" for f in res.findings):
|
||||
res.findings.insert(0, Finding("missing-obb", "fatal", MISSING_OBB, [],
|
||||
"analysis: the APK expects an OBB (Unreal bHasOBBFiles); no data folder"))
|
||||
return res
|
||||
|
||||
|
||||
LOGCAT_TIME = re.compile(r"^\d\d-\d\d (\d\d):(\d\d):(\d\d)\.(\d{3})")
|
||||
|
||||
|
||||
|
||||
+4
-1
@@ -24,7 +24,7 @@ def isolated_home(tmp_path, monkeypatch):
|
||||
|
||||
# ---------------------------------------------------------------------------------------------- AXML builder
|
||||
def build_axml(elements, strings_extra=()):
|
||||
"""elements: list of ("start", name, [(attr, kind, value)]) / ("end", name). kind: "str" | "bool".
|
||||
"""elements: list of ("start", name, [(attr, kind, value)]) / ("end", name). kind: "str" | "bool" | "int" | "ref".
|
||||
Produces a UTF-16 string-pool binary XML like aapt does (enough for our editor)."""
|
||||
ns = "http://schemas.android.com/apk/res/android"
|
||||
pool = []
|
||||
@@ -61,6 +61,9 @@ def build_axml(elements, strings_extra=()):
|
||||
for a, kind, v in e[2]:
|
||||
if kind == "str":
|
||||
attrs += struct.pack("<IIIHBBI", idx(ns), idx(a), idx(v), 8, 0, 0x03, idx(v))
|
||||
elif kind in ("int", "ref"): # decimal integer / resource reference (@string/…)
|
||||
attrs += struct.pack("<IIIHBBI", idx(ns), idx(a), 0xFFFFFFFF, 8, 0, 0x10 if kind == "int" else 0x01,
|
||||
v)
|
||||
else:
|
||||
attrs += struct.pack("<IIIHBBI", idx(ns), idx(a), 0xFFFFFFFF, 8, 0, 0x12, 0xFFFFFFFF if v else 0)
|
||||
ext = struct.pack("<IIHHHHHH", 0xFFFFFFFF, idx(e[1]), 20, 20, len(e[2]), 0, 0, 0)
|
||||
|
||||
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user