Compare commits

..
42 Commits
Author SHA1 Message Date
github-actions[bot] 80092098e0 catalog: com.schellgames.ieytd3 (from #181) 2026-10-11 14:10:06 +00:00
spoopyghosty0 82e617741e FramePort 1.0.1 2026-10-11 09:59:28 -04:00
spoopyghosty0 9de0707070 Diagnostics: a zip saved while a Quest game runs includes threads.txt (each thread's wait, syscall and CPU use, TCP sockets) to find what a hanging game waits for (agent v79, GitHub #157) 2026-10-11 09:58:09 -04:00
spoopyghosty0 1343930ae0 Triage: Android's WebView helper exit is no force-quit; new app-not-started signature for Lepton's "Target app did not start" (GitHub #179) 2026-10-11 09:44:54 -04:00
spoopyghosty0 5424e429e4 Site: the patch reference in the docs; recipe chips (How it works, games board) and patch ids in docs code spans link to their entry 2026-10-11 00:58:52 -04:00
spoopyghosty0 9149eaecd7 Patch reference: scripts/patch_docs.py generates docs/PATCHES.md (every patch and game setting, anchors per id) and the site's id map; --check test, catalog PRs regenerate it 2026-10-11 00:58:51 -04:00
spoopyghosty0 64278e9e2c README and site: recipes for 120+ games (the catalog has 126) 2026-10-11 00:54:41 -04:00
spoopyghosty0 1531acb575 Site: the Built on section only on the homepage, What's new shows only the contributors
Footer's credits defaulted to on, so privacy and 404 showed it. credits is now 'all' (homepage), 'contributors'
(What's new) or off (default).
2026-10-11 00:51:58 -04:00
spoopyghosty0 6796e006d9 Site compare: the open-source card stacks on phones
The title's grid-column 2 / -1 from the wide layout added an implicit second column in the one-column layout,
squeezing FramePort's text to one word per line beside the other tool's.
2026-10-11 00:51:56 -04:00
spoopyghosty0 45f05dd3c6 Site demo: the Library header no longer squeezes its title on narrow screens
The search and buttons sat in an auto column that took the width and left "Libr" and one word per line for the
title. Narrow (icon rail): title, full-width search, buttons and Add games each wrap below. Tablet widths: the title
keeps its own width.
2026-10-11 00:51:54 -04:00
spoopyghosty0 5a2cd45fb8 Site: the hero scene stays sharp while it's in view on phones; no click ripples on touch
Stacked (<= 900 px) the scene sits below the hero text, so --sp measured from the page's scroll had it blurred and
enlarged by the time it reached the middle of the screen. There it now follows its own position (0 until its middle
passes 40 % of the screen height). Wide layouts keep the old mapping. The empty-space click rings and the install
badge's press ripple skip touch pointers.
2026-10-11 00:51:52 -04:00
spoopyghosty0 757671b389 Merge pull request #172 from spoopyghosty0/showcase/update
Update docs screenshots and videos
2026-10-10 23:52:39 -04:00
spoopyghosty0 8a2029c079 Docs: update screenshots and videos 2026-10-11 03:38:33 +00:00
spoopyghosty0 b2688dd368 Catalog: BlazeRush verified in the headset with FramePort's fixes (owner, 2026-10-11) 2026-10-10 23:33:55 -04:00
spoopyghosty0 13de114f39 test_showcase: the fake setup server check looks at every PairingServer method the GUI touches (it missed open_asks) 2026-10-10 23:26:11 -04:00
spoopyghosty0 9016982394 Showcase: the pretend setup server has open_asks/prune like the real one (the install tutorial failed to record); test that it offers everything the GUI uses 2026-10-10 23:25:14 -04:00
spoopyghosty0 cad2cd366c BlazeRush: min_app 1.0.0 (ships with 1.0) 2026-10-10 23:24:03 -04:00
spoopyghosty0 a5820e68d7 BlazeRush works (GitHub #57, from Klownicle's guide; headset check pending): frame.blazerush (1.0.349 only, fingerprint + words checked: avatar wait NOP, avatar renderer RET, shader cache cache/br_shader_v1), device.config_sync (agent v78: saved resolution in user_config.xml from the runtime eye size x scale at finalize, settings changes and before each start), catalog recipe with the GL shim fixes and scale 1.7, triage vrapi-fov-before-tracking / gl-integer-attribs 2026-10-10 23:18:42 -04:00
spoopyghosty0 aff939a6d8 BlazeRush native fixes (GitHub #57, Klownicle's guide): GL shim gl_int_attribs (integer vertex inputs fed as floats re-set with glVertexAttribIPointer per draw, per-context reflection dropped on link/binary/delete) and gl_highp_markers (matching vertex shaders compute in highp); VrApi bridge answers FOV properties 7/8 before any session with the live FOV saved by an earlier session (also located before tracking); FrameBridge logs the runtime's unscaled eye size 2026-10-10 23:18:41 -04:00
spoopyghosty0 5e725fbd08 Issue chooser: links to Discussions (questions, ideas) and the docs 2026-10-10 23:16:30 -04:00
spoopyghosty0 1cb9e6f65b docs/brand: GitHub social preview image (1280 x 640) 2026-10-10 23:11:13 -04:00
spoopyghosty0 7a843962e8 Catalog: Time Crisis II VR (test build) works (shared working config)
Closes #173
2026-10-10 23:09:47 -04:00
spoopyghosty0 51220a4942 docs/brand: square FramePort icon (1024 px PNG + SVG) for project pages and listings 2026-10-10 23:00:24 -04:00
spoopyghosty0 981daa3c2d Unreal: start without the OBB check (frame.unreal_skip_obb_check, GitHub #159)
Epic's DownloaderActivity checks the OBB's name and size and, with the manifest's
bVerifyOBBOnStartUp=true, CRC-checks the whole file; its screens are invisible in Lepton,
so games like Contractors seem to hang at 'Displayed .../.DownloaderActivity'. The patch
flips that manifest boolean (axml.set_meta_data_bool, one 4-byte value); analysis
unreal_verify_obb (ANALYSIS_VERSION 11) suggests it. Triage unreal-obb-check-stuck (fatal)
fails such launch tests; triage keeps system_server's lines about the game's own activities.
2026-10-10 22:49:21 -04:00
spoopyghosty0 9781c2bb6b Showcase: sharper README teaser (1280 px, 20 fps, WebP drawing preset from quality 95, up to 8 MB) and tour/install videos (CRF from 17, up to 20 MB); the teaser showed compression artifacts 2026-10-10 22:32:47 -04:00
spoopyghosty0 a7c349835c Install looks for a game's data files again when it was added without them (OBBs copied next to the APK afterwards; GitHub #156) 2026-10-10 22:29:52 -04:00
spoopyghosty0 9a48eac3e6 Catalog: TRIANGLE STRATEGY works (shared working config)
Closes #171
Closes #85
2026-10-10 22:18:23 -04:00
spoopyghosty0 9082c684ed test_vr_overlay: expect systemd quotes only where the path needs them (CI's checkout path has no spaces; the v1.0.0 build failed on it) 2026-10-10 22:17:22 -04:00
spoopyghosty0 84235fbc9f FramePort 1.0.0 2026-10-10 22:04:58 -04:00
spoopyghosty0 8accd6221a Install links: the homepage's example button is a demo link (frameport.app/demo/cool-game.json, published on the site). FramePort recognises every form of it without any network request and answers with a demo install question for "Cool Game"; Install tosses its cover out of the logo's portal (easter eggs off: "Nice, it works!"), nothing is downloaded or added. The landing page marks it as a demo link; open-link says it's the demo and stops 2026-10-10 21:47:57 -04:00
spoopyghosty0 c37eebb59f Site: link the rift-on-frame branch from How it works 2026-10-10 21:47:04 -04:00
spoopyghosty0 5b0bd2589b PC Steam shortcuts: Revive games (all started by ReviveInjector.exe) are told apart by the game in the launch options; a second Revive game overwrote the first one's shortcut 2026-10-10 21:44:47 -04:00
spoopyghosty0 231e5e50cd Merge branch 'worktree-agent-ad6131f295772bee1' 2026-10-10 21:39:29 -04:00
spoopyghosty0 423b2e1c3e Rename… for every library entry: the name is kept from then on and reaches Steam without a new shortcut (agent v77 rename)
Game menu (Library right-click, game page "Name and artwork") and CLI 'frameport rename <pkg> "<name>"|--reset'.
The title is locked (title_locked + auto_title for the reset); rescans, Rift store matches and install links only
update auto_title; a chosen name gets no Quest/Rift twin suffix. Installed copies: the Frame's deployment.json title
changes and the shortcut is rewritten with the same appid (matched by Exe), one Steam restart; PC VR installs on this
PC keep their appid too (reinstalls and library updates now record the shortcut's real appid). A Frame that isn't
connected shows "Update name on Frame" on the game page until then or the next install.
2026-10-10 21:38:56 -04:00
spoopyghosty0 b6c5f7c18e Scanning: SideQuest backup folders and AXRB downloads import with their OBBs. AXRB's own PC-patched copies (patched/, *-axrb.apk, no OBBs) are skipped and a rescan replaces games added from them (they started and stopped at once: Steam showed Resume); AXRB's other store files go with the OBBs; an APK inside another game's data folder isn't a game; backups are named after the package, not the backup time. FAQ explains both layouts 2026-10-10 21:29:22 -04:00
spoopyghosty0 74320e607f Site: How it works explains PC VR games on the Frame and on the PC (automatic Revive for Rift games), plus two experimental paths (Quest games in PC VR, Rift games on the Frame alone), closed by default. Install video: download from frameport.app 2026-10-10 21:13:29 -04:00
spoopyghosty0 d87e3b3b5f Merge branch 'worktree-agent-ad5dca676e297ca5d' 2026-10-10 20:59:46 -04:00
spoopyghosty0 c4153be8bc Docs: FramePort's overlay autostart service (agent v76), dev Frame result 2026-10-10 20:58:57 -04:00
spoopyghosty0 b5a16a2680 Overlay apps start with SteamVR through FramePort's own service (agent v76)
SteamVR's auto-launch flag (SetApplicationAutoLaunch) didn't persist on the dev
Frame and a registered watch didn't start by itself. The deployment's overlay
autostart flag is now the source of truth: ensure_host_fixes installs the user
service frameport-vr-overlays.service while an installed overlay app has
autostart on (removed when none has, on uninstall/purge, or with the kill switch
~/.local/share/frameport/vr-overlays.disabled / FRAMEPORT_NO_OVERLAY_AUTOSTART).
It polls every 5 s for a new vrserver (pid + start time, remembered across agent
updates), waits until SteamVR answers, gives SteamVR's own auto-launch 15 s and
starts each autostart overlay app that isn't running (process of its folder or
GetApplicationProcessId) through LaunchApplication, in a child process with a
timeout. Log: ~/.local/share/frameport/vr-overlays.log (capped).
2026-10-10 20:53:32 -04:00
spoopyghosty0 f6870da310 Merge branch 'worktree-agent-a541d9c7207d19375' 2026-10-10 20:52:50 -04:00
spoopyghosty0 6c8cda5d7a Godot apps: start without a clipboard service (frame.godot_clipboard, GitHub #134)
Godot 4.2-4.4's Kotlin code casts getSystemService(CLIPBOARD_SERVICE) with a
non-null 'as ClipboardManager'; Lepton has no clipboard service, so the app
crashes with a NullPointerException in Godot.<init> (4.3/4.4) or on first use
(4.2's lazy property). In place in classes*.dex, matched exactly: the
Intrinsics.checkNotNull before that check-cast becomes nops (older kotlinc
branch forms: the branch is fixed), and Godot's getClipboard/hasClipboard/
setClipboard return at once (Dex.return_early gains String -> "").
Analysis field godot_clipboard (ANALYSIS_VERSION 10), triage
godot-no-clipboard, PLAYBOOK row, unit tests on synthetic dex. Checked against
godot-lib 4.1.4-4.7.2 from Maven Central and the 4.3/4.4.1 export templates:
4.2.2, 4.3.0, 4.4.1 patched (D8 re-reads the result), the others untouched.
2026-10-10 20:52:23 -04:00
spoopyghosty0 7331d9320a Catalog: VR4 zeroes a second permutation of the campaign shader that reads its loop counter before setting it (6604-byte module, found in the reporter's shader capture; GitHub #140) 2026-10-10 20:40:44 -04:00
117 changed files with 8275 additions and 146 deletions

No files matched your search

+7
View File
@@ -1 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Questions and ideas
url: https://github.com/spoopyghosty0/frameport/discussions
about: Setup help, how-to questions and feature ideas go to Discussions.
- name: Docs and FAQ
url: https://frameport.app/docs/
about: Install steps, game folder layouts and common questions.
+2 -1
View File
@@ -40,7 +40,8 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$branch"
git add "catalog/games/$PKG.yaml"
uv run python scripts/patch_docs.py # docs/PATCHES.md lists the catalog games per patch
git add "catalog/games/$PKG.yaml" docs/PATCHES.md site/src/data/patches.json
git commit -m "catalog: $PKG (from #$ISSUE)"
git push --force origin "$branch"
if ! gh pr view "$branch" >/dev/null 2>&1; then
+34 -2
View File
@@ -211,7 +211,14 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
`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.
- Scanning: SideQuest backups (`<package>/<time>/apk|obb|data`) and AXRB downloads (`AXRB/<app id>/<binary id>/base.apk`
+ OBBs/assets; its `patched/`/`*-axrb.apk` PC builds are skipped) are recognised (`sources/quest_dump.py`, FAQ).
- Quest/Rift twins stay separate entries, shown and named in Steam "Title (Quest)"/"(Rift)" (`core/titles.py`).
- Rename… (game menu "Name and artwork", Library right-click; CLI `frameport rename <pkg> "<name>"|--reset`):
`pipeline.rename_game` sets `title` + `title_locked` (+ `auto_title` = the automatic name for reset; rescans, Rift
store matches and links then only update `auto_title`; a locked name gets no twin suffix); `sync_title` → Frame
agent v77 `rename` (deployment.json title, same shortcut appid: upsert matches by Exe) + art + one Steam restart, PC
`PcReviveTarget.rename`; an unreachable Frame → `steam_name_stale` (game page "Update name on Frame", next install).
- `Recipe.as_is` = install unchanged (pre-patched libraries): `pipeline.prepare_as_is`; auto for APKs that already
contain FrameBridge (`frame_patched`). For Rift it changes nothing (the dump is never modified; the Frame copy
still gets launch fixes like the crash-reporter rename — `-nocrashreports` alone doesn't stop UE 4.23) — it does
@@ -622,7 +629,10 @@ shortcuts.vdf and writes once more if Steam put its old copy back (`shortcuts_lo
auto-repair (`_add_then_play`) crashed (its job got a Job, not a reporter). Uploads resume on a transient OSError.
Lepton's Android 11 has no clipboard service (134 services; checked with `podman exec … service check clipboard`):
SDL/LÖVE apps crashed at start → `frame.sdl_clipboard` (`apk/dex.py`: in-place dex edit, nops one invoke, fixes
the header checksum/signature; verified with LÖVE for Android 11.5, GitHub #24 Dramatic Shape). 2D apps (vr_kind none)
the header checksum/signature; verified with LÖVE for Android 11.5, GitHub #24 Dramatic Shape). Godot 4.2-4.4 (Kotlin `as ClipboardManager`,
GitHub #134 EndoparasiticVR) → `frame.godot_clipboard` (nops the `Intrinsics.checkNotNull` before that check-cast; Godot's
clipboard methods return at once; analysis `godot_clipboard`, ANALYSIS_VERSION 10; checked on real godot-lib/templates
4.2.2-4.4.1, not on the device; 4.1 and 4.5+ don't throw). 2D apps (vr_kind none)
keep every suggested patch that isn't about VR (`needs_vr = False`), not a fixed list. FramePort on the Frame:
`frame/local.py` (127.0.0.1, own key authorized; "This Frame (experimental)"; app data in
~/.local/share/frameport-app because ~/.local/share/frameport is the agent's) and Steam library changes wait while
@@ -712,7 +722,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).
**SteamVR overlay apps (agent v75, 2026-10-10):** host OpenVR overlays (VRApplication_Overlay) are composited over Lepton (Quest) games on the Frame (probe checker seen over 4XVR in the headset view, `native/vroverlay_probe`); Windows ones register through Proton's vrclient too (Temporal Reality's Windows build created its overlays; fpsVR never reaches OpenVR under Proton). Detection `analysis/vroverlay.py` (bundled `.vrmanifest` with `is_dashboard_overlay`, else OpenVR + IVROverlay + self-registration markers, engine Other; Rift fingerprint `RIFT_ANALYSIS`), patch `pcvr.vr_overlay` (param autostart), Linux entry fields `vr_overlay`/`vr_overlay_autostart` (game page switch); agent `register_vr_overlay`/`unregister_vr_overlay`/`launch_vr_overlay` (IVRApplications via ctypes, live; manifest `<anchor>/frameport-overlay.vrmanifest`, the app's own key, binary = launch.sh). **SteamVR on the Frame reads only `binary_path_linux_arm`** (binary_path_linux alone is skipped). Overlay apps: no launch test, no Linux Steam-parent watchdog (`FRAMEPORT_OVERLAY`), not a "running game". A Steam overlay-app shortcut and a game shortcut run together. Autostart at the next SteamVR start: not verified yet. Details: docs/FRAME_RUNTIME.md "Overlay apps".
**SteamVR overlay apps (agent v75, 2026-10-10):** host OpenVR overlays (VRApplication_Overlay) are composited over Lepton (Quest) games on the Frame (probe checker seen over 4XVR in the headset view, `native/vroverlay_probe`); Windows ones register through Proton's vrclient too (Temporal Reality's Windows build created its overlays; fpsVR never reaches OpenVR under Proton). Detection `analysis/vroverlay.py` (bundled `.vrmanifest` with `is_dashboard_overlay`, else OpenVR + IVROverlay + self-registration markers, engine Other; Rift fingerprint `RIFT_ANALYSIS`), patch `pcvr.vr_overlay` (param autostart), Linux entry fields `vr_overlay`/`vr_overlay_autostart` (game page switch); agent `register_vr_overlay`/`unregister_vr_overlay`/`launch_vr_overlay` (IVRApplications via ctypes, live; manifest `<anchor>/frameport-overlay.vrmanifest`, the app's own key, binary = launch.sh). **SteamVR on the Frame reads only `binary_path_linux_arm`** (binary_path_linux alone is skipped). Overlay apps: no launch test, no Linux Steam-parent watchdog (`FRAMEPORT_OVERLAY`), not a "running game". A Steam overlay-app shortcut and a game shortcut run together. SteamVR's SetApplicationAutoLaunch didn't persist on the dev Frame (the watch never started by itself) → **agent v76: FramePort's own autostart**: the deployment's `overlay.autostart` is the source of truth; `ensure_host_fixes` (`ensure_overlay_service`) keeps the user service `frameport-vr-overlays.service` (`_vr_overlay_watch`, 5 s polls of the known vrserver's /proc stat) only while an overlay app has autostart on (register/unregister, uninstall, purge and the kill switch `~/.local/share/frameport/vr-overlays.disabled` / `FRAMEPORT_NO_OVERLAY_AUTOSTART` update it); a new vrserver (pid + start time, kept in `~/.cache/frameport-vr-overlays.json` across agent updates) → child `_vr_overlay_round`: wait for IVRApplications, 15 s grace, `launch_vr_overlay` for apps not running (folder process or GetApplicationProcessId); log `~/.local/share/frameport/vr-overlays.log`. Dev Frame: adopted the running vrserver, skipped the already running watch; a start after a real SteamVR start/boot is not yet seen. Details: docs/FRAME_RUNTIME.md "Overlay apps".
**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
@@ -779,6 +789,7 @@ with an index naming modules, `shader_dumps` sends every module of the index's *
index lines; no index (fp_spirv) = the old newest-by-mtime 4 MB. One JSON line (~40 MB base64) over the SSH channel
is read whole by `Frame.agent` (fine at that size); `_Writer.fit` drops the least recently used modules last, after
cutting logs, to stay under the 24 MB zip limit.
**Unreal OBB check (GitHub #159, 2026-10-10):** Epic's DownloaderActivity checks OBB name+size (OBBData), then with `bVerifyOBBOnStartUp=true` CRCs the whole OBB; its screens are invisible in Lepton (stuck at `Displayed …/.DownloaderActivity`, triage `unreal-obb-check-stuck`) → `frame.unreal_skip_obb_check` (analysis `unreal_verify_obb`, ANALYSIS_VERSION 11) flips the manifest boolean (`axml.set_meta_data_bool`); skips only the CRC pass. Host-tested only.
**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
@@ -795,6 +806,21 @@ table comes from dlsym on that handle; also first DT_NEEDED for its direct gl*/e
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. Headset-verified by Xandrix1987 (2026-10-09: HUD/PDA shown, no fps drop) -> in the Doom3Quest catalog recipe.
**BlazeRush (GitHub #57, Klownicle's verified guide, 2026-10-10; FramePort's version headless-tested only):**
`frame.blazerush` (1.0.349 only: exec-segment SHA + every word checked, offsets from PT_LOAD) NOPs the avatar-readiness
wait (0x4b2dbc), RETs the avatar renderer (0x4b2adc), renames `cache/shader_cache` → `cache/br_shader_v1`. GL shim
(now also exports glShaderSource/glLinkProgram/glProgramBinary/glDeleteProgram/glDraw*: pass-through unless a setting
is on) `gl_int_attribs` (integer inputs fed via glVertexAttribPointer re-set with glVertexAttribIPointer per draw,
reflection cached per EGL context + program, dropped on link/binary/delete) and `gl_highp_markers` (vertex shaders with
every '|' marker: mediump→highp except on in/out/uniform lines); host test `tests/test_blazerush.py` with a stand-in
GL. FOV: the game reads VrApi props 7/8 right after vrapi_Initialize (no session; the Frame has no
XR_EPIC_view_configuration_fov) → the bridge saves the live FOV (also located before tracking, headless: 118.87×120.04
on the dev Frame) in `files/framebridge-vrapi-fov.txt` and answers with it at the next start (log `FOV properties: …
(saved by an earlier session)`). `device.config_sync` (catalog field `config_sync`, agent v78): user_config.xml
r_3dwidth/height, r_width/height from the runtime eye size (FrameBridge logs `runtime recommended eye size`) × scale,
written at finalize, set_settings and by launch.sh before Lepton starts (`_config_sync`). Headless runs at scale 1.7
end in `zink: DEVICE LOST` right after the bridge's 30 s no-tracking deadline tears the session down (gpu fault at the
teardown; not at scale 1.0): a headless-only artifact, the headset never hits that deadline.
**Session triage (agent v70, 2026-10-09):** launch tests never reach FOCUSED, so real play sessions are triaged
too. list_installed gives each game `last_play` {start, end, test} from `<anchor>/plays.log` (launch tests write a
`test <unix>` line first → test sessions are only marked); `session_log` returns the newest session's launch.log
@@ -847,6 +873,10 @@ up to 4 s for the link file to be taken before trusting the heartbeat, the windo
`gui.alive`, and the Windows command runs under `conhost.exe --headless` (plain `-WindowStyle Hidden` flashed a
console). Screens: `scripts/ui_smoke.py --links --fake-frame --game <pkg>` (tall pages: `--viewport
1280x7000`; Flutter's popup menu ignores Escape).
Demo link: the homepage's example button = `deeplink.DEMO_MANIFEST` (https://frameport.app/demo/cool-game.json, published
from site/public/demo/); every form (https button page, frameport://, framedrop://, pasted) → `InstallRequest.demo`,
no network (fetch_manifest returns `demo_manifest()`, download refuses), `link_dialog.show_demo` (Install =
`easter.demo_install`: Cool Game's cover out of the portal; eggs off → "Nice, it works!"), CLI just says so.
## Releases, CI, GitHub
Maintainer-only notes (accounts, credentials, key locations) live in the git-ignored `CLAUDE.local.md`.
@@ -937,5 +967,7 @@ the unresolved eye distortion, and testing the release bundles on real Windows/m
fallback (`core/cache.py`). Don't hardcode what can be discovered (e.g. Lepton path via appmanifests).
- Keep APK edits minimal and byte-stable (parity depends on it). Every new fix: a patch module + a triage signature +
a PLAYBOOK row + a unit test.
- New or changed patch, game setting, catalog recipe or triage signature → run `python scripts/patch_docs.py`
(regenerates docs/PATCHES.md + site/src/data/patches.json, the site's id → anchor map; a test runs `--check`).
- Known-good backups of every working APK and the signing keys: `PATCHED/_known-good-2026-09-28/` (don't delete).
- Your development Frame: `frameport frame info` (remembered in `<user data>/frames.json`); SSH as `steamos@<frame>`.
+3 -3
View File
@@ -28,7 +28,7 @@ Frame, patches each game so it runs there, uploads it and adds it to your Steam
- **Easy setup:** one line in the Frame's terminal. No root, no password. [What it changes](docs/FRAME_SETUP.md).
- **One click per game:** patch, upload, add to Steam with artwork and test that it starts.
- **Recipes:** tested patches and settings for 90+ games; other games get suggested patches, each explained in
- **Recipes:** tested patches and settings for 120+ games; other games get suggested patches, each explained in
plain words.
- **Game settings:** sharpness, refresh rate, controllers, 360° video and mixed reality as simple switches.
- **More than Quest games:** Android apps in a window, Linux apps, PC VR games and Windows programs.
@@ -76,7 +76,7 @@ suggests patches. See the [list of tested games](docs/GAMES.md). Got a game work
FrameDrop and Valve's own tools install an app as it is. FramePort differs in four ways:
- **Free and open source** (GPL-3.0). FrameDrop is free (donationware) without published source.
- **Quest games that don't run on the Frame as they are** get converted and patched, with a tested recipe for 90+
- **Quest games that don't run on the Frame as they are** get converted and patched, with a tested recipe for 120+
games. The others install the game unchanged.
- **Windows, macOS and Linux.** FrameDrop is for Windows.
- **Wi-Fi or a USB cable**, and no **Pair new host** step.
@@ -91,7 +91,7 @@ FrameDrop and Valve's own tools install an app as it is. FramePort differs in fo
| First connection | One line in the Frame's terminal; it turns on Developer Mode itself. No password | Turn on Developer Mode, then **Pair new host** | Turn on Developer Mode and pair, or use Android's debug tool (adb) |
| Wireless or cable | Wi-Fi, the Frame's hotspot or a USB cable | Same Wi-Fi network | Wi-Fi, or adb |
| Quest games that don't run as they are | Converted and patched for the Frame | Installed as they are | Installed as they are |
| Knows which patches a game needs | Tested recipes for 90+ games, updated without an app update | Not stated | No |
| Knows which patches a game needs | Tested recipes for 120+ games, updated without an app update | Not stated | No |
| In the Steam library | Yes, with artwork and tags | As a "Devkit Game" shortcut | As "Devkit Game: &lt;title&gt;"; not with adb |
| Android apps, Linux apps, Windows programs | All three; Proton (runs Windows programs) is installed for you | All three; install Proton first | Yes; 2D Android apps need an extra file |
| PC VR games | On your PC; some also on the Frame | Not supported | Not supported |
+628 -8
View File
@@ -44,7 +44,7 @@ try:
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
fcntl = None
AGENT_VERSION = 75
AGENT_VERSION = 79
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -737,6 +737,12 @@ def ensure_host_fixes():
overlays = []
if overlays:
changed.append(f"SteamVR overlay apps registered ({len(overlays)})")
try:
service = ensure_overlay_service()
except Exception: # noqa: BLE001
service = None
if service:
changed.append(f"overlay autostart service {service}")
return changed
@@ -944,7 +950,15 @@ def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Fram
data = open(vdf_path, "rb").read() if os.path.exists(vdf_path) else b""
root = vdf_decode(data) if data else {"shortcuts": {}}
shortcuts = root.setdefault("shortcuts", {})
entry = next((v for v in shortcuts.values() if isinstance(v, dict) and v.get("Exe") == exe), None)
# matched by Exe; a launcher many games share (Revive's injector on a PC) also by the game in its launch options,
# or two Revive games overwrote each other's shortcut
shared = "reviveinjector" in exe.lower()
def game_of(options): # the game's program in the injector's arguments (its flags may change)
found = re.findall(r'"([^"]+\.exe)"|(\S+\.exe)', options or "", re.IGNORECASE)
return (found[-1][0] or found[-1][1]).lower() if found else (options or "")
entry = next((v for v in shortcuts.values() if isinstance(v, dict) and v.get("Exe") == exe
and (not shared or game_of(v.get("LaunchOptions", "")) == game_of(launch_options))), None)
ident = entry["appid"] if entry else shortcut_appid(exe, title)
if entry is None:
entry = {"appid": ident, "LastPlayTime": 0, "tags": {"0": tag}}
@@ -2513,6 +2527,7 @@ def cmd_register_vr_overlay(args):
out = register_overlay(pkg, bool(args.get("autostart")))
dep["overlay"]["registered"] = out["registered"]
write_deployment(pkg, dep)
out["service"] = overlay_service_quiet()
return out
@@ -2523,6 +2538,7 @@ def cmd_unregister_vr_overlay(args):
if dep and "overlay" in dep:
dep.pop("overlay")
write_deployment(pkg, dep)
overlay_service_quiet()
return {"unregistered": True, "steamvr": reached}
@@ -2554,6 +2570,15 @@ def cmd_launch_vr_overlay(args):
return {"started": not err, "pid": pid or None, "error": err}
def overlay_service_quiet():
"""ensure_overlay_service() after an overlay app's autostart flag changed; a systemd problem never fails the
command (ensure_host_fixes tries again at the next connection)."""
try:
return ensure_overlay_service()
except Exception as exc: # noqa: BLE001
return f"error: {exc}"
def overlay_registrations():
"""Register overlay apps whose registration is missing (SteamVR wasn't running at install, or forgot it)."""
done = []
@@ -2581,6 +2606,259 @@ def overlay_registrations():
return done
# ------------------------------------------------------------------------------------------ overlay autostart (v76)
# SteamVR's own auto-launch flag (SetApplicationAutoLaunch) was accepted on the dev Frame but never written to
# steamvr.vrsettings, and a registered watch didn't start by itself (2026-10-10). FramePort's deployment flag
# (overlay.autostart) is the source of truth: a small user service (only while at least one installed overlay app has
# autostart on) notices every new vrserver process (SteamVR start or restart, boot), waits until SteamVR answers,
# gives SteamVR's own auto-launch a moment, then starts each autostart overlay app that isn't running yet, the same
# way launch_vr_overlay does (LaunchApplication: SteamVR spawns it, in its own environment). Off for every app: the
# flag file VR_OVERLAY_DISABLED or FRAMEPORT_NO_OVERLAY_AUTOSTART=1 (the service is then removed).
VR_OVERLAY_UNIT = "frameport-vr-overlays"
SYSTEMD_USER_DIR = os.path.join(HOME, ".config/systemd/user")
VR_OVERLAY_UNIT_PATH = os.path.join(SYSTEMD_USER_DIR, VR_OVERLAY_UNIT + ".service")
VR_OVERLAY_DISABLED = os.path.join(HOME, ".local/share/frameport/vr-overlays.disabled")
VR_OVERLAY_LOG = os.path.join(HOME, ".local/share/frameport/vr-overlays.log")
VR_OVERLAY_STATE = os.path.join(HOME, ".cache/frameport-vr-overlays.json")
VR_OVERLAY_LOG_MAX = 128 * 1024
VR_OVERLAY_POLL = 5 # s between /proc scans (nothing else happens while SteamVR keeps running)
VR_OVERLAY_READY_WAIT = 180 # s for a new SteamVR to answer IVRApplications
VR_OVERLAY_GRACE = 15 # s after SteamVR answers: its own auto-launch may start the app first
def overlay_autostart_disabled():
return os.environ.get("FRAMEPORT_NO_OVERLAY_AUTOSTART", "0") not in ("", "0") or \
os.path.exists(VR_OVERLAY_DISABLED)
def autostart_overlays():
"""Installed overlay apps whose deployment has autostart on: [(package, deployment)]."""
found = []
for dep_path in sorted(glob.glob(os.path.join(ANCHORS, "*/deployment.json"))):
try:
dep = json.load(open(dep_path))
except (OSError, ValueError):
continue
if isinstance(dep, dict) and dep.get("package") and (dep.get("overlay") or {}).get("autostart"):
dep.setdefault("base", os.path.dirname(dep_path))
found.append((dep["package"], dep))
return found
def systemd_quote(arg):
"""One ExecStart= word (systemd's own quoting: double quotes, % and $ doubled)."""
arg = arg.replace("%", "%%").replace("$", "$$")
if re.fullmatch(r"[A-Za-z0-9_./+:=@-]+", arg):
return arg
return '"' + arg.replace("\\", "\\\\").replace('"', '\\"') + '"'
def vr_overlay_unit():
"""The user service's unit text (the agent's path and Python are written in: the agent lives in AGENT_HOME)."""
agent = os.path.abspath(__file__)
return ("[Unit]\n"
"Description=FramePort: start SteamVR overlay apps with SteamVR\n"
"\n[Service]\n"
"Type=simple\n"
f"ExecStart={systemd_quote(sys.executable or '/usr/bin/python3')} {systemd_quote(agent)} "
"_vr_overlay_watch\n"
"Restart=always\n"
"RestartSec=10\n"
"Nice=10\n"
"\n[Install]\n"
"WantedBy=default.target\n")
def user_systemctl(*args):
return run(["systemctl", "--user", *args])
def ensure_overlay_service():
"""Install (or update) the overlay autostart service when an installed overlay app has autostart on, remove it
when none has (or the kill switch is set). Returns "installed", "updated", "removed" or None (no change)."""
want = bool(autostart_overlays()) and not overlay_autostart_disabled()
text = vr_overlay_unit()
try:
current = open(VR_OVERLAY_UNIT_PATH).read()
except OSError:
current = None
if not want:
if current is None:
return None
user_systemctl("disable", "--now", VR_OVERLAY_UNIT + ".service")
try:
os.remove(VR_OVERLAY_UNIT_PATH)
except OSError:
pass
user_systemctl("daemon-reload")
user_systemctl("reset-failed", VR_OVERLAY_UNIT + ".service")
return "removed"
if current == text:
if user_systemctl("is-active", "--quiet", VR_OVERLAY_UNIT + ".service").returncode == 0:
return None
user_systemctl("enable", "--now", VR_OVERLAY_UNIT + ".service")
return "started"
os.makedirs(SYSTEMD_USER_DIR, exist_ok=True)
tmp = VR_OVERLAY_UNIT_PATH + ".tmp"
with open(tmp, "w") as f:
f.write(text)
os.replace(tmp, VR_OVERLAY_UNIT_PATH)
user_systemctl("daemon-reload")
user_systemctl("enable", VR_OVERLAY_UNIT + ".service")
user_systemctl("restart", VR_OVERLAY_UNIT + ".service")
return "installed" if current is None else "updated"
def overlay_log(msg):
try:
os.makedirs(os.path.dirname(VR_OVERLAY_LOG), exist_ok=True)
if os.path.exists(VR_OVERLAY_LOG) and os.path.getsize(VR_OVERLAY_LOG) > VR_OVERLAY_LOG_MAX:
os.replace(VR_OVERLAY_LOG, VR_OVERLAY_LOG + ".1")
with open(VR_OVERLAY_LOG, "a") as f:
f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + msg + "\n")
except OSError:
pass
def vrserver_ident(pid):
""""<pid>:<start ticks>" when process `pid` is a vrserver (the start time tells a reused pid apart), else None."""
try:
with open(f"/proc/{pid}/stat") as f:
stat = f.read()
except OSError:
return None
if stat[stat.find("(") + 1:stat.rfind(")")] != "vrserver":
return None
return f"{pid}:{stat[stat.rfind(')') + 2:].split()[19]}"
def vrserver_process(known=None):
"""The running vrserver's vrserver_ident, None if none. `known` (the last one seen) is checked first: while
SteamVR keeps running a poll reads one file instead of scanning /proc."""
if known and vrserver_ident(known.split(":")[0]) == known:
return known
for d in os.listdir("/proc"):
if d.isdigit():
found = vrserver_ident(d)
if found:
return found
return None
def overlay_running(pkg, dep, apps=None):
"""The overlay app runs already: a process of its install folder or launcher (also when Steam or SteamVR started
it), or SteamVR knows its process (GetApplicationProcessId)."""
if pcvr_pids(dep.get("base") or os.path.join(ANCHORS, pkg)) or pcvr_pids(os.path.join(ANCHORS, pkg)):
return True
if apps is not None:
try:
return bool(apps.pid(dep_overlay_key(pkg, dep)))
except Exception: # noqa: BLE001
return False
return False
def overlay_autostart_round():
"""One new SteamVR: wait until it answers, give its own auto-launch a moment, then start each autostart overlay
app that isn't running. Returns {package: "started" | "running" | error text}; {} without SteamVR."""
deadline = time.time() + VR_OVERLAY_READY_WAIT
apps = SteamVRApps.open()
while apps is None and time.time() < deadline:
time.sleep(3)
apps = SteamVRApps.open()
if apps is None:
overlay_log("SteamVR didn't answer: nothing started")
return {}
try:
time.sleep(VR_OVERLAY_GRACE)
todo = [(pkg, dep) for pkg, dep in autostart_overlays() if not overlay_running(pkg, dep, apps)]
result = {pkg: "running" for pkg, _ in autostart_overlays() if pkg not in dict(todo)}
finally:
apps.close()
for pkg, _ in todo:
try:
r = cmd_launch_vr_overlay({"package": pkg})
result[pkg] = "started" if r.get("started") else (r.get("error") or "not started")
except Exception as exc: # noqa: BLE001
result[pkg] = str(exc) or type(exc).__name__
return result
def overlay_watch_tick(state):
"""One poll of the watcher: a vrserver not handled before (state["vrserver"]) gets one autostart round. Returns
the round's result, or None when there was nothing to do."""
if overlay_autostart_disabled():
return None
proc = vrserver_process(state.get("vrserver"))
if not proc or proc == state.get("vrserver"):
return None
state["vrserver"] = proc
overlay_save_state(state) # a restarted watcher (agent update) doesn't start apps the user closed again
if not autostart_overlays():
return None
overlay_log(f"new SteamVR (vrserver {proc.split(':')[0]})")
result = run_overlay_round()
for pkg, what in result.items():
overlay_log(f"{pkg}: {what}" if what in ("started", "running") else f"{pkg}: couldn't start it: {what}")
state["last"] = {"time": time.time(), "result": result}
overlay_save_state(state)
return result
def run_overlay_round():
"""overlay_autostart_round() in a child process (_vr_overlay_round): OpenVR is loaded only there, and a SteamVR
that never answers can't hang the watcher (timeout)."""
try:
p = subprocess.run([sys.executable, os.path.abspath(__file__), "_vr_overlay_round"], capture_output=True,
text=True, errors="replace", timeout=VR_OVERLAY_READY_WAIT + VR_OVERLAY_GRACE + 120)
except subprocess.TimeoutExpired:
return {"*": "timed out"}
try:
out = json.loads(p.stdout.strip().splitlines()[-1])
return out if isinstance(out, dict) else {}
except (ValueError, IndexError):
return {"*": (p.stderr.strip().splitlines() or [f"exit {p.returncode}"])[-1]}
def overlay_save_state(state):
try:
os.makedirs(os.path.dirname(VR_OVERLAY_STATE), exist_ok=True)
with open(VR_OVERLAY_STATE + ".tmp", "w") as f:
json.dump(state, f)
os.replace(VR_OVERLAY_STATE + ".tmp", VR_OVERLAY_STATE)
except OSError:
pass
def vr_overlay_watch():
"""The service's loop (_vr_overlay_watch): a /proc scan every VR_OVERLAY_POLL s. Exits when its own file changes
(an agent update: systemd starts the new one)."""
me = os.path.abspath(__file__)
try:
mine = os.path.getmtime(me)
except OSError:
mine = None
try:
state = json.load(open(VR_OVERLAY_STATE))
state = state if isinstance(state, dict) else {}
except (OSError, ValueError):
state = {}
overlay_log(f"watching (agent v{AGENT_VERSION}, overlay apps with autostart: "
f"{', '.join(p for p, _ in autostart_overlays()) or 'none'})")
while True:
try:
if os.path.getmtime(me) != mine:
overlay_log("agent updated: restarting")
return
except OSError:
return
try:
overlay_watch_tick(state)
except Exception as exc: # noqa: BLE001 (keep watching)
overlay_log(f"error: {exc}")
time.sleep(VR_OVERLAY_POLL)
def cmd_prepare(args):
"""Where to upload, and what the Frame already has (so unchanged data is not re-sent)."""
pkg = check_pkg(args["package"])
@@ -2724,6 +3002,8 @@ def upgrade_launchers():
if "export LEPTON_ENV_FRAMEBRIDGE_CONFIG=" in new:
new = set_codec_line(new, wants_hw_video(os.path.dirname(path)))
new = set_gamepad_line(new, wants_gamepad(os.path.dirname(path)))
new = set_config_sync_line(new, os.path.basename(os.path.dirname(path)),
wants_config_sync(os.path.dirname(path)))
if "a Linux app. Generated by FramePort" in new and "FRAMEPORT_DESKTOP" not in new:
new = upgrade_linux_launcher(new)
if OLD_WATCHDOG in new and "parent=$PPID" not in new:
@@ -3078,7 +3358,7 @@ def upgrade_linux_launcher(text):
return text
def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False, gamepad=False):
def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False, gamepad=False, config_sync=False):
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (env or {}).items()
if re.fullmatch(r"[A-Z_][A-Z0-9_]*", k))
text = LAUNCH_SH.format(title=title.replace("\n", " "), pkg=pkg, base_q=shlex.quote(base), appid=appid,
@@ -3087,6 +3367,7 @@ def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False,
video_codec=wrapper_lines(hw_video, gamepad),
plays_start=plays_lines(anchor)[0],
plays_end=plays_lines(anchor)[1])
text = set_config_sync_line(text, pkg, config_sync)
path = os.path.join(anchor, "launch.sh")
with open(path + ".tmp", "w") as f:
f.write(text)
@@ -3098,6 +3379,187 @@ def data_files_dir(base, pkg):
return os.path.join(base, "lepton-data/external/Android/data", pkg, "files")
# ------------------------------------------------- saved game settings in step with the eye buffers (agent v78)
# device.config_sync (per game, from its recipe; deployment.json "config_sync": {file relative to the game's
# Android/data/<pkg>/files: {setting: expression}}). Some games save their render resolution and load it after they
# created the headset's eye buffers: BlazeRush creates 2938x2938 buffers at scale 1.7, then loads its saved
# user_config.xml (r_3dwidth/r_3dheight 1728, r_width/r_height 3072x1728) and draws a displaced, stretched picture with
# black borders (Klownicle's guide, GitHub #57). The values are written while the game is stopped: at finalize, after
# a Game settings change, and by the launcher before Lepton starts (the game rewrites its file when it exits, so a scale
# changed while it ran is caught at the next start). The eye buffer = the runtime's recommended size (learned from the
# game's last launch.log: FrameBridge logs it; older builds: the bridge's stereo= size divided by the logged scale)
# times settings.conf's scale, rounded like FrameBridge does. Only existing numeric entries change; the previous file
# is kept as <file>.frameport-backup and the new one replaces it atomically.
CONFIG_EXPR_RE = re.compile(r"\s*([wh])\s*(?:\*\s*(\d+(?:\.\d+)?))?\s*(?:/\s*(\d+(?:\.\d+)?))?\s*")
CONFIG_SYNC_LINE = 'python3 {agent_q} _config_sync {pkg_q} >"$app_dir/config-sync.log" 2>&1 9>&- || true'
CONFIG_BACKUP = ".frameport-backup"
RUNTIME_EYE_RE = re.compile(r"FrameBridge: runtime recommended eye size (\d+)x(\d+)")
BRIDGE_STEREO_RE = re.compile(r"OVRPortVrApi: Initialized .*?stereo=(\d+)x(\d+)")
ADAPTER_VIEW_RE = re.compile(r"FrameBridge: view 0 recommended (\d+)x(\d+)")
ADAPTER_SCALE_RE = re.compile(r"FrameBridge: scale=([0-9.]+) ")
def config_value(expr, w, h):
"""An expression of the eye-buffer size: w, h, optionally *a and/or /b (for example h*16/9), rounded."""
m = CONFIG_EXPR_RE.fullmatch(str(expr))
if not m:
raise AgentError(f"bad config_sync expression {expr!r}")
value = (w if m.group(1) == "w" else h) * float(m.group(2) or 1) / float(m.group(3) or 1)
if not 0 < value < 1e6:
raise AgentError(f"config_sync expression {expr!r} out of range")
return int(value + 0.5)
def settings_scale(base):
"""FrameBridge's resolution scale for this game (settings.conf; out-of-range values are ignored like FrameBridge
does)."""
scale = 1.0
try:
for line in open(os.path.join(base, "settings.conf")):
k, _, v = line.strip().partition("=")
if k == "scale":
try:
value = float(v)
except ValueError:
continue
if 0.5 <= value <= 2.0:
scale = value
except OSError:
pass
return scale
def runtime_eye_size(base):
"""(width, height) the Frame's runtime recommends per eye before FrameBridge's scale, from the game's last
launch.log / session.log, or None."""
for name in ("launch.log", "session.log"):
try:
with open(os.path.join(base, name), errors="replace") as f:
text = f.read(16 << 20)
except OSError:
continue
found = RUNTIME_EYE_RE.findall(text)
if found:
return int(found[-1][0]), int(found[-1][1])
scales = ADAPTER_SCALE_RE.findall(text)
sized = BRIDGE_STEREO_RE.findall(text) or ADAPTER_VIEW_RE.findall(text)
if sized and scales:
try:
scale = float(scales[-1])
except ValueError:
continue
if 0.5 <= scale <= 2.0:
return int(int(sized[-1][0]) / scale + 0.5), int(int(sized[-1][1]) / scale + 0.5)
return None
def sync_config_text(text, values):
"""(new text, {name: (old, new)}, [names not found]) with the numeric value of every named setting replaced; the
rest of the text stays byte for byte. Matches <x name="NAME" ... value="V">, <x value="V" ... name="NAME">,
<NAME value="V"/> and <NAME>V</NAME>."""
changed, missing = {}, []
num = r"-?\d+(?:\.\d+)?"
for name, new in values.items():
n = re.escape(name)
patterns = (rf"""(<[^<>]*\bname\s*=\s*["']{n}["'][^<>]*?\bvalue\s*=\s*["'])({num})(["'])""",
rf"""(<[^<>]*?\bvalue\s*=\s*["'])({num})(["'][^<>]*\bname\s*=\s*["']{n}["'])""",
rf"""(<{n}\b[^<>]*?\bvalue\s*=\s*["'])({num})(["'])""",
rf"""(<{n}>\s*)({num})(\s*</{n}>)""")
for pattern in patterns:
m = re.search(pattern, text)
if m:
old = m.group(2)
if old != str(new):
text = text[:m.start(2)] + str(new) + text[m.end(2):]
changed[name] = (old, str(new))
break
else:
missing.append(name)
return text, changed, missing
def sync_game_config(pkg, dep, check_running=True):
"""Write the eye-buffer size into the game's saved settings (device.config_sync). Returns a report (None when the
game has no rules)."""
rules = (dep or {}).get("config_sync") or {}
if not isinstance(rules, dict) or not rules:
return None
base = dep["base"]
if check_running and dep.get("appid") and container_running(dep["appid"]):
return {"skipped": "the game is running (its next start corrects it)"}
runtime = runtime_eye_size(base)
if runtime and runtime != tuple(dep.get("eye_base") or ()):
dep["eye_base"] = list(runtime)
try:
write_deployment(pkg, dep)
except OSError:
pass
runtime = runtime or tuple(dep.get("eye_base") or ())
if len(runtime) != 2:
return {"skipped": "the Frame's eye-buffer size isn't known yet (it is after the game's first start)"}
scale = settings_scale(base)
w, h = int(runtime[0] * scale + 0.5), int(runtime[1] * scale + 0.5)
files_dir = os.path.realpath(data_files_dir(base, pkg))
out = {"eye": [w, h], "runtime": list(runtime), "scale": scale, "changed": {}, "missing": [], "files": []}
for rel, exprs in rules.items():
target = os.path.realpath(os.path.join(files_dir, str(rel)))
if not target.startswith(files_dir + os.sep) or not isinstance(exprs, dict):
out["missing"].append(str(rel))
continue
if not os.path.isfile(target):
out["files"].append({"file": rel, "exists": False}) # the game writes it itself, from the right size
continue
values = {str(k): config_value(v, w, h) for k, v in exprs.items()}
with open(target, encoding="utf-8", errors="surrogateescape", newline="") as f:
text = f.read()
new, changed, missing = sync_config_text(text, values)
out["missing"] += [f"{rel}:{k}" for k in missing]
out["files"].append({"file": rel, "exists": True, "changed": sorted(changed)})
if not changed:
continue
shutil.copy2(target, target + CONFIG_BACKUP)
tmp = target + ".frameport-tmp"
with open(tmp, "w", encoding="utf-8", errors="surrogateescape", newline="") as f:
f.write(new)
shutil.copymode(target, tmp)
os.replace(tmp, target)
out["changed"].update(changed)
return out
def wants_config_sync(anchor):
try:
with open(os.path.join(anchor, "deployment.json")) as f:
dep = json.load(f)
except (OSError, ValueError):
return False
return isinstance(dep, dict) and isinstance(dep.get("config_sync"), dict) and bool(dep["config_sync"])
def set_config_sync_line(text, pkg, want):
"""A Lepton launcher that brings the game's saved settings in step before Lepton starts (want), or without it."""
text = "".join(line for line in text.splitlines(True) if " _config_sync " not in line)
if not want or "\nsetsid " not in text:
return text
line = CONFIG_SYNC_LINE.format(agent_q=shlex.quote(os.path.abspath(__file__)), pkg_q=shlex.quote(pkg))
return text.replace("\nsetsid ", f"\n{line}\nsetsid ", 1)
def config_sync_worker(pkg):
"""launch.sh, before Lepton starts (the game isn't running: the launcher holds its launch lock)."""
dep = deployment(check_pkg(pkg))
print(json.dumps({"time": time.time(), "result": sync_game_config(pkg, dep, check_running=False) if dep else None}))
def cmd_config_sync(args):
"""Bring a game's saved settings in step with the eye buffers now (device.config_sync), unless it runs."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
return sync_game_config(pkg, dep)
def cmd_video_codec_status(args):
"""The shared codec installed on this Frame ({digest, revision}, {} if none or damaged) and whether FramePort's
setting switched it off for every game ("disabled")."""
@@ -3291,19 +3753,25 @@ def cmd_finalize(args):
gamepad = GAMEPAD_PATCH in (recipe.get("patches") or []) # Steam Input's virtual gamepad (PODMAN_WRAPPER)
if gamepad:
ensure_podman_wrapper()
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"), hw_video, gamepad)
config_sync = args.get("config_sync") if isinstance(args.get("config_sync"), dict) else {}
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"), hw_video, gamepad, bool(config_sync))
art_in = os.path.join(base, "incoming-artwork")
if os.path.isdir(art_in):
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
shutil.move(art_in, os.path.join(anchor, "artwork"))
previous = deployment(pkg) or {}
dep = {"package": pkg, "appid": int(appid), "base": base, "title": title, "tags": args.get("tags") or [],
"apk": args.get("apk_name", "game.apk"),
"sha256": args.get("apk_sha256"), "recipe": args.get("recipe"), "installed_by": "frameport",
"hw_video_decode": hw_video, "steam_gamepad": gamepad, "agent_version": AGENT_VERSION,
"time": time.time()}
"hw_video_decode": hw_video, "steam_gamepad": gamepad, "config_sync": config_sync,
"agent_version": AGENT_VERSION, "time": time.time()}
if previous.get("eye_base"): # the runtime's eye size learned from an earlier start (device.config_sync)
dep["eye_base"] = previous["eye_base"]
with open(os.path.join(anchor, "deployment.json"), "w") as f:
json.dump(dep, f, indent=2)
return {"ok": True, "base": base, "appid": appid, "moved_data_files": moved, "controller_models": models}
synced = sync_game_config(pkg, dep)
return {"ok": True, "base": base, "appid": appid, "moved_data_files": moved, "controller_models": models,
"config_sync": synced}
# --------------------------------------------------------------- Steam Frame controller models (XR_FB_render_model)
@@ -4561,7 +5029,43 @@ def cmd_set_settings(args):
with open(os.path.join(files_dir, "framebridge.conf"), "w") as f:
f.write(text)
models = install_controller_models(files_dir, current.get("controller_models", "0") not in ("0", "0.0"))
return {"settings": current, "controller_models": models}
return {"settings": current, "controller_models": models, "config_sync": sync_game_config(pkg, dep)}
TITLE_MAX = 128
def clean_title(title):
"""A game's name for its Steam shortcut: one line, no control characters, at most TITLE_MAX characters."""
text = " ".join("".join(c if c.isprintable() else " " for c in str(title or "")).split())
if not text:
raise AgentError("the new name is empty")
return text[:TITLE_MAX]
def cmd_rename(args):
"""Rename an installed game (agent v77): deployment.json's title is what its Steam shortcut shows. The shortcut
keeps its appid (upsert_shortcut matches by Exe, deployment.json keeps the appid), so Play, its grid art, launch
tests and the game's container stay the same. shortcuts (default on) rewrites the library entry like an install
does (Steam restarts once, after a running game or Desktop Mode). A devkit entry is named by its id: unchanged."""
pkg = check_pkg(args["package"])
title = clean_title(args.get("title"))
dep = deployment(pkg)
if not dep or not dep.get("appid"):
raise AgentError(f"{pkg} is not installed")
old = dep.get("title") or pkg
out = {"package": pkg, "title": title, "old": old, "appid": dep["appid"], "renamed": old != title}
if out["renamed"]:
dep["title"] = title
write_deployment(pkg, dep)
if (dep.get("overlay") or {}).get("registered"): # its SteamVR manifest shows the name too
try:
register_overlay(pkg, bool(dep["overlay"].get("autostart")))
except Exception as exc: # noqa: BLE001 - the shortcut matters more
out["overlay_error"] = str(exc)
out["shortcuts"] = cmd_shortcuts({"packages": [pkg], "restart": True}) \
if args.get("shortcuts", True) and out["renamed"] else {"started": False}
return out
def cmd_uninstall(args):
@@ -4607,6 +5111,8 @@ def cmd_uninstall(args):
for name in ("deployment.json", "launch.sh", "artwork", "launch.log", "launch-test.log", "session.log"):
p = os.path.join(anchor, name)
remove_tree(p)
if is_overlay(dep):
overlay_service_quiet() # the last autostart overlay app gone: the service goes too
return {"removed": True, "kept_saves": keep_data, "shortcut_removed": removed_sc}
@@ -5430,6 +5936,96 @@ def shader_dumps(files_dir, max_bytes=SHADER_DUMP_BYTES, session_bytes=SHADER_SE
return out
# arm64 syscall numbers seen in stuck threads (/proc/<tid>/syscall gives the number)
ARM64_SYSCALLS = {22: "epoll_pwait", 29: "ioctl", 56: "openat", 63: "read", 64: "write", 73: "ppoll",
98: "futex", 101: "nanosleep", 115: "clock_nanosleep", 203: "connect", 206: "sendto",
207: "recvfrom", 212: "recvmsg", 260: "wait4"}
TCP_STATES = {"01": "ESTABLISHED", "02": "SYN_SENT", "03": "SYN_RECV", "04": "FIN_WAIT1", "05": "FIN_WAIT2",
"06": "TIME_WAIT", "07": "CLOSE", "08": "CLOSE_WAIT", "09": "LAST_ACK", "0A": "LISTEN", "0B": "CLOSING"}
def _remote_kind(hexaddr):
"""/proc/net/tcp(6) remote address → loopback / local / internet (never the address itself: it would need
redacting, and what matters is whether the game waits on the internet)."""
import ipaddress
try:
raw = bytes.fromhex(hexaddr)
if len(raw) == 4:
ip = ipaddress.IPv4Address(raw[::-1])
else: # four little-endian 32-bit words
ip = ipaddress.IPv6Address(b"".join(raw[i:i + 4][::-1] for i in range(0, 16, 4)))
ip = ip.ipv4_mapped or ip
except ValueError:
return "?"
if ip.is_unspecified:
return "none"
if ip.is_loopback:
return "loopback"
return "local" if ip.is_private or ip.is_link_local else "internet"
def game_threads(pkg, proc=None, samples=3, gap=1.0, sleep=time.sleep):
"""What a running (e.g. stuck) Android game's threads are doing (GitHub #157): for every process of `pkg` (its
main process and `pkg:<name>` ones), each thread's state, CPU time used over the sampled seconds, kernel wait
channel and current syscall, plus its TCP sockets. Readable as steamos: the container's user namespace belongs to
steamos. None when the game isn't running."""
proc = proc or PROC
pids = []
for d in os.listdir(proc):
if not d.isdigit():
continue
cmd = (_rd(os.path.join(proc, d, "cmdline")) or "").split("\0")[0]
if cmd == pkg or cmd.startswith(pkg + ":"):
pids.append((int(d), cmd))
if not pids:
return None
def cpu(pid):
out = {}
for t in os.listdir(os.path.join(proc, str(pid), "task")) if os.path.isdir(os.path.join(proc, str(pid),
"task")) else []:
st = _rd(os.path.join(proc, str(pid), "task", t, "stat")) or ""
f = st[st.rfind(")") + 2:].split()
if len(f) > 12:
out[t] = (f[0], int(f[11]) + int(f[12]))
return out
first = {pid: cpu(pid) for pid, _ in sorted(pids)}
for _ in range(max(0, samples - 1)):
sleep(gap)
span = max(0, samples - 1) * gap
lines = [f"# threads of the running game {pkg}; cpu_ms = CPU time used in {span:g} s, wchan = kernel wait, "
"syscall = what the thread is blocked in"]
for pid, cmd in sorted(pids):
last = cpu(pid)
lines.append(f"\n## process {cmd} (pid {pid}, {len(last)} threads)")
lines.append(f"{'tid':>7} st {'cpu_ms':>7} {'comm':<16} {'wchan':<24} syscall")
rows = []
for tid, (state, ticks) in last.items():
used = (ticks - first.get(pid, {}).get(tid, (state, ticks))[1]) * 1000 // CLK_TCK
tdir = os.path.join(proc, str(pid), "task", tid)
comm = (_rd(os.path.join(tdir, "comm")) or "?").strip()
wchan = (_rd(os.path.join(tdir, "wchan")) or "?").strip() or "-"
sc = (_rd(os.path.join(tdir, "syscall")) or "?").split()
call = sc[0] if sc else "?"
if call.isdigit():
call = ARM64_SYSCALLS.get(int(call), "syscall " + call)
rows.append((-used, int(tid), f"{tid:>7} {state:>2} {used:>7} {comm:<16} {wchan:<24} {call}"))
lines += [r[2] for r in sorted(rows)]
socks = []
for name in ("tcp", "tcp6"):
for ln in (_rd(os.path.join(proc, str(pid), "net", name)) or "").splitlines()[1:]:
f = ln.split()
if len(f) > 3 and ":" in f[2]:
addr, port = f[2].rsplit(":", 1)
socks.append(f"{name:<4} {TCP_STATES.get(f[3], f[3]):<12} remote={_remote_kind(addr)}"
f":{int(port, 16)}")
if socks:
lines.append("# TCP sockets (remote = loopback / local network / internet, and port)")
lines += sorted(set(socks))
return "\n".join(lines) + "\n"
def cmd_collect_diag(args):
"""Everything useful for debugging without the game or the PC app: host runtime facts and, with a package, the
game's launcher, settings, deployment, logs (launch, Lepton logcat, Proton/Revive/Unreal) and its file listing.
@@ -5510,6 +6106,13 @@ def cmd_collect_diag(args):
for p in sorted(cands, key=lambda p: next((i for i, k in enumerate(order) if k in os.path.basename(p)), 9)):
name = os.path.basename(p)
files[name if name.startswith("logcat") else "logcat-" + name] = _tail(p, max_bytes)
if dep.get("kind", "quest") == "quest":
try:
threads = game_threads(pkg, samples=int(args.get("thread_samples", 3)))
except OSError:
threads = None
if threads:
files["threads.txt"] = threads
shaders = shader_dumps(data_files_dir(base, pkg), int(args.get("shader_bytes", SHADER_DUMP_BYTES)),
int(args.get("shader_session_bytes", SHADER_SESSION_BYTES)))
if shaders:
@@ -5560,6 +6163,14 @@ def purge_worker(payload):
result["removed"].append(f"SteamVR overlay: {d.get('title')}")
except Exception as exc: # noqa: BLE001
result["errors"].append(f"{d.get('title')}: {exc}")
try: # the overlay autostart service runs this agent: it goes before AGENT_HOME does
if os.path.exists(VR_OVERLAY_UNIT_PATH):
user_systemctl("disable", "--now", VR_OVERLAY_UNIT + ".service")
os.remove(VR_OVERLAY_UNIT_PATH)
user_systemctl("daemon-reload")
result["removed"].append(VR_OVERLAY_UNIT_PATH)
except Exception as exc: # noqa: BLE001
result["errors"].append(f"overlay autostart service: {exc}")
try:
service = stop_steam()
except AgentError as exc:
@@ -6794,6 +7405,12 @@ COMMANDS = {n[4:]: f for n, f in globals().items() if n.startswith("cmd_")}
def main():
if len(sys.argv) >= 2 and sys.argv[1] == "_keyboard":
return keyboard_session(sys.stdin, sys.stdout)
if len(sys.argv) >= 2 and sys.argv[1] == "_vr_overlay_watch":
vr_overlay_watch()
return
if len(sys.argv) >= 2 and sys.argv[1] == "_vr_overlay_round":
print(json.dumps(overlay_autostart_round()), flush=True)
return
if len(sys.argv) >= 2 and sys.argv[1] == "_monitor":
return monitor_session(sys.stdin, sys.stdout)
if len(sys.argv) >= 3 and sys.argv[1] == "_shortcuts_worker":
@@ -6814,6 +7431,9 @@ def main():
if len(sys.argv) >= 4 and sys.argv[1] == "_dashboard_worker":
dashboard_worker(sys.argv[2], sys.argv[3])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_config_sync":
config_sync_worker(sys.argv[2])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_tools_worker":
tools_worker(sys.argv[2])
return 0
+4 -4
View File
@@ -6,11 +6,11 @@ e8c0965c188665a4e565e4a3af983ef1866d1cf9225665cc90d8df286bc96ef3 ./arm64-v8a/li
383054f8b3b41dde76d062c71856cd3163655e50009c08454bc1793f393ead65 ./arm64-v8a/libfpg.so
5432674a59d02f411cd853a5dc57e3fa547d1ac518f25bf433fc54c43677177a ./arm64-v8a/libfpglmv.so
8a8f6b1da8952cb3933a5b53558424a5fc56043b2527a16bb6dd62a00d2de01a ./arm64-v8a/libframe_xrshim.so
fc559dbe0b87eecc3641256837293260cefb7c2ce7b21d808d10edd2a2dba9fc ./arm64-v8a/libglshim.so
1655921f4c91f2f336edf0aac66d8df1b2b649a272aafe365c1f2fa7867129de ./arm64-v8a/libopenxr_loader_generic.so
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
449e13bbe58de6718d4e530177924e09552a95d748a082739899144e2cb3ccf3 ./arm64-v8a/libopenxr_loader_generic.so
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
aa4dc0020c77e12d41ef6ce80b23ad90c9cb7e882338eddf2261efe8645cf38d ./arm64-v8a/libvrapi.so
7943c6825e1c2841e54627f96acbd0754a3c06bb608c47f7aa15007aec2191a5 ./armeabi-v7a/libopenxr_loader_generic.so
1d83eb94ec1df44f29a5f525dce4d5b608dbbb13a9c7a5efeb80a4b1dc54a721 ./arm64-v8a/libvrapi.so
47f736dfeffce6aa56be2fc049c2d11b87b0d1f7389db7ee1737fcded9457811 ./armeabi-v7a/libopenxr_loader_generic.so
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -1,9 +1,9 @@
package: com.Armature.VR4
title: VR4
status: works
notes: Installs the no-ForceQuit build and fixes a campaign shader that hung the GPU after the opening cutscene.
details: Entering the campaign gave a black screen, slowing audio and a crash (Mercenaries loaded). One fragment shader
reads a loop counter before setting it, which hangs the GPU; the Vulkan shim zeroes it (vk_shader_fix). The
notes: Installs the no-ForceQuit build and fixes two campaign shaders that hung the GPU at cutscenes.
details: Entering the campaign gave a black screen, slowing audio and a crash (Mercenaries loaded). Two permutations of one fragment shader
read a loop counter before setting it, which hangs the GPU; the Vulkan shim zeroes it (vk_shader_fix). The
no-ForceQuit build is kept. Diagnosed and confirmed on a Frame by the reporter of GitHub issue 10.
tested_version: '2.3'
engine: Unreal
@@ -14,8 +14,8 @@ use_alt: true
adapter:
# one campaign shader reads an uninitialized loop counter and hangs the GPU (black screen, slowing audio, crash);
# the Vulkan shim inserts two OpStores that zero it and its accumulator (from the reporter's capture, GitHub
# issue 10)
vk_shader_fix: 6488:5dcd842db8d21e3fdd13ec916d2e34dd0cfdf12a92fffaf8a27115072ef55c59:2624:0x0003003e,138,18,0x0003003e,150,42
# issue 10); a second permutation of the same shader (6604 bytes) hung the GPU at a later cutscene (GitHub #140)
vk_shader_fix: 6488:5dcd842db8d21e3fdd13ec916d2e34dd0cfdf12a92fffaf8a27115072ef55c59:2624:0x0003003e,138,18,0x0003003e,150,42;6604:a0e6edd8a8e969acbefe1a193f0bc535d8c0c18d004def4c361840a9f2601f13:2956:0x0003003e,122,18,0x0003003e,135,42
verified:
date: '2026-10-02'
known_good_sha256: 40ff74e37264b28ae8826923b69d0731d9620094c76462be34159643d6ae91b3
@@ -24,5 +24,5 @@ verified:
frame_build: '20260930.6234839'
agent: 26
issue: 1
updated: '2026-10-04'
updated: '2026-10-11'
source_hint: Resident Evil 4
+24
View File
@@ -0,0 +1,24 @@
package: com.schellgames.ieytd3
title: IEYTD3
status: works
tested_version: 1.0.1.211419 (ReleaseCandidate_1.0.0_QuestStore)
engine: Unity
xr: VrApi
frame:
- frame.unity_no_msaa
- 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-11'
known_good_sha256: 06f901ae22f7c07a79e11fc1af6b3941a51bbc99624336d6a0939f9593f3bfa0
app: 0.12.1.dev263
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 78
issue: 181
source_hint: 2026-10-08T23-57-41.310Z_211419
@@ -0,0 +1,18 @@
package: com.square_enix.android_meta.trianglexr
title: TRIANGLE STRATEGY
status: works
notes: Works in XR, with and without Arcturus Vision color (switched while playing).
tested_version: '1.0'
engine: Unreal
xr: OpenXR
alt_overport:
- patch_remove_unreal_force_quit
adapter:
haptic_fix: 1
verified:
date: '2026-10-10'
known_good_sha256: 86e8297adeb4056eecfbc175438ef25387df67dd0a15ae79afbe6f2e67319a0c
app: 0.12.0
overport_cli: 1.2.5
issue: 171
source_hint: com.square_enix.android_meta.trianglexr
@@ -0,0 +1,17 @@
package: org.timecrisis2.arcadevr
title: Time Crisis II VR (Test)
status: works
tested_version: 0.2.0
engine: Other
xr: OpenXR
frame:
- frame.sdl_clipboard
verified:
date: '2026-10-10'
known_good_sha256: 7829a4cb84298a03ebe539b893916a962aecc4119f189f9fa9ceeab24c747bc5
app: 0.12.0
overport_cli: 1.2.3
frame_build: '20261007.6125817'
agent: 59
issue: 173
source_hint: TimeCrisis2VR-v0.2.0-quest3
+31 -8
View File
@@ -1,18 +1,41 @@
package: ru.targem.blazerush
title: BlazeRush
status: unsupported
notes: Starts and reaches the menu room, but the room shows no controllers and ignores all input (it all reaches
the game); no fix yet.
status: works
notes: From Klownicle's guide (GitHub issue 57); FramePort's version verified in a Steam Frame headset (2026-10-11).
Menus and racing controls, car bodies and controllers, particle effects, the full field of view and a sharp,
correctly placed picture.
details: 'Seven fixes from the guide. frame.blazerush (this exact build only) skips the menus'' wait for a Meta avatar,
which never comes on the Frame, so the game creates its own controllers, and gives its shader cache a new folder
(cache/br_shader_v1) so programs compiled before the shader fixes aren''t reused. The GL shim feeds integer vertex
inputs (bone indices) as integers (gl_int_attribs: car bodies and controllers were invisible) and computes the
particle vertex shaders in highp (gl_highp_markers: streaks and flashes). The game reads the field of view once at
start, before tracking: the VrApi bridge answers with the FOV of the previous session (FramePort''s launch test
after the install is one), not 90x90 degrees. scale 1.7 keeps the wider view sharp (about 2.9x the pixels).
device.config_sync keeps the resolution saved in user_config.xml at the eye-buffer size, else the picture is
displaced with black borders after a scale change.'
tested_version: 1.0.349
engine: Other
xr: VrApi
frame:
- frame.vrapi_bridge
- frame.avatar_stub
- frame.gl_shim
- frame.blazerush
adapter:
scale: 1.7
gl_int_attribs: 1
gl_highp_markers: in uvec2 inBoneIndices;|uniform InstanceData|void main()
gl_hide_multiview: 0
gl_hide_msrtt: 0
config_sync:
user_config.xml:
r_3dwidth: w
r_3dheight: h
r_width: h*16/9
r_height: h
verified:
date: '2026-10-06'
known_good_sha256: 7cbd5d4252788ccd3c95b5c1841dcc798ea029d687938c024e33bd698ba17671
app: 0.12.0
updated: '2026-10-06'
min_app: 0.12.0
date: '2026-10-11'
issue: 57
updated: '2026-10-11'
min_app: 1.0.0
source_hint: BlazeRush
+41 -2
View File
@@ -74,7 +74,8 @@ signatures:
diagnosis: Android's debug checks stopped the game. CheckJNI is on because the converted app is debuggable, and it aborts on sloppy JNI use.
suggest: [frame.nodebug]
- id: force-quit
pattern: 'System\.exit|ForceQuit|FAndroidMisc::RequestExit'
# not Android's own WebView helper, which calls System.exit at every container boot (GitHub #179)
pattern: '(?m)^(?!.*WebViewLoader).*System\.exit|ForceQuit|FAndroidMisc::RequestExit'
severity: error
diagnosis: The game quit itself right after starting. Usually Unreal's ForceQuit after a failed Quest platform check; the alternate build leaves it out.
suggest: [patch_remove_unreal_force_quit]
@@ -154,6 +155,11 @@ signatures:
severity: fatal
diagnosis: The app crashed at start because the Frame's Android has no clipboard. SDL apps (SDL2 / LÖVE) need Android's clipboard service, which Lepton lacks.
suggest: [frame.sdl_clipboard]
- id: godot-no-clipboard
pattern: 'non-null type android\.content\.ClipboardManager(?:.*\n){1,4}?.*at org\.godotengine\.godot\.Godot\b'
severity: fatal
diagnosis: The app crashed at start because the Frame's Android has no clipboard. Godot 4 apps (Godot 4.2 to 4.4) expect Android's clipboard service, which Lepton lacks.
suggest: [frame.godot_clipboard]
- id: controller-profile-rejected
pattern: 'PATH_UNSUPPORTED.*xrSuggestInteractionProfileBindings|xrSuggestInteractionProfileBindings.*(-48|PATH_UNSUPPORTED)'
severity: info
@@ -193,6 +199,20 @@ signatures:
diagnosis: Meta's avatar library stopped the game because it needs Meta's Horizon app. A do-nothing stand-in lets the game run without Meta avatars (for example BlazeRush).
suggest: [frame.avatar_stub]
supersedes: [native-crash, java-crash]
- id: vrapi-fov-before-tracking
pattern: 'OVRPortVrApi: FOV properties: [0-9.]+ x [0-9.]+ degrees \(default, nothing known before tracking\)'
severity: info
diagnosis: The game read the field of view right after starting VrApi, before the Frame's runtime could tell it, and
got 90x90 degrees (a narrow, square view if it keeps that value, for example BlazeRush). The VrApi bridge saves the
headset's real FOV for the next start (framebridge-vrapi-fov.txt in the game's files); start the game once more.
suggest: []
- id: gl-integer-attribs
pattern: 'GLShim: int attribs: program \d+ location \d+: floats -> glVertexAttribIPointer'
severity: info
diagnosis: The game feeds integer shader inputs (for example bone indices) through glVertexAttribPointer; the GL shim
sets them up as integers (gl_int_attribs), else the Frame's GL driver converts them to floats and skinned models
vanish (for example BlazeRush's cars and controllers).
suggest: []
- id: vrapi-symbol-missing
pattern: 'cannot locate symbol "vrapi_\w+"'
severity: fatal
@@ -288,6 +308,19 @@ signatures:
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 (for example 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: unreal-obb-check-stuck
pattern: 'Displayed \S+/\S*DownloaderActivity\b'
# the downloader returned (the engine's main init resumed, OpenXR started) or the game crashed (its own finding)
unless: 'xrCreateInstance|xrCreateSession|FrameBridge: pacing:|Resuming main init|Fatal signal \d+|FATAL EXCEPTION'
severity: fatal
diagnosis: >-
The game stays on Unreal's OBB check screen (DownloaderActivity), which the Frame doesn't show, and never
starts VR (for example Contractors, GitHub #159). Unreal checks that the data file (.obb) has the name and size
this APK expects, and with bVerifyOBBOnStartUp it also reads the whole file to check it; a failed check waits
on an error screen. "Unreal - start without the OBB check" skips the content check. If the game still stops
here, the OBB doesn't match this APK version - copy the APK and the OBB from the same install, add the game
again and reinstall.
suggest: [frame.unreal_skip_obb_check]
- id: native-crash
pattern: 'Fatal signal (\d+) \(SIG[A-Z]+\)'
severity: fatal
@@ -308,6 +341,12 @@ signatures:
severity: fatal
diagnosis: 'Games can''t start until the Frame restarts: it ran out of kernel keys. Rootless podman leaks one session keyring per container start; the FramePort agent sets keyring=false in ~/.config/containers/containers.conf, and a reboot frees the leaked keys.'
suggest: []
- id: app-not-started
pattern: 'Target app \(.*\) did not start'
severity: error
diagnosis: Lepton didn't start the game this time, which happens now and then on the first start after an install. Start it again; if it never starts, attach diagnostics.
suggest: []
supersedes: [force-quit]
- 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
@@ -450,7 +489,7 @@ milestones: # progress markers, in order; the furthest one reached is reported
- {id: pcvr-openxr, kind: pcvr, pattern: 'wineopenxr: .*[Ss]ession|LibReviveXR|Revive.*OpenXR|xrCreateSession', label: OpenXR reached}
- {id: pcvr-session, kind: pcvr, pattern: 'xrCreateSession|XR_SESSION_STATE_(READY|SYNCHRONIZED)', label: OpenXR session created}
- {id: container, pattern: 'Boot complete!', label: Lepton container started}
- {id: process, pattern: 'Start proc \d+:', label: App process started}
- {id: process, pattern: 'Start proc \d+:(?!WebViewLoader|com\.android\.|android\.)', label: App process started}
- {id: adapter, pattern: 'FrameBridge: scale=', label: FrameBridge adapter loaded}
- {id: instance, pattern: 'xrCreateInstance result=0', label: OpenXR instance created}
- {id: session, pattern: 'xrCreateSession|session state=1|Created OpenXR session', label: OpenXR session created}
+3
View File
@@ -40,6 +40,9 @@ games/<pkg>/target/ from the Frame (or the PC): launch.sh, settings.con
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)
threads.txt (agent v79): saved while a Quest game runs (e.g. hangs), each thread's
state, CPU ms over 2 s, kernel wait and syscall, plus its TCP sockets (remote = loopback /
local / internet + port, no addresses); read once from /proc, the game isn't paused
games/<pkg>/target/shaders/ shader dumps (agent v72), when the game wrote any:
fp_vk_shaders/ adapter vk_shader_dump=1 (Vulkan shim): <size>_<sha256>.spv + index.txt
fp_spirv/ adapter zink_shader_dump=1 (OpenGL ES, shader-fix layer under Zink): <size>_<sha256>.spv
+37
View File
@@ -25,3 +25,40 @@ Games/ ← scan this folder (Add games → Scan
Not sure of the package name? It's in the OBB file names (`main.<version>.<package name>.obb`), and the game's page
shows it under **Details**.
## Can I scan SideQuest backups or AXRB downloads directly?
Yes. Scan the folder as it is; FramePort finds the APK and its OBB files in both layouts:
```tree
SideQuest Backups/ ← scan this folder
└── com.Armature.VR4/ ← one folder per game (the package name)
└── 2026-10-07T02-10-19-171Z/ ← one folder per backup
├── apk/com.Armature.VR4.apk ← the game
├── obb/ ← its OBB files, sent to the Frame
│ ├── main.203.com.Armature.VR4.obb
│ ├── patch.203.com.Armature.VR4.obb
│ └── VR4-Android-Shipping-arm64.apk ← ignored (not a second game)
├── data/ ← the app's own files on the Quest: not needed
├── icon.png
└── manifest.json
AXRB/ ← AXRB's download folder (Downloads/AXRB): scan this folder
├── 1234567890/ ← AXRB's numbers for the game and the build
│ └── 987654321/
│ ├── base.apk ← the game
│ ├── main.203.com.Armature.VR4.obb ← sent to the Frame, with any other files here (DLC)
│ └── patch.203.com.Armature.VR4.obb
└── patched/ ← AXRB's own PC builds: ignored
```
- **Several backups of one game:** FramePort uses the newest one. To use another, scan just that backup's folder.
- **AXRB's patched builds** (`patched/`, `*-axrb.apk`) are converted for AXRB's PC runtime and lack the game's data, so
FramePort skips them and, on the next scan, replaces a game that was added from one with the original download.
- **AXRB export ZIPs:** unzip first. A game's ZIP holds `base.apk` and `Android/obb/<package>/`, which FramePort finds
as is.
- **The game page says the data file (.obb) is missing** although you have one: the backup didn't include it (copy
the game's `Android/obb/<package>` folder from the Quest with SideQuest), or it sits in a folder FramePort doesn't
look in. Then use the layout in the previous answer.
- **The game starts and stops at once (Steam shows Resume):** add the game again from its original download or
backup, click **Update on Frame**, then **Report a problem…** on its page if it still stops.
+19 -2
View File
@@ -206,8 +206,9 @@ over Quest games.
- **Registration** (what SteamVR honours on the Frame): `IVRApplications::AddApplicationManifest(path, false)` from
a utility client, live; the path is kept in `~/.config/openvr/config/appconfig.json` `manifest_paths`.
`SetApplicationAutoLaunch` is accepted (GetApplicationAutoLaunch → true; vrserver has
`CAppInfoManager::StartAutolaunchOverlays`) but wasn't written to `steamvr.vrsettings` right away; whether it
starts the app at the next SteamVR start is **not yet verified**. The linuxarm64 vrserver only reads
`CAppInfoManager::StartAutolaunchOverlays`) but on the dev Frame it never reached `steamvr.vrsettings` (no
autolaunch entry for `temporalreality.overlay` there, appconfig.json holds only manifest_paths) and the owner's
watch didn't start by itself, so FramePort doesn't rely on it (see "Autostart" below). The linuxarm64 vrserver only reads
**`binary_path_linux_arm`**: a manifest with `binary_path_linux` alone is skipped ("must specify binary_path for
launch_type binary. Skipping"; Steam's own steamapps.vrmanifest entries are skipped the same way), so an app's
own Linux manifest/`--install` that only writes binary_path_linux can't be launched by SteamVR on the Frame.
@@ -219,6 +220,22 @@ over Quest games.
absolute paths) and registers it; `unregister_vr_overlay`, uninstall and purge remove it; `ensure_host_fixes`
registers ones SteamVR missed (it wasn't running). Overlay apps skip launch tests, the Linux launcher's
Steam-parent watchdog (`FRAMEPORT_OVERLAY=1`) and don't count as a running game.
- **Autostart (agent v76)**: the deployment's `overlay.autostart` is the source of truth (SetApplicationAutoLaunch
is still set for SteamVR builds that honour it). While at least one installed overlay app has it on,
`ensure_host_fixes` keeps the user service `frameport-vr-overlays.service` (`~/.config/systemd/user`, enabled for
default.target, Restart=always) = `frameport_agent.py _vr_overlay_watch`: every 5 s it checks the vrserver it knows
(`/proc/<pid>/stat`: name + start time; a full /proc scan only when that one is gone); a new vrserver (boot,
SteamVR restart, or the first look after the service starts) gets one round in a child process
(`_vr_overlay_round`, timeout): wait ≤180 s until IVRApplications answers, 15 s for SteamVR's own auto-launch,
then `launch_vr_overlay` for each autostart app that isn't running (a process with its install folder/anchor in
the command line, or GetApplicationProcessId ≠ 0: never a second copy). The handled vrserver is kept in
`~/.cache/frameport-vr-overlays.json`, so an agent update (the watcher exits when its file changes, systemd starts
the new one) doesn't restart an app the user closed. Log: `~/.local/share/frameport/vr-overlays.log` (128 KB, one
`.1`). Removed when no overlay app has autostart (game page switch, uninstall), by purge, and by the kill switch
`~/.local/share/frameport/vr-overlays.disabled` (or `FRAMEPORT_NO_OVERLAY_AUTOSTART=1` in the agent's environment).
Cost: one idle python3 (~30-40 MB RSS, no CPU between polls). Dev Frame 2026-10-10 (vrserver up since boot,
Temporal Reality already started from its Steam shortcut): `new SteamVR (vrserver 2297)` →
`linux.temporalreality: running` (not started again). Start after a real SteamVR start/boot: not yet seen.
## 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
+7 -3
View File
@@ -104,6 +104,9 @@ refresh rate, controllers, menus, 360° video and mixed reality. Changes are use
![Game settings](images/game-settings.png)
**Rename…** (in the game's menu) changes the name in the Library and in Steam; FramePort keeps it from then on. An
installed game keeps its Steam entry, artwork and saves (Steam restarts once).
### microSD cards and other drives
- The **Steam Frame** page's **Storage** section sets where new games go (**Install new games to**).
@@ -192,9 +195,10 @@ the Steam library.
page (**Desktop Mode**).
- **SteamVR overlay apps** (a wrist watch, a performance display: programs that draw over games instead of being
one) are registered with the Frame's SteamVR, which shows them over whatever you play, Quest and PC VR games
alike. FramePort recognises them by the `.vrmanifest` file they ship. By default SteamVR starts them by itself
(**SteamVR overlay → Start with SteamVR** on the game page); otherwise start the app from your library before the
game. Windows overlay apps get the patch **SteamVR overlay app (Frame)**.
alike. FramePort recognises them by the `.vrmanifest` file they ship. By default they start by themselves whenever
SteamVR starts (**SteamVR overlay → Start with SteamVR** on the game page; a small FramePort service on the Frame
does this, `frameport-vr-overlays`); otherwise start the app from your library before the game. Windows overlay
apps get the patch **SteamVR overlay app (Frame)**.
## Files on the Frame
+12
View File
@@ -90,6 +90,18 @@ not clickable. Use the `https://frameport.app/install?…` link:
Use the animated or still SVG: GitHub shows SVGs as images, so the hover version doesn't react there.
## Testing your setup
The example button on [frameport.app](https://frameport.app/#button) uses a demo link:
```
https://frameport.app/install/?manifest=https%3A%2F%2Fframeport.app%2Fdemo%2Fcool-game.json
```
Click it to check that FramePort receives install links on your PC. FramePort recognises this one address and shows
a demo install question for a placeholder game, "Cool Game"; nothing is downloaded, added to the library or
installed (`frameport open-link` says it's the demo link and stops). Real buttons install real games.
## If a visitor doesn't have FramePort
The `https://frameport.app/install?…` link handles this: its page offers the download and a second try. A bare
+2314
View File
File diff suppressed because it is too large. Load diff
+9 -1
View File
@@ -22,13 +22,16 @@ 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 |
| BlazeRush (GitHub #57, Klownicle's guide): the first menu takes A, the next ignores all input; car bodies and controllers invisible; coloured streaks/flashes from particles; narrow square view; blurry, or displaced with black borders after a scale change | the menus wait for a Meta avatar the stub never delivers; bone indices fed with glVertexAttribPointer (Mesa converts them to floats); mediump particle maths; FOV read once before any session (bridge said 90x90); old program binaries in its shader cache; user_config.xml keeps the old resolution | catalog recipe: `frame.blazerush` (avatar wait NOP + avatar renderer RET + cache folder `cache/br_shader_v1`, exact build only), GL shim `gl_int_attribs=1` + `gl_highp_markers=in uvec2 inBoneIndices;\|uniform InstanceData\|void main()` (`gl_hide_multiview=0`, `gl_hide_msrtt=0` keep its GL as tested), the VrApi bridge's saved FOV (`framebridge-vrapi-fov.txt`; triage `vrapi-fov-before-tracking`), `scale=1.7`, `device.config_sync` (agent writes r_3dwidth/r_3dheight/r_width/r_height from the runtime's eye size × scale before each start); triage `gl-integer-attribs` |
| Frame freezes or the game is killed (OOM) on the first controller vibration (for example 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 |
| An overlay app (fpsVR, a wrist watch) installed on the Frame never appears, or SteamVR doesn't start it | not registered with the Frame's SteamVR, or registered with a manifest SteamVR skips: on the Frame vrserver reads only `binary_path_linux_arm` (vrserver.txt: "must specify binary_path for launch_type binary. Skipping") | `pcvr.vr_overlay` (Windows) / a Linux app with a bundled `.vrmanifest` (`is_dashboard_overlay`): agent v75 `register_vr_overlay` (check `vrcmd --overlays`, `~/.config/openvr/config/appconfig.json` manifest_paths); the app's own `--install` writes binary_path_linux only. fpsVR under Proton never loads OpenVR (quits after 60 s): unsupported for now |
| An overlay app with **Start with SteamVR** on doesn't start by itself after the Frame or SteamVR starts (it works when started by hand) | SteamVR's own auto-launch flag (SetApplicationAutoLaunch) didn't persist on the Frame (no autolaunch entry in `~/.config/openvr/config/steamvr.vrsettings`) | agent v76: FramePort's service `frameport-vr-overlays.service` starts it at every new vrserver. Check `systemctl --user status frameport-vr-overlays`, `~/.local/share/frameport/vr-overlays.log` (`new SteamVR (vrserver N)` → `<pkg>: started` / `running` / `couldn't start it: …`), the deployment's `overlay.autostart`, and that `~/.local/share/frameport/vr-overlays.disabled` doesn't exist; `frameport frame info` (ensure_host_fixes) installs a missing service |
| 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 |
| Launch test EXITED, launch.log has `Target app (<pkg>) did not start` and no `Start proc …:<pkg>` (GitHub #179) | Lepton occasionally doesn't start the app on the first start after an APK change; Android's `WebViewLoader-: System.exit` at boot is not the game (triage `app-not-started`) | start it again |
| A Unity app's text field shows a caret for a moment and loses focus; no keyboard appears (for example 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) |
| Steam's Exit game leaves the game running (container `lepton-steamlaunch-<appid>` still up) | Steam stopped only its `reaper`; launch.sh and Lepton (setsid) never got a signal (GitHub #36) | agent v44: launch.sh's 2 s loop ends the game when its parent is gone (`upgrade_launchers` adds it to existing launchers); by hand: `podman kill lepton-steamlaunch-<appid>` |
@@ -50,8 +53,10 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| 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 (for example 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 neighboring 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) |
| A game from an AXRB download starts and stops at once (Steam shows Resume), no OBB on the Frame; or a SideQuest backup adds a second game named after an APK in its `obb/` folder (reddit 2026-10-11) | AXRB keeps its own PC builds in `AXRB/patched/<package>/*-axrb.apk` (OVRPort-converted for its runtime, no OBBs); the scan added them over the original `AXRB/<app id>/<binary id>/base.apk`. SideQuest's VR4 backup has Unreal's `VR4-Android-Shipping-arm64.apk` next to the OBBs | `quest_dump._axrb_patched` skips AXRB's patched copies; a rescan replaces entries made from them; AXRB downloads send every non-APK file next to `base.apk` (DLC, assets) and are named after the package; APKs inside another game's data folder are ignored; SideQuest backups are named after the package folder, not the backup time. docs/FAQ.md has both layouts |
| 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) |
| Unreal game: launch.log ends with `ActivityTaskManager: START u0 {… cmp=<pkg>/.DownloaderActivity}` and `Displayed <pkg>/.DownloaderActivity`, then nothing: no OpenXR instance, no crash (GitHub #159 Contractors; triage `unreal-obb-check-stuck`, also when the OBB is on the Frame) | Epic's DownloaderActivity (started by GameActivity.onResume before the engine's main init) checks every OBB's name + size compiled into the APK (OBBData); with manifest meta-data `com.epicgames.ue4.GameActivity.bVerifyOBBOnStartUp=true` it then reads the whole OBB and compares each entry's CRC32 (cached in `files/cacheFile.txt` by the OBB's mtime). Its progress and error screens are invisible in Lepton (headless VR); a CRC mismatch waits for a button forever. A missing / wrong-size OBB starts Google Play's downloader (or a "No Google Play Store Key" dialog) and also waits | `frame.unreal_skip_obb_check` (suggested by analysis `unreal_verify_obb`): sets that meta-data to false (one boolean in the manifest, `axml.set_meta_data_bool`), which skips only the CRC pass. Still stuck: the OBB doesn't match the APK version (name `main.<versionCode>.<pkg>.obb` and its size) - copy APK + OBB from the same install |
| `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) |
@@ -98,6 +103,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Crash right after the game gets focus, in `vrclient.so … UpdateActionStateInternal` / `sxr_xrSyncActions` (for example 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 (for example 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>` (for example 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 |
| Godot 4 app crashes at start: `java.lang.NullPointerException: null cannot be cast to non-null type android.content.ClipboardManager` at `org.godotengine.godot.Godot.<init>` (Godot 4.3/4.4; 4.2 when the clipboard is first used, in `Godot$mClipboard$2.invoke`) (for example EndoparasiticVR, GitHub #134); triage `godot-no-clipboard` | Lepton's Android has no clipboard service and Godot's Kotlin code casts it with a non-null `as ClipboardManager` (`Intrinsics.checkNotNull` before the check-cast). Godot 4.1 (Java) and 4.5+ (`as?`) don't throw | `frame.godot_clipboard` (suggested by analysis `godot_clipboard`): that checkNotNull call → nops in classes.dex (check-cast accepts null), and Godot's getClipboard/hasClipboard/setClipboard return at once ("" / false / nothing; `Dex.return_early`), in place; matched exactly, other dex files untouched. Checked against the Maven godot-lib 4.2.2/4.3.0/4.4.1 and the 4.3/4.4.1 release export templates (D8 re-reads the result); not yet run on the device |
| Game crashes at start: `java.lang.NoSuchMethodError: No virtual method getAvailableCommunicationDevices()… in class Landroid/media/AudioManager` at `com.vivox.sdk.AudioChangeListener.checkAudioRouteAndApplyChanges` (for example 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 (for example stuck on a setup screen); log: `xrSuggestInteractionProfileBindings … XR_ERROR_PATH_UNSUPPORTED` | the game only suggests bindings for Meta's newer profiles (Touch Plus / Touch Pro); the Frame's Android runtime knows `oculus/touch_controller`, not those | adapter `profile_remap` (default on): the bindings are suggested again for Touch (log: `controller profile … -> oculus/touch_controller`) |
| Some buttons do nothing or the wrong thing (for example a Quest game's X/Y on the Frame's left controller) | the game binds only Quest Touch and SteamVR maps Touch onto the Frame controllers with its own remap (Touch left X/Y land on the d-pad, right X and Y repeat B), or the runtime rejected a profile or an input call | turn on adapter `input_diag` (diagnostics, off by default; `frameport settings <pkg> input_diag=1`) and look for `input_diag:` lines: `bindings: <profile> accepted` or `unsupported: interaction profile <profile> -> <result>` with the rejected paths, `bindings: /user/hand/<hand> uses <profile>`, `unsupported: function <name>`, `unsupported: xrCreateAction -> <result> (<action>)` |
@@ -177,7 +183,9 @@ 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
- Hangs (every thread asleep): first ask for a diagnostics zip saved *while the game hangs*: `target/threads.txt`
(agent v79) shows each thread's wait and syscall (futex = a lock, ppoll/recvfrom + an internet socket = network). Then
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
+10
View File
@@ -0,0 +1,10 @@
# Brand assets
- `frameport-icon-1024.png`: square app icon, 1024 × 1024, background to the edges (for project pages, store listings,
profile pictures).
- `frameport-icon-square.svg`: the same as a vector (FramePort's logo, `src/frameport/ui/icons/logo.svg`, with square
corners).
For the "Install with FramePort" button, see [`docs/badges/`](../badges/) and [INSTALL_BUTTON.md](../INSTALL_BUTTON.md).
- `frameport-social-preview.png`: GitHub's social preview (1280 × 640): repository Settings → General → Social
preview. Built from the logo and the docs' Library screenshot (`docs/images/library.png`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 64 64"><defs><linearGradient id="fp0" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#3AA8FF" stop-opacity=".18"/><stop offset=".5" stop-color="#3AA8FF" stop-opacity="0"/><stop offset=".5" stop-color="#FF8A1F" stop-opacity="0"/><stop offset="1" stop-color="#FF8A1F" stop-opacity=".18"/></linearGradient><linearGradient id="fp1" gradientUnits="userSpaceOnUse" x1="26" y1="0" x2="38" y2="0"><stop offset=".3" stop-color="#3AA8FF"/><stop offset=".7" stop-color="#FF8A1F"/></linearGradient><clipPath id="fp2"><polygon points="-30,-30 49.14502116644019,-30 8.854978833559809,94 -30,94"/></clipPath><clipPath id="fp3"><polygon points="55.14502116644019,-30 94,-30 94,94 14.854978833559809,94"/></clipPath></defs><rect width="64" height="64" rx="0" fill="#101218"/><rect width="64" height="64" rx="0" fill="url(#fp0)"/><g clip-path="url(#fp2)"><g transform="translate(0 0)"><path d="M34 15H9a3 3 0 0 0-3 3v20a3 3 0 0 0 3 3h25z" fill="#101218" stroke="#ECEDF3" stroke-width="3" stroke-linejoin="round"/><rect x="10.5" y="19.5" width="24" height="17" rx="1" fill="#3AA8FF" fill-opacity="0.35"/><path d="M18 41v6M11 47h14" stroke="#ECEDF3" stroke-width="3" stroke-linecap="round"/></g></g><g clip-path="url(#fp3)"><path d="M56.5 31.5H58.5" stroke="#ECEDF3" stroke-width="3.4" stroke-linecap="round"/><path d="M22 21h27a8 8 0 0 1 8 8v5a8 8 0 0 1-8 8H48.65a3 3 0 0 1-2.6-1.5l-1-1.7a.9.9 0 0 0-1.6 0l-1 1.7a3 3 0 0 1-2.6 1.5H22z" fill="#101218" stroke="#ECEDF3" stroke-width="3" stroke-linejoin="round"/><ellipse cx="39.5" cy="30.5" rx="4.2" ry="3.8" fill="#FF8A1F"/><ellipse cx="49" cy="30.5" rx="4.2" ry="3.8" fill="#FF8A1F"/></g><g transform="rotate(18 32 32)"><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="#08090C" stroke="#08090C" stroke-width="5.8"/><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="none" stroke="url(#fp1)" stroke-opacity=".25" stroke-width="6.46"/><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="url(#fp1)" fill-opacity="0.14" stroke="url(#fp1)" stroke-width="3.4"/></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 690 KiB

After

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.
+4 -4
View File
@@ -5,7 +5,7 @@
# docs/SHOWCASE.md ("Videos").
output: docs/media/frameport-install.mp4
budget_mb: 12
budget_mb: 20
poster_at: 9
start:
profile: fresh # FramePort's first start
@@ -24,7 +24,7 @@ title:
end:
heading: You're set
line: Your game is in the Frame's Steam library. More help at frameport.app/docs/install/
footer: github.com/spoopyghosty0/frameport
footer: frameport.app
seconds: 4.0
scenes:
@@ -34,8 +34,8 @@ scenes:
eyebrow: "Step 1 · On your PC"
heading: Download FramePort
steps:
- "Open **github.com/spoopyghosty0/frameport** → **Releases** → the latest one"
- "Download the file for your PC: **FramePort-windows-x64.zip**, **FramePort-macos-arm64.zip** or **FramePort-linux-x64.tar.gz**"
- "Open **frameport.app/download**"
- "Download the file for your PC (the page picks it): **FramePort-windows-x64.zip**, **FramePort-macos-arm64.zip** or **FramePort-linux-x64.tar.gz**"
- "Unpack it anywhere. No installer or admin rights needed"
- name: first-start
+1 -1
View File
@@ -3,7 +3,7 @@
output: docs/media/frameport-tour.mp4
teaser: docs/images/tour-teaser.webp
budget_mb: 12
budget_mb: 20
start:
profile: demo # the library in use: games installed on the pretend Frame, BONELAB running
frame: connected
+5
View File
@@ -401,6 +401,11 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateViewConfigurationViews(XrInstance inst
(PFN_xrEnumerateViewConfigurationViews)lookup(instance, "xrEnumerateViewConfigurationViews");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
XrResult result = fn(instance, system, type, capacity, count, views);
static int runtime_logged;
if (XR_SUCCEEDED(result) && views && count && *count && capacity && !runtime_logged++)
// the runtime's own size, before scale (FramePort's agent reads it to keep games' saved resolutions in step)
LOG("runtime recommended eye size %ux%u (max %ux%u, scale %.2f)", views[0].recommendedImageRectWidth,
views[0].recommendedImageRectHeight, views[0].maxImageRectWidth, views[0].maxImageRectHeight, scale);
if (XR_SUCCEEDED(result) && views && count && scale != 1.0f) {
for (uint32_t i = 0; i < *count && i < capacity; ++i) {
uint32_t w = (uint32_t)(views[i].recommendedImageRectWidth * scale + 0.5f);
+1
View File
@@ -251,6 +251,7 @@ def build_bridge(tc: Path):
inc = openxr_include("bridge")
run([tc / "bin/clang++", "--target=aarch64-linux-android29", f"--sysroot={tc / 'sysroot'}", "-std=c++17", "-O2", "-g",
"-fPIC", f"-ffile-prefix-map={src}=native/vrapi-bridge", f"-ffile-prefix-map={CACHE}=native/.cache",
f"-ffile-prefix-map={CACHE.resolve()}=native/.cache", # a symlinked cache (worktrees)
"-fvisibility=hidden", "-fvisibility-inlines-hidden", "-Wall", "-Wextra",
"-Wno-missing-field-initializers", "-Werror=return-type", "-DXR_NO_PROTOTYPES", "-DXR_USE_PLATFORM_ANDROID",
"-DXR_USE_GRAPHICS_API_VULKAN", "-DXR_USE_GRAPHICS_API_OPENGL_ES", "-DXR_USE_TIMESPEC", f"-I{inc}", "-shared",
+132
View File
@@ -0,0 +1,132 @@
// SPDX-License-Identifier: GPL-3.0-only
// Pure helpers of the GL shim's per-game fixes (no GL calls; tests/test_blazerush.py builds them for the host).
//
// gl_highp_markers: vertex shaders whose source contains every '|'-separated marker get their calculation precision
// raised: whole-word "mediump" becomes "highp" on every line that has none of the words in / out / uniform, so the
// shader's interface (inputs, outputs to the fragment shader, uniforms and their blocks' declarations) keeps its
// precision. BlazeRush's particle vertex shaders (markers "in uvec2 inBoneIndices;", "uniform InstanceData",
// "void main()") computed with large encoded values at mediump and drew big coloured streaks and flashes on the
// Frame's GPU (Klownicle's guide, GitHub #57).
// gl_int_attribs: the attribute type helpers for the integer vertex-attribute fix in glshim.c.
#pragma once
#include <stdlib.h>
#include <string.h>
static inline int glfix_ident(char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
}
// `word` as a whole word in s[0..n)
static inline int glfix_has_word(const char *s, size_t n, const char *word) {
size_t w = strlen(word);
for (size_t i = 0; i + w <= n; ++i)
if (!memcmp(s + i, word, w) && (i == 0 || !glfix_ident(s[i - 1])) && (i + w == n || !glfix_ident(s[i + w])))
return 1;
return 0;
}
// every non-empty '|'-separated marker occurs in src (and there is at least one)
static inline int glfix_markers_match(const char *src, const char *markers) {
if (!src || !markers || !*markers) return 0;
int any = 0;
char piece[256];
for (const char *p = markers; *p;) {
const char *bar = strchr(p, '|');
size_t n = bar ? (size_t)(bar - p) : strlen(p);
if (n >= sizeof(piece)) return 0;
if (n) {
memcpy(piece, p, n);
piece[n] = 0;
if (!strstr(src, piece)) return 0;
any = 1;
}
p += n + (bar ? 1 : 0);
}
return any;
}
// A copy of src with the calculation precision raised (see above), or NULL when nothing changed. *count = number of
// "mediump" replaced.
static inline char *glfix_promote_highp(const char *src, int *count) {
if (count) *count = 0;
if (!src) return NULL;
size_t len = strlen(src);
char *out = malloc(len + 1);
if (!out) return NULL;
size_t o = 0;
int n = 0;
for (const char *line = src; *line;) {
const char *end = strchr(line, '\n');
size_t ll = end ? (size_t)(end - line + 1) : strlen(line);
int interface = glfix_has_word(line, ll, "in") || glfix_has_word(line, ll, "out") ||
glfix_has_word(line, ll, "uniform");
for (size_t i = 0; i < ll;) {
if (!interface && i + 7 <= ll && !memcmp(line + i, "mediump", 7) && (i == 0 || !glfix_ident(line[i - 1])) &&
(i + 7 == ll || !glfix_ident(line[i + 7]))) {
memcpy(out + o, "highp", 5);
o += 5;
i += 7;
++n;
} else {
out[o++] = line[i++];
}
}
line += ll;
}
out[o] = 0;
if (!n) {
free(out);
return NULL;
}
if (count) *count = n;
return out;
}
// GL enums (numeric, so this header needs no GL headers)
#define GLFIX_BYTE 0x1400
#define GLFIX_UNSIGNED_BYTE 0x1401
#define GLFIX_SHORT 0x1402
#define GLFIX_UNSIGNED_SHORT 0x1403
#define GLFIX_INT 0x1404
#define GLFIX_UNSIGNED_INT 0x1405
#define GLFIX_INT_VEC2 0x8B53
#define GLFIX_INT_VEC3 0x8B54
#define GLFIX_INT_VEC4 0x8B55
#define GLFIX_UNSIGNED_INT_VEC2 0x8DC6
#define GLFIX_UNSIGNED_INT_VEC3 0x8DC7
#define GLFIX_UNSIGNED_INT_VEC4 0x8DC8
#define GLFIX_FLOAT_MAT2 0x8B5A
#define GLFIX_FLOAT_MAT3 0x8B5B
#define GLFIX_FLOAT_MAT4 0x8B5C
#define GLFIX_FLOAT_MAT2x3 0x8B65
#define GLFIX_FLOAT_MAT2x4 0x8B66
#define GLFIX_FLOAT_MAT3x2 0x8B67
#define GLFIX_FLOAT_MAT3x4 0x8B68
#define GLFIX_FLOAT_MAT4x2 0x8B69
#define GLFIX_FLOAT_MAT4x3 0x8B6A
// an active attribute's GLSL type: 1 signed integer (int, ivecN), 2 unsigned (uint, uvecN), 0 floating point
static inline int glfix_int_attrib_kind(unsigned type) {
switch (type) {
case GLFIX_INT: case GLFIX_INT_VEC2: case GLFIX_INT_VEC3: case GLFIX_INT_VEC4: return 1;
case GLFIX_UNSIGNED_INT: case GLFIX_UNSIGNED_INT_VEC2: case GLFIX_UNSIGNED_INT_VEC3:
case GLFIX_UNSIGNED_INT_VEC4: return 2;
default: return 0;
}
}
// attribute locations an active attribute uses per array element (matrices take one per column)
static inline int glfix_attrib_columns(unsigned type) {
switch (type) {
case GLFIX_FLOAT_MAT2: case GLFIX_FLOAT_MAT2x3: case GLFIX_FLOAT_MAT2x4: return 2;
case GLFIX_FLOAT_MAT3: case GLFIX_FLOAT_MAT3x2: case GLFIX_FLOAT_MAT3x4: return 3;
case GLFIX_FLOAT_MAT4: case GLFIX_FLOAT_MAT4x2: case GLFIX_FLOAT_MAT4x3: return 4;
default: return 1;
}
}
// vertex data glVertexAttribIPointer accepts (integer component types only)
static inline int glfix_int_component_type(unsigned type) {
return type == GLFIX_BYTE || type == GLFIX_UNSIGNED_BYTE || type == GLFIX_SHORT || type == GLFIX_UNSIGNED_SHORT ||
type == GLFIX_INT || type == GLFIX_UNSIGNED_INT;
}
+376 -3
View File
@@ -12,6 +12,12 @@
#include <string.h>
#define LOG(...) __android_log_print(ANDROID_LOG_INFO, "GLShim", __VA_ARGS__)
#define EXPORT __attribute__((visibility("default")))
#ifndef GLSHIM_GLES_LIB // (the host test points these at a stand-in GL library)
#define GLSHIM_GLES_LIB "libGLESv3.so"
#define GLSHIM_EGL_LIB "libEGL.so"
#endif
#include "glfix.h"
static const char *const hidden_multiview[] = {
"GL_OVR_multiview", "GL_OVR_multiview2", "GL_OVR_multiview_multisampled_render_to_texture",
@@ -37,14 +43,25 @@ static int hide_msrtt = 1;
// 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;
// Per-game fixes (off unless a recipe sets them; see glfix.h): integer vertex attributes fed as floats are rebound as
// integers before each draw (gl_int_attribs), and vertex shaders containing every marker get highp calculations
// (gl_highp_markers).
static int int_attribs;
static char highp_markers[512];
static void forget_program(GLuint prog);
static void read_conf_file(const char *path) {
FILE *f = fopen(path, "r");
if (!f) return;
char line[256];
char line[1024];
while (fgets(line, sizeof(line), f)) {
if (!strncmp(line, "gl_hide_multiview=", 18)) hide_multiview = atoi(line + 18);
if (!strncmp(line, "gl_hide_msrtt=", 14)) hide_msrtt = atoi(line + 14);
if (!strncmp(line, "gl_int_attribs=", 15)) int_attribs = atoi(line + 15);
if (!strncmp(line, "gl_highp_markers=", 17)) {
snprintf(highp_markers, sizeof(highp_markers), "%s", line + 17);
highp_markers[strcspn(highp_markers, "\r\n")] = 0;
}
}
fclose(f);
}
@@ -213,6 +230,7 @@ static void w_compile(GLuint sh) {
}
static void w_link(GLuint prog) {
r_link(prog);
forget_program(prog);
GLint ok = 1;
r_getProgramiv(prog, GL_LINK_STATUS, &ok);
if (!ok && shader_logs < 40) {
@@ -263,6 +281,231 @@ static void w_blit(GLint a, GLint b, GLint c, GLint d, GLint e, GLint f, GLint g
}
}
// ---- per-game fixes: the real functions (from libGLESv3 / libEGL, whatever the game imports or looks up)
static struct {
void (*ShaderSource)(GLuint, GLsizei, const GLchar *const *, const GLint *);
void (*GetShaderiv)(GLuint, GLenum, GLint *);
void (*LinkProgram)(GLuint);
void (*ProgramBinary)(GLuint, GLenum, const void *, GLsizei);
void (*DeleteProgram)(GLuint);
void (*GetProgramiv)(GLuint, GLenum, GLint *);
void (*GetActiveAttrib)(GLuint, GLuint, GLsizei, GLsizei *, GLint *, GLenum *, GLchar *);
GLint (*GetAttribLocation)(GLuint, const GLchar *);
void (*GetVertexAttribiv)(GLuint, GLenum, GLint *);
void (*GetVertexAttribPointerv)(GLuint, GLenum, void **);
void (*VertexAttribPointer)(GLuint, GLint, GLenum, GLboolean, GLsizei, const void *);
void (*VertexAttribIPointer)(GLuint, GLint, GLenum, GLsizei, const void *);
void (*VertexAttribI4i)(GLuint, GLint, GLint, GLint, GLint);
void (*VertexAttribI4ui)(GLuint, GLuint, GLuint, GLuint, GLuint);
void (*BindBuffer)(GLenum, GLuint);
void (*DrawArrays)(GLenum, GLint, GLsizei);
void (*DrawElements)(GLenum, GLsizei, GLenum, const void *);
void (*DrawArraysInstanced)(GLenum, GLint, GLsizei, GLsizei);
void (*DrawElementsInstanced)(GLenum, GLsizei, GLenum, const void *, GLsizei);
void (*DrawRangeElements)(GLenum, GLuint, GLuint, GLsizei, GLenum, const void *);
EGLContext (*GetCurrentContext)(void);
} d;
// gl_highp_markers: a copy of a matching vertex shader's source with highp calculations, else NULL
static int highp_logs;
static char *precision_fix(GLuint sh, const char *src) {
if (!highp_markers[0] || !glfix_markers_match(src, highp_markers)) return NULL;
GLint type = 0;
if (d.GetShaderiv) d.GetShaderiv(sh, GL_SHADER_TYPE, &type);
if (type != GL_VERTEX_SHADER) return NULL;
int n = 0;
char *out = glfix_promote_highp(src, &n);
if (out && highp_logs < 16) { ++highp_logs; LOG("highp: vertex shader %u: %d mediump -> highp", sh, n); }
return out;
}
static char *concat_sources(GLsizei count, const GLchar *const *strs, const GLint *lens) {
size_t total = 0;
for (GLsizei i = 0; i < count; ++i) total += lens && lens[i] >= 0 ? (size_t)lens[i] : strlen(strs[i]);
char *src = malloc(total + 1);
if (!src) return NULL;
size_t off = 0;
for (GLsizei i = 0; i < count; ++i) {
size_t n = lens && lens[i] >= 0 ? (size_t)lens[i] : strlen(strs[i]);
memcpy(src + off, strs[i], n);
off += n;
}
src[off] = 0;
return src;
}
// gl_int_attribs: the shader declares an integer input (int/uint/ivecN/uvecN), the game set its array with
// glVertexAttribPointer (floats; GL_VERTEX_ATTRIB_ARRAY_INTEGER 0). Qualcomm's Quest driver read the raw integers
// anyway; Mesa (Zink) converts them to floats, so e.g. BlazeRush's bone indices were garbage and its car bodies and
// controller models invisible (Klownicle's guide, GitHub #57). Before each draw, such an array is set again with
// glVertexAttribIPointer (same buffer, size, component type, stride and offset; the game's GL_ARRAY_BUFFER binding is
// restored), and set back to floats (with the game's normalization) when a program with a float input at that
// location draws from the same vertex array state. A disabled integer input gets the integer default (0, 0, 0, 1).
// The active-attribute reflection is cached per EGL context and program, and dropped when a program is linked,
// loaded from a binary or deleted (program names are reused).
#define MAXLOC 32
#define NPROG 1024
#define PROBE 16
#define NCONV 256
#define NCTX 16
struct proginfo { EGLContext ctx; GLuint prog; uint32_t int_mask, unsigned_mask, float_mask; };
struct convrec { EGLContext ctx; GLuint vao, buffer; int loc, used; GLint size, stride; GLenum type; const void *ptr;
GLboolean normalized; };
struct ctxstate { EGLContext ctx; uint32_t converted; };
static struct proginfo progs[NPROG];
static struct convrec convs[NCONV];
static struct ctxstate ctxs[NCTX];
static unsigned conv_next, ctx_next;
static pthread_mutex_t attrib_lock = PTHREAD_MUTEX_INITIALIZER;
static int attrib_logs, reflect_logs, skip_logs;
static unsigned prog_slot(EGLContext ctx, GLuint prog) {
uint64_t h = (uint64_t)(uintptr_t)ctx * 0x9E3779B97F4A7C15ull ^ (uint64_t)prog * 0x85EBCA6Bu;
return (unsigned)((h ^ (h >> 29)) % NPROG);
}
static void forget_program(GLuint prog) { // every context's entry: shared contexts see the same program name
if (!prog || !int_attribs) return;
pthread_mutex_lock(&attrib_lock);
for (unsigned i = 0; i < NPROG; ++i)
if (progs[i].prog == prog) progs[i] = (struct proginfo){0};
pthread_mutex_unlock(&attrib_lock);
}
static void reflect(struct proginfo *pi, GLuint prog) {
GLint linked = 0, n = 0;
d.GetProgramiv(prog, GL_LINK_STATUS, &linked);
if (!linked) return;
d.GetProgramiv(prog, GL_ACTIVE_ATTRIBUTES, &n);
for (GLint i = 0; i < n; ++i) {
char name[128] = {0};
GLsizei len = 0;
GLint size = 0;
GLenum type = 0;
d.GetActiveAttrib(prog, (GLuint)i, sizeof(name), &len, &size, &type, name);
GLint loc = name[0] ? d.GetAttribLocation(prog, name) : -1;
if (loc < 0) continue; // built-ins (gl_VertexID ...)
int kind = glfix_int_attrib_kind(type), cols = glfix_attrib_columns(type);
for (int k = 0; k < (size > 0 ? size : 1) * cols && loc + k < MAXLOC; ++k) {
uint32_t bit = 1u << (loc + k);
if (kind) pi->int_mask |= bit; else pi->float_mask |= bit;
if (kind == 2) pi->unsigned_mask |= bit;
}
if (kind && reflect_logs < 16) {
++reflect_logs;
LOG("int attribs: program %u has integer input %s (%s) at location %d", prog, name,
kind == 2 ? "unsigned" : "signed", loc);
}
}
}
static struct proginfo *program_info(EGLContext ctx, GLuint prog) {
unsigned h = prog_slot(ctx, prog), free_slot = NPROG;
for (unsigned k = 0; k < PROBE; ++k) {
struct proginfo *e = &progs[(h + k) % NPROG];
if (e->prog == prog && e->ctx == ctx) return e;
if (!e->prog && free_slot == NPROG) free_slot = (h + k) % NPROG;
}
struct proginfo *e = &progs[free_slot < NPROG ? free_slot : h];
*e = (struct proginfo){.ctx = ctx, .prog = prog};
reflect(e, prog);
return e;
}
static struct ctxstate *ctx_state(EGLContext ctx) {
for (unsigned i = 0; i < NCTX; ++i)
if (ctxs[i].ctx == ctx) return &ctxs[i];
struct ctxstate *c = &ctxs[ctx_next++ % NCTX];
*c = (struct ctxstate){.ctx = ctx};
return c;
}
static struct convrec *find_conv(EGLContext ctx, GLuint vao, int loc) {
for (unsigned i = 0; i < NCONV; ++i)
if (convs[i].used && convs[i].ctx == ctx && convs[i].vao == vao && convs[i].loc == loc) return &convs[i];
return NULL;
}
static void rebind(GLuint loc, GLuint buffer, GLint size, GLenum type, GLboolean normalized, GLsizei stride,
const void *ptr, int integer) {
GLint previous = 0;
real_glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &previous);
d.BindBuffer(GL_ARRAY_BUFFER, buffer);
if (integer) d.VertexAttribIPointer(loc, size, type, stride, ptr);
else d.VertexAttribPointer(loc, size, type, normalized, stride, ptr);
d.BindBuffer(GL_ARRAY_BUFFER, (GLuint)previous);
}
static void fix_attribs(void) {
if (!int_attribs || !real_glGetIntegerv || !d.GetVertexAttribiv || !d.GetVertexAttribPointerv || !d.BindBuffer ||
!d.VertexAttribIPointer || !d.VertexAttribPointer || !d.GetProgramiv || !d.GetActiveAttrib ||
!d.GetAttribLocation)
return;
GLint prog = 0;
real_glGetIntegerv(GL_CURRENT_PROGRAM, &prog);
if (prog <= 0) return;
EGLContext ctx = d.GetCurrentContext ? d.GetCurrentContext() : NULL;
pthread_mutex_lock(&attrib_lock);
struct proginfo *pi = program_info(ctx, (GLuint)prog);
struct ctxstate *cs = ctx_state(ctx);
uint32_t check = pi->int_mask | (pi->float_mask & cs->converted);
if (!check) { pthread_mutex_unlock(&attrib_lock); return; }
GLint vao = 0;
real_glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &vao);
for (int loc = 0; loc < MAXLOC; ++loc) {
uint32_t bit = 1u << loc;
if (!(check & bit)) continue;
int want_int = (pi->int_mask & bit) != 0;
GLint enabled = 0, integer = 0;
d.GetVertexAttribiv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_ENABLED, &enabled);
if (!enabled) {
if (want_int && (pi->unsigned_mask & bit) && d.VertexAttribI4ui) d.VertexAttribI4ui((GLuint)loc, 0, 0, 0, 1);
else if (want_int && d.VertexAttribI4i) d.VertexAttribI4i((GLuint)loc, 0, 0, 0, 1);
continue;
}
d.GetVertexAttribiv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_INTEGER, &integer);
if (want_int == (integer != 0)) continue;
GLint buffer = 0, size = 0, type = 0, stride = 0, normalized = 0;
void *ptr = NULL;
d.GetVertexAttribiv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_BUFFER_BINDING, &buffer);
d.GetVertexAttribiv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_SIZE, &size);
d.GetVertexAttribiv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_TYPE, &type);
d.GetVertexAttribiv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_STRIDE, &stride);
d.GetVertexAttribiv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_NORMALIZED, &normalized);
d.GetVertexAttribPointerv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_POINTER, &ptr);
if (vao && !buffer) continue; // client-side arrays only exist on the default vertex array
if (want_int) {
if (!glfix_int_component_type((unsigned)type)) {
if (skip_logs < 8) {
++skip_logs;
LOG("int attribs: program %d location %d has data of type 0x%x, left as it is", prog, loc, type);
}
continue;
}
rebind((GLuint)loc, (GLuint)buffer, size, (GLenum)type, (GLboolean)normalized, stride, ptr, 1);
struct convrec *r = find_conv(ctx, (GLuint)vao, loc);
if (!r) r = &convs[conv_next++ % NCONV];
*r = (struct convrec){ctx, (GLuint)vao, (GLuint)buffer, loc, 1, size, stride, (GLenum)type, ptr,
(GLboolean)normalized};
cs->converted |= bit;
if (attrib_logs < 24) {
++attrib_logs;
LOG("int attribs: program %d location %d: floats -> glVertexAttribIPointer (vao %d buffer %d size %d "
"type 0x%x stride %d offset %p)", prog, loc, vao, buffer, size, type, stride, ptr);
}
} else {
struct convrec *r = find_conv(ctx, (GLuint)vao, loc);
if (!r || r->buffer != (GLuint)buffer || r->size != size || r->type != (GLenum)type || r->stride != stride ||
r->ptr != ptr)
continue; // the game set it up as integers itself
rebind((GLuint)loc, (GLuint)buffer, size, (GLenum)type, r->normalized, stride, ptr, 0);
r->used = 0;
if (attrib_logs < 24) { ++attrib_logs; LOG("int attribs: program %d location %d: back to floats", prog, loc); }
}
}
pthread_mutex_unlock(&attrib_lock);
}
// Mesa's GLSL preprocessor rejects "#extension" after "#pragma" lines ("not allowed in the middle of a shader");
// Quest drivers accept it. Neutralise #pragma lines (optimize/debug hints only) as sources are uploaded.
typedef void (*PFN_SS)(GLuint, GLsizei, const GLchar *const *, const GLint *);
@@ -323,6 +566,8 @@ static void w_shaderSource(GLuint sh, GLsizei count, const GLchar *const *strs,
}
}
}
char *highp = precision_fix(sh, src);
if (highp) { free(src); src = highp; }
const GLchar *one = src;
r_shaderSourceSet(sh, 1, &one, NULL);
free(src);
@@ -334,12 +579,24 @@ __attribute__((constructor)) static void shim_ctor(void) {
static void init(void) {
read_conf();
gles = dlopen("libGLESv3.so", RTLD_NOW | RTLD_LOCAL);
void *egl = dlopen("libEGL.so", RTLD_NOW | RTLD_LOCAL);
gles = dlopen(GLSHIM_GLES_LIB, RTLD_NOW | RTLD_LOCAL);
void *egl = dlopen(GLSHIM_EGL_LIB, RTLD_NOW | RTLD_LOCAL);
real_glGetString = gles ? dlsym(gles, "glGetString") : NULL;
real_glGetStringi = gles ? dlsym(gles, "glGetStringi") : NULL;
real_glGetIntegerv = gles ? dlsym(gles, "glGetIntegerv") : NULL;
real_eglGetProcAddress = egl ? dlsym(egl, "eglGetProcAddress") : NULL;
#define REAL(field, sym) d.field = gles ? dlsym(gles, sym) : NULL;
REAL(ShaderSource, "glShaderSource") REAL(GetShaderiv, "glGetShaderiv") REAL(LinkProgram, "glLinkProgram")
REAL(ProgramBinary, "glProgramBinary") REAL(DeleteProgram, "glDeleteProgram") REAL(GetProgramiv, "glGetProgramiv")
REAL(GetActiveAttrib, "glGetActiveAttrib") REAL(GetAttribLocation, "glGetAttribLocation")
REAL(GetVertexAttribiv, "glGetVertexAttribiv") REAL(GetVertexAttribPointerv, "glGetVertexAttribPointerv")
REAL(VertexAttribPointer, "glVertexAttribPointer") REAL(VertexAttribIPointer, "glVertexAttribIPointer")
REAL(VertexAttribI4i, "glVertexAttribI4i") REAL(VertexAttribI4ui, "glVertexAttribI4ui")
REAL(BindBuffer, "glBindBuffer") REAL(DrawArrays, "glDrawArrays") REAL(DrawElements, "glDrawElements")
REAL(DrawArraysInstanced, "glDrawArraysInstanced") REAL(DrawElementsInstanced, "glDrawElementsInstanced")
REAL(DrawRangeElements, "glDrawRangeElements")
#undef REAL
d.GetCurrentContext = egl ? dlsym(egl, "eglGetCurrentContext") : NULL;
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++) {
@@ -355,6 +612,9 @@ static void init(void) {
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)" : "");
if (int_attribs || highp_markers[0])
LOG("GL shim per-game fixes: gl_int_attribs=%d gl_highp_markers=%s", int_attribs,
highp_markers[0] ? highp_markers : "(none)");
}
static int is_hidden(const char *name) {
@@ -440,6 +700,100 @@ static void plain_ftmsmv(GLenum target, GLenum attachment, GLuint texture, GLint
if (r_plain_ftmv) r_plain_ftmv(target, attachment, texture, level, base, views);
}
// gl_int_attribs: draws looked up through eglGetProcAddress (wrapped only when the setting is on)
typedef void (*PFN_PB)(GLuint, GLenum, const void *, GLsizei);
typedef void (*PFN_DEBV)(GLenum, GLsizei, GLenum, const void *, GLint);
typedef void (*PFN_DREBV)(GLenum, GLuint, GLuint, GLsizei, GLenum, const void *, GLint);
typedef void (*PFN_DEIBV)(GLenum, GLsizei, GLenum, const void *, GLsizei, GLint);
static PFN_PB e_pb, e_pboes;
static PFN_V_U e_dp;
static PFN_DA e_da;
static PFN_DE e_de;
static PFN_DAI e_dai;
static PFN_DEI e_dei;
static PFN_DRE e_dre;
static PFN_DEBV e_debv0, e_debv1, e_debv2;
static PFN_DREBV e_drebv0, e_drebv1, e_drebv2;
static PFN_DEIBV e_deibv0, e_deibv1, e_deibv2;
static void x_pb(GLuint p, GLenum f, const void *b, GLsizei n) { e_pb(p, f, b, n); forget_program(p); }
static void x_pboes(GLuint p, GLenum f, const void *b, GLsizei n) { e_pboes(p, f, b, n); forget_program(p); }
static void x_dp(GLuint p) { e_dp(p); forget_program(p); }
static void x_da(GLenum m, GLint f, GLsizei c) { fix_attribs(); e_da(m, f, c); }
static void x_de(GLenum m, GLsizei c, GLenum t, const void *i) { fix_attribs(); e_de(m, c, t, i); }
static void x_dai(GLenum m, GLint f, GLsizei c, GLsizei n) { fix_attribs(); e_dai(m, f, c, n); }
static void x_dei(GLenum m, GLsizei c, GLenum t, const void *i, GLsizei n) { fix_attribs(); e_dei(m, c, t, i, n); }
static void x_dre(GLenum m, GLuint s, GLuint e, GLsizei c, GLenum t, const void *i) {
fix_attribs();
e_dre(m, s, e, c, t, i);
}
#define BV(n) \
static void x_debv##n(GLenum m, GLsizei c, GLenum t, const void *i, GLint b) { fix_attribs(); e_debv##n(m, c, t, i, b); } \
static void x_drebv##n(GLenum m, GLuint s, GLuint e, GLsizei c, GLenum t, const void *i, GLint b) { \
fix_attribs(); e_drebv##n(m, s, e, c, t, i, b); } \
static void x_deibv##n(GLenum m, GLsizei c, GLenum t, const void *i, GLsizei k, GLint b) { \
fix_attribs(); e_deibv##n(m, c, t, i, k, b); }
BV(0) BV(1) BV(2)
#undef BV
// Direct imports of a game library that loads the shim first (DT_NEEDED): these come before libGLESv3's. Plain
// pass-through unless a per-game fix is on.
EXPORT void glShaderSource(GLuint sh, GLsizei count, const GLchar *const *strs, const GLint *lens) {
pthread_once(&once, init);
if (!d.ShaderSource) return;
if (highp_markers[0] && count > 0 && strs) {
char *src = concat_sources(count, strs, lens);
char *highp = src ? precision_fix(sh, src) : NULL;
free(src);
if (highp) {
const GLchar *one = highp;
d.ShaderSource(sh, 1, &one, NULL);
free(highp);
return;
}
}
d.ShaderSource(sh, count, strs, lens);
}
EXPORT void glLinkProgram(GLuint p) {
pthread_once(&once, init);
if (d.LinkProgram) d.LinkProgram(p);
forget_program(p);
}
EXPORT void glProgramBinary(GLuint p, GLenum f, const void *b, GLsizei n) {
pthread_once(&once, init);
if (d.ProgramBinary) d.ProgramBinary(p, f, b, n);
forget_program(p);
}
EXPORT void glDeleteProgram(GLuint p) {
pthread_once(&once, init);
if (d.DeleteProgram) d.DeleteProgram(p);
forget_program(p);
}
EXPORT void glDrawArrays(GLenum m, GLint f, GLsizei c) {
pthread_once(&once, init);
fix_attribs();
if (d.DrawArrays) d.DrawArrays(m, f, c);
}
EXPORT void glDrawElements(GLenum m, GLsizei c, GLenum t, const void *i) {
pthread_once(&once, init);
fix_attribs();
if (d.DrawElements) d.DrawElements(m, c, t, i);
}
EXPORT void glDrawArraysInstanced(GLenum m, GLint f, GLsizei c, GLsizei n) {
pthread_once(&once, init);
fix_attribs();
if (d.DrawArraysInstanced) d.DrawArraysInstanced(m, f, c, n);
}
EXPORT void glDrawElementsInstanced(GLenum m, GLsizei c, GLenum t, const void *i, GLsizei n) {
pthread_once(&once, init);
fix_attribs();
if (d.DrawElementsInstanced) d.DrawElementsInstanced(m, c, t, i, n);
}
EXPORT void glDrawRangeElements(GLenum m, GLuint s, GLuint e, GLsizei c, GLenum t, const void *i) {
pthread_once(&once, init);
fix_attribs();
if (d.DrawRangeElements) d.DrawRangeElements(m, s, e, c, t, i);
}
__attribute__((visibility("default"))) __eglMustCastToProperFunctionPointerType eglGetProcAddress(const char *name) {
pthread_once(&once, init);
if (name && !strcmp(name, "glGetStringi")) return (__eglMustCastToProperFunctionPointerType)glGetStringi;
@@ -477,6 +831,25 @@ __attribute__((visibility("default"))) __eglMustCastToProperFunctionPointerType
WRAP("glShaderSource", r_shaderSourceSet, w_shaderSource)
WRAP("glCompileShader", r_compile, w_compile)
WRAP("glLinkProgram", r_link, w_link)
if (int_attribs) {
WRAP("glProgramBinary", e_pb, x_pb)
WRAP("glProgramBinaryOES", e_pboes, x_pboes)
WRAP("glDeleteProgram", e_dp, x_dp)
WRAP("glDrawArrays", e_da, x_da)
WRAP("glDrawElements", e_de, x_de)
WRAP("glDrawArraysInstanced", e_dai, x_dai)
WRAP("glDrawElementsInstanced", e_dei, x_dei)
WRAP("glDrawRangeElements", e_dre, x_dre)
WRAP("glDrawElementsBaseVertex", e_debv0, x_debv0)
WRAP("glDrawElementsBaseVertexEXT", e_debv1, x_debv1)
WRAP("glDrawElementsBaseVertexOES", e_debv2, x_debv2)
WRAP("glDrawRangeElementsBaseVertex", e_drebv0, x_drebv0)
WRAP("glDrawRangeElementsBaseVertexEXT", e_drebv1, x_drebv1)
WRAP("glDrawRangeElementsBaseVertexOES", e_drebv2, x_drebv2)
WRAP("glDrawElementsInstancedBaseVertex", e_deibv0, x_deibv0)
WRAP("glDrawElementsInstancedBaseVertexEXT", e_deibv1, x_deibv1)
WRAP("glDrawElementsInstancedBaseVertexOES", e_deibv2, x_deibv2)
}
#ifdef GLSHIM_TRACE
WRAP("glBindFramebuffer", r_bindFb, w_bindFb)
WRAP("glFramebufferTexture2D", r_ft2d, w_ft2d)
+55
View File
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: GPL-3.0-only
// FramePort: the VrApi FOV properties (7/8) before tracking starts.
//
// VrApi games may read the FOV right after vrapi_Initialize and cache it (BlazeRush builds its projection and its
// TexCoordsFromTanAngles matrix from it once). OpenXR has no FOV before a session exists unless the runtime offers
// XR_EPIC_view_configuration_fov, which the Steam Frame's doesn't, so the bridge answered 90 x 90 degrees and those
// games kept a narrow, square view. The bridge now saves the live FOV of each session (it is a property of the
// headset, not of the game's state) in the app's external files folder and answers with it the next time the
// properties are asked for before a session has located its views. The headset's FOV is never hardcoded: the first
// session on a Frame still starts at 90 degrees (FramePort's launch test after an install is that session).
// Plain functions, no Android or OpenXR types: tests/test_blazerush.py builds them for the host.
#pragma once
#include <cmath>
#include <cstdio>
#include <cstring>
namespace ovp {
namespace fovcache {
constexpr const char* FileName = "framebridge-vrapi-fov.txt";
// symmetric coverage angles a VR headset can have (the Frame reports about 118 x 120)
inline bool plausible(float horizontal, float vertical) {
return std::isfinite(horizontal) && std::isfinite(vertical) && horizontal >= 60.0f && horizontal <= 170.0f &&
vertical >= 60.0f && vertical <= 170.0f;
}
// "fov_h=118.14\nfov_v=119.55\n" (other lines ignored); false unless both are there and plausible
inline bool parse(const char* text, float out[2]) {
float h = NAN, v = NAN;
for (const char* line = text; line && *line; line = std::strchr(line, '\n'), line = line ? line + 1 : nullptr) {
float value = 0;
if (std::sscanf(line, "fov_h=%f", &value) == 1) h = value;
else if (std::sscanf(line, "fov_v=%f", &value) == 1) v = value;
}
if (!plausible(h, v)) return false;
out[0] = h;
out[1] = v;
return true;
}
inline int format(char* buffer, size_t size, const float fov[2]) {
return std::snprintf(buffer, size, "fov_h=%.3f\nfov_v=%.3f\n", fov[0], fov[1]);
}
// worth rewriting the saved value: none saved yet, or a change of more than 0.01 degrees on an axis
inline bool changed(bool haveSaved, const float saved[2], const float now[2]) {
return !haveSaved || std::fabs(saved[0] - now[0]) > 0.01f || std::fabs(saved[1] - now[1]) > 0.01f;
}
// the external files folder's file for the package in /proc/self/cmdline ("pkg" or "pkg:process"); false if unknown
inline bool path(const char* cmdline, char* buffer, size_t size) {
char package[256] = {0};
std::strncpy(package, cmdline ? cmdline : "", sizeof(package) - 1);
if (char* colon = std::strchr(package, ':')) *colon = 0;
if (!*package || std::strchr(package, '/') || std::strstr(package, "..")) return false;
const int n = std::snprintf(buffer, size, "/sdcard/Android/data/%s/files/%s", package, FileName);
return n > 0 && static_cast<size_t>(n) < size;
}
} // namespace fovcache
} // namespace ovp
+81 -4
View File
@@ -1,8 +1,10 @@
// SPDX-License-Identifier: GPL-3.0-only
#include "runtime.h"
#include "fov_cache.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <dlfcn.h>
#include <thread>
@@ -45,6 +47,49 @@ static bool load(const char* name, PFN_xrVoidFunction* target) {
auto& s = runtime();
return xrOk(s.xr.xrGetInstanceProcAddr(s.instance, name, target), name) && *target;
}
// FramePort: the live FOV of earlier sessions for games that read it before any session (see fov_cache.h)
static bool savedFovPath(char* buffer, size_t size) {
char cmdline[256] = {0};
if (FILE* f = std::fopen("/proc/self/cmdline", "r")) {
cmdline[std::fread(cmdline, 1, sizeof(cmdline) - 1, f)] = 0;
std::fclose(f);
}
return fovcache::path(cmdline, buffer, size);
}
static void loadSavedFov() {
auto& s = runtime();
char path[512], text[256] = {0};
if (!savedFovPath(path, sizeof(path))) return;
FILE* f = std::fopen(path, "r");
if (!f) return;
text[std::fread(text, 1, sizeof(text) - 1, f)] = 0;
std::fclose(f);
float fov[2];
if (!fovcache::parse(text, fov)) return;
s.savedFovDegrees = {fov[0], fov[1]};
s.savedFovValid = true;
OVP_LOG("FOV of an earlier session: %.2f x %.2f degrees", fov[0], fov[1]);
}
static void saveLiveFov() {
auto& s = runtime();
const float now[2] = {s.locatedFovDegrees[0], s.locatedFovDegrees[1]};
const float saved[2] = {s.savedFovDegrees[0], s.savedFovDegrees[1]};
if (!s.locatedFovValid || !fovcache::plausible(now[0], now[1]) || !fovcache::changed(s.savedFovValid, saved, now))
return;
s.savedFovDegrees = {now[0], now[1]}; // also when it can't be written: tried once per change, not every frame
s.savedFovValid = true;
char path[512], tmp[540], text[96];
if (!savedFovPath(path, sizeof(path))) return;
std::snprintf(tmp, sizeof(tmp), "%s.tmp", path);
FILE* f = std::fopen(tmp, "w");
if (!f) return;
const int n = fovcache::format(text, sizeof(text), now);
const bool ok = n > 0 && std::fwrite(text, 1, static_cast<size_t>(n), f) == static_cast<size_t>(n);
if (std::fclose(f) == 0 && ok && std::rename(tmp, path) == 0)
OVP_LOG("live FOV %.2f x %.2f degrees saved for the next start", now[0], now[1]);
else
std::remove(tmp);
}
static bool referenceSpace(XrReferenceSpaceType type, const XrPosef& pose, XrSpace& space) {
auto& s = runtime();
XrReferenceSpaceCreateInfo info{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
@@ -94,6 +139,9 @@ static void shutdown() {
s.locatedFovValid = false;
s.recommendedFovDegrees = {};
s.locatedFovDegrees = {};
s.savedFovValid = false;
s.savedFovDegrees = {};
s.loggedFovSource = -1;
s.recenterCount = 0;
s.loggedDisplayRefreshRateSource = DisplayRefreshRateSource::Unqueried;
s.loggedDisplayRefreshRate = 0;
@@ -284,6 +332,23 @@ bool beginFrame(int64_t index) {
// Steam Frame reports poses as VALID without the TRACKED bits here, so only validity is required.
constexpr XrSpaceLocationFlags tracked = XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | XR_SPACE_LOCATION_POSITION_VALID_BIT;
const bool visible = s.sessionState == XR_SESSION_STATE_VISIBLE || s.sessionState == XR_SESSION_STATE_FOCUSED;
if (!s.locatedFovValid) {
// The views' FOV doesn't depend on tracking: take it as soon as the session runs (also headless, without
// anyone wearing the headset), so it is saved for games that ask before their next session.
XrViewLocateInfo fovInfo{XR_TYPE_VIEW_LOCATE_INFO};
fovInfo.viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
fovInfo.displayTime = s.frame.predictedDisplayTime;
fovInfo.space = s.viewSpace;
XrViewState fovState{XR_TYPE_VIEW_STATE};
uint32_t fovCount = 0;
if (XR_SUCCEEDED(s.xr.xrLocateViews(s.session, &fovInfo, &fovState, 2, &fovCount, s.views.data())) &&
fovCount == 2 && symmetricFovDegrees(s.views[0].fov, s.views[1].fov, s.locatedFovDegrees)) {
s.locatedFovValid = true;
OVP_LOG("FOV located before tracking: %.2f x %.2f degrees (view state 0x%llx)", s.locatedFovDegrees[0],
s.locatedFovDegrees[1], (unsigned long long)fovState.viewStateFlags);
saveLiveFov();
}
}
const bool located = visible &&
xrOk(s.xr.xrLocateSpace(s.viewSpace, s.localSpace, s.frame.predictedDisplayTime, &head), "initial eye-level origin");
static int logged;
@@ -325,6 +390,7 @@ bool beginFrame(int64_t index) {
}
s.locatedFovValid = symmetricFovDegrees(
s.views[0].fov, s.views[1].fov, s.locatedFovDegrees);
saveLiveFov();
input::sync();
return true;
}
@@ -519,6 +585,7 @@ VRAPI int vrapi_Initialize(const ovrInitParms* parms) {
symmetricFovDegrees(s.viewConfigFov[0].recommendedFov,
s.viewConfigFov[1].recommendedFov,
s.recommendedFovDegrees);
if (!s.recommendedFovValid) loadSavedFov();
s.colorSpaceExtension = has(XR_FB_COLOR_SPACE_EXTENSION_NAME);
s.performanceExtension = has(XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME);
s.threadExtension = has(XR_KHR_ANDROID_THREAD_SETTINGS_EXTENSION_NAME);
@@ -680,10 +747,20 @@ VRAPI float vrapi_GetSystemPropertyFloat(const ovrJava*, int property) {
if (property == 4) return queryDisplayRefreshRate();
if (property == 7 || property == 8) {
const size_t axis = static_cast<size_t>(property - 7);
// Live optical FOV supersedes a provisional startup recommendation.
if (s.locatedFovValid) return s.locatedFovDegrees[axis];
if (s.recommendedFovValid) return s.recommendedFovDegrees[axis];
return 90.0f;
// Live optical FOV supersedes a provisional startup recommendation; before any session (no runtime
// recommendation): the live FOV saved by an earlier session, else 90 degrees.
static const char* const sources[] = {"live", "runtime recommendation", "saved by an earlier session",
"default, nothing known before tracking"};
int source = 3;
std::array<float, 2> fov{90.0f, 90.0f};
if (s.locatedFovValid) { source = 0; fov = s.locatedFovDegrees; }
else if (s.recommendedFovValid) { source = 1; fov = s.recommendedFovDegrees; }
else if (s.savedFovValid) { source = 2; fov = s.savedFovDegrees; }
if (source != s.loggedFovSource) {
s.loggedFovSource = source;
OVP_LOG("FOV properties: %.2f x %.2f degrees (%s)", fov[0], fov[1], sources[source]);
}
return fov[axis];
}
OVP_LOG("Unsupported float system property %d", property);
return 0;
+6
View File
@@ -60,6 +60,12 @@ struct Runtime {
std::array<XrViewConfigurationViewFovEPIC, 2> viewConfigFov{};
std::array<float, 2> recommendedFovDegrees{};
std::array<float, 2> locatedFovDegrees{};
// FramePort: the last live FOV of an earlier session (framebridge-vrapi-fov.txt in the app's external files).
// Games that read VrApi's FOV properties right after vrapi_Initialize (before any session, e.g. BlazeRush) and
// cache them get this instead of a 90 degree guess when the runtime has no XR_EPIC_view_configuration_fov.
std::array<float, 2> savedFovDegrees{};
bool savedFovValid = false;
int loggedFovSource = -1;
std::array<XrView, 2> views{{{XR_TYPE_VIEW}, {XR_TYPE_VIEW}}};
ovrSystemCreateInfoVulkan vk{};
// GLES apps (no vrapi_CreateSystemVulkan): EGL objects from ovrModeParms.
+2 -2
View File
@@ -25,7 +25,7 @@ LIST_FIELDS = {"overport_extra", "overport_remove", "alt_overport", "frame", "fr
"device"}
# FramePort's own patch ids must exist (OVRPort's are listed by its CLI at runtime, so not checked here)
OWN_PATCH_FIELDS = ("frame", "frame_remove", "pcvr", "pcvr_remove", "device")
DICT_FIELDS = {"adapter", "device_files", "lepton_env", "proton_env", "verified"}
DICT_FIELDS = {"adapter", "device_files", "config_sync", "lepton_env", "proton_env", "verified"}
ID_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,80}$")
@@ -77,7 +77,7 @@ def validate(d: dict) -> CatalogEntry:
for k in DICT_FIELDS & set(d):
if not isinstance(d[k], dict):
raise Invalid(f"{k} must be a mapping")
for k in ("device_files",):
for k in ("device_files", "config_sync"):
for path in (d.get(k) or {}):
if not isinstance(path, str) or path.startswith("/") or ".." in Path(path).parts:
raise Invalid(f"{k}: bad path {path!r}")
+393
View File
@@ -0,0 +1,393 @@
"""Generate docs/PATCHES.md (every patch and game setting) and site/src/data/patches.json (id -> anchor) from the
live patch registry, the bundled catalog and catalog/triage.yaml.
python scripts/patch_docs.py # rewrite both files
python scripts/patch_docs.py --check # exit 1 if either file is out of date (CI / tests)
Anchors: every entry starts with `<a id="<anchor>"></a>`, where the anchor is the patch id with "." replaced by "-"
(`frame.unity_text_input` -> `#frame-unity_text_input`, `patch_copy_libraries` stays as it is). GitHub resolves such
ids (it prefixes them with "user-content-" and follows plain `#anchor` links), and the site's docs renderer uses them
as the heading id. Nothing here runs a patch's detection: "suggested when" is read from the reasons in the source.
"""
from __future__ import annotations
import argparse
import ast
import inspect
import json
import re
import sys
import textwrap
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
import yaml # noqa: E402
from frameport.patches import base, settings # noqa: E402
DOC = ROOT / "docs" / "PATCHES.md"
SITE_MAP = ROOT / "site" / "src" / "data" / "patches.json"
HEADER = "<!-- generated by scripts/patch_docs.py — don't edit by hand -->"
MAX_GAMES = 8
# the game page's group names (ui/views/game.py CATEGORY_TITLES) plus what each group is
CATEGORIES = {
"overport": ("Patches from OVRPort",
"OVRPort converts a Quest game's Meta VR code to standard OpenXR; FramePort picks which of its "
"patches run. The list comes from the OVRPort CLI at runtime (new OVRPort patches appear in the app "
"on their own); this page shows FramePort's bundled list (`patches/overport.py`)."),
"frame": ("Steam Frame patches",
"FramePort's own changes to a converted Quest game, for what the Steam Frame lacks or does differently."),
"adapter": ("Game settings",
"Settings of FrameBridge, FramePort's OpenXR layer inside every converted Quest game. Most can be "
"changed on the Frame later without a rebuild (game page → Game settings)."),
"device": ("Files and settings on the Frame",
"Files, environment and window options written on the Frame when a Quest game or Android app is "
"installed. They don't change the APK."),
"pcvr": ("PC VR (Proton and Revive)",
"Options for PC VR games, which run through Proton on the Frame (or Revive on this PC)."),
}
def anchor(pid: str) -> str:
return pid.replace(".", "-")
def one_line(text: str) -> str:
return " ".join(str(text).split())
def first_sentence(text: str) -> str:
text = one_line(text)
m = re.search(r"(?<=[.!?])\s+(?=[A-Z(])", text)
return text[:m.start()] if m else text
def md_escape(text: str) -> str:
"""Text for a markdown table cell or a plain line (no accidental HTML or cell breaks)."""
return one_line(text).replace("|", "\\|").replace("<", "&lt;").replace(">", "&gt;")
# ------------------------------------------------------------------ "suggested when", read from detect()'s source
def _string(node: ast.AST) -> str | None:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.JoinedStr):
parts = []
for v in node.values:
parts.append(v.value if isinstance(v, ast.Constant) else "…")
return "".join(parts)
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
a, b = _string(node.left), _string(node.right)
return a + b if a is not None and b is not None else None
return None
def _scope(test: ast.AST, patch: base.Patch) -> bool | None:
"""Whether an `if` test that names this kind of patch (self.key == "x", self.id == "x", self.default_on) holds for
`patch`; None when the test is about the game, not the patch."""
tests = test.values if isinstance(test, ast.BoolOp) and isinstance(test.op, ast.And) else [test]
for t in tests:
if isinstance(t, ast.Attribute) and isinstance(t.value, ast.Name) and t.value.id == "self" \
and t.attr == "default_on":
return bool(patch.default_on)
if isinstance(t, ast.Compare) and len(t.ops) == 1 and isinstance(t.left, ast.Attribute) \
and isinstance(t.left.value, ast.Name) and t.left.value.id == "self" and t.left.attr in ("key", "id"):
own = getattr(patch, t.left.attr, None)
right = t.comparators[0]
try:
value = ast.literal_eval(right)
except ValueError:
continue
if isinstance(t.ops[0], ast.Eq):
return own == value
if isinstance(t.ops[0], ast.In):
return own in value
return None
def suggestions(patch: base.Patch) -> list[tuple[bool, str, dict]]:
fn = type(patch).detect
if fn is base.Patch.detect:
return [(True, "On for every game.", {})] if patch.default_on else []
try:
tree = ast.parse(textwrap.dedent(inspect.getsource(fn)))
except (OSError, TypeError, SyntaxError):
return []
found: list[tuple[bool, str, dict]] = []
def visit(node: ast.AST) -> None:
if isinstance(node, ast.If):
ok = _scope(node.test, patch)
if ok is not False:
for n in node.body:
visit(n)
for n in node.orelse:
visit(n)
return
if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "Suggestion" and len(node.args) >= 2:
reason = _string(node.args[1])
if reason:
rec = node.args[0]
recommended = rec.value if isinstance(rec, ast.Constant) else True
params = {}
if len(node.args) > 2:
try:
params = ast.literal_eval(node.args[2])
except ValueError:
params = {}
found.append((bool(recommended), reason, params if isinstance(params, dict) else {}))
for child in ast.iter_child_nodes(node):
visit(child)
visit(tree)
seen, out = set(), []
for s in found:
if s[1] not in seen:
seen.add(s[1])
out.append(s)
return out
# ------------------------------------------------------------------ catalog and triage
CATALOG_FIELDS = {"overport_extra": None, "alt_overport": None, "frame": None, "device": None, "pcvr": None}
CATALOG_MAPS = {"device_files": "device.files", "config_sync": "device.config_sync", "lepton_env": "device.lepton_env",
"foveation": "device.foveation", "proton_env": "pcvr.proton_env", "proton_tool": "pcvr.proton_tool"}
def catalog_uses() -> dict[str, list[str]]:
"""patch id -> titles of bundled catalog games whose recipe names it."""
uses: dict[str, set[str]] = {}
for f in sorted((ROOT / "catalog" / "games").glob("*.yaml")):
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
title = one_line(d.get("title") or d.get("package") or f.stem)
if d.get("kind") == "rift":
title += " (PC VR)"
ids: set[str] = set()
for field in CATALOG_FIELDS:
ids.update(str(x) for x in d.get(field) or [])
for key in d.get("adapter") or {}:
ids.add(f"adapter.{key}")
for field, pid in CATALOG_MAPS.items():
if d.get(field):
ids.add(pid)
for pid in ids:
uses.setdefault(pid, set()).add(title)
return {k: sorted(v, key=str.casefold) for k, v in uses.items()}
def triage_uses() -> dict[str, list[tuple[str, str, str]]]:
"""patch id -> (signature id, severity, diagnosis) of the triage signatures that suggest it."""
data = yaml.safe_load((ROOT / "catalog" / "triage.yaml").read_text(encoding="utf-8")) or {}
out: dict[str, list[tuple[str, str, str]]] = {}
for sig in data.get("signatures") or []:
for item in sig.get("suggest") or []:
pid = str(item).split("=", 1)[0].strip()
diagnosis = first_sentence(sig.get("diagnosis", ""))
out.setdefault(pid, []).append((sig["id"], sig.get("severity", ""), diagnosis))
return {k: sorted(set(v)) for k, v in out.items()}
# ------------------------------------------------------------------ one entry
def changes(p: base.Patch) -> str:
if p.stage == "overport":
return "Changes the APK (during the OVRPort conversion)."
if p.stage == "apk":
return "Changes the APK (a rebuild and reinstall apply it)."
if p.category == "adapter":
text = (p.description + " " + settings.UI.get(getattr(p, "key", ""), {}).get("help", "")).lower()
if "rebuild" in text:
return "Game setting, stored in the build: needs a rebuild."
return "Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild."
if p.category == "pcvr":
return "Applied when the PC VR game is installed or started; no rebuild."
return "Install only: written on the Frame when the game is installed; the APK isn't changed."
def _value(v) -> str:
if v == "" or v is None:
return "empty"
if isinstance(v, dict):
return "none" if not v else json.dumps(v, sort_keys=True)
return f"`{v}`"
def options(p: base.Patch) -> list[str]:
lines = []
if p.category == "adapter":
key = p.key
spec = next(s for s in settings.SETTINGS if s[0] == key)
kind, default = spec[1], spec[2]
line = f"Value: {kind}, default {_value(default)}"
if key in settings.BASE_SETTINGS:
line += " (always written)"
lines.append(line + ".")
ui = settings.UI.get(key)
if ui and kind in ("int", "float") and key not in settings.HIDDEN:
group = dict(settings.GROUPS).get(ui["group"], ui["group"])
control = ui["control"]
where = f"Game settings → {group} → \"{ui['label']}\""
if ui.get("level") == "advanced":
where += " (advanced)"
if control[0] == "slider":
where += f": slider {control[1]:g}–{control[2]:g} in steps of {control[3]:g}"
elif control[0] == "choice":
where += ": " + ", ".join(f"{label} (`{v:g}`)" if isinstance(v, float) else f"{label} (`{v}`)"
for v, label in control[1])
if ui.get("depends"):
where += f"; shown while `adapter.{ui['depends']}` is on"
lines.append(where + ".")
else:
lines.append("Not in the Game settings dialog (set by a recipe or the CLI).")
return lines
for prm in p.params:
text = f"`{prm.key}`: {prm.kind}, default {_value(prm.default)}"
if prm.minimum is not None or prm.maximum is not None:
low = prm.minimum if prm.minimum is not None else "…"
high = prm.maximum if prm.maximum is not None else "…"
text += f", {low}–{high}"
if prm.help:
text += f" — {md_escape(prm.help)}"
lines.append(text)
return lines
def source_link(p: base.Patch) -> str:
try:
path = Path(inspect.getsourcefile(type(p))).resolve().relative_to(ROOT)
except (TypeError, ValueError):
return ""
return f"[{path.relative_to('src/frameport').as_posix()}](../{path.as_posix()})"
def entry(p: base.Patch, games: list[str], sigs: list[tuple[str, str, str]]) -> str:
out = [f"### <a id=\"{anchor(p.id)}\"></a>{md_escape(p.title)}", ""]
if p.summary:
out += [md_escape(p.summary), ""]
facts = [f"`{p.id}`"]
if p.default_on:
facts.append("on by default")
if p.experimental:
facts.append("experimental")
if not p.needs_vr:
facts.append("also for 2D apps")
rev = p.revision
if rev > 1:
facts.append(f"revision {rev}")
out.append("- " + " · ".join(facts))
out.append(f"- **Changes:** {changes(p)}")
sug = suggestions(p)
for recommended, reason, params in sug:
label = "Suggested (on)" if recommended else "Offered (off)"
if params and p.category == "adapter" and "value" in params:
label += f", value `{params['value']}`"
out.append(f"- **{label}:** {md_escape(reason)}")
if not sug:
out.append("- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a "
"launch test.")
if getattr(p, "strict", False):
out.append("- **Only for its game's build:** outside it the whole OVRPort run fails, so recipes only use it "
"where it applies.")
if p.conflicts:
out.append("- **Conflicts with:** " + ", ".join(f"[`{c}`](#{anchor(c)})" for c in sorted(p.conflicts)))
if p.requires:
out.append("- **Requires:** " + ", ".join(f"[`{c}`](#{anchor(c)})" for c in sorted(p.requires)))
opts = options(p)
if len(opts) == 1:
out.append(f"- **Options:** {opts[0]}")
elif opts:
out.append("- **Options:**")
out += [f" - {o}" for o in opts]
if games:
shown = ", ".join(md_escape(g) for g in games[:MAX_GAMES])
more = f" and {len(games) - MAX_GAMES} more" if len(games) > MAX_GAMES else ""
out.append(f"- **Catalog games ({len(games)}):** {shown}{more}.")
if sigs:
out.append("- **Suggested by launch-test signatures:**")
out += [f" - `{sid}` ({sev}): {md_escape(diag)}" for sid, sev, diag in sigs]
src = source_link(p)
if src:
out.append(f"- **Source:** {src}")
desc = md_escape(p.description)
if desc and desc != md_escape(p.summary):
out += ["", f"> {desc}"]
return "\n".join(out) + "\n"
# ------------------------------------------------------------------ the page
def build() -> tuple[str, str]:
patches = base.all_patches()
ids = [p.id for p in patches]
anchors = [anchor(i) for i in ids]
if len(set(anchors)) != len(anchors):
raise SystemExit("two patches share an anchor")
uses, sigs = catalog_uses(), triage_uses()
cats = sorted({p.category for p in patches}, key=lambda c: (base.CATEGORIES.index(c), c))
by_cat = {c: sorted((p for p in patches if p.category == c), key=lambda p: p.id) for c in cats}
counts = {c: len(by_cat[c]) for c in cats}
title = lambda c: CATEGORIES.get(c, (c.capitalize(), "")) # noqa: E731
lines = [HEADER, "", "# Patches", "",
f"Every patch and game setting FramePort knows: {len(patches)} entries in {len(cats)} groups. A game's "
"recipe is its chosen patches and settings; tested games get theirs from the catalog "
"([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You "
"change them on the game page under Customize.", "",
"Each entry says what the patch does, whether it changes the APK or only the install, when FramePort "
"suggests it, its options, the catalog games whose recipe names it and the launch-test signatures "
"that suggest it. Patches that are on by default are on for every game, so the catalog rarely names "
"them.", "",
"Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_text_input` for "
"`frame.unity_text_input`.", "",
"## Groups", ""]
for c in cats:
lines.append(f"- [{title(c)[0]}](#{anchor('group-' + c)}) ({counts[c]})")
lines += ["", "## Index", "", "| Patch | Id | Group | Catalog games |", "| --- | --- | --- | --- |"]
for c in cats:
for p in by_cat[c]:
n = len(uses.get(p.id, []))
games = ("default" + (f" · {n}" if n else "")) if p.default_on else (str(n) if n else "")
lines.append(f"| [{md_escape(p.title)}](#{anchor(p.id)}) | `{p.id}` | {title(c)[0]} | {games} |")
lines.append("")
for c in cats:
name, intro = title(c)
lines += [f"## <a id=\"{anchor('group-' + c)}\"></a>{name}", ""]
if intro:
lines += [intro, ""]
for p in by_cat[c]:
lines.append(entry(p, uses.get(p.id, []), sigs.get(p.id, [])))
unknown = sorted(set(sigs) - set(ids))
if unknown:
raise SystemExit(f"catalog/triage.yaml suggests unknown patches: {', '.join(unknown)}")
doc = "\n".join(lines).rstrip() + "\n"
site = {p.id: {"anchor": anchor(p.id), "title": one_line(p.title), "group": title(p.category)[0]}
for p in patches}
site_json = json.dumps(dict(sorted(site.items())), indent=1, ensure_ascii=False) + "\n"
return doc, site_json
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--check", action="store_true", help="only check that the files are up to date")
args = ap.parse_args()
doc, site_json = build()
targets = [(DOC, doc), (SITE_MAP, site_json)]
if args.check:
stale = [p for p, text in targets if not p.exists() or p.read_text(encoding="utf-8") != text]
for p in stale:
print(f"{p.relative_to(ROOT)} is out of date: run python scripts/patch_docs.py", file=sys.stderr)
return 1 if stale else 0
for p, text in targets:
p.write_text(text, encoding="utf-8", newline="\n")
print(f"wrote {p.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+7
View File
@@ -490,6 +490,13 @@ class FakePairingServer:
self.asks: list = [] # the setup URL: the `frame_asks` step hook plays a Frame asking (Allow / Deny)
self.running = False
def open_asks(self) -> list:
"""Like PairingServer.open_asks (requests never expire on film)."""
return [a for a in self.asks if a.state == "open"]
def prune(self, now: float | None = None) -> list:
return []
def decide(self, ask_id: str, allow: bool) -> None:
for a in self.asks:
if a.id == ask_id and a.state == "open":
+10 -8
View File
@@ -139,21 +139,21 @@ def join(clips: list[Path], durations: list[float], out: Path, transition: float
def encode_within(clips: list[Path], durations: list[float], out: Path, budget_mb: float, transition: float = 0.5,
crf: int = 22, max_crf: int = 32) -> int:
crf: int = 17, max_crf: int = 30) -> int:
"""The final MP4 at the best quality (lowest CRF) that fits `budget_mb`. Returns the CRF used."""
while True:
join(clips, durations, out, transition, crf)
if out.stat().st_size <= budget_mb * 1e6 or crf >= max_crf:
return crf
crf += 2
crf += 1
def poster(video: Path, at: float, out: Path) -> None:
run(["-ss", f"{at:.2f}", "-i", video, "-frames:v", "1", "-q:v", "2", out])
def teaser_webp(clips: list[Path], durations: list[float], out: Path, width: int = 960, fps: int = 15,
budget_mb: float = 4.0, transition: float = 0.35) -> int:
def teaser_webp(clips: list[Path], durations: list[float], out: Path, width: int = 1280, fps: int = 20,
budget_mb: float = 8.0, transition: float = 0.35) -> int:
"""A looping animated WebP of the highlight clips (README: plays inline where an MP4 wouldn't). Lowers the
quality until it fits `budget_mb`. Returns the quality used."""
graph, label = xfade_graph(durations, transition)
@@ -161,13 +161,15 @@ def teaser_webp(clips: list[Path], durations: list[float], out: Path, width: int
args = []
for c in clips:
args += ["-i", c]
quality = 72
# UI footage: the "drawing" preset keeps text and edges crisp; start near-lossless (quality 72 at 960 px showed
# blocky text on GitHub) and only step down when the file would be too big for a README
quality = 95
while True:
run([*args, "-filter_complex", graph, "-map", label, "-an", "-c:v", "libwebp_anim", "-lossless", "0",
"-quality", quality, "-compression_level", "6", "-loop", "0", out])
if out.stat().st_size <= budget_mb * 1e6 or quality <= 30:
"-preset", "drawing", "-quality", quality, "-compression_level", "6", "-loop", "0", out])
if out.stat().st_size <= budget_mb * 1e6 or quality <= 50:
return quality
quality -= 8
quality -= 5
# ------------------------------------------------------------------ cards and captions
+1 -1
View File
@@ -39,7 +39,7 @@ START_KEYS = {"profile", "frame", "stream", "route"}
SCENE_KEYS = {"name", "caption", "teaser", "hold", "steps", "card", "seconds"}
CARD_KEYS = {"eyebrow", "heading", "line", "steps", "code", "note"}
VIEWPORT = (1440, 810) # the layout size; filmed at 4/3 device pixels = 1920x1080
TEASER_MB = 4.0
TEASER_MB = 8.0
def video_names() -> list[str]:
+7
View File
@@ -281,6 +281,8 @@ def main() -> int:
a.library_view.open_menu(LINUX_APPIMAGE)))]
if args.links:
from frameport import deeplink
from frameport.ui import components as C
from frameport.ui import easter
from frameport.ui.views import link_dialog
fake = deeplink.Manifest("Example Game", [
@@ -305,6 +307,11 @@ def main() -> int:
steps = [("library", lambda a: a.navigate(0)),
("links-paste", lambda a: a.pick_link()),
("links-confirm", lambda a: (a.page.pop_dialog(), link_dialog._confirm(a, fake, sizes, False, icon))),
# the homepage's example button: the demo question (no network), then its Install's easter egg
("links-demo", lambda a: (a.page.pop_dialog(), a.open_install_link(
deeplink.make_link(deeplink.DEMO_MANIFEST), from_web=True))),
("links-demo-install", lambda a: (a.page.pop_dialog(), easter.demo_install(
a, C._asset_src(link_dialog.DEMO_COVER)), time.sleep(0.9))),
("links-settings", lambda a: (a.page.pop_dialog(), a.go("settings")))]
if game:
steps.append(("links-display-mode", lambda a: a.open_game(game, advanced=True)))
+11
View File
@@ -0,0 +1,11 @@
{
"schema": "framedrop.install/v1",
"name": "Cool Game",
"frameport": {
"description": "A placeholder game for the example \"Install with FramePort\" button. FramePort recognises this link and shows a demo: nothing is downloaded or installed.",
"icon": "https://frameport.app/demo/cool-game.svg"
},
"files": [
{ "url": "https://frameport.app/demo/cool-game-arm64.apk" }
]
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="96" viewBox="0 0 72 96"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2D7FF9"/><stop offset=".55" stop-color="#7A4BD8"/><stop offset="1" stop-color="#FF8A3D"/></linearGradient><radialGradient id="sun" cx=".4" cy=".35" r=".75"><stop offset="0" stop-color="#FFE48A"/><stop offset="1" stop-color="#FFB42E"/></radialGradient></defs><rect width="72" height="96" fill="url(#bg)"/><path d="M0 74 18 60 30 68 46 54 72 72V96H0z" fill="#101218" fill-opacity=".35"/><circle cx="36" cy="40" r="20" fill="url(#sun)" stroke="#101218" stroke-width="2.4"/><path d="M17.5 35.5h37M20 35.5h13l-1.4 6.2a3.4 3.4 0 0 1-3.3 2.6h-4.6A3.4 3.4 0 0 1 20.4 42zM39 35.5h13l-.4 6.5a3.4 3.4 0 0 1-3.3 2.3h-4.6a3.4 3.4 0 0 1-3.3-2.6z" fill="#101218" stroke="#101218" stroke-width="2" stroke-linejoin="round"/><path d="M23.5 37.5h4" stroke="#FFFFFF" stroke-opacity=".7" stroke-width="1.4" stroke-linecap="round"/><path d="M29 50.5c4 3.2 10 3.2 14 0" fill="none" stroke="#101218" stroke-width="2.4" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+13
View File
@@ -556,6 +556,12 @@ const ICON: Record<string, string> = {
@keyframes blink { 50% { opacity: 0; } }
@keyframes halo { 50% { box-shadow: 0 0 0 2px var(--accent), 0 0 0 11px rgba(255, 138, 31, .12); } }
/* medium (tablets): the title keeps its own width; the buttons' "auto" column squeezed it to "Libr" and one word
per line. The buttons wrap in what's left */
@container (min-width: 701px) and (max-width: 960px) {
.view :global(.lib-h) { grid-template-columns: max-content minmax(0, 1fr); }
}
/* narrow: the sidebar becomes an icon rail */
@container (max-width: 700px) {
.body { grid-template-columns: 56px minmax(0, 1fr); }
@@ -567,5 +573,12 @@ const ICON: Record<string, string> = {
.view :global(.mon-game) { grid-template-columns: 56px minmax(0, 1fr); }
.view :global(.mon-game .big-spark) { grid-column: 1 / -1; }
.view :global(.grid) { grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); }
/* Library header: title and count on their own line, the search full width, the buttons wrapping below */
.view :global(.lib-h) { grid-template-columns: minmax(0, 1fr); }
.view :global(.lib-h .vh-act) { justify-content: flex-start; }
.view :global(.lib-h .search-w) { flex: 1 1 100%; }
.view :global(.lib-h .search-w .search) { flex: 1; min-width: 0; width: auto; }
.view :global(.add-w) { grid-column: 1; justify-self: start; }
.view :global(.add-w .menu) { right: auto; left: 0; }
}
</style>
+3
View File
@@ -166,6 +166,9 @@ const ICON = {
.heads { grid-template-columns: minmax(0, 1fr); }
.hcard.foss { grid-template-columns: minmax(0, 1fr); }
.hcard.foss .hicon { grid-row: auto; }
/* one column: the title's "2 / -1" from the wide layout would add a second, implicit column and squeeze
FramePort's text into the first */
.hcard.foss h3 { grid-column: auto; }
.hcard.foss .other { border-left: 0; padding-left: 0; border-top: 1px dashed var(--border-strong); padding-top: 8px; }
th, td { padding: 10px 12px; }
.rowh { width: 38%; }
+7 -2
View File
@@ -3,6 +3,7 @@
// date in the browser from GitHub's main branch (scripts/catalog-live.ts), the way the app follows the catalog.
import { loadSnapshot } from '../data/catalog';
import { STATUS, counts, engines, latestCheck, rowsHtml, sortGames, type Game, type Status } from '../scripts/catalog';
import { patchHref } from '../scripts/patchlinks';
import { SHARE_CONFIG, REPORT_PROBLEM, blob } from '../data/site';
const snapshot = loadSnapshot();
@@ -58,7 +59,7 @@ const latest = latestCheck(games);
<span role="columnheader">Status</span>
<span role="columnheader" class="c-date">Checked</span>
</div>
<ul class="rows" role="rowgroup" data-rows set:html={rowsHtml(games)} />
<ul class="rows" role="rowgroup" data-rows set:html={rowsHtml(games, patchHref)} />
<p class="empty" data-empty hidden>No tested game matches. It may still work: FramePort suggests patches.</p>
</div>
<div class="board-foot">
@@ -79,6 +80,7 @@ const latest = latestCheck(games);
<script>
import { counts, engines, latestCheck, rowsHtml, sortGames, type Snapshot } from '../scripts/catalog';
import { patchHref } from '../scripts/patchlinks';
import { refreshCatalog } from '../scripts/catalog-live';
const board = document.querySelector<HTMLElement>('[data-board]');
@@ -151,7 +153,7 @@ const latest = latestCheck(games);
src.classList.add('live');
if (!live.changed) return;
const games = sortGames(live.games);
list.innerHTML = rowsHtml(games);
list.innerHTML = rowsHtml(games, patchHref);
const n = counts(games);
for (const s of ['works', 'issues', 'unsupported'] as const) set(`[data-n="${s}"]`, String(n[s]));
set('[data-total]', String(games.length));
@@ -221,6 +223,9 @@ const latest = latestCheck(games);
.rows :global(.pt) { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.rows :global(.pt span) { font: 600 10.5px/1 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--text-2); margin-right: 4px; }
.rows :global(.pt code) { font-size: 12px; padding: 3px 8px; border-radius: 6px; background: rgba(58, 168, 255, .1); color: var(--secondary); }
.rows :global(.pt a) { text-decoration: none; border-radius: 6px; }
.rows :global(.pt a code) { transition: background 160ms; }
.rows :global(.pt a:hover code), .rows :global(.pt a:focus-visible code) { background: rgba(58, 168, 255, .22); text-decoration: underline; }
.rows :global(.fx) { font: 400 12px/1.4 var(--font-mono); color: var(--text-3); }
@keyframes open { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
.rows :global(.row[hidden]) { display: none; }
+10 -3
View File
@@ -3,8 +3,10 @@ import Wordmark from './Wordmark.astro';
import Contributors from './Contributors.astro';
import { REPO, REPORT_PROBLEM, url } from '../data/site';
interface Props { credits?: boolean }
const { credits = true } = Astro.props;
// "all" = the "Built on" section with the projects and the contributors (the homepage), "contributors" = only the
// contributors (What's new), none by default (docs, download, privacy, 404 and the other utility pages)
interface Props { credits?: 'all' | 'contributors' | false }
const { credits = false } = Astro.props;
// each project named links to its own page (Lepton has none of its own: it links to Waydroid, which it is built on)
type Part = [name: string, href?: string];
const builtOn: { parts: Part[]; what: string }[] = [
@@ -23,7 +25,12 @@ const builtOn: { parts: Part[]; what: string }[] = [
/** "a", "a and b", "a, b and c" */
const joiner = (i: number, n: number) => (i === 0 ? '' : i === n - 1 ? ' and ' : ', ');
---
{credits && (
{credits === 'contributors' && (
<section class="section credits" id="contributors">
<div class="wrap"><Contributors /></div>
</section>
)}
{credits === 'all' && (
<section class="section credits" id="credits">
<div class="wrap">
<div class="head reveal">
+2 -2
View File
@@ -26,8 +26,8 @@ const snippet = `<a href="${pageUrl}?manifest=https%3A%2F%2Fexample.com%2Fmy-gam
<h3>An "Install with FramePort" button</h3>
<p class="muted">Put it on your download page. A click opens FramePort, which installs your game on the
visitor's Frame.</p>
<div class="badge-row"><InstallBadge id="makers" href={url('install/?url=https%3A%2F%2Fexample.com%2Fgames%2Fcool-game-arm64.apk')}
label="Try it: what a visitor without FramePort sees" /><span class="try">← try it</span></div>
<div class="badge-row"><InstallBadge id="makers" href={url('install/?manifest=https%3A%2F%2Fframeport.app%2Fdemo%2Fcool-game.json')}
label="Try it: a demo install link (nothing is downloaded)" /><span class="try">← try it</span></div>
<a class="small" href={url('docs/install-button/')}>How to add the button</a>
</div>
<div class="snippet">
+2 -1
View File
@@ -26,7 +26,8 @@ const logo = readFileSync(join(process.cwd(), '..', 'src/frameport/ui/icons/logo
b.style.setProperty('--mx', `${((e.clientX - r.left) / r.width) * 100}%`);
b.style.setProperty('--my', `${((e.clientY - r.top) / r.height) * 100}%`);
});
b.addEventListener('pointerdown', () => { b.classList.remove('pressed'); void b.offsetWidth; b.classList.add('pressed'); });
// the press ripple is for the mouse: on a touch screen it fired on every scroll that started on the badge
b.addEventListener('pointerdown', (e) => { if (e.pointerType === 'touch') return; b.classList.remove('pressed'); void b.offsetWidth; b.classList.add('pressed'); });
});
</script>
+112
View File
@@ -0,0 +1,112 @@
---
// How it works for PC VR games (Windows VR games): on the Frame through Proton, on the PC through SteamVR (Revive for
// Oculus Rift games), and two paths that only exist on experimental branches. Closed by default, like Compare's table.
import Glyph from './Glyph.astro';
import { REPO, url } from '../data/site';
type Step = { glyph: Parameters<typeof Glyph>[0]['name']; side: 'pc' | 'frame'; name: string; text: string };
const onFrame: Step[] = [
{ glyph: 'pc', side: 'pc', name: 'scan', text: 'One folder per game. FramePort finds the program that starts it and asks when there are several.' },
{ glyph: 'container', side: 'pc', name: 'analyze', text: 'Reads which VR interface the game uses (OpenXR, OpenVR or Oculus) from its files.' },
{ glyph: 'sync', side: 'frame', name: 'upload', text: 'The game goes to the Frame unchanged. Proton, Valve’s layer for Windows games, is installed for you.' },
{ glyph: 'patch', side: 'frame', name: 'patch', text: 'A small OpenXR layer lets the game talk to the Frame’s SteamVR. Unreal games start without their crash reporter.' },
{ glyph: 'frame', side: 'frame', name: 'library', text: 'The game shows up in your Steam library and starts in the headset like any other.' },
];
const onPc: Step[] = [
{ glyph: 'pc', side: 'pc', name: 'install', text: '“Install on this PC” adds the game to Steam on your PC, with its artwork.' },
{ glyph: 'port', side: 'pc', name: 'revive', text: 'Oculus Rift games get Revive automatically: your installed Revive if you have one, else a private copy unpacked from Revive’s own installer. Your install is never replaced.' },
{ glyph: 'live', side: 'pc', name: 'play', text: 'Play starts SteamVR. After a session, FramePort picks a refresh rate and motion smoothing that the game keeps up with.' },
{ glyph: 'frame', side: 'frame', name: 'stream', text: 'Steam Link streams the game from your PC to the Frame.' },
];
---
<details class="pcvr reveal">
<summary><span class="show">How PC VR games work</span><span class="hide">Hide PC VR</span><i aria-hidden="true">▾</i></summary>
<p class="lead muted">PC VR games are Windows VR games. FramePort runs them on the Frame itself or on your PC.</p>
<div class="panels">
<details class="panel">
<summary><Glyph name="frame" size={18} /><span>On the Frame</span><i aria-hidden="true">▾</i></summary>
<ol class="steps">
{onFrame.map((s) => (
<li class={s.side}><span class="g"><Glyph name={s.glyph} size={18} /></span><code class="n">{s.name}</code><p>{s.text}</p></li>
))}
</ol>
<p class="note muted">Games that use OpenXR or OpenVR run directly. Oculus-only games run on your PC; running them on the Frame
is the experimental path below. <a href={url('docs/install/#pc-vr-games')}>PC VR in the docs</a></p>
</details>
<details class="panel">
<summary><Glyph name="pc" size={18} /><span>On your PC</span><i aria-hidden="true">▾</i></summary>
<ol class="steps">
{onPc.map((s) => (
<li class={s.side}><span class="g"><Glyph name={s.glyph} size={18} /></span><code class="n">{s.name}</code><p>{s.text}</p></li>
))}
</ol>
<p class="note muted">Needs Windows with Steam and SteamVR. Games that check their license through Meta’s app need that
app on the PC too. <a href={url('docs/install/#pc-vr-games')}>PC VR in the docs</a></p>
</details>
<details class="panel">
<summary><Glyph name="pc" size={18} /><span>Quest games in PC VR</span>
<span class="tag" aria-label="Experimental, not in the app yet">Experimental · not in the app yet</span><i aria-hidden="true">▾</i></summary>
<p>Quest games run on your PC through <b>AXRB</b> (Android XR Bridge) and SteamVR, then stream to the Frame like any PC
VR game. FramePort downloads AXRB the first time you use it, like Revive. Some games already start; most don’t
show a usable picture yet.</p>
<p class="note muted">On an experimental branch, not in a release yet. <a href={`${REPO}/pull/136`}>Follow its progress</a></p>
</details>
<details class="panel">
<summary><Glyph name="frame" size={18} /><span>Rift games on the Frame alone</span>
<span class="tag" aria-label="Experimental, not in the app yet">Experimental · not in the app yet</span><i aria-hidden="true">▾</i></summary>
<p>Oculus Rift games run on the Frame itself, without a PC: Windows x86 emulation (FEX and Wine) runs the game,
Revive and Meta’s own PC runtime, and you sign in with your Meta account. The first Oculus game has run in the
headset; it isn’t smooth enough to ship yet.</p>
<p class="note muted">On an experimental branch, not in a release yet. <a href={`${REPO}/tree/rift-on-frame`}>Follow its progress</a></p>
</details>
</div>
</details>
<style>
.pcvr { margin-top: 28px; }
.pcvr > summary { list-style: none; display: inline-flex; align-items: center; gap: 10px; padding: 10px 16px; border-radius: var(--radius-sm);
border: 1px solid var(--border); cursor: pointer; font-weight: 600; }
.pcvr summary::-webkit-details-marker { display: none; }
.pcvr summary:hover { background: rgba(58, 168, 255, .08); }
.pcvr summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.pcvr summary i { font-style: normal; transition: transform 300ms var(--ease-out); }
.pcvr[open] > summary i, .panel[open] > summary i { transform: rotate(180deg); }
.pcvr > summary .hide, .pcvr[open] > summary .show { display: none; }
.pcvr[open] > summary .hide { display: inline; }
.lead { margin: 16px 0 12px; }
.panels { display: grid; gap: 10px; }
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; }
.panel > summary { list-style: none; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 14px 18px;
cursor: pointer; font: 600 15px/1.3 var(--font-sans); }
.panel > summary .glyph { color: var(--accent); }
.panel > summary i { margin-left: auto; color: var(--text-2); }
.panel > :not(summary) { margin-inline: 18px; }
.panel > p { color: var(--text-2); line-height: 1.6; }
.panel > :last-child { margin-bottom: 16px; }
.tag { font: 600 11px/1 var(--font-mono); letter-spacing: .04em; padding: 4px 8px; border-radius: 999px;
color: var(--accent); background: rgba(255, 138, 31, .12); border: 1px solid rgba(255, 138, 31, .35); }
.steps { list-style: none; padding: 0; margin-top: 0; }
.steps li { display: grid; grid-template-columns: 32px 72px minmax(0, 1fr); gap: 4px 12px; align-items: baseline; padding: 10px 0; }
.steps li + li { border-top: 1px solid rgba(42, 45, 55, 0.6); }
.steps .g { align-self: center; width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; }
.steps .pc .g { color: var(--secondary); background: rgba(58, 168, 255, 0.12); }
.steps .frame .g { color: var(--accent); background: rgba(255, 138, 31, 0.12); }
.steps .n { font-size: 13px; font-weight: 600; }
.steps .pc .n { color: var(--secondary); }
.steps .frame .n { color: var(--accent); }
.steps p { margin: 0; color: var(--text-2); font-size: 15px; line-height: 1.55; }
.note { font-size: 14px; }
@media (max-width: 640px) {
.steps li { grid-template-columns: 32px minmax(0, 1fr); }
.steps .g { grid-row: span 2; align-self: start; }
.steps p { grid-column: 2; }
}
</style>
+23 -3
View File
@@ -2,7 +2,9 @@
// How it works, as the install log of one real catalog game: the stages on the PC in blue, the ones on the Frame in
// orange, the portal between them.
import Glyph from './Glyph.astro';
import PcvrHow from './PcvrHow.astro';
import { patchLabel, stageDetails, type LogGame } from '../scripts/logdetails';
import { PATCH_DOC, patchHref, patchInfo } from '../scripts/patchlinks';
interface Props { example: LogGame; others: LogGame[] }
const { example, others } = Astro.props;
@@ -78,12 +80,16 @@ const frame: Stage[] = [
<p class="foot muted reveal">
<span class="recipe-box" aria-live="polite">
<span class="rb-k">Recipe</span>
<span class="rb-p" data-lg-patches>{example.patches.length ? example.patches.map((x) => <code>{patchLabel(x)}</code>) : <code>Default patches</code>}</span>
<span class="rb-p" data-lg-patches>{example.patches.length
? example.patches.map((x) => { const href = patchHref(x); return href
? <a class="chip" href={href} title={patchInfo(x)!.title}><code>{patchLabel(x)}</code></a> : <code>{patchLabel(x)}</code>; })
: <a class="chip" href={PATCH_DOC} title="Every patch FramePort knows"><code>Default patches</code></a>}</span>
<span class="rb-n" data-lg-note>{example.note}</span>
</span>
<span class="foot-t"><span data-lg="foot">{example.title}</span>'s recipe from the catalog. Untested games get suggested patches.</span>
<button type="button" class="btn btn-secondary btn-small again" data-again>↻ Try another game</button>
</p>
<PcvrHow />
</div>
</section>
@@ -91,6 +97,7 @@ const frame: Stage[] = [
<script>
import { patchLabel, stageDetails, type LogGame } from '../scripts/logdetails';
import { PATCH_DOC, patchHref, patchInfo } from '../scripts/patchlinks';
// another tested game, its real engine, VR API and recipe; the log plays again
const log = document.querySelector<HTMLElement>('[data-log]');
const games = JSON.parse(document.querySelector('[data-log-games]')?.textContent || '[]') as
@@ -110,7 +117,15 @@ const frame: Stage[] = [
chips.replaceChildren(...(g.patches.length ? g.patches : ['Default patches']).map((x) => {
const c = document.createElement('code');
c.textContent = g.patches.length ? patchLabel(x) : x;
return c;
// each chip links to its entry in the patch reference (the default chip to the whole reference)
const href = g.patches.length ? patchHref(x) : PATCH_DOC;
if (!href) return c;
const a = document.createElement('a');
a.className = 'chip';
a.href = href;
a.title = g.patches.length ? patchInfo(x)!.title : 'Every patch FramePort knows';
a.append(c);
return a;
}));
}
const note = document.querySelector('[data-lg-note]');
@@ -176,7 +191,12 @@ const frame: Stage[] = [
padding: 14px 16px; border-radius: var(--radius-sm); background: var(--surface); border: 1px solid var(--border); }
.rb-k { font: 600 11px/1 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--accent); }
.rb-p { display: flex; flex-wrap: wrap; gap: 6px; }
.rb-p code { font-size: 12px; padding: 3px 8px; border-radius: 6px; background: rgba(58, 168, 255, .1); color: var(--secondary);
/* :global: "Try another game" builds the chips in the browser, without Astro's scoping attribute */
/* chips are links without the running-text underline (global.css .foot a): the chip's fill brightens instead */
.foot .rb-p :global(a.chip), .foot .rb-p :global(a.chip:hover) { text-decoration: none; background: none; padding: 0; border-radius: 6px; }
.rb-p :global(a code) { transition: background 160ms; }
.rb-p :global(a:hover code), .rb-p :global(a:focus-visible code) { background: rgba(58, 168, 255, .22); text-decoration: underline; }
.rb-p :global(code) { font-size: 12px; padding: 3px 8px; border-radius: 6px; background: rgba(58, 168, 255, .1); color: var(--secondary);
animation: chip .35s var(--ease-out) both; }
.rb-n { grid-column: 2; color: var(--text-2); font-size: 14px; }
.rb-n:empty { display: none; }
+2 -2
View File
@@ -17,7 +17,7 @@ export const HEADLINES: Headline[] = [
frameport: 'GPL-3.0. Read it, build it, change it, share it.',
others: ['Free (donationware). Source not published.', 'Free, from Valve.'] },
{ icon: 'game', title: "Quest games that don't run as they are",
frameport: 'Patches them for the Frame, with a tested recipe for 90+ games.',
frameport: 'Patches them for the Frame, with a tested recipe for 120+ games.',
others: ['Installs the APK as it is.', 'Installs the APK as it is.'] },
{ icon: 'pc', title: 'Your PC',
frameport: 'Windows, macOS and Linux.',
@@ -45,7 +45,7 @@ export const ROWS: Row[] = [
y('Converted and patched for the Frame'),
n('Installs the APK as it is'),
n('Installs the APK as it is')] },
{ label: 'Does it know which patches a game needs?', cells: [y('A tested recipe for 90+ games, updated without an app update'), u(), n('No')] },
{ label: 'Does it know which patches a game needs?', cells: [y('A tested recipe for 120+ games, updated without an app update'), u(), n('No')] },
{ label: 'Will the game be in my Steam library?', cells: [
y('Yes, with artwork and tags'), p('As a "Devkit Game" shortcut'), p('As "Devkit Game: <title>"; not with adb')] },
{ label: 'Android apps, Linux apps, Windows programs?', cells: [
+25 -4
View File
@@ -5,8 +5,13 @@ import { join } from 'node:path';
import { Marked, type Tokens } from 'marked';
import { REPO, url } from './site';
import { fileTree } from '../scripts/filetree';
import { patchHref } from '../scripts/patchlinks';
export interface DocInfo { file: string; slug: string; name: string; group: string; blurb: string; summary: string }
export interface DocInfo {
file: string; slug: string; name: string; group: string; blurb: string; summary: string;
/** deepest heading level in the page's table of contents (default 3) */
tocDepth?: number;
}
export const DOCS: DocInfo[] = [
{ file: 'INSTALL.md', slug: 'install', name: 'Install and first steps', group: 'Get started', blurb: 'Download, connect the Frame, install games.',
summary: 'Install FramePort on Windows, macOS or Linux, connect your Steam Frame once, then install and play Quest and PC VR games.' },
@@ -22,6 +27,9 @@ export const DOCS: DocInfo[] = [
summary: 'Add an Install with FramePort button to your download page or README, so one click installs your game on a Steam Frame.' },
{ file: 'DIAGNOSTICS.md', slug: 'diagnostics', name: 'Diagnostics and problem reports', group: 'For developers', blurb: 'What a problem report contains.',
summary: "What FramePort's problem reports and shared recipes contain, how personal data is removed, and how recipes reach the catalog." },
{ file: 'PATCHES.md', slug: 'patches', name: 'Patch reference', group: 'For developers', blurb: 'Every patch and game setting.',
summary: 'Every FramePort patch and game setting: what it does, whether it changes the APK, when it is suggested, its options and the games that use it.',
tocDepth: 2 },
{ file: 'ARCHITECTURE.md', slug: 'architecture', name: 'Architecture', group: 'For developers', blurb: 'How FramePort is built and what it adds.',
summary: 'How FramePort is built: the projects it wraps, the pipeline from scan to launch test, and what it adds for the Steam Frame.' },
{ file: 'PLAYBOOK.md', slug: 'playbook', name: 'Porting playbook', group: 'For developers', blurb: 'Symptoms and fixes from real games.',
@@ -72,6 +80,8 @@ export function loadDoc(info: DocInfo): Doc {
if (h1) md = md.replace(h1[0], '');
const slug = slugger();
const toc: Doc['toc'] = [];
const tocDepth = info.tocDepth ?? 3;
const linked = new WeakSet<object>(); // code spans inside a link: never a second link
const marked = new Marked({
gfm: true,
walkTokens(token) {
@@ -84,6 +94,7 @@ export function loadDoc(info: DocInfo): Doc {
link.tokens = [{ type: 'text', raw: named.name, text: named.name }];
}
link.href = rewrite(link.href);
for (const t of link.tokens ?? []) if (t.type === 'codespan') linked.add(t);
}
if (token.type === 'html') {
(token as Tokens.HTML).text = (token as Tokens.HTML).text.replace(/(src|href)="([^"]+)"/g, (_, attr, v) => `${attr}="${rewrite(v)}"`);
@@ -101,10 +112,20 @@ export function loadDoc(info: DocInfo): Doc {
return `<figure class="diagram"><div class="diagram-scroll"><img src="${href}" alt="${alt}" loading="lazy" /></div>`
+ '<figcaption>Scroll sideways or tap to enlarge.</figcaption></figure>';
},
// a heading that starts with its own anchor (<a id="…"></a>, as in PATCHES.md) keeps that id, so GitHub and the
// site share one link target
heading({ tokens, depth, text }) {
const id = slug(text);
if (depth <= 3) toc.push({ id, text: text.replace(/[`*_]/g, ''), depth });
return `<h${depth} id="${id}"><a class="anchor" href="#${id}" aria-label="Link to this section">#</a>${this.parser.parseInline(tokens)}</h${depth}>\n`;
const own = text.match(/^<a id="([^"]+)"><\/a>\s*/);
const label = own ? text.slice(own[0].length) : text;
const id = own ? own[1] : slug(text);
if (depth <= tocDepth) toc.push({ id, text: label.replace(/[`*_]/g, ''), depth });
const inner = this.parser.parseInline(tokens).replace(/^<a id="[^"]+"><\/a>\s*/, '');
return `<h${depth} id="${id}"><a class="anchor" href="#${id}" aria-label="Link to this section">#</a>${inner}</h${depth}>\n`;
},
// a patch id in a code span (`frame.unity_text_input`) links to its entry in the patch reference
codespan(token) {
const href = linked.has(token) || !/^[\w.]+$/.test(token.text) ? null : patchHref(token.text);
return href ? `<a class="patch-ref" href="${href}"><code>${token.text}</code></a>` : false;
},
table(token) {
const head = token.header.map((c) => `<th>${this.parser.parseInline(c.tokens)}</th>`).join('');
+777
View File
@@ -0,0 +1,777 @@
{
"adapter.aim_forward": {
"anchor": "adapter-aim_forward",
"title": "Pointer start",
"group": "Game settings"
},
"adapter.aim_pitch": {
"anchor": "adapter-aim_pitch",
"title": "Pointer angle",
"group": "Game settings"
},
"adapter.aim_yaw": {
"anchor": "adapter-aim_yaw",
"title": "Pointer turn",
"group": "Game settings"
},
"adapter.controller_fix": {
"anchor": "adapter-controller_fix",
"title": "Use controllers",
"group": "Game settings"
},
"adapter.controller_models": {
"anchor": "adapter-controller_models",
"title": "Show Steam Frame controllers",
"group": "Game settings"
},
"adapter.cube_standin": {
"anchor": "adapter-cube_standin",
"title": "Stand in for cube layers",
"group": "Game settings"
},
"adapter.cylinder_strips": {
"anchor": "adapter-cylinder_strips",
"title": "Show curved menus and screens",
"group": "Game settings"
},
"adapter.equirect_emul": {
"anchor": "adapter-equirect_emul",
"title": "Show 360° backgrounds and videos",
"group": "Game settings"
},
"adapter.equirect_face": {
"anchor": "adapter-equirect_face",
"title": "360° detail",
"group": "Game settings"
},
"adapter.equirect_flip": {
"anchor": "adapter-equirect_flip",
"title": "360° picture orientation",
"group": "Game settings"
},
"adapter.equirect_fps": {
"anchor": "adapter-equirect_fps",
"title": "360° video frame rate limit",
"group": "Game settings"
},
"adapter.equirect_res": {
"anchor": "adapter-equirect_res",
"title": "360° sharpness",
"group": "Game settings"
},
"adapter.equirect_stereo": {
"anchor": "adapter-equirect_stereo",
"title": "360° 3D",
"group": "Game settings"
},
"adapter.eye_debug": {
"anchor": "adapter-eye_debug",
"title": "Per-eye diagnostics (log)",
"group": "Game settings"
},
"adapter.flip_emul": {
"anchor": "adapter-flip_emul",
"title": "Fix upside-down menus",
"group": "Game settings"
},
"adapter.flip_quads": {
"anchor": "adapter-flip_quads",
"title": "Rotate panels 180°",
"group": "Game settings"
},
"adapter.focus_hold": {
"anchor": "adapter-focus_hold",
"title": "Don't pause on short interruptions",
"group": "Game settings"
},
"adapter.focus_hold_ms": {
"anchor": "adapter-focus_hold_ms",
"title": "Ignore focus dips up to",
"group": "Game settings"
},
"adapter.foveation_fix": {
"anchor": "adapter-foveation_fix",
"title": "Hide Quest foveation",
"group": "Game settings"
},
"adapter.gl_hide_msrtt": {
"anchor": "adapter-gl_hide_msrtt",
"title": "Hide fast anti-aliasing (OpenGL ES)",
"group": "Game settings"
},
"adapter.gl_hide_multiview": {
"anchor": "adapter-gl_hide_multiview",
"title": "Draw each eye separately (OpenGL ES)",
"group": "Game settings"
},
"adapter.gl_highp_markers": {
"anchor": "adapter-gl_highp_markers",
"title": "GL shim: high-precision vertex shaders",
"group": "Game settings"
},
"adapter.gl_int_attribs": {
"anchor": "adapter-gl_int_attribs",
"title": "GL shim: integer vertex inputs",
"group": "Game settings"
},
"adapter.gl_mv_debug": {
"anchor": "adapter-gl_mv_debug",
"title": "Flat panel diagnostics (log)",
"group": "Game settings"
},
"adapter.haptic_fix": {
"anchor": "adapter-haptic_fix",
"title": "Fix freezes when controllers vibrate",
"group": "Game settings"
},
"adapter.haptic_scale": {
"anchor": "adapter-haptic_scale",
"title": "Vibration strength",
"group": "Game settings"
},
"adapter.hide_space_warp": {
"anchor": "adapter-hide_space_warp",
"title": "Turn off space warp",
"group": "Game settings"
},
"adapter.input_diag": {
"anchor": "adapter-input_diag",
"title": "Controller input diagnostics (log)",
"group": "Game settings"
},
"adapter.layer_debug": {
"anchor": "adapter-layer_debug",
"title": "Extra diagnostics (log)",
"group": "Game settings"
},
"adapter.layer_fix": {
"anchor": "adapter-layer_fix",
"title": "Drop broken layers",
"group": "Game settings"
},
"adapter.mutable_fix": {
"anchor": "adapter-mutable_fix",
"title": "Vulkan format workaround",
"group": "Game settings"
},
"adapter.ovrp_begin_gate": {
"anchor": "adapter-ovrp_begin_gate",
"title": "Unity frame loop: wait for skipped frames",
"group": "Game settings"
},
"adapter.ovrp_hold_physics": {
"anchor": "adapter-ovrp_hold_physics",
"title": "Steadier hands (older Unity)",
"group": "Game settings"
},
"adapter.passthrough_emul": {
"anchor": "adapter-passthrough_emul",
"title": "See your room",
"group": "Game settings"
},
"adapter.pose_consistency": {
"anchor": "adapter-pose_consistency",
"title": "Steadier head tracking",
"group": "Game settings"
},
"adapter.pose_debug": {
"anchor": "adapter-pose_debug",
"title": "Pose timing diagnostics (log)",
"group": "Game settings"
},
"adapter.pose_time_fix": {
"anchor": "adapter-pose_time_fix",
"title": "Fix hands lagging behind controllers",
"group": "Game settings"
},
"adapter.profile_remap": {
"anchor": "adapter-profile_remap",
"title": "Treat newer Quest controllers as Touch",
"group": "Game settings"
},
"adapter.proximity_emul": {
"anchor": "adapter-proximity_emul",
"title": "Animate fingers from touch",
"group": "Game settings"
},
"adapter.rect_clamp": {
"anchor": "adapter-rect_clamp",
"title": "Keep pictures in bounds",
"group": "Game settings"
},
"adapter.refresh_rate": {
"anchor": "adapter-refresh_rate",
"title": "Refresh rate",
"group": "Game settings"
},
"adapter.release_wait": {
"anchor": "adapter-release_wait",
"title": "Wait for the game's GPU",
"group": "Game settings"
},
"adapter.respace_kick": {
"anchor": "adapter-respace_kick",
"title": "Re-create play space",
"group": "Game settings"
},
"adapter.scale": {
"anchor": "adapter-scale",
"title": "Sharpness",
"group": "Game settings"
},
"adapter.scene_depth": {
"anchor": "adapter-scene_depth",
"title": "Room depth",
"group": "Game settings"
},
"adapter.scene_emul": {
"anchor": "adapter-scene_emul",
"title": "Pretend room layout",
"group": "Game settings"
},
"adapter.scene_height": {
"anchor": "adapter-scene_height",
"title": "Room height",
"group": "Game settings"
},
"adapter.scene_width": {
"anchor": "adapter-scene_width",
"title": "Room width",
"group": "Game settings"
},
"adapter.snapshot": {
"anchor": "adapter-snapshot",
"title": "Eye-image snapshots (diagnostics)",
"group": "Game settings"
},
"adapter.stable_local": {
"anchor": "adapter-stable_local",
"title": "Keep menus in place",
"group": "Game settings"
},
"adapter.strip_color_bias": {
"anchor": "adapter-strip_color_bias",
"title": "Drop layer color fades",
"group": "Game settings"
},
"adapter.strip_depth": {
"anchor": "adapter-strip_depth",
"title": "Strip depth layers",
"group": "Game settings"
},
"adapter.surface_emul": {
"anchor": "adapter-surface_emul",
"title": "Show video panels",
"group": "Game settings"
},
"adapter.surface_native": {
"anchor": "adapter-surface_native",
"title": "Native video surfaces (Batman renderer)",
"group": "Game settings"
},
"adapter.swap_eyes": {
"anchor": "adapter-swap_eyes",
"title": "Swap eyes",
"group": "Game settings"
},
"adapter.swapchain_fix": {
"anchor": "adapter-swapchain_fix",
"title": "Retry refused picture formats",
"group": "Game settings"
},
"adapter.sync_guard": {
"anchor": "adapter-sync_guard",
"title": "Steady controller input after focus",
"group": "Game settings"
},
"adapter.vk_hide_fdm": {
"anchor": "adapter-vk_hide_fdm",
"title": "Hide Vulkan foveation",
"group": "Game settings"
},
"adapter.vk_query_slots": {
"anchor": "adapter-vk_query_slots",
"title": "Stop objects popping in and out",
"group": "Game settings"
},
"adapter.vk_shader_dump": {
"anchor": "adapter-vk_shader_dump",
"title": "Vulkan shader dump (diagnostics)",
"group": "Game settings"
},
"adapter.vk_shader_fix": {
"anchor": "adapter-vk_shader_fix",
"title": "Vulkan shader fixes",
"group": "Game settings"
},
"adapter.vk_spec_fixes": {
"anchor": "adapter-vk_spec_fixes",
"title": "Fix depth flicker (Unreal Vulkan)",
"group": "Game settings"
},
"adapter.vk_validation": {
"anchor": "adapter-vk_validation",
"title": "Vulkan error checks (log)",
"group": "Game settings"
},
"adapter.zink_shader_dump": {
"anchor": "adapter-zink_shader_dump",
"title": "OpenGL ES shader dump (diagnostics)",
"group": "Game settings"
},
"adapter.zink_shader_fix": {
"anchor": "adapter-zink_shader_fix",
"title": "OpenGL ES shader fixes",
"group": "Game settings"
},
"device.config_sync": {
"anchor": "device-config_sync",
"title": "Keep the game's saved resolution in step",
"group": "Files and settings on the Frame"
},
"device.display_mode": {
"anchor": "device-display_mode",
"title": "Show as VR or as a flat window",
"group": "Files and settings on the Frame"
},
"device.files": {
"anchor": "device-files",
"title": "Settings files for the game",
"group": "Files and settings on the Frame"
},
"device.foveation": {
"anchor": "device-foveation",
"title": "Eye-tracked foveation (Valve)",
"group": "Files and settings on the Frame"
},
"device.hide_navbar": {
"anchor": "device-hide_navbar",
"title": "Hide Android's navigation bar",
"group": "Files and settings on the Frame"
},
"device.lepton_env": {
"anchor": "device-lepton_env",
"title": "Extra Lepton settings",
"group": "Files and settings on the Frame"
},
"device.steam_gamepad": {
"anchor": "device-steam_gamepad",
"title": "Controllers as a gamepad (Steam Input)",
"group": "Files and settings on the Frame"
},
"device.text_input_window": {
"anchor": "device-text_input_window",
"title": "Show the app's Android window (for typing)",
"group": "Files and settings on the Frame"
},
"frame.adapter": {
"anchor": "frame-adapter",
"title": "FrameBridge OpenXR adapter",
"group": "Steam Frame patches"
},
"frame.asset_files": {
"anchor": "frame-asset_files",
"title": "Content files from the game's data",
"group": "Steam Frame patches"
},
"frame.avatar_stub": {
"anchor": "frame-avatar_stub",
"title": "Skip Meta avatars",
"group": "Steam Frame patches"
},
"frame.blazerush": {
"anchor": "frame-blazerush",
"title": "BlazeRush: controllers without Meta avatars, own shader cache",
"group": "Steam Frame patches"
},
"frame.gl_multiview_fbo": {
"anchor": "frame-gl_multiview_fbo",
"title": "OpenGL ES: multiview shaders on flat panels (HUD, menus)",
"group": "Steam Frame patches"
},
"frame.gl_shim": {
"anchor": "frame-gl_shim",
"title": "GL shim (Mesa GLSL compatibility)",
"group": "Steam Frame patches"
},
"frame.godot_clipboard": {
"anchor": "frame-godot_clipboard",
"title": "Godot apps: start without a clipboard service",
"group": "Steam Frame patches"
},
"frame.hw_video_decode": {
"anchor": "frame-hw_video_decode",
"title": "Hardware video decoding (H.264, HEVC, VP9)",
"group": "Steam Frame patches"
},
"frame.langpacks": {
"anchor": "frame-langpacks",
"title": "Language packs from the game's files",
"group": "Steam Frame patches"
},
"frame.launcher": {
"anchor": "frame-launcher",
"title": "Launcher category (INFO → LAUNCHER)",
"group": "Steam Frame patches"
},
"frame.ltw_depth": {
"anchor": "frame-ltw_depth",
"title": "GL wrapper: OpenGL ES depth formats (Minecraft)",
"group": "Steam Frame patches"
},
"frame.meta_permissions": {
"anchor": "frame-meta_permissions",
"title": "Declare Meta-only permissions",
"group": "Steam Frame patches"
},
"frame.metaxr_telemetry": {
"anchor": "frame-metaxr_telemetry",
"title": "Meta XR Audio: skip Quest telemetry class",
"group": "Steam Frame patches"
},
"frame.nodebug": {
"anchor": "frame-nodebug",
"title": "Clear android:debuggable",
"group": "Steam Frame patches"
},
"frame.oculusos": {
"anchor": "frame-oculusos",
"title": "Oculus OS telemetry class stubs",
"group": "Steam Frame patches"
},
"frame.ovr_microphone": {
"anchor": "frame-ovr_microphone",
"title": "Fix crash when the game prepares the microphone",
"group": "Steam Frame patches"
},
"frame.ovr_trace": {
"anchor": "frame-ovr_trace",
"title": "Trace Meta platform requests (diagnostics)",
"group": "Steam Frame patches"
},
"frame.ovrplatformcompat": {
"anchor": "frame-ovrplatformcompat",
"title": "Platform compat (ovrMessageType_ToString)",
"group": "Steam Frame patches"
},
"frame.ovrstubs": {
"anchor": "frame-ovrstubs",
"title": "Stub missing Meta platform functions",
"group": "Steam Frame patches"
},
"frame.pac_hints": {
"anchor": "frame-pac_hints",
"title": "Unpaired pointer-authentication checks",
"group": "Steam Frame patches"
},
"frame.sdl_clipboard": {
"anchor": "frame-sdl_clipboard",
"title": "SDL apps: start without a clipboard service",
"group": "Steam Frame patches"
},
"frame.slz_vulkan_hooks": {
"anchor": "frame-slz_vulkan_hooks",
"title": "Stress Level Zero: let Unity start Vulkan itself",
"group": "Steam Frame patches"
},
"frame.start_activity": {
"anchor": "frame-start_activity",
"title": "Start straight in VR (skip the 2D launcher)",
"group": "Steam Frame patches"
},
"frame.swapchain_limit": {
"anchor": "frame-swapchain_limit",
"title": "Allow swapchains larger than 4096 px",
"group": "Steam Frame patches"
},
"frame.tbxr_vendor": {
"anchor": "frame-tbxr_vendor",
"title": "Team Beef ports: treat the Frame as a Meta headset",
"group": "Steam Frame patches"
},
"frame.unity_gl_shim": {
"anchor": "frame-unity_gl_shim",
"title": "GL shim for Unity (no multisampled render-to-texture)",
"group": "Steam Frame patches"
},
"frame.unity_multipass": {
"anchor": "frame-unity_multipass",
"title": "Unity: render each eye separately (no multiview)",
"group": "Steam Frame patches"
},
"frame.unity_no_msaa": {
"anchor": "frame-unity_no_msaa",
"title": "Unity: disable MSAA",
"group": "Steam Frame patches"
},
"frame.unity_no_overlay_copy": {
"anchor": "frame-unity_no_overlay_copy",
"title": "Unity: skip OVROverlay layers (fades, splash screens)",
"group": "Steam Frame patches"
},
"frame.unity_oculus_check": {
"anchor": "frame-unity_oculus_check",
"title": "Unity: start VR without Meta's system apps",
"group": "Steam Frame patches"
},
"frame.unity_runtime_msaa_off": {
"anchor": "frame-unity_runtime_msaa_off",
"title": "Unity: keep MSAA off at runtime (OVRManager)",
"group": "Steam Frame patches"
},
"frame.unity_text_input": {
"anchor": "frame-unity_text_input",
"title": "Make Unity text fields work",
"group": "Steam Frame patches"
},
"frame.unity_user_presence": {
"anchor": "frame-unity_user_presence",
"title": "Unity: headset always counts as worn",
"group": "Steam Frame patches"
},
"frame.unreal_gl_shim": {
"anchor": "frame-unreal_gl_shim",
"title": "GL shim for Unreal (no multisampled render-to-texture)",
"group": "Steam Frame patches"
},
"frame.unreal_ovrp_entrypoints": {
"anchor": "frame-unreal_ovrp_entrypoints",
"title": "Unreal: stand-ins for OVRPlugin functions OVRPort lacks",
"group": "Steam Frame patches"
},
"frame.unreal_quest_keymap": {
"anchor": "frame-unreal_quest_keymap",
"title": "Unreal: Quest controller bindings (RPOC key map)",
"group": "Steam Frame patches"
},
"frame.unreal_quest_precompile": {
"anchor": "frame-unreal_quest_precompile",
"title": "Unreal: don't wait for the Quest shader precompile",
"group": "Steam Frame patches"
},
"frame.unreal_skip_obb_check": {
"anchor": "frame-unreal_skip_obb_check",
"title": "Unreal: start without the OBB check",
"group": "Steam Frame patches"
},
"frame.unreal_thumb_touch": {
"anchor": "frame-unreal_thumb_touch",
"title": "Unreal: thumb follows the capacitive touches",
"group": "Steam Frame patches"
},
"frame.vivox_audio_route": {
"anchor": "frame-vivox_audio_route",
"title": "Vivox voice chat: skip Android 12 audio routing",
"group": "Steam Frame patches"
},
"frame.vk_sanitize": {
"anchor": "frame-vk_sanitize",
"title": "Vulkan: drop invalid render-pass pointers",
"group": "Steam Frame patches"
},
"frame.vrapi_bridge": {
"anchor": "frame-vrapi_bridge",
"title": "VrApi → OpenXR bridge",
"group": "Steam Frame patches"
},
"frame.vrapi_stub": {
"anchor": "frame-vrapi_stub",
"title": "Quiet Meta's VrApi loader (OpenXR games)",
"group": "Steam Frame patches"
},
"frame.zink_shader_fix": {
"anchor": "frame-zink_shader_fix",
"title": "OpenGL ES: shader fixes (Vulkan layer under Zink)",
"group": "Steam Frame patches"
},
"patch_ac_nexus_no_appsw_72": {
"anchor": "patch_ac_nexus_no_appsw_72",
"title": "AC Nexus: no AppSW at 72 Hz",
"group": "Patches from OVRPort"
},
"patch_ac_nexus_no_appsw_90": {
"anchor": "patch_ac_nexus_no_appsw_90",
"title": "AC Nexus: no AppSW at 90 Hz",
"group": "Patches from OVRPort"
},
"patch_clean_up_frida": {
"anchor": "patch_clean_up_frida",
"title": "Clean up Frida leftovers in smali",
"group": "Patches from OVRPort"
},
"patch_copy_libraries": {
"anchor": "patch_copy_libraries",
"title": "Copy OVRPort libraries",
"group": "Patches from OVRPort"
},
"patch_copy_ovrplugin_vrapi": {
"anchor": "patch_copy_ovrplugin_vrapi",
"title": "Copy OVRPlugin if VrApi is present",
"group": "Patches from OVRPort"
},
"patch_disable_controller_offset": {
"anchor": "patch_disable_controller_offset",
"title": "Disable controller tracking offset",
"group": "Patches from OVRPort"
},
"patch_disable_meta_xr_audio_telemetry": {
"anchor": "patch_disable_meta_xr_audio_telemetry",
"title": "Disable Meta XR Audio telemetry",
"group": "Patches from OVRPort"
},
"patch_disable_space_warp": {
"anchor": "patch_disable_space_warp",
"title": "Disable application space warp if used",
"group": "Patches from OVRPort"
},
"patch_fix_min_android_sdk": {
"anchor": "patch_fix_min_android_sdk",
"title": "Fix minimal Android SDK",
"group": "Patches from OVRPort"
},
"patch_fix_unreal_crash": {
"anchor": "patch_fix_unreal_crash",
"title": "Fix UE4 crash with Unity stub",
"group": "Patches from OVRPort"
},
"patch_force_passthrough": {
"anchor": "patch_force_passthrough",
"title": "Force enable passthrough",
"group": "Patches from OVRPort"
},
"patch_generate_config": {
"anchor": "patch_generate_config",
"title": "Generate OVRPort config",
"group": "Patches from OVRPort"
},
"patch_launcher_entry": {
"anchor": "patch_launcher_entry",
"title": "Fix launcher icon entry",
"group": "Patches from OVRPort"
},
"patch_mark_allow_backup": {
"anchor": "patch_mark_allow_backup",
"title": "Mark application to allow backup",
"group": "Patches from OVRPort"
},
"patch_mark_as_debuggable": {
"anchor": "patch_mark_as_debuggable",
"title": "Mark application as debuggable",
"group": "Patches from OVRPort"
},
"patch_meta_xr_audio": {
"anchor": "patch_meta_xr_audio",
"title": "Patch Meta XR Audio",
"group": "Patches from OVRPort"
},
"patch_oculus_unity": {
"anchor": "patch_oculus_unity",
"title": "Patch Oculus detection for Unity",
"group": "Patches from OVRPort"
},
"patch_oculus_unreal": {
"anchor": "patch_oculus_unreal",
"title": "Patch Oculus detection for Unreal",
"group": "Patches from OVRPort"
},
"patch_remove_localized_names": {
"anchor": "patch_remove_localized_names",
"title": "Remove localized app names",
"group": "Patches from OVRPort"
},
"patch_remove_unreal_force_quit": {
"anchor": "patch_remove_unreal_force_quit",
"title": "Remove Unreal's ForceQuit",
"group": "Patches from OVRPort"
},
"patch_remove_uses_library": {
"anchor": "patch_remove_uses_library",
"title": "Remove uses-library",
"group": "Patches from OVRPort"
},
"patch_remove_vrapi": {
"anchor": "patch_remove_vrapi",
"title": "Remove VrApi library",
"group": "Patches from OVRPort"
},
"patch_replace_icon_label": {
"anchor": "patch_replace_icon_label",
"title": "Replace application label and icon",
"group": "Patches from OVRPort"
},
"patch_vr_metadata": {
"anchor": "patch_vr_metadata",
"title": "Pico/YVR/Quest metadata",
"group": "Patches from OVRPort"
},
"patch_vrapi_openxr": {
"anchor": "patch_vrapi_openxr",
"title": "VrApi → OpenXR adapter (OVRPort)",
"group": "Patches from OVRPort"
},
"pcvr.launch_args": {
"anchor": "pcvr-launch_args",
"title": "Launch options (SteamVR or OpenXR)",
"group": "PC VR (Proton and Revive)"
},
"pcvr.libovr_redirect": {
"anchor": "pcvr-libovr_redirect",
"title": "Provide the Oculus runtime from Revive (Frame)",
"group": "PC VR (Proton and Revive)"
},
"pcvr.no_crash_reporter": {
"anchor": "pcvr-no_crash_reporter",
"title": "No Unreal crash reporter",
"group": "PC VR (Proton and Revive)"
},
"pcvr.oculus_unreal": {
"anchor": "pcvr-oculus_unreal",
"title": "Patch Oculus detection for Unreal (Frame)",
"group": "PC VR (Proton and Revive)"
},
"pcvr.proton_env": {
"anchor": "pcvr-proton_env",
"title": "Extra Proton settings (Frame)",
"group": "PC VR (Proton and Revive)"
},
"pcvr.proton_log": {
"anchor": "pcvr-proton_log",
"title": "Proton debug log (Frame)",
"group": "PC VR (Proton and Revive)"
},
"pcvr.proton_tool": {
"anchor": "pcvr-proton_tool",
"title": "Proton version (Frame)",
"group": "PC VR (Proton and Revive)"
},
"pcvr.repack_launcher": {
"anchor": "pcvr-repack_launcher",
"title": "Use the repack's launcher (bundled Revive)",
"group": "PC VR (Proton and Revive)"
},
"pcvr.revive": {
"anchor": "pcvr-revive",
"title": "Revive (Oculus LibOVR → OpenXR)",
"group": "PC VR (Proton and Revive)"
},
"pcvr.revive_openvr": {
"anchor": "pcvr-revive_openvr",
"title": "Revive: SteamVR (OpenVR) backend on PC",
"group": "PC VR (Proton and Revive)"
},
"pcvr.steamvr_tuning": {
"anchor": "pcvr-steamvr_tuning",
"title": "Automatic SteamVR performance settings (this PC)",
"group": "PC VR (Proton and Revive)"
},
"pcvr.vr_overlay": {
"anchor": "pcvr-vr_overlay",
"title": "SteamVR overlay app (Frame)",
"group": "PC VR (Proton and Revive)"
},
"pcvr.xr_timefix": {
"anchor": "pcvr-xr_timefix",
"title": "Frame OpenXR compatibility layer",
"group": "PC VR (Proton and Revive)"
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ const count = (n: number) => (n === 1 ? '1 change' : `${n} changes`);
{RELEASES.length === 0 && <p class="lead">No releases yet. See <a href={`${REPO}/releases`}>GitHub</a>.</p>}
</div>
</main>
<Footer />
<Footer credits="contributors" />
</Layout>
<script>
+2
View File
@@ -45,6 +45,8 @@ const prev = DOCS[i - 1], next = DOCS[i + 1];
</Layout>
<style>
/* long paths and identifiers (the patch reference has many) wrap instead of widening the page on phones */
.prose :global(:is(p, li, blockquote)) { overflow-wrap: break-word; }
.crumb { font-size: 13px; color: var(--text-2); margin-bottom: 10px; }
.crumb a { color: var(--text-2); }
.crumb span { margin: 0 4px; color: var(--text-3); }
+1 -1
View File
@@ -60,5 +60,5 @@ const logGames = games.filter((g) => g.status !== 'unsupported' && g.platform ==
<Install />
<WhatsNew />
</main>
<Footer />
<Footer credits="all" />
</Layout>
+10 -1
View File
@@ -21,10 +21,12 @@ import { DOWNLOADS, downloadPage, url } from '../../data/site';
<article class="item" data-item hidden>
<div class="item-icon" data-icon aria-hidden="true"><Glyph name="frame" size={30} /></div>
<div class="item-text">
<p class="item-kicker">This link installs</p>
<p class="item-kicker">This link installs <span class="demo-tag" data-demo hidden>Demo link</span></p>
<h1 data-name>…</h1>
<p class="item-desc" data-desc hidden></p>
<p class="item-from"><span data-files></span> <span class="from">from <b data-host></b></span></p>
<p class="demo-note" data-demo hidden>This is the example button from frameport.app: use it to check that
FramePort receives install links. Real buttons install real games.</p>
</div>
</article>
@@ -107,6 +109,10 @@ import { DOWNLOADS, downloadPage, url } from '../../data/site';
$('[data-files]').textContent = parsed.kind === 'url' ? '' : 'A manifest';
$('[data-item]').hidden = false;
document.title = `Install ${parsed.kind === 'url' ? fileName : 'with FramePort'}`;
if (parsed.demo) {
page.querySelectorAll<HTMLElement>('[data-demo]').forEach((el) => { el.hidden = false; });
$('[data-name]').textContent = 'Cool Game';
}
// the manifest's own name and description, when its host allows reading it from a page (CORS)
if (parsed.kind === 'manifest') {
@@ -173,6 +179,9 @@ import { DOWNLOADS, downloadPage, url } from '../../data/site';
.item-desc { color: var(--text-2); white-space: pre-line; }
.item-from { font: 400 13px/1.5 var(--font-mono); color: var(--text-2); overflow-wrap: anywhere; }
.item-from b { color: var(--text); font-weight: 500; }
.demo-tag { margin-left: 8px; padding: 3px 8px; border-radius: 20px; letter-spacing: .08em; color: var(--secondary);
background: var(--secondary-soft); }
.demo-note { font-size: 14px; color: var(--text-2); }
[data-when] { display: none; }
.page[data-state="opening"] [data-when~="opening"],
+9
View File
@@ -32,3 +32,12 @@ test('rows escape what comes from GitHub', () => {
assert.ok(html.includes('&#60;img'));
assert.ok(html.includes('data-engine="&#34;x"'));
});
test('recipe chips link to the patch reference when a link is given', () => {
const g = toGame({ title: 'A', status: 'works', frame: ['frame.x', 'frame.unknown'] });
const link = (id) => (id === 'frame.x' ? '/docs/patches/#frame-x' : null);
const html = rowHtml(g, false, link);
assert.match(html, /<a href="\/docs\/patches\/#frame-x" [^>]*><code>frame\.x<\/code><\/a>/);
assert.match(html, /<code>frame\.unknown<\/code>/);
assert.doesNotMatch(rowHtml(g), /<a /);
});
+12 -4
View File
@@ -71,13 +71,21 @@ export function sortGames(games: Game[]): Game[] {
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
export function rowHtml(g: Game, showPackage = false): string {
/** Where a recipe chip links to (its entry in the patch reference), or null for none. */
export type PatchLink = (id: string) => string | null;
const chip = (p: string, link?: PatchLink) => {
const href = link?.(p);
return href ? `<a href="${esc(href)}" title="What this patch does"><code>${esc(p)}</code></a>` : `<code>${esc(p)}</code>`;
};
export function rowHtml(g: Game, showPackage = false, link?: PatchLink): string {
const s = STATUS[g.status];
const facts = [g.version && `tested with version ${g.version}`, g.app && `checked with FramePort ${g.app}`, g.checked]
.filter(Boolean).join(' · ');
const more = `<div class="more" role="cell" hidden>`
+ (g.details ? `<p>${esc(g.details)}</p>` : '<p>Runs with the default patches; nothing special needed.</p>')
+ (g.patches.length ? `<p class="pt"><span>Recipe</span>${g.patches.map((p) => `<code>${esc(p)}</code>`).join('')}</p>` : '')
+ (g.patches.length ? `<p class="pt"><span>Recipe</span>${g.patches.map((p) => chip(p, link)).join('')}</p>` : '')
+ (facts ? `<p class="fx">${esc(facts)}</p>` : '') + '</div>';
return `<li class="row" role="row" tabindex="0" aria-expanded="false" data-status="${g.status}" data-engine="${esc(g.engine)}" `
+ `data-text="${esc(`${g.title} ${g.engine} ${g.xr} ${g.platform} ${g.patches.join(' ')}`.toLowerCase())}">`
@@ -92,11 +100,11 @@ export function rowHtml(g: Game, showPackage = false): string {
}
/** All rows; games that share a title and platform (e.g. two builds) show their package to tell them apart. */
export function rowsHtml(games: Game[]): string {
export function rowsHtml(games: Game[], link?: PatchLink): string {
const seen = new Map<string, number>();
const key = (g: Game) => `${g.title.toLowerCase()}|${g.platform}`;
for (const g of games) seen.set(key(g), (seen.get(key(g)) ?? 0) + 1);
return games.map((g) => rowHtml(g, seen.get(key(g))! > 1)).join('');
return games.map((g) => rowHtml(g, seen.get(key(g))! > 1, link)).join('');
}
export function counts(games: Game[]): Record<Status, number> {
+22 -1
View File
@@ -1,7 +1,8 @@
// node --test --experimental-strip-types src/scripts/ (npm test). Cases from tests/test_deeplink.py.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkUrl, parseInstallParams, readManifest, toFrameportLink } from './install-link.ts';
import { readFileSync } from 'node:fs';
import { DEMO_MANIFEST, checkUrl, isDemo, parseInstallParams, readManifest, toFrameportLink } from './install-link.ts';
const MANIFEST = 'https://cdn.example.com/game.framedrop.json';
@@ -55,3 +56,23 @@ test('manifest preview', () => {
assert.equal(readManifest({ schema: 'framedrop.install/v2', name: 'x', files: [{ url: 'https://a.com/x.apk' }] }), null);
assert.equal(readManifest({ schema: 'framedrop.install/v1', files: [{ url: 'https://a.com/x.apk' }] }), null);
});
test('the homepage demo link is recognised (and only it)', () => {
const p = parseInstallParams(`?manifest=${encodeURIComponent(DEMO_MANIFEST)}`);
assert.deepEqual([p.ok, p.kind, p.demo], [true, 'manifest', true]);
assert.equal(p.link, `frameport://install?manifest=${encodeURIComponent(DEMO_MANIFEST)}`);
assert.equal(parseInstallParams(`?manifest=${encodeURIComponent(MANIFEST)}`).demo, false);
assert.equal(isDemo('https://www.frameport.app/demo/cool-game.json'), true);
for (const u of ['https://frameport.app/demo/cool-game.json?x=1', 'https://frameport.app.evil.com/demo/cool-game.json',
'http://frameport.app/demo/cool-game.json', 'https://frameport.app:8443/demo/cool-game.json']) {
assert.equal(isDemo(u), false, u);
}
});
test('the published demo manifest previews as Cool Game', () => {
const data = JSON.parse(readFileSync(new URL('../../public/demo/cool-game.json', import.meta.url), 'utf8'));
const m = readManifest(data);
assert.equal(m?.name, 'Cool Game');
assert.equal(m?.icon, 'https://frameport.app/demo/cool-game.svg');
assert.deepEqual(m?.files, ['cool-game-arm64.apk']);
});
+17 -3
View File
@@ -3,7 +3,20 @@
// lookups here: the app checks where a name resolves right before it downloads.
export type Kind = 'manifest' | 'url';
export type Parsed = { ok: true; kind: Kind; target: string; link: string } | { ok: false; error: string };
export type Parsed = { ok: true; kind: Kind; target: string; link: string; demo: boolean } | { ok: false; error: string };
/** The homepage's example button installs this placeholder manifest (public/demo/cool-game.json); the app knows it
* (deeplink.DEMO_MANIFEST) and only shows a demo, nothing is downloaded. */
export const DEMO_MANIFEST = 'https://frameport.app/demo/cool-game.json';
/** Whether a manifest address is the demo one (deeplink.is_demo: https, frameport.app, that path, nothing else). */
export function isDemo(text: string): boolean {
let url: URL;
try { url = new URL(text.trim()); } catch { return false; }
return url.protocol === 'https:' && (url.hostname === 'frameport.app' || url.hostname === 'www.frameport.app')
&& !url.port && !url.username && !url.password && url.pathname === new URL(DEMO_MANIFEST).pathname
&& !url.search && !url.hash;
}
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
const APP_FILES = ['.apk', '.exe', '.zip', '.tar.gz', '.tgz', '.tar.xz', '.txz', '.tar.bz2', '.tar', '.appimage'];
@@ -66,7 +79,8 @@ export function parseInstallParams(search: string): Parsed {
const file = q.get('url')?.trim();
if (manifest) {
const error = checkUrl(manifest, 'manifest address');
return error ? { ok: false, error } : { ok: true, kind: 'manifest', target: manifest, link: toFrameportLink('manifest', manifest) };
if (error) return { ok: false, error };
return { ok: true, kind: 'manifest', target: manifest, link: toFrameportLink('manifest', manifest), demo: isDemo(manifest) };
}
if (file) {
const error = checkUrl(file, 'file address');
@@ -75,7 +89,7 @@ export function parseInstallParams(search: string): Parsed {
if (!APP_FILES.some((ext) => name.endsWith(ext))) {
return { ok: false, error: 'The file address must point to an APK, a Windows program (.exe) or a Linux app (zip, tar or AppImage).' };
}
return { ok: true, kind: 'url', target: file, link: toFrameportLink('url', file) };
return { ok: true, kind: 'url', target: file, link: toFrameportLink('url', file), demo: false };
}
return { ok: false, error: "The link doesn't say what to install (no manifest= or url=)." };
}
+25 -5
View File
@@ -25,10 +25,12 @@ function cursorLight() {
document.addEventListener('pointerleave', () => light.classList.remove('on'));
}
/** Clicking empty space: the app's Play ripple, a blue ring then an orange one. */
/** Clicking empty space: the app's Play ripple, a blue ring then an orange one. Mouse (and pen) only: on a touch
* screen every tap or scroll start would draw it. */
function clickRipples() {
if (reduce) return;
addEventListener('pointerdown', (e) => {
if (e.pointerType === 'touch') return;
const t = e.target as Element;
if (t.closest('a, button, input, select, textarea, label, summary, [data-mock], [data-crossing], canvas, [role="button"], .row, [tabindex]')) return;
for (const [i, cls] of ['b', 'o'].entries()) {
@@ -129,17 +131,35 @@ export function countUp(root: ParentNode = document) {
root.querySelectorAll<HTMLElement>('[data-count]').forEach((el) => io.observe(el));
}
/** Scrolling away from the top flies the hero toward its portal; the hero's own scene leans in. */
/** Scrolling away flies the hero toward its portal; the hero's own scene leans in (--sp, 0..1, from the page's
* scroll over the hero's height). Stacked (phones, narrow windows) the scene sits below the text: measured that way it
* was already blurred and enlarged when it came into view and unreadable in the middle of the screen, so there it
* follows its own position: 0 until its middle rises above 40 % of the screen's height, 1 once it has left at the top. */
function heroScroll() {
const hero = document.querySelector<HTMLElement>('.hero');
if (!hero || reduce) return;
const copy = hero?.querySelector<HTMLElement>('.copy');
const scene = hero?.querySelector<HTMLElement>('.scene');
if (!hero || !copy || !scene || reduce) return;
const stacked = matchMedia('(max-width: 900px)'); // Hero.astro's one-column breakpoint
let raf = 0;
const update = () => {
raf = 0;
const p = Math.min(1, Math.max(0, scrollY / Math.max(1, hero.offsetHeight)));
hero.style.setProperty('--sp', p.toFixed(3));
const page = Math.min(1, Math.max(0, scrollY / Math.max(1, hero.offsetHeight)));
copy.style.setProperty('--sp', page.toFixed(3));
let p = page;
if (stacked.matches) {
// the layout box, not the transformed one (the transform is what --sp drives)
const parent = (scene.offsetParent as HTMLElement | null) ?? document.body;
const centre = parent.getBoundingClientRect().top + scene.offsetTop + scene.offsetHeight / 2;
// counting starts a little above the middle of the screen (fully sharp around the middle), or where the scene
// sits at the top of the page if that's higher, so it never starts out blurred
const start = Math.min(innerHeight * 0.4, centre + scrollY);
p = Math.min(1, Math.max(0, (start - centre) / (start + scene.offsetHeight / 2)));
}
scene.style.setProperty('--sp', p.toFixed(3));
};
addEventListener('scroll', () => { if (!raf) raf = requestAnimationFrame(update); }, { passive: true });
addEventListener('resize', update);
update();
}
+28
View File
@@ -0,0 +1,28 @@
// Patch ids → their entry in the patch reference (docs/PATCHES.md → /docs/patches/). The map is written by
// scripts/patch_docs.py next to the markdown, so the site needs no Python to build.
import map from '../data/patches.json';
import { url } from '../data/site';
export interface PatchInfo { anchor: string; title: string; group: string }
const PATCHES = map as Record<string, PatchInfo>;
/** The patch reference page itself. */
export const PATCH_DOC = url('docs/patches/');
/** The registry id behind a recipe reference: "overport.patch_x" (the board's OVRPort prefix) → "patch_x", lepton_env
* keys ("env.X") → device.lepton_env, "adapter.scale=0.85" → "adapter.scale". */
export function patchId(ref: string): string {
const id = ref.trim().split('=')[0];
if (id.startsWith('overport.')) return id.slice('overport.'.length);
if (id.startsWith('env.')) return 'device.lepton_env';
return id;
}
export function patchInfo(ref: string): PatchInfo | undefined {
return PATCHES[patchId(ref)];
}
/** The site link to a patch's entry, or null for an id the reference doesn't know. */
export function patchHref(ref: string): string | null {
const info = patchInfo(ref);
return info ? `${PATCH_DOC}#${info.anchor}` : null;
}
+1
View File
@@ -213,6 +213,7 @@ code, kbd, pre { font-family: var(--font-mono); }
.click-ring { position: fixed; z-index: 95; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; pointer-events: none;
border: 1px solid var(--secondary); opacity: 0; animation: click-ring .55s var(--ease-out) both; }
.click-ring.o { border-color: var(--accent); }
@media (hover: none) { .click-ring { display: none; } }
@keyframes click-ring { 0% { opacity: .45; transform: scale(1); } 100% { opacity: 0; transform: scale(2.6); } }
/* headings: words rise in, the pointer lights them in the portal's colours */
+1 -1
View File
@@ -1,2 +1,2 @@
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
__version__ = "0.12.0"
__version__ = "1.0.1"
+20 -1
View File
@@ -30,7 +30,9 @@ IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
# 8: media_codec (plays video through Android's decoders: MediaCodec/ExoPlayer/Media3 or VLC: frame.hw_video_decode)
# 9: gamepad (the manifest declares gamepad support: android.hardware.gamepad / Android TV's LEANBACK_LAUNCHER:
# device.steam_gamepad) (2026-10)
ANALYSIS_VERSION = 9
# 10: godot_clipboard (Godot 4.2-4.4's non-null ClipboardManager cast: frame.godot_clipboard) (2026-10)
# 11: unreal_verify_obb (Unreal's bVerifyOBBOnStartUp: frame.unreal_skip_obb_check, GitHub #159) (2026-10)
ANALYSIS_VERSION = 11
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
@@ -62,6 +64,15 @@ def expects_obb(meta: dict) -> bool:
return any(k.endswith(UE_OBB_KEY) and v in (True, "true", "True") for k, v in meta.items())
UE_VERIFY_OBB_KEY = ".GameActivity.bVerifyOBBOnStartUp"
def unreal_verify_obb(meta: dict) -> bool:
"""Unreal's DownloaderActivity checks every OBB entry's CRC at the first start (a full read; on a mismatch it waits
on an error screen). Only a real boolean counts: GameActivity reads it with Bundle.getBoolean."""
return any(k.endswith(UE_VERIFY_OBB_KEY) and v is True for k, v in meta.items())
def _read_manifest_info(path: Path):
"""(package, versionName, label, main activity, pyaxmlparser APK)."""
from pyaxmlparser import APK
@@ -152,6 +163,9 @@ def vr_kind(libs: set[str], manifest_strings: list[str]) -> str:
return "none"
# Kotlin's message for a failed non-null cast to ClipboardManager (Godot 4.2-4.4; 4.1 is Java, 4.5+ uses `as?`)
GODOT_CLIPBOARD_CAST = b"null cannot be cast to non-null type android.content.ClipboardManager"
# dex type names of Android's video decoding APIs (frame.hw_video_decode)
MEDIA_CODEC_MARKERS = (b"Landroid/media/MediaCodec;", b"Landroidx/media3/exoplayer", b"Lcom/google/android/exoplayer2/")
@@ -171,6 +185,8 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
# SDL's Java side (SDL2 / LÖVE apps): crashes in Lepton without a clipboard service (frame.sdl_clipboard)
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)
# Godot 4.2-4.4's Kotlin `as ClipboardManager` (non-null cast): the same crash (frame.godot_clipboard)
godot_clipboard = any(b"Lorg/godotengine/godot/Godot;" in d and GODOT_CLIPBOARD_CAST 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)
# video through Android's decoders (frame.hw_video_decode): Java MediaCodec, ExoPlayer/Media3, or libVLC
@@ -294,6 +310,7 @@ 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,
"godot_clipboard": godot_clipboard,
"vivox_api31": vivox_api31,
"media_codec": media_codec,
# declares gamepad support (SDL's manifest template has both): device.steam_gamepad
@@ -312,6 +329,8 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
# 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 CRC-checks the OBB before the game starts (frame.unreal_skip_obb_check, GitHub #159)
"unreal_verify_obb": unreal_verify_obb(meta),
# 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 []),
+18
View File
@@ -260,6 +260,24 @@ def meta_data(manifest: bytes) -> dict[str, str | int | bool | None]:
return out
def set_meta_data_bool(manifest: bytes, name_suffix: str, value: bool) -> bytes | None:
"""Set the boolean android:value of every <meta-data> whose android:name ends with `name_suffix` (one 4-byte
value per element, nothing else moves). None when no such boolean entry differs from `value`. A string value
("true") is left alone: Android's Bundle.getBoolean reads only real booleans."""
x = Axml(manifest)
names = x.strings()
hits = 0
for el in x.elements():
if el.name != "meta-data" or not (x.attr_str(el, "name") or "").endswith(name_suffix):
continue
for a in el.attrs:
if (a.name < len(names) and names[a.name] == "value" and a.dtype == TYPE_INT_BOOLEAN
and (a.value != 0) != value):
struct.pack_into("<I", x.data, a.offset + 16, 0xFFFFFFFF if value else 0)
hits += 1
return x.bytes() if hits else None
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}
+15 -7
View File
@@ -11,6 +11,7 @@ INVOKE_VIRTUAL, INVOKE_VIRTUAL_RANGE = 0x6E, 0x74 # 35c / 3rc: 3 code units, me
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
CONST_STRING_V0, RETURN_OBJECT_V0 = 0x001A, 0x0011 # const-string v0, string@BBBB ; return-object v0
def _widths() -> list[int]:
@@ -78,6 +79,11 @@ class Dex:
"""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_names(self) -> list[str]:
"""Type names of the classes defined in this dex."""
return [self.type_name(struct.unpack_from("<I", self.data, self.classes_off + 32 * c)[0])
for c in range(self.classes_n)]
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 = []
@@ -147,16 +153,18 @@ class Dex:
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."""
"""Make a method return at once: its first instruction(s) become `return-void` (ret "V"), `const/4 v0, 0;
return v0` (ret Z/B/S/C/I: false / 0) or `const-string v0, ""; return-object v0` (ret Ljava/lang/String;: the
empty string, only when the dex has it), 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, other objects) or its first instructions can't be decoded."""
regs = struct.unpack_from("<H", self.data, code_off)[0] # registers_size: v0 must exist for a value
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
elif ret in ("Z", "B", "S", "C", "I") and regs:
new = [CONST4_V0_0, RETURN_V0]
elif ret == "Ljava/lang/String;" and regs and self.strings_n and self.string(0) == "":
new = [CONST_STRING_V0, 0, RETURN_OBJECT_V0] # string ids are sorted: "" is always the first
else:
return False
start = code_off + 16
+46 -1
View File
@@ -5,6 +5,7 @@ Errors are one line on stderr; FRAMEPORT_DEBUG=1 shows the traceback.
frameport tools install # portable Java + OVRPort + apksigner
frameport scan "<folder with game dumps>" # analyze + suggest recipes
frameport list | show <pkg> | patches
frameport rename <pkg> "<new name>" # the name in the library and in Steam (--reset: automatic)
frameport recipe <pkg> --enable frame.nodebug --set scale=1.2
frameport build <pkg>|--all
frameport frame discover | pair | info --frame steamos@frame.local
@@ -282,7 +283,12 @@ def open_link(link: str = typer.Argument(..., help="an \"Install with FrameDrop\
typer.echo("handed to the FramePort window")
return
try:
m = deeplink.fetch_manifest(deeplink.parse(link))
req = deeplink.parse(link)
if req.demo: # the homepage's example button: nothing to fetch or install
typer.echo("This is FramePort's demo link (the example button on frameport.app): FramePort receives it; "
"nothing is downloaded or installed.")
return
m = deeplink.fetch_manifest(req)
except deeplink.LinkError as exc:
raise typer.BadParameter(str(exc)) from None
typer.echo(f"{m.name} (from {m.host or m.source})")
@@ -353,6 +359,45 @@ def show(package: str, as_json: bool = typer.Option(False, "--json", help=JSON_H
typer.echo(f" alternate build adds: {', '.join(r['alt_patches'])} (installed: {which})")
@app.command()
def rename(package: str = typer.Argument(..., help="the game (package or part of its title)"),
title: Optional[str] = typer.Argument(None, help="the new name"),
reset: bool = typer.Option(False, "--reset", help="back to FramePort's own name (store or APK name)"),
sync: bool = typer.Option(True, "--sync/--no-sync",
help="also rename it in Steam where it's installed (Steam restarts once)"),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""Rename a game in the library and in Steam. The name is kept from then on (scans and store lookups don't
change it); its Steam entry keeps its id, so Play, artwork and saves stay."""
[pkg] = _pkgs(package, False)
if reset == bool(title):
raise typer.BadParameter("give a new name or --reset")
if not reset and not pipeline.clean_title(title):
raise typer.BadParameter("the new name is empty")
before = library.game(pkg).get("title")
g = pipeline.rename_game(pkg, None if reset else title)
typer.echo(f"{pkg}: {before} -> {g['title']}" + ("" if g.get("title_locked") else " (automatic name)"))
if g["title"] == before or not sync:
if g.get("steam_name_stale"):
typer.echo("The Frame's Steam library keeps the old name until `frameport rename` runs with --sync or "
"the game is installed again.")
return
from .targets.base import PC_LABEL
rep = printing_reporter(verbose=False)
installs = g.get("installs") or {}
if any(w != PC_LABEL for w in installs):
try:
pipeline.sync_title(pkg, _target(frame), rep)
except Exception as exc: # noqa: BLE001 - the library name is saved either way
typer.echo(f"Steam on the Frame wasn't renamed ({exc}); run this again when the Frame is reachable.",
err=True)
if g.get("kind") == "rift":
from .targets.pc_revive import local_installs
if pkg in local_installs():
pipeline.sync_title(pkg, _target(None, to="pc"), rep)
@app.command()
def patches():
"""List every available patch."""
+2 -2
View File
@@ -32,9 +32,9 @@ def twins(games: list[dict]) -> set[str]:
def display_title(game: dict, twin_set: set[str] | None = None) -> str:
"""The title shown everywhere (cards, pages, jobs, Steam shortcut): "<Title> (Quest)" / "(Rift)" while both
versions of a game are in the library."""
versions of a game are in the library. A name the user chose (Rename…, title_locked) is shown as it is."""
title = game.get("title") or game["package"]
if twin_set is not None and game["package"] in twin_set:
if twin_set is not None and game["package"] in twin_set and not game.get("title_locked"):
return f"{title} ({'Rift' if game.get('kind') == 'rift' else 'Quest'})"
return title
+44 -1
View File
@@ -8,6 +8,10 @@ system's handler, its own `frameport://`, FramePort's `https://frameport.app/ins
pasted into "Install from link…"), follows the same
rules (https only, http only on this PC, no credentials, no LAN addresses, a URL that ends in a file name) and
always asks before it downloads anything a web page sent. `urlhandler.py` registers the schemes.
The homepage's example button points at one fixed demo manifest (DEMO_MANIFEST, also published on the site): every
form of that link becomes a request flagged `demo`, which never touches the network (fetch_manifest answers with
demo_manifest(), download refuses it) and the GUI answers with a placeholder dialog (ui/views/link_dialog.py).
"""
from __future__ import annotations
@@ -32,6 +36,8 @@ MAX_DESCRIPTION = 2000
MAX_ICON = 2 << 20
LOOPBACK = ("localhost", "127.0.0.1", "[::1]", "::1")
APK, LINUX, EXE, OBB = "apk", "linux", "exe", "obb"
DEMO_MANIFEST = "https://frameport.app/demo/cool-game.json" # the homepage's example button
DEMO_NAME = "Cool Game"
LINUX_EXTS = (".zip", ".tar.gz", ".tgz", ".tar.xz", ".txz", ".tar.bz2", ".tar", ".appimage")
@@ -44,6 +50,7 @@ class InstallRequest:
manifest_url: str | None = None
file_url: str | None = None
local_manifest: str | None = None # a .framedrop.json dropped on the window
demo: bool = False # the homepage's example button (DEMO_MANIFEST): nothing is fetched or installed
@property
def source(self) -> str:
@@ -73,6 +80,7 @@ class Manifest:
# FramePort-only extension (FrameDrop ignores it): "frameport": {"description": "…", "icon": "https://….png"}
description: str = ""
icon: str | None = None
demo: bool = False # demo_manifest(): never downloaded
@property
def host(self) -> str:
@@ -145,6 +153,31 @@ def check_url(url: str, what: str = "link", resolve: bool = False) -> str:
return url.strip()
def is_demo(url: str | None) -> bool:
"""Whether a manifest address is the homepage's demo manifest (https, frameport.app, that path, nothing else)."""
try:
parts = urlsplit((url or "").strip())
port = parts.port
except ValueError:
return False
host = (parts.hostname or "").lower()
return parts.scheme.lower() == "https" and host in ("frameport.app", "www.frameport.app") and port is None \
and not parts.username and not parts.password and parts.path == urlsplit(DEMO_MANIFEST).path \
and not parts.query and not parts.fragment
def demo_request() -> InstallRequest:
return InstallRequest(manifest_url=DEMO_MANIFEST, demo=True)
def demo_manifest() -> Manifest:
"""What the demo link "installs": built here, never fetched (site/public/demo/cool-game.json says the same)."""
return Manifest(DEMO_NAME, [ManifestFile("https://frameport.app/demo/cool-game-arm64.apk")], DEMO_MANIFEST,
description="A placeholder game for the example \"Install with FramePort\" button. FramePort "
"recognises this link and shows a demo: nothing is downloaded or installed.",
demo=True)
def parse(text: str) -> InstallRequest:
"""framedrop://install?…, frameport://install?…, the https://framedropvr.com/install?… button link, a manifest
URL (…json) or a direct file URL."""
@@ -156,12 +189,16 @@ def parse(text: str) -> InstallRequest:
except ValueError:
raise LinkError("That isn't a link FramePort understands.") from None
scheme = parts.scheme.lower()
if is_demo(text): # the demo manifest pasted on its own
return demo_request()
if scheme in SCHEMES or (scheme == "https" and (parts.hostname or "").lower() in WEB_HOSTS):
action = (parts.netloc or parts.path.strip("/")) if scheme in SCHEMES else parts.path.strip("/")
if action.lower().split("/")[0] != "install":
raise LinkError("FramePort only understands install links (…/install?manifest=… or ?url=…).")
query = parse_qs(parts.query)
manifest, file = (query.get("manifest") or [None])[0], (query.get("url") or [None])[0]
if manifest and is_demo(manifest):
return demo_request()
if manifest:
return InstallRequest(manifest_url=check_url(manifest, "manifest address"))
if file:
@@ -240,7 +277,10 @@ def manifest_from_data(data, source: str = "") -> Manifest:
def fetch_manifest(req: InstallRequest) -> Manifest:
"""The manifest a request names (a direct file link becomes a one-file manifest titled after the file)."""
"""The manifest a request names (a direct file link becomes a one-file manifest titled after the file; the demo
link's is built in, without a network request)."""
if req.demo or is_demo(req.manifest_url):
return demo_manifest()
if req.file_url:
name = filename_of(req.file_url)
m = Manifest(title_from_filename(name), [ManifestFile(req.file_url)], req.file_url, direct=True)
@@ -366,6 +406,9 @@ def download(m: Manifest, reporter: Reporter | None = None) -> Path:
`obb/` folder next to it, where the APK scan finds them), the Linux build or the Windows program."""
import shutil
if m.demo or is_demo(m.source):
raise LinkError("This is FramePort's demo link: there's nothing to download.")
folder = staging_dir(m)
shutil.rmtree(folder, ignore_errors=True)
folder.mkdir(parents=True)
+25 -2
View File
@@ -113,13 +113,26 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
apk_name=plan.apk.name, settings=ctx.adapter_settings,
files={k: v.decode() if isinstance(v, bytes) else v for k, v in ctx.files.items()}, env=ctx.env,
obb_manifest=manifest or None, flatscreen=show_window(ctx, _flatscreen(plan.package)),
config_sync=ctx.config_sync or None,
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source, "alt": plan.recipe.use_alt},
)
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
log_controller_models(result.get("controller_models"), reporter)
log_config_sync(result.get("config_sync"), reporter)
return result
def log_config_sync(report: dict | None, reporter: Reporter) -> None:
"""What the agent did to the game's saved settings (device.config_sync)."""
if not report:
return
if report.get("changed"):
reporter.log("game settings kept in step with the eye buffers: " + ", ".join(
f"{k} {old} -> {new}" for k, (old, new) in sorted(report["changed"].items())))
elif report.get("skipped"):
reporter.log(f"game settings not changed: {report['skipped']}")
def log_controller_models(models: dict | None, reporter: Reporter) -> None:
"""Outcome of the agent's Steam Frame controller model conversion (adapter setting controller_models)."""
if not models:
@@ -490,9 +503,11 @@ def apply_app_icon(frame: Frame, package: str, anchor: str | None, result: dict,
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:
def update_steam_art(frame: Frame, package: str, reporter: Reporter, title: str | None = None) -> 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)."""
the art set goes to the game's anchor, then the shortcut is rewritten (Steam restarts once). `title`: the game's
name in the library; a different name on the Frame is renamed first (agent v77 `rename`: the shortcut keeps its
appid, so Play, grid art and saves stay). Placeholder art shows the name, so it's sent again too."""
from ..artwork.steam import steam_set_for
dep = next((d for d in frame.agent("list_installed")["games"] if d.get("package") == package), None)
@@ -500,8 +515,16 @@ def update_steam_art(frame: Frame, package: str, reporter: Reporter) -> dict:
raise RuntimeError(f"{package} isn't installed on the Frame")
if not dep.get("anchor"):
raise RuntimeError("the Frame's FramePort agent is too old; reinstall the game to update its art")
renamed = False
if title and dep.get("title") != title:
reporter.stage("Name in the Steam library")
res = frame.agent("rename", package=package, title=title, shortcuts=False)
renamed = bool(res.get("renamed"))
reporter.check("Name", True, f"{dep.get('title')} -> {res.get('title')} (shortcut {res.get('appid')} kept)")
art = steam_set_for(package)
if not art:
if renamed:
return add_to_steam(frame, [package], reporter)
raise RuntimeError("no artwork to send")
reporter.stage("Artwork for the Steam library")
remote = posixpath.join(dep["anchor"], "artwork")
+34 -1
View File
@@ -162,7 +162,6 @@
"Appearance": "",
"AppImage": "",
"Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead (components Touch lacks are dropped). Without it such apps get no controller input.": "",
"Artwork": "",
"Artwork for {get}": "",
"Artwork updated ({src}). The Frame's Steam library still shows the old art.": "",
"Artwork updated for {get}": "",
@@ -186,6 +185,8 @@
"Before a swapchain image is handed to the Frame, wait until the game's GPU work on it has finished, for games that hand over images early (an eye flickers or jitters). Costs some frame time. 2 = only after the first 60 s (compare both in one session).": "",
"Before each Play on this PC, FramePort reads SteamVR's record of the game's last session (frames dropped; frame times from fpsVR if installed). If it dropped frames, it lowers the game's refresh rate to one whose frame budget fits (for example 96 → 90 Hz) and turns motion smoothing on, through SteamVR's per-application settings; each later session can step down again. Set a refresh rate or smoothing mode here to choose yourself. Ignored on the Frame.": "",
"Best guess": "",
"BlazeRush 1.0.349 only (Klownicle's guide, GitHub #57). Its menus wait for Meta's avatar, which never comes on the Frame, before they create the game's own controllers: the second menu ignored all input. Two instructions in libtargemapp.so skip that wait and the avatar mesh drawing; the game's shader cache gets a new folder name (cache/br_shader_v1), so programs compiled before the GL shim's shader fixes aren't reused. Only this exact build is changed (code fingerprint and every instruction checked); anything else is left as it is.": "",
"BlazeRush: controllers without Meta avatars, own shader cache": "",
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan). Fixes UI panels and text that show upside down (for example Assassin's Creed Nexus).": "",
"Browse each installed game's files and download your saves.": "",
"Browse this game's files.": "",
@@ -250,6 +251,7 @@
"Collect logs": "",
"Collect logs: {title}": "",
"Command-line arguments for the game program. Games that support several VR runtimes pick one with an argument, for example Unreal's -hmd=OpenXR or -hmd=SteamVR, Unity's -vrmode OpenVR, or a game's own switch (-Runtime=OpenXRHMD). FramePort fills them in from the game files (the repack's VD.bat with the Oculus module swapped for OpenXR, else the engine's usual switch).": "",
"Computes some of the game's effects more precisely, so they don't draw streaks or flashes (for example BlazeRush's particles). Comes from a game's recipe.": "",
"Connect": "",
"Connect Frames in Developer Mode automatically": "",
"Connect the Frame's USB-C port to this PC with a data cable (not a charge-only one).": "",
@@ -276,6 +278,7 @@
"Controllers": "",
"Controllers as a gamepad (Steam Input)": "",
"Converts Quest games to OpenXR": "",
"Cool Game went through the portal. It was only a demo: nothing was downloaded or installed.": "",
"Copied": "",
"Copied. Save it as a .json file, change the colors and install it.": "",
"Copies the game and its saves to the other drive, checks the copy, then removes the old one. The game must be closed. A game on a microSD card only starts while the card is in.": "",
@@ -340,6 +343,8 @@
],
"Deletes copies kept from earlier installs and unfinished uploads. Games and saves stay.": "",
"Delete…": "",
"Demo": "",
"demo: nothing is downloaded": "",
"Deny": "",
"Deselect": "",
"Desktop": "",
@@ -551,6 +556,7 @@
"FramePort {version} · {kind}": "",
"FramePort's layer between the game and the Steam Frame: fills in what the Frame lacks (controllers, room, passthrough). Needed for every converted game.": "",
"FramePort's original violet": "",
"FramePort's own name for it": "",
"FramePort's tools, library, artwork and your games' signing keys. Back it up: an update needs the same key, or the game must be reinstalled and loses its saves.": "",
"Frames in Developer Mode show up here.": "",
"frames per second": "",
@@ -593,13 +599,18 @@
"GiB": "",
"GitHub issue opened": "",
"Gives older Quest games a newer version of Meta's VR plugin that can be converted.": "",
"GL shim (frame.gl_shim): shader inputs declared as integers (int/uint, ivecN/uvecN) whose vertex data the game set up with glVertexAttribPointer (as floats) are set up again with glVertexAttribIPointer before each draw. Qualcomm's Quest driver read the raw integers anyway; the Frame's GL driver converts them to floats, so skinned models using bone indices vanish (for example BlazeRush's car bodies and controllers; Klownicle's guide, GitHub #57). Comes from a game's recipe.": "",
"GL shim (frame.gl_shim): vertex shaders whose source contains every '|'-separated text compute in highp: whole-word mediump becomes highp on every line without the words in, out or uniform (the shader's interface keeps its precision). For effects that draw streaks or flashes from mediump rounding on the Frame (for example BlazeRush's particles: in uvec2 inBoneIndices;|uniform InstanceData|void main()). Pair it with a new shader cache name if the game caches compiled programs. Comes from a game's recipe.": "",
"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, for example 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 (for example Path of the Warrior).": "",
"GL shim: high-precision vertex shaders": "",
"GL shim: integer vertex inputs": "",
"GL wrapper: OpenGL ES depth formats (Minecraft)": "",
"Go to my library": "",
"Godot apps: start without a clipboard service": "",
"GPU": "",
"Graphics": "",
"Group": "",
@@ -728,12 +739,14 @@
"Keep game saves on the Frame": "",
"Keep menus in place": "",
"Keep pictures in bounds": "",
"Keep the game's saved resolution in step": "",
"Keep the signing keys: without them, updating a game loses its saves.": "",
"Keeps a smoothing setting off that the game turns on itself and that can freeze the Frame.": "",
"Keeps every submitted image rect inside its swapchain. Unity can size the eye area a few pixels past the image on some Frames; SteamVR then rejects every frame (xrEndFrame -25, XR_ERROR_SWAPCHAIN_RECT_INVALID) and the game stops drawing (for example PowerWash Simulator stuck at \"Waiting\").": "",
"Keeps games running that use a 360° cube layer the Frame lacks (for example Budget Cuts Ultimate). Leave it on.": "",
"Keeps one label so the title is stable.": "",
"Keeps one name for the game, so it shows the same everywhere.": "",
"Keeps the resolution the game saves in step with the headset's picture size (else the picture is shifted, with black borders, after a sharpness change).": "",
"Keyboard connected to {label}.": "",
"Keyboard disconnected: {error}": "",
"KiB": "",
@@ -761,12 +774,14 @@
"Lepton only launches an activity with category LAUNCHER (Quest apps often use INFO). Symptom when missing: launch.log says 'APP_ACTIVITY is empty' and nothing starts.": "",
"Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as \"Gamescope\" (the Frame's compositor). Pairs with \"Make Unity text fields work without a system keyboard\".": "",
"Lepton's Android has no clipboard service, and SDL's Java code (SDL2 and LÖVE games, for example 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).": "",
"Lepton's Android has no clipboard service, and the Android code of Godot 4.2-4.4 (for example EndoparasiticVR) casts the missing service to a ClipboardManager without checking, so the app crashes at start with a NullPointerException in Godot.<init>. That check is skipped and Godot's clipboard functions do nothing instead (an in-place edit of classes.dex).": "",
"Lepton's Android only gets a keyboard, a pointer and touch from the Frame, so a 2D game never sees a controller (the controllers only move the pointer; SDL games show their touch controls). This passes Steam Input's virtual gamepad (\"Microsoft X-Box 360 pad\", made by Steam for the Frame's controllers and paired Bluetooth pads) into this app's container with an Xbox 360 key layout, and tells SDL games to use it (SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD). Steam Input must be on for the game's shortcut; the controllers must be on when the game starts (a pad Steam makes later isn't seen until the next start). The APK isn't changed. Off for one start: FRAMEPORT_NO_GAMEPAD=1 in the game's launch options.": "",
"Lets games built on Meta's oldest VR interface run on the Frame.": "",
"Lets games made for Oculus PCs run on SteamVR.": "",
"Lets games start that would stop at Meta's avatars (for example 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 Godot games start on the Frame (they'd crash looking for a clipboard).": "",
"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 on the Frame (for example Star Wars Pinball VR).": "",
@@ -824,6 +839,7 @@
"Looking up the store description and screenshots…": "",
"Low": "",
"Make Unity text fields work": "",
"Makes BlazeRush's menus and controllers work without Meta's avatars, and lets its shader fixes take effect.": "",
"Makes grip, trigger and touch controls reach the game's hands (it picked the empty Gear VR controls on the Frame, for example Vader Immortal).": "",
"Makes Meta's 3D audio library work outside Quest headsets.": "",
"Makes sure the game has an entry that can be started.": "",
@@ -867,6 +883,7 @@
"Multiplies the recommended eye-buffer width and height (0.5–2.0). 1.5 ≈ 2.25× pixels.": "",
"Must be at least the video's own frame rate.": "",
"Name": "",
"Name and artwork": "",
"Native video surfaces (Batman renderer)": "",
"Needed by another patch": "",
"Needs the Meta Horizon app for its license check, so it crashes on the Frame. Play it on this PC.": "",
@@ -881,6 +898,7 @@
"New name": "",
"New recipe applied. Click Update on Frame to install it.": "",
"Next": "",
"Nice, it works! FramePort receives install links. (Demo: nothing was installed.)": "",
"No": "",
"No activity": "",
"no answer from the Frame": "",
@@ -983,6 +1001,7 @@
"or": "",
"Or paste text to type it in one go": "",
"Or turn on Developer Mode on the Frame (Settings → System) and connect it under step 2.": "",
"Or use:": "",
"Orange for actions, blue for the PC side: the logo's two portals": "",
"Other": "",
"other file": "",
@@ -1158,7 +1177,11 @@
"Removes {title} from Steam on this PC (Steam restarts once). The game folder stays.": "",
"Removes {title}'s game files from the Frame. Its saves and own files stay unless you tick the box below. Its Steam entry goes away after Steam's next restart.": "",
"Rename": "",
"Rename in Steam: {title}": "",
"Rename {name}": "",
"Rename {title}": "",
"Renamed to {title}": "",
"Renamed to {title}. Steam restarts once to show it.": "",
"Rename…": "",
"Repairs OpenGL ES shaders that hang the graphics chip, from the game's recipe.": "",
"Repairs shaders that hang the graphics chip at certain effects, from the game's recipe (for example Vader Immortal's lightspeed jump).": "",
@@ -1287,6 +1310,7 @@
"Shows 3D 360° pictures flat if they look doubled.": "",
"Shows Batman: Arkham Shadow's cutscenes; set by that game's recipe.": "",
"Shows each eye's picture to the other eye. For testing.": "",
"Shows models whose shapes come from bone data the Frame's graphics driver misreads (for example BlazeRush's cars). Comes from a game's recipe.": "",
"Shows the Frame's cameras where the game expects passthrough.": "",
"Shows the headset's view with sound, whatever is running. It plays here where possible, else in mpv or your browser. Stop it when you're done: it costs the game some speed.": "",
"Shows videos that games play on a flat panel (for example an intro video).": "",
@@ -1311,6 +1335,7 @@
"Some engines leave the pNext of unused Vulkan attachment references uninitialized. The Frame's driver never reads it, but Lepton always loads Steam's Fossilize shader-cache layer, which follows it and crashes on the first frame (SIGSEGV in libVkLayer_fossilize.so from FVulkanRenderPass, for example Deadpool VR). Loads Vulkan through a small shim that keeps valid pointers and drops only unreadable ones or ones pointing at the wrong structure type. It also drops a depth resolve named in a subpass without a depth attachment, which crashes the Frame's driver at the first render pass (for example Metro Awakening).": "",
"Some game folders are already set up for SteamVR: a Revive copy (LibRevive64.dll) next to the game, started by a small loader DLL (for example xinput1_3.dll) that Windows loads from the game's folder. Then FramePort starts the game program directly — like double-clicking it — instead of through its own Revive (two Revives conflict; for example the Oculus Platform check then fails). On the Frame, Wine is told to use those loader DLLs from the game folder instead of its own.": "",
"Some games keep content in separate files next to their OBB (for example Star Wars: Tales from the Galaxy's Edge: its seasons and sound banks) and ask Meta's platform for them as asset files. OVRPort's platform loader doesn't know them, so the game waits for content that never arrives (black screen after loading). Lists the content files shipped with the game's data (*.pak) as installed and answers their download request at once with the file's path; nothing is copied or downloaded. Shares the language-pack library.": "",
"Some games save their render resolution in a settings file and load it after creating the headset's eye buffers; after a resolution scale change the saved size no longer matches and the picture is displaced or stretched, with black borders (for example BlazeRush's user_config.xml). FramePort writes the values its recipe names into the game's file while the game isn't running (after an install and after a Game settings change), from the Frame's own eye-buffer size times the resolution scale: w and h = the eye buffer's width and height, for example h*16/9. Only existing entries change; the previous file is kept as <file>.frameport-backup. The size is learned from the game's last start, so a file the game wrote before is corrected from the next install or settings change on.": "",
"Some OpenGL ES engines compile every shader for multiview (both eyes in one draw, `num_views=2`) and use the same shaders to draw into ordinary single-layer framebuffers, for example a HUD or PDA screen rendered to a texture. Quest's driver draws them anyway; the Frame's driver (Mesa) follows the OVR_multiview rule and silently drops every such draw, so those panels stay black (for example Doom3Quest's HUD and PDA). Loads OpenGL ES through a small interposer (libfpglmv.so) that draws those cases with a single-view copy of the shader program (left-eye view; uniforms copied before each such draw). Eye-buffer draws are unchanged. Logs under the tag GLMV; the setting gl_mv_debug adds error checks and counters.": "",
"Some OpenXR games still ship Meta's VrApi loader and call it a few times without starting VrApi (for example Jurassic World Aftermath); Meta's loader then stops the game (\"vrapi_SetPropertyInt 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.": "",
"Some websites have \"Install with FramePort\" buttons (frameport.app links). FramePort opens these links, shows what they offer and installs it when you agree. On macOS, use Add games → Add from a link… instead.": "",
@@ -1347,6 +1372,7 @@
"Starts the game on the Frame — put the headset on.": "",
"Starts the game with options, for example to choose SteamVR or OpenXR.": "",
"Starts the game with the launcher that came with your copy.": "",
"Starts Unreal games without re-checking their data file (they'd seem to hang on an invisible check screen).": "",
"Starts with SteamVR": "",
"Status": "",
"Steadier hands (older Unity)": "",
@@ -1453,6 +1479,7 @@
"The Frame's Steam couldn't start {title} (error {code}). Please report it.": "",
"The Frame's Steam didn't accept {title}, so it was added as \"Devkit Game: …\". Starting it now — put the headset on.": "",
"The Frame's Steam library still shows the old artwork.": "",
"The Frame's Steam library still shows the old name.": "",
"the game failed its checks (see the list above)": "",
"The game's audio streams along with the picture.": "",
"The headset is asleep: put it on, then take the screenshot.": "",
@@ -1477,6 +1504,7 @@
"The recipe or your game files changed since the install. Update to install the new build; saves are kept.": "",
"The running game's frames per second, as it plays.": "",
"The setup turns on Developer Mode, lets FramePort in and installs Lepton if needed. The Frame's desktop closes during it, then FramePort connects by itself. No password needed.": "",
"The store's name": "",
"The time the Frame spends collecting these numbers.": "",
"The upload to the Frame was damaged. Try again, ideally over a cable or the Frame's hotspot.": "",
"The VR runtime games talk to. On the Frame that's SteamVR.": "",
@@ -1499,6 +1527,7 @@
"This game can't run on the Steam Frame.": "",
"This game is marked \"Can't run\" on the Steam Frame:": "",
"This is a website in an app: it opens in Meta's browser, which the Frame doesn't have. If nothing opens, visit the website in a browser.": "",
"This is the example button from frameport.app. Real buttons install real games.": "",
"this PC": "",
"This PC": "",
"This PC (for PC VR games)": "",
@@ -1575,11 +1604,13 @@
"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, for example 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 (for example NOPE Challenge).": "",
"Unreal games can check their whole data file (.obb) before starting (bVerifyOBBOnStartUp). On the Frame that check's progress and error screens are invisible (Lepton runs VR games without an Android window), so the game seems to hang on its DownloaderActivity, for example Contractors. This turns the check off in the manifest; Unreal still checks that the OBB is there with the right size, so a missing or wrong-version OBB still stops the game.": "",
"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 (for example 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: start without the OBB check": "",
"Unreal: thumb follows the capacitive touches": "",
"Untested": "",
"Update all": "",
@@ -1590,6 +1621,7 @@
"Update failed: {exc}": "",
"Update FramePort to {version}": "",
"Update game recipes from GitHub automatically": "",
"Update name on Frame": "",
"Update now": "",
"Update on Frame": "",
"Update on this PC": "",
@@ -1855,6 +1887,7 @@
"{title}: details from {value}": "",
"{title}: done": "",
"{title}: launch test {verdict} · furthest step: {value}": "",
"{title}: name updated in Steam": "",
"{title}: ready": "",
"{title}: Steam artwork updated on the Frame": "",
"{used} of {total}": "",
+2
View File
@@ -59,6 +59,8 @@ class InstallContext:
adapter_settings: dict[str, Any]
flatscreen: bool = False # show the app's Android window (Lepton's lepton-show-flatscreen) even for a VR app
display: str = "" # device.display_mode: "vr" / "flat" = the user's choice, "" = automatic (2D apps get a window)
# device.config_sync: path relative to files/ -> {setting: w | h | expression}, kept in step by the agent
config_sync: dict = field(default_factory=dict)
class Patch:
+173
View File
@@ -0,0 +1,173 @@
"""BlazeRush (ru.targem.blazerush 1.0.349): two instruction edits and one string in libtargemapp.so, from Klownicle's
verified developer guide (GitHub #57).
1. Controllers: the menus wait for Meta's Avatar SDK to deliver the player's avatar before they create the game's own
interactive controller entities. FramePort's avatar stub (frame.avatar_stub) keeps the game alive without Meta's
avatar driver but never delivers an avatar, so the second menu ignored all input. The conditional branch that waits
for avatar readiness becomes a NOP (0x4b2dbc), and the wrapper that renders the avatar meshes returns at once
(0x4b2adc), so the entities that now exist never touch the missing avatar renderer. The generic avatar stub keeps
reporting "no avatar": other games could dereference avatar assets that don't exist.
2. Shader cache: the game keeps compiled program binaries under files/cache/shader_cache and uses them instead of the
shader sources. "cache/shader_cache" becomes "cache/br_shader_v1" (same length), so programs compiled before the GL
shim's precision fix (gl_highp_markers) aren't used; the old cache and the saves stay.
Only the exact build is edited: its code segment must hash to the known fingerprint (with the edit sites as shipped
or already edited), every instruction word must be the expected original or replacement, and the string must sit at
its known address, once. Anything else is refused and left unchanged (logged in the build notes). File offsets come
from the PT_LOAD segments, so libraries rewritten by other patches (the GL shim adds a DT_NEEDED entry and a segment,
nothing that exists moves) still match.
The guide's other fixes are elsewhere: integer vertex attributes and the particle shaders' precision in the GL shim
(gl_int_attribs, gl_highp_markers), the startup FOV in the VrApi bridge (the FOV of the previous session), scale 1.7
in the catalog recipe, and the saved resolution in user_config.xml through device.config_sync.
"""
from __future__ import annotations
import hashlib
import struct
from dataclasses import dataclass
from ...analysis import elf
from ..base import ApkContext, Patch, Suggestion, register
PACKAGE = "ru.targem.blazerush"
LIB = "libtargemapp.so"
NOP = 0xD503201F
RET = 0xD65F03C0
OLD_CACHE = b"cache/shader_cache\0"
NEW_CACHE = b"cache/br_shader_v1\0"
EM_AARCH64 = 183
@dataclass(frozen=True)
class Build:
version: str
text_sha256: str # SHA-256 of the executable PT_LOAD segment with every edit site as shipped
sites: tuple[tuple[int, int, int, str], ...] # (virtual address, original word, replacement, what)
cache_va: int # virtual address of "cache/shader_cache"
BUILDS = (
# 1.0.349: libtargemapp.so SHA-256 89686ffc5105f5149adf9bf08d88eafdd4e8e99a3cd14d1ac9cc0d3317dfe568 (as shipped;
# OVRPort and FramePort's other patches leave it unchanged)
Build("1.0.349", "951bf1d5208ae36603f73990346d164a8523e7d1e60cc3e67d3571c7fc55d5f4",
((0x4B2DBC, 0x36000AE0, NOP, "avatar readiness wait (tbz) -> nop"),
(0x4B2ADC, 0xF0002368, RET, "avatar mesh renderer -> ret")),
0x13AF70),
)
def _exec_segment(data: bytes, va: int) -> tuple[int, int, int] | None:
"""(vaddr, file offset, size) of the executable PT_LOAD segment holding `va`."""
for seg in elf._elf(data).iter_segments():
start, size = seg["p_vaddr"], seg["p_filesz"]
if seg["p_type"] == "PT_LOAD" and seg["p_flags"] & 1 and start <= va < start + size:
return start, seg["p_offset"], size
return None
def find_sites(data: bytes) -> dict | None:
"""The edits for a supported build: {"build", "edits": [(file offset, old word, new word, what)], "cache_offset",
"done": every edit already made}; None for anything else (another build, a damaged or foreign library)."""
if not elf.is_elf(data) or not elf.is_64bit(data) or data[5] != 1:
return None
if struct.unpack_from("<H", data, 18)[0] != EM_AARCH64:
return None
try:
segments = elf.load_segments(data)
except Exception: # noqa: BLE001 - not a readable ELF: refuse
return None
for build in BUILDS:
text = _exec_segment(data, build.sites[0][0])
if not text:
continue
t_va, t_off, t_size = text
code = bytearray(data[t_off:t_off + t_size])
edits, ok = [], True
for va, old, new, what in build.sites:
if not t_va <= va < t_va + t_size - 3:
ok = False
break
at = va - t_va
word = struct.unpack_from("<I", code, at)[0]
if word not in (old, new):
ok = False
break
struct.pack_into("<I", code, at, old) # fingerprint over the code as shipped
edits.append((t_off + at, old, new, what, word == new))
if not ok or hashlib.sha256(code).hexdigest() != build.text_sha256:
continue
off = elf.vaddr_to_offset(segments, build.cache_va)
if off is None:
continue
here = data[off:off + len(OLD_CACHE)]
old_n, new_n = data.count(OLD_CACHE), data.count(NEW_CACHE)
if not ((here == OLD_CACHE and old_n == 1 and new_n == 0) or (here == NEW_CACHE and old_n == 0 and new_n == 1)):
continue
cache_done = here == NEW_CACHE
return {"build": build.version, "edits": [e[:4] for e in edits], "cache_offset": off,
"done": cache_done and all(e[4] for e in edits),
"pending": [e[3] for e in edits if not e[4]] + ([] if cache_done else ["shader cache namespace"])}
return None
def patch_library(data: bytes) -> tuple[bytes, dict | None]:
"""(edited library, sites) for a supported build; the data unchanged and None otherwise."""
site = find_sites(data)
if not site or site["done"]:
return data, site
buf = bytearray(data)
for at, _old, new, _what in site["edits"]:
struct.pack_into("<I", buf, at, new)
buf[site["cache_offset"]:site["cache_offset"] + len(NEW_CACHE)] = NEW_CACHE
return bytes(buf), site
class BlazeRush(Patch):
id = "frame.blazerush"
title = "BlazeRush: controllers without Meta avatars, own shader cache"
description = ("BlazeRush 1.0.349 only (Klownicle's guide, GitHub #57). Its menus wait for Meta's avatar, which "
"never comes on the Frame, before they create the game's own controllers: the second menu ignored "
"all input. Two instructions in libtargemapp.so skip that wait and the avatar mesh drawing; the "
"game's shader cache gets a new folder name (cache/br_shader_v1), so programs compiled before the "
"GL shim's shader fixes aren't reused. Only this exact build is changed (code fingerprint and "
"every instruction checked); anything else is left as it is.")
order = 59 # after frame.avatar_stub (58), before the GL shim (61): it doesn't matter, nothing here moves
strict = True # a catalog recipe only uses it where applies() holds
note = "BlazeRush: avatar wait skipped, avatar meshes not drawn, shader cache cache/br_shader_v1"
reason = ("BlazeRush waits for a Meta avatar before it creates its controllers (the menus ignore input on the "
"Frame) and reuses shaders compiled before the GL shim's fixes.")
def applies(self, a):
return a.package == PACKAGE and "arm64-v8a" in a.abis and LIB in a.libs
def detect(self, a):
if self.applies(a) and a.version in {b.version for b in BUILDS}:
return Suggestion(True, self.reason)
return None
def apply(self, ctx: ApkContext) -> bool:
ws = ctx.ws
if ws.abi != "arm64-v8a" or not ws.has(ws.lib(LIB)):
return False
data = ws.read_lib(LIB)
new, site = patch_library(data)
if not site:
ctx.notes.append(f"{self.id}: {LIB} isn't BlazeRush 1.0.349's library as known, left unchanged")
return False
if new == data:
return False
ws.put(ws.lib(LIB), new)
ctx.notes.append(f"{self.note} ({LIB}: " + ", ".join(site["pending"]) + ")")
return True
def validate(self, ctx: ApkContext):
ws = ctx.ws
if ws.abi != "arm64-v8a" or not ws.has(ws.lib(LIB)):
return []
site = find_sites(ws.read_lib(LIB))
return [("BlazeRush avatar wait skipped and own shader cache", bool(site) and site["done"],
"" if site else "library not recognised (different build)")]
register(BlazeRush)
@@ -0,0 +1,160 @@
"""Godot 4 apps (Godot 4.2-4.4 Android library, e.g. EndoparasiticVR, GitHub #134) crash at start in Lepton: its
Android has no clipboard service, getSystemService(CLIPBOARD_SERVICE) returns null, and Godot's Kotlin code casts it
with `as ClipboardManager` (a non-null cast: `Intrinsics.checkNotNull(x, "null cannot be cast to non-null type
android.content.ClipboardManager")` before the check-cast) → NullPointerException in Godot.<init> (4.3/4.4) or the
first time the clipboard is used (4.2's lazy property). Godot 4.1 (Java) and 4.5+ (`as?`) don't throw.
In place in classes*.dex: the null check in front of that one cast is turned into nops (check-cast accepts null, so the
field simply holds null), and, because the property was non-null typed, Godot's clipboard methods use it without a
null check: they return at once instead (hasClipboard false, getClipboard "", setClipboard nothing). Only exactly that
bytecode is edited; a dex without it is left alone."""
from __future__ import annotations
from ...apk.dex import Dex
from ..base import ApkContext, Patch, Suggestion, register
PACKAGE = "Lorg/godotengine/"
CLIPBOARD = "Landroid/content/ClipboardManager;"
MESSAGE = "null cannot be cast to non-null type android.content.ClipboardManager"
CHECK_NOT_NULL = ("Lkotlin/jvm/internal/Intrinsics;", "checkNotNull")
NPE = "Ljava/lang/NullPointerException;"
CHECK_CAST, NEW_INSTANCE, THROW, IF_EQZ, IF_NEZ, GOTO16 = 0x1F, 0x22, 0x27, 0x38, 0x39, 0x29
INVOKE_DIRECT, INVOKE_STATIC, INVOKE_STATIC_RANGE = 0x70, 0x71, 0x77
def _const_string(dex: Dex, u) -> tuple[int, str] | None:
"""(register, text) of a const-string(/jumbo), else None."""
op = u[0] & 0xFF
if op == 0x1A:
return u[0] >> 8, dex.string(u[1])
if op == 0x1B:
return u[0] >> 8, dex.string(u[1] | u[2] << 16)
return None
def _args(u) -> list[int]:
"""Argument registers of an invoke (35c or 3rc)."""
op = u[0] & 0xFF
if op in (INVOKE_STATIC_RANGE, 0x76): # invoke-static/range, invoke-direct/range
return list(range(u[2], u[2] + (u[0] >> 8)))
regs = [u[2] & 0xF, (u[2] >> 4) & 0xF, (u[2] >> 8) & 0xF, u[2] >> 12, (u[0] >> 8) & 0xF]
return regs[:u[0] >> 12]
def _s16(unit: int) -> int:
return unit - 0x10000 if unit & 0x8000 else unit
def _is_message(dex: Dex, u, reg: int | None = None) -> bool:
cs = _const_string(dex, u)
return cs is not None and cs[1] == MESSAGE and (reg is None or cs[0] == reg)
def _throw_block(dex: Dex, insns: list, i: int) -> bool:
"""insns[i:i+4] = new-instance vX, NullPointerException; const-string vY, MESSAGE; invoke-direct {vX, vY},
NullPointerException.<init>; throw vX (what kotlinc before Intrinsics.checkNotNull emitted)."""
if i + 4 > len(insns):
return False
(_, new), (_, msg), (_, init), (_, thr) = insns[i:i + 4]
if new[0] & 0xFF != NEW_INSTANCE or dex.type_name(new[1]) != NPE or not _is_message(dex, msg):
return False
x, y = new[0] >> 8, msg[0] >> 8
return (init[0] & 0xFF == INVOKE_DIRECT and dex.method(init[1]) == (NPE, "<init>") and _args(init) == [x, y]
and thr[0] & 0xFF == THROW and thr[0] >> 8 == x)
def unchecked_casts(dex: Dex, code: int) -> list[tuple[int, list[int]]]:
"""(code unit position, replacement units) that let `x as ClipboardManager` pass a null x in one method."""
insns = list(dex._insns(code))
pos_of = {pos: k for k, (pos, _u) in enumerate(insns)}
edits = []
for k, (pos, u) in enumerate(insns):
if u[0] & 0xFF != CHECK_CAST or dex.type_name(u[1]) != CLIPBOARD:
continue
reg = u[0] >> 8
# Kotlin >= 1.4: const-string vB, MESSAGE; invoke-static {vA, vB}, Intrinsics.checkNotNull; check-cast vA
if k >= 2:
(_, msg), (ipos, inv) = insns[k - 2], insns[k - 1]
if (inv[0] & 0xFF in (INVOKE_STATIC, INVOKE_STATIC_RANGE) and dex.method(inv[1]) == CHECK_NOT_NULL
and _args(inv) == [reg, msg[0] >> 8] and _is_message(dex, msg)):
edits.append((ipos, [0, 0, 0]))
continue
# older kotlinc: if-nez vA, :cast; <throw block>; :cast check-cast vA → the branch is always taken
if k >= 5:
bpos, br = insns[k - 5]
if (br[0] & 0xFF == IF_NEZ and br[0] >> 8 == reg and bpos + _s16(br[1]) == pos
and _throw_block(dex, insns, k - 4)):
edits.append((bpos, [GOTO16, br[1]]))
continue
# the same with the throw block moved away: if-eqz vA, :throw; check-cast vA → never branches
if k >= 1:
bpos, br = insns[k - 1]
if (br[0] & 0xFF == IF_EQZ and br[0] >> 8 == reg and bpos + _s16(br[1]) in pos_of
and _throw_block(dex, insns, pos_of[bpos + _s16(br[1])])):
edits.append((bpos, [0, 0]))
return edits
def patch_dex(dex: Dex) -> list[str]:
"""What was changed (method names); [] if this dex doesn't have Godot's throwing clipboard cast."""
if MESSAGE.encode() not in dex.data:
return []
classes = [c for c in dex.class_names() if c.startswith(PACKAGE)]
done = []
for cls in classes:
for idx, code in dex.class_methods(cls):
edits = unchecked_casts(dex, code)
for pos, units in edits:
for i, unit in enumerate(units):
dex.data[code + 16 + 2 * (pos + i): code + 18 + 2 * (pos + i)] = unit.to_bytes(2, "little")
if edits:
done.append(f"{_short(cls)}.{dex.method(idx)[1]} (clipboard cast)")
if not done:
return []
# the property was typed non-null, so nothing checks it before use: its users return at once
users = {i for i in range(dex.methods_n) if dex.method(i)[0] == CLIPBOARD}
for cls in classes:
for idx, code in dex.class_methods(cls):
if dex.invoked(code) & users and dex.return_early(code, dex.return_type(idx)):
done.append(f"{_short(cls)}.{dex.method(idx)[1]} (returns at once)")
return done
def _short(cls: str) -> str:
return cls[1:-1].rsplit("/", 1)[-1]
class GodotClipboard(Patch):
id = "frame.godot_clipboard"
title = "Godot apps: start without a clipboard service"
description = ("Lepton's Android has no clipboard service, and the Android code of Godot 4.2-4.4 (for example "
"EndoparasiticVR) casts the missing service to a ClipboardManager without checking, so the app "
"crashes at start with a NullPointerException in Godot.<init>. That check is skipped and Godot's "
"clipboard functions do nothing instead (an in-place edit of classes.dex).")
order = 44
needs_vr = False
def applies(self, a):
return bool((a.extra or {}).get("godot_clipboard"))
def detect(self, a):
if self.applies(a):
return Suggestion(True, "Godot 4 app: it crashes at start in Lepton, which has no clipboard service.")
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 MESSAGE.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}: Godot runs without a clipboard service ({', '.join(done)})")
changed = True
return changed
register(GodotClipboard)
@@ -0,0 +1,51 @@
"""Unreal (UE4/UE5) games with an OBB start through Epic's DownloaderActivity (GameActivity.onResume starts it for a
result before the engine's main init). It checks that every OBB exists with the name and size compiled into the APK
(OBBData), and with the manifest meta-data `com.epicgames.ue4.GameActivity.bVerifyOBBOnStartUp` = true it then reads
the whole OBB and compares each zip entry's CRC32 (once: the result is cached in files/cacheFile.txt by the OBB's
modified time). A CRC mismatch shows an error screen that waits for a button, and the check itself shows a progress
screen while it reads gigabytes; Lepton runs VR apps headless, so neither is visible and the game seems to hang on
the downloader (GitHub #159, Contractors). Setting the meta-data to false skips only that CRC pass: the name + size
check stays (a missing or wrong-size OBB still stops at the downloader). One 4-byte manifest value changes."""
from __future__ import annotations
from ...analysis.detect import UE_VERIFY_OBB_KEY
from ...apk import axml
from ..base import ApkContext, Patch, Suggestion, register
MANIFEST = "AndroidManifest.xml"
class UnrealSkipObbCheck(Patch):
id = "frame.unreal_skip_obb_check"
title = "Unreal: start without the OBB check"
description = ("Unreal games can check their whole data file (.obb) before starting (bVerifyOBBOnStartUp). On "
"the Frame that check's progress and error screens are invisible (Lepton runs VR games without "
"an Android window), so the game seems to hang on its DownloaderActivity, for example Contractors. "
"This turns the check off in the manifest; Unreal still checks that the OBB is there with the "
"right size, so a missing or wrong-version OBB still stops the game.")
order = 23
needs_vr = False
def applies(self, a):
return bool((a.extra or {}).get("unreal_verify_obb"))
def detect(self, a):
if self.applies(a):
return Suggestion(True, "Unreal checks the OBB's contents before starting (bVerifyOBBOnStartUp); on the "
"Frame a failed or slow check can't be seen and the game seems to hang.")
return None
def apply(self, ctx: ApkContext) -> bool:
fixed = axml.set_meta_data_bool(ctx.ws.read(MANIFEST), UE_VERIFY_OBB_KEY, False)
if fixed:
ctx.ws.put(MANIFEST, fixed)
ctx.notes.append("bVerifyOBBOnStartUp=false")
return bool(fixed)
def validate(self, ctx):
meta = axml.meta_data(ctx.ws.read(MANIFEST))
on = [k for k, v in meta.items() if k.endswith(UE_VERIFY_OBB_KEY) and v is True]
return [("OBB check at start off", not on, ", ".join(on) or "bVerifyOBBOnStartUp=false")]
register(UnrealSkipObbCheck)
+43 -2
View File
@@ -221,16 +221,29 @@ SETTINGS = [
("gl_hide_multiview", "int", 1, "GL shim: hide multiview",
"GL shim only: hide GL_OVR_multiview so all passes use single-view shaders. For GLES games whose multiview "
"shaders fail on single-view render targets (for example Path of the Warrior)."),
("gl_int_attribs", "int", 0, "GL shim: integer vertex inputs",
"GL shim (frame.gl_shim): shader inputs declared as integers (int/uint, ivecN/uvecN) whose vertex data the game "
"set up with glVertexAttribPointer (as floats) are set up again with glVertexAttribIPointer before each draw. "
"Qualcomm's Quest driver read the raw integers anyway; the Frame's GL driver converts them to floats, so skinned "
"models using bone indices vanish (for example BlazeRush's car bodies and controllers; Klownicle's guide, GitHub "
"#57). Comes from a game's recipe."),
("gl_highp_markers", "str", "", "GL shim: high-precision vertex shaders",
"GL shim (frame.gl_shim): vertex shaders whose source contains every '|'-separated text compute in highp: "
"whole-word mediump becomes highp on every line without the words in, out or uniform (the shader's interface "
"keeps its precision). For effects that draw streaks or flashes from mediump rounding on the Frame (for example "
"BlazeRush's particles: in uvec2 inBoneIndices;|uniform InstanceData|void main()). Pair it with a new shader "
"cache name if the game caches compiled programs. Comes from a game's recipe."),
]
# 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. 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}
REVISIONS = {"vk_shader_fix": 2, "vk_query_slots": 3, "vk_spec_fixes": 2, "haptic_fix": 2, "pose_consistency": 2,
"gl_int_attribs": 2, "gl_highp_markers": 2} # the GL shim learned these in 1.0.1
# Settings a game's recipe sets that the Game settings dialog doesn't show (engine-specific, not user choices)
HIDDEN = {"surface_native"}
HIDDEN = {"surface_native", "gl_int_attribs"}
# How the "Game settings" dialog shows each setting to non-technical users: group, level (common settings are always
# shown; advanced ones only under "Show advanced settings"), a plain label and one-line help, the control, and the
@@ -489,6 +502,8 @@ 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_int_attribs": ap.is_gles,
"gl_highp_markers": 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,
@@ -557,6 +572,31 @@ class DeviceFiles(Patch):
ctx.files[rel] = content.encode() if isinstance(content, str) else content
class ConfigSync(Patch):
id = "device.config_sync"
title = "Keep the game's saved resolution in step"
description = ("Some games save their render resolution in a settings file and load it after creating the "
"headset's eye buffers; after a resolution scale change the saved size no longer matches and the "
"picture is displaced or stretched, with black borders (for example BlazeRush's user_config.xml). "
"FramePort writes the values its recipe names into the game's file while the game isn't running "
"(after an install and after a Game settings change), from the Frame's own eye-buffer size times "
"the resolution scale: w and h = the eye buffer's width and height, for example h*16/9. Only "
"existing entries change; the previous file is kept as <file>.frameport-backup. The size is "
"learned from the game's last start, so a file the game wrote before is corrected from the next "
"install or settings change on.")
category = "device"
needs_vr = True
stage = "install"
params = [Param("files", "text", {}, "path relative to files/ -> {setting name: w | h | expression}")]
def install(self, ctx: InstallContext) -> None:
rules = {}
for rel, values in (ctx.params.get("files") or {}).items():
if isinstance(rel, str) and isinstance(values, dict) and values:
rules[rel] = {str(k): str(v) for k, v in values.items()}
ctx.config_sync.update(rules)
class LeptonEnv(Patch):
id = "device.lepton_env"
title = "Extra Lepton settings"
@@ -689,6 +729,7 @@ class TextInputWindow(Patch):
for _spec in SETTINGS:
register(AdapterSetting(*_spec))
register(DeviceFiles)
register(ConfigSync)
register(LeptonEnv)
register(HideNavBar)
register(SteamGamepad)
+11
View File
@@ -69,6 +69,9 @@ SUMMARIES = {
"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.godot_clipboard": "Lets Godot games start on the Frame (they'd crash looking for a clipboard).",
"frame.unreal_skip_obb_check": "Starts Unreal games without re-checking their data file (they'd seem to hang "
"on an invisible check screen).",
"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 "
@@ -88,6 +91,8 @@ SUMMARIES = {
"frame.avatar_stub": "Lets games start that would stop at Meta's avatars (for example BlazeRush). No Meta "
"avatars.",
"frame.vrapi_bridge": "Lets games built on Meta's oldest VR interface run on the Frame.",
"frame.blazerush": "Makes BlazeRush's menus and controllers work without Meta's avatars, and lets its shader "
"fixes take effect.",
"frame.gl_shim": "Corrects graphics code the Frame's drivers reject (black screen with sound).",
"frame.metaxr_telemetry": "Skips a Quest-only reporting step in Meta's audio library that crashes some games.",
"frame.oculusos": "Provides stand-ins for Quest system calls some games make at start.",
@@ -104,6 +109,8 @@ SUMMARIES = {
# Files and environment on the Frame
"device.files": "Puts settings files next to the game on the Frame, for example to turn off an unsupported "
"effect.",
"device.config_sync": "Keeps the resolution the game saves in step with the headset's picture size (else the "
"picture is shifted, with black borders, after a sharpness change).",
"device.hide_navbar": "Hides Android's back, home and recents buttons, which cover the app's own buttons.",
"device.text_input_window": "Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard. VR "
"keeps working.",
@@ -129,5 +136,9 @@ SUMMARIES = {
"pcvr.steamvr_tuning": "Picks a refresh rate and motion smoothing that suit the game on this PC.",
"pcvr.proton_env": "Extra settings for Proton (for testing).",
# Game settings the Game settings dialog doesn't show (the others use their help line there)
"adapter.gl_int_attribs": "Shows models whose shapes come from bone data the Frame's graphics driver misreads "
"(for example BlazeRush's cars). Comes from a game's recipe.",
"adapter.gl_highp_markers": "Computes some of the game's effects more precisely, so they don't draw streaks or "
"flashes (for example BlazeRush's particles). Comes from a game's recipe.",
"adapter.vk_shader_fix": "Corrects specific shaders that hang the game on the Frame. Comes from a game's recipe.",
}
+122 -6
View File
@@ -25,7 +25,7 @@ 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
from .targets.base import PC_LABEL, Target
def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_rift: bool = False,
@@ -38,7 +38,9 @@ def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_
path = Path(path)
added = []
known_apks = {str(Path(g["apk"]).resolve()) for g in library.games() if g.get("apk")} if only_new else set()
known_packages = {g["package"] for g in library.games()} if only_new else set()
# entries made from AXRB's PC-patched copies (no OBBs; older FramePort scanned them) get the original download
known_packages = {g["package"] for g in library.games()
if not (g.get("apk") and quest_dump._axrb_patched(Path(g["apk"])))} if only_new else set()
def done(entry):
if only_new and entry["package"] in known_packages: # already in the library (e.g. an unchanged Rift folder)
@@ -177,6 +179,8 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
fields.update(recipe=library.recipe_to_dict(recipe), status=recipe.status)
if not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
fields["title"] = recipe.title or a.label # (store-matched titles are kept)
elif old.get("title_locked") and old.get("art_source") not in ("oculusdb", "meta", "steam"):
fields["auto_title"] = recipe.title or a.label # the user's name stays; a reset brings this one
stored = library.upsert_game(package, **fields)
if art and not _has_art(package):
_rift_art(stored, reporter)
@@ -208,8 +212,8 @@ def _rift_art(entry: dict, reporter: Reporter | None = None) -> dict:
update["quest_package"] = found["quest_package"]
if found.get("oculus_app_id"):
update["oculus_app_id"] = found["oculus_app_id"]
if found.get("title") and not entry.get("title_locked"):
update["title"] = found["title"]
if found.get("title"): # a name the user chose stays (a reset of it brings the store's)
update["auto_title" if entry.get("title_locked") else "title"] = found["title"]
library.upsert_game(pkg, **update)
try:
thumbs.prewarm(pkg)
@@ -277,6 +281,92 @@ def steam_title(entry: dict) -> str:
return display_title(entry, twins(library.games()))
TITLE_MAX = 128 # (the agent cuts Steam shortcut names to the same length)
def clean_title(title: str | None) -> str:
"""A name as the user typed it, made one line: no control characters, spaces collapsed, at most TITLE_MAX."""
text = " ".join("".join(c if c.isprintable() else " " for c in str(title or "")).split())
return text[:TITLE_MAX]
def store_title(entry: dict) -> str | None:
"""The store's name for a game when FramePort fetched one (Meta store art comes with it), else None."""
from .artwork.fetch import artwork_dir
try:
f = artwork_dir(entry["package"]) / "title.txt"
return clean_title(f.read_text(encoding="utf-8")) or None if f.is_file() else None
except OSError:
return None
def automatic_title(entry: dict) -> str:
"""The name FramePort gives a game by itself (what Rename… → reset brings back): the current title unless the user
(or an install link) chose one; then the automatic name remembered at that moment, else the store's name or the
APK's label."""
if not entry.get("title_locked"):
return entry.get("title") or entry["package"]
return entry.get("auto_title") or store_title(entry) or (entry.get("analysis") or {}).get("label") or \
entry["package"]
def title_suggestions(entry: dict) -> list[str]:
"""Other names the Rename dialog offers: the automatic name and the store's name (when they differ from the
current title)."""
current = entry.get("title") or entry["package"]
out = []
for t in (automatic_title(entry), store_title(entry)):
if t and t != current and t not in out:
out.append(t)
return out
def rename_game(package: str, title: str | None) -> dict:
"""Rename a game in the library. The new title is locked (scans, artwork and store lookups and catalog updates
keep it); an empty title or the automatic name unlocks it again. Returns the entry. Installed copies keep their
old Steam name until sync_title (or the next install) updates them."""
if library.game(package) is None:
raise ValueError(f"{package} is not in the library")
title = clean_title(title)
old: dict = {}
def change(g: dict) -> None:
old.update(title=g.get("title"))
auto = automatic_title(g)
if not title or title == auto:
g["title"] = auto
g.pop("title_locked", None)
g.pop("auto_title", None)
else:
g.setdefault("auto_title", auto)
g["title"] = title
g["title_locked"] = True
if g["title"] != old["title"] and any(w != PC_LABEL for w in g.get("installs") or {}):
g["steam_name_stale"] = True # the Frame's Steam library shows the old name until sync_title
entry = library.update_game(package, change)
if entry.get("title") != old.get("title"):
try: # FramePort's own cover/banner (no store art) are coloured by the name: make them again
from .artwork.steam import ensure_cover
ensure_cover(package)
except Exception: # noqa: BLE001 - artwork is optional
pass
return entry
def sync_title(package: str, target: Target, reporter: Reporter) -> dict:
"""Give an installed game its library name in Steam (after Rename…): the Frame (agent v77 `rename`, same shortcut
appid, Steam restarts once) or this PC's Steam (PC VR games)."""
entry = library.game(package)
title = steam_title(entry)
if target.kind == "pc":
return target.rename(package, title, reporter)
result = target.update_steam_art(package, reporter, title=title)
library.update_game(package, lambda g: (g.pop("steam_name_stale", None), g.pop("steam_art_stale", None)))
return result
def is_rift(entry: dict) -> bool:
return entry.get("kind") == "rift"
@@ -432,6 +522,8 @@ def add_from_link(manifest, path: Path, reporter: Reporter | None = None, icon:
fields = {"link": {"source": manifest.source, "name": manifest.name, "time": time.time()}}
if not manifest.direct: # a manifest (not a bare file link) names it
fields.update(title=manifest.name, title_locked=True)
if not entry.get("title_locked"): # what Rename… → reset brings back
fields["auto_title"] = entry.get("title") or pkg
# FramePort's manifest extension: a description where no store has one, the icon unless the user picked one
details = dict(library.game(pkg).get("details") or {})
if manifest.description and not details.get("description"):
@@ -509,8 +601,11 @@ def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
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)
old = library.game(a.package) or {}
# a name the user chose (Rename…, an install link) stays when the game is scanned again
names = {"auto_title": recipe.title or a.label} if old.get("title_locked") else {"title": recipe.title or a.label}
return library.upsert_game(
a.package, title=recipe.title or a.label, name=src.name, apk=str(src.apk),
a.package, **names, name=src.name, apk=str(src.apk),
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),
@@ -771,6 +866,21 @@ def build_game(package: str, reporter: Reporter, outdir: Path | None = None) ->
return build_info
def _find_late_data(entry: dict, reporter: Reporter | None = None) -> dict:
"""A game added without data files: look for them again next to its APK (the user may have copied the OBBs in
afterwards, or added the APK alone). Found → stored, like a fresh scan would."""
src_apk = Path(entry["apk"])
if not src_apk.is_file():
return entry
src = quest_dump.from_path(src_apk)
if not src or not src.data_dir:
return entry
if reporter:
reporter.log(f"found the game's data files: {src.data_dir}")
return library.upsert_game(entry["package"], data_dir=str(src.data_dir), data_files=src.data_files,
data_bytes=src.data_bytes())
def install_game(package: str, target: Target, reporter: Reporter, apk_only: bool = False,
add_to_library: bool = True, apk: Path | None = None) -> dict:
"""Install the game's last build (or `apk`, e.g. a test build of the same package signed with the same key)."""
@@ -791,6 +901,8 @@ def install_game(package: str, target: Target, reporter: Reporter, apk_only: boo
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"]))
if not entry.get("data_dir") and entry.get("apk") and not apk_only:
entry = _find_late_data(entry, reporter) # OBBs copied next to the APK after it was added (GitHub #156)
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,
@@ -930,7 +1042,11 @@ def remove_converted_copies(package: str) -> int:
def _record_install(package: str, where: str, record: dict) -> None:
"""Merge into the game's current install records (the entry read before a long install may be stale)."""
library.update_game(package, lambda g: g.setdefault("installs", {}).__setitem__(where, record))
def change(g: dict) -> None:
g.setdefault("installs", {})[where] = record
if where != PC_LABEL: # the install gave the Frame's shortcut the current name
g.pop("steam_name_stale", None)
library.update_game(package, change)
def install_rift(package: str, target: Target, reporter: Reporter, add_to_library: bool = True) -> dict:
+3
View File
@@ -41,6 +41,8 @@ class CatalogEntry:
device: list[str] = field(default_factory=list) # device.* toggles, e.g. device.text_input_window
adapter: dict = field(default_factory=dict)
device_files: dict = field(default_factory=dict)
# device.config_sync: settings file (relative to files/) -> {setting: w | h | expression of the eye-buffer size}
config_sync: dict = field(default_factory=dict)
lepton_env: dict = field(default_factory=dict)
foveation: str = "" # device.foveation: fixed / off (Valve's eye-tracked foveation layer)
pcvr_alternative: str | None = None
@@ -350,6 +352,7 @@ def entry_from_library(g: dict, status: str | None = None, notes: str | None = N
device=[p for p in r.patches if p in TOGGLED_DEVICE],
adapter={p.split(".", 1)[1]: v.get("value") for p, v in r.patches.items() if p.startswith("adapter.")},
device_files=r.params("device.files").get("files", {}),
config_sync=r.params("device.config_sync").get("files", {}),
foveation=r.params("device.foveation").get("mode") or "")
+3
View File
@@ -63,6 +63,9 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
recipe.reasons["device.files"] = why
if entry.lepton_env:
recipe.patches["device.lepton_env"] = {"env": dict(entry.lepton_env)}
if entry.config_sync:
recipe.patches["device.config_sync"] = {"files": {k: dict(v) for k, v in entry.config_sync.items()}}
recipe.reasons["device.config_sync"] = why
if entry.foveation:
recipe.patches["device.foveation"] = {"mode": entry.foveation}
recipe.reasons["device.foveation"] = why
+33 -3
View File
@@ -48,7 +48,22 @@ def _package_of(apk: Path) -> str | None:
return _apk_ids(apk)[0]
def _axrb_patched(path: Path) -> bool:
"""AXRB's own PC builds (OVRPort-converted for its Windows runtime, without the game's OBBs): <name>-axrb.apk,
AXRB/patched/<package>/ or an axrb-patched folder. The original download next to them is the game."""
parts = [p.lower() for p in path.parts]
return path.stem.lower().endswith("-axrb") or "axrb-patched" in parts or any(
a == "axrb" and b == "patched" for a, b in zip(parts, parts[1:], strict=False))
def _axrb_download(folder: Path, apk: Path) -> bool:
"""AXRB's launcher download: <app id>/<binary id>/base.apk (both folder names numbers)."""
return apk.name.lower() == "base.apk" and folder.name.isdigit() and folder.parent.name.isdigit()
def _is_apk(path: Path) -> bool:
if _axrb_patched(path):
return False
try:
with zipfile.ZipFile(path) as z:
return "AndroidManifest.xml" in z.namelist()
@@ -176,7 +191,9 @@ def _obb_folders(apk_dir: Path, pkg: str, depth: int = 3) -> list[tuple[Path, li
def below(d: Path, level: int, skip: Path | None = None) -> None:
for sub in _subdirs(d):
if sub == skip or _has_apks(sub):
# a folder with APKs is another game, except an obb folder holding this package's files (SideQuest's VR4
# backup has Unreal's VR4-Android-Shipping-arm64.apk next to its OBBs)
if sub == skip or (_has_apks(sub) and not (sub.name.lower() in OBB_FOLDERS and _named_obbs(sub, pkg))):
continue
visit(sub)
if level > 1:
@@ -227,6 +244,8 @@ def find_data_dir(apk_dir: Path, pkg: str | None, version_code: int | None = Non
# 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")
# SideQuest's backup folder names: 2026-10-08T12-08-41-153Z (also with ':' / '.' separators)
BACKUP_TIME = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}[-:.]\d{2}[-:.]\d{2}([-:.]\d+)?Z?$")
def from_path(path: Path) -> SourceGame | None:
@@ -245,7 +264,15 @@ def from_path(path: Path) -> SourceGame | None:
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)
# SideQuest backups: <package>/<backup time>/apk/x.apk; the game is named after the package folder, not the time.
# AXRB downloads: <app id>/<binary id>/base.apk: numbers, so the package names it (the store title comes later)
name = game_dir.parent.name if BACKUP_TIME.match(game_dir.name) else game_dir.name
if name.isdigit() and pkg:
name = pkg
if _axrb_download(path, apk) and data == path and files is not None:
# AXRB puts the game's other store files (DLC, extra assets) next to its OBBs: they belong in Android/obb too
files = sorted(f.name for f, is_dir in _list(path) if not is_dir and f.suffix.lower() != ".apk")
return SourceGame(display_name(name), apk, data, game_dir, [a for a in apks if a != apk], files)
def _no_quest_games_below(d: Path) -> bool:
@@ -304,4 +331,7 @@ def scan(root: Path, depth: int = 5) -> list[SourceGame]:
walk(root, 0)
finally:
_LISTINGS.reset(token)
return found
# an APK inside another game's data folder is part of that game's data, not a game (SideQuest's VR4 backup keeps
# Unreal's VR4-Android-Shipping-arm64.apk next to its OBBs)
data = [(g, g.data_dir.resolve()) for g in found if g.data_dir and g.data_dir.resolve() != g.apk.resolve().parent]
return [g for g in found if not any(h is not g and d in g.apk.resolve().parents for h, d in data)]
+2
View File
@@ -8,6 +8,8 @@ from pathlib import Path
from ..core.events import Reporter
from ..core.models import Recipe
PC_LABEL = "This PC" # PcReviveTarget.label: the key of PC installs in a library entry's "installs"
class Target(ABC):
kind: str = ""
+4 -3
View File
@@ -48,8 +48,8 @@ class FrameLeptonTarget(Target):
def add_to_library(self, packages, reporter):
return installer.add_to_steam(self.connect().frame, packages, reporter)
def update_steam_art(self, package, reporter):
return installer.update_steam_art(self.connect().frame, package, reporter)
def update_steam_art(self, package, reporter, title=None):
return installer.update_steam_art(self.connect().frame, package, reporter, title=title)
def launch_test(self, package, reporter, seconds=45):
return device.launch_test(self.connect().frame, package, reporter, seconds)
@@ -96,7 +96,8 @@ class FrameLeptonTarget(Target):
return self.connect().frame.agent("desktop_entry", package=package, enabled=enabled)
def set_vr_overlay(self, package: str, autostart: bool) -> dict:
"""An installed SteamVR overlay app's auto-start with SteamVR (agent >= 75)."""
"""An installed SteamVR overlay app's auto-start with SteamVR (agent >= 75; >= 76: FramePort's own
frameport-vr-overlays service starts it at every SteamVR start, kept by the agent from this flag)."""
return self.connect().frame.agent("register_vr_overlay", package=package, autostart=autostart)
def proton_status(self, tool: str | None = None) -> dict:
+30 -4
View File
@@ -19,7 +19,7 @@ from ..core.models import Recipe
from ..core.paths import agent_file, user_data_dir
from ..patches.pcvr import game_args
from ..validate.triage import triage
from .base import Target
from .base import PC_LABEL, Target
TAG = "FramePort PC VR" # marks the Steam shortcuts FramePort made (for updates and cleanup)
OLD_TAGS = ("Rift via Revive",) # earlier name of the same tag
@@ -86,7 +86,7 @@ def local_installs() -> dict[str, dict]:
class PcReviveTarget(Target):
kind = "pc"
label = "This PC"
label = PC_LABEL
def __init__(self):
if not winhost.available():
@@ -126,6 +126,24 @@ class PcReviveTarget(Target):
except (OSError, ValueError):
raise RuntimeError(f"{package} is not installed on this PC") from None
@staticmethod
def _save_dep(package: str, dep: dict) -> None:
path = pc_dir() / package / "deployment.json"
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(dep, indent=2), encoding="utf-8")
tmp.replace(path)
def rename(self, package: str, title: str, reporter: Reporter) -> dict:
"""A new name for an installed game's Steam shortcut on this PC (after Rename…): the shortcut is matched by
its Exe, so it keeps its appid (Play, grid art); Steam restarts once if it runs."""
dep = self._dep(package)
if dep.get("title") == title:
reporter.check("Steam library (PC)", True, f"already named {title}")
return {"state": "done", "added": [], "errors": [], "unchanged": True}
dep["title"] = title
self._save_dep(package, dep)
return self.add_to_library([package], reporter)
def collect_diag(self, package=None):
files = {}
log_path = (winhost.env_path("LOCALAPPDATA") or Path("/nonexistent")) / REVIVE_LOG
@@ -175,8 +193,13 @@ class PcReviveTarget(Target):
"sha256": extra.get("exe_sha256"), "art_lookup": extra.get("art_lookup"),
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source}, "time": time.time()}
exe_field, _, _ = shortcut_fields(dep)
dep["appid"] = _vdf().shortcut_appid(exe_field, title)
d = pc_dir() / package
try: # a reinstall keeps the shortcut's appid: Steam's entry is matched by its Exe, whatever its name now
old = json.loads((d / "deployment.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
old = {}
same_exe = bool(old.get("appid") and old.get("exe_win") and shortcut_fields(old)[0] == exe_field)
dep["appid"] = old["appid"] if same_exe else _vdf().shortcut_appid(exe_field, title)
d.mkdir(parents=True, exist_ok=True)
(d / "deployment.json").write_text(json.dumps(dep, indent=2), encoding="utf-8")
reporter.log(f"ready to run from {dep['exe_win']}" + (" through Revive" if rdir else ""))
@@ -214,7 +237,7 @@ class PcReviveTarget(Target):
for old in grid_files(grid, stale):
old.unlink(missing_ok=True)
reporter.log(f"removed the old Steam entry for {dep['title']} (its launch command changed)")
ident = vdf_mod.shortcut_appid(exe, dep["title"])
ident = dep.get("appid") or vdf_mod.shortcut_appid(exe, dep["title"])
icon = dep["exe_win"]
if "icon" in art: # Steam wants a local path for the shortcut icon
icon_path = grid / f"{ident}_icon.png"
@@ -229,6 +252,9 @@ class PcReviveTarget(Target):
for old in grid.glob(f"{got}{suffix}.*"):
old.unlink()
shutil.copy(f, grid / f"{got}{suffix}{f.suffix}")
if got != dep.get("appid"): # Play starts the shortcut by this id (a renamed game keeps its own)
dep["appid"] = got
self._save_dep(pkg, dep)
added.append({"package": pkg, "appid": got})
reporter.check(f"Steam library (PC): {dep['title']}", True, f"shortcut {got}")
except Exception as exc: # noqa: BLE001
+68 -2
View File
@@ -1100,6 +1100,7 @@ class FramePortApp:
"settings": lambda e: self.settings_dialog(pkg),
"screenshots": lambda e: self.go("screenshots", pkg),
"files": lambda e: self.go("files", pkg),
"rename": lambda e: self.rename_game(pkg),
"find_art": lambda e: self.find_artwork(pkg),
"custom_art": lambda e: self.custom_artwork(pkg),
"steam_art": lambda e: self.update_steam_art(pkg),
@@ -1576,8 +1577,7 @@ class FramePortApp:
def update_steam_art(self, pkg: str) -> Job:
"""Send the game's current artwork to its Steam entry on the Frame (Steam restarts once)."""
def run(job: Job):
self._target_for("frame").update_steam_art(pkg, job.reporter)
library.update_game(pkg, lambda e: e.pop("steam_art_stale", None))
pipeline.sync_title(pkg, self._target_for("frame"), job.reporter) # the art, and a new name if any
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")
@@ -1710,6 +1710,72 @@ class FramePortApp:
tr("Removes FramePort's entry. Anything installed on the Frame stays."), tr("Remove"), remove,
danger=True, extra=extra)
def rename_game(self, pkg: str) -> None:
"""Rename…: a new name for the game in the library and in Steam (GitHub feature request: Quest games are
often named like their APK). The name is kept from then on; the automatic name brings the old behaviour."""
g = library.game(pkg)
if not g:
return
page = self.page
current = g.get("title") or pkg
field = C.field(label=tr("Name"), value=current, autofocus=True, width=T.DIALOG_S,
max_length=pipeline.TITLE_MAX)
done = []
def save(e=None, value=None):
if done: # Enter and the button at once: act once
return
done.append(True)
page.pop_dialog()
self._apply_rename(pkg, field.value if value is None else value)
def use(name):
return lambda e: save(value=name)
field.on_submit = save
rows: list[ft.Control] = [field]
suggestions = pipeline.title_suggestions(g)
if suggestions:
rows.append(C.meta(tr("Or use:")))
rows += [C.ghost(name, ft.Icons.HISTORY_ROUNDED, use(name),
tooltip=tr("The store's name") if name == pipeline.store_title(g) else
tr("FramePort's own name for it")) for name in suggestions]
page.show_dialog(C.dialog(tr("Rename {title}").format(title=current),
ft.Column(rows, spacing=T.S2, tight=True),
[C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
C.primary(tr("Save"), on_click=save)], size="s"))
def _apply_rename(self, pkg: str, value: str) -> None:
"""Store the new name, then update the game's Steam entry where it's installed and reachable now (the Frame:
same shortcut, Steam restarts once; this PC's Steam for PC VR games). A Frame that isn't connected gets it
with the game page's "Update name on Frame" or the next install."""
before = (library.game(pkg) or {}).get("title")
try:
g = pipeline.rename_game(pkg, value)
except ValueError as exc:
self.toast(str(exc), error=True)
return
title = self._title(pkg)
if g.get("title") == before:
return
on_frame = self.frame_state == "connected" and \
C.install_state(g, self.frame_info) in ("installed", "outdated")
on_pc = g.get("kind") == "rift" and pkg in self.pc_installs()
if on_frame:
self.sync_title(pkg, "frame")
if on_pc:
self.sync_title(pkg, "pc")
self.toast(tr("Renamed to {title}. Steam restarts once to show it.").format(title=title)
if on_frame or on_pc else tr("Renamed to {title}").format(title=title))
self.refresh_view()
def sync_title(self, pkg: str, to: str = "frame") -> Job:
"""A job that gives the installed game its library name in Steam (pipeline.sync_title)."""
def run(job: Job):
pipeline.sync_title(pkg, self._target_for(to), job.reporter)
return tr("{title}: name updated in Steam").format(title=self._title(pkg))
return self.submit(tr("Rename in Steam: {title}").format(title=self._title(pkg)), run, pkg, "art", to=to)
def _target_for(self, to: str):
if to == "pc":
from ..targets.pc_revive import PcReviveTarget
+40 -3
View File
@@ -17,6 +17,9 @@ gets in the way:
the window's corner and waves back.
- The Frame reaching 100 % on the charger: its battery ring pulses.
- Long uploads (over two and a half minutes): the cover riding the progress bar does a hop now and then.
- The homepage's example "Install with FramePort" button (deeplink.DEMO_MANIFEST): its install question is a demo
for "Cool Game", and Install sends Cool Game's cover out of the logo's portal instead of installing anything
(link_dialog.show_demo; with easter eggs off it just says the link works).
Off with library setting `ui.easter_eggs` = False (the docs screenshots and videos turn them off; there's no
switch in Settings). Animations respect Reduce motion (the words stay, the motion goes). `FRAMEPORT_TODAY`
@@ -254,6 +257,38 @@ class Typed:
return self.text.endswith(HELLO)
def demo_finale(eggs: bool, reduce_motion: bool) -> str:
"""What the demo install link's Install button does: "toss" (Cool Game's cover out of the portal, then a toast),
"words" (Reduce motion: only the toast) or "plain" (easter eggs off: a plain "it works")."""
if not eggs:
return "plain"
return "words" if reduce_motion else "toss"
def demo_message(finale: str) -> str:
if finale == "plain":
return tr("Nice, it works! FramePort receives install links. (Demo: nothing was installed.)")
return tr("Cool Game went through the portal. It was only a demo: nothing was downloaded or installed.")
def demo_install(app: FramePortApp, cover_src: str) -> None:
"""The demo link's Install: the toast, after (motion and easter eggs allowed) the logo's portal tosses Cool
Game's cover across the window."""
finale = demo_finale(enabled(), app.reduce_motion)
logo = getattr(app, "logo_egg", None)
if finale != "toss" or logo is None or logo.busy:
app.toast(demo_message(finale))
return
logo.busy = True
async def run():
try:
await logo._toss(cover_src)
finally:
app.toast(demo_message(finale))
app.page.run_task(run)
def says_hello(text: str | None) -> bool:
return HELLO in (text or "").lower()
@@ -383,7 +418,7 @@ class Logo:
self.busy = True
self.app.page.run_task(self._toss)
async def _toss(self) -> None:
async def _toss(self, cover_url: str | None = None) -> None:
"""The wordmark's portal (between "Frame" and "Port") lights up: a blue-and-orange glow swells around it and
two rings run out of it; then a game's cover comes out of it small, grows to full size and falls down across
the window on a random arc, tumbling; the glow fades. Themes without the wordmark's portal use the logo's."""
@@ -392,7 +427,8 @@ class Logo:
page = self.app.page
mark = getattr(self.app, "_portal_mark", None) # the wordmark's portal (dual themes)
try:
cover_url = await asyncio.to_thread(_a_cover) # (may make a thumbnail: not on the event loop)
if cover_url is None: # a random game's (the demo install link brings its own)
cover_url = await asyncio.to_thread(_a_cover) # (may make a thumbnail: not on the event loop)
if cover_url is None:
return
width = float(getattr(page, "width", None) or 1440)
@@ -425,7 +461,8 @@ class Logo:
dt = 0.06
cover = ft.Container(width=cw, height=ch, left=cx - cw / 2, top=cy - ch / 2, scale=0.08, opacity=0,
rotate=0, border_radius=T.px(5), clip_behavior=ft.ClipBehavior.ANTI_ALIAS,
image=ft.DecorationImage(src=cover_url, fit=ft.BoxFit.COVER),
# (an Image, not a DecorationImage: the demo's cover is an SVG)
content=ft.Image(src=cover_url, width=cw, height=ch, fit=ft.BoxFit.COVER),
border=ft.Border.all(1, T.soft("#FFFFFF", 0.25)),
shadow=ft.BoxShadow(blur_radius=14, spread_radius=1, color=T.soft("#000000", 0.55),
offset=ft.Offset(0, 4)),
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="96" viewBox="0 0 72 96"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2D7FF9"/><stop offset=".55" stop-color="#7A4BD8"/><stop offset="1" stop-color="#FF8A3D"/></linearGradient><radialGradient id="sun" cx=".4" cy=".35" r=".75"><stop offset="0" stop-color="#FFE48A"/><stop offset="1" stop-color="#FFB42E"/></radialGradient></defs><rect width="72" height="96" fill="url(#bg)"/><path d="M0 74 18 60 30 68 46 54 72 72V96H0z" fill="#101218" fill-opacity=".35"/><circle cx="36" cy="40" r="20" fill="url(#sun)" stroke="#101218" stroke-width="2.4"/><path d="M17.5 35.5h37M20 35.5h13l-1.4 6.2a3.4 3.4 0 0 1-3.3 2.6h-4.6A3.4 3.4 0 0 1 20.4 42zM39 35.5h13l-.4 6.5a3.4 3.4 0 0 1-3.3 2.3h-4.6a3.4 3.4 0 0 1-3.3-2.6z" fill="#101218" stroke="#101218" stroke-width="2" stroke-linejoin="round"/><path d="M23.5 37.5h4" stroke="#FFFFFF" stroke-opacity=".7" stroke-width="1.4" stroke-linecap="round"/><path d="M29 50.5c4 3.2 10 3.2 14 0" fill="none" stroke="#101218" stroke-width="2.4" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+4 -2
View File
@@ -53,7 +53,8 @@ def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> lis
if st.movable and st.on_frame and not job:
top.append((tr("Move to…"), ft.Icons.DRIVE_FILE_MOVE_ROUNDED, act("move")))
artwork = [(tr("Find artwork…"), ft.Icons.IMAGE_SEARCH_ROUNDED, act("find_art")),
artwork = [(tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, act("rename")),
(tr("Find artwork…"), ft.Icons.IMAGE_SEARCH_ROUNDED, act("find_art")),
(tr("Use your own artwork…"), ft.Icons.UPLOAD_FILE_OUTLINED, act("custom_art"))]
if not job and st.on_frame:
artwork.append((tr("Update art on Frame"), ft.Icons.WALLPAPER_ROUNDED, act("steam_art")))
@@ -77,7 +78,7 @@ def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> lis
danger = [(tr("Remove from library…"), ft.Icons.REMOVE_CIRCLE_OUTLINE_ROUNDED, act("remove"))]
out: list = list(top)
for head, items in ((tr("Artwork"), artwork), (tr("Recipe"), recipe), (tr("Troubleshoot"), trouble)):
for head, items in ((tr("Name and artwork"), artwork), (tr("Recipe"), recipe), (tr("Troubleshoot"), trouble)):
if items:
out += [None, Header(head), *items]
return out + [None, *danger]
@@ -96,6 +97,7 @@ def quick_menu(st: MenuState, act: Callable[[str], Callable]) -> list:
out += st.installs[:1]
if st.settings:
out.append((tr("Game settings…"), ft.Icons.TUNE_ROUNDED, act("settings")))
out.append((tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, act("rename")))
out += [None, (tr("More actions…"), ft.Icons.MORE_HORIZ_ROUNDED, act("more")), None]
if st.on_frame:
out.append((tr("Uninstall from Frame…"), ft.Icons.DELETE_OUTLINE_ROUNDED, act("uninstall_frame")))
+7 -1
View File
@@ -384,7 +384,13 @@ class GameView:
"settings."), T.TEXT, expand=True),
C.secondary(tr("Use the new recipe"), ft.Icons.AUTO_FIX_HIGH_ROUNDED, take_update)],
vertical_alignment=ft.CrossAxisAlignment.CENTER), "info", ft.Icons.NEW_RELEASES_OUTLINED))
if g.get("steam_art_stale") and installed:
if g.get("steam_name_stale") and installed:
out.append(C.callout(ft.Row([
C.body(tr("The Frame's Steam library still shows the old name."), T.TEXT, expand=True),
C.secondary(tr("Update name on Frame"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED,
lambda e: self.app.update_steam_art(pkg))]), "info",
ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED))
elif g.get("steam_art_stale") and installed:
out.append(C.callout(ft.Row([
C.body(tr("The Frame's Steam library still shows the old artwork."), T.TEXT, expand=True),
C.secondary(tr("Update art on Frame"), ft.Icons.IMAGE_OUTLINED,
+39 -1
View File
@@ -1,5 +1,7 @@
"""Install links ("Install with FrameDrop" buttons, frameport:// links, pasted links): read the manifest, ask the user,
download, add to the library and install on the Frame. The protocol itself is in deeplink.py (no Flet)."""
download, add to the library and install on the Frame. The protocol itself is in deeplink.py (no Flet).
The homepage's example button (deeplink.DEMO_MANIFEST) gets a demo question instead (show_demo): no job, no network,
nothing added; its Install plays an easter egg (ui/easter.demo_install)."""
from __future__ import annotations
from typing import TYPE_CHECKING
@@ -46,6 +48,12 @@ def show_paste_dialog(app: FramePortApp) -> None:
C.primary(tr("Continue"), ft.Icons.ARROW_FORWARD_ROUNDED, on_click=go)]))
def route(req: deeplink.InstallRequest) -> str:
""""demo" (the homepage's example button: answered here, nothing fetched) or "fetch" (read the manifest in a
job, then ask)."""
return "demo" if req.demo else "fetch"
def open_link(app: FramePortApp, text: str, pasted: bool = False) -> None:
"""A link from a web page (framedrop://, frameport://) or pasted: read what it offers in the background, then ask
before anything is downloaded."""
@@ -54,6 +62,9 @@ def open_link(app: FramePortApp, text: str, pasted: bool = False) -> None:
except deeplink.LinkError as exc:
app.toast(tr("Can't use that link: {reason}").format(reason=str(exc)), error=True)
return
if route(req) == "demo":
show_demo(app, pasted)
return
def run(job: Job):
job.reporter.stage(tr("Reading the install link"))
@@ -135,3 +146,30 @@ def download_and_install(app: FramePortApp, m: deeplink.Manifest, icon=None) ->
app.page.run_thread(lambda: app.install(pkg, "frame")) # asks the usual install questions, then queues it
return tr("Downloaded {name}").format(name=g.get("title") or m.name)
return app.submit(tr("Download {name}").format(name=m.name), run, None, "task")
DEMO_COVER = "demo-cool-game" # ui/icons/demo-cool-game.svg (also the site's site/public/demo/cool-game.svg)
def show_demo(app: FramePortApp, pasted: bool = False) -> None:
"""The example button's install question: the usual layout for the placeholder "Cool Game", marked as a demo.
Install plays easter.demo_install; nothing is downloaded, added or queued."""
from .. import easter
m = deeplink.demo_manifest()
cover = C._asset_src(DEMO_COVER)
header = ft.Row([
ft.Image(src=cover, width=T.px(48), height=T.px(64), fit=ft.BoxFit.COVER, border_radius=T.RADIUS_SM),
ft.Column([
ft.Row([C.pill(tr("Demo"), T.SECONDARY)], tight=True),
C.body(tr("This is the example button from frameport.app. Real buttons install real games."), T.TEXT),
], spacing=T.S1, tight=True, expand=True),
], spacing=T.S3, vertical_alignment=ft.CrossAxisAlignment.START)
f = m.main
file_row = ft.Column([
C.body(f.filename, T.TEXT, weight=ft.FontWeight.W_500, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS),
C.meta(" · ".join((kind_label(f.kind), tr("demo: nothing is downloaded")))),
], spacing=T.px(2), tight=True)
intro = tr("You pasted a link to {name}.") if pasted else tr("A web page asked FramePort to install {name}.")
C.confirm(app.page, tr("Install {name}?").format(name=m.name), intro.format(name=m.name), tr("Install"),
lambda: easter.demo_install(app, cover), extra=ft.Column([header, file_row], spacing=T.S3, tight=True))
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more