Compare commits

...
63 Commits
Author SHA1 Message Date
spoopyghosty0 fa473c5ada frame.unreal_thumb_touch: UE4 OculusInput's ThumbUp from the capacitive touches instead of near-touch, which the Frame never reports (thumbs always pointed up). Three instructions in SendControllerEvents (masks 0x2/0x8 -> 0x0f00/0x000f, NearTouches load -> Touches), matched exactly; suggested where the code matches (analysis unreal_thumb_touch, ANALYSIS_VERSION 7): Vader Immortal Ep. I, Robo Recall, Phantom: Covert Ops, Time Stall, Star Wars: Tales. Vader's recipe uses it instead of proximity_emul (the binding didn't animate the thumbs). Found by Klownicle, GitHub #49 2026-10-09 11:35:36 -04:00
spoopyghosty0 1e6ce04e4f pose_time_fix: never move a recent predicted display time; monotonic times 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 of CLOCK_MONOTONIC, a request at the frame's display time after a hitch was taken for a monotonic time and moved +67.7 ms). Found by Klownicle, GitHub #49 2026-10-09 11:35:35 -04:00
spoopyghosty0 5cd5b119e7 Lint: extraneous parentheses (ruff UP034) 2026-10-09 11:33:00 -04:00
spoopyghosty0 186138cc3c Game page: a patch the build left out because the OVRPort runtime already has the fix (build.superseded, e.g. haptic_fix with runtime 3.4.3-aa54c3f) no longer counts as a settings change: 'Update on Frame' stayed after every update (owner's VR HOT, Klownicle's Vader, GitHub #49) 2026-10-09 11:32:40 -04:00
spoopyghosty0 98c7ca980a Triage: Lepton's own short lines survive the game-process filter. With the package known, game_lines dropped every line of 3 words or fewer, so Lepton's 'Boot complete!' vanished and the transient 'is not a running context' failed launch tests in the GUI although the games ran (owner's runs: Come Closer, AntiZeroGames CH, SKYBOX; Klownicle's Vader, GitHub #49). Only logcat lines are filtered by pid now 2026-10-09 11:29:12 -04:00
spoopyghosty0 b2470b962a Catalog: Max Mustard is listed by its name, not its package name Matilda (GitHub #110) 2026-10-09 11:10:09 -04:00
spoopyghosty0 2a705bc6d1 frame.gl_multiview_fbo: single-view twins for multiview programs drawn into flat framebuffers (GitHub #77)
Mesa enforces OVR_multiview's rule that a draw's program declares as many views as the draw framebuffer has and drops the draw silently; Doom3Quest compiles every vertex shader with layout(num_views=2) and draws its HUD/PDA into 2D-texture FBOs, which stay black on the Frame.

native/glmv (libfpglmv.so, same length as libGLESv3.so): the engine library's dlopen string is rewritten to it and it becomes the first DT_NEEDED. It records stage sources at link time and, when a multiview program draws into a framebuffer without views, draws with a lazily built single-view twin (num_views layout blanked, gl_ViewID_OVR -> 0u; attribute locations, block bindings and uniform values copied), then rebinds the original. Opt-in and experimental (analysis gl_multiview_libs, ANALYSIS_VERSION 6), setting gl_mv_debug, triage gl-multiview-twin-failed.

Host tests: the rewriter on Doom3Quest's 19 shaders (GPL-3.0 fixtures) and glmv.c against a stand-in GL that applies Mesa's rule. Untested on the device.
2026-10-09 10:54:40 -04:00
spoopyghosty0 5d44831757 Agent v67: keep launch.log filling when Lepton's logcat mirror dies ('logcat: Unexpected EOF!' right after the game starts, about 1 launch in 50: Vader Immortal on Lepton 3.0.5, Under Cover on 2.8.14). launch.sh starts _logcat_keeper, which then reads the container's logcat itself (podman exec ... logcat -T 2000, up to 5 restarts) so the dashboard auto-hide and launch tests see the game; upgrade_launchers adds it to existing launchers 2026-10-09 10:14:57 -04:00
spoopyghosty0 456880501b PC VR: a catalog recipe verified with another build (different VR APIs) keeps the build's own launch arguments; Electron launchers rank below the game; agent v67 collects Unity's Player.log / output_log.txt / crash error.log for launch tests and diagnostics; triage unity-vr-init and unity-crash (GitHub #105) 2026-10-09 10:10:33 -04:00
spoopyghosty0 63f7ae12e1 Catalog: Vader Immortal: Episode I works (owner's headset test of the GitHub #49 fixes by Klownicle) 2026-10-09 10:06:44 -04:00
spoopyghosty0 fa69ffefd2 Vader Immortal: loading card, controls, lightspeed shaders, thumbs (GitHub #49)
Klownicle's five fixes for Vader Immortal: Episode I, reimplemented as FramePort patches (found by Klownicle,
GitHub #49; their prebuilt APK is not used):

- frame.unreal_quest_precompile: UVRUtils::GetQuestShaderPrecompilePercent returned 0.0 on its non-Quest branch, so
  the menu waited forever on the loading card; that branch now returns 1.0. Located by symbol, every instruction
  around it checked (bl IsRunningOnSantaCruz; tbz w0,#0; fmov s0,wzr; ldp; ret); other builds stay untouched.
- frame.unreal_quest_keymap: the RPOC key selector (shared by AddAxisMapping and AddActionMapping, so both mapping
  kinds were affected) picked the empty Gear VR key set for an Oculus HMD; the tbz to it becomes a nop. Located as
  the common callee of both functions, pattern-checked.
- frame.zink_shader_fix + native/zinkfix (libVkLayer_fp_shaderfix.so): a Vulkan layer under Zink for GLES games
  that applies zink_shader_fix entries (vk_shader_fix format) to the SPIR-V Zink generates; it adds itself to
  GraphicsEnv's debug layer list from a constructor (the engine library loads it first). zink_shader_dump=1
  captures modules. Vader's recipe carries the two lightspeed-shader fixes (12 OpStores each).
- adapter proximity_emul (FrameBridge): OVRPlugin's thumb/index proximity actions get bindings to the capacitive
  touch inputs when the runtime lacks XR_FB_touch_controller_proximity (OVRPort offers the extension anyway):
  generic replacement for the libUE4 ThumbUp patch.
- Vader's recipe: pose_time_fix + proximity_emul, status unknown (to test in the headset), min_app 0.12.1.

Analysis records unreal_quest_gates (ANALYSIS_VERSION 4). Star Wars: Tales from the Galaxy's Edge (same studio)
has neither gate (different engine build) and gets a catalog entry with pose_time_fix + proximity_emul.

Headless on the dev Frame (Lepton 3.0.5): the layer loads into Zink's instances, the proximity bindings are
accepted, and the game passes the loading card to the "guardian smaller than recommended, press any button" screen.
2026-10-09 09:20:15 -04:00
spoopyghosty0 f78bc82cc4 frame.vivox_audio_route: Vivox voice chat without Android 12 audio routing (GitHub #101)
Newer Vivox builds (Green Hell VR) call AudioManager communication-device methods (API 31) from com.vivox.sdk.AudioChangeListener without a version check; Lepton is Android 11 -> NoSuchMethodError. The methods that call them now return at once (in-place dex edit, Dex.return_early). Detected by analysis vivox_api31 (ANALYSIS_VERSION 4), triage vivox-api31. Verified headless: Green Hell starts, Vivox initialises, ~65-70 fps.
2026-10-09 09:09:24 -04:00
spoopyghosty0 dfacf6a206 FrameBridge: serve cube swapchains the runtime refuses (GitHub #107)
Budget Cuts Ultimate asks for a 2048x2048 cube swapchain (faces=6); the Frame's runtime has no cube layers and refuses it (-2), OVRPlugin carries on with no images and crashes in ovrp_EndFrame4 (memset). cube_standin (default on, GLES) serves it as a GL cube map in the game's context and drops its layers. Triage cube-swapchain-refused (supersedes unity-render-crash/native-crash). Verified headless: the game runs on at ~70 fps.
2026-10-09 09:09:23 -04:00
spoopyghosty0 b2f14c1e2e Catalog: SUPERHOT VR (Rift) keeps the build's own launch arguments (GitHub #105: the older Oculus + SteamVR build needs -vrmode OpenVR; the catalog recipe from the OpenXR build dropped it) 2026-10-09 08:49:53 -04:00
spoopyghosty0 03d7a311bf Catalog: Batman: Arkham Shadow gets sync_guard to test (GitHub #102: quit to Steam's waiting screen after the Frame runtime crashed in xrSyncActions, the input race Myst had) 2026-10-09 08:32:50 -04:00
spoopyghosty0 cd5119278b Catalog: The Light Brigade (works with issues)
Closes #114
2026-10-09 07:56:14 -04:00
spoopyghosty0 6ab3608a44 Catalog: Gravity Lab (works)
Closes #113
2026-10-09 07:56:06 -04:00
spoopyghosty0 c0539af368 Catalog: Does it Stack? (works with issues)
Closes #112
2026-10-09 07:56:02 -04:00
spoopyghosty0 c6363140f4 Catalog: Walkabout Mini Golf (works)
Closes #111
2026-10-09 07:55:58 -04:00
spoopyghosty0 187105b729 Catalog: Matilda (works)
Closes #110
2026-10-09 07:55:55 -04:00
spoopyghosty0 b983c0f11f Catalog: The Tale of Onogoro (works)
Closes #108
2026-10-09 07:55:52 -04:00
spoopyghosty0 c7cee33889 Catalog: Please Don't Touch Anything (works)
Closes #106
2026-10-09 07:55:49 -04:00
spoopyghosty0 d9d73b8aec Triage: Lepton 3.0.5's transient 'is not a running context' (printed while its container is still starting, then 'Boot complete!') no longer fails a launch test (container-not-started unless Boot complete!; VR4 ran at 72 fps on the dev Frame but was marked failed) 2026-10-09 07:55:24 -04:00
spoopyghosty0 48455b9b72 pose_time_fix: robust clock offset (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 up to 2 s into the future), far-past requests located at the frame's display time, nothing moved before 8 samples. Headless survey of 54 games: no other game asks for head/views on the monotonic clock; UE4 OVRPlugin 1.89 games and The Room VR ask for XrTime ~0 every frame and work today, so the setting stays suggested for Unity built-in Oculus games only. BattleSisters on the Frame: every moved request now at 0.0 ms from the display time 2026-10-09 01:35:15 -04:00
spoopyghosty0 af798c0bf3 Catalog: BattleSisters entry parses again (my previous edit put colons into a plain YAML text block); written with catalog.to_yaml 2026-10-08 22:55:47 -04:00
spoopyghosty0 ed714c9d65 Catalog: BattleSisters plays with hands following the controllers (adapter pose_time_fix, owner's headset test) 2026-10-08 22:51:26 -04:00
spoopyghosty0 832b401630 Pose time fix (adapter pose_time_fix, pose_debug): OVRPlugin locates its "now" poses at the monotonic clock
BattleSisters' hands lagged behind the controllers (also with ovrp_hold_physics on or off). pose_debug=1 (new,
FrameBridge: per 5 s and per located space, the requested time minus the predicted display time) showed on the dev
Frame (SteamOS 0.4.5): XrTime runs 2.56 s ahead of CLOCK_MONOTONIC, the hand spaces were located at -2564 ms (the
monotonic "now" passed on as an XrTime: OVRPort's dispatcher converts XR_KHR_convert_timespec_time 1:1 and
FrameBridge's emulation is never asked) and the head at XrTime 0.1 s (~1450 xrLocateViews/xrLocateSpace per 5 s).

pose_time_fix (default off; suggested for Unity built-in OVRPlugin games): in xrLocateSpace(s)/xrLocateViews a time
nearer the monotonic clock than XrTime's "now" is moved by the offset measured at xrWaitFrame, one more than 0.5 s
before the display time goes to "now". Headless with it on: every far-past request moved, located times -9.8..0 ms
from the display time. Host unit test for the time rules; PLAYBOOK row.
2026-10-08 22:41:34 -04:00
spoopyghosty0 114b7cb4d6 Catalog: BONELAB works on 1.2068 and 1.2974 (owner's headset test of frame.slz_vulkan_hooks) 2026-10-08 22:08:29 -04:00
spoopyghosty0 a4d3ce5e00 Merge BONELAB 1.2974 fix by UnicronNL (GitHub #82)
frame.slz_vulkan_hooks: Stress Level Zero's plugin no longer intercepts Unity's Vulkan start-up and samplers (it called an invalid function right after OVRPlugin's pre-init instance was destroyed). Test fixture rebuilt by us, byte-identical. Owner's headset test: BONELAB 1.2974 plays well.

Closes #82

# Conflicts:
#	docs/GAMES.md
#	tests/test_patches.py
#	tests/test_triage.py
2026-10-08 22:07:54 -04:00
spoopyghosty0 b3095b110a Merge Sniper Elite VR fixes by UnicronNL (GitHub #81)
ovrpshim frame-begin gate and held-back physics update (per-game settings ovrp_begin_gate / ovrp_hold_physics, on in Sniper Elite VR's recipe), xrshim PCM vibrations, unity_oculus_check revision 5; artifacts rebuilt by us. Owner's headset test: Sniper Elite VR plays, Accounting+ unchanged, BattleSisters unaffected (settings off).

Closes #81

# Conflicts:
#	artifacts/SHA256SUMS
#	docs/GAMES.md
2026-10-08 22:06:18 -04:00
spoopyghosty0 fb284b6fc4 Catalog: Star Wars Pinball VR (works; frame.unreal_gl_shim + frame.unreal_ovrp_entrypoints + frame.pac_hints, needs FramePort 0.12.1)
Closes #83
2026-10-08 21:56:46 -04:00
spoopyghosty0 c0df52b981 Unpaired pointer-authentication hints (frame.pac_hints, GitHub #83): with VR running, Star Wars Pinball VR died with SIGILL ILL_ILLOPN at an autiasp (*pc=0xd50323bf) on Unreal's HttpManager thread: its libUE4.so has 38 paciasp but 40 autiasp (OpenSSL's Poly1305 NEON assembly). Quest CPUs run PAC hints as NOPs, the Frame's CPU checks them. In a library whose counts differ, every paciasp/autiasp becomes a NOP in place (libraries using retaa/pacibsp forms are left alone). Suggested by the new triage signature pac-unpaired 2026-10-08 21:50:01 -04:00
spoopyghosty0 a1b0a3f350 Unreal OVRPlugin stand-ins (frame.unreal_ovrp_entrypoints, GitHub #83): Unreal's Oculus module dlsyms every ovrp_* function of the OVRPlugin it was built against and ANDs the results; OVRPort's OpenXR OVRPlugin lacks ovrp_GetPTWNear (Star Wars Pinball VR, UE 4.25 / OVRPlugin 1.44), so the wrapper failed, OculusHMD never pre-initialised and the game crashed without an HMD. Analysis records the engine library's ovrp_* names (extra.unreal_ovrp_lookups, ANALYSIS_VERSION 3); the patch compares them with the shipped libOVRPlugin.so and adds a generated libfp_ovrpstubs.so (DT_NEEDED of the plugin: dlsym on its handle searches its dependencies) whose stand-ins return ovrpFailure (stubgen result=). libUE4.so stays byte-identical. Suggested when the newest OVRPort runtime's plugin (tools.overport.runtime_lib) lacks a looked-up name (bundled fallback before a runtime is downloaded) 2026-10-08 21:33:06 -04:00
spoopyghosty0 df864e9fed GL shim keeps Unreal's multiview (GitHub #83): Unreal 4.25 only enables mobile multiview when GL_OVR_multiview, GL_OVR_multiview2 and GL_OVR_multiview_multisampled_render_to_texture are all listed (FOpenGLES::ProcessExtensions), so hiding the last one with gl_hide_msrtt switched multiview off. For Unreal (libUE4.so/libUnreal.so loaded, MSRTT hidden, multiview kept) the shim keeps that extension visible and maps glFramebufferTextureMultisampleMultiviewOVR to the single-sampled glFramebufferTextureMultiviewOVR (log 'multiview multisampled render-to-texture (N samples) drawn single-sampled'). Unity/other engines unchanged. Previous source rebuilt byte-identical before the change 2026-10-08 21:26:30 -04:00
spoopyghosty0 6595a31048 ovrpshim: frame-begin gate and held-back physics update only with the per-game settings ovrp_begin_gate / ovrp_hold_physics (default off; Sniper Elite VR's recipe turns both on). With them BattleSisters' hands lagged and its loading screen stuttered (owner's headset test); Accounting+ was fine. libfp_ovrp.so rebuilt 2026-10-08 21:13:28 -04:00
spoopyghosty0 f4bcb658e1 Unreal GL shim (frame.unreal_gl_shim, GitHub #83): Star Wars Pinball VR crashes ~3 s in with fault addr 0x10000 on the RHIThread in libgallium_dri.so: Unreal's mobile MSAA renders through multisampled render-to-texture and Zink's find_rp_state indexes rendering_state_cache[6] one past its end. The GL shim hides MSRTT (gl_hide_msrtt); it now also keeps multiview for Unreal (libUE4.so/libUnreal.so, like Unity). Offered for Unreal GLES games, suggested by the new triage signature unreal-msrtt-crash, not from the APK alone. Old glshim source rebuilt byte-identical before the change 2026-10-08 20:11:40 -04:00
spoopyghosty0 e4f70144e9 Unity split-binary builds expect an OBB too (GitHub #92): analysis extra.unity_split → expects_obb, ANALYSIS_VERSION 2
analysis/unity_split.py: an XR-plugin build (Oculus XR Plugin / Unity OpenXR library) without assets/bin/Data/UnitySubsystems/ in the APK (zip names only), or BuildSettings listing more scenes than the APK has levelN files (globalgamemanagers loose, or its node read from the head of data.unity3d: only the blocks up to it). Exact on the 48 Unity APKs of both dump folders: all 13 split builds flagged, none of the 17 full builds (whose OBBs are asset bundles or sound banks). The missing-OBB warning and launch-test finding now cover them; existing entries are re-analysed at the next start.
2026-10-08 16:39:12 -04:00
spoopyghosty0 020334b37b OBBs found by file name: (main|patch).<versionCode>.<package>.obb next to the APK, in an obb folder beside an apk folder, or in a SideQuest backup (GitHub #85, #91)
quest_dump.find_data: after the folder-layout checks, search the APK's folder, its parent and up to 3 levels below each (folders with their own APKs are other games) for the package's expansion files; the folder with the APK's own versionCode wins, per kind the newest file not newer than it, else any version. When that folder holds other things too (the APK, other games' OBBs), the entry gets data_files and only those files are uploaded, counted and deleted with the game; no language-pack/content-file lookup there. Both dump folders scan identically (62/47 games).
2026-10-08 16:39:11 -04:00
spoopyghosty0 24a98ab263 Linux apps use their own icon (agent v64): Desktop Mode entry, Steam shortcut, library
The agent copies an AppImage's .DirIcon / the bundled .desktop file's Icon= (hicolor, pixmaps; symlinks resolved
and kept inside the app folder) to <anchor>/artwork/app-icon.{png,svg} at finalize_linux and when menu entries are
refreshed, and uses it for the menu entry's Icon= and the Steam shortcut (PNG only) unless the user chose an icon
(artwork/.icon-source = custom, sent by the PC with every art upload). StartupWMClass comes from the app's .desktop
file. A PNG goes back to the PC and becomes the library icon when there is no pick or store icon (no .picked;
.app-icon marker); folder apps get it at add time.

Closes #99
2026-10-08 16:33:16 -04:00
spoopyghosty0 58563b99a5 Re-analyse library entries an older FramePort analysed (GitHub #104)
detect.ANALYSIS_VERSION (stored as analysis.extra.analysis_version, bumped
whenever analyze() gains a field): Quest/Android entries below it whose APK
still exists are analysed again in a background thread at the GUI's start
and before a build (pipeline.refresh_analyses). Only analysis and suggested
change; recipes the user didn't edit follow the new suggestion (e.g. an SDL
app gets frame.sdl_clipboard once sdl_java exists), user recipes, tags, art,
titles and builds stay. An unreadable APK keeps its entry and is marked
analysis_failed (not retried until the next bump); a missing one is tried at
a later start. Analyze again sets the version too. Rift/Linux entries skipped.
PLAYBOOK row + CLAUDE.md note.
2026-10-08 16:25:35 -04:00
spoopyghosty0 9013a7d13c Catalog: AgeOfJoy (works)
Closes #103
2026-10-08 16:14:38 -04:00
spoopyghosty0 f36a17b46a Catalog: Pistol Whip (works)
Closes #100
2026-10-08 16:14:36 -04:00
spoopyghosty0 7335ff2ce5 Catalog: VR HOT Quest (works)
Closes #98
2026-10-08 16:14:33 -04:00
spoopyghosty0 21c039f9b3 Catalog: Retronika (works)
Closes #97
2026-10-08 16:14:31 -04:00
spoopyghosty0 3e8a480ea6 Android version / web wrapper: warnings only, never unsupported from the manifest alone (owner's call): the recipe notes, game page (warning callout) and CLI say what the manifest asks for; a launch test's triage (android-too-new, web-wrapper) decides 2026-10-07 23:08:36 -04:00
spoopyghosty0 ea2cd893b4 Linux apps in Desktop Mode's menu: per-app switch on the game page, docs
The game page's "What FramePort will do" for a Linux app gets a Desktop Mode switch ("In the menu and on the
desktop", default on): kept in the library entry (desktop_entry; patches don't apply to Linux apps), passed to
finalize_linux at install and applied at once on the Frame through the agent's desktop_entry command when the app is
installed. The agent side (menu + desktop .desktop files, launcher with FRAMEPORT_DESKTOP=1, refresh for existing
installs, removal on uninstall/purge) came with agent v63. Help text desktop_entry, INSTALL/FRAME_RUNTIME/PLAYBOOK,
CLAUDE.md. Game Mode's Steam Input problem from the report is separate.

Closes #84
2026-10-07 22:46:38 -04:00
spoopyghosty0 a90a8c27dc Install to and move games to a microSD card: Frame page Storage section, game menu Move to…
The Steam Frame page lists the Frame's drives (filesystem, free space, games on each; exFAT/NTFS cards say why they
can't hold games) and sets where new games go (library setting install.drive, default internal storage). Game menu
→ "Move to…" (Frame-installed games, agent v63) picks a drive in a dialog and queues a Frame job that follows the
agent's copy with progress (cancellable until the copy starts). The game page and the Frame page's installed list
show "on SD Card" / "SD Card not inserted"; a game whose card is out counts as installed, not "Not installed".
Help texts install_drive / move_game, ui_smoke --fake-frame drives + the Move dialog, docs (FRAME_RUNTIME "Where
FramePort keeps games", INSTALL, PLAYBOOK row, CLAUDE.md).

Closes #90
2026-10-07 22:46:37 -04:00
spoopyghosty0 9d0d72c40a Agent v63: games on another drive (microSD) and Desktop Mode entries for Linux apps
#90: `drives` lists internal storage, the drives under /run/media (from /proc/mounts) and the drives of Steam
library folders, with free space and whether they can hold games (vfat/exfat/ntfs can't: Lepton data and Proton
prefixes need Unix permissions and symlinks; read-only mounts neither). Games on a drive live in
<mount>/FramePort/<pkg>; the anchor stays internal, so the Steam shortcut never changes. prepare/prepare_pcvr/
prepare_linux take dest (free space from it); a drive that isn't mounted is an error, never a silent fallback to
internal storage, and an installed game keeps its folder. `move` (refused while the game runs, free-space check)
renames on the same drive, else copies with cp -a (in podman's user namespace when podman is there: container-owned
files), checks file count + bytes, retargets symlinks into the old folder (LibOVRRT redirect), points launch.sh at
the new folder (Quest: the app_dir line; Linux/PC VR: written again from the install record, which now keeps env and
game args) and rewrites deployment.json before removing the old copy; detached via systemd-run with a status file
(move_status). list_installed adds drive + drive_missing; launch refuses a game whose card is out; cleanup may remove
leftovers in a drive's FramePort folder (never an installed game); purge/uninstall remove an empty FramePort folder.
PC: install/drives.py (list, setting install.drive, move with progress, picks a running move up again), targets
pass dest for Quest/PC VR/Linux installs, CLI `frameport frame drives`, `frameport frame move <pkg> --to`,
`frameport install --dest`.

#84: finalize_linux writes ~/.local/share/applications/frameport-<slug>.desktop (+ an executable copy on ~/Desktop
when it exists; Exec quoted per the Desktop Entry spec, X-FramePort-Package marks ours) unless desktop_entry is off;
`desktop_entry` command switches it per app; ensure_host_fixes adds entries for existing Linux installs and drops
stale ones; uninstall/purge remove them. The Linux launcher with FRAMEPORT_DESKTOP=1 skips the Steam-parent
watchdog and Steam's display (upgrade_launchers updates existing launchers).
2026-10-07 22:46:36 -04:00
spoopyghosty0 56d2ac117f Missing OBB: analysis extra.expects_obb (Unreal's GameActivity.bHasOBBFiles, UE4 and UE5 names); a game that expects one but has no data folder gets an error callout on the game page, a question before install (default: skip) and a warning in frameport scan/install, and a launch test without frames gets the missing-obb finding from the library (GitHub #85: TRIANGLE STRATEGY was installed without its OBB and hung silently after OVRPlugin's JNI_OnLoad). In the dumps every Unreal game with the flag has its data folder 2026-10-07 22:42:41 -04:00
spoopyghosty0 cb8202d891 Scan: OBBs in more backup layouts (quest_dump.find_data_dir): a <package> folder with .obb files up to 3 levels below the APK's folder (obb/<package>/, OBB/, Android/obb/<package>/) or 2 below its parent, skipping neighbouring folders with APKs of their own (other games/versions); <game>/apk(s)/ + <game>/obb/<package>/ (SideQuest-style backups) is one game named after <game>. The data folder is always the one holding the .obb files (obb/<package>/ used to be sent as obb/ and land one level too deep). Both dump folders scan exactly as before 2026-10-07 22:42:40 -04:00
spoopyghosty0 029b0674d1 Web wrappers: a Trusted Web Activity (manifest meta-data android.support.customtabs.trusted.DEFAULT_URL, resolved through resources.arsc, or androidbrowserhelper's LauncherActivity) is analysis extra.web_wrapper with its URL; suggested status unsupported, the game page/CLI say to open the site in a browser (GitHub #86 Mahjong Table VR opened Meta's com.oculus.browser, which the Frame doesn't have). Triage web-wrapper 2026-10-07 22:42:39 -04:00
spoopyghosty0 c852473a43 Android version gate: analysis records the APK's minSdk (extra.min_sdk, read from the original: OVRPort lowers it to 29); above 32 the suggested status is unsupported and the game page/CLI say which Android it needs (GitHub #71/#72: minSdk 34 apps crash at start on android.window / VarHandle fences, Lepton runs Android 11). minSdk 32 stays allowed: 16 working catalog games declare it. Triage android-too-new 2026-10-07 22:42:38 -04:00
spoopyghosty0 acf1ef1da2 haptic_fix: keep revision 2 (owner's call, drop this commit to keep #81's bump)
The PCM conversion is preventive (Sniper Elite VR and Hyper Clinic send envelopes), and revision 3 would show
'Update on Frame' for every build with adapter.haptic_fix (suggested for every OVRPlugin game), including builds
where upstream.py already left the workaround out. Same reasoning as rect_clamp. New builds get the PCM conversion
anyway (the artifact is rebuilt).
2026-10-07 22:27:16 -04:00
spoopyghosty0 5fe120e897 Sniper Elite VR follow-up: merge the new haptic OOM PLAYBOOK row into the existing one (same OVRPort bug, now with the upstream-fix note); wrap unity_oculus_check's description to 120 columns (ruff); regenerate docs/GAMES.md 2026-10-07 22:25:43 -04:00
spoopyghosty0 41624c8dbd Rebuild libfp_ovrp.so and libframe_xrshim.so from the reviewed sources (native/build.py --only ovrpshim,xrshim, NDK r27c; the previous sources reproduce the committed artifacts byte for byte) 2026-10-07 22:23:19 -04:00
UnicronNL e8ec39db86 Sniper Elite VR: works (GitHub #81)
- native/ovrpshim: libunity.so's ovrp_BeginFrame/ovrp_EndFrame go through the shim; the frame wait waits <= 50 ms
  for the last waited frame to begin and skips otherwise (the game deadlocked at its Init scene when Unity skipped a
  begin); a begin/end of another index while a wait is outstanding gets the waited index. The physics-step
  ovrp_Update2 is held back (prediction 0 located the controllers in the past on the Frame: the hands lagged).
  frame.unity_oculus_check revision 5.
- native/xrshim: PCM vibrations (XrHapticPcmVibrationFB) become one plain vibration too; haptic_fix revision 3.
- Catalog: works with frame.unity_oculus_check + haptic_fix, min_app 0.12.1. Triage unity-frame-not-begun (info),
  PLAYBOOK rows and how to get all-thread backtraces with gdbserver64 inside the container, test.
2026-10-07 22:21:08 -04:00
spoopyghosty0 936d4b188b BONELAB: catalog note leads with the per-build status (GAMES.md showed only 'One recipe for both builds.'); regenerate docs/GAMES.md (also adds the missing Doom3Quest row) 2026-10-07 22:20:24 -04:00
UnicronNL e9ab1489cc BONELAB 1.2974: frame.slz_vulkan_hooks (GitHub #82)
Stress Level Zero's graphics plugin (libSLZQuestNative.so) hooks Unity's Vulkan start-up and vkCreateSampler; on
the Frame its vkCreateInstance wrapper calls an invalid pointer right after OVRPlugin's pre-init instance is
destroyed (pc 0, a jump into an unloaded library, or a hang). The new patch turns the plugin's two registrations into
no-ops (found through its exported functions, 4 bytes each), so Unity starts Vulkan itself; ~35 s of shader
prewarming without the plugin's pipeline cache, then 72 fps (headless). One BONELAB recipe serves 1.2068 and 1.2974.
Triage slz-vulkan-hook-crash, PLAYBOOK row, tests with a fixture built from tests/fixtures/src/fakeslz.S.
2026-10-07 22:15:57 -04:00
spoopyghosty0 1043eb1d1b Catalog: Richie's Plank Experience (works)
Closes #95
2026-10-07 22:13:33 -04:00
spoopyghosty0 0f444da595 Catalog: RUINSMAGUS (works)
Closes #89
2026-10-07 22:13:27 -04:00
spoopyghosty0 fda644d036 Catalog: GORN2 (works)
Closes #88
2026-10-07 22:13:19 -04:00
spoopyghosty0 fa0fbbd29a Catalog: Time Crisis VR (Experimental) (works)
Closes #80
2026-10-07 22:13:14 -04:00
spoopyghosty0 3446999109 Upstream fixes replace our workarounds per build (patches/upstream.py)
Each build probes OVRPort's output for registered upstream fixes and leaves out the workarounds they make
unnecessary (recipes unchanged: builds with an older runtime keep them). Logged ("not needed: ..."), recorded in
build.superseded, left out of settings.conf at install, shown on the game page and in `frameport show`;
FRAMEPORT_KEEP_WORKAROUNDS=1 turns it off. Registered: ovrport.haptic_envelope (-> adapter.haptic_fix) and
ovrport.microphone_stream (-> frame.ovr_microphone), both fixed in OVRPort runtime 3.4.3-aa54c3f (ovrport/app#73;
haptics verified in the headset with Lucky's Tale). Probes checked against both real runtimes; fixtures in
tests/fixtures/src/fakeruntime.S.
2026-10-07 21:02:12 -04:00
153 changed files with 11877 additions and 240 deletions

No files matched your search

+44 -2
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+7 -5
View File
@@ -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
+20
View File
@@ -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
+12 -9
View File
@@ -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
+14 -9
View File
@@ -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
+22
View File
@@ -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
+25
View File
@@ -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
+10 -5
View File
@@ -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
+22
View File
@@ -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
+27
View File
@@ -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
+21
View File
@@ -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
+18
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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:'
+5
View File
@@ -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`.
+2
View File
@@ -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).
+42
View File
@@ -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
View File
@@ -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. |
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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. |
+130
View File
@@ -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;
}
+1
View File
@@ -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) {
+78 -5
View File
@@ -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)
}
+2
View File
@@ -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;
+174
View File
@@ -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);
}
+91 -5
View File
@@ -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, &copy);
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, &copy) : XR_ERROR_PATH_UNSUPPORTED;
XrResult r = kept ? suggest_touch(instance, fn, to_string, &copy) : 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
View File
@@ -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]()
+704
View File
@@ -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;
}
+87
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+452
View File
@@ -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
View File
@@ -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
+158 -5
View File
@@ -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 = []
+32
View File
@@ -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
+54
View File
@@ -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."""
+14 -1
View File
@@ -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)")
+10 -1
View File
@@ -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
+152
View File
@@ -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))
+45
View File
@@ -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}
+91 -5
View File
@@ -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)
+62
View File
@@ -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
View File
@@ -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
View File
@@ -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)."""
+3 -2
View File
@@ -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
+13 -3
View File
@@ -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
+98
View File
@@ -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
+52 -8
View File
@@ -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)
+91 -1
View File
@@ -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",
))
+40 -7
View File
@@ -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",
))
+99
View File
@@ -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:
+120
View File
@@ -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)
+67 -5
View File
@@ -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",
+21 -1
View File
@@ -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.",
+152
View File
@@ -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
View File
@@ -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"
+58 -2
View File
@@ -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
+207 -15
View File
@@ -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
+3 -2
View File
@@ -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")
+28 -6
View File
@@ -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)
+1 -1
View File
@@ -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):
+20
View File
@@ -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
View File
@@ -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
+23 -2
View File
@@ -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}
+11
View File
@@ -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 "
+65 -3
View File
@@ -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)
+57 -2
View File
@@ -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)
+22 -3
View File
@@ -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
View File
@@ -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