Compare commits

..
11 Commits
Author SHA1 Message Date
spoopyghosty0 919d059add Notes: the jni build override and re-tagging a failed release build 2026-10-06 12:14:37 -04:00
spoopyghosty0 674c26f2d9 Build: override jni to 1.1.0 instead (jni_flutter 1.0.4 also generates bindings that require jni ^1.1.0; Flet's template pins 1.0.0) 2026-10-06 12:04:20 -04:00
spoopyghosty0 a6daa5cccb Build: pin jni_flutter 1.0.4 (1.0.4+1, published today, requires jni ^1.1.0 but Flet pins jni 1.0.0; all flet build bundles failed) 2026-10-06 11:54:47 -04:00
spoopyghosty0 6f89123017 FramePort 0.11.1 2026-10-06 11:44:01 -04:00
spoopyghosty0 518c94d79f Vader Immortal: Episode I catalog entry (works with issues, GitHub #49): plays the intro, then stays on its loading card; plain recipe without diagnostics 2026-10-06 11:42:04 -04:00
spoopyghosty0 edf9f494e8 Agent v57: one launcher per game (flock on <base>/.launch.lock). Play pressed again while Lepton still booted made the second Lepton stop the first container and both died (Vader Immortal, BattleSisters); a second launch now exits and leaves the first running. upgrade_launchers adds it to existing launchers; verified on the device 2026-10-06 11:25:56 -04:00
spoopyghosty0 0db053c31f FrameBridge snapshot=N: every N seconds the left-eye image a GLES game submits is saved as fb_snap_0-7.ppm in its files folder (headless launches have a black headset view). Vader Immortal: the intro plays, then its loading card never advances 2026-10-06 09:26:38 -04:00
spoopyghosty0 7439f082db BattleSisters works (owner-verified 2026-10-06: buttons via the Oculus device-model fix; its first controller vibration ran OVRPort's haptic-envelope bug into an 11 GB OOM kill). haptic_fix is now suggested for every game that ships Meta's OVRPlugin; existing FramePort recipes pick it up after the next update 2026-10-06 09:01:40 -04:00
spoopyghosty0 f12f4aec4c FrameBridge layer_debug: count xrAcquire/Wait/ReleaseSwapchainImage per 5 s (Vader Immortal's per-frame leak in SteamVR's runtime; hooked only with layer_debug, after the other wait/release hooks) 2026-10-06 01:23:58 -04:00
spoopyghosty0 8d95592f53 Unity 2019 built-in Oculus input on Lepton (BattleSisters, GitHub #48): libunity only reports controllers when the device model starts with "Oculus"; Lepton's Android is "Valve Lepton", so Unity polled only Go-style controllers and the game's buttons never arrived. unity_oculus_check revision 4 gives those strncmp calls length 0 (in place); on the device libunity now polls the Touch controllers 2026-10-06 01:19:47 -04:00
spoopyghosty0 44d23f778d FrameBridge diagnostics from Vader Immortal (GitHub #49): layer_debug logs xrDestroySwapchain and per-5 s xrBeginFrame/xrEndFrame counts; strip_color_bias (drop XR_KHR_composition_layer_color_scale_bias, 2 = the layer's whole extension chain) and frame_balance (end an open frame before the next begin), both opt-in. Vader leaks ~430 GPU mappings a second inside SteamVR's runtime (GPU timing command buffers per frame); none of these stop it 2026-10-06 01:07:11 -04:00
21 changed files with 575 additions and 116 deletions

No files matched your search

+40 -5
View File
@@ -554,11 +554,41 @@ passes stance selection (NewtonVR grip/trigger) but its motion warning waits for
which Lepton never delivers (no focused Android window) → ovrpshim registers its own
`UnityEngine.Input::GetMouseButtonDown(System.Int32)` icall (il2cpp_add_internal_call, after Unity's: resolve first)
that adds a click in the frame a Touch trigger or A/B/X/Y is newly pressed (`unity_oculus_check` revision 3).
BattleSisters (Unity 2019 InputSystem/XR InputDevices; libunity polls only Go masks 0x8000000/0x1000000/0x2000000;
device model "Oculus Quest" vs Lepton's "Valve Lepton", ovrp_GetSystemProductName (already "Oculus Quest"),
ovrp_GetNodePresent (hands present after ~2 s, logged by the shim) and missing ovrp_GetControllerState exports were
all ruled out in the headset): buttons still dead; next = find what makes libunity's Oculus input poll only Go
controllers. Accounting+ works (owner, 2026-10-06).
BattleSisters (Unity 2019 InputSystem/XR InputDevices via `VrHandInput`): libunity's Oculus module (OVRPlugin
function table: a global pointer at 0x16b57c0 in this build, slots filled by name, e.g. +0x160 GetControllerState,
+0x168 State2, +0xe8 GetNodePresent) only reports controllers when `strncmp(deviceModel, "Oculus", 6) == 0` (next to
the Go check `deviceModel == "Oculus Pacific"`); Lepton's model is "Valve Lepton" → Go/unknown → only Go masks polled,
buttons dead. `unity_oculus_check` revision 4 (`oculus_model_checks`, Unity 2019 only) turns the 17 compares' length
into 0 (`orr w2, wzr, #6` → `mov w2, #0` after the adrp/add of the "Oculus" literal; the string stays: it is also
Unity's VR device name). Device result: libunity now polls `ovrp_GetControllerState(0x3)` (Touch); owner-confirmed
2026-10-06: buttons work. Its first controller vibration then hit OVRPort's haptic-envelope bug (11 GB, OOM kill) →
`haptic_fix` is now suggested for every game with libOVRPlugin.so (settings detect), BattleSisters works. Ruled out before: ProductName, GetNodePresent, the device-model string itself, exports.
Accounting+ works (owner, 2026-10-06).
**Vader Immortal (UE4, GitHub #49, 2026-10-06, headless):** stuck after the intro on an in-game image (the splash
quad ends ~6 s in; then the game's own projection frames, 72 fps, balanced xrBeginFrame/xrEndFrame). Not the Platform
SDK (`frame.ovr_trace`: only user + entitlement, both answered) and not the repack's Frida gadget (OVRPort's
`patch_clean_up_frida` removes its loadLibrary). It **leaks ~430 GPU mappings (/dev/dri/renderD128) and ~20 MB a
second** (6.5 GB + swap after 5 min, then 26 fps): page-fault stacks (perf -e page-faults, offsets resolved with the
process maps + vrclient.so's own symbols; its text segment is at file offset + 0x4000) end in SteamVR's runtime:
`xrBeginFrame → CSxrCompositorOpenVR::BeginFrame → SubmitExplicitTimingData → CVRCompositorSharedTextures::
BeginGPUTimingCommandBuffer` and `xrEndFrame → CVRCompositorClient::SubmitWithArrayIndexAndTime`. Ruled out: the
layer color scale/bias + image layout structs (FrameBridge `strip_color_bias` 1/2, diagnostic), Valve's Vulkan
layers (VK_INSTANCE_LAYERS=""), array swapchains in general (Lucky's Tale/I Am Cat flat). Kernel tracepoints aren't
allowed for steamos. FrameBridge: `layer_debug` logs xrDestroySwapchain and per-5 s xrBeginFrame/xrEndFrame counts;
`frame_balance` (ends an open frame before the next begin) exists but Vader never leaves one open. Next (built,
not yet run: the Frame slept): `layer_debug` also counts xrAcquire/Wait/ReleaseSwapchainImage per 5 s (hooked only
with layer_debug, after the other conditional hooks) to see whether Vader skips a wait or release: it doesn't (361/361/361 per 5 s).
FrameBridge `snapshot=N` (`snapshot_gl.c`, GLES): every N s the left-eye image the game submits is read back on its
own context and saved as `files/fb_snap_0-7.ppm` (quarter size) — headless launches show a black headset view, this
shows what the game draws. Vader: Lucasfilm logo (an OBB mp4: video works), then its loading card (portrait + segmented
bar) that never advances; the async loader thread sleeps and OBB reads stop (~168 MB of a 2.7 GB pak).
**Double launch (agent v57, 2026-10-06):** a second Play while Lepton still boots (~10 s with nothing to see) made
the second Lepton stop the first one's container ("Waiting for steamlaunch-<appid> (PID …) to exit", exit 137
"(starting)", "Clearing baked app data due to early exit") and both died (Vader, BattleSisters). launch.sh now takes
`flock` on `<base>/.launch.lock` (fd 9, inherited by Lepton; the watchdog and dashboard helpers close it with
`9>&-`); a second launch exits 0 and logs to `<base>/launch-dup.log`. `upgrade_launchers` adds it to existing
launchers. Verified on the device (second launch during boot: ignored, the first kept running). Vader on its loading
card ignores input too (owner pressed/held every button: presses reach the runtime, the card never changes).
**Lepton storage (2026-09-30):** each app's /sdcard (= /storage/emulated/0 → `<base>/lepton-data/external`) has `Movies`/`Download`/`Documents` symlinked to the Frame's `~/Videos`/`~/Downloads`/`~/Documents` (liblepton/mounting.sh, only if they exist at start); agent v24 `storage_targets` reads that mapping. Android's MediaProvider canonicalises paths to /home/steamos/... and rejects every file ("doesn't appear under [/system/media...]"), `sm list-volumes` is empty: the media index never works, apps must browse folders. Lepton installs with `adb install -g` (runtime permissions granted, MANAGE_EXTERNAL_STORAGE too). Files: `install/files.py`, `frameport frame send|storage`, GUI Files tab (formerly Frame → Send files).
**SteamVR per-app settings (2026-09-30):** editing steamvr.vrsettings while SteamVR runs is lost; the web API (127.0.0.1:27062 /app/setsettings) needs `x-steamvr-secret`. `native/vrsettings` = `fp_vrsettings.exe` (freestanding, OpenVR `FnTable:IVRSettings_003` as a Utility app, loads SteamVR's bin/win64/openvr_api.dll) sets them live and SteamVR persists them: section `steam.app.<shortcut appid>`, keys `preferredRefreshRate` (float) and `motionSmoothingOverride` (0 global, 1 on, 2 off, 3 always). Steam Link (vrlink) lists the Frame's rates 72/80/90/96/108/120/144 in vrserver.txt and follows the per-app preference ("host preferred N Hz"; whether the key is honoured is unverified in-headset yet). Judder metric: vrcompositor.txt session summary dropped + "Timed out. N total" (Stormland: 0 dropped but 313 timeouts in 2 min); fpsVR (`%LOCALAPPDATA%\fpsVR\*.json`, 0.1 ms histograms) gives p99 CPU/GPU ms. `pcvr.steamvr_tuning` (default on, PC only) applies on Play: highest rate whose budget ≥ p99×1.05, at least one step down, smoothing on.
@@ -596,6 +626,11 @@ Installed apps find the release themselves (self-update), so the notes are what
Windows console) plus PYTHONUTF8; macOS builds need `--python-version 3.12 --arch arm64` (cryptography has no wheels
for flet's default Python / x86_64 cross-build), with a PyInstaller fallback step; `astral-sh/setup-uv` has no
floating major tags after v7 → pin the exact version; force-moving a tag starts duplicate runs (cancel one).
- `jni` (2026-10-06): jni_flutter 1.0.4/1.0.4+1 generate bindings that require jni ^1.1.0 while Flet's build template
pins jni 1.0.0 → every `flet build` bundle failed (`JniVersionCheck`, "generated bindings expect package:jni
^1.1.0"). pyproject `[tool.flet.flutter.pubspec.dependency_overrides] jni = "1.1.0"` fixes it (pinning
jni_flutter 1.0.4 does not); drop it once Flet's template moves to jni 1.1. A failed tag build publishes nothing:
delete and re-push the tag on the fixed commit.
- macOS runner (2026-10-02): `macos-latest` jobs went unassigned (cancelled after 15 min, no steps); `macos-15`'s Xcode
16.4 fails a Flutter plugin (`NWPath has no member`); `macos-26` (Xcode 26) builds with `flet build`. The PyInstaller
fallback (`package.py --pyinstaller`) passes `--yes` so a failed `flet build`'s folder doesn't stop it at a prompt.
+21 -4
View File
@@ -36,7 +36,7 @@ import time
import zlib
from types import SimpleNamespace
AGENT_VERSION = 56
AGENT_VERSION = 57
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -1889,6 +1889,7 @@ LAUNCH_SH = (r"""#!/usr/bin/env bash
set -euo pipefail
app_dir={base_q}
[[ -d "$app_dir/lepton-app" ]] || {{ echo "Game files missing at $app_dir (storage not mounted?)" >&2; exit 1; }}
{single}
# Some games (Unreal cloud saves, SUPERHOT's cloud/data: mode 1700) create folders without write/search permission
# for the app inside Lepton (it writes through the folder's group), which breaks saving or makes the game quit.
# Repair them before and during every launch.
@@ -1939,10 +1940,19 @@ OLD_WATCHDOG = "( while sleep 2 && kill -0 $$ 2>/dev/null; do fix_perms; done )
# by hand (also when Lepton shows its Android launcher first). Once the game's first VR frames are logged, the agent
# closes it through Steam's UI (_dashboard_worker); opt out per game with FRAMEPORT_KEEP_DASHBOARD=1.
DASHBOARD_LINE = ('[[ -n "${{FRAMEPORT_KEEP_DASHBOARD:-}}" ]] || python3 {agent_q} _dashboard_worker '
'"$app_dir/launch.log" $$ >"$app_dir/dashboard.log" 2>&1 &')
'"$app_dir/launch.log" $$ >"$app_dir/dashboard.log" 2>&1 9>&- &')
WATCHDOG = ("parent=$PPID\n"
"( while sleep 2 && kill -0 $$ 2>/dev/null; do fix_perms;"
" if [[ $parent -gt 1 ]] && ! kill -0 $parent 2>/dev/null; then kill -TERM $$; fi; done ) & permfix=$!")
" if [[ $parent -gt 1 ]] && ! kill -0 $parent 2>/dev/null; then kill -TERM $$; fi; done )"
" 9>&- & permfix=$!")
# One launcher per game: Play pressed again while Lepton still boots (~10 s with nothing to see) made the second Lepton
# stop the first one's container, and both died (Vader Immortal, BattleSisters). The lock is held by Lepton's process
# (inherited fd), so it is released when the game ends.
SINGLE_LINE = ('exec 9>"$app_dir/.launch.lock"; flock -n 9 || '
'{ echo "$(date +%s) already starting or running: second launch ignored" >>"$app_dir/launch-dup.log"; '
'exit 0; }')
def dashboard_line():
@@ -1971,6 +1981,13 @@ def upgrade_launchers():
new = new.replace(OLD_WATCHDOG, WATCHDOG, 1)
if "_dashboard_worker" not in new and 'child=$!\nwait "$child"' in new:
new = new.replace('child=$!\nwait "$child"', 'child=$!\n' + dashboard_line() + '\nwait "$child"', 1)
guard = '[[ -d "$app_dir/lepton-app" ]] ||'
if ".launch.lock" not in new and guard in new:
i = new.index("\n", new.index(guard)) + 1
new = new[:i] + SINGLE_LINE + "\n" + new[i:]
# the lock belongs to Lepton only: helpers that outlive the game must not keep it
new = new.replace("; fi; done ) & permfix=$!", "; fi; done ) 9>&- & permfix=$!", 1)
new = new.replace('>"$app_dir/dashboard.log" 2>&1 &', '>"$app_dir/dashboard.log" 2>&1 9>&- &', 1)
if PLAYS_LOG not in new:
start, end = plays_lines(os.path.dirname(path))
if "\nsetsid " in new and "\n trap - EXIT INT TERM\n" in new: # Lepton launcher
@@ -1994,7 +2011,7 @@ def write_launcher(anchor, base, pkg, title, appid, lepton, env):
if re.fullmatch(r"[A-Z_][A-Z0-9_]*", k))
text = LAUNCH_SH.format(title=title.replace("\n", " "), pkg=pkg, base_q=shlex.quote(base), appid=appid,
lepton_q=shlex.quote(lepton), extra_env=extra, watchdog=WATCHDOG,
dashboard=dashboard_line(), plays_start=plays_lines(anchor)[0],
dashboard=dashboard_line(), single=SINGLE_LINE, plays_start=plays_lines(anchor)[0],
plays_end=plays_lines(anchor)[1])
path = os.path.join(anchor, "launch.sh")
with open(path + ".tmp", "w") as f:
+2 -2
View File
@@ -5,10 +5,10 @@ b5949e9e90e4c51e2733b9f6cd3c857a447552ac7291aec340407c27b034b600 ./arm64-v8a/li
383054f8b3b41dde76d062c71856cd3163655e50009c08454bc1793f393ead65 ./arm64-v8a/libfpg.so
424d8e5cb15608ad627f9415df4c4665efd9358fed3447a5d3765279dc652f6b ./arm64-v8a/libframe_xrshim.so
3bfd1261688ed766c0adb9172f44dd7d41cc6459f26205ce3069f536a52e4ab9 ./arm64-v8a/libglshim.so
899b76382e6d10fe2b594b40fed369d03808a1f3ec7a18ccc05f420c11056aeb ./arm64-v8a/libopenxr_loader_generic.so
a93928879af7b930f19c8c0a037a52711e194d2aacafea0d6d1de9809de76228 ./arm64-v8a/libopenxr_loader_generic.so
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
32525cd0a9ee3d9993d8871cd6905bdcf22cde4f4ba28f5db6434ce7d6554758 ./arm64-v8a/libvrapi.so
79955f386b4e8d43b36b052f9f838e85310c4234403c31e1b2b3f2e914aa4627 ./armeabi-v7a/libopenxr_loader_generic.so
49513211094cdaaa02f81f0101fa497da38979499cc8533e3f4bd0b71f6a3f85 ./armeabi-v7a/libopenxr_loader_generic.so
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json
28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.so
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,20 @@
package: com.ILMxLAB.VaderImmortal.ep1
title: 'Vader Immortal: Episode I'
status: issues
notes: Starts in VR and plays the intro, then stays on the loading card (Vader's portrait with a progress bar).
details: Unreal Engine 4 (GLES). After the Lucasfilm intro the game shows its loading card and never loads the next
scene; it keeps rendering at 72 fps and ignores input. Ruled out so far - Meta platform requests (all answered),
video playback (the intro is a video and plays), the no-ForceQuit build, frame and swapchain handling. The Frame's
SteamVR runtime also leaks about 20 MB of memory a second while the game runs, which slows it down after a few
minutes. Next steps - find what the loading code waits for in the game's engine library (no symbols) and report
the runtime leak to Valve. GitHub issue 49.
tested_version: 1.1.1+667256.cl.387770
engine: Unreal
xr: VrApi
alt_overport:
- patch_remove_unreal_force_quit
verified:
date: '2026-10-06'
issue: 49
updated: '2026-10-06'
source_hint: Vader Immortal- Episode I
@@ -0,0 +1,26 @@
package: com.PixelToys.BattleSisters
title: BattleSisters
status: works
notes: Starts in VR; controller buttons and vibration work.
details: Unity 2019.4 with Unity's built-in Oculus support. frame.unity_oculus_check starts VR (Meta's system-app check),
adds the frame wait its legacy loop never makes (without it the GPU hung, black screen) and lets Unity's Oculus
input accept Lepton's device model (it only reported controllers on a device named "Oculus ...", so buttons were
dead). haptic_fix stops the first controller vibration from freezing the Frame (OVRPort's loader read its duration
in nanoseconds as seconds and the game ran out of memory).
tested_version: 1.2.4
engine: Unity
xr: VrApi
frame:
- frame.unity_no_msaa
- frame.unity_oculus_check
- frame.unity_text_input
- frame.unity_runtime_msaa_off
- frame.unity_gl_shim
adapter:
haptic_fix: 1
verified:
date: '2026-10-06'
issue: 48
updated: '2026-10-06'
min_app: 0.11.1
source_hint: Battle Sister
+2
View File
@@ -14,6 +14,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| BARTENDER VR SIMULATOR | Quest | ✅ Works | |
| Batman: Arkham Shadow | Quest | ✅ Works | |
| BattleGlide | Quest | ✅ Works | |
| BattleSisters | Quest | ✅ Works | |
| Beat Saber | Quest | ✅ Works | |
| Beat Saber (co-existence build) | Quest | ✅ Works | |
| Blade & Sorcery: Nomad | Quest | ✅ Works | |
@@ -66,6 +67,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
| Time Stall | Quest | ⚠️ Works with issues | Both eyes distort during movement (unresolved). |
| Vader Immortal: Episode I | Quest | ⚠️ Works with issues | Starts in VR and plays the intro, then stays on the loading card (Vader's portrait with a progress bar). |
| WiiCompiled VR | Quest | ⚠️ Works with issues | To add a game: in FramePort's Files tab, upload your .wcgame file to this game's storage, folder Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR… |
| BONELAB | Quest | ❌ Doesn't run | Crashes while starting its graphics (Vulkan); no fix yet. |
| Espire 1: VR Operative (Quest Edition) | Quest | ❌ Doesn't run | Mesa GL driver crash during texture upload. |
-45
View File
@@ -28,21 +28,6 @@ xrAttachSessionActionSets:
.globl fwd_xrAttachSessionActionSets
.hidden fwd_xrAttachSessionActionSets
fwd_xrAttachSessionActionSets:
.quad frame_unsupported
.text
.p2align 2
.globl xrBeginFrame
.type xrBeginFrame, %function
xrBeginFrame:
adrp x16, fwd_xrBeginFrame
ldr x16, [x16, :lo12:fwd_xrBeginFrame]
br x16
.size xrBeginFrame, .-xrBeginFrame
.data
.p2align 3
.globl fwd_xrBeginFrame
.hidden fwd_xrBeginFrame
fwd_xrBeginFrame:
.quad frame_unsupported
.text
.p2align 2
@@ -403,21 +388,6 @@ xrPathToString:
.globl fwd_xrPathToString
.hidden fwd_xrPathToString
fwd_xrPathToString:
.quad frame_unsupported
.text
.p2align 2
.globl xrReleaseSwapchainImage
.type xrReleaseSwapchainImage, %function
xrReleaseSwapchainImage:
adrp x16, fwd_xrReleaseSwapchainImage
ldr x16, [x16, :lo12:fwd_xrReleaseSwapchainImage]
br x16
.size xrReleaseSwapchainImage, .-xrReleaseSwapchainImage
.data
.p2align 3
.globl fwd_xrReleaseSwapchainImage
.hidden fwd_xrReleaseSwapchainImage
fwd_xrReleaseSwapchainImage:
.quad frame_unsupported
.text
.p2align 2
@@ -523,20 +493,5 @@ xrSyncActions:
.globl fwd_xrSyncActions
.hidden fwd_xrSyncActions
fwd_xrSyncActions:
.quad frame_unsupported
.text
.p2align 2
.globl xrWaitSwapchainImage
.type xrWaitSwapchainImage, %function
xrWaitSwapchainImage:
adrp x16, fwd_xrWaitSwapchainImage
ldr x16, [x16, :lo12:fwd_xrWaitSwapchainImage]
br x16
.size xrWaitSwapchainImage, .-xrWaitSwapchainImage
.data
.p2align 3
.globl fwd_xrWaitSwapchainImage
.hidden fwd_xrWaitSwapchainImage
fwd_xrWaitSwapchainImage:
.quad frame_unsupported
.section .note.GNU-stack,"",%progbits
-3
View File
@@ -1,7 +1,6 @@
// Generated from overport 3.4.3 libopenxr_loader_generic.so exports minus hooked functions.
FORWARD(xrApplyHapticFeedback)
FORWARD(xrAttachSessionActionSets)
FORWARD(xrBeginFrame)
FORWARD(xrBeginSession)
FORWARD(xrCreateAction)
FORWARD(xrCreateActionSet)
@@ -26,7 +25,6 @@ FORWARD(xrGetReferenceSpaceBoundsRect)
FORWARD(xrGetSystem)
FORWARD(xrGetViewConfigurationProperties)
FORWARD(xrPathToString)
FORWARD(xrReleaseSwapchainImage)
FORWARD(xrRequestExitSession)
FORWARD(xrResultToString)
FORWARD(xrStopHapticFeedback)
@@ -34,4 +32,3 @@ FORWARD(xrStringToPath)
FORWARD(xrStructureTypeToString)
FORWARD(xrSuggestInteractionProfileBindings)
FORWARD(xrSyncActions)
FORWARD(xrWaitSwapchainImage)
-51
View File
@@ -34,23 +34,6 @@ xrAttachSessionActionSets:
.globl fwd_xrAttachSessionActionSets
.hidden fwd_xrAttachSessionActionSets
fwd_xrAttachSessionActionSets:
.word frame_unsupported
.text
.p2align 2
.globl xrBeginFrame
.type xrBeginFrame, %function
xrBeginFrame:
ldr ip, 1f
2: add ip, pc, ip
ldr ip, [ip]
bx ip
1: .word fwd_xrBeginFrame - (2b + 8)
.size xrBeginFrame, .-xrBeginFrame
.data
.p2align 2
.globl fwd_xrBeginFrame
.hidden fwd_xrBeginFrame
fwd_xrBeginFrame:
.word frame_unsupported
.text
.p2align 2
@@ -459,23 +442,6 @@ xrPathToString:
.globl fwd_xrPathToString
.hidden fwd_xrPathToString
fwd_xrPathToString:
.word frame_unsupported
.text
.p2align 2
.globl xrReleaseSwapchainImage
.type xrReleaseSwapchainImage, %function
xrReleaseSwapchainImage:
ldr ip, 1f
2: add ip, pc, ip
ldr ip, [ip]
bx ip
1: .word fwd_xrReleaseSwapchainImage - (2b + 8)
.size xrReleaseSwapchainImage, .-xrReleaseSwapchainImage
.data
.p2align 2
.globl fwd_xrReleaseSwapchainImage
.hidden fwd_xrReleaseSwapchainImage
fwd_xrReleaseSwapchainImage:
.word frame_unsupported
.text
.p2align 2
@@ -595,22 +561,5 @@ xrSyncActions:
.globl fwd_xrSyncActions
.hidden fwd_xrSyncActions
fwd_xrSyncActions:
.word frame_unsupported
.text
.p2align 2
.globl xrWaitSwapchainImage
.type xrWaitSwapchainImage, %function
xrWaitSwapchainImage:
ldr ip, 1f
2: add ip, pc, ip
ldr ip, [ip]
bx ip
1: .word fwd_xrWaitSwapchainImage - (2b + 8)
.size xrWaitSwapchainImage, .-xrWaitSwapchainImage
.data
.p2align 2
.globl fwd_xrWaitSwapchainImage
.hidden fwd_xrWaitSwapchainImage
fwd_xrWaitSwapchainImage:
.word frame_unsupported
.section .note.GNU-stack,"",%progbits
+144 -1
View File
@@ -86,6 +86,18 @@ static int foveation_fix = 1;
// hide XR_FB_space_warp: games then render every frame themselves (UE5 Application SpaceWarp flickered on the Frame,
// e.g. Into The Radius 2; OVRPort's patch_disable_space_warp doesn't reach UE5's OpenXR plugin)
static int hide_space_warp = 0;
static int strip_color_bias = 0;
static int snapshot = 0; // seconds between eye-image snapshots (snapshot_gl.c; 0 = off)
static char snap_dir[256];
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);
static void snap_on_acquire(XrSwapchain handle, uint32_t index);
static void snap_end_frame(const XrFrameEndInfo *info);
static int frame_balance = 0; // end a still-open frame before the next xrBeginFrame
static int frame_begins, frame_discarded, frame_ends, frame_balanced; // per pacing period (layer_debug)
static int frame_open;
static int sc_acquires, sc_waits, sc_releases, sc_wait_fails; // per pacing period (layer_debug) // drop XrCompositionLayerColorScaleBiasKHR (the runtime's color pass)
static int controller_fix = 1;
static int swapchain_fix = 1;
static int rect_clamp = 1;
@@ -136,6 +148,9 @@ static void read_settings(const char *path) {
if (sscanf(line, "scale=%f", &value) == 1 && value >= 0.5f && value <= 2.0f) scale = value;
if (sscanf(line, "foveation_fix=%f", &value) == 1) foveation_fix = value != 0;
if (sscanf(line, "hide_space_warp=%f", &value) == 1) hide_space_warp = value != 0;
if (sscanf(line, "strip_color_bias=%f", &value) == 1) strip_color_bias = (int)value;
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, "controller_fix=%f", &value) == 1) controller_fix = value != 0;
if (sscanf(line, "swapchain_fix=%f", &value) == 1) swapchain_fix = value != 0;
if (sscanf(line, "rect_clamp=%f", &value) == 1) rect_clamp = value != 0;
@@ -209,11 +224,17 @@ static void initialize(void) {
}
const char *env = getenv("FRAMEBRIDGE_CONFIG");
if (env && *env) read_settings(env);
if (snapshot && *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);
}
if (eye_debug && *process && !strchr(process, '/')) {
snprintf(path, sizeof(path), "/sdcard/Android/data/%s/files/framebridge.log", process);
log_file = fopen(path, "a");
}
if (hide_space_warp) LOG("per-game: hide_space_warp=1 (XR_FB_space_warp hidden, space warp info removed)");
if (strip_color_bias) LOG("per-game: strip_color_bias=%d (layer color scale/bias removed)", strip_color_bias);
if (frame_balance) LOG("per-game: frame_balance=1 (an open frame is ended before the next one begins)");
if (eye_debug || release_wait) LOG("per-game: eye_debug=%d release_wait=%d log_file=%s", eye_debug, release_wait,
log_file ? path : "none");
if (controller_models && *process && !strchr(process, '/')) render_model_init(process);
@@ -474,6 +495,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
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;
}
XrResult result = fn(session, &fixed, swapchain);
@@ -485,6 +507,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
note_swapchain_size(*swapchain, fixed.width, fixed.height);
flip_on_create_swapchain(*swapchain, &fixed);
emul_on_create_swapchain(*swapchain, &fixed);
snap_on_create(*swapchain, &fixed);
return result;
}
if (!swapchain_fix) return result;
@@ -508,6 +531,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
note_swapchain_size(*swapchain, fixed.width, fixed.height);
flip_on_create_swapchain(*swapchain, &fixed);
emul_on_create_swapchain(*swapchain, &fixed);
snap_on_create(*swapchain, &fixed);
}
return result;
}
@@ -515,10 +539,15 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
XRAPI_ATTR XrResult XRAPI_CALL xrDestroySwapchain(XrSwapchain swapchain) {
PFN_xrDestroySwapchain fn = (PFN_xrDestroySwapchain)lookup(active_instance, "xrDestroySwapchain");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
if (layer_debug) {
static int logged;
if (logged++ < 200) LOG("layer_debug: xrDestroySwapchain %p", (void *)swapchain);
}
forget_swapchain(swapchain);
surf_on_destroy(swapchain);
flip_on_destroy(swapchain);
emul_on_destroy_swapchain(swapchain);
snap_on_destroy(swapchain);
if (equirect_emul && emul_is_virtual(swapchain)) { emul_virtual_destroy(swapchain); return XR_SUCCESS; }
return fn(swapchain);
}
@@ -770,6 +799,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpace(XrSpace space) {
#include "session_fixes.c"
#include "layer_emul_gl.c"
#include "snapshot_gl.c"
#include "surface_swapchain.c"
#include "eye_debug.c"
@@ -796,10 +826,28 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSwapchainImages(XrSwapchain swapchain,
if (XR_SUCCEEDED(result) && images && capacity && count) {
flip_on_enumerate_images(swapchain, *count, images);
emul_on_enumerate(swapchain, *count < capacity ? *count : capacity, images);
snap_on_enumerate(swapchain, *count < capacity ? *count : capacity, images);
}
return result;
}
// counted for layer_debug (an app that skips the wait or release keeps runtime resources alive)
XRAPI_ATTR XrResult XRAPI_CALL xrWaitSwapchainImage(XrSwapchain swapchain, const XrSwapchainImageWaitInfo *info) {
PFN_xrWaitSwapchainImage fn = (PFN_xrWaitSwapchainImage)lookup(active_instance, "xrWaitSwapchainImage");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
XrResult result = fn(swapchain, info);
__atomic_add_fetch(&sc_waits, 1, __ATOMIC_RELAXED);
if (result != XR_SUCCESS) __atomic_add_fetch(&sc_wait_fails, 1, __ATOMIC_RELAXED);
return result;
}
XRAPI_ATTR XrResult XRAPI_CALL xrReleaseSwapchainImage(XrSwapchain swapchain, const XrSwapchainImageReleaseInfo *info) {
PFN_xrReleaseSwapchainImage fn = (PFN_xrReleaseSwapchainImage)lookup(active_instance, "xrReleaseSwapchainImage");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
__atomic_add_fetch(&sc_releases, 1, __ATOMIC_RELAXED);
return fn(swapchain, info);
}
XRAPI_ATTR XrResult XRAPI_CALL xrAcquireSwapchainImage(XrSwapchain swapchain, const XrSwapchainImageAcquireInfo *info,
uint32_t *index) {
PFN_xrAcquireSwapchainImage fn = (PFN_xrAcquireSwapchainImage)lookup(active_instance, "xrAcquireSwapchainImage");
@@ -811,7 +859,12 @@ XRAPI_ATTR XrResult XRAPI_CALL xrAcquireSwapchainImage(XrSwapchain swapchain, co
return XR_SUCCESS;
}
XrResult result = fn(swapchain, info, index);
if (XR_SUCCEEDED(result) && index) { flip_on_acquire(swapchain, *index); emul_on_acquire(swapchain, *index); }
__atomic_add_fetch(&sc_acquires, 1, __ATOMIC_RELAXED);
if (XR_SUCCEEDED(result) && index) {
flip_on_acquire(swapchain, *index);
emul_on_acquire(swapchain, *index);
snap_on_acquire(swapchain, *index);
}
return result;
}
@@ -1019,9 +1072,40 @@ XRAPI_ATTR XrResult XRAPI_CALL xrLocateViews(XrSession session, const XrViewLoca
return result;
}
// xrBeginFrame: counted for layer_debug. A game that begins a new frame while the previous one is still open
// (never ended) makes the runtime discard it; the Frame's runtime then never recycles that frame's GPU timing
// command buffer (Vader Immortal: ~430 GPU mappings / 20 MB a second). frame_balance ends the open frame first,
// with no layers, as the runtime expects.
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;
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
: last_predicted_time,
XR_ENVIRONMENT_BLEND_MODE_OPAQUE, 0, NULL};
XrResult r = end ? end(session, &empty) : XR_ERROR_FUNCTION_UNSUPPORTED;
static int logged;
if (logged++ < 3) LOG("frame_balance: ended an open frame before xrBeginFrame (result %d)", r);
__atomic_add_fetch(&frame_balanced, 1, __ATOMIC_RELAXED);
}
XrResult result = fn(session, info);
__atomic_add_fetch(&frame_begins, 1, __ATOMIC_RELAXED);
if (result == XR_FRAME_DISCARDED) __atomic_add_fetch(&frame_discarded, 1, __ATOMIC_RELAXED);
if (XR_SUCCEEDED(result)) {
frame_open = 1;
last_begin_display_time = last_predicted_time;
}
return result;
}
XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInfo *info) {
PFN_xrEndFrame fn = (PFN_xrEndFrame)lookup(active_instance, "xrEndFrame");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
frame_open = 0;
__atomic_add_fetch(&frame_ends, 1, __ATOMIC_RELAXED);
snap_end_frame(info);
if (eye_debug) eye_debug_end_frame(session, info);
{ // frame pacing statistics every ~5 s: fps and submitted-vs-predicted display time
static struct timespec start;
@@ -1038,6 +1122,18 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
if (elapsed >= 5.0) {
LOG("pacing: %.1f fps, displayTime vs predicted: avg %.2f ms, max %.2f ms", frames / elapsed,
drift_sum / (double)frames / 1e6, drift_max / 1e6);
if (layer_debug)
LOG("layer_debug: swapchain images: %d acquired, %d waited (%d not ok), %d released",
__atomic_exchange_n(&sc_acquires, 0, __ATOMIC_RELAXED),
__atomic_exchange_n(&sc_waits, 0, __ATOMIC_RELAXED),
__atomic_exchange_n(&sc_wait_fails, 0, __ATOMIC_RELAXED),
__atomic_exchange_n(&sc_releases, 0, __ATOMIC_RELAXED));
if (layer_debug)
LOG("layer_debug: frames: %d xrBeginFrame (%d discarded), %d xrEndFrame, %d balanced",
__atomic_exchange_n(&frame_begins, 0, __ATOMIC_RELAXED),
__atomic_exchange_n(&frame_discarded, 0, __ATOMIC_RELAXED),
__atomic_exchange_n(&frame_ends, 0, __ATOMIC_RELAXED),
__atomic_exchange_n(&frame_balanced, 0, __ATOMIC_RELAXED));
if (layer_debug)
LOG("layer_debug: input: %d xrSyncActions (%d ok, last %d), %d bool reads, %d pressed",
__atomic_exchange_n(&input_syncs, 0, __ATOMIC_RELAXED),
@@ -1171,6 +1267,48 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
if (!logged++) LOG("strip_depth: removed depth info from projection layer");
++swapped;
}
{
// XR_KHR_composition_layer_color_scale_bias (1000034000) on the layer itself: logged when its values
// change (layer_debug); with strip_color_bias the runtime never gets it (Vader Immortal: the Frame's
// runtime allocates GPU memory for it every frame and never frees it)
const XrBaseInStructure *head = (const XrBaseInStructure *)layer->next;
const XrCompositionLayerColorScaleBiasKHR *cb = NULL;
for (const XrBaseInStructure *n = head; n; n = n->next)
if (n->type == XR_TYPE_COMPOSITION_LAYER_COLOR_SCALE_BIAS_KHR) cb = (const XrCompositionLayerColorScaleBiasKHR *)n;
if (cb && layer_debug) {
static float last[8] = {-1};
float now[8] = {cb->colorScale.r, cb->colorScale.g, cb->colorScale.b, cb->colorScale.a,
cb->colorBias.r, cb->colorBias.g, cb->colorBias.b, cb->colorBias.a};
static int logged;
if (memcmp(now, last, sizeof(now)) && logged++ < 100) {
LOG("layer_debug: layer type=%d color scale %.2f %.2f %.2f %.2f bias %.2f %.2f %.2f %.2f", layer->type,
now[0], now[1], now[2], now[3], now[4], now[5], now[6], now[7]);
memcpy(last, now, sizeof(now));
}
}
if (cb && strip_color_bias && (layer->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION ||
layer->type == XR_TYPE_COMPOSITION_LAYER_QUAD)) {
const XrBaseInStructure *rest = head; // skip leading color structs; one further down drops the chain
while (rest && rest->type == XR_TYPE_COMPOSITION_LAYER_COLOR_SCALE_BIAS_KHR) rest = rest->next;
for (const XrBaseInStructure *n = rest; n; n = n->next)
if (n->type == XR_TYPE_COMPOSITION_LAYER_COLOR_SCALE_BIAS_KHR) rest = NULL;
if (strip_color_bias > 1) rest = NULL; // 2: the layer's whole extension chain (diagnostics)
if (layer->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION) {
if (layer != (const XrCompositionLayerBaseHeader *)&projections[count])
projections[count] = *(const XrCompositionLayerProjection *)layer;
projections[count].next = rest;
layer = (const XrCompositionLayerBaseHeader *)&projections[count];
} else {
if (layer != (const XrCompositionLayerBaseHeader *)&quads[count])
quads[count] = *(const XrCompositionLayerQuad *)layer;
quads[count].next = rest;
layer = (const XrCompositionLayerBaseHeader *)&quads[count];
}
static int logged;
if (!logged++) LOG("strip_color_bias: removed the color scale/bias from a layer");
++swapped;
}
}
if (hide_space_warp && layer->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION &&
((const XrCompositionLayerProjection *)layer)->viewCount == 2) {
// The game may still attach XrCompositionLayerSpaceWarpInfoFB (1000171000) to its views: OVRPort's
@@ -1464,6 +1602,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
HOOK(xrCreateSwapchain)
HOOK(xrDestroySwapchain)
HOOK(xrEndFrame)
HOOK(xrBeginFrame)
HOOK(xrLocateHandJointsEXT)
HOOK(xrGetCurrentInteractionProfile)
HOOK(xrEnumerateInstanceExtensionProperties)
@@ -1480,6 +1619,10 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
}
if (surface_emul) HOOK_AS(xrCreateSwapchainAndroidSurfaceKHR, hook_xrCreateSwapchainAndroidSurfaceKHR)
if (eye_debug || release_wait) HOOK_AS(xrReleaseSwapchainImage, eye_hook_xrReleaseSwapchainImage)
if (layer_debug) { // counters only; every other wait/release hook above takes precedence
HOOK(xrWaitSwapchainImage)
HOOK(xrReleaseSwapchainImage)
}
if (sync_guard || layer_debug) HOOK_AS(xrSyncActions, hook_xrSyncActions)
if (layer_debug) HOOK_AS(xrGetActionStateBoolean, hook_xrGetActionStateBoolean)
if (layer_debug || aim_correction_on() || profile_remap)
+173
View File
@@ -0,0 +1,173 @@
// FramePort FrameBridge: eye-image snapshots for headless diagnostics (setting snapshot=<seconds>, GLES games only).
// Every <seconds>, at xrEndFrame, the left-eye image the game just submitted (projection layer, view 0: its swapchain
// image and array layer) is read back on the game's own GL context and written at a quarter of its size as
// /sdcard/Android/data/<package>/files/fb_snap_<n>.ppm (n = 0-7, the oldest is overwritten). Nobody wears the headset
// in a launch test, so the headset view stays black: this shows what the game itself draws (e.g. which screen it is
// stuck on). The game's framebuffer, pack-buffer and pack-alignment bindings are restored afterwards.
#define SNAP_MAX_SWAPCHAINS 16
#define SNAP_MAX_IMAGES 8
#define SNAP_SCALE 4
#define SNAP_GL_FUNCS(X) X(glGenFramebuffers) X(glDeleteFramebuffers) X(glBindFramebuffer) X(glFramebufferTexture2D) \
X(glFramebufferTextureLayer) X(glCheckFramebufferStatus) X(glReadPixels) X(glGetIntegerv) X(glPixelStorei) \
X(glBindBuffer) X(glGetError)
#define SNAP_DECLARE(fn) static __typeof__(&fn) s_##fn;
SNAP_GL_FUNCS(SNAP_DECLARE)
#undef SNAP_DECLARE
static EGLContext (*s_eglGetCurrentContext)(void);
static struct {
XrSwapchain handle;
uint32_t width, height, array_size, count, last_acquired;
GLuint images[SNAP_MAX_IMAGES];
} snap_chains[SNAP_MAX_SWAPCHAINS];
static pthread_mutex_t snap_lock = PTHREAD_MUTEX_INITIALIZER;
static int snap_load_gl(void) {
static int state; // 0 untried, 1 ok, -1 failed
if (state) return state > 0;
void *egl = dlopen("libEGL.so", RTLD_NOW | RTLD_LOCAL), *gles = dlopen("libGLESv3.so", RTLD_NOW | RTLD_LOCAL);
int ok = egl && gles;
if (ok) s_eglGetCurrentContext = (EGLContext (*)(void))dlsym(egl, "eglGetCurrentContext");
#define SNAP_RESOLVE(fn) ok &= gles && (s_##fn = (__typeof__(s_##fn))dlsym(gles, #fn)) != NULL;
SNAP_GL_FUNCS(SNAP_RESOLVE)
#undef SNAP_RESOLVE
state = ok && s_eglGetCurrentContext ? 1 : -1;
if (state < 0) LOG("snapshot: OpenGL ES not available, no snapshots");
return state > 0;
}
static int snap_find(XrSwapchain handle, int create) {
for (int i = 0; i < SNAP_MAX_SWAPCHAINS; ++i)
if (snap_chains[i].handle == handle) return i;
if (!create) return -1;
for (int i = 0; i < SNAP_MAX_SWAPCHAINS; ++i)
if (!snap_chains[i].handle) { snap_chains[i].handle = handle; snap_chains[i].count = 0; return i; }
return -1;
}
static void snap_on_create(XrSwapchain handle, const XrSwapchainCreateInfo *info) {
if (!snapshot || !info) return;
pthread_mutex_lock(&snap_lock);
int i = snap_find(handle, 1);
if (i >= 0) {
snap_chains[i].width = info->width;
snap_chains[i].height = info->height;
snap_chains[i].array_size = info->arraySize ? info->arraySize : 1;
}
pthread_mutex_unlock(&snap_lock);
}
static void snap_on_destroy(XrSwapchain handle) {
if (!snapshot) return;
pthread_mutex_lock(&snap_lock);
int i = snap_find(handle, 0);
if (i >= 0) snap_chains[i].handle = XR_NULL_HANDLE;
pthread_mutex_unlock(&snap_lock);
}
static void snap_on_enumerate(XrSwapchain handle, uint32_t count, const XrSwapchainImageBaseHeader *images) {
if (!snapshot || !images || !count || images->type != (XrStructureType)1000024002) return; // OPENGL_ES_KHR
pthread_mutex_lock(&snap_lock);
int i = snap_find(handle, 1);
if (i >= 0) {
uint32_t n = count < SNAP_MAX_IMAGES ? count : SNAP_MAX_IMAGES;
const emul_gles_image *gl = (const emul_gles_image *)images;
for (uint32_t k = 0; k < n; ++k) snap_chains[i].images[k] = gl[k].image;
snap_chains[i].count = n;
}
pthread_mutex_unlock(&snap_lock);
}
static void snap_on_acquire(XrSwapchain handle, uint32_t index) {
if (!snapshot) return;
pthread_mutex_lock(&snap_lock);
int i = snap_find(handle, 0);
if (i >= 0) snap_chains[i].last_acquired = index;
pthread_mutex_unlock(&snap_lock);
}
static void snap_write(const unsigned char *rgba, uint32_t w, uint32_t h, int n) {
char path[320];
snprintf(path, sizeof(path), "%s/fb_snap_%d.ppm", snap_dir, n);
FILE *f = fopen(path, "wb");
if (!f) { LOG("snapshot: can't write %s", path); return; }
uint32_t ow = w / SNAP_SCALE, oh = h / SNAP_SCALE;
fprintf(f, "P6\n%u %u\n255\n", ow, oh);
unsigned char *row = malloc((size_t)ow * 3);
for (uint32_t y = 0; row && y < oh; ++y) {
const unsigned char *src = rgba + (size_t)(h - 1 - y * SNAP_SCALE) * w * 4; // GL rows are bottom-up
for (uint32_t x = 0; x < ow; ++x) {
const unsigned char *p = src + (size_t)x * SNAP_SCALE * 4;
row[x * 3] = p[0], row[x * 3 + 1] = p[1], row[x * 3 + 2] = p[2];
}
fwrite(row, 1, (size_t)ow * 3, f);
}
free(row);
fclose(f);
LOG("snapshot: %s (%ux%u)", path, ow, oh);
}
static void snap_end_frame(const XrFrameEndInfo *info) {
if (!snapshot || !*snap_dir || !info) return;
static int64_t next_ns;
static int taken;
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
int64_t t = (int64_t)now.tv_sec * 1000000000ll + now.tv_nsec;
if (t < next_ns) return;
next_ns = t + (int64_t)snapshot * 1000000000ll;
const XrCompositionLayerProjection *proj = NULL;
for (uint32_t i = 0; i < info->layerCount && !proj; ++i)
if (info->layers[i] && info->layers[i]->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION &&
((const XrCompositionLayerProjection *)info->layers[i])->viewCount)
proj = (const XrCompositionLayerProjection *)info->layers[i];
if (!proj || !snap_load_gl()) return;
if (!s_eglGetCurrentContext()) {
static int logged;
if (!logged++) LOG("snapshot: no GL context current in xrEndFrame, no snapshot");
return;
}
const XrSwapchainSubImage *sub = &proj->views[0].subImage;
pthread_mutex_lock(&snap_lock);
int i = snap_find(sub->swapchain, 0);
GLuint tex = 0;
uint32_t w = 0, h = 0, array_size = 1;
if (i >= 0 && snap_chains[i].count && snap_chains[i].last_acquired < snap_chains[i].count) {
tex = snap_chains[i].images[snap_chains[i].last_acquired];
w = snap_chains[i].width, h = snap_chains[i].height, array_size = snap_chains[i].array_size;
}
pthread_mutex_unlock(&snap_lock);
if (!tex || !w || !h) return;
GLint read_fb = 0, pack_buffer = 0, pack_alignment = 4;
s_glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &read_fb);
s_glGetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, &pack_buffer);
s_glGetIntegerv(GL_PACK_ALIGNMENT, &pack_alignment);
GLuint fb = 0;
s_glGenFramebuffers(1, &fb);
s_glBindFramebuffer(GL_READ_FRAMEBUFFER, fb);
if (array_size > 1)
s_glFramebufferTextureLayer(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, tex, 0, (GLint)sub->imageArrayIndex);
else
s_glFramebufferTexture2D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0);
unsigned char *rgba = NULL;
if (s_glCheckFramebufferStatus(GL_READ_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE &&
(rgba = malloc((size_t)w * h * 4))) {
s_glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
s_glPixelStorei(GL_PACK_ALIGNMENT, 1);
s_glReadPixels(0, 0, (GLsizei)w, (GLsizei)h, GL_RGBA, GL_UNSIGNED_BYTE, rgba);
} else {
static int logged;
if (logged++ < 3) LOG("snapshot: eye image %u not readable (framebuffer incomplete)", tex);
}
s_glPixelStorei(GL_PACK_ALIGNMENT, pack_alignment);
s_glBindBuffer(GL_PIXEL_PACK_BUFFER, (GLuint)pack_buffer);
s_glBindFramebuffer(GL_READ_FRAMEBUFFER, (GLuint)read_fb);
s_glDeleteFramebuffers(1, &fb);
while (s_glGetError() != GL_NO_ERROR) {} // our calls must not leave errors for the game to find
if (rgba) {
snap_write(rgba, w, h, taken++ % 8);
free(rgba);
}
}
+6
View File
@@ -79,6 +79,12 @@ company = "FramePort"
path = "src"
module = "main"
# jni_flutter (1.0.4 and 1.0.4+1, 2026-10) generates bindings that require jni ^1.1.0, while Flet's build template pins
# jni 1.0.0: every bundle failed ("generated bindings expect package:jni ^1.1.0"). jni 1.1 also satisfies bindings
# made for 1.0 (the check is "same major, at least this minor"). Remove once Flet's template moves to jni 1.1.
[tool.flet.flutter.pubspec.dependency_overrides]
jni = "1.1.0"
[tool.flet.macos]
entitlement."com.apple.security.files.user-selected.read-write" = true
+1 -1
View File
@@ -1,2 +1,2 @@
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
__version__ = "0.11.0"
__version__ = "0.11.1"
+4
View File
@@ -306,6 +306,7 @@
"Download…": "",
"Draws each eye separately; try it when one eye shows a grey or broken picture.": "",
"Drop invalid layers": "",
"Drop layer color fades": "",
"Drop layers whose swapchain failed or whose extension isn't enabled.": "",
"Drop to upload": "",
"Drops Quest-only shared library requirements.": "",
@@ -317,6 +318,7 @@
"Environment variables for the Proton launcher on the Frame (e.g. DXVK_HUD=fps), one KEY=value per line.": "",
"Every 'local' play space the game creates lines up with the one at the start. For games whose menus or screens jump to where you look on the Frame.": "",
"Every game start on the Frame used to leak one kernel key, and after about 200 launches every game fails to start. FramePort switches that leak off, but keys already used only come back after a restart of the Frame.": "",
"Every N seconds, saves the left-eye image the game submits (a quarter of its size) as fb_snap_0-7.ppm in the game's files folder on the Frame. Launch tests run without anyone wearing the headset, so the headset view stays black; this shows what the game itself draws. OpenGL ES games only.": "",
"Every uploaded file is present with the right size.": "",
"everything in ~ (not seen by games)": "",
"Everything is up to date": "",
@@ -328,6 +330,7 @@
"Extra Lepton environment": "",
"Extra settings for Proton (for testing).": "",
"Extra settings for the Android container (for testing).": "",
"Eye-image snapshots (diagnostics)": "",
"failed": "",
"Failed": "",
"Failed: {error}": "",
@@ -808,6 +811,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, e.g. Vader Immortal leaked ~20 MB/s on its loading screen. 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 the previous version kept after each reinstall and any leftover uploads. Games and saves aren't touched.": "",
"Removes {title} from this PC's Steam library (Steam restarts once). The game folder isn't touched.": "",
@@ -27,6 +27,46 @@ INPUT_CALLS = ("ovrp_GetConnectedControllers", "ovrp_GetControllerState4", "ovrp
UNITY_INPUT_CALLS = ("ovrp_GetControllerState", "ovrp_GetControllerState2")
ORR_W2_6 = 0x321F07E2 # orr w2, wzr, #6 (strncmp length 6)
MOV_W2_0 = 0x52800002 # mov w2, #0 (strncmp length 0: always equal)
def oculus_model_checks(data: bytes) -> tuple[bytes, int]:
"""Unity's built-in Oculus input only reports controllers on a device whose model starts with "Oculus"
(strncmp(SystemInfo.deviceModel, "Oculus", 6), next to its Oculus Go check "Oculus Pacific"). Lepton's Android is
"Valve Lepton", so BattleSisters got no controller buttons. Every such compare (the literal loaded with adrp/add
right before `orr w2, wzr, #6`) gets length 0, so any device but the Go counts as an Oculus one. The "Oculus"
string itself stays: Unity also uses it as its VR device name."""
import struct
literal = data.find(b"\0Oculus\0") + 1
if literal <= 0:
return data, 0
buf, count = bytearray(data), 0
end = len(data) - len(data) % 4
for at in range(0, end, 4):
if struct.unpack_from("<I", data, at)[0] != ORR_W2_6:
continue
# look back for `adrp xA, page` + `add x1, xA, #off` that computes the literal's address
for back in range(4, 16, 4):
add = struct.unpack_from("<I", data, at - back)[0] if at >= back else 0
if add & 0xFFC00000 != 0x91000000 or add & 0x1F != 1: # add x1, xN, #imm (no shift)
continue
rn, imm12 = (add >> 5) & 0x1F, (add >> 10) & 0xFFF
adrp_at = at - back - 4
adrp = struct.unpack_from("<I", data, adrp_at)[0] if adrp_at >= 0 else 0
if adrp & 0x9F000000 != 0x90000000 or adrp & 0x1F != rn:
continue
imm = ((adrp >> 5) & 0x7FFFF) << 2 | (adrp >> 29) & 3
if imm & (1 << 20):
imm -= 1 << 21
if (adrp_at & ~0xFFF) + (imm << 12) + imm12 == literal:
struct.pack_into("<I", buf, at, MOV_W2_0)
count += 1
break
return bytes(buf), count
class UnityOculusCheck(Patch):
id = "frame.unity_oculus_check"
title = "Unity: start VR without Meta's system apps"
@@ -40,8 +80,9 @@ class UnityOculusCheck(Patch):
"mouse click (Input.GetMouseButtonDown), which Go-era screens wait for (e.g. Accounting+'s motion "
"warning) and which Lepton never delivers.")
order = 45
# 2: frame wait also for Unity 2019 without the Oculus XR Plugin; 3: controller presses as mouse clicks (ovrpshim)
revision = 3
# 2: frame wait also for Unity 2019 without the Oculus XR Plugin; 3: controller presses as mouse clicks
# (ovrpshim); 4: Unity 2019's Oculus device-model checks (Touch controllers on Lepton)
revision = 4
@staticmethod
def _major(a) -> int:
@@ -76,7 +117,10 @@ class UnityOculusCheck(Patch):
return False
data, loops = elf.replace_rodata_string(data, UPDATE, SHIM_UPDATE) if self.legacy_loop(ctx.analysis) \
else (data, 0)
if loops:
if loops and self._major(ctx.analysis) >= 2019: # Accounting+ (2017) reads OVRInput: works without
data, models = oculus_model_checks(data)
if models:
ctx.notes.append(f"libunity.so: {models} Oculus device-model checks accept Lepton's model")
data, nodes = elf.replace_rodata_string(data, NODE, SHIM_NODE)
if nodes:
ctx.notes.append(f"libunity.so: {NODE} -> {SHIM_NODE}")
+13 -1
View File
@@ -16,6 +16,14 @@ SETTINGS = [
("scale", "float", 1.0, "Resolution scale",
"Multiplies the recommended eye-buffer width and height (0.5–2.0). 1.5 ≈ 2.25× pixels."),
("foveation_fix", "int", 1, "Hide Quest foveation", "Hides Quest foveation extensions the Frame runtime lacks."),
("snapshot", "int", 0, "Eye-image snapshots (diagnostics)",
"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."),
("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, e.g. Vader "
"Immortal leaked ~20 MB/s on its loading screen. Fades done this way no longer show."),
("hide_space_warp", "int", 0, "Turn off space warp",
"Hides XR_FB_space_warp, so the game renders every frame itself instead of half of them plus motion vectors "
"(Application SpaceWarp). For games whose picture flickers or smears on the Frame (e.g. Unreal Engine 5 games "
@@ -255,7 +263,7 @@ UI: dict[str, dict] = {
control=("choice", [(0, "As the game sends it"), (2, "Flat")])),
**{key: dict(group="troubleshooting", level="advanced", control=("switch",)) for key in (
"foveation_fix", "hide_space_warp", "swapchain_fix", "layer_fix", "gl_hide_multiview", "mutable_fix",
"flip_quads", "swap_eyes", "vk_validation", "rect_clamp", "gl_hide_msrtt",
"flip_quads", "swap_eyes", "vk_validation", "rect_clamp", "gl_hide_msrtt", "strip_color_bias", "snapshot",
"strip_depth", "respace_kick", "layer_debug", "eye_debug", "release_wait")},
}
@@ -282,6 +290,10 @@ class AdapterSetting(Patch):
if self.key == "controller_models" and uses_render_models(a):
return Suggestion(True, "The game asks the headset for its controller models: show Steam Frame controllers "
"instead of Quest Touch controllers.", {"value": 1})
if self.key == "haptic_fix" and "libOVRPlugin.so" in a.libs:
return Suggestion(True, "Meta's OVRPlugin vibrates controllers through OVRPort's loader, which turns some "
"vibrations into gigabyte allocations that freeze the Frame (e.g. Lucky's Tale, "
"BattleSisters): vibrations are converted before they reach it.", {"value": 1})
if self.key == "equirect_emul" and uses_equirect_layers(a):
return Suggestion(True, "The game draws 360° layers (e.g. a video player's theatre or 360° videos), which "
"the Frame's runtime can't show: show them as panels around you.", {"value": 1})
+50
View File
@@ -1363,3 +1363,53 @@ def test_launch_registers_a_lost_devkit_entry_again(monkeypatch, tmp_path):
out = a.cmd_launch({"package": "com.camouflaj.manta"})
assert registered == ["com.camouflaj.manta"] and launches == [222, 333]
assert out["via"] == "devkit" and out["steam"]["result"] == "started"
@pytest.mark.skipif(sys.platform == "win32" or not __import__("shutil").which("flock"), reason="bash + flock")
def test_second_launch_while_starting_is_ignored(monkeypatch, tmp_path):
"""Play pressed again while Lepton still boots made the second Lepton stop the first one's container (both died:
Vader Immortal, BattleSisters). The launcher holds a lock for the game's lifetime; a second launch leaves it."""
import time
a = load_agent(monkeypatch, tmp_path)
base = tmp_path / "game"
(base / "lepton-app").mkdir(parents=True)
lepton = tmp_path / "lepton"
lepton.write_text(f"#!/bin/bash\necho started >>{tmp_path}/starts\nsleep ${{FAKE_RUN:-30}}\n")
lepton.chmod(0o755)
text = a.LAUNCH_SH.format(title="T", pkg="com.x.y", base_q=str(base), appid=1, lepton_q=str(lepton), extra_env="",
watchdog=a.WATCHDOG, dashboard="true", single=a.SINGLE_LINE, plays_start="true",
plays_end="true")
launcher = tmp_path / "launch.sh"
launcher.write_text(text)
launcher.chmod(0o755)
first = subprocess.Popen([str(launcher)], start_new_session=True)
try:
for _ in range(100):
if (tmp_path / "starts").exists():
break
time.sleep(0.05)
second = subprocess.run([str(launcher)], timeout=10)
assert second.returncode == 0 and first.poll() is None # ignored, the first keeps running
assert (tmp_path / "starts").read_text().count("started") == 1
assert "second launch ignored" in (base / "launch-dup.log").read_text()
finally:
os.killpg(first.pid, 15)
first.wait(timeout=10)
third = subprocess.run([str(launcher)], timeout=20, env={**os.environ, "FAKE_RUN": "0"})
assert third.returncode == 0 and (tmp_path / "starts").read_text().count("started") == 2 # free again
def test_upgrade_launchers_adds_the_single_launch_lock(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
anchor = tmp_path / "Applications/quest-frame/com.x.y"
anchor.mkdir(parents=True)
old = ('#!/bin/bash\napp_dir=/x\n[[ -d "$app_dir/lepton-app" ]] || { exit 1; }\nfix_perms\nparent=$PPID\n'
'( while sleep 2; do fix_perms; if true; then kill -TERM $$; fi; done ) & permfix=$!\nexport SteamAppId=1\n')
(anchor / "launch.sh").write_text(old)
a.upgrade_launchers()
text = (anchor / "launch.sh").read_text()
assert text.index(".launch.lock") < text.index("fix_perms") and "done ) 9>&- & permfix=$!" in text
assert text.count(".launch.lock") == 1
a.upgrade_launchers()
assert (anchor / "launch.sh").read_text().count(".launch.lock") == 1
+8
View File
@@ -510,3 +510,11 @@ def test_start_activity_after_overport_conversion(tmp_path):
ctx = base.ApkContext(ws, a, {}, Reporter(), {"frame.start_activity": {}})
assert patch.apply(ctx)
assert patch.validate(ctx) == [("Only the VR activity is a launcher", True, "org.x.game.QuestActivity")]
def test_haptic_fix_suggested_for_ovrplugin_games():
base.load_all()
p = base.REGISTRY["adapter.haptic_fix"]
s = p.detect(_analysis(libs=["libunity.so", "libOVRPlugin.so"]))
assert s and s.recommended and s.params == {"value": 1}
assert p.detect(_analysis(libs=["libunity.so"])) is None
+18
View File
@@ -273,3 +273,21 @@ def test_user_recipes_get_a_catalog_update_offer(tmp_path, monkeypatch):
assert "catalog_update" not in g and g["recipe"]["patches"]["adapter.scale"] == {"value": 0.9}
assert g["recipe"].get("source") != "user" and "adapter.vk_shader_fix" in g["recipe"]["patches"]
def test_oculus_model_checks_accept_any_device_but_the_go():
import struct
from frameport.patches.frame import unity_oculus_check as C
# .text: adrp x1, page(0x1000); add x1, x1, #0x10; orr w2, wzr, #6; bl ... -> strncmp(model, "Oculus", 6)
code = struct.pack("<4I", 0xB0000001, 0x91004021, C.ORR_W2_6, 0x94000000)
other = struct.pack("<4I", 0xB0000001, 0x91008021, C.ORR_W2_6, 0x94000000) # a different string: untouched
data = bytearray(0x2000)
data[0:16], data[16:32] = code, other
data[0x100F:0x1017] = b"\0Oculus\0" # the literal at 0x1010
data[0x1020:0x1028] = b"OculusX\0"
out, n = C.oculus_model_checks(bytes(data))
assert n == 1
assert struct.unpack_from("<I", out, 8)[0] == C.MOV_W2_0 and struct.unpack_from("<I", out, 24)[0] == C.ORR_W2_6
assert out[0x1010:0x1016] == b"Oculus" # the string itself stays (Unity's VR device name)