mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-14 21:01:23 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a87ccf4c7b | ||
|
|
6b8605a83e | ||
|
|
bef42dfd1b | ||
|
|
c29c3a3fe2 | ||
|
|
2a608e92f4 | ||
|
|
90ebabab01 | ||
|
|
5f2dd4bf27 | ||
|
|
2e2fac2348 | ||
|
|
5b1f09e79a | ||
|
|
fb0fe55c6d | ||
|
|
84ae3c1ce0 | ||
|
|
2ee1dcbfcb | ||
|
|
14f026c529 | ||
|
|
30a71a1ad4 | ||
|
|
a2515f13a7 | ||
|
|
5d6072e11e | ||
|
|
0e588f74f7 | ||
|
|
13a350b3bf | ||
|
|
36dbbe0de7 | ||
|
|
eb251c45b5 | ||
|
|
df0cc6a34f | ||
|
|
3b018b6976 | ||
|
|
7c066b5fea | ||
|
|
095281fca5 | ||
|
|
8453408d24 | ||
|
|
d7a26ca181 | ||
|
|
536fba2145 |
No files matched your search
@@ -643,7 +643,9 @@ reaches its OVRInput cleanly (probe: both Touch connected 0x63, individual butto
|
||||
"outside of frame bounds" warnings stay (~2/frame) and are harmless.
|
||||
Steam library (GitHub #4/#21/#27, agent v38): shortcuts go to every Steam account (signed-in first; MostRecent isn't
|
||||
always the account on the Frame); stop_steam also waits for Steam's helpers; after the restart the agent re-reads
|
||||
shortcuts.vdf and writes once more if Steam put its old copy back (`shortcuts_lost`), else reports it. 0.6.3's Play
|
||||
shortcuts.vdf and writes once more if Steam put its old copy back (`shortcuts_lost`); lost again (agent v89): Steam
|
||||
ignores FramePort's entries on that Frame, so `add_devkit_entry` adds the devkit entry instead (`added` gets `via:
|
||||
devkit`) and only reports an error if that fails too. 0.6.3's Play
|
||||
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
|
||||
@@ -741,6 +743,7 @@ own context and saved as `files/fb_snap_0-7.ppm` (quarter size) — headless lau
|
||||
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. 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".
|
||||
**Vader's leak solved / AC Nexus flips (2026-10-14):** Vader Immortal leaves GL errors pending on its context; SteamVR's GL interop (`openxr_graphics_gl_openvr.cpp`, `xrclient_<pkg>.txt` in Steam's logs: `SXR_GL_CHECK: error:0x502 ... glGenTextures`) takes the stale error as its own failure once, and from then on `CVRCompositorSharedTextures::BeginCommandBuffer` allocates a command buffer every frame (perf page-fault stacks; ~430 renderD128 mappings + 22 MB/s). Adapter `gl_error_clear` (GLES, catalog on for Vader ep1-3) clears them before xrCreateSwapchain/xrBeginFrame/xrEndFrame: flat memory, no SXR_GL_CHECK (headless). The per-game `xrclient_<pkg>.txt` logs are worth reading for runtime errors. The Frame's runtime caps swapchains per session (XR_ERROR_LIMIT_REACHED from ~15; AC Nexus asks for more): flip emulation now turns quads upright in place (scratch VkImage, once per acquire; transfer usage added to single-layer colour swapchains) instead of a shadow swapchain per quad, which ran into the cap (#138). Headless sessions don't draw AC Nexus's panels (adapter `flip_dump` saves before/after images of flipped quads, for headset runs).
|
||||
**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
|
||||
|
||||
@@ -44,7 +44,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 88
|
||||
AGENT_VERSION = 89
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -1256,7 +1256,7 @@ def _shortcuts_worker(payload):
|
||||
if service or args.get("restart", True):
|
||||
start_steam(service)
|
||||
lost = shortcuts_lost(users, args["packages"])
|
||||
if lost: # Steam wrote its old copy back over ours (it was still saving): once more, then report
|
||||
if lost: # Steam wrote its old copy back over ours (it was still saving): once more, then the devkit entry
|
||||
result["retried"] = lost
|
||||
service = stop_steam()
|
||||
for user in users:
|
||||
@@ -1264,7 +1264,7 @@ def _shortcuts_worker(payload):
|
||||
start_steam(service)
|
||||
for pkg in shortcuts_lost(users, lost):
|
||||
result["added"] = [a for a in result["added"] if a["package"] != pkg]
|
||||
result["errors"].append(f"{pkg}: Steam removed the new library entry again after restarting")
|
||||
add_devkit_entry(pkg, result)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["state"] = "failed"
|
||||
result["errors"].append(str(exc))
|
||||
@@ -1274,6 +1274,19 @@ def _shortcuts_worker(payload):
|
||||
json.dump(result, f)
|
||||
|
||||
|
||||
def add_devkit_entry(pkg, result):
|
||||
"""Steam dropped the game's shortcuts.vdf entry twice: this Frame's Steam ignores FramePort's entries (GitHub #41),
|
||||
so add it the way Valve's devkit tool does (Steam is running again). That entry is the one Steam keeps."""
|
||||
try:
|
||||
appid = devkit_register(pkg)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"{pkg}: Steam removed the new library entry again after restarting, and the devkit "
|
||||
f"entry failed too: {exc}")
|
||||
return
|
||||
result["added"].append({"package": pkg, "appid": appid, "expected": (deployment(pkg) or {}).get("appid"),
|
||||
"via": "devkit"})
|
||||
|
||||
|
||||
def remove_from_library(user, remove, result):
|
||||
"""Remove uninstalled games' shortcuts + grid art from one Steam account (Steam is closed)."""
|
||||
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
1a4dce5c4a6d0dfc5f20e55ac29e70c73cc2cc124c21ca6a7e1b316e6afb1c79 ./arm64-v8a/libVkLayer_fdm_darken.so
|
||||
5becb96ee86fbda0ce98c0e0fd064f8ba092314aceaa400e3dcc785705dc3c23 ./arm64-v8a/libVkLayer_fp_shaderfix.so
|
||||
e7554c6343ee2989b0a273ee6230e65c25bfe6499eefd00de19f5f0fee58754f ./arm64-v8a/libfp_langpack.so
|
||||
d6337034fb0bdcca7ef9b7063d85b7586b8508b63b435959e8fffaba0691091c ./arm64-v8a/libfp_langpack.so
|
||||
69102b128e00fa1809489a7308e09c1505f563bf7091bbdd3fe5a58e4cc96c2c ./arm64-v8a/libfp_luajit.so
|
||||
321da502e0f8f46f0880aad39fe6b84bdef88f925164568814cf704a1b31a577 ./arm64-v8a/libfp_ovrp.so
|
||||
40defdaddcda53bd2649eb48076fae1622bfc2bfc88e9e03c94fc8b45af2a4f2 ./arm64-v8a/libfp_ovrtrace.so
|
||||
@@ -10,11 +10,11 @@ e8c0965c188665a4e565e4a3af983ef1866d1cf9225665cc90d8df286bc96ef3 ./arm64-v8a/li
|
||||
5432674a59d02f411cd853a5dc57e3fa547d1ac518f25bf433fc54c43677177a ./arm64-v8a/libfpglmv.so
|
||||
8a8f6b1da8952cb3933a5b53558424a5fc56043b2527a16bb6dd62a00d2de01a ./arm64-v8a/libframe_xrshim.so
|
||||
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
|
||||
e44ded778758de33ad87bb31398281d7ec94c1ecd43dcf55b1dc99311f011859 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
51f5e0c5b3d3556baab27ece2936257782d6b590183ae59601cc6d8158c96ee2 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
|
||||
7f30861c770361a4e311e3f486dcd2e48cd99a7f907961829ce68e24af17d775 ./arm64-v8a/libvrapi.so
|
||||
7f30861c770361a4e311e3f486dcd2e48cd99a7f907961829ce68e24af17d775 ./arm64-v8a/libvrapi_foveation.so
|
||||
d2361ee8b6ff62d2ff6b3f59f7b15730b95bdbe0e9dcee469ffa1117411e7288 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
6927f100536283a8c26b784af73ee400140dd910e9294e4a9d55f855547ffaf2 ./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.
@@ -13,8 +13,9 @@ details: Unreal Engine 4 (GLES). Found and verified in gameplay on a Frame by Kl
|
||||
the captured modules are from Lepton 2.8.14 and stop matching if a Frame update changes the driver's output -
|
||||
the layer logs that). OVRPlugin asks for poses at its own clock (pose_time_fix). Unreal's Oculus input animates the
|
||||
thumbs from near-touch, which the Frame never reports (frame.unreal_thumb_touch reads the touches instead; binding
|
||||
the proximity to the touch inputs in FrameBridge didn't reach the game). The Frame's SteamVR runtime
|
||||
leaks about 20 MB a second while the game runs (reported to Valve).
|
||||
the proximity to the touch inputs in FrameBridge didn't reach the game). The game leaves GL errors
|
||||
pending; the runtime's GL check took one as its own failure and then allocated a command buffer every frame
|
||||
(~20 MB a second until the frame rate collapsed). gl_error_clear clears them first (GitHub #285).
|
||||
tested_version: 1.1.1+667256.cl.387770
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
@@ -27,10 +28,11 @@ frame:
|
||||
- frame.zink_shader_fix
|
||||
adapter:
|
||||
pose_time_fix: 1
|
||||
gl_error_clear: 1
|
||||
zink_shader_fix: 12016:b2919629761ad268e0b21c72ac8520f81b76e72c3adc26897f97eae9a3e70a66:3092:0x0003003e,166,82,0x0003003e,177,82,0x0003003e,183,82,0x0003003e,189,82,0x0003003e,256,82,0x0003003e,266,82,0x0003003e,272,82,0x0003003e,278,82,0x0003003e,318,82,0x0003003e,328,82,0x0003003e,334,82,0x0003003e,340,82;11436:6f18be49f2aaa2452f962eb16f7af744c5c22a7b25f37ce681e0d6399e35a869:2932:0x0003003e,164,80,0x0003003e,175,80,0x0003003e,181,80,0x0003003e,187,80,0x0003003e,254,80,0x0003003e,264,80,0x0003003e,270,80,0x0003003e,276,80,0x0003003e,316,80,0x0003003e,326,80,0x0003003e,332,80,0x0003003e,338,80
|
||||
verified:
|
||||
date: '2026-10-09'
|
||||
issue: 49
|
||||
updated: '2026-10-09'
|
||||
updated: '2026-10-14'
|
||||
min_app: 0.12.1
|
||||
source_hint: Vader Immortal- Episode I
|
||||
@@ -18,7 +18,9 @@ frame:
|
||||
- frame.unreal_thumb_touch
|
||||
adapter:
|
||||
pose_time_fix: 1
|
||||
gl_error_clear: 1
|
||||
verified:
|
||||
date: '2026-10-09'
|
||||
updated: '2026-10-14'
|
||||
min_app: 0.12.1
|
||||
source_hint: Vader Immortal- Episode II
|
||||
@@ -18,7 +18,9 @@ frame:
|
||||
- frame.unreal_thumb_touch
|
||||
adapter:
|
||||
pose_time_fix: 1
|
||||
gl_error_clear: 1
|
||||
verified:
|
||||
date: '2026-10-09'
|
||||
updated: '2026-10-14'
|
||||
min_app: 0.12.1
|
||||
source_hint: Vader Immortal- Episode III
|
||||
@@ -4,9 +4,9 @@ tested_version: MAIN.450412.207706.final
|
||||
source_hint: Assassins Creed Nexus VR
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
status: issues
|
||||
notes: Some launch warning text is still upside down; the rest of the UI is fixed by flip emulation. Ubisoft login
|
||||
may not work.
|
||||
status: works
|
||||
notes: FrameBridge turns its upside-down UI panels upright (flip emulation, in place since FramePort 1.1.2). Ubisoft
|
||||
login may not work.
|
||||
details: Ubisoft online login may not work. Data folder holds raw assets (start0..2, .climb, bundles), not .obb
|
||||
files; install to obb as-is.
|
||||
overport_extra:
|
||||
@@ -17,3 +17,6 @@ verified:
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 89644a12bf8df956fa3d3a9a40540ba9e1fb62f63494714a05c9f05984383ac8
|
||||
no_appsw_90: '2026-10-02, OVRPort 1.2.5 (owner: 90 Hz felt better than 72 Hz; same known issues)'
|
||||
flip_in_place: '2026-10-14, owner in the headset: launch warnings and tutorial pop-ups upright (GitHub #138)'
|
||||
updated: '2026-10-14'
|
||||
min_app: 1.1.2
|
||||
@@ -0,0 +1,20 @@
|
||||
package: com.edde746.plezy
|
||||
title: Plezy
|
||||
status: issues
|
||||
notes: Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding.
|
||||
Otherwise works.
|
||||
tested_version: 2.22.0
|
||||
engine: Other
|
||||
xr: '?'
|
||||
frame:
|
||||
- frame.hw_video_decode
|
||||
as_is: true
|
||||
verified:
|
||||
date: '2026-10-13'
|
||||
known_good_sha256: 893ae7c01d3d7688b27cbf4e7fc1260fb617614baeaf378fe5ca0af41febc92f
|
||||
app: 1.1.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 85
|
||||
issue: 270
|
||||
source_hint: plezy
|
||||
@@ -0,0 +1,20 @@
|
||||
package: com.fulldivegames.majorasmaskvr
|
||||
title: Majora's Mask VR
|
||||
status: issues
|
||||
notes: Audio on the file select screen sounds crunchy. Controls may need rebinding in the game. Quest build from https://github.com/fulldivegames/Majoras-Mask-VR.
|
||||
tested_version: '0.36'
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.sdl_clipboard
|
||||
adapter:
|
||||
refresh_rate: 144.0
|
||||
verified:
|
||||
date: '2026-10-13'
|
||||
known_good_sha256: 220096c70fb515ec7b8db1cc7a9075836b4d09f7df63026908af3c30e9fdb7e5
|
||||
app: 1.1.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 85
|
||||
issue: 268
|
||||
source_hint: MMVR-Quest-0.36
|
||||
@@ -0,0 +1,26 @@
|
||||
package: com.nDreams.Ghostbusters
|
||||
title: 'Ghostbusters: Rise of the Ghost Lord'
|
||||
status: works
|
||||
notes: 'Needs the game''s offline mode: it is built for online play, and without a profile the player name is a random
|
||||
number. The flat loading screen floats in front of you instead of following your head, so assets can be seen loading
|
||||
in behind it.'
|
||||
tested_version: 1.325635.prod.shipping
|
||||
engine: Unreal
|
||||
xr: OpenXR
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
frame:
|
||||
- frame.unreal_ovrp_entrypoints
|
||||
- frame.hw_video_decode
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
scene_emul: 1
|
||||
verified:
|
||||
date: '2026-10-14'
|
||||
known_good_sha256: 7556a78c8772aed70c38fc4781677c0900e9dfd4be085a79c421a31323af7888
|
||||
app: 1.1.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 85
|
||||
issue: 279
|
||||
source_hint: GBRotgl
|
||||
+12
-4
@@ -6,6 +6,11 @@
|
||||
# with diagnostics. Launch tests and real play sessions (agent session_log, `frameport session`) are both matched.
|
||||
# Sources: every failure we hit porting 34 games to the Steam Frame (see docs/PLAYBOOK.md).
|
||||
signatures:
|
||||
- id: meta-asset-status-error
|
||||
pattern: 'Asset status error\s+\(code 100\)'
|
||||
severity: error
|
||||
diagnosis: A DLC manager reports a Meta asset-file status error after VR starts (e.g. PixelRipped 1995). The asset-file check failed; the log does not identify the native call or asset ID. Try the experimental asset-file bypass (empty list, installed status with no file path); this does not supply DLC and has not been verified in the game.
|
||||
suggest: [frame.asset_files_stub]
|
||||
- id: lua-lightuserdata-pointer
|
||||
pattern: 'bad light userdata pointer'
|
||||
severity: fatal
|
||||
@@ -375,10 +380,13 @@ signatures:
|
||||
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]
|
||||
on an error screen. "Unreal - start without the OBB check" skips the content check. Copy the APK and OBB
|
||||
from the same install, add the game again and reinstall.
|
||||
If the downloader still appears with bVerifyOBBOnStartUp=false (the skip patch already applied), try
|
||||
"Unreal: skip the OBB delivery check", an experimental bypass of the separate Java name and size gate
|
||||
(e.g. Into The Radius, GitHub #283). An external-storage path or visibility failure can reject an uploaded
|
||||
OBB; the native engine still needs complete, matching, readable data.
|
||||
suggest: [frame.unreal_skip_obb_check, frame.unreal_skip_obb_delivery]
|
||||
- id: tbxr-fdm-shutdown-crash
|
||||
pattern: '(?s)\A(?=.*Fatal signal 11)(?=.*fdm_injection_xrDestroySwapchain)(?=.*TBXR_LeaveVR)'
|
||||
severity: error
|
||||
|
||||
+4
-1
@@ -12,6 +12,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| AllInOneSports | Quest | ✅ Works | |
|
||||
| Arkio | Quest | ✅ Works | |
|
||||
| Asgard's Wrath 2 | Quest | ✅ Works | |
|
||||
| Assassin's Creed Nexus | Quest | ✅ Works | |
|
||||
| Audica | Quest | ✅ Works | |
|
||||
| BAM | Quest | ✅ Works | |
|
||||
| BARTENDER VR SIMULATOR | Quest | ✅ Works | |
|
||||
@@ -49,6 +50,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Fruit Ninja | Quest | ✅ Works | |
|
||||
| Fruit Salon | Quest | ✅ Works | |
|
||||
| Genotype | Quest | ✅ Works | |
|
||||
| Ghostbusters: Rise of the Ghost Lord | Quest | ✅ Works | |
|
||||
| GORN2 | Quest | ✅ Works | |
|
||||
| Gravity Lab | Quest | ✅ Works | |
|
||||
| H.U.N.T | Quest | ✅ Works | |
|
||||
@@ -133,7 +135,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| ZombielandVR | Quest | ✅ Works | |
|
||||
| Ancient_Dungeon | Quest | ⚠️ Works with issues | Multiplayer isn't available (offline). |
|
||||
| Arcsmith | Quest | ⚠️ Works with issues | Right eye distorts during movement (unresolved; swap, tracking, Valve layers, depth and pacing ruled out). |
|
||||
| Assassin's Creed Nexus | Quest | ⚠️ Works with issues | Some launch warning text is still upside down; the rest of the UI is fixed by flip emulation. |
|
||||
| Does it Stack? | Quest | ⚠️ Works with issues | Mixed reality mode does not work (works on Demeo), everything else seems to be OK |
|
||||
| Dungeons of Eternity | Quest | ⚠️ Works with issues | It can not connect to servers |
|
||||
| End Space | Quest | ⚠️ Works with issues | Controls stop responding after the first mission loads. |
|
||||
@@ -141,9 +142,11 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Fisherman | Quest | ⚠️ Works with issues | Loading screens have corruption, but everything works otherwise. |
|
||||
| Freedom | Quest | ⚠️ Works with issues | Volume can't be changed in the game. |
|
||||
| MacacoVR | Quest | ⚠️ Works with issues | 2d loading screens are glitchy, otherwise main gameplay is fine |
|
||||
| Majora's Mask VR | Quest | ⚠️ Works with issues | Audio on the file select screen sounds crunchy. |
|
||||
| Myst | Quest | ⚠️ Works with issues | Minor graphical glitches on some objects. |
|
||||
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
|
||||
| Pinball FX VR | Quest | ⚠️ Works with issues | Plays; mixed reality mode not working yet. |
|
||||
| Plezy | Quest | ⚠️ Works with issues | Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding. |
|
||||
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
|
||||
| Swarm 2 | Quest | ⚠️ Works with issues | MSAA creates visible seams around edge geometry and also the skybox edge |
|
||||
| The Light Brigade | Quest | ⚠️ Works with issues | Judder in weapons |
|
||||
|
||||
+78
-19
@@ -2,7 +2,7 @@
|
||||
|
||||
# Patches
|
||||
|
||||
Every patch and game setting FramePort knows: 164 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
Every patch and game setting FramePort knows: 168 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
|
||||
Each entry says what the patch does, whether it changes the APK or only the install, when FramePort suggests it, its options, the catalog games whose recipe names it and the launch-test signatures that suggest it. Patches that are on by default are on for every game, so the catalog rarely names them.
|
||||
|
||||
@@ -11,8 +11,8 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
## Groups
|
||||
|
||||
- [Patches from OVRPort](#group-overport) (25)
|
||||
- [Steam Frame patches](#group-frame) (52)
|
||||
- [Game settings](#group-adapter) (66)
|
||||
- [Steam Frame patches](#group-frame) (54)
|
||||
- [Game settings](#group-adapter) (68)
|
||||
- [Files and settings on the Frame](#group-device) (8)
|
||||
- [PC VR (Proton and Revive)](#group-pcvr) (13)
|
||||
|
||||
@@ -39,7 +39,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Patch Oculus detection for Unity](#patch_oculus_unity) | `patch_oculus_unity` | Patches from OVRPort | default |
|
||||
| [Patch Oculus detection for Unreal](#patch_oculus_unreal) | `patch_oculus_unreal` | Patches from OVRPort | default |
|
||||
| [Remove localized app names](#patch_remove_localized_names) | `patch_remove_localized_names` | Patches from OVRPort | default |
|
||||
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 35 |
|
||||
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 36 |
|
||||
| [Remove uses-library](#patch_remove_uses_library) | `patch_remove_uses_library` | Patches from OVRPort | default |
|
||||
| [Remove VrApi library](#patch_remove_vrapi) | `patch_remove_vrapi` | Patches from OVRPort | |
|
||||
| [Replace application label and icon](#patch_replace_icon_label) | `patch_replace_icon_label` | Patches from OVRPort | default |
|
||||
@@ -47,6 +47,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [VrApi → OpenXR adapter (OVRPort)](#patch_vrapi_openxr) | `patch_vrapi_openxr` | Patches from OVRPort | |
|
||||
| [FrameBridge OpenXR adapter](#frame-adapter) | `frame.adapter` | Steam Frame patches | default |
|
||||
| [Content files from the game's data](#frame-asset_files) | `frame.asset_files` | Steam Frame patches | 1 |
|
||||
| [Asset-file service bypass](#frame-asset_files_stub) | `frame.asset_files_stub` | Steam Frame patches | |
|
||||
| [Skip Meta avatars](#frame-avatar_stub) | `frame.avatar_stub` | Steam Frame patches | 1 |
|
||||
| [bHaptics: skip the Bluetooth permission request](#frame-bhaptics_permission) | `frame.bhaptics_permission` | Steam Frame patches | |
|
||||
| [BlazeRush: controllers without Meta avatars, own shader cache](#frame-blazerush) | `frame.blazerush` | Steam Frame patches | 1 |
|
||||
@@ -54,7 +55,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [OpenGL ES: multiview shaders on flat panels (HUD, menus)](#frame-gl_multiview_fbo) | `frame.gl_multiview_fbo` | Steam Frame patches | 1 |
|
||||
| [GL shim (Mesa GLSL compatibility)](#frame-gl_shim) | `frame.gl_shim` | Steam Frame patches | 2 |
|
||||
| [Godot apps: start without a clipboard service](#frame-godot_clipboard) | `frame.godot_clipboard` | Steam Frame patches | |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 7 |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 9 |
|
||||
| [Language packs from the game's files](#frame-langpacks) | `frame.langpacks` | Steam Frame patches | |
|
||||
| [Launcher category (INFO → LAUNCHER)](#frame-launcher) | `frame.launcher` | Steam Frame patches | default |
|
||||
| [GL wrapper: OpenGL ES depth formats (Minecraft)](#frame-ltw_depth) | `frame.ltw_depth` | Steam Frame patches | default |
|
||||
@@ -69,7 +70,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Platform compat (ovrMessageType_ToString)](#frame-ovrplatformcompat) | `frame.ovrplatformcompat` | Steam Frame patches | default |
|
||||
| [Stub missing Meta platform functions](#frame-ovrstubs) | `frame.ovrstubs` | Steam Frame patches | default |
|
||||
| [Unpaired pointer-authentication checks](#frame-pac_hints) | `frame.pac_hints` | Steam Frame patches | 1 |
|
||||
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 3 |
|
||||
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 4 |
|
||||
| [Stress Level Zero: let Unity start Vulkan itself](#frame-slz_vulkan_hooks) | `frame.slz_vulkan_hooks` | Steam Frame patches | 1 |
|
||||
| [Start straight in VR (skip the 2D launcher)](#frame-start_activity) | `frame.start_activity` | Steam Frame patches | |
|
||||
| [Allow swapchains larger than 4096 px](#frame-swapchain_limit) | `frame.swapchain_limit` | Steam Frame patches | default |
|
||||
@@ -85,10 +86,11 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Unity: headset always counts as worn](#frame-unity_user_presence) | `frame.unity_user_presence` | Steam Frame patches | 1 |
|
||||
| [GL shim for Unreal (no multisampled render-to-texture)](#frame-unreal_gl_shim) | `frame.unreal_gl_shim` | Steam Frame patches | 1 |
|
||||
| [Unreal: match multiview depth to the eye size](#frame-unreal_multiview_depth) | `frame.unreal_multiview_depth` | Steam Frame patches | 1 |
|
||||
| [Unreal: stand-ins for OVRPlugin functions OVRPort lacks](#frame-unreal_ovrp_entrypoints) | `frame.unreal_ovrp_entrypoints` | Steam Frame patches | 1 |
|
||||
| [Unreal: stand-ins for OVRPlugin functions OVRPort lacks](#frame-unreal_ovrp_entrypoints) | `frame.unreal_ovrp_entrypoints` | Steam Frame patches | 2 |
|
||||
| [Unreal: Quest controller bindings (RPOC key map)](#frame-unreal_quest_keymap) | `frame.unreal_quest_keymap` | Steam Frame patches | 3 |
|
||||
| [Unreal: don't wait for the Quest shader precompile](#frame-unreal_quest_precompile) | `frame.unreal_quest_precompile` | Steam Frame patches | 3 |
|
||||
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 3 |
|
||||
| [Unreal: skip the OBB delivery check](#frame-unreal_skip_obb_delivery) | `frame.unreal_skip_obb_delivery` | Steam Frame patches | |
|
||||
| [Unreal: bypass the stalled loading splash](#frame-unreal_splash_bypass) | `frame.unreal_splash_bypass` | Steam Frame patches | 1 |
|
||||
| [Unreal: thumb follows the capacitive touches](#frame-unreal_thumb_touch) | `frame.unreal_thumb_touch` | Steam Frame patches | 7 |
|
||||
| [Vermillion: paint canvases in the scene](#frame-vermillion_scene_canvas) | `frame.vermillion_scene_canvas` | Steam Frame patches | 1 |
|
||||
@@ -112,17 +114,19 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [360° 3D](#adapter-equirect_stereo) | `adapter.equirect_stereo` | Game settings | |
|
||||
| [Per-eye diagnostics (log)](#adapter-eye_debug) | `adapter.eye_debug` | Game settings | |
|
||||
| [Draw fades over the picture](#adapter-fade_quad) | `adapter.fade_quad` | Game settings | 2 |
|
||||
| [Upside-down panel images (diagnostics)](#adapter-flip_dump) | `adapter.flip_dump` | Game settings | |
|
||||
| [Fix upside-down menus](#adapter-flip_emul) | `adapter.flip_emul` | Game settings | |
|
||||
| [Rotate panels 180°](#adapter-flip_quads) | `adapter.flip_quads` | Game settings | |
|
||||
| [Don't pause on short interruptions](#adapter-focus_hold) | `adapter.focus_hold` | Game settings | 4 |
|
||||
| [Ignore focus dips up to](#adapter-focus_hold_ms) | `adapter.focus_hold_ms` | Game settings | 2 |
|
||||
| [Hide Quest foveation](#adapter-foveation_fix) | `adapter.foveation_fix` | Game settings | |
|
||||
| [Clear the game's GL errors (GLES)](#adapter-gl_error_clear) | `adapter.gl_error_clear` | Game settings | 3 |
|
||||
| [Hide fast anti-aliasing (OpenGL ES)](#adapter-gl_hide_msrtt) | `adapter.gl_hide_msrtt` | Game settings | 1 |
|
||||
| [Draw each eye separately (OpenGL ES)](#adapter-gl_hide_multiview) | `adapter.gl_hide_multiview` | Game settings | 1 |
|
||||
| [GL shim: high-precision vertex shaders](#adapter-gl_highp_markers) | `adapter.gl_highp_markers` | Game settings | 1 |
|
||||
| [GL shim: integer vertex inputs](#adapter-gl_int_attribs) | `adapter.gl_int_attribs` | Game settings | 1 |
|
||||
| [Flat panel diagnostics (log)](#adapter-gl_mv_debug) | `adapter.gl_mv_debug` | Game settings | |
|
||||
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 65 |
|
||||
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 66 |
|
||||
| [Vibration strength](#adapter-haptic_scale) | `adapter.haptic_scale` | Game settings | |
|
||||
| [Turn off space warp](#adapter-hide_space_warp) | `adapter.hide_space_warp` | Game settings | 4 |
|
||||
| [Controller input diagnostics (log)](#adapter-input_diag) | `adapter.input_diag` | Game settings | |
|
||||
@@ -138,12 +142,12 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Treat newer Quest controllers as Touch](#adapter-profile_remap) | `adapter.profile_remap` | Game settings | |
|
||||
| [Animate fingers from touch](#adapter-proximity_emul) | `adapter.proximity_emul` | Game settings | |
|
||||
| [Keep pictures in bounds](#adapter-rect_clamp) | `adapter.rect_clamp` | Game settings | |
|
||||
| [Refresh rate](#adapter-refresh_rate) | `adapter.refresh_rate` | Game settings | 2 |
|
||||
| [Refresh rate](#adapter-refresh_rate) | `adapter.refresh_rate` | Game settings | 3 |
|
||||
| [Wait for the game's GPU](#adapter-release_wait) | `adapter.release_wait` | Game settings | |
|
||||
| [Re-create play space](#adapter-respace_kick) | `adapter.respace_kick` | Game settings | |
|
||||
| [Sharpness](#adapter-scale) | `adapter.scale` | Game settings | 4 |
|
||||
| [Room depth](#adapter-scene_depth) | `adapter.scene_depth` | Game settings | |
|
||||
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 3 |
|
||||
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 4 |
|
||||
| [Room height](#adapter-scene_height) | `adapter.scene_height` | Game settings | |
|
||||
| [Room width](#adapter-scene_width) | `adapter.scene_width` | Game settings | |
|
||||
| [Eye-image snapshots (diagnostics)](#adapter-snapshot) | `adapter.snapshot` | Game settings | |
|
||||
@@ -415,7 +419,7 @@ Stops some Unreal games from closing themselves right after starting.
|
||||
- `patch_remove_unreal_force_quit`
|
||||
- **Changes:** Changes the APK (during the OVRPort conversion).
|
||||
- **Offered (off):** Built as the alternate APK for Unreal games (use it if the game quits itself).
|
||||
- **Catalog games (35):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2, ExploreVR and 27 more.
|
||||
- **Catalog games (36):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2, ExploreVR and 28 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `force-quit` (error): The game quit itself right after starting.
|
||||
- **Source:** [patches/overport.py](../src/frameport/patches/overport.py)
|
||||
@@ -511,6 +515,19 @@ Lets the game find extra content it keeps next to its data (for example Star War
|
||||
|
||||
> 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.
|
||||
|
||||
### <a id="frame-asset_files_stub"></a>Asset-file service bypass
|
||||
|
||||
Experimental bypass for a failed Meta asset-file check at startup. Reports an empty asset list and installed status without a file path; does not provide DLC. Experimental.
|
||||
|
||||
- `frame.asset_files_stub` · experimental
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `meta-asset-status-error` (error): A DLC manager reports a Meta asset-file status error after VR starts (e.g.
|
||||
- **Source:** [patches/frame/langpack.py](../src/frameport/patches/frame/langpack.py)
|
||||
|
||||
> Experimental bypass for a DLC manager blocked on Meta asset-file service errors at startup (e.g. PixelRipped 1995: Asset status error (code 100)). Returns an empty successful asset list and reports requested asset IDs as installed, with an empty file path. Does not supply DLC or change download requests. Enable only to test this startup failure; disable with FRAMEPORT_ASSET_FILES_STUB=0. Shares the language-pack library and needs no language packs.
|
||||
|
||||
### <a id="frame-avatar_stub"></a>Skip Meta avatars
|
||||
|
||||
Lets games start that would stop at Meta's avatars (for example BlazeRush). No Meta avatars.
|
||||
@@ -611,7 +628,7 @@ Plays the game's videos on the Frame's video hardware, so 4K and 8K video runs s
|
||||
- `frame.hw_video_decode` · also for 2D apps
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** The game plays video through Android's decoders (MediaCodec, ExoPlayer or VLC): decode it on the Frame's hardware.
|
||||
- **Catalog games (7):** 4XVR Video Player, Batman: Arkham Shadow, Dance Central, Fruit Ninja, Kill It With Fire VR, MacacoVR, Moss: Book II.
|
||||
- **Catalog games (9):** 4XVR Video Player, Batman: Arkham Shadow, Dance Central, Fruit Ninja, Ghostbusters: Rise of the Ghost Lord, Kill It With Fire VR, MacacoVR, Moss: Book II and 1 more.
|
||||
- **Source:** [patches/frame/hw_video_decode.py](../src/frameport/patches/frame/hw_video_decode.py)
|
||||
|
||||
> Decodes the game's videos on the Frame's hardware video decoder instead of Android's software decoders, which stutter or fall behind with 4K/8K video (for example video players, Batman: Arkham Shadow's cutscenes). Adds FramePort's codec plugin (OMX.frameport.avc/hevc/vp9.decoder) to this game's container only; the APK isn't changed. When the hardware is busy (for example Steam's own hardware video decoding is on) the video is decoded in software as before.
|
||||
@@ -804,7 +821,7 @@ Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboar
|
||||
- `frame.sdl_clipboard` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** SDL app: it crashes at start in Lepton, which has no clipboard service.
|
||||
- **Catalog games (3):** Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
|
||||
- **Catalog games (4):** Majora's Mask VR, Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `sdl-no-clipboard` (fatal): The app crashed at start because the Frame's Android has no clipboard.
|
||||
- **Source:** [patches/frame/sdl_clipboard.py](../src/frameport/patches/frame/sdl_clipboard.py)
|
||||
@@ -1021,7 +1038,7 @@ Lets older Unreal games start VR on the Frame (for example Star Wars Pinball VR)
|
||||
- `frame.unreal_ovrp_entrypoints`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unreal looks up OVRPlugin function(s) OVRPort's OVRPlugin lacks: ……. Without them Unreal starts no VR.
|
||||
- **Catalog games (1):** Star Wars Pinball VR.
|
||||
- **Catalog games (2):** Ghostbusters: Rise of the Ghost Lord, Star Wars Pinball VR.
|
||||
- **Source:** [patches/frame/unreal_ovrp.py](../src/frameport/patches/frame/unreal_ovrp.py)
|
||||
|
||||
> 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.
|
||||
@@ -1064,6 +1081,19 @@ Starts Unreal games without re-checking their data file (they'd seem to hang on
|
||||
|
||||
> 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.
|
||||
|
||||
### <a id="frame-unreal_skip_obb_delivery"></a>Unreal: skip the OBB delivery check
|
||||
|
||||
Skips a separate data-file check when an Unreal game still hangs at startup with the content check off. Experimental; the game still needs its data.
|
||||
|
||||
- `frame.unreal_skip_obb_delivery` · experimental · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unreal-obb-check-stuck` (fatal): 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).
|
||||
- **Source:** [patches/frame/unreal_obb_check.py](../src/frameport/patches/frame/unreal_obb_check.py)
|
||||
|
||||
> Unreal's Java OBB delivery check can leave a game on an invisible DownloaderActivity screen even with the manifest CRC check off (e.g. Into The Radius). Makes known Unreal expansionFilesDelivered methods return true, bypassing the Java name and size gate. Experimental: the native engine still needs complete, matching, readable OBB data.
|
||||
|
||||
### <a id="frame-unreal_splash_bypass"></a>Unreal: bypass the stalled loading splash
|
||||
|
||||
Lets the game finish loading when its separate loading splash stalls the render thread.
|
||||
@@ -1397,6 +1427,20 @@ For games whose fades to black don't show, so you see the level loading (for exa
|
||||
|
||||
> Games that fade to black by dimming their picture with a layer color scale (XR_KHR_composition_layer_color_scale_bias, for example Batman: Arkham Shadow's loading screens) can show the level loading instead of black on the Frame. FrameBridge then draws the fade itself: a black quad in front of the eyes whose opacity follows the game's dimming. 2 also removes the game's own dimming (if both show, the fade is doubled).
|
||||
|
||||
### <a id="adapter-flip_dump"></a>Upside-down panel images (diagnostics)
|
||||
|
||||
Saves a few UI panels before and after FrameBridge turns them upright, for testing without the headset.
|
||||
|
||||
- `adapter.flip_dump`
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Troubleshooting → "Upside-down panel images (diagnostics)" (advanced).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Saves the first N UI panels that FrameBridge turns upright (Vulkan games whose panels are stored upside down), before and after the flip, as fb_flip_<n>_raw.ppm / fb_flip_<n>_upright.ppm in the game's files folder on the Frame. Shows what the runtime gets without anyone wearing the headset.
|
||||
|
||||
### <a id="adapter-flip_emul"></a>Fix upside-down menus
|
||||
|
||||
Turns menus and text the right way up.
|
||||
@@ -1471,6 +1515,21 @@ Hides a Quest-only way of saving GPU power that the Frame lacks. Leave it on.
|
||||
|
||||
> Hides Quest foveation extensions the Frame runtime lacks.
|
||||
|
||||
### <a id="adapter-gl_error_clear"></a>Clear the game's GL errors (GLES)
|
||||
|
||||
For OpenGL ES games whose memory use keeps growing (e.g. Vader Immortal).
|
||||
|
||||
- `adapter.gl_error_clear`
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Troubleshooting → "Clear the game's GL errors (GLES)" (advanced).
|
||||
- **Catalog games (3):** Vader Immortal: Episode I, Vader Immortal: Episode II, Vader Immortal: Episode III.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Clears OpenGL errors the game left pending before FrameBridge calls the Frame's runtime. The runtime's GL code fails on any pending error; after one such failure at the start it allocated GPU memory every frame without freeing it (e.g. Vader Immortal, ~20 MB a second).
|
||||
|
||||
### <a id="adapter-gl_hide_msrtt"></a>Hide fast anti-aliasing (OpenGL ES)
|
||||
|
||||
For Unity games that crash when they turn on anti-aliasing (for example The Room VR).
|
||||
@@ -1555,7 +1614,7 @@ For games that freeze the Frame when a controller vibrates (for example Lucky's
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Controllers → "Fix freezes when controllers vibrate" (advanced).
|
||||
- **Catalog games (65):** AgeOfJoy, Ancient_Dungeon, Audica, BattleSisters, Beat Saber, Caves, Clockwork, COMPOUND and 57 more.
|
||||
- **Catalog games (66):** AgeOfJoy, Ancient_Dungeon, Audica, BattleSisters, Beat Saber, Caves, Clockwork, COMPOUND and 58 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the Frame when the game vibrates a controller (for example Lucky's Tale's save slots, GitHub #9). Needs a rebuild.
|
||||
@@ -1790,7 +1849,7 @@ How often the picture updates. Higher is smoother if the game can keep up.
|
||||
- **Options:**
|
||||
- Value: float, default `0.0`.
|
||||
- Game settings → Picture → "Refresh rate": Game's choice (`0`), 72 Hz (`72`), 80 Hz (`80`), 90 Hz (`90`), 96 Hz (`96`), 108 Hz (`108`), 120 Hz (`120`), 144 Hz (`144`).
|
||||
- **Catalog games (2):** Does it Stack?, Vacation Simulator.
|
||||
- **Catalog games (3):** Does it Stack?, Majora's Mask VR, Vacation Simulator.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Display refresh rate for this game (72, 80, 90, 96, 108, 120 or 144; 0 = the game's choice). A video's frame rate that divides the refresh rate plays smoothest (for example 30 fps at 90 Hz, 24 fps at 72 Hz).
|
||||
@@ -1863,7 +1922,7 @@ Mixed-reality games that build their level from your room get a simple room with
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Mixed reality → "Pretend room layout".
|
||||
- **Catalog games (3):** Arkio, Demeter, Vermillion.
|
||||
- **Catalog games (4):** Arkio, Demeter, Ghostbusters: Rise of the Ghost Lord, Vermillion.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `scene-missing` (warning): The game wants Meta's room data (scene API), which the Frame lacks.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
@@ -1929,7 +1988,7 @@ For games whose menus or screens jump to wherever you look.
|
||||
|
||||
### <a id="adapter-strip_color_bias"></a>Drop layer color fades
|
||||
|
||||
For games whose memory use keeps growing (for example Vader Immortal). Some fades no longer show.
|
||||
Diagnostics: removes layer color fades. Some fades no longer show.
|
||||
|
||||
- `adapter.strip_color_bias`
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
@@ -1939,7 +1998,7 @@ For games whose memory use keeps growing (for example Vader Immortal). Some fade
|
||||
- Game settings → Troubleshooting → "Drop layer color fades" (advanced).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Removes the color scale/bias (XR_KHR_composition_layer_color_scale_bias) from the game's layers, so the runtime doesn't apply it. The Frame's runtime allocates GPU memory for it every frame without freeing it, for example Vader Immortal leaked ~20 MB/s on its loading screen. Fades done this way no longer show.
|
||||
> Removes the color scale/bias (XR_KHR_composition_layer_color_scale_bias) from the game's layers, so the runtime doesn't apply it (diagnostics). Fades done this way no longer show.
|
||||
|
||||
### <a id="adapter-strip_depth"></a>Strip depth layers
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| A Unity OpenXR game closes right after the OpenXR instance is created: `F VrApiLoader: vrapi_SetPropertyInt was called before vrapi_Initialize()!`, SIGABRT on UnityMain (GitHub #62, Jurassic World Aftermath) | the APK still ships Meta's VrApi loader; OVRPlugin (1.89, on OpenXR) calls it without starting VrApi and Meta's loader aborts. The VrApi bridge can't replace it (39 of the loader's 114 functions: OVRPlugin wouldn't link) | `frame.vrapi_stub` (same exports, each returns 0); triage `vrapi-before-init` |
|
||||
| A VrApi game exits at once: `UnsatisfiedLinkError … dlopen failed: cannot locate symbol "vrapi_PollEvent"` (GitHub #57, BlazeRush) | the engine imports a VrApi function the VrApi bridge didn't implement | the bridge has `vrapi_PollEvent`, `RecenterPose`, `SetDisplayRefreshRate`, `GetSystemPropertyFloatArray` (rebuild the game); the static check "VrApi functions resolvable" names any other missing one; triage `vrapi-symbol-missing` |
|
||||
| A Team Beef port (Lambda1VR, RTCWQuest, …) exits at once: `UnsatisfiedLinkError: dlopen failed: library "libopenxr_loader_valve.so" not found` (GitHub #59) | TBXR loads `openxr_loader_<Build.MANUFACTURER>` (Lepton: valve) and takes the Meta path only when `strstr(OPENXR_HMD, "meta")` matches, else Pico's | `frame.tbxr_vendor` (empty `libopenxr_loader_valve.so` + the "meta" literal → "alve"). Game data (`xash/`, …) goes to /sdcard: upload it with the Files tab to the game's storage. Black eyes with `TBXR: Incomplete frame buffer object: GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE`: TBXR always uses multisampled render-to-texture (even `--msaa 1`); `frame.tbxr_vendor` links the GL shim, which hands it single-sampled versions (log `GL shim: multisampled render-to-texture … drawn single-sampled`) |
|
||||
| Black screen after VR starts; `Asset status error (code 100)` in `DLC_Manager:AssetStatusCallback` (GitHub #203, e.g. PixelRipped 1995) | Meta asset-file status fails after entitlement succeeds; the log does not identify the native call or asset ID | Try `frame.asset_files_stub` (opt-in, triage-only): native/langpack answers GetList with an empty successful list and StatusById with installed status, an empty path and the requested ID. No DLC files are supplied; download requests retain their existing behavior. Disable with `FRAMEPORT_ASSET_FILES_STUB=0`. Host-tested only; whether this fixes startup needs a game/device test |
|
||||
| Missing sound or extra content in a game whose OBB folder has separate `*.pak` files (GitHub #61, Star Wars: Tales from the Galaxy's Edge: seasons + Wwise banks) | the game asks Meta's platform for these asset files (`ovr_AssetFile_GetList` / `DownloadById`); OVRPort's loader doesn't list them | copy every content/sound-bank PAK and use `frame.asset_files` (native/langpack lists the data's `*.pak` as installed `default` assets, IAP `free`, and answers DownloadById at once with the path; off: env FRAMEPORT_ASSET_FILES=0). Tales also needs the splash and depth-size fixes below |
|
||||
| A SideQuest backup has `apk/` and a sibling `obb/` with content/sound-bank PAKs, but the Frame only has the main OBB (GitHub #61, Tales from the Galaxy's Edge) | older scans treated that layout as loose expansion files and selected only the named OBBs; the asset-file patch then found no PAKs | the scanner chooses the OBB folder by version first, then keeps the whole sibling data folder when all its expansion files are selected for this APK; app updates repair affected library entries and refresh asset/language-pack detection. Reinstall to copy the missing files, or scan the backup again first. Folders with other packages or unselected OBB versions still send only the selected OBBs |
|
||||
| Tales from the Galaxy's Edge stops during map loading (GitHub #61): game thread in `FlushRenderingCommands` / `UEngine::LoadMap`, render thread in `FSplash::RenderFrame_RenderThread` / `ovrp_WaitToBeginFrame` / SteamVR `WaitFrame` | the separate Oculus loading splash waits for an unbegun frame while map travel waits for rendering | `frame.unreal_splash_bypass`: returns from the splash renderer, with a checked symbol, size and prologue. The last image stays visible during loading; the normal renderer and menu layers continue. Headset test with the depth-size fix: gameplay, both eyes, controller input and sound work |
|
||||
@@ -72,6 +73,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| The Climb 666.0: audio with no headset picture, `OVRPortVrApi: Unsupported frame flags or swap interval`; then SIGSEGV in `vulkan.freedreno.so` during `vkCmdEndRenderPass` once frames are accepted (GitHub #238) | The game submits the obsolete Quest texture-density debug flag (`0x100`), which the bridge rejected. It also binds the bridge's full-rate texture as a fragment-density attachment even though foveation support is reported off; Turnip reads density maps on the CPU, but an ordinary sampled image has no mapped backing memory | Updated `frame.vrapi_bridge` accepts the compositor debug/volume flags and creates the compatibility texture with `VK_IMAGE_USAGE_FRAGMENT_DENSITY_MAP_BIT_EXT`. Do not gate that usage on the advertised format features (the Frame reports R8G8_UNORM without the density bit). `device.files` selects `r_vr_foveated = 0` when the engine contains that cvar; The Climb 2 keeps `r_variable_rate_shading = 0`. Triage `vrapi-frame-rejected` marks rejected frames as fatal despite pacing logs. Picture confirmed on the headset. With the corrected config, an uncapped launch stayed running for over four minutes at a median 72 fps with no native crash or GPU fault. The user confirmed working menus and gameplay on 2026-10-12 |
|
||||
| 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 |
|
||||
| Unreal game still stays on `DownloaderActivity` with CRC checking off (`bVerifyOBBOnStartUp=false`) and a fully uploaded matching OBB (GitHub #283, Into The Radius) | The separate Java `expansionFilesDelivered()` name and size gate can reject an OBB through Android external storage in Lepton; disabling CRC does not bypass it | Try `frame.unreal_skip_obb_delivery` (opt-in, experimental, triage-only). It returns true from known Unreal/package delivery predicates in every dex, preserving normal downloader success and main init. Analysis `unreal_obb_delivery` (version 16) controls applicability. The native engine still needs complete, matching, readable OBB data; unverified on a device |
|
||||
| `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) |
|
||||
@@ -146,6 +148,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Unreal game (ILMxLAB, for example Vader Immortal): controllers track but grip/trigger do nothing, grabbing never works (Unreal's own input values move, the game's stay 0) | `URPOCKeyMapManagerComponent` picks the Quest or the Gear VR key set by `IsRunningOnSantaCruz()`; on the Frame it takes Gear VR, empty in a Quest build | `frame.unreal_quest_keymap` (the Oculus case keeps the Quest set, for action and axis mappings; found by Klownicle, GitHub #49) |
|
||||
| The hands' thumbs never move when you touch the thumbstick or buttons (Unreal `ThumbUp` stays 1, for example Vader Immortal) | OVRPlugin reports thumb proximity from XR_FB_touch_controller_proximity, which the Frame's runtime lacks; OVRPort's loader offers it anyway and the proximity actions never get a binding | adapter `proximity_emul=1` (thumb proximity from the touch inputs: thumbstick, face buttons, thumb rest; 2 = also the index finger from trigger touch); log `proximity_emul: finger proximity bound to touch`. **For UE4 Oculus games this didn't animate the thumbs** (Vader, GitHub #49): use `frame.unreal_thumb_touch` (found by Klownicle): `OculusInput::FOculusInput::SendControllerEvents` computes ThumbUp from NearTouches (masks 0x2/0x8); the patch makes it read Touches (the slot 4 bytes before) with masks 0x0f00 (X/Y/stick/thumb rest) / 0x000f (A/B/stick/thumb rest), keeping the inversion; three instructions, matched exactly (build note `OculusInput ThumbUp: from the capacitive touches`). Matches Vader Immortal Ep. I, Robo Recall, Phantom: Covert Ops, Time Stall, Star Wars: Tales from the Galaxy's Edge; newer UE4 builds (for example Asgard's Wrath 2, RE4, In Death) compile it differently (shared mask registers) and aren't matched |
|
||||
| Vulkan game (Unreal / own engine with the Vulkan shim): the GPU hangs at one effect or cutscene (kernel `hangcheck detected gpu lockup`, freeze/exit; for example a VR4 cutscene, GitHub #140) | a SPIR-V shader reads a loop counter/accumulator before setting it (VR4's campaign shader, GitHub #10) | adapter `vk_shader_dump=1` (`frame.vk_sanitize`'s shim; log `vk shim: dumping SPIR-V modules to …`) writes every distinct module once to `files/fp_vk_shaders/<size>_<sha256>.spv` + `index.txt` (`<seq> <ms> <unix ms> <name> new\|known\|again`); play to the hang, collect diagnostics (`target/shaders/fp_vk_shaders/`, newest 4 MB) and take the modules first created (`new`) shortly before the kernel's hangcheck time; disassemble (`spirv-dis`), find the variable read before its first store, write a `vk_shader_fix` (`<size>:<sha256>:<byte offset>:<words>` = inserted OpStores; log `vk shim: fixed shader module`); switch the dump off again |
|
||||
| OpenGL ES game: memory and GPU mappings grow every frame (~20 MB/s, `/dev/dri/renderD128` mappings ~6 per frame) until the frame rate collapses; the game's `xrclient_<package>.txt` (Steam logs) has one `SXR_GL_CHECK: error:0x502 ... glGenTextures` before the session starts (GitHub #285, Vader Immortal) | The game leaves GL errors pending on its context; the runtime's GL interop reads glGetError after its own calls and takes the stale error as its own failure. From then on `CVRCompositorSharedTextures::BeginCommandBuffer` allocates a new command buffer every frame instead of reusing one (perf page-fault stacks) | Adapter `gl_error_clear=1` (GLES only) clears pending GL errors before xrCreateSwapchain/xrBeginFrame/xrEndFrame; Vader's catalog recipes set it. Verified headless 2026-10-14: 191 GPU mappings and ~1.07 GB flat over 100 s (before: +430 mappings/s, +22 MB/s), no SXR_GL_CHECK |
|
||||
| OpenGL ES game: the GPU hangs at one effect (freeze/exit, compositor watchdog, for example Vader Immortal's lightspeed jump) | a shader (as Zink compiles it) reads loop counters/accumulators before setting them | capture the SPIR-V (adapter `zink_shader_dump=1` writes every module to `files/fp_spirv/`, or a GPU capture), then a `zink_shader_fix` entry (same format as `vk_shader_fix`) + `frame.zink_shader_fix` (Vulkan layer under Zink, activated through GraphicsEnv's debug layer list); log `shader fix layer: fixed shader module N`; triage `zink-shader-fix-mismatch` when a Frame update changed the driver's output |
|
||||
| 2D Android app: Steam says it's running but nothing shows | Lepton runs apps headless unless the app folder has `lepton-show-flatscreen` | FramePort adds it for apps without VR (agent v28+); reinstall apps installed before |
|
||||
| A phone/tablet app shows nothing in the headset, or a VR app opens as a flat window | FramePort's VR/2D guess (`vr_kind`) is wrong for this app | patch `device.display_mode` → **Flat window** or **VR** (Customize → "Show as VR or as a flat window"), then Update on Frame |
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Transfer measurements and incremental installs
|
||||
|
||||
## Production OpenSSH follow-up
|
||||
|
||||
OpenSSH is now the preferred installer backend for files >=8 MiB. The new default is three workers (range 1–8),
|
||||
with saved preferences preserved. Fresh files use `put`; existing partial files use `reput`. Each worker keeps
|
||||
one SFTP batch session. Progress is polled through a separate bounded SFTP channel. Native failures resume via
|
||||
Paramiko; cancellation stops the owned SFTP/SSH process trees and does not trigger fallback.
|
||||
|
||||
Windows matched production comparison: eight 64 MiB files, 512 MiB total, three shuffled repetitions per backend,
|
||||
three workers, same home Wi-Fi/internal storage. OpenSSH median **15.51 MB/s**, Paramiko median **11.08 MB/s**
|
||||
(about 40% faster). All six checksums passed. OpenSSH trials ranged 9.5–16.8 MB/s, so link variability still
|
||||
matters; it was slower than Paramiko in one pass. Native credential/progress setup took 0.12–0.13 seconds.
|
||||
|
||||
The separate Windows 128 MiB production test also passed every checksum. Native cancellation left a partial file
|
||||
of 74,752,000 bytes; resuming with OpenSSH produced the expected SHA-256. The log confirmed no fallback for the
|
||||
resumed production upload. Reports:
|
||||
`/mnt/c/Users/Austin/AppData/Local/Temp/frameport-upload-native-production-comparison-windows.json` and
|
||||
`/mnt/c/Users/Austin/AppData/Local/Temp/frameport-upload-native-production-windows.json`.
|
||||
|
||||
The earlier sections below preserve measurements made before the production backend switch.
|
||||
|
||||
The WSL production validation (128 MiB, three workers, three repetitions) measured 14.1 / 19.1 / 15.0 MB/s,
|
||||
with every checksum passing and native backend logs recorded. Production cancellation left a 72,330,240-byte
|
||||
partial file; OpenSSH resumed it in 4.23 seconds with the expected SHA-256 and no Paramiko fallback. Report:
|
||||
`/tmp/frameport-upload-native-production-wsl.json`.
|
||||
|
||||
## Measurements (2026-10-14)
|
||||
|
||||
Both runs used the same paired Frame, the home-router connection and internal ext4 storage under the paired user's
|
||||
home. Each large-file trial sent the same 128 MiB random payload; each small-file trial sent 512 files of 64 KiB
|
||||
(32 MiB total). Methods were shuffled for each of three repetitions. Windows and WSL runs were sequential, not
|
||||
concurrent. Every completed transfer passed SHA-256 verification. Rates below include connection/setup and transfer
|
||||
completion, but exclude checksum verification and cleanup; MB/s uses decimal bytes.
|
||||
|
||||
| Method | WSL2 median MB/s | Windows 11 native median MB/s |
|
||||
|---|---:|---:|
|
||||
| FramePort pipelined SFTP | 10.4 | 6.1 |
|
||||
| Paramiko SFTP, 64 MiB window | 17.1 | 4.5 |
|
||||
| OpenSSH SFTP | 16.8 | 15.4 |
|
||||
| OpenSSH SCP (default protocol) | 11.1 | 10.1 |
|
||||
| OpenSSH `ssh` + streamed file | 9.9 | 8.1 |
|
||||
| FramePort tar stream | 14.9 | 6.8 |
|
||||
| OpenSSH tar stream | 15.0 | 7.2 |
|
||||
|
||||
The network varied considerably: WSL's application trials ranged from 9.6–12.8 MB/s, native SFTP from
|
||||
16.2–17.3 MB/s, and some other methods dropped below 5 MB/s. Windows application trials ranged from 5.6–6.4 MB/s;
|
||||
native SFTP ranged from 9.1–16.5 MB/s. These are useful end-to-end comparisons on this setup, not a measurement of
|
||||
the maximum throughput each transport can achieve. Window tuning did not help the Windows run.
|
||||
|
||||
Cancellation/resume checks interrupted the application upload at 64 MiB, then independently resumed that partial
|
||||
file with the application and with OpenSSH SFTP `reput`. All four resumed files passed SHA-256 verification. This
|
||||
checks application cancellation and both resume paths; native SFTP cancellation itself remains unmeasured.
|
||||
|
||||
The Frame's USB and hotspot interfaces were up, but neither direct endpoint was reachable from the PC. No removable
|
||||
storage was mounted. USB, hotspot and microSD performance remain **unmeasured** in this round. The Frame's router
|
||||
connection used 5 GHz / 80 MHz Wi-Fi, with roughly -56 dBm signal during a read-only probe. This does not reproduce
|
||||
the 6 GHz direct-hotspot setup in [discussion #242](https://github.com/spoopyghosty0/frameport/discussions/242), where
|
||||
the reporter measured about 114 MB/s with OpenSSH versus 23 MB/s with Paramiko.
|
||||
|
||||
**Recommendation:** prototype an optional OpenSSH SFTP backend for large files, retaining `.part`/`reput`,
|
||||
cancellation, retries, strict paired-host verification and Paramiko fallback. Validate it on a fast direct link
|
||||
before making it the default. On this Windows run SFTP was about 2.5 times faster than the current application,
|
||||
and it beat SCP. The small-file tar measurements do not justify selecting a new tar transport yet. Production
|
||||
transport selection remains Paramiko pending that decision. The bounded parallel-file setting described below
|
||||
uses independent Paramiko connections.
|
||||
|
||||
## Parallel-file uploads
|
||||
|
||||
### Matched native OpenSSH comparison
|
||||
|
||||
The later comparison tested both backends sequentially on Windows and WSL, with three shuffled repetitions of
|
||||
the same eight 16 MiB files per worker count. Native `sftp-files-N` balances files across N persistent OpenSSH
|
||||
SFTP batch processes. Setup and completion are included; checksum verification is excluded. All 60 transfers
|
||||
passed SHA-256 checks. This is benchmark code; the production uploader still uses Paramiko.
|
||||
|
||||
| Workers | Windows Paramiko MB/s | Windows OpenSSH MB/s | WSL Paramiko MB/s | WSL OpenSSH MB/s |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 1 | 6.22 | 14.96 | 6.17 | 14.64 |
|
||||
| 2 | 10.61 | 17.73 | 14.41 | 14.45 |
|
||||
| 3 | 8.90 | 18.89 | 13.65 | 17.75 |
|
||||
| 4 | 14.90 | 16.80 | 15.67 | 16.63 |
|
||||
| 8 | 13.50 | 20.16 | 17.03 | 16.82 |
|
||||
|
||||
Three is a reasonable default candidate for a future OpenSSH backend: versus two it improved median throughput
|
||||
by 6.5% on Windows and 22.8% on WSL. Eight was faster on Windows but slower on WSL. The production Paramiko
|
||||
default remains two because three regressed by 16.1% on Windows and 5.3% on WSL in this matched comparison.
|
||||
Link variability remains substantial, and no fast hotspot/USB result is claimed.
|
||||
|
||||
Reports: `/mnt/c/Users/Austin/AppData/Local/Temp/frameport-upload-native-parallel-windows.json` and
|
||||
`/tmp/frameport-upload-native-parallel-wsl.json`.
|
||||
|
||||
Settings → Installing → **Parallel uploads** accepts **1–8**, default **2**. It controls large files within one
|
||||
install; the FIFO still runs one install job at a time. A single APK is not split into chunks. Large files are
|
||||
scheduled largest first, with at most one authenticated SSH connection per active worker and never more workers
|
||||
than files. Small files still use tar batches after the large-file uploads. Choose 1 on a busy network; a higher
|
||||
count can consume all available bandwidth and is not automatically faster.
|
||||
|
||||
The parallel comparison uses the same eight 16 MiB OBB-shaped files (128 MiB total) for every worker count, with
|
||||
three shuffled repetitions. The production uploader is used by `app-files-N`, including its aggregate progress,
|
||||
resume baselines, cancellation checks and connection cleanup. A failed or cancelled cohort stops queued work and
|
||||
closes only its worker transports; completed files and interrupted `.part` files remain resumable.
|
||||
|
||||
| Workers | WSL2 production median MB/s | Windows 11 production median MB/s |
|
||||
|---|---:|---:|
|
||||
| 1 | 9.6 | 5.6 |
|
||||
| 2 (default) | 9.1 | 10.9 |
|
||||
| 3 | 6.5 | Not measured in the final production run |
|
||||
| 4 | 12.4 | 14.8 |
|
||||
| 8 | 11.2 | 17.3 |
|
||||
|
||||
On Windows, the production uploader improved from 5.6 to 10.9 MB/s with 2 workers (**1.95 times faster**), and
|
||||
17.3 MB/s with 8 (**3.08 times faster**). All twelve Windows production transfers passed SHA-256 verification.
|
||||
The preceding Windows prototype also tested 3 workers (12.2 MB/s median), before aggregate progress/cancellation
|
||||
were integrated; that is not included as a production measurement in the table.
|
||||
|
||||
WSL was noisy: four workers improved its median by about 29%, but 2 did not improve its median, and 3 was slower.
|
||||
There is no evidence that a particular worker count is universally fastest. The option is retained because it
|
||||
produced a repeatable benefit on Windows and higher counts improved the WSL median. Default 2 limits competing
|
||||
transfers while retaining a measured Windows benefit; users can measure higher counts or select 1 in their own
|
||||
environment. Production code, including cleanup and progress checks, was used for both final comparisons.
|
||||
|
||||
```sh
|
||||
uv run python scripts/upload_bench.py --mib 128 --repeat 3 --methods app-files-1,app-files-2,app-files-3,app-files-4,app-files-8 --json parallel.json
|
||||
```
|
||||
|
||||
`install.upload_threads` persists the setting. Malformed values use 2 and numeric values are clamped to 1–8.
|
||||
The serial setting reuses the existing control connection; parallel workers verify that the new transport reaches
|
||||
the same paired headset before sending any files.
|
||||
|
||||
## Reproducing the measurements
|
||||
|
||||
Run from the checkout using the Python environment for the platform being measured:
|
||||
|
||||
```sh
|
||||
uv run python scripts/upload_bench.py --mib 512 --repeat 3 --check-resume --json uploads.json
|
||||
uv run python scripts/upload_bench.py --fast-link --probe
|
||||
uv run python scripts/upload_bench.py --fast-link --remote-dir /path/on/card --mib 512 --repeat 3 --json card.json
|
||||
```
|
||||
|
||||
`--host` selects a specific paired endpoint; `--fast-link` uses the same authenticated direct-link discovery as an
|
||||
install. Inspect the reported endpoint and link before comparing methods. Join the Frame's hotspot or connect its
|
||||
USB cable before testing those paths; the benchmark does not change PC networking. Keep the headset awake and avoid
|
||||
other network traffic. A 512 MiB payload is preferred for follow-up tests on fast links; the available-link runs
|
||||
above used 128 MiB.
|
||||
|
||||
The script creates a unique scratch directory on the selected filesystem, checks content outside the timed upload,
|
||||
and removes its own temporary remote files and temporary key copy. It never modifies the pairing key's ACL. Windows
|
||||
OpenSSH rejects an `OWNER RIGHTS` ACL entry; the temporary copy grants only the current user, without Python's
|
||||
Windows `chmod` behaviour that can introduce that entry. Native clients use the application's key and known-hosts
|
||||
file with strict host verification.
|
||||
|
||||
JSON rows contain bytes, total seconds, separately measured setup seconds where available, payload and total rates,
|
||||
verification duration and checksums. SCP/SFTP's setup is included in total time because their command-line clients
|
||||
do not expose its completion separately. On POSIX, CPU includes the Python parent and child processes; on Windows,
|
||||
`os.times()` measures only the parent, so native subprocess CPU numbers must not be compared with Paramiko's CPU.
|
||||
An error is recorded rather than counted as a successful trial. `--methods` can restrict the run; `--small-files`
|
||||
controls the tar corpus. Cipher comparisons are available as `ssh-gcm` and `ssh-chacha`.
|
||||
|
||||
## Incremental install behaviour
|
||||
|
||||
`game_update.plan_update` is the shared, I/O-free Quest/Android classifier used by badges and installation. Recipe
|
||||
source, notes and catalog revision do not affect it. A newer tested catalog recipe remains a separate suggestion
|
||||
when the user customized their recipe.
|
||||
|
||||
- Build records keep an APK recipe fingerprint, source hash/stat, selected native-asset identities, converter
|
||||
identity and the existing validated APK hashes. Reusable local APKs are checksum-checked before installation.
|
||||
- Deployment records keep applied runtime configuration and its fingerprint, APK stat/hash, source version,
|
||||
data manifest/digest and artwork hashes. Routine device samples carry only the digest, and updates fetch a
|
||||
detailed manifest only when necessary. Live numeric settings writes update only the applied runtime configuration,
|
||||
leaving APK build identity intact. Desired data manifests are cached locally for badges, without render-path I/O.
|
||||
- Runtime-only updates use agent v87 `configure`: no conversion, signing, APK transfer, artwork, Steam restart or
|
||||
automatic launch test. They still require the installed APK identity and an available destination, and refuse
|
||||
changes while the game is running. Defaults explicitly reset overrides embedded in an older APK. Generated
|
||||
configuration files are restored or removed only when they still match FramePort's last write; user edits survive.
|
||||
- Build support dependencies remain conservative: controller models/native surfaces/haptic support, Zink-layer
|
||||
selection, mixed foveation patches and revised adapter support can require a new APK. A recorded alternate
|
||||
variant can be reused when switching variants.
|
||||
- Data-only updates transfer missing/different-size data using the resumable uploader. Recipe/APK updates skip
|
||||
data synchronization when the source data manifest is unchanged, preserving Frame-side mods; explicit forced
|
||||
reinstalls still check/repair data. Artwork-only
|
||||
updates send changed slots; unchanged artwork avoids a Steam refresh. APK/data changes retain the configured
|
||||
automatic launch test. Settings/artwork changes skip it.
|
||||
- APK-only updates preserve installed OBBs, including mods, and do not apply old staged OBBs or artwork from an
|
||||
interrupted upload. Ordinary data comparison still uses sizes, matching existing behaviour; equal-size content
|
||||
replacement is not detected automatically. Explicit rebuild/reinstall remains available (`install --force`).
|
||||
- Legacy build identities take the full path once because their original build inputs cannot be established
|
||||
safely. Converted-copy cleanup and `build.keep_copies` remain in effect; a configuration/data update does not
|
||||
require the deleted converted APK.
|
||||
|
||||
Install results record `stage_seconds` in the library's install history. This includes planning, conversion,
|
||||
validation, transfer and finalization stages reached by that operation; the GUI's subsequent optional launch test
|
||||
is separate. Use these timings to distinguish build cost, network transfer and device/Steam work when reviewing a
|
||||
slow install. Configuration success is recorded after writing the configuration and completing its sync; a failed
|
||||
operation leaves the prior applied fingerprint so it remains retryable.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 215 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 103 KiB |
+218
-89
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Included by frame_adapter.c. Emulates XR_FB_composition_layer_image_layout's VERTICAL_FLIP for quad
|
||||
// layers of Vulkan apps when the runtime lacks the extension (Frame): the app's image is blitted
|
||||
// upside-down into a runtime-owned shadow swapchain, which is submitted instead.
|
||||
// layers of Vulkan apps when the runtime lacks the extension (Frame): the quad's rectangle is turned upside down in
|
||||
// the app's own image (through a scratch image of ours), once per acquired image. An extra runtime swapchain per
|
||||
// flipped quad (the first version) ran into the Frame's swapchain limit: AC Nexus has more UI panels than it allows,
|
||||
// so some stayed upside down and the game's own later panels failed with XR_ERROR_LIMIT_REACHED (GitHub #138).
|
||||
#define VK_NO_PROTOTYPES
|
||||
#include <vulkan/vulkan.h>
|
||||
#define XR_USE_GRAPHICS_API_VULKAN
|
||||
@@ -40,6 +42,25 @@ static struct {
|
||||
PFN_vkCreateFence CreateFence;
|
||||
PFN_vkWaitForFences WaitForFences;
|
||||
PFN_vkResetFences ResetFences;
|
||||
PFN_vkCreateImage CreateImage;
|
||||
PFN_vkDestroyImage DestroyImage;
|
||||
PFN_vkGetImageMemoryRequirements GetImageMemoryRequirements;
|
||||
PFN_vkAllocateMemory AllocateMemory;
|
||||
PFN_vkFreeMemory FreeMemory;
|
||||
PFN_vkBindImageMemory BindImageMemory;
|
||||
PFN_vkCreateBuffer CreateBuffer; // flip_dump
|
||||
PFN_vkDestroyBuffer DestroyBuffer;
|
||||
PFN_vkGetBufferMemoryRequirements GetBufferMemoryRequirements;
|
||||
PFN_vkBindBufferMemory BindBufferMemory;
|
||||
PFN_vkMapMemory MapMemory;
|
||||
PFN_vkUnmapMemory UnmapMemory;
|
||||
PFN_vkCmdCopyImageToBuffer CmdCopyImageToBuffer;
|
||||
VkPhysicalDeviceMemoryProperties memory;
|
||||
// scratch image for in-place flips: grown to the largest rectangle, one format at a time
|
||||
VkImage scratch;
|
||||
VkDeviceMemory scratch_memory;
|
||||
VkFormat scratch_format;
|
||||
uint32_t scratch_w, scratch_h;
|
||||
} vk;
|
||||
|
||||
typedef struct {
|
||||
@@ -49,10 +70,12 @@ typedef struct {
|
||||
uint32_t image_count;
|
||||
uint32_t last_acquired;
|
||||
uint32_t acquired[MAX_IMAGES], acquired_count, waited_count;
|
||||
XrSwapchain shadow;
|
||||
XrRect2Di shadow_rect;
|
||||
VkImage shadow_images[MAX_IMAGES];
|
||||
uint32_t shadow_count;
|
||||
// rectangles already turned upside down in each image since the app last acquired it (static quads are
|
||||
// submitted again without a new acquire: flipping them again would undo it)
|
||||
struct { XrRect2Di rect; uint32_t layer; } flipped[MAX_IMAGES][4];
|
||||
uint32_t flipped_count[MAX_IMAGES];
|
||||
int dumped; // flip_dump saved this swapchain's quad
|
||||
int logged; // its first in-place flip was logged
|
||||
} tracked_swapchain;
|
||||
|
||||
static pthread_mutex_t flip_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
@@ -81,6 +104,9 @@ static void flip_on_create_session(const XrSessionCreateInfo *info) {
|
||||
vk.family = b->queueFamilyIndex;
|
||||
vk.index = b->queueIndex;
|
||||
vk.state = 0;
|
||||
vk.scratch = VK_NULL_HANDLE; // belonged to the previous session's device
|
||||
vk.scratch_memory = VK_NULL_HANDLE;
|
||||
vk.scratch_w = vk.scratch_h = 0;
|
||||
LOG("flip: Vulkan session (queue family %u index %u)", vk.family, vk.index);
|
||||
}
|
||||
}
|
||||
@@ -109,22 +135,26 @@ static void flip_on_acquire(XrSwapchain handle, uint32_t index) {
|
||||
tracked_swapchain *t = find_tracked(handle, 0);
|
||||
if (t) {
|
||||
t->last_acquired = index;
|
||||
if (index < MAX_IMAGES) t->flipped_count[index] = 0; // new content: upside down again
|
||||
if(t->acquired_count<MAX_IMAGES)t->acquired[t->acquired_count++]=index;
|
||||
}
|
||||
pthread_mutex_unlock(&flip_lock);
|
||||
}
|
||||
|
||||
static void flip_on_destroy(XrSwapchain handle) {
|
||||
PFN_xrDestroySwapchain destroy = (PFN_xrDestroySwapchain)lookup(active_instance, "xrDestroySwapchain");
|
||||
pthread_mutex_lock(&flip_lock);
|
||||
tracked_swapchain *t = find_tracked(handle, 0);
|
||||
if (t) {
|
||||
if (t->shadow && destroy) destroy(t->shadow);
|
||||
memset(t, 0, sizeof(*t));
|
||||
}
|
||||
if (t) memset(t, 0, sizeof(*t));
|
||||
pthread_mutex_unlock(&flip_lock);
|
||||
}
|
||||
|
||||
// xrCreateSwapchain: single-image colour swapchains of a Vulkan session (quad candidates) also get transfer usage,
|
||||
// so the in-place flip may blit from and back into them.
|
||||
static int flip_wants_transfer(const XrSwapchainCreateInfo *info) {
|
||||
return flip_emul && vk.device && !runtime_has_image_layout && info->arraySize == 1 && info->faceCount == 1 &&
|
||||
info->sampleCount == 1 && (info->usageFlags & XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT);
|
||||
}
|
||||
|
||||
static int vk_ready(void) {
|
||||
if (vk.state) return vk.state > 0;
|
||||
vk.state = -1;
|
||||
@@ -137,8 +167,15 @@ static int vk_ready(void) {
|
||||
#define DEV(name) vk.name = (PFN_vk##name)vk.GetDeviceProcAddr(vk.device, "vk" #name); if (!vk.name) return 0;
|
||||
DEV(GetDeviceQueue) DEV(CreateCommandPool) DEV(AllocateCommandBuffers) DEV(BeginCommandBuffer)
|
||||
DEV(EndCommandBuffer) DEV(ResetCommandBuffer) DEV(CmdPipelineBarrier) DEV(CmdBlitImage) DEV(CmdClearColorImage) DEV(QueueSubmit)
|
||||
DEV(CreateFence) DEV(WaitForFences) DEV(ResetFences)
|
||||
DEV(CreateFence) DEV(WaitForFences) DEV(ResetFences) DEV(CreateImage) DEV(DestroyImage)
|
||||
DEV(GetImageMemoryRequirements) DEV(AllocateMemory) DEV(FreeMemory) DEV(BindImageMemory)
|
||||
DEV(CreateBuffer) DEV(DestroyBuffer) DEV(GetBufferMemoryRequirements) DEV(BindBufferMemory) DEV(MapMemory)
|
||||
DEV(UnmapMemory) DEV(CmdCopyImageToBuffer)
|
||||
#undef DEV
|
||||
PFN_vkGetPhysicalDeviceMemoryProperties memory_properties =
|
||||
(PFN_vkGetPhysicalDeviceMemoryProperties)gipa(vk.instance, "vkGetPhysicalDeviceMemoryProperties");
|
||||
if (!memory_properties) return 0;
|
||||
memory_properties(vk.physical, &vk.memory);
|
||||
vk.GetDeviceQueue(vk.device, vk.family, vk.index, &vk.queue);
|
||||
VkCommandPoolCreateInfo pool = {VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, NULL,
|
||||
VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, vk.family};
|
||||
@@ -169,103 +206,195 @@ static int wants_vertical_flip(const XrCompositionLayerQuad *q) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Returns a replacement quad referencing a flipped copy, or NULL to keep the original.
|
||||
static const XrCompositionLayerQuad *flip_quad(XrSession session, const XrCompositionLayerQuad *q,
|
||||
XrCompositionLayerQuad *out) {
|
||||
if (!flip_emul || runtime_has_image_layout || !wants_vertical_flip(q)) return NULL;
|
||||
if (!vk_ready()) { static int warned; if (!warned++) LOG("flip: needed but Vulkan blitter unavailable (GLES app?)"); return NULL; }
|
||||
PFN_xrCreateSwapchain create = (PFN_xrCreateSwapchain)lookup(active_instance, "xrCreateSwapchain");
|
||||
PFN_xrEnumerateSwapchainImages enumerate =
|
||||
(PFN_xrEnumerateSwapchainImages)lookup(active_instance, "xrEnumerateSwapchainImages");
|
||||
PFN_xrAcquireSwapchainImage acquire = (PFN_xrAcquireSwapchainImage)lookup(active_instance, "xrAcquireSwapchainImage");
|
||||
PFN_xrWaitSwapchainImage wait = (PFN_xrWaitSwapchainImage)lookup(active_instance, "xrWaitSwapchainImage");
|
||||
PFN_xrReleaseSwapchainImage release = (PFN_xrReleaseSwapchainImage)lookup(active_instance, "xrReleaseSwapchainImage");
|
||||
if (!create || !enumerate || !acquire || !wait || !release) return NULL;
|
||||
|
||||
pthread_mutex_lock(&flip_lock);
|
||||
tracked_swapchain *t = find_tracked(q->subImage.swapchain, 0);
|
||||
const XrCompositionLayerQuad *result = NULL;
|
||||
const XrRect2Di rect = q->subImage.imageRect;
|
||||
if (!t || !t->image_count || t->last_acquired >= t->image_count || rect.extent.width <= 0 || rect.extent.height <= 0) {
|
||||
static int skipped;
|
||||
if (skipped++ < 10)
|
||||
LOG("flip: skipped quad swapchain=%p tracked=%d images=%u last=%u rect=%dx%d", (void *)(uintptr_t)q->subImage.swapchain,
|
||||
t != NULL, t ? t->image_count : 0, t ? t->last_acquired : 0, rect.extent.width, rect.extent.height);
|
||||
goto done;
|
||||
// A scratch image at least w x h in this format (device-local, transfer source + destination).
|
||||
static int scratch_fit(VkFormat format, uint32_t w, uint32_t h) {
|
||||
if (vk.scratch && vk.scratch_format == format && vk.scratch_w >= w && vk.scratch_h >= h) return 1;
|
||||
if (vk.scratch) {
|
||||
vk.DestroyImage(vk.device, vk.scratch, NULL);
|
||||
vk.FreeMemory(vk.device, vk.scratch_memory, NULL);
|
||||
vk.scratch = VK_NULL_HANDLE;
|
||||
vk.scratch_memory = VK_NULL_HANDLE;
|
||||
}
|
||||
if (t->shadow && (t->shadow_rect.extent.width != rect.extent.width || t->shadow_rect.extent.height != rect.extent.height)) {
|
||||
// The app changed the sub-rectangle it shows: rebuild the shadow at the new size.
|
||||
PFN_xrDestroySwapchain destroy = (PFN_xrDestroySwapchain)lookup(active_instance, "xrDestroySwapchain");
|
||||
forget_swapchain(t->shadow);
|
||||
if (destroy) destroy(t->shadow);
|
||||
t->shadow = XR_NULL_HANDLE;
|
||||
if (vk.scratch_format == format) { // grow, don't shrink: panels of one game come in several sizes
|
||||
if (vk.scratch_w > w) w = vk.scratch_w;
|
||||
if (vk.scratch_h > h) h = vk.scratch_h;
|
||||
}
|
||||
|
||||
if (!t->shadow) {
|
||||
XrSwapchainCreateInfo ci = t->info;
|
||||
ci.createFlags = 0;
|
||||
ci.usageFlags |= XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT | XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT;
|
||||
ci.width = (uint32_t)rect.extent.width;
|
||||
ci.height = (uint32_t)rect.extent.height;
|
||||
ci.arraySize = 1;
|
||||
ci.mipCount = 1;
|
||||
ci.faceCount = 1;
|
||||
if (XR_FAILED(create(session, &ci, &t->shadow))) { t->shadow = XR_NULL_HANDLE; goto done; }
|
||||
remember_swapchain(t->shadow);
|
||||
XrSwapchainImageVulkanKHR images[MAX_IMAGES];
|
||||
for (int i = 0; i < MAX_IMAGES; ++i) images[i] = (XrSwapchainImageVulkanKHR){XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR, NULL, VK_NULL_HANDLE};
|
||||
uint32_t n = 0;
|
||||
if (XR_FAILED(enumerate(t->shadow, MAX_IMAGES, &n, (XrSwapchainImageBaseHeader *)images))) goto done;
|
||||
t->shadow_count = n;
|
||||
for (uint32_t i = 0; i < n; ++i) t->shadow_images[i] = images[i].image;
|
||||
t->shadow_rect = rect;
|
||||
LOG("flip: created %dx%d shadow swapchain for upside-down quad", rect.extent.width, rect.extent.height);
|
||||
VkImageCreateInfo ci = {VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, NULL, 0, VK_IMAGE_TYPE_2D, format, {w, h, 1}, 1, 1,
|
||||
VK_SAMPLE_COUNT_1_BIT, VK_IMAGE_TILING_OPTIMAL,
|
||||
VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT,
|
||||
VK_SHARING_MODE_EXCLUSIVE, 0, NULL, VK_IMAGE_LAYOUT_UNDEFINED};
|
||||
if (vk.CreateImage(vk.device, &ci, NULL, &vk.scratch) != VK_SUCCESS) { vk.scratch = VK_NULL_HANDLE; return 0; }
|
||||
VkMemoryRequirements req;
|
||||
vk.GetImageMemoryRequirements(vk.device, vk.scratch, &req);
|
||||
uint32_t type = UINT32_MAX;
|
||||
for (uint32_t i = 0; i < vk.memory.memoryTypeCount && type == UINT32_MAX; ++i)
|
||||
if ((req.memoryTypeBits & (1u << i)) &&
|
||||
(vk.memory.memoryTypes[i].propertyFlags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT)) type = i;
|
||||
for (uint32_t i = 0; i < vk.memory.memoryTypeCount && type == UINT32_MAX; ++i)
|
||||
if (req.memoryTypeBits & (1u << i)) type = i;
|
||||
VkMemoryAllocateInfo ai = {VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, NULL, req.size, type};
|
||||
if (type == UINT32_MAX || vk.AllocateMemory(vk.device, &ai, NULL, &vk.scratch_memory) != VK_SUCCESS ||
|
||||
vk.BindImageMemory(vk.device, vk.scratch, vk.scratch_memory, 0) != VK_SUCCESS) {
|
||||
if (vk.scratch_memory) vk.FreeMemory(vk.device, vk.scratch_memory, NULL);
|
||||
vk.DestroyImage(vk.device, vk.scratch, NULL);
|
||||
vk.scratch = VK_NULL_HANDLE;
|
||||
vk.scratch_memory = VK_NULL_HANDLE;
|
||||
return 0;
|
||||
}
|
||||
vk.scratch_format = format;
|
||||
vk.scratch_w = w;
|
||||
vk.scratch_h = h;
|
||||
LOG("flip: scratch image %ux%u format %d", w, h, (int)format);
|
||||
return 1;
|
||||
}
|
||||
|
||||
uint32_t index = 0;
|
||||
XrSwapchainImageAcquireInfo ai = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO, NULL};
|
||||
XrSwapchainImageWaitInfo wi = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO, NULL, 100000000};
|
||||
XrSwapchainImageReleaseInfo ri = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO, NULL};
|
||||
if (XR_FAILED(acquire(t->shadow, &ai, &index)) || index >= t->shadow_count) goto done;
|
||||
if (XR_FAILED(wait(t->shadow, &wi))) { release(t->shadow, &ri); goto done; }
|
||||
|
||||
VkImage src = t->images[t->last_acquired], dst = t->shadow_images[index];
|
||||
uint32_t layer = q->subImage.imageArrayIndex;
|
||||
// flip_dump=N (diagnostics): the quad's rectangle as the app drew it ("raw") and as submitted after the flip
|
||||
// ("upright"), saved as files/fb_flip_<n>_<raw|upright>.ppm (8-bit RGBA formats only).
|
||||
static int dump_rect(VkImage app, uint32_t layer, XrRect2Di rect, int64_t format, int n, const char *what) {
|
||||
int written = 0;
|
||||
if (!*snap_dir || (format != VK_FORMAT_R8G8B8A8_SRGB && format != VK_FORMAT_R8G8B8A8_UNORM &&
|
||||
format != VK_FORMAT_B8G8R8A8_SRGB && format != VK_FORMAT_B8G8R8A8_UNORM)) return 0;
|
||||
uint32_t w = (uint32_t)rect.extent.width, h = (uint32_t)rect.extent.height;
|
||||
VkDeviceSize size = (VkDeviceSize)w * h * 4;
|
||||
VkBufferCreateInfo bi = {VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, NULL, 0, size, VK_BUFFER_USAGE_TRANSFER_DST_BIT,
|
||||
VK_SHARING_MODE_EXCLUSIVE, 0, NULL};
|
||||
VkBuffer buf = VK_NULL_HANDLE;
|
||||
VkDeviceMemory mem = VK_NULL_HANDLE;
|
||||
if (vk.CreateBuffer(vk.device, &bi, NULL, &buf) != VK_SUCCESS) return 0;
|
||||
VkMemoryRequirements req;
|
||||
vk.GetBufferMemoryRequirements(vk.device, buf, &req);
|
||||
uint32_t type = UINT32_MAX;
|
||||
for (uint32_t i = 0; i < vk.memory.memoryTypeCount && type == UINT32_MAX; ++i)
|
||||
if ((req.memoryTypeBits & (1u << i)) && (vk.memory.memoryTypes[i].propertyFlags &
|
||||
(VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) ==
|
||||
(VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) type = i;
|
||||
VkMemoryAllocateInfo ai = {VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, NULL, req.size, type};
|
||||
if (type == UINT32_MAX || vk.AllocateMemory(vk.device, &ai, NULL, &mem) != VK_SUCCESS ||
|
||||
vk.BindBufferMemory(vk.device, buf, mem, 0) != VK_SUCCESS) goto out;
|
||||
VkCommandBufferBeginInfo begin = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, NULL,
|
||||
VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, NULL};
|
||||
vk.ResetCommandBuffer(vk.cmd, 0);
|
||||
vk.BeginCommandBuffer(vk.cmd, &begin);
|
||||
barrier(src, layer, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
|
||||
barrier(app, layer, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
|
||||
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT);
|
||||
VkBufferImageCopy region = {0, 0, 0, {VK_IMAGE_ASPECT_COLOR_BIT, 0, layer, 1},
|
||||
{rect.offset.x, rect.offset.y, 0}, {w, h, 1}};
|
||||
vk.CmdCopyImageToBuffer(vk.cmd, app, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, buf, 1, ®ion);
|
||||
barrier(app, layer, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
|
||||
VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT);
|
||||
vk.EndCommandBuffer(vk.cmd);
|
||||
VkSubmitInfo submit = {VK_STRUCTURE_TYPE_SUBMIT_INFO, NULL, 0, NULL, NULL, 1, &vk.cmd, 0, NULL};
|
||||
int ok = vk.QueueSubmit(vk.queue, 1, &submit, vk.fence) == VK_SUCCESS &&
|
||||
vk.WaitForFences(vk.device, 1, &vk.fence, VK_TRUE, 1000000000ull) == VK_SUCCESS;
|
||||
vk.ResetFences(vk.device, 1, &vk.fence);
|
||||
void *mapped = NULL;
|
||||
if (!ok || vk.MapMemory(vk.device, mem, 0, size, 0, &mapped) != VK_SUCCESS) goto out;
|
||||
const unsigned char *bytes = mapped;
|
||||
VkDeviceSize nonzero = 0;
|
||||
for (VkDeviceSize i = 0; i < size && !nonzero; ++i) nonzero = bytes[i];
|
||||
char path[320];
|
||||
snprintf(path, sizeof(path), "%s/fb_flip_%d_%s.ppm", snap_dir, n, what);
|
||||
FILE *f = nonzero ? fopen(path, "wb") : NULL; // (an empty panel, e.g. before its first content: not counted)
|
||||
if (f) {
|
||||
int bgra = format == VK_FORMAT_B8G8R8A8_SRGB || format == VK_FORMAT_B8G8R8A8_UNORM;
|
||||
fprintf(f, "P6\n%u %u\n255\n", w, h);
|
||||
const unsigned char *px = mapped;
|
||||
for (VkDeviceSize i = 0; i < (VkDeviceSize)w * h; ++i) {
|
||||
unsigned char rgb[3] = {px[i * 4 + (bgra ? 2 : 0)], px[i * 4 + 1], px[i * 4 + (bgra ? 0 : 2)]};
|
||||
fwrite(rgb, 1, 3, f);
|
||||
}
|
||||
fclose(f);
|
||||
written = 1;
|
||||
LOG("flip_dump: wrote %s (%ux%u)", path, w, h);
|
||||
}
|
||||
vk.UnmapMemory(vk.device, mem);
|
||||
out:
|
||||
if (mem) vk.FreeMemory(vk.device, mem, NULL);
|
||||
vk.DestroyBuffer(vk.device, buf, NULL);
|
||||
return written;
|
||||
}
|
||||
|
||||
// Turns the quad's rectangle upside down in the app's own image, once per acquire of that image. Always returns
|
||||
// NULL: the original quad (same swapchain, same rectangle) is submitted and now shows upright.
|
||||
static const XrCompositionLayerQuad *flip_quad(XrSession session, const XrCompositionLayerQuad *q,
|
||||
XrCompositionLayerQuad *out) {
|
||||
(void)session; (void)out;
|
||||
if (!flip_emul || runtime_has_image_layout || !wants_vertical_flip(q)) return NULL;
|
||||
if (!vk_ready()) { static int warned; if (!warned++) LOG("flip: needed but Vulkan blitter unavailable (GLES app?)"); return NULL; }
|
||||
|
||||
pthread_mutex_lock(&flip_lock);
|
||||
tracked_swapchain *t = find_tracked(q->subImage.swapchain, 0);
|
||||
const XrRect2Di rect = q->subImage.imageRect;
|
||||
uint32_t layer = q->subImage.imageArrayIndex, index = t ? t->last_acquired : 0;
|
||||
if (!t || !t->image_count || index >= t->image_count || rect.extent.width <= 0 || rect.extent.height <= 0) {
|
||||
static int skipped;
|
||||
if (skipped++ < 10)
|
||||
LOG("flip: skipped quad swapchain=%p tracked=%d images=%u last=%u rect=%dx%d", (void *)(uintptr_t)q->subImage.swapchain,
|
||||
t != NULL, t ? t->image_count : 0, index, rect.extent.width, rect.extent.height);
|
||||
goto done;
|
||||
}
|
||||
for (uint32_t i = 0; i < t->flipped_count[index]; ++i)
|
||||
if (t->flipped[index][i].layer == layer && !memcmp(&t->flipped[index][i].rect, &rect, sizeof rect)) goto done;
|
||||
if (t->flipped_count[index] >= 4) goto done; // never seen: more than 4 quads on one image
|
||||
if (!(t->info.usageFlags & XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT)) { // created before the Vulkan session was known
|
||||
static int warned;
|
||||
if (warned++ < 5) LOG("flip: quad swapchain=%p has no transfer usage, left as it is", (void *)(uintptr_t)q->subImage.swapchain);
|
||||
goto done;
|
||||
}
|
||||
uint32_t w = (uint32_t)rect.extent.width, h = (uint32_t)rect.extent.height;
|
||||
if (!scratch_fit((VkFormat)t->info.format, w, h)) {
|
||||
static int failed;
|
||||
if (failed++ < 5) LOG("flip: no scratch image for %ux%u format %lld", w, h, (long long)t->info.format);
|
||||
goto done;
|
||||
}
|
||||
VkImage app = t->images[index];
|
||||
static int dumps; // one non-empty image per quad swapchain, at most flip_dump of them
|
||||
int dump = 0;
|
||||
if (dumps < flip_dump && !t->dumped && dump_rect(app, layer, rect, t->info.format, dumps + 1, "raw")) {
|
||||
dump = ++dumps;
|
||||
t->dumped = 1;
|
||||
}
|
||||
VkCommandBufferBeginInfo begin = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, NULL,
|
||||
VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, NULL};
|
||||
vk.ResetCommandBuffer(vk.cmd, 0);
|
||||
vk.BeginCommandBuffer(vk.cmd, &begin);
|
||||
barrier(app, layer, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
|
||||
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT);
|
||||
barrier(dst, 0, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, VK_ACCESS_TRANSFER_WRITE_BIT);
|
||||
VkImageBlit blit = {
|
||||
barrier(vk.scratch, 0, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, VK_ACCESS_TRANSFER_WRITE_BIT);
|
||||
VkImageBlit down = { // the rectangle into the scratch image, rows reversed
|
||||
{VK_IMAGE_ASPECT_COLOR_BIT, 0, layer, 1},
|
||||
{{rect.offset.x, rect.offset.y, 0}, {rect.offset.x + rect.extent.width, rect.offset.y + rect.extent.height, 1}},
|
||||
{VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1},
|
||||
{{0, rect.extent.height, 0}, {rect.extent.width, 0, 1}}, // rows reversed
|
||||
{{0, (int32_t)h, 0}, {(int32_t)w, 0, 1}},
|
||||
};
|
||||
vk.CmdBlitImage(vk.cmd, src, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1,
|
||||
&blit, VK_FILTER_NEAREST);
|
||||
barrier(src, layer, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
|
||||
VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT);
|
||||
barrier(dst, 0, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
|
||||
vk.CmdBlitImage(vk.cmd, app, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, vk.scratch, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
|
||||
1, &down, VK_FILTER_NEAREST);
|
||||
barrier(vk.scratch, 0, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
|
||||
VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT);
|
||||
barrier(app, layer, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
|
||||
VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT);
|
||||
VkImageBlit back = { // and back into the app's image as it is
|
||||
{VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1},
|
||||
{{0, 0, 0}, {(int32_t)w, (int32_t)h, 1}},
|
||||
{VK_IMAGE_ASPECT_COLOR_BIT, 0, layer, 1},
|
||||
{{rect.offset.x, rect.offset.y, 0}, {rect.offset.x + rect.extent.width, rect.offset.y + rect.extent.height, 1}},
|
||||
};
|
||||
vk.CmdBlitImage(vk.cmd, vk.scratch, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, app, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
|
||||
1, &back, VK_FILTER_NEAREST);
|
||||
barrier(app, layer, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
|
||||
VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT);
|
||||
vk.EndCommandBuffer(vk.cmd);
|
||||
VkSubmitInfo submit = {VK_STRUCTURE_TYPE_SUBMIT_INFO, NULL, 0, NULL, NULL, 1, &vk.cmd, 0, NULL};
|
||||
int ok = vk.QueueSubmit(vk.queue, 1, &submit, vk.fence) == VK_SUCCESS &&
|
||||
vk.WaitForFences(vk.device, 1, &vk.fence, VK_TRUE, 100000000ull) == VK_SUCCESS;
|
||||
vk.ResetFences(vk.device, 1, &vk.fence);
|
||||
release(t->shadow, &ri);
|
||||
if (!ok) goto done;
|
||||
|
||||
*out = *q;
|
||||
out->subImage.swapchain = t->shadow;
|
||||
out->subImage.imageArrayIndex = 0;
|
||||
out->subImage.imageRect = (XrRect2Di){{0, 0}, rect.extent};
|
||||
result = out;
|
||||
static int logged;
|
||||
if (!logged++) LOG("flip: presenting vertically flipped quad");
|
||||
if (dump) dump_rect(app, layer, rect, t->info.format, dump, "upright");
|
||||
t->flipped[index][t->flipped_count[index]].rect = rect;
|
||||
t->flipped[index][t->flipped_count[index]++].layer = layer;
|
||||
if (!t->logged++) // once per quad swapchain
|
||||
LOG("flip: turning quad swapchain=%p (%ux%u) upright in place", (void *)(uintptr_t)q->subImage.swapchain, w, h);
|
||||
done:
|
||||
pthread_mutex_unlock(&flip_lock);
|
||||
return result;
|
||||
return NULL;
|
||||
}
|
||||
@@ -104,6 +104,9 @@ static int fade_quad = 0;
|
||||
static uint32_t runtime_max_layers = 16; // XrSystemGraphicsProperties::maxLayerCount (the Frame: 16)
|
||||
static int snapshot = 0; // seconds between eye-image snapshots (snapshot_gl.c; 0 = off)
|
||||
static char snap_dir[256];
|
||||
static int gl_error_clear; // GLES: clear GL errors the app left pending before calling the runtime (Vader)
|
||||
static int gles_session;
|
||||
static int flip_dump; // diagnostics: save the first N flipped quads before/after the flip (flip_vk.c)
|
||||
static void snap_on_create(XrSwapchain handle, const XrSwapchainCreateInfo *info);
|
||||
static void snap_on_destroy(XrSwapchain handle);
|
||||
static void snap_on_enumerate(XrSwapchain handle, uint32_t count, const XrSwapchainImageBaseHeader *images);
|
||||
@@ -176,6 +179,8 @@ static void read_settings(const char *path) {
|
||||
if (sscanf(line, "fade_quad=%f", &value) == 1) fade_quad = value > 0 ? (int)value : 0;
|
||||
if (sscanf(line, "frame_balance=%f", &value) == 1) frame_balance = value != 0;
|
||||
if (sscanf(line, "snapshot=%f", &value) == 1) snapshot = value > 0 ? (int)value : 0;
|
||||
if (sscanf(line, "flip_dump=%f", &value) == 1) flip_dump = value > 0 ? (int)value : 0;
|
||||
if (sscanf(line, "gl_error_clear=%f", &value) == 1) gl_error_clear = value != 0;
|
||||
if (sscanf(line, "controller_fix=%f", &value) == 1) controller_fix = value != 0;
|
||||
if (sscanf(line, "swapchain_fix=%f", &value) == 1) swapchain_fix = value != 0;
|
||||
if (sscanf(line, "cube_standin=%f", &value) == 1) cube_standin = value != 0;
|
||||
@@ -256,7 +261,7 @@ static void initialize(void) {
|
||||
}
|
||||
const char *env = getenv("FRAMEBRIDGE_CONFIG");
|
||||
if (env && *env) read_settings(env);
|
||||
if (snapshot && *process && !strchr(process, '/')) {
|
||||
if ((snapshot || flip_dump) && *process && !strchr(process, '/')) {
|
||||
snprintf(snap_dir, sizeof(snap_dir), "/sdcard/Android/data/%s/files", process);
|
||||
LOG("per-game: snapshot every %d s to %s/fb_snap_N.ppm", snapshot, snap_dir);
|
||||
}
|
||||
@@ -554,6 +559,8 @@ static void forget_swapchain(XrSwapchain handle) {
|
||||
}
|
||||
|
||||
static void flip_on_create_swapchain(XrSwapchain handle, const XrSwapchainCreateInfo *info);
|
||||
static int flip_wants_transfer(const XrSwapchainCreateInfo *info);
|
||||
static void clear_gl_errors(const char *where);
|
||||
static void flip_on_destroy(XrSwapchain handle);
|
||||
static void emul_on_create_swapchain(XrSwapchain handle, const XrSwapchainCreateInfo *info);
|
||||
static void emul_on_destroy_swapchain(XrSwapchain handle);
|
||||
@@ -636,12 +643,16 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
|
||||
if(surface_native && fixed.arraySize==2 && fixed.sampleCount==1 &&
|
||||
(fixed.usageFlags&XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT))
|
||||
fixed.usageFlags |= XR_SWAPCHAIN_USAGE_TRANSFER_SRC_BIT;
|
||||
// in-place flips of upside-down quads (flip_vk.c) blit from and back into these images
|
||||
if (flip_wants_transfer(&fixed))
|
||||
fixed.usageFlags |= XR_SWAPCHAIN_USAGE_TRANSFER_SRC_BIT | XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT;
|
||||
if (equirect_emul && swapchain && emul_virtual_create(session, &fixed, swapchain)) {
|
||||
remember_swapchain(*swapchain);
|
||||
emul_on_create_swapchain(*swapchain, &fixed);
|
||||
snap_on_create(*swapchain, &fixed);
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
clear_gl_errors("xrCreateSwapchain");
|
||||
XrResult result = fn(session, &fixed, swapchain);
|
||||
LOG("xrCreateSwapchain %ux%u format=%lld samples=%u array=%u faces=%u usage=0x%llx flags=0x%llx result=%d",
|
||||
fixed.width, fixed.height, (long long)fixed.format, fixed.sampleCount, fixed.arraySize, fixed.faceCount,
|
||||
@@ -1096,6 +1107,35 @@ static int ask_graphics_requirements(XrInstance instance, const XrSessionCreateI
|
||||
return 0;
|
||||
}
|
||||
|
||||
// gl_error_clear: the runtime's GL interop checks glGetError after its own GL calls and fails on any error, also one
|
||||
// the app left pending on the same context. Vader Immortal leaves GL_INVALID_OPERATION pending before its first
|
||||
// frame; the runtime then logs "SXR_GL_CHECK: error:0x502 ... glGenTextures", and from that moment allocates a new
|
||||
// command buffer every frame (CVRCompositorSharedTextures::BeginCommandBuffer, ~20 MB/s, GitHub #285).
|
||||
static void clear_gl_errors(const char *where) {
|
||||
if (!gl_error_clear || !gles_session) return;
|
||||
static unsigned (*get_error)(void);
|
||||
static void *(*current_context)(void);
|
||||
static int resolved;
|
||||
if (!resolved) {
|
||||
resolved = 1;
|
||||
get_error = (unsigned (*)(void))dlsym(RTLD_DEFAULT, "glGetError");
|
||||
current_context = (void *(*)(void))dlsym(RTLD_DEFAULT, "eglGetCurrentContext");
|
||||
if (!get_error || !current_context) {
|
||||
void *gl = dlopen("libGLESv2.so", RTLD_NOW | RTLD_NOLOAD), *egl = dlopen("libEGL.so", RTLD_NOW | RTLD_NOLOAD);
|
||||
if (!get_error && gl) get_error = (unsigned (*)(void))dlsym(gl, "glGetError");
|
||||
if (!current_context && egl) current_context = (void *(*)(void))dlsym(egl, "eglGetCurrentContext");
|
||||
}
|
||||
LOG("gl_error_clear: %s", get_error && current_context ? "on" : "glGetError/eglGetCurrentContext not found");
|
||||
}
|
||||
if (!get_error || !current_context || !current_context()) return;
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
unsigned err = get_error();
|
||||
if (!err) break;
|
||||
static int logged;
|
||||
if (logged++ < 20) LOG("gl_error_clear: cleared the app's pending GL error 0x%x before %s", err, where);
|
||||
}
|
||||
}
|
||||
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrCreateSession(XrInstance instance, const XrSessionCreateInfo *info, XrSession *session) {
|
||||
PFN_xrCreateSession fn = (PFN_xrCreateSession)lookup(instance, "xrCreateSession");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
@@ -1103,6 +1143,9 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSession(XrInstance instance, const XrSess
|
||||
if (result == XR_ERROR_GRAPHICS_REQUIREMENTS_CALL_MISSING && ask_graphics_requirements(instance, info))
|
||||
result = fn(instance, info, session);
|
||||
if (XR_SUCCEEDED(result)) {
|
||||
gles_session = 0;
|
||||
for (const XrBaseInStructure *b = info ? (const XrBaseInStructure *)info->next : NULL; b; b = b->next)
|
||||
gles_session |= b->type == (XrStructureType)1000024001; // XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR
|
||||
foveation_eye_supported = 0;
|
||||
if (foveation_eye_enabled) {
|
||||
PFN_xrGetSystemProperties properties = (PFN_xrGetSystemProperties)lookup(instance, "xrGetSystemProperties");
|
||||
@@ -1496,6 +1539,7 @@ static XrTime last_begin_display_time;
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrBeginFrame(XrSession session, const XrFrameBeginInfo *info) {
|
||||
PFN_xrBeginFrame fn = (PFN_xrBeginFrame)lookup(active_instance, "xrBeginFrame");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
clear_gl_errors("xrBeginFrame");
|
||||
if (frame_open && frame_balance) {
|
||||
PFN_xrEndFrame end = (PFN_xrEndFrame)lookup(active_instance, "xrEndFrame");
|
||||
XrFrameEndInfo empty = {XR_TYPE_FRAME_END_INFO, NULL, last_begin_display_time ? last_begin_display_time
|
||||
@@ -1520,6 +1564,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
|
||||
PFN_xrEndFrame fn = (PFN_xrEndFrame)lookup(active_instance, "xrEndFrame");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
frame_open = 0;
|
||||
clear_gl_errors("xrEndFrame");
|
||||
__atomic_add_fetch(&frame_ends, 1, __ATOMIC_RELAXED);
|
||||
snap_end_frame(info);
|
||||
if(surface_native)++sc_serial;
|
||||
|
||||
@@ -300,6 +300,16 @@ static int assets_enabled(void) {
|
||||
return ((const volatile char *)g_assets_slot)[sizeof ASSETS_MARK - 1] == '1';
|
||||
}
|
||||
|
||||
// Triage-only bypass for games blocked on Meta's unavailable asset-file service. No file paths are invented.
|
||||
#define STUB_MARK "@FPASSETSTUB@"
|
||||
__attribute__((used)) static char g_stub_slot[sizeof STUB_MARK + 1] = STUB_MARK "0";
|
||||
|
||||
static int stub_enabled(void) {
|
||||
const char *env = getenv("FRAMEPORT_ASSET_FILES_STUB");
|
||||
if (env && *env) return *env == '1';
|
||||
return ((const volatile char *)g_stub_slot)[sizeof STUB_MARK - 1] == '1';
|
||||
}
|
||||
|
||||
static u64 hash_name(const char *name) {
|
||||
u64 h = 1469598103934665603ULL ^ 0x66; // FNV-1a, seeded apart from the tags
|
||||
for (const char *p = name; *p; p++) {
|
||||
@@ -754,6 +764,14 @@ EXPORT u64 ovr_LanguagePack_SetCurrent(const char *tag) {
|
||||
}
|
||||
|
||||
EXPORT u64 ovr_AssetFile_GetList(void) {
|
||||
if (stub_enabled()) {
|
||||
msg_t *m = new_msg(MSG_ASSETFILE_GETLIST, 0);
|
||||
if (!m) return 0;
|
||||
u64 req = m->req;
|
||||
fplog("AssetFile_GetList: stub, empty list");
|
||||
post(m);
|
||||
return req;
|
||||
}
|
||||
u64 req = o_AssetFile_GetList();
|
||||
fplog("AssetFile_GetList (dispatcher request %llu)", (unsigned long long)req);
|
||||
if (req) {
|
||||
@@ -772,6 +790,19 @@ EXPORT u64 ovr_AssetFile_GetList(void) {
|
||||
}
|
||||
|
||||
EXPORT u64 ovr_AssetFile_StatusById(u64 id) {
|
||||
if (stub_enabled()) {
|
||||
msg_t *m = new_msg(MSG_ASSETFILE_STATUSBYID, 0);
|
||||
if (!m) return 0;
|
||||
u64 req = m->req;
|
||||
m->has_det = 1;
|
||||
m->det.id = id;
|
||||
snprintf(m->det.type, sizeof m->det.type, "default");
|
||||
snprintf(m->det.status, sizeof m->det.status, "installed");
|
||||
snprintf(m->det.iap, sizeof m->det.iap, "free");
|
||||
fplog("AssetFile_StatusById(%llu): stub, installed without a file", (unsigned long long)id);
|
||||
post(m);
|
||||
return req;
|
||||
}
|
||||
fplog("AssetFile_StatusById(%llu)%s", (unsigned long long)id, (id >> 48) == 0x4650 ? " (ours)" : "");
|
||||
if ((id >> 48) == 0x4650) {
|
||||
pack_t packs[MAXP];
|
||||
|
||||
@@ -74,6 +74,11 @@
|
||||
"title": "Draw fades over the picture",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.flip_dump": {
|
||||
"anchor": "adapter-flip_dump",
|
||||
"title": "Upside-down panel images (diagnostics)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.flip_emul": {
|
||||
"anchor": "adapter-flip_emul",
|
||||
"title": "Fix upside-down menus",
|
||||
@@ -99,6 +104,11 @@
|
||||
"title": "Hide Quest foveation",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.gl_error_clear": {
|
||||
"anchor": "adapter-gl_error_clear",
|
||||
"title": "Clear the game's GL errors (GLES)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.gl_hide_msrtt": {
|
||||
"anchor": "adapter-gl_hide_msrtt",
|
||||
"title": "Hide fast anti-aliasing (OpenGL ES)",
|
||||
@@ -379,6 +389,11 @@
|
||||
"title": "Content files from the game's data",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.asset_files_stub": {
|
||||
"anchor": "frame-asset_files_stub",
|
||||
"title": "Asset-file service bypass",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.avatar_stub": {
|
||||
"anchor": "frame-avatar_stub",
|
||||
"title": "Skip Meta avatars",
|
||||
@@ -589,6 +604,11 @@
|
||||
"title": "Unreal: start without the OBB check",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unreal_skip_obb_delivery": {
|
||||
"anchor": "frame-unreal_skip_obb_delivery",
|
||||
"title": "Unreal: skip the OBB delivery check",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unreal_splash_bypass": {
|
||||
"anchor": "frame-unreal_splash_bypass",
|
||||
"title": "Unreal: bypass the stalled loading splash",
|
||||
|
||||
@@ -39,7 +39,8 @@ IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
|
||||
# 13: validate monolithic CryEngine projects/config, OBB filename formats and foveation cvar (The Climb, GitHub #238)
|
||||
# 14: bhaptics_permission (bHaptics asks for Android 12's BLUETOOTH_CONNECT: frame.bhaptics_permission, GitHub #235)
|
||||
# 15: verified xLua/LuaJIT 47-bit lightuserdata engine (frame.luajit_pointers, GitHub #257)
|
||||
ANALYSIS_VERSION = 15
|
||||
# 16: unreal_obb_delivery (Java expansionFilesDelivered gate, GitHub #283) (2026-10)
|
||||
ANALYSIS_VERSION = 16
|
||||
|
||||
|
||||
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
|
||||
@@ -192,6 +193,7 @@ LAYER_EXTENSIONS = ("XR_KHR_composition_layer_cylinder", "XR_KHR_composition_lay
|
||||
|
||||
def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Analysis:
|
||||
path = Path(path)
|
||||
package, version, label, activity, apk_info = _read_manifest_info(path)
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = z.namelist()
|
||||
abis = sorted({n.split("/")[1] for n in names if n.startswith("lib/") and n.count("/") >= 2})
|
||||
@@ -212,6 +214,9 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
for d in dexes)
|
||||
# video through Android's decoders (frame.hw_video_decode): Java MediaCodec, ExoPlayer/Media3, or libVLC
|
||||
media_codec = any(m in d for d in dexes for m in MEDIA_CODEC_MARKERS) or "libvlc.so" in libs
|
||||
from ..patches.frame.unreal_obb_check import has_delivery_check
|
||||
|
||||
unreal_obb_delivery = any(has_delivery_check(d, package) for d in dexes)
|
||||
del dexes
|
||||
manifest = z.read("AndroidManifest.xml")
|
||||
lib_bytes = {}
|
||||
@@ -234,7 +239,6 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
system_cfg = (z.read("assets/system.cfg").decode("utf-8", "replace")
|
||||
if "assets/system.cfg" in names else "")
|
||||
|
||||
package, version, label, activity, apk_info = _read_manifest_info(path)
|
||||
libset = set(libs)
|
||||
engine = ("Unreal" if libset & {"libUE4.so", "libUnreal.so"} else "Unity" if "libunity.so" in libset
|
||||
else "CryEngine" if "libCrySystem.so" in libset or cry_project else "Other")
|
||||
@@ -366,6 +370,7 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
"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_obb_delivery": unreal_obb_delivery,
|
||||
# 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 []),
|
||||
|
||||
@@ -12,6 +12,7 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.paths import user_data_dir
|
||||
from ..i18n import tr
|
||||
|
||||
EM_AARCH64, EM_X86_64 = 183, 62
|
||||
ARCHIVES = (".zip", ".tar.gz", ".tgz", ".tar.xz", ".txz", ".tar.bz2", ".tar")
|
||||
@@ -234,6 +235,10 @@ def inspect(path: Path) -> dict:
|
||||
"vr_overlay": False, "vr_overlay_app": None, "vr_overlay_from": ""}
|
||||
programs = rank_programs(root)
|
||||
if not programs:
|
||||
if any(p.is_file() and p.name.lower() in ("install.sh", "setup.sh")
|
||||
for pattern in ("*", "*/*") for p in root.glob(pattern)):
|
||||
raise ValueError(tr("This folder contains an installer without a launchable Linux program. "
|
||||
"FramePort runs programs, not installers. Use the project's own install instructions."))
|
||||
raise ValueError(f"no Linux program found in {path.name}")
|
||||
# an arm64 build wins; x86_64 programs only when there is nothing else (they run through FEX on the Frame)
|
||||
for want in (EM_AARCH64, EM_X86_64):
|
||||
|
||||
@@ -152,17 +152,18 @@ class Dex:
|
||||
"""Make a void method return at once (see return_early)."""
|
||||
return self.return_early(code_off, "V")
|
||||
|
||||
def return_early(self, code_off: int, ret: str) -> bool:
|
||||
def return_early(self, code_off: int, ret: str, *, boolean_value: bool = False) -> bool:
|
||||
"""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."""
|
||||
empty string, only when the dex has it). Z can return true with boolean_value=True. 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") and regs:
|
||||
new = [CONST4_V0_0, RETURN_V0]
|
||||
new = [0x1012 if ret == "Z" and boolean_value else 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:
|
||||
|
||||
@@ -7,11 +7,18 @@ install queue then waits for it instead of failing every remaining job).
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import ssl
|
||||
import zipfile
|
||||
|
||||
from .i18n import tr
|
||||
|
||||
|
||||
class MissingGameSource(ValueError):
|
||||
def __init__(self, path, kind="APK"):
|
||||
self.path, self.kind = path, kind
|
||||
super().__init__(f"missing game {kind}: {path}")
|
||||
|
||||
|
||||
def _paramiko():
|
||||
try:
|
||||
import paramiko
|
||||
@@ -46,6 +53,16 @@ def explain(exc: BaseException) -> str:
|
||||
paramiko, _ = _paramiko()
|
||||
msg = " ".join(str(exc).split()).removeprefix("[Errno None] ")
|
||||
low = msg.lower()
|
||||
if isinstance(exc, MissingGameSource):
|
||||
kind = {"APK": tr("APK"), "data folder": tr("data folder"), "data file": tr("data file")}.get(exc.kind, exc.kind)
|
||||
return tr("The game's {kind} isn't at {path} any more. Add the game again from the folder that has it.") \
|
||||
.format(kind=kind, path=exc.path)
|
||||
if isinstance(exc, ssl.SSLCertVerificationError) or any(
|
||||
marker in low for marker in ("certificate_verify_failed", "certificate verify failed",
|
||||
"self-signed certificate", "self signed certificate")):
|
||||
return tr("The site's HTTPS certificate couldn't be verified. Something on the network, such as antivirus "
|
||||
"HTTPS scanning, a proxy or a school/work network, may have replaced it. Try another network or "
|
||||
"turn off HTTPS scanning.")
|
||||
if type(exc).__name__ == "HostKeyChanged":
|
||||
return tr("This Frame's SSH key changed since FramePort last connected (SteamOS was reset, or another "
|
||||
"device now has its address). If it's your Frame, choose Trust the new key on the Steam Frame "
|
||||
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
import posixpath
|
||||
import threading
|
||||
from collections import deque
|
||||
@@ -185,6 +186,57 @@ def main_game(sample: dict | None) -> dict | None:
|
||||
return next((g for g in (sample or {}).get("games") or [] if not g.get("overlay")), None)
|
||||
|
||||
|
||||
SMOOTH_TAU = 1.5 # seconds: the shown numbers follow a change within ~3 s instead of jumping with every tick
|
||||
|
||||
|
||||
class Smoother:
|
||||
"""Exponential moving average per value, by time (the same feel at 0.1 s or 2 s intervals): the big numbers
|
||||
stopped flickering between neighbours (71/72 fps, 18/21 %) at fast intervals. A gap of several `tau` (paused,
|
||||
reconnected) or a missing value starts that value afresh."""
|
||||
|
||||
def __init__(self, tau: float = SMOOTH_TAU):
|
||||
self.tau = tau
|
||||
self.state: dict[str, tuple[float, float]] = {} # key -> (value, time)
|
||||
|
||||
def value(self, key: str, v: float | None, t: float | None) -> float | None:
|
||||
if v is None:
|
||||
self.state.pop(key, None)
|
||||
return None
|
||||
prev = self.state.get(key)
|
||||
if prev is None or t is None or not 0 < t - prev[1] < 4 * self.tau:
|
||||
out = float(v)
|
||||
else:
|
||||
alpha = 1 - math.exp(-(t - prev[1]) / self.tau)
|
||||
out = prev[0] + alpha * (v - prev[0])
|
||||
self.state[key] = (out, t if t is not None else 0.0)
|
||||
return out
|
||||
|
||||
def sample(self, s: dict) -> dict:
|
||||
"""A copy of the sample with the shown numbers smoothed (tiles + each game's fps/CPU/GPU, by package)."""
|
||||
t = s.get("t")
|
||||
out = dict(s)
|
||||
|
||||
def part(name, keys=None):
|
||||
d = s.get(name)
|
||||
if isinstance(d, dict):
|
||||
out[name] = {**d, **{k: self.value(f"{name}.{k}", d.get(k), t) for k in (keys or d)
|
||||
if isinstance(d.get(k), (int, float)) and not isinstance(d.get(k), bool)}}
|
||||
|
||||
part("cpu", ("total",))
|
||||
part("gpu", ("busy",))
|
||||
part("mem", ("avail",))
|
||||
part("temps")
|
||||
part("power")
|
||||
if s.get("games"):
|
||||
out["games"] = [{**g, **{k: self.value(f"game:{g.get('package')}.{k}", g.get(k), t)
|
||||
for k in ("fps", "cpu", "gpu") if g.get(k) is not None}}
|
||||
for g in s["games"]]
|
||||
return out
|
||||
|
||||
def clear(self) -> None:
|
||||
self.state.clear()
|
||||
|
||||
|
||||
def series_of(sample: dict) -> dict[str, float | None]:
|
||||
"""The values one sample adds to the History."""
|
||||
mem = sample.get("mem") or {}
|
||||
|
||||
@@ -184,6 +184,7 @@
|
||||
"Assassin's Creed Nexus (build 207706 only): turns off application space warp and runs at 90 Hz.": "",
|
||||
"Assassin's Creed Nexus: smoother picture at 72 Hz (turns off Meta's frame-rate trick).": "",
|
||||
"Assassin's Creed Nexus: smoother picture at 90 Hz (turns off Meta's frame-rate trick).": "",
|
||||
"Asset-file service bypass": "",
|
||||
"At most this many redraws per second of a 360° layer (0 = no limit). Video players need at least the video's frame rate.": "",
|
||||
"Automatic": "",
|
||||
"Automatic ({auto:.0%})": "",
|
||||
@@ -254,6 +255,8 @@
|
||||
"Clear search": "",
|
||||
"Clear selection": "",
|
||||
"Clear tags": "",
|
||||
"Clear the game's GL errors (GLES)": "",
|
||||
"Clears OpenGL errors the game left pending before FrameBridge calls the Frame's runtime. The runtime's GL code fails on any pending error; after one such failure at the start it allocated GPU memory every frame without freeing it (e.g. Vader Immortal, ~20 MB a second).": "",
|
||||
"Click here, then type. Every key goes to the Frame.": "",
|
||||
"Click to open · right-click for quick actions": "",
|
||||
"Click to open · right-click for quick actions · drag across cards to select several": "",
|
||||
@@ -338,6 +341,8 @@
|
||||
"Cylinder layers (curved menus and movie screens, for example 4XVR), which the Frame's runtime lacks, are shown as a few flat strips along the curve. 0 = drop them. 360° (equirect) layers can't be shown on the Frame.": "",
|
||||
"Darken lower-resolution regions (test)": "",
|
||||
"Data": "",
|
||||
"data file": "",
|
||||
"data folder": "",
|
||||
"Data folder": "",
|
||||
"Decide later": "",
|
||||
"Declare Meta-only permissions": "",
|
||||
@@ -371,6 +376,7 @@
|
||||
"Dev builds are early, less tested versions and may have bugs. You still get the next release as a normal update (you have {version}).": "",
|
||||
"Developer Mode lets FramePort find and connect to the Frame; the setup turns it on for you. With it on, you can also open Settings → Developer → Pair new host on the Frame and approve FramePort there.": "",
|
||||
"Diagnostics for frame.gl_multiview_fbo: checks for GL errors after every single-view draw of a multiview shader and logs counters every 5 seconds (tag GLMV in launch.log).": "",
|
||||
"Diagnostics: removes layer color fades. Some fades no longer show.": "",
|
||||
"Diagnostics: the Vulkan shim (frame.vk_sanitize) adds Khronos' validation layer to the game's instance; its findings go to launch.log. The layer library (libVkLayer_khronos_validation.so) must be in the APK.": "",
|
||||
"Disable application space warp if used": "",
|
||||
"Disable controller tracking offset": "",
|
||||
@@ -449,6 +455,8 @@
|
||||
"Everything is up to date": "",
|
||||
"Experimental": "",
|
||||
"experimental": "",
|
||||
"Experimental bypass for a DLC manager blocked on Meta asset-file service errors at startup (e.g. PixelRipped 1995: Asset status error (code 100)). Returns an empty successful asset list and reports requested asset IDs as installed, with an empty file path. Does not supply DLC or change download requests. Enable only to test this startup failure; disable with FRAMEPORT_ASSET_FILES_STUB=0. Shares the language-pack library and needs no language packs.": "",
|
||||
"Experimental bypass for a failed Meta asset-file check at startup. Reports an empty asset list and installed status without a file path; does not provide DLC. Experimental.": "",
|
||||
"Experimental Vulkan mutable-format workaround.": "",
|
||||
"Experimental. Leave it off unless asked to test it.": "",
|
||||
"Experimental: shows HUDs, menus and other flat panels that stay black on the Frame (for example Doom3Quest's HUD and PDA).": "",
|
||||
@@ -508,7 +516,6 @@
|
||||
"For games whose fades to black don't show, so you see the level loading (for example Batman: Arkham Shadow).": "",
|
||||
"For games whose hands trail the controllers when you move them (for example BattleSisters).": "",
|
||||
"For games whose hands' thumbs never move when you touch the buttons or thumbstick (for example Vader Immortal).": "",
|
||||
"For games whose memory use keeps growing (for example Vader Immortal). Some fades no longer show.": "",
|
||||
"For games whose menus or screens jump to wherever you look.": "",
|
||||
"For games whose picture flickers or smears (for example Into The Radius 2).": "",
|
||||
"For heavy games that use application space warp: it causes artifacts or hangs on non-Quest runtimes (for example Asgard's Wrath 2, Batman: Arkham Shadow).": "",
|
||||
@@ -517,6 +524,7 @@
|
||||
"For older Unity games that freeze at the first scene change (for example Sniper Elite VR). Can make loading screens stutter.": "",
|
||||
"For older Unity games whose hands trail the controllers (for example Sniper Elite VR). In others the hands lag.": "",
|
||||
"For one eye looking wrong (for example a jittering right eye): logs each eye's submitted pose against a fresh one, the eye-to-eye relation and when each eye's image was released, also into framebridge.log in the game's storage (some games stop Android's log early). No effect on the game.": "",
|
||||
"For OpenGL ES games whose memory use keeps growing (e.g. Vader Immortal).": "",
|
||||
"For overlay apps (fpsVR, wrist watches and other SteamVR add-ons that draw over games instead of being a game): FramePort registers the app with the Frame's SteamVR, which draws its overlays over whatever you play there, Quest and PC VR games alike. With 'start with SteamVR' on, SteamVR starts it by itself (also after a restart); otherwise start it from the library. It isn't launch-tested (it shows nothing without a game). Ignored on this PC.": "",
|
||||
"For troubleshooting: records the game's requests to Meta's online services.": "",
|
||||
"For Unity games that crash when they turn on anti-aliasing (for example The Room VR).": "",
|
||||
@@ -1215,7 +1223,7 @@
|
||||
"Removes Meta's old VR library when the game doesn't need it.": "",
|
||||
"Removes OVRPort's controller pose offset if controllers look misplaced.": "",
|
||||
"Removes requirements for Quest-only system parts.": "",
|
||||
"Removes the color scale/bias (XR_KHR_composition_layer_color_scale_bias) from the game's layers, so the runtime doesn't apply it. The Frame's runtime allocates GPU memory for it every frame without freeing it, for example Vader Immortal leaked ~20 MB/s on its loading screen. Fades done this way no longer show.": "",
|
||||
"Removes the color scale/bias (XR_KHR_composition_layer_color_scale_bias) from the game's layers, so the runtime doesn't apply it (diagnostics). Fades done this way no longer show.": "",
|
||||
"Removes the game from the Frame. Saves are kept, so a reinstall picks up where you left off.": "",
|
||||
"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.": "",
|
||||
@@ -1288,7 +1296,9 @@
|
||||
"Saved {name} — drag it into the GitHub issue that opened": "",
|
||||
"Saved. Restart the game to use the new settings.": "",
|
||||
"Saved. They apply when the game is installed.": "",
|
||||
"Saves a few UI panels before and after FrameBridge turns them upright, for testing without the headset.": "",
|
||||
"saves and settings": "",
|
||||
"Saves the first N UI panels that FrameBridge turns upright (Vulkan games whose panels are stored upside down), before and after the flip, as fb_flip_<n>_raw.ppm / fb_flip_<n>_upright.ppm in the game's files folder on the Frame. Shows what the runtime gets without anyone wearing the headset.": "",
|
||||
"Saves the game's shaders to capture one for a fix. No effect on the game.": "",
|
||||
"Saves the game's shaders to find one that hangs the GPU. No effect on the game.": "",
|
||||
"Saves what the game draws every few seconds, for testing without the headset.": "",
|
||||
@@ -1370,6 +1380,7 @@
|
||||
"Skip Meta avatars": "",
|
||||
"Skip this version": "",
|
||||
"Skips a Quest-only reporting step in Meta's audio library that crashes some games.": "",
|
||||
"Skips a separate data-file check when an Unreal game still hangs at startup with the content check off. Experimental; the game still needs its data.": "",
|
||||
"Skips Meta XR Audio's telemetry under x86_64 ARM translation (emulators). Not needed on the Frame, which runs games natively.": "",
|
||||
"Skips picture layers the Frame would refuse. Leave it on.": "",
|
||||
"Sleep": "",
|
||||
@@ -1532,6 +1543,7 @@
|
||||
"The Frame's Steam library still shows the old name.": "",
|
||||
"the game failed its checks (see the list above)": "",
|
||||
"The game's files or settings changed. Update applies only what's needed; saves are kept.": "",
|
||||
"The game's {kind} isn't at {path} any more. Add the game again from the folder that has it.": "",
|
||||
"The headset is asleep: put it on, then take the screenshot.": "",
|
||||
"The headset's view and sound open in an mpv window. Open it in your browser any time.": "",
|
||||
"The headset's view and sound open in your web browser. Stop the stream when you're done.": "",
|
||||
@@ -1551,6 +1563,7 @@
|
||||
"the previous version, kept as a backup": "",
|
||||
"The processor types the game has code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games can't run on it.": "",
|
||||
"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 site's HTTPS certificate couldn't be verified. Something on the network, such as antivirus HTTPS scanning, a proxy or a school/work network, may have replaced it. Try another network or turn off HTTPS scanning.": "",
|
||||
"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.": "",
|
||||
@@ -1569,6 +1582,7 @@
|
||||
"They're deleted on the Frame for good. Steam's own list may show them until Steam restarts.": "",
|
||||
"They're part of SteamOS. Ending them may restart Steam and stop the running game.": "",
|
||||
"This deletes the {what} on the Frame. It can't be undone.": "",
|
||||
"This folder contains an installer without a launchable Linux program. FramePort runs programs, not installers. Use the project's own install instructions.": "",
|
||||
"This folder is empty. Upload files with the buttons above.": "",
|
||||
"This Frame isn't set up for FramePort yet. Go to Steam Frame → Start setup.": "",
|
||||
"This Frame isn't set up for FramePort yet. Run the setup once.": "",
|
||||
@@ -1658,11 +1672,13 @@
|
||||
"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 Java OBB delivery check can leave a game on an invisible DownloaderActivity screen even with the manifest CRC check off (e.g. Into The Radius). Makes known Unreal expansionFilesDelivered methods return true, bypassing the Java name and size gate. Experimental: the native engine still needs complete, matching, readable OBB data.": "",
|
||||
"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: bypass the stalled loading splash": "",
|
||||
"Unreal: don't wait for the Quest shader precompile": "",
|
||||
"Unreal: match multiview depth to the eye size": "",
|
||||
"Unreal: Quest controller bindings (RPOC key map)": "",
|
||||
"Unreal: skip the OBB delivery check": "",
|
||||
"Unreal: stand-ins for OVRPlugin functions OVRPort lacks": "",
|
||||
"Unreal: start without the OBB check": "",
|
||||
"Unreal: thumb follows the capacitive touches": "",
|
||||
@@ -1705,6 +1721,7 @@
|
||||
],
|
||||
"Upside down": "",
|
||||
"Upside down and mirrored": "",
|
||||
"Upside-down panel images (diagnostics)": "",
|
||||
"Use controllers": "",
|
||||
"Use FramePort": "",
|
||||
"Use FramePort for these links": "",
|
||||
|
||||
@@ -34,6 +34,18 @@ def with_assets(lib: bytes) -> bytes:
|
||||
at += len(ASSETS_MARK)
|
||||
return lib[:at] + b"1" + lib[at + 1:]
|
||||
|
||||
STUB_MARK = b"@FPASSETSTUB@"
|
||||
|
||||
|
||||
def with_stub(lib: bytes) -> bytes:
|
||||
"""Enable the empty asset-list / installed-status bypass without listing local files."""
|
||||
at = lib.find(STUB_MARK + b"0")
|
||||
if at < 0:
|
||||
return lib
|
||||
at += len(STUB_MARK)
|
||||
return lib[:at] + b"1" + lib[at + 1:]
|
||||
|
||||
|
||||
# Every function native/langpack/langpack.c defines. The loader's own exports of these are hidden (see
|
||||
# elf.hide_exports) so lookups reach LIB first; tests/test_langpack.py keeps this list equal to the C source.
|
||||
EXPORTS = (
|
||||
@@ -112,6 +124,7 @@ class AssetFiles(Patch):
|
||||
"installed and answers their download request at once with the file's path; nothing is copied or "
|
||||
"downloaded. Shares the language-pack library.")
|
||||
order = 33
|
||||
flag_library = staticmethod(with_assets)
|
||||
|
||||
def applies(self, a):
|
||||
extra = a.extra or {}
|
||||
@@ -137,11 +150,11 @@ class AssetFiles(Patch):
|
||||
ws.put(ws.lib(LOADER), elf.add_needed(patched, LIB))
|
||||
ws.put(ws.lib(LIB), with_metadata(artifact(ws.abi, LIB), (ctx.analysis.version or "").strip()))
|
||||
lib = ws.read_lib(LIB)
|
||||
flagged = with_assets(lib)
|
||||
flagged = self.flag_library(lib)
|
||||
if flagged == lib:
|
||||
return False
|
||||
ws.put(ws.lib(LIB), flagged)
|
||||
ctx.notes.append(f"content files listed as installed asset files by {LIB}")
|
||||
ctx.notes.append(f"{self.title} enabled by {LIB}")
|
||||
return True
|
||||
|
||||
def validate(self, ctx: ApkContext):
|
||||
@@ -151,5 +164,36 @@ class AssetFiles(Patch):
|
||||
return [("Content files listed (asset-file library)", ASSETS_MARK + b"1" in ws.read_lib(LIB), LIB)]
|
||||
|
||||
|
||||
class AssetFilesStub(AssetFiles):
|
||||
id = "frame.asset_files_stub"
|
||||
title = "Asset-file service bypass"
|
||||
description = ("Experimental bypass for a DLC manager blocked on Meta asset-file service errors "
|
||||
"at startup (e.g. PixelRipped 1995: Asset status error (code 100)). Returns an empty successful asset list and "
|
||||
"reports requested asset IDs as installed, with an empty file path. Does not supply DLC or "
|
||||
"change download requests. Enable only to test this startup failure; disable with "
|
||||
"FRAMEPORT_ASSET_FILES_STUB=0. Shares the language-pack library and needs no language packs.")
|
||||
order = 34
|
||||
experimental = True
|
||||
flag_library = staticmethod(with_stub)
|
||||
|
||||
@property
|
||||
def summary(self):
|
||||
return ("Experimental bypass for a failed Meta asset-file check at startup. Reports an empty asset list and "
|
||||
"installed status without a file path; does not provide DLC. Experimental.")
|
||||
|
||||
def applies(self, a):
|
||||
return LOADER in a.libs and "arm64-v8a" in a.abis
|
||||
|
||||
def detect(self, a):
|
||||
return None
|
||||
|
||||
def validate(self, ctx: ApkContext):
|
||||
ws = ctx.ws
|
||||
linked = ws.has(ws.lib(LOADER)) and LIB in elf.needed(ws.read_lib(LOADER))
|
||||
enabled = ws.has(ws.lib(LIB)) and STUB_MARK + b"1" in ws.read_lib(LIB)
|
||||
return [("Asset-file bypass linked and enabled", linked and enabled, LIB)]
|
||||
|
||||
|
||||
register(AssetFilesStub)
|
||||
register(LanguagePacks)
|
||||
register(AssetFiles)
|
||||
@@ -8,13 +8,36 @@ the downloader (GitHub #159, Contractors). Setting the meta-data to false skips
|
||||
check stays (a missing or wrong-size OBB still stops at the downloader). One 4-byte manifest value changes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from ...analysis.detect import UE_VERIFY_OBB_KEY
|
||||
from ...apk import axml
|
||||
from ...apk.dex import Dex
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
|
||||
MANIFEST = "AndroidManifest.xml"
|
||||
|
||||
|
||||
def delivery_methods(dex: Dex, package: str) -> list[tuple[int, int]]:
|
||||
classes = {f"L{package.replace('.', '/')}/{name};" for name in ("DownloaderActivity", "OBBData")}
|
||||
classes.update(f"Lcom/epicgames/{engine}/{name};" for engine in ("ue4", "unreal")
|
||||
for name in ("DownloadShim", "GameActivity"))
|
||||
return [(idx, code) for cls in dex.class_names() if cls in classes
|
||||
for idx, code in dex.class_methods(cls)
|
||||
if dex.method(idx)[1] == "expansionFilesDelivered" and dex.return_type(idx) == "Z"]
|
||||
|
||||
|
||||
def has_delivery_check(data: bytes, package: str) -> bool:
|
||||
if b"expansionFilesDelivered" not in data:
|
||||
return False
|
||||
return bool(delivery_methods(Dex(data), package))
|
||||
|
||||
|
||||
def patch_dex(dex: Dex, package: str) -> list[str]:
|
||||
return [dex.method(idx)[0] for idx, code in delivery_methods(dex, package)
|
||||
if dex.return_early(code, "Z", boolean_value=True)]
|
||||
|
||||
|
||||
class UnrealSkipObbCheck(Patch):
|
||||
id = "frame.unreal_skip_obb_check"
|
||||
title = "Unreal: start without the OBB check"
|
||||
@@ -49,3 +72,52 @@ class UnrealSkipObbCheck(Patch):
|
||||
|
||||
|
||||
register(UnrealSkipObbCheck)
|
||||
|
||||
|
||||
class UnrealSkipObbDelivery(Patch):
|
||||
id = "frame.unreal_skip_obb_delivery"
|
||||
title = "Unreal: skip the OBB delivery check"
|
||||
description = ("Unreal's Java OBB delivery check can leave a game on an invisible DownloaderActivity screen "
|
||||
"even with the manifest CRC check off (e.g. Into The Radius). Makes known Unreal "
|
||||
"expansionFilesDelivered methods return true, bypassing the Java name and size gate. "
|
||||
"Experimental: the native engine still needs complete, matching, readable OBB data.")
|
||||
order = 24
|
||||
needs_vr = False
|
||||
experimental = True
|
||||
|
||||
def applies(self, a):
|
||||
return bool((a.extra or {}).get("unreal_obb_delivery"))
|
||||
|
||||
def detect(self, a):
|
||||
return None # triage-only: most games pass the delivery check
|
||||
|
||||
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 not has_delivery_check(data, ctx.analysis.package):
|
||||
continue
|
||||
dex = Dex(data)
|
||||
done = patch_dex(dex, ctx.analysis.package)
|
||||
if done:
|
||||
ctx.ws.put(name, dex.finish())
|
||||
ctx.notes.append(f"{name}: expansionFilesDelivered=true in {', '.join(sorted(done))}")
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
def validate(self, ctx):
|
||||
checks = []
|
||||
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 not has_delivery_check(data, ctx.analysis.package):
|
||||
continue
|
||||
dex = Dex(data)
|
||||
for idx, code in delivery_methods(dex, ctx.analysis.package):
|
||||
ready = (struct.unpack_from("<H", data, code)[0] > 0
|
||||
and struct.unpack_from("<I", data, code + 12)[0] >= 2
|
||||
and struct.unpack_from("<2H", data, code + 16) == (0x1012, 0x000F))
|
||||
checks.append(("OBB delivery check bypassed", ready, f"{name}: {dex.method(idx)[0]}"))
|
||||
return checks
|
||||
|
||||
|
||||
register(UnrealSkipObbDelivery)
|
||||
@@ -22,10 +22,17 @@ SETTINGS = [
|
||||
"Every N seconds, saves the left-eye image the game submits (a quarter of its size) as fb_snap_0-7.ppm in the "
|
||||
"game's files folder on the Frame. Launch tests run without anyone wearing the headset, so the headset view stays "
|
||||
"black; this shows what the game itself draws. OpenGL ES games only."),
|
||||
("gl_error_clear", "int", 0, "Clear the game's GL errors (GLES)",
|
||||
"Clears OpenGL errors the game left pending before FrameBridge calls the Frame's runtime. The runtime's GL code "
|
||||
"fails on any pending error; after one such failure at the start it allocated GPU memory every frame without "
|
||||
"freeing it (e.g. Vader Immortal, ~20 MB a second)."),
|
||||
("flip_dump", "int", 0, "Upside-down panel images (diagnostics)",
|
||||
"Saves the first N UI panels that FrameBridge turns upright (Vulkan games whose panels are stored upside down), "
|
||||
"before and after the flip, as fb_flip_<n>_raw.ppm / fb_flip_<n>_upright.ppm in the game's files folder on the "
|
||||
"Frame. Shows what the runtime gets without anyone wearing the headset."),
|
||||
("strip_color_bias", "int", 0, "Drop layer color fades",
|
||||
"Removes the color scale/bias (XR_KHR_composition_layer_color_scale_bias) from the game's layers, so the runtime "
|
||||
"doesn't apply it. The Frame's runtime allocates GPU memory for it every frame without freeing it, for example "
|
||||
"Vader Immortal leaked ~20 MB/s on its loading screen. Fades done this way no longer show."),
|
||||
"doesn't apply it (diagnostics). Fades done this way no longer show."),
|
||||
("fade_quad", "int", 0, "Draw fades over the picture",
|
||||
"Games that fade to black by dimming their picture with a layer color scale (XR_KHR_composition_layer_color_"
|
||||
"scale_bias, for example Batman: Arkham Shadow's loading screens) can show the level loading instead of black on "
|
||||
@@ -392,11 +399,15 @@ UI: dict[str, dict] = {
|
||||
help="For Unity games that crash when they turn on anti-aliasing (for example The Room "
|
||||
"VR).", control=("switch",)),
|
||||
"strip_color_bias": dict(group="troubleshooting", level="advanced", label="Drop layer color fades",
|
||||
help="For games whose memory use keeps growing (for example Vader Immortal). Some fades no"
|
||||
" longer show.", control=("switch",)),
|
||||
help="Diagnostics: removes layer color fades. Some fades no longer show.", control=("switch",)),
|
||||
"snapshot": dict(group="troubleshooting", level="advanced", label="Eye-image snapshots (diagnostics)",
|
||||
help="Saves what the game draws every few seconds, for testing without the headset.",
|
||||
control=("switch",)),
|
||||
"gl_error_clear": dict(group="troubleshooting", level="advanced", label="Clear the game's GL errors (GLES)",
|
||||
help="For OpenGL ES games whose memory use keeps growing (e.g. Vader Immortal).", control=("switch",)),
|
||||
"flip_dump": dict(group="troubleshooting", level="advanced", label="Upside-down panel images (diagnostics)",
|
||||
help="Saves a few UI panels before and after FrameBridge turns them upright, for testing without the "
|
||||
"headset.", control=("switch",)),
|
||||
"strip_depth": dict(group="troubleshooting", level="advanced", label="Strip depth layers",
|
||||
help="Removes depth information from the game's layers. For testing.", control=("switch",)),
|
||||
"respace_kick": dict(group="troubleshooting", level="advanced", label="Re-create play space",
|
||||
|
||||
@@ -72,6 +72,8 @@ SUMMARIES = {
|
||||
"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_delivery": "Skips a separate data-file check when an Unreal game still hangs at startup "
|
||||
"with the content check off. Experimental; the game still needs its data.",
|
||||
"frame.unreal_skip_obb_check": "Starts Unreal games without re-checking their data file (they'd seem to hang "
|
||||
"on an invisible check screen).",
|
||||
"frame.bhaptics_permission": "Stops bHaptics from asking for a Bluetooth permission the Frame doesn't have (the "
|
||||
|
||||
@@ -763,6 +763,23 @@ def source_of(entry: dict) -> SourceGame:
|
||||
Path(entry["origin"]) if entry.get("origin") else None, data_files=entry.get("data_files"))
|
||||
|
||||
|
||||
def check_source(entry: dict) -> SourceGame:
|
||||
from .errors import MissingGameSource
|
||||
|
||||
src = source_of(entry)
|
||||
if not src.apk.is_file():
|
||||
raise MissingGameSource(src.apk)
|
||||
if src.data_dir is not None:
|
||||
if not src.data_dir.is_dir():
|
||||
raise MissingGameSource(src.data_dir, "data folder")
|
||||
# only files the user picked: the folder's other files can change (newer OBBs) and are listed again at build
|
||||
for name in src.data_files or ():
|
||||
path = src.data_dir / name
|
||||
if not path.is_file():
|
||||
raise MissingGameSource(path, "data file")
|
||||
return src
|
||||
|
||||
|
||||
def set_recipe(package: str, recipe: Recipe) -> None:
|
||||
library.upsert_game(package, recipe=library.recipe_to_dict(recipe))
|
||||
|
||||
@@ -831,7 +848,7 @@ def prepare_as_is(package: str, reporter: Reporter) -> dict:
|
||||
from .build import sha256
|
||||
|
||||
entry = library.game(package)
|
||||
apk = Path(entry["apk"])
|
||||
apk = check_source(entry).apk
|
||||
a = entry["analysis"]
|
||||
reporter.stage("Checking the game (installing it as it is)")
|
||||
checks = []
|
||||
@@ -956,6 +973,9 @@ def _build_inputs_changed(entry: dict) -> bool:
|
||||
|
||||
|
||||
def build_game(package: str, reporter: Reporter, outdir: Path | None = None) -> dict:
|
||||
entry = library.game(package)
|
||||
if not is_linux(entry) and not is_rift(entry):
|
||||
check_source(entry) # fail before reanalysis or tool setup when the original files have gone
|
||||
if analysis_outdated(library.game(package) or {}): # analysed by an older FramePort: new fields first
|
||||
refresh_analyses([package], reporter)
|
||||
entry = library.game(package)
|
||||
|
||||
@@ -297,8 +297,9 @@ class LibraryView:
|
||||
self._lock = threading.Lock()
|
||||
self._search_timer: threading.Timer | None = None
|
||||
self._gen = 0
|
||||
# (left padding: the grid clips at its edge, and a hovered card's lift and accent border were cut off there)
|
||||
self.grid = ft.GridView(expand=True, max_extent=T.px(196), child_aspect_ratio=0.62, spacing=T.S4,
|
||||
run_spacing=T.S4, padding=ft.Padding(0, T.S2, T.S2, T.S5))
|
||||
run_spacing=T.S4, padding=ft.Padding(T.S2, T.S2, T.S2, T.S5))
|
||||
self.count = C.meta("")
|
||||
self.subtitle = C.body("", max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)
|
||||
# typing anywhere on the Library goes here (app._on_key); search_focused tracks where keys already land
|
||||
@@ -663,12 +664,12 @@ class LibraryView:
|
||||
tile.border = ft.Border.all(1, T.ACCENT if on else T.BORDER)
|
||||
quick.opacity = 1 if on else 0
|
||||
button.show(on)
|
||||
tile.update()
|
||||
C.update(tile) # a hover can still arrive for a card the shelf just replaced
|
||||
tile.on_hover = hover
|
||||
# (padding: room for the hover lift and shadow inside the scrolling row, which clips)
|
||||
return ft.Container(ft.GestureDetector(content=tile,
|
||||
on_secondary_tap_down=lambda e: self.open_menu(pkg, e.global_position)),
|
||||
padding=ft.Padding(T.px(2), T.px(6), T.px(2), T.px(8)))
|
||||
padding=ft.Padding(T.px(6), T.px(6), T.px(6), T.px(8)))
|
||||
|
||||
# ---------------------------------------------------------------- header / filter bar
|
||||
def _update_hint(self):
|
||||
@@ -855,7 +856,7 @@ class LibraryView:
|
||||
if quick:
|
||||
quick.opacity = 1 if show else 0
|
||||
button.show(show)
|
||||
tile.update()
|
||||
C.update(tile) # a hover can still arrive for a card the grid just replaced (it raised and crashed)
|
||||
tile.on_hover = hover
|
||||
self.marks[pkg] = (tile, overlay, False)
|
||||
self._paint_card(pkg)
|
||||
|
||||
@@ -99,6 +99,8 @@ class LiveView:
|
||||
self.stop_btn = C.ghost(tr("Stop"), ft.Icons.STOP_ROUNDED, self._stop_click)
|
||||
self.sound_btn = C.ghost(tr("Sound off"), ft.Icons.VOLUME_OFF_ROUNDED, self._toggle_sound)
|
||||
self.window_btn = C.ghost(tr("Show the mpv window"), ft.Icons.OPEN_IN_NEW_ROUNDED, self._reopen_mpv)
|
||||
# a Steam screenshot of what you're watching, without going to the Screenshots tab
|
||||
self.shot_btn = C.secondary(tr("Take screenshot"), G.SHOT, self._take_shot)
|
||||
self.url = C.meta("", selectable=True)
|
||||
self._push = None # components.LoopUpdater (updates from background threads; a direct update dropped patches)
|
||||
# where the stream plays (ui/live_players): the user's pick, what's available here, what plays it now
|
||||
@@ -170,7 +172,7 @@ class LiveView:
|
||||
# squeezed the status into a column of single words while streaming)
|
||||
C.card(ft.Column([
|
||||
ft.Row([self.dot, ft.Column([self.state, self.detail], spacing=T.px(2), expand=True),
|
||||
self.start_btn, self.sound_btn, self.window_btn, self.open_btn, self.stop_btn],
|
||||
self.start_btn, self.shot_btn, self.sound_btn, self.window_btn, self.open_btn, self.stop_btn],
|
||||
spacing=T.S3, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
ft.Row([self.player_dd, self.quality_dd, ft.Container(self.url, expand=True)],
|
||||
spacing=T.S3, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
@@ -214,6 +216,7 @@ class LiveView:
|
||||
self.start_btn.visible = not running
|
||||
self.start_btn.disabled = self.idle_start.disabled = self._busy
|
||||
self.open_btn.visible = self.stop_btn.visible = running
|
||||
self.shot_btn.visible = running
|
||||
self.sound_btn.visible = running and self.mode == "app"
|
||||
self.sound_btn.content = tr("Sound on") if self.muted else tr("Sound off")
|
||||
self.sound_btn.icon = ft.Icons.VOLUME_UP_ROUNDED if self.muted else ft.Icons.VOLUME_OFF_ROUNDED
|
||||
@@ -236,7 +239,8 @@ class LiveView:
|
||||
if update: # called from the ticker / start / stream-end threads: send through Flet's event loop
|
||||
if self._push is None:
|
||||
self._push = C.LoopUpdater(self.app.page)
|
||||
self._push(self.dot, self.state, self.detail, self.where, self.start_btn, self.open_btn, self.stop_btn,
|
||||
self._push(self.dot, self.state, self.detail, self.where, self.start_btn, self.shot_btn, self.open_btn,
|
||||
self.stop_btn,
|
||||
self.sound_btn, self.window_btn, self.quality_dd, self.url, self.idle, self.idle_text,
|
||||
self.idle_start, self.idle_open, self.idle_title, self.screen)
|
||||
|
||||
@@ -411,6 +415,26 @@ class LiveView:
|
||||
def _on_stream_end(self) -> None:
|
||||
self._dismiss_player()
|
||||
|
||||
def _take_shot(self, e=None) -> None:
|
||||
"""Take screenshot (while streaming): the same Steam screenshot as the Screenshots tab's button; that tab
|
||||
shows it the next time it's opened."""
|
||||
if self.shot_btn.disabled:
|
||||
return
|
||||
self.shot_btn.disabled = True
|
||||
C.update(self.shot_btn)
|
||||
|
||||
def work():
|
||||
from .screenshots import take_screenshot
|
||||
|
||||
try:
|
||||
if take_screenshot(self.app) and self.app.screenshots_view is not None:
|
||||
self.app.screenshots_view._loaded = False # reload on the next visit
|
||||
finally:
|
||||
self.shot_btn.disabled = False
|
||||
C.update(self.shot_btn)
|
||||
|
||||
self.app.run_bg(work)
|
||||
|
||||
def _open(self, e=None) -> None:
|
||||
if self.live and self.live.running:
|
||||
self.app.open_url(self.live.url)
|
||||
|
||||
@@ -146,6 +146,7 @@ class MonitorView:
|
||||
self.stopped = True # not subscribed to the app's monitor hub (the tab isn't shown)
|
||||
self.root = None
|
||||
self.history = M.History()
|
||||
self.smooth = M.Smoother() # the big numbers (sparklines keep their per-second averages)
|
||||
self.static: dict = {}
|
||||
self.procs: list[dict] = []
|
||||
self.sort_key, self.descending = "cpu", True
|
||||
@@ -448,7 +449,8 @@ class MonitorView:
|
||||
self.last_sample = s
|
||||
for name, value in M.series_of(s).items():
|
||||
self.history.add(name, value, s.get("t"))
|
||||
changed = [self._apply_tiles(s), self._apply_game(s), self._apply_details(s)]
|
||||
shown = self.smooth.sample(s)
|
||||
changed = [self._apply_tiles(shown), self._apply_game(shown), self._apply_details(s)]
|
||||
if "procs" in s:
|
||||
self.procs = s.get("procs") or []
|
||||
changed.append(self._bind_rows(update=False))
|
||||
|
||||
@@ -441,24 +441,9 @@ class ScreenshotsView:
|
||||
self.app.run_bg(self._take_bg)
|
||||
|
||||
def _take_bg(self) -> None:
|
||||
from ...install import screenshots
|
||||
|
||||
app = self.app
|
||||
try:
|
||||
r = screenshots.take(app.target.frame)
|
||||
if r.get("taken"):
|
||||
app.toast(tr("Screenshot taken"))
|
||||
if take_screenshot(self.app):
|
||||
self.load()
|
||||
elif r.get("reason") == "steamvr":
|
||||
app.toast(tr("SteamVR isn't running on the Frame: put the headset on, then take the screenshot."),
|
||||
error=True)
|
||||
elif r.get("reason") == "capture" or r.get("hmd") == "Standby":
|
||||
app.toast(tr("The headset is asleep: put it on, then take the screenshot."), error=True)
|
||||
else:
|
||||
app.toast(tr("The Frame didn't save a screenshot ({why}). Try again.").format(
|
||||
why=r.get("reason") or tr("no reason given")), error=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
app.toast(tr("Couldn't take a screenshot: {error}").format(error=explain(exc)), error=True)
|
||||
finally:
|
||||
self.take_btn.disabled = False
|
||||
C.update(self.take_btn)
|
||||
@@ -530,3 +515,26 @@ class ScreenshotsView:
|
||||
C.confirm(self.app.page, tr_n("Delete {n} screenshot?", "Delete {n} screenshots?", len(shots)),
|
||||
tr("They're deleted on the Frame for good. Steam's own list may show them until Steam "
|
||||
"restarts."), tr("Delete"), go, danger=True)
|
||||
|
||||
|
||||
def take_screenshot(app) -> bool:
|
||||
"""A Steam screenshot of the headset's view, saved on the Frame (Screenshots tab and Live view; runs on a
|
||||
background thread, waits while the Frame saves it). Tells the user how it went; True when one was taken."""
|
||||
from ...install import screenshots
|
||||
|
||||
try:
|
||||
r = screenshots.take(app.target.frame)
|
||||
if r.get("taken"):
|
||||
app.toast(tr("Screenshot taken"))
|
||||
return True
|
||||
if r.get("reason") == "steamvr":
|
||||
app.toast(tr("SteamVR isn't running on the Frame: put the headset on, then take the screenshot."),
|
||||
error=True)
|
||||
elif r.get("reason") == "capture" or r.get("hmd") == "Standby":
|
||||
app.toast(tr("The headset is asleep: put it on, then take the screenshot."), error=True)
|
||||
else:
|
||||
app.toast(tr("The Frame didn't save a screenshot ({why}). Try again.").format(
|
||||
why=r.get("reason") or tr("no reason given")), error=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
app.toast(tr("Couldn't take a screenshot: {error}").format(error=explain(exc)), error=True)
|
||||
return False
|
||||
@@ -432,7 +432,11 @@ class SettingsView:
|
||||
self._mark(key)
|
||||
C.update(*self.index.values())
|
||||
if self.page_col is not None:
|
||||
await self.page_col.scroll_to(scroll_key=scroll_key(key), duration=0 if self.app.reduce_motion else 300)
|
||||
try:
|
||||
await self.page_col.scroll_to(scroll_key=scroll_key(key),
|
||||
duration=0 if self.app.reduce_motion else 300)
|
||||
except RuntimeError: # the page left the screen (or the window closed) before the scroll ran
|
||||
pass
|
||||
|
||||
def build(self) -> ft.Control:
|
||||
app = self.app
|
||||
|
||||
@@ -1053,6 +1053,35 @@ def test_launch_re_adds_a_forgotten_devkit_entry_live(monkeypatch, tmp_path):
|
||||
got = a.cmd_launch({"package": "com.x.y"}) # no NOT_IN_LIBRARY (whose repair restarts Steam)
|
||||
assert got["via"] == "devkit" and got["steam"]["appid"] == 333
|
||||
|
||||
def test_library_falls_back_to_a_devkit_entry_when_steam_drops_the_shortcut(monkeypatch, tmp_path):
|
||||
# this Frame's Steam drops every shortcuts.vdf entry on restart (GitHub #41): "Add to Steam library" failed every
|
||||
# time with "Steam removed the new library entry again"; the devkit entry is the one Steam keeps
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
||||
anchor.mkdir(parents=True)
|
||||
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 1, "title": "X", "base": "b"}))
|
||||
(tmp_path / ".local/share/Steam/userdata/42/config").mkdir(parents=True)
|
||||
monkeypatch.setattr(a, "library_users", lambda: ["42"])
|
||||
monkeypatch.setattr(a, "game_running", lambda: False)
|
||||
monkeypatch.setattr(a, "desktop_mode", lambda: False)
|
||||
monkeypatch.setattr(a, "stop_steam", lambda: True)
|
||||
monkeypatch.setattr(a, "start_steam", lambda s: None)
|
||||
monkeypatch.setattr(a, "shortcuts_lost", lambda users, pkgs, wait=25: list(pkgs))
|
||||
registered = []
|
||||
monkeypatch.setattr(a, "devkit_register", lambda pkg: registered.append(pkg) or 333)
|
||||
a.shortcuts_worker(json.dumps({"packages": ["com.x.y"], "remove": []}))
|
||||
status = json.load(open(a.STATUS_FILE))
|
||||
assert registered == ["com.x.y"] and status["state"] == "done" and not status["errors"]
|
||||
assert status["added"] == [{"package": "com.x.y", "appid": 333, "expected": 1, "via": "devkit"}]
|
||||
|
||||
monkeypatch.setattr(a, "devkit_register", lambda pkg: (_ for _ in ()).throw(a.AgentError("no answer")))
|
||||
(tmp_path / ".local/share/Steam/userdata/42/config/shortcuts.vdf").unlink() # Steam dropped it, as above
|
||||
a.shortcuts_worker(json.dumps({"packages": ["com.x.y"], "remove": []}))
|
||||
status = json.load(open(a.STATUS_FILE))
|
||||
assert not status["added"] and "Steam removed the new library entry" in status["errors"][0]
|
||||
assert "no answer" in status["errors"][0]
|
||||
|
||||
|
||||
def test_shortcuts_lost_after_steam_restart(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Missing original APK/data files fail before tools or reanalysis run (#278)."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport import pipeline
|
||||
from frameport.errors import MissingGameSource, explain
|
||||
|
||||
|
||||
@pytest.mark.parametrize("as_is", [False, True])
|
||||
@pytest.mark.parametrize("directory", [False, True])
|
||||
def test_build_missing_apk_before_tools(tmp_path, monkeypatch, as_is, directory):
|
||||
apk = tmp_path / "base.apk"
|
||||
if directory:
|
||||
apk.mkdir()
|
||||
entry = {"package": "com.example.game", "apk": str(apk), "recipe": {"as_is": as_is}}
|
||||
monkeypatch.setattr(pipeline.library, "game", lambda package: entry)
|
||||
refresh, build = Mock(), Mock()
|
||||
monkeypatch.setattr(pipeline, "refresh_analyses", refresh)
|
||||
monkeypatch.setattr(pipeline.builder, "build", build)
|
||||
with pytest.raises(MissingGameSource) as caught:
|
||||
pipeline.build_game(entry["package"], Mock())
|
||||
assert explain(caught.value) == (
|
||||
f"The game's APK isn't at {apk} any more. Add the game again from the folder that has it.")
|
||||
refresh.assert_not_called()
|
||||
build.assert_not_called()
|
||||
|
||||
|
||||
def test_changed_folder_contents_dont_block(tmp_path):
|
||||
# a folder scanned without picked files: newer OBBs may replace the ones listed at scan time
|
||||
apk = tmp_path / "base.apk"
|
||||
apk.write_bytes(b"apk")
|
||||
(tmp_path / "obb").mkdir()
|
||||
entry = {"package": "com.example.game", "apk": str(apk), "data_dir": str(tmp_path / "obb"),
|
||||
"data_manifest": {"main.1.com.example.game.obb": 3}}
|
||||
assert pipeline.check_source(entry).apk == apk
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["folder", "selected"])
|
||||
def test_missing_saved_data(tmp_path, mode):
|
||||
apk = tmp_path / "base.apk"
|
||||
apk.write_bytes(b"apk")
|
||||
data = tmp_path / "obb"
|
||||
entry = {"package": "com.example.game", "apk": str(apk), "data_dir": str(data)}
|
||||
if mode != "folder":
|
||||
data.mkdir()
|
||||
entry["data_files"] = ["main.obb"]
|
||||
with pytest.raises(MissingGameSource) as caught:
|
||||
pipeline.check_source(entry)
|
||||
assert "data" in explain(caught.value) and "Add the game again" in explain(caught.value)
|
||||
if mode == "folder":
|
||||
data.mkdir()
|
||||
else:
|
||||
(data / "main.obb").write_bytes(b"obb")
|
||||
assert pipeline.source_of(entry).apk == apk
|
||||
|
||||
|
||||
def test_existing_missing_obb_warning_is_nonblocking(tmp_path):
|
||||
apk = tmp_path / "base.apk"
|
||||
apk.write_bytes(b"apk")
|
||||
entry = {"package": "com.example.game", "apk": str(apk), "data_dir": None, "data_bytes": 0,
|
||||
"analysis": {"extra": {"expects_obb": True}}}
|
||||
assert pipeline.missing_obb(entry)
|
||||
assert pipeline.source_of(entry).data_dir is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", ["apk", "folder", "file"])
|
||||
def test_source_of_allows_missing_original_files(tmp_path, missing):
|
||||
apk = tmp_path / "base.apk"
|
||||
data = tmp_path / "obb"
|
||||
if missing != "apk":
|
||||
apk.write_bytes(b"apk")
|
||||
if missing != "folder":
|
||||
data.mkdir()
|
||||
entry = {"package": "com.example.game", "apk": str(apk), "data_dir": str(data),
|
||||
"data_files": ["main.obb"]}
|
||||
src = pipeline.source_of(entry)
|
||||
assert src.apk == apk and src.data_dir == data
|
||||
|
||||
|
||||
def test_prepare_as_is_checks_source_before_analysis(tmp_path, monkeypatch):
|
||||
entry = {"package": "com.example.game", "apk": str(tmp_path / "base.apk")}
|
||||
monkeypatch.setattr(pipeline.library, "game", lambda package: entry)
|
||||
with pytest.raises(MissingGameSource):
|
||||
pipeline.prepare_as_is(entry["package"], Mock())
|
||||
@@ -41,3 +41,46 @@ def test_other_errors_keep_their_message():
|
||||
assert explain(auth).startswith("This Frame isn't set up") and not is_connection_error(auth)
|
||||
assert explain(AgentFailed("Lepton is not installed (Developer Mode → …)")).startswith("Lepton is not installed")
|
||||
assert explain(RuntimeError()) == "RuntimeError"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrapped", [False, True])
|
||||
def test_certificate_verification_explanation(wrapped):
|
||||
import ssl
|
||||
|
||||
import requests
|
||||
|
||||
exc = ssl.SSLCertVerificationError(1, "self-signed certificate in certificate chain")
|
||||
if wrapped:
|
||||
exc = requests.exceptions.SSLError(exc)
|
||||
message = explain(exc)
|
||||
assert "HTTPS certificate couldn't be verified" in message
|
||||
assert "another network" in message and "HTTPS scanning" in message
|
||||
assert not is_connection_error(exc)
|
||||
|
||||
|
||||
def test_unrelated_ssl_error_keeps_message():
|
||||
import requests
|
||||
|
||||
assert explain(requests.exceptions.SSLError("TLS protocol mismatch")) == "TLS protocol mismatch"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stage", ["metadata", "archive"])
|
||||
def test_java_certificate_failure_reaches_explain(monkeypatch, stage):
|
||||
from unittest.mock import Mock
|
||||
|
||||
import requests
|
||||
|
||||
from frameport.tools import toolchain
|
||||
|
||||
exc = requests.exceptions.SSLError("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
|
||||
get = Mock(side_effect=exc)
|
||||
if stage == "archive":
|
||||
get = Mock(return_value=Mock(json=lambda: [{"binary": {"package": {
|
||||
"link": "https://example.invalid/java.tar.gz", "name": "java.tar.gz", "checksum": "unused"}},
|
||||
"version": {"semver": "test"}}]))
|
||||
monkeypatch.setattr(toolchain.cache, "download", Mock(side_effect=exc))
|
||||
monkeypatch.setattr(toolchain.cache, "http_get", get)
|
||||
with pytest.raises(requests.exceptions.SSLError) as caught:
|
||||
toolchain.install_java()
|
||||
assert "another network" in explain(caught.value)
|
||||
assert "verify" not in get.call_args.kwargs
|
||||
@@ -484,3 +484,89 @@ def test_asset_files_patch_switches_the_library_on(built, tmp_path):
|
||||
extra={"size": 1, "asset_files": ["Season1Taco-Android_ASTC.pak"], "asset_file_api": True})
|
||||
assert p.applies(a) and p.detect(a)
|
||||
assert not p.detect(_analysis(libs=["libovrplatformloader.so"], extra={"size": 1, "asset_files": ["a.pak"]}))
|
||||
|
||||
|
||||
def test_asset_stub_empty_list_and_installed_status(api, packs, monkeypatch):
|
||||
monkeypatch.setenv("FRAMEPORT_ASSET_FILES_STUB", "1")
|
||||
monkeypatch.setenv("FAKE_SILENT_LIST", "1")
|
||||
m = api.ask("ovr_AssetFile_GetList") # immediate, even with packs and a silent dispatcher
|
||||
arr = api.f("ovr_Message_GetAssetDetailsArray", VOIDP, VOIDP)(m)
|
||||
assert arr and api.f("ovr_AssetDetailsArray_GetSize", C.c_size_t, VOIDP)(arr) == 0
|
||||
get = api.f("ovr_AssetDetailsArray_GetElement", VOIDP, VOIDP, C.c_size_t)
|
||||
assert get(arr, 0) is None and get(arr, 2**32) is None
|
||||
api.free(m)
|
||||
for asset in (7, 0x4650000000001234):
|
||||
m = api.ask("ovr_AssetFile_StatusById", asset)
|
||||
assert api.msg_type(m) == STATUSBYID and not api.is_error(m)
|
||||
det = api.f("ovr_Message_GetAssetDetails", VOIDP, VOIDP)(m)
|
||||
assert api.details(det) == {"id": asset, "type": b"default", "status": b"installed", "path": b""}
|
||||
assert api.f("ovr_AssetDetails_GetIapStatus", C.c_char_p, VOIDP)(det) == b"free"
|
||||
path = api.f("ovr_AssetDetails_GetFilepath", VOIDP, VOIDP)(det)
|
||||
assert path and C.string_at(path) == b"" # allocated empty string, not a NULL pointer
|
||||
assert api.f("ovr_AssetDetails_GetMetadata", C.c_char_p, VOIDP)(det) == b""
|
||||
api.free(m)
|
||||
assert api.pop() is None
|
||||
assert api.loader.fake_freed() == 0 # no dispatcher requests were sent
|
||||
monkeypatch.setenv("FRAMEPORT_ASSET_FILES_STUB", "0")
|
||||
m = api.ask("ovr_AssetFile_StatusById", 7)
|
||||
assert api.details(api.f("ovr_Message_GetAssetDetails", VOIDP, VOIDP)(m))["path"] == b"/store/a"
|
||||
api.free(m)
|
||||
|
||||
|
||||
def test_asset_stub_patch_without_language_packs(built, tmp_path, monkeypatch):
|
||||
lib = built["lib"].read_bytes()
|
||||
assert patch_mod.STUB_MARK + b"0" in lib
|
||||
assert patch_mod.STUB_MARK + b"0" in patch_mod.with_assets(lib)
|
||||
assert patch_mod.STUB_MARK + b"0" in patch_mod.with_metadata(lib, "1.0")
|
||||
on = patch_mod.with_stub(lib)
|
||||
assert len(on) == len(lib) and patch_mod.STUB_MARK + b"1" in on
|
||||
assert patch_mod.with_stub(on) == on
|
||||
monkeypatch.setattr(patch_mod, "artifact", lambda abi, name: lib)
|
||||
patch = base.get("frame.asset_files_stub")
|
||||
a = _analysis(libs=["libovrplatformloader.so"])
|
||||
assert patch.applies(a) and patch.detect(a) is None
|
||||
assert not patch.applies(_analysis(libs=[]))
|
||||
assert not patch.applies(_analysis(libs=["libovrplatformloader.so"], abis=["armeabi-v7a"]))
|
||||
with ApkWorkspace(_apk(tmp_path, built)) as ws:
|
||||
ctx = base.ApkContext(ws, a, {}, Reporter(), {})
|
||||
assert patch.apply(ctx)
|
||||
loader = ws.read_lib(patch_mod.LOADER)
|
||||
assert elf.needed(loader)[0] == patch_mod.LIB
|
||||
assert not elf.hide_exports(loader, patch_mod.EXPORTS)[1]
|
||||
assert patch_mod.STUB_MARK + b"1" in ws.read_lib(patch_mod.LIB)
|
||||
assert all(ok for _, ok, _ in patch.validate(ctx))
|
||||
assert not patch.apply(ctx)
|
||||
|
||||
|
||||
def test_asset_stub_marker_and_environment_override(built, tmp_path, monkeypatch):
|
||||
marked = tmp_path / "marked.so"
|
||||
marked.write_bytes(patch_mod.with_stub(built["lib"].read_bytes()))
|
||||
monkeypatch.delenv("FRAMEPORT_ASSET_FILES_STUB", raising=False)
|
||||
for api in _api({**built, "marked": marked}, tmp_path, monkeypatch, "marked"):
|
||||
assert _list(api) == []
|
||||
m = api.ask("ovr_AssetFile_StatusById", 7)
|
||||
assert not api.is_error(m)
|
||||
assert api.details(api.f("ovr_Message_GetAssetDetails", VOIDP, VOIDP)(m))["status"] == b"installed"
|
||||
api.free(m)
|
||||
monkeypatch.setenv("FRAMEPORT_ASSET_FILES_STUB", "0")
|
||||
assert _list(api)[0]["path"] == b"/store/a"
|
||||
|
||||
|
||||
def test_asset_stub_via_platform_loader_handle(built, tmp_path, monkeypatch):
|
||||
"""Unity's P/Invoke resolves against the dispatcher's handle, including its DT_NEEDED library."""
|
||||
lib = tmp_path / "libfp_langpack.so"
|
||||
lib.write_bytes(patch_mod.with_stub(built["lib"].read_bytes()))
|
||||
loader_path = tmp_path / "libovrplatformloader.so"
|
||||
loader_path.write_bytes(elf.add_needed(built["patched"], str(lib)))
|
||||
monkeypatch.delenv("FRAMEPORT_ASSET_FILES_STUB", raising=False)
|
||||
loader = C.CDLL(str(loader_path))
|
||||
api = Api(loader, loader)
|
||||
try:
|
||||
assert _list(api) == []
|
||||
m = api.ask("ovr_AssetFile_StatusById", 7)
|
||||
assert api.msg_type(m) == STATUSBYID and not api.is_error(m)
|
||||
assert api.details(api.f("ovr_Message_GetAssetDetails", VOIDP, VOIDP)(m))["status"] == b"installed"
|
||||
api.free(m)
|
||||
assert api.pop() is None
|
||||
finally:
|
||||
_dlclose(loader)
|
||||
@@ -235,3 +235,37 @@ def test_install_takes_the_icon_the_frame_found(tmp_path):
|
||||
installer.apply_app_icon(frame, pkg, "/anchor", result, Reporter()) # unchanged: nothing sent
|
||||
installer.apply_app_icon(frame, pkg, "/anchor", {"app_icon": {"file": "app-icon.svg"}}, Reporter())
|
||||
assert frame.puts == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script", ["install.sh", "setup.sh"])
|
||||
@pytest.mark.parametrize("archive", [False, True])
|
||||
def test_installer_only_linux_app_refused(tmp_path, script, archive):
|
||||
from frameport.errors import explain
|
||||
|
||||
root = tmp_path / "service"
|
||||
root.mkdir()
|
||||
(root / script).write_text("#!/bin/sh\necho install\n")
|
||||
path = root
|
||||
if archive:
|
||||
path = tmp_path / "service.tar.gz"
|
||||
with tarfile.open(path, "w:gz") as t:
|
||||
t.add(root, arcname="service")
|
||||
with pytest.raises(ValueError) as caught:
|
||||
pipeline.add_linux_app(path)
|
||||
message = explain(caught.value)
|
||||
assert "FramePort runs programs, not installers" in message
|
||||
assert "project's own install instructions" in message
|
||||
|
||||
|
||||
def test_installer_beside_program_does_not_prevent_detection(tmp_path):
|
||||
(tmp_path / "install.sh").write_text("#!/bin/sh\n")
|
||||
(tmp_path / "program").write_bytes(elf(linux.EM_AARCH64))
|
||||
assert linux.inspect(tmp_path)["exe"] == "program"
|
||||
|
||||
|
||||
def test_deep_installer_is_not_used_for_refusal(tmp_path):
|
||||
nested = tmp_path / "release" / "scripts"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "install.sh").write_text("#!/bin/sh\n")
|
||||
with pytest.raises(ValueError, match="no Linux program found"):
|
||||
linux.inspect(tmp_path)
|
||||
@@ -254,3 +254,42 @@ def test_modules_need_the_agent_that_has_them():
|
||||
assert not M.MonitorSession.supports_modules.fget(s)
|
||||
s.static["agent"] = 73
|
||||
assert M.MonitorSession.supports_modules.fget(s)
|
||||
|
||||
|
||||
def test_smoother_steadies_flickering_numbers_whatever_the_interval():
|
||||
# fps flickering 71/73 every 0.1 s settles near 72 instead of jumping with each tick
|
||||
sm = M.Smoother()
|
||||
shown = [sm.value("fps", 71 if i % 2 else 73, i * 0.1) for i in range(60)]
|
||||
assert max(shown[30:]) - min(shown[30:]) < 0.3
|
||||
# a real change still comes through within a few seconds, at 0.1 s and at 2 s ticks alike
|
||||
for dt in (0.1, 2.0):
|
||||
sm = M.Smoother()
|
||||
sm.value("cpu", 10, 0.0)
|
||||
t, v = 0.0, 10.0
|
||||
while t < 4.5:
|
||||
t += dt
|
||||
v = sm.value("cpu", 50, t)
|
||||
assert v > 45
|
||||
|
||||
|
||||
def test_smoother_restarts_after_a_gap_or_a_missing_value():
|
||||
sm = M.Smoother()
|
||||
sm.value("gpu", 10, 0.0)
|
||||
assert sm.value("gpu", 90, 30.0) == 90 # paused / reconnected: no slow slide from old numbers
|
||||
assert sm.value("gpu", None, 30.5) is None
|
||||
assert sm.value("gpu", 40, 31.0) == 40
|
||||
|
||||
|
||||
def test_smoother_sample_smooths_tiles_and_games_and_keeps_the_rest():
|
||||
sm = M.Smoother()
|
||||
s1 = {"t": 0.0, "cpu": {"total": 20.0, "mhz": [2000]}, "mem": {"total": 100, "avail": 50},
|
||||
"temps": {"cpu": 40.0}, "power": {"system": 5.0}, "battery": {"percent": 80},
|
||||
"games": [{"package": "a.b", "fps": 72.0, "title": "A"}]}
|
||||
sm.sample(s1)
|
||||
s2 = {**s1, "t": 0.5, "cpu": {"total": 60.0, "mhz": [2400]},
|
||||
"games": [{"package": "a.b", "fps": 60.0, "title": "A"}]}
|
||||
out = sm.sample(s2)
|
||||
assert 20 < out["cpu"]["total"] < 60 and out["cpu"]["mhz"] == [2400]
|
||||
assert 60 < out["games"][0]["fps"] < 72 and out["games"][0]["title"] == "A"
|
||||
assert out["battery"] == {"percent": 80} and out["mem"]["total"] == 100
|
||||
assert s2["cpu"]["total"] == 60.0 # the raw sample (history, details, processes) is untouched
|
||||
@@ -474,3 +474,11 @@ def test_crash_after_the_headless_tracking_deadline_passes_a_launch_test():
|
||||
# no frames before the deadline: the game never ran
|
||||
no_frames = "\n".join(ln for ln in HEADLESS_END.splitlines() if "pacing:" not in ln)
|
||||
assert triage(no_frames, "EXITED", "ru.targem.blazerush", headless=True).verdict == "fail"
|
||||
|
||||
|
||||
def test_pixelripped_asset_status_error():
|
||||
log = "E Unity : |||||||||| Asset status error (code 100)\nE Unity : DLC_Manager:AssetStatusCallback(Message`1)\n"
|
||||
result = triage(log, "RUNNING")
|
||||
assert "meta-asset-status-error" in {f.id for f in result.findings}
|
||||
assert "frame.asset_files_stub" in result.suggestions()
|
||||
assert "meta-asset-status-error" not in {f.id for f in triage("Asset status OK\n", "RUNNING").findings}
|
||||
@@ -107,7 +107,7 @@ STUCK = f"""Boot complete!
|
||||
def test_triage_flags_the_stuck_downloader():
|
||||
res = triage(STUCK, "RUNNING", PKG)
|
||||
f = next(f for f in res.findings if f.id == "unreal-obb-check-stuck")
|
||||
assert f.severity == "fatal" and f.suggest == ["frame.unreal_skip_obb_check"]
|
||||
assert f.severity == "fatal" and f.suggest == ["frame.unreal_skip_obb_check", "frame.unreal_skip_obb_delivery"]
|
||||
assert "DownloaderActivity" in f.evidence
|
||||
assert res.verdict == "fail" # used to pass with only "Lepton container started"
|
||||
|
||||
@@ -124,3 +124,154 @@ def test_triage_ignores_a_downloader_that_returned():
|
||||
def test_triage_keeps_only_the_games_own_activity_lines():
|
||||
other = STUCK.replace(f"{PKG}/.DownloaderActivity", "com.other.app/.DownloaderActivity")
|
||||
assert not any(f.id == "unreal-obb-check-stuck" for f in triage(other, "RUNNING", PKG).findings)
|
||||
|
||||
|
||||
def test_delivery_predicates_return_true_in_place():
|
||||
import hashlib
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
from test_vivox_audio_route import build_dex
|
||||
|
||||
from frameport.apk.dex import Dex
|
||||
from frameport.patches.frame.unreal_obb_check import has_delivery_check, patch_dex
|
||||
|
||||
cls = f"L{PKG.replace('.', '/')}/DownloaderActivity;"
|
||||
obb = f"L{PKG.replace('.', '/')}/OBBData;"
|
||||
shim = "Lcom/epicgames/ue4/DownloadShim;"
|
||||
methods = [(cls, "expansionFilesDelivered", "Z", [0x0013, 5, 0x000F]),
|
||||
(obb, "expansionFilesDelivered", "Z", [0x0012, 0x000F]),
|
||||
(shim, "expansionFilesDelivered", "Z", [0x0071, 0, 0, 0x000A, 0x000F]),
|
||||
(cls, "other", "Z", [0x0012, 0x000F]),
|
||||
("Lother/DownloaderActivity;", "expansionFilesDelivered", "Z", [0x0012, 0x000F]),
|
||||
(cls, "expansionFilesDelivered", "I", [0x0012, 0x000F])]
|
||||
raw = build_dex(methods)
|
||||
dex = Dex(raw)
|
||||
assert has_delivery_check(raw, PKG)
|
||||
assert set(patch_dex(dex, PKG)) == {cls, obb, shim}
|
||||
out = dex.finish()
|
||||
assert len(out) == len(raw)
|
||||
assert out[12:32] == hashlib.sha1(out[32:]).digest()
|
||||
assert struct.unpack_from("<I", out, 8)[0] == zlib.adler32(out[12:]) & 0xFFFFFFFF
|
||||
for idx, (_c, _n, _r, units) in enumerate(methods):
|
||||
off = next(code for i, code in dex.class_methods(_c) if i == idx)
|
||||
code = list(struct.unpack_from(f"<{len(units)}H", out, off + 16))
|
||||
expected = ([0x1012, 0x000F] + ([0] if idx == 2 else []) + units[3 if idx == 2 else 2:]
|
||||
if idx < 3 else units)
|
||||
assert code == expected
|
||||
assert out[off:off + 16] == raw[off:off + 16]
|
||||
assert patch_dex(Dex(out), PKG) == []
|
||||
|
||||
|
||||
def test_delivery_detection_and_multidex_apply_with_crc_already_off(tmp_path):
|
||||
from test_vivox_audio_route import build_dex
|
||||
|
||||
from frameport.patches.frame.unreal_obb_check import UnrealSkipObbDelivery
|
||||
|
||||
patch = UnrealSkipObbDelivery()
|
||||
path = _apk(tmp_path, _manifest(verify=False))
|
||||
raw = build_dex([(f"L{PKG.replace('.', '/')}/DownloaderActivity;", "expansionFilesDelivered", "Z",
|
||||
[0x0012, 0x000F])])
|
||||
with zipfile.ZipFile(path, "a") as z:
|
||||
z.writestr("classes2.dex", raw)
|
||||
analysis = analyze(path)
|
||||
assert analysis.extra["analysis_version"] == 16
|
||||
assert analysis.extra["unreal_obb_delivery"] is True
|
||||
assert patch.applies(analysis) and patch.detect(analysis) is None
|
||||
assert patch.experimental and not patch.default_on
|
||||
assert not patch.applies(_analysis())
|
||||
original = base.get("frame.unreal_skip_obb_check")
|
||||
assert original.detect(analysis) is None and not original.applies(analysis)
|
||||
assert original.revision == 1
|
||||
with ApkWorkspace(path) as ws:
|
||||
ctx = base.ApkContext(ws, analysis, {}, Reporter(), {patch.id: {}})
|
||||
assert patch.validate(ctx)[-1][1] is False
|
||||
assert patch.apply(ctx)
|
||||
assert all(ok for _title, ok, _detail in patch.validate(ctx))
|
||||
assert ws.read("AndroidManifest.xml") == _manifest(verify=False)
|
||||
assert ws.read("classes.dex") == b"dex\n035\0"
|
||||
assert len(ws.read("classes2.dex")) == len(raw)
|
||||
assert "expansionFilesDelivered=true" in ctx.notes[-1]
|
||||
assert not patch.apply(ctx)
|
||||
|
||||
|
||||
def test_delivery_ignores_unrelated_referenced_and_unsupported_methods():
|
||||
from test_vivox_audio_route import build_dex
|
||||
|
||||
from frameport.apk.dex import Dex
|
||||
from frameport.patches.frame.unreal_obb_check import has_delivery_check, patch_dex
|
||||
|
||||
cls = f"L{PKG.replace('.', '/')}/DownloaderActivity;"
|
||||
raw = build_dex([("Lother/DownloaderActivity;", "expansionFilesDelivered", "Z", [0x0012, 0x000F]),
|
||||
(cls, "expansionFilesDelivered", "I", [0x0012, 0x000F]),
|
||||
(cls, "expansionFilesDelivered", "Z", None)])
|
||||
assert not has_delivery_check(raw, PKG)
|
||||
dex = Dex(raw)
|
||||
assert patch_dex(dex, PKG) == [] and bytes(dex.data) == raw
|
||||
raw = build_dex([(cls, "expansionFilesDelivered", "Z", [0x000F])])
|
||||
dex = Dex(raw)
|
||||
assert patch_dex(dex, PKG) == [] # too short for const/4 + return
|
||||
assert bytes(dex.data) == raw
|
||||
|
||||
|
||||
def test_triage_does_not_blame_only_a_wrong_version_when_crc_is_off():
|
||||
log = STUCK.replace("Boot complete!", "Boot complete!\nbVerifyOBBOnStartUp=false")
|
||||
finding = next(f for f in triage(log, "RUNNING", PKG).findings if f.id == "unreal-obb-check-stuck")
|
||||
assert "skip patch already applied" in finding.diagnosis
|
||||
assert "external-storage path" in finding.diagnosis
|
||||
|
||||
|
||||
def test_ue5_shim_and_zero_register_method():
|
||||
import struct
|
||||
|
||||
from test_vivox_audio_route import build_dex
|
||||
|
||||
from frameport.apk.dex import Dex
|
||||
from frameport.patches.frame.unreal_obb_check import has_delivery_check, patch_dex
|
||||
|
||||
cls = "Lcom/epicgames/unreal/DownloadShim;"
|
||||
raw = build_dex([(cls, "expansionFilesDelivered", "Z", [0x0012, 0x000F])])
|
||||
assert has_delivery_check(raw, PKG)
|
||||
dex = Dex(raw)
|
||||
off = dex.code_items(cls, "expansionFilesDelivered")[0]
|
||||
struct.pack_into("<H", dex.data, off, 0)
|
||||
before = bytes(dex.data)
|
||||
assert patch_dex(dex, PKG) == [] and bytes(dex.data) == before
|
||||
dex = Dex(raw)
|
||||
assert patch_dex(dex, PKG) == [cls]
|
||||
|
||||
|
||||
def test_crc_patch_preserves_head_output_with_delivery_dex(tmp_path):
|
||||
from test_vivox_audio_route import build_dex
|
||||
|
||||
patch = base.get("frame.unreal_skip_obb_check")
|
||||
manifest = _manifest()
|
||||
raw = build_dex([(f"L{PKG.replace('.', '/')}/DownloaderActivity;", "expansionFilesDelivered", "Z",
|
||||
[0x0012, 0x000F])])
|
||||
path = _apk(tmp_path, manifest)
|
||||
with zipfile.ZipFile(path, "a") as z:
|
||||
z.writestr("classes2.dex", raw)
|
||||
with ApkWorkspace(path) as ws:
|
||||
before = {name: ws.read(name) for name in ws.names()}
|
||||
ctx = base.ApkContext(ws, analyze(path), {}, Reporter(), {patch.id: {}})
|
||||
assert patch.detect(ctx.analysis).recommended
|
||||
assert patch.apply(ctx)
|
||||
# HEAD writes only the manifest boolean; all other APK entries stay byte-identical.
|
||||
expected = {**before, "AndroidManifest.xml": axml.set_meta_data_bool(manifest, VERIFY, False)}
|
||||
assert {name: ws.read(name) for name in ws.names()} == expected
|
||||
assert ctx.notes == ["bVerifyOBBOnStartUp=false"]
|
||||
assert patch.validate(ctx) == [("OBB check at start off", True, "bVerifyOBBOnStartUp=false")]
|
||||
|
||||
|
||||
def test_triage_offers_delivery_when_crc_patch_already_enabled(monkeypatch):
|
||||
from dataclasses import asdict
|
||||
|
||||
from frameport import pipeline
|
||||
|
||||
base.load_all()
|
||||
monkeypatch.setattr(pipeline.library, "game", lambda _pkg: {
|
||||
"analysis": asdict(_analysis(unreal_verify_obb=True, unreal_obb_delivery=True)),
|
||||
"recipe": {"patches": {"frame.unreal_skip_obb_check": {}}},
|
||||
})
|
||||
assert pipeline.useful_suggestions(PKG, triage(STUCK, "RUNNING", PKG).suggestions()) == [
|
||||
"frame.unreal_skip_obb_delivery"]
|
||||
Reference in new issue
Block a user