mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-17 20:01:23 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2103d9fb5 |
No files matched your search
@@ -274,15 +274,6 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
`working-config.yml` issue → maintainer label `catalog-accepted` (a plain issue needs `working-config` added by
|
||||
hand) → `catalog-from-issue.yml` workflow (`scripts/catalog_from_issue.py` validates) opens a catalog PR. App log:
|
||||
`core/applog.py` (`<data>/logs/app.log`, finished GUI jobs in `<data>/logs/jobs/`).
|
||||
- Announcements (2026-10-15, announcements/README.md; app only, the owner dropped the site pop-up): source of
|
||||
truth `announcements/current.md` on `main` (front matter id/title/date + optional image/image_alt/button+url/
|
||||
expires/active, Markdown body; ships `active: false`). `announcements.py` (no Flet; raw GitHub via
|
||||
`cache.cached_text`, 6 h) + `ui/announcer.py` (pop-up 20 s after start / every 6 h when no dialog is open and not
|
||||
on welcome; `C.dialog` with the portal edge, image downloaded + Pillow-checked into `<data>/announcements/`, shown
|
||||
by asset URL). Shown only if the user was around before `date` (setting `announce.since`, set once at the GUI's
|
||||
first start: 0 when `USED_BEFORE` settings exist, else now), once: any close (X, outside click, Escape, button)
|
||||
stores the id (`announce.dismissed`, last 50). New announcement = new id; same id = typo fix.
|
||||
`FRAMEPORT_NO_ANNOUNCEMENTS`; `ui_smoke.py --announcement` renders it.
|
||||
- Self-update (docs/ARCHITECTURE.md "Self-update", docs/INSTALL.md "Updating"): `_version.py` = the only version
|
||||
(`frameport.__version__`; pyproject reads it via hatch `dynamic`; app log, diagnostics, User-Agent, Settings use
|
||||
it — never `importlib.metadata`, bundles have no dist-info). `updates.py` (no Flet): `check()` = GitHub
|
||||
@@ -920,13 +911,6 @@ hide_space_warp / vk_hide_fdm / no Valve layers; FNAF HW (#135) "freeze" = first
|
||||
on Zink (threads busy in zink/disk cache), not a hang; VR4 (#199) reporter's patch.203 OBB truncated. Upload benchmark
|
||||
(`scripts/upload_bench.py`, #242): home Wi-Fi caps every method at ~11 MB/s; paramiko uses ~3x OpenSSH's CPU; Windows
|
||||
OpenSSH refuses FramePort's key file (ACL "bad permissions") until its permissions are tightened.
|
||||
**OpenSSH uploads (2026-10-15, #324/#242):** sftp escapes `* ? [` inside double quotes itself (and undoes it for
|
||||
put's destination), so `native_upload.quote` escapes only `\\`/`"`; Win32 sftp turns every local backslash into `/`
|
||||
(escapes too) → local paths go as `C:/…`. Logins: `Frame.auth` = the attempt that worked (app_key / user_key = a key
|
||||
file from Settings → Installing → SSH keys, library `ssh.extra_keys`, CLI `frame ssh-keys` / agent / password).
|
||||
OpenSSH strict (FramePort + Settings keys, IdentitiesOnly) unless auth was "agent"; a refused strict login retries
|
||||
relaxed (+ private copies of ~/.ssh/id_{rsa,dsa,ecdsa,ed25519}: OpenSSH skips its defaults once any -i is given, + the
|
||||
agent); `-v` key lines go into the error. FramePort doesn't add its key to the Frame for this. Windows-tested.
|
||||
**Meta user id (GitHub #182, 2026-10-11):** OVRPort's platform loader (runtimes 3.4.3-23204ea and -aa54c3f) answers
|
||||
`ovr_GetLoggedInUserID` (and two other callers) without a Meta account with a made-up id from a helper (`stp; mov x29,
|
||||
sp; bl random; …; mov/movk x9 = 0x53444835ec580000 (6e18); add x0, x8, x9; ldp; ret`), kept only in memory → a new id
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# Announcements
|
||||
|
||||
`current.md` is the one announcement FramePort shows as a pop-up in the app. Edit it on `main`: the app reads it
|
||||
from GitHub `main` (raw.githubusercontent.com) at most every 6 hours, so a push reaches everyone without a release.
|
||||
|
||||
## Who sees it
|
||||
|
||||
- **Only people who already used FramePort before the announcement's `date`.** A new install never shows one, and
|
||||
nor does an announcement published before FramePort was installed.
|
||||
- **Once.** Closing it in any way (the X, a click outside it, Escape, or its button) dismisses it for good. The app
|
||||
remembers dismissals in its settings (`announce.dismissed`).
|
||||
- **A new announcement = a new `id`.** Fixing a typo with the same `id` doesn't show it again to anyone who closed it.
|
||||
A new `id` shows to everyone who used FramePort before its `date`, whether they closed the earlier one or not.
|
||||
|
||||
## The file
|
||||
|
||||
```markdown
|
||||
---
|
||||
active: true # false = nothing is shown (the fields are still checked)
|
||||
id: meetup-2026-11 # letters, digits, . _ - — change it for every new announcement
|
||||
title: Steam Frame meetup on Discord # required
|
||||
date: 2026-11-02T18:00:00Z # required: when it was published (UTC; a bare date = midnight UTC)
|
||||
image: announcements/meetup.png # optional: a file in this folder, or an https URL (PNG, JPEG, WebP, GIF; ≤ 5 MiB)
|
||||
image_alt: The meetup poster # optional: what the picture shows (screen readers)
|
||||
button: Join the meetup # optional: an action button, with url
|
||||
url: https://example.com/meetup
|
||||
expires: 2026-11-30 # optional: not shown after this day (UTC, inclusive)
|
||||
---
|
||||
The body, in Markdown: **bold**, *italic*, `code`, [links](https://frameport.app/), lists, headings.
|
||||
```
|
||||
|
||||
The body is Markdown (no HTML). A `date` in the future works as a schedule: nobody sees it before then.
|
||||
|
||||
Images look best at about 2:1 (e.g. 1200×600). The pop-up is 600 px wide and shows between 140 and 320 px of the
|
||||
picture's height.
|
||||
|
||||
`pytest tests/test_announcements.py` checks this file, so a broken announcement fails CI instead of silently showing
|
||||
nothing.
|
||||
|
||||
To turn announcements off for testing: `FRAMEPORT_NO_ANNOUNCEMENTS=1`.
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
# The announcement FramePort shows once (announcements/README.md). Switched off: set active: true.
|
||||
active: false
|
||||
id: example-2026-10
|
||||
title: Example announcement
|
||||
date: 2026-10-15T12:00:00Z
|
||||
# image: announcements/example.png
|
||||
# image_alt: What the picture shows
|
||||
# button: Read more
|
||||
# url: https://frameport.app/changelog/
|
||||
# expires: 2026-11-30
|
||||
---
|
||||
This is where the text goes, in **Markdown**: *emphasis*, `code`, [links](https://frameport.app/) and lists.
|
||||
|
||||
- Keep it short: one or two paragraphs.
|
||||
- Change `id` for every new announcement.
|
||||
@@ -11,13 +11,13 @@ d6337034fb0bdcca7ef9b7063d85b7586b8508b63b435959e8fffaba0691091c ./arm64-v8a/li
|
||||
e8c0965c188665a4e565e4a3af983ef1866d1cf9225665cc90d8df286bc96ef3 ./arm64-v8a/libfp_vk.so
|
||||
383054f8b3b41dde76d062c71856cd3163655e50009c08454bc1793f393ead65 ./arm64-v8a/libfpg.so
|
||||
5432674a59d02f411cd853a5dc57e3fa547d1ac518f25bf433fc54c43677177a ./arm64-v8a/libfpglmv.so
|
||||
36400eae4ba3807bcfcd54463daa217347032b07218792faa448d8cbbd086a0e ./arm64-v8a/libframe_xrshim.so
|
||||
8a8f6b1da8952cb3933a5b53558424a5fc56043b2527a16bb6dd62a00d2de01a ./arm64-v8a/libframe_xrshim.so
|
||||
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
|
||||
8e34dc3b191c64bf0cc1aa2276fe6e2cc1a0c5ccbd3a8ad8bd9d041e2101915c ./arm64-v8a/libopenxr_loader_generic.so
|
||||
0f058921e9c1f5b9ef5847cc9e6b80a23e75028c76c1d3bb02d1610b535f81c9 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
|
||||
f4d513c6359bed58dcd857f34bffec9212f26dd94318d1892824b4548e736c83 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
9e8a7ce599b62a7c607b0d3637e4ec1b1ea1974c3373a7af3f5920ad2adc5e00 ./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.
@@ -1,20 +0,0 @@
|
||||
package: com.CreaTeam.Sclerosis
|
||||
title: Sclerosis
|
||||
status: works
|
||||
notes: Copy the "Amnesia The Dark Descent" folder from a Windows/Proton install to "FramePort/com.CreaTeam.Sclerosis/lepton-data/external".
|
||||
Default hand positions are rather askew and should be amended via VR Controller Mappings in Steam itself. FOV
|
||||
warping effects are EXTREMELY disorienting in VR, beware. Otherwise seems to work without issue.
|
||||
tested_version: 1.10.7
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: e17421ad9894f5e57fb9b1b8110544715166756ef924b5a86831f6f20e70a6d5
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
issue: 339
|
||||
source_hint: Sclerosis_v1.10.7
|
||||
@@ -1,24 +0,0 @@
|
||||
package: com.Curve.LawnMowingSimulatorVR
|
||||
title: Lawn Mowing Simulator VR
|
||||
status: issues
|
||||
notes: Left eye textures flicker only when mowing in specific areas.
|
||||
tested_version: 1.0.50
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
pose_consistency: 1
|
||||
hide_space_warp: 1
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: 2653e9b1dfbd11d607b5dd17b9c07ab19d9e98e1004ef9b14cb52867b5f959dc
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 344
|
||||
source_hint: com.Curve.LawnMowingSimulatorVR
|
||||
@@ -1,22 +0,0 @@
|
||||
package: com.HypetrainDigitalLtd.Breathedge
|
||||
title: Breathedge VR
|
||||
status: works
|
||||
notes: Loading fade is broken and only shows a small square but the game works!
|
||||
tested_version: 1.0.7
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
frame:
|
||||
- frame.hw_video_decode
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: 4975a32fbf36b4d417142a944bf7d771c18a184dc0320b5937ac2c67bd013575
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 347
|
||||
source_hint: Breathadge
|
||||
@@ -1,28 +0,0 @@
|
||||
package: com.Miragesoft.RealVrFishingQ
|
||||
title: Real VR Fishing
|
||||
status: issues
|
||||
notes: Seems to be almost 100% functional with meta permissions enabled. In the new Mixed Reality mode, the room
|
||||
scan doesn't seem to do anything and I can't seem to manually place anything either.
|
||||
tested_version: '3.06096'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.vivox_audio_route
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
- frame.meta_permissions
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
scene_emul: 1
|
||||
controller_models: 1
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: 8b17dd26ef0486618ec006f9d948a60dabea4d7763bbe6061db07ee8aad27c76
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 91
|
||||
issue: 333
|
||||
source_hint: Real VR Fishing
|
||||
@@ -1,22 +0,0 @@
|
||||
package: com.SchellGames.LostRecipes
|
||||
title: Lost Recipes
|
||||
status: works
|
||||
tested_version: 1.0.1+273175
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
frame:
|
||||
- frame.unreal_skip_obb_check
|
||||
- frame.hw_video_decode
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: f071a1deea3e3b6dce0c3a923127f540a48b88532106536c1838f496e9d3e6c9
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 340
|
||||
source_hint: Lost Recipes
|
||||
@@ -1,24 +1,22 @@
|
||||
package: com.UNIVRS.Freedom
|
||||
title: 'Attack on Titan VR: Unbreakable'
|
||||
status: works
|
||||
notes: Keep the extra files that come with the APK and OBB next to the OBB (they hold the audio). Cutscenes are
|
||||
detached from the head but still viewable.
|
||||
title: Freedom
|
||||
status: issues
|
||||
notes: Volume can't be changed in the game.
|
||||
tested_version: 1.0.2950
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.asset_files
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: 1b3ced1a24b3b708f66aba6aa879863b667c0e502ef153d8a8cfa03e529d21fd
|
||||
app: 1.2.0
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: 24662272c4cbe5dc7c4f3c803bdb9dead9661ac3d59bf80cca2e260256acc4bc
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 337
|
||||
source_hint: Aot
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 132
|
||||
source_hint: AOT VR
|
||||
@@ -1,5 +1,5 @@
|
||||
package: com.asg.clockworkdev
|
||||
title: The Last Clockwinder
|
||||
title: Clockwork
|
||||
status: works
|
||||
tested_version: '1.29'
|
||||
engine: Unity
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package: com.crytek.climb
|
||||
title: The Climb
|
||||
status: works
|
||||
tested_version: '666.0'
|
||||
engine: CryEngine
|
||||
xr: VrApi
|
||||
frame:
|
||||
- frame.vrapi_bridge
|
||||
device_files:
|
||||
user.cfg: 'r_vr_foveated = 0
|
||||
|
||||
'
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: 3899967dd6d3ee0ce3409e94d543405c779e9c282ead29536abe93b3531ca285
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 341
|
||||
source_hint: TheClimb
|
||||
@@ -1,22 +0,0 @@
|
||||
package: com.fasttravelgames.mascot
|
||||
title: 'Cities: VR'
|
||||
status: works
|
||||
notes: Loading screen Cities VR Logo doesn't load right.
|
||||
tested_version: 1.00.502
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: 5537e4e9333a04eda8c1b2f96cdc9572c1bbed52a9d3af42269ba442ce9ba801
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 345
|
||||
source_hint: CitiesSkylines
|
||||
@@ -1,22 +0,0 @@
|
||||
package: com.s3d.ExplodingKittensVR.TestRelease
|
||||
title: Exploding Kittens VR
|
||||
status: issues
|
||||
notes: Requires online for most modes. Practice lobby does work though.
|
||||
tested_version: 1.4.25779
|
||||
engine: Unreal
|
||||
xr: OpenXR
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
frame:
|
||||
- frame.hw_video_decode
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: 4b0a2348a5553851dcec820835e02c93a1a64927a30533d554943a9b81b3baa0
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 343
|
||||
source_hint: com.s3d.ExplodingKittensVR.TestRelease
|
||||
@@ -1,24 +0,0 @@
|
||||
package: jp.co.hit_point.vneko
|
||||
title: Neko Atsume Purrfect
|
||||
status: works
|
||||
tested_version: 2.3.0
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
controller_models: 1
|
||||
haptic_fix: 1
|
||||
scene_emul: 1
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: da0196efa495d6ea7b34be2cea5b279acf23489134bb1dd1acef7cf54aa57da6
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261015.6141207'
|
||||
agent: 91
|
||||
issue: 338
|
||||
source_hint: jp.co.hit_point.vneko
|
||||
@@ -1,18 +0,0 @@
|
||||
package: org.crisiszone.vr
|
||||
title: Crisis Zone VR
|
||||
status: works
|
||||
notes: Played the beginning of the first mission without issues.
|
||||
tested_version: 0.1.4
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.sdl_clipboard
|
||||
verified:
|
||||
date: '2026-10-15'
|
||||
known_good_sha256: 747d1e6613ae443ef03e509b509e4ec89039eec1aecf3689643db4057e53266a
|
||||
app: 1.2.1.dev433
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 91
|
||||
issue: 331
|
||||
source_hint: CrisisZoneVR-v0.1.4-quest3
|
||||
@@ -1,9 +1,9 @@
|
||||
package: xyz.TheAviary.Vermillion
|
||||
title: Vermillion
|
||||
status: works
|
||||
notes: "The canvas used to hide the hand and brush while painting (the Frame draws the game's canvas layer in front
|
||||
of everything); the recipe makes the game draw canvases in the scene instead (fix found by Klownicle, GitHub #200).
|
||||
Confirmed in the headset by Klownicle."
|
||||
status: issues
|
||||
notes: Runs. The canvas used to hide the hand and brush while painting (the Frame draws the game's canvas layer in
|
||||
front of everything); the recipe makes the game draw canvases in the scene instead (fix found by Klownicle, GitHub
|
||||
#200). Not yet confirmed in the headset with this patch.
|
||||
tested_version: 1.7.2
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
@@ -16,10 +16,10 @@ adapter:
|
||||
haptic_fix: 1
|
||||
scene_emul: 1
|
||||
verified:
|
||||
date: '2026-10-15'
|
||||
date: '2026-10-11'
|
||||
app: 1.0.1
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 79
|
||||
issue: 200
|
||||
updated: '2026-10-15'
|
||||
updated: '2026-10-12'
|
||||
source_hint: Vermillion - VR Painting
|
||||
@@ -243,16 +243,6 @@ signatures:
|
||||
severity: error
|
||||
diagnosis: The screen stays black because the game submits a layer the VrApi bridge can't show. Whole frames with that VrApi layer type are dropped.
|
||||
suggest: []
|
||||
- id: unreal-reserve-refused
|
||||
# GitHub #349: Unreal's out-of-memory handler dereferences its not yet created log while libUnreal.so's static
|
||||
# constructors run (System.loadLibrary), after the Binned3 allocator's up-front reservation failed
|
||||
pattern: '(?s)fault addr 0x10\b(?:(?!Fatal signal).){0,8000}?#00 pc [0-9a-f]+\s+\S*lib(?:Unreal|UE4)\.so(?:(?!Fatal signal).){0,8000}?soinfo17call_constructors'
|
||||
severity: fatal
|
||||
diagnosis: The game crashed while Unreal was still loading, before its first frame. Its memory allocator (Binned3)
|
||||
reserves tens of gigabytes of address space at once, which Quest allows and the Frame's kernel refuses in one
|
||||
piece (for example Transformers Beyond Reality). Rebuild with the reservation marked as not committed.
|
||||
suggest: [frame.unreal_noreserve]
|
||||
supersedes: [native-crash]
|
||||
- id: graphics-requirements-missing
|
||||
pattern: '(XR_ERROR_GRAPHICS_REQUIREMENTS_CALL_MISSING|Failed to create XR session: -50\b|xrCreateSession[^\n]*-50\b)'
|
||||
severity: fatal
|
||||
@@ -329,13 +319,6 @@ signatures:
|
||||
diagnosis: 'The Frame''s Vulkan driver crashed on a call from Unreal. Unreal 5 passes things Quest''s driver ignores: a depth resolve in a subpass without a depth attachment (handled by the Vulkan shim), and image barriers and views for a missing image when it expects fragment density maps (foveation) from the headset (for example Metro Awakening).'
|
||||
suggest: [frame.vk_sanitize, adapter.vk_spec_fixes, adapter.vk_hide_fdm]
|
||||
supersedes: [native-crash]
|
||||
- id: quest-foveation-image-missing
|
||||
pattern: 'foveationImage = 0x0, width = 0, height = 0'
|
||||
unless: 'hide_fb_foveation: filtering dispatcher extensions'
|
||||
severity: info
|
||||
question: Did the Vulkan game stop on its first frames?
|
||||
diagnosis: OVRPlugin enabled Quest Vulkan foveation but received no density image. For a first-frame failure, compare with Quest foveation suppressed. This message alone does not prove the crash cause; a null Unreal graphics pipeline can also follow an earlier fatal error.
|
||||
suggest: [adapter.hide_fb_foveation]
|
||||
- id: unreal-fdm-missing
|
||||
pattern: 'vk shim: left out \d+ image barrier\(s\) without an image \(first: layout \d+ -> 1000218000'
|
||||
unless: 'vk shim: fragment density map extensions hidden'
|
||||
|
||||
+3
-13
@@ -13,7 +13,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Arkio | Quest | ✅ Works | |
|
||||
| Asgard's Wrath 2 | Quest | ✅ Works | |
|
||||
| Assassin's Creed Nexus | Quest | ✅ Works | |
|
||||
| Attack on Titan VR: Unbreakable | Quest | ✅ Works | |
|
||||
| Audica | Quest | ✅ Works | |
|
||||
| AugmentedEmpire | Quest | ✅ Works | |
|
||||
| BAM | Quest | ✅ Works | |
|
||||
@@ -30,15 +29,13 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| BlazeRush | Quest | ✅ Works | |
|
||||
| BodyCombat | Quest | ✅ Works | |
|
||||
| BONELAB | Quest | ✅ Works | |
|
||||
| Breathedge VR | Quest | ✅ Works | |
|
||||
| Carve Snowboarding | Quest | ✅ Works | |
|
||||
| Caves | Quest | ✅ Works | |
|
||||
| Cities: VR | Quest | ✅ Works | |
|
||||
| Clockwork | Quest | ✅ Works | |
|
||||
| COMPOUND | Quest | ✅ Works | |
|
||||
| Cook-Out | Quest | ✅ Works | |
|
||||
| Cosmodread | Quest | ✅ Works | |
|
||||
| Creed | Quest | ✅ Works | |
|
||||
| Crisis Zone VR | Quest | ✅ Works | |
|
||||
| cubism | Quest | ✅ Works | |
|
||||
| Dance Central | Quest | ✅ Works | |
|
||||
| Deep Cuts | Quest | ✅ Works | |
|
||||
@@ -76,7 +73,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Ladder | Quest | ✅ Works | |
|
||||
| Lambda1VR | Quest | ✅ Works | |
|
||||
| LEGO® Bricktales | Quest | ✅ Works | |
|
||||
| Lost Recipes | Quest | ✅ Works | |
|
||||
| Lucky's Tale | Quest | ✅ Works | |
|
||||
| Maestro | Quest | ✅ Works | |
|
||||
| Marvel's Deadpool VR | Quest | ✅ Works | |
|
||||
@@ -90,7 +86,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Moss: Book II | Quest | ✅ Works | |
|
||||
| Munkr Funkr (The Legacy Days) | Quest | ✅ Works | |
|
||||
| Nano | Quest | ✅ Works | |
|
||||
| Neko Atsume Purrfect | Quest | ✅ Works | |
|
||||
| NEX Player | Quest | ✅ Works | |
|
||||
| NFL PRO ERA II | Quest | ✅ Works | |
|
||||
| NOPE CHALLENGE | Quest | ✅ Works | |
|
||||
@@ -113,7 +108,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Riven | Quest | ✅ Works | |
|
||||
| Robo Recall | Quest | ✅ Works | |
|
||||
| RUINSMAGUS | Quest | ✅ Works | |
|
||||
| Sclerosis | Quest | ✅ Works | |
|
||||
| Shadow Point | Quest | ✅ Works | |
|
||||
| Shoot The Zombirds VR | Quest | ✅ Works | |
|
||||
| Shores of Loci | Quest | ✅ Works | |
|
||||
@@ -134,9 +128,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Tea For God | Quest | ✅ Works | |
|
||||
| TetrisEffect | Quest | ✅ Works | |
|
||||
| The Boys VR | Quest | ✅ Works | |
|
||||
| The Climb | Quest | ✅ Works | |
|
||||
| The Climb 2 | Quest | ✅ Works | |
|
||||
| The Last Clockwinder | Quest | ✅ Works | |
|
||||
| The Room VR | Quest | ✅ Works | |
|
||||
| The Tale of Onogoro | Quest | ✅ Works | |
|
||||
| Time Crisis II VR (Test) | Quest | ✅ Works | |
|
||||
@@ -151,7 +143,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Vader Immortal: Episode III | Quest | ✅ Works | |
|
||||
| Vampire Survivors | Quest | ✅ Works | |
|
||||
| Vendetta Forever | Quest | ✅ Works | |
|
||||
| Vermillion | Quest | ✅ Works | |
|
||||
| VR HOT Quest | Quest | ✅ Works | |
|
||||
| VR4 | Quest | ✅ Works | |
|
||||
| Walkabout Mini Golf | Quest | ✅ Works | |
|
||||
@@ -165,11 +156,10 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| 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. |
|
||||
| Exploding Kittens VR | Quest | ⚠️ Works with issues | Requires online for most modes. |
|
||||
| ExploreVR | Quest | ⚠️ Works with issues | The 3D world warps and shifts with head movement (wrong perspective). |
|
||||
| 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. |
|
||||
| Homeworld: Vast Reaches | Quest | ⚠️ Works with issues | The game starts but after selecting to start a new game it seems to crash. |
|
||||
| Lawn Mowing Simulator VR | Quest | ⚠️ Works with issues | Left eye textures flicker only when mowing in specific areas. |
|
||||
| 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. |
|
||||
@@ -178,13 +168,13 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Plezy | Quest | ⚠️ Works with issues | Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding. |
|
||||
| Quantum Void | Quest | ⚠️ Works with issues | no hands |
|
||||
| Ragnarock | Quest | ⚠️ Works with issues | |
|
||||
| Real VR Fishing | Quest | ⚠️ Works with issues | Seems to be almost 100% functional with meta permissions enabled. |
|
||||
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
|
||||
| Spunky Dungeon | Quest | ⚠️ Works with issues | Gear VR / Oculus Go game (32-bit only), runs through FramePort's 32-bit bridge (experimental). |
|
||||
| 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 |
|
||||
| The Thrill Of The Fight | Quest | ⚠️ Works with issues | Right eye doesn't draw the opponent or the referee. |
|
||||
| Time Stall | Quest | ⚠️ Works with issues | Both eyes distort during movement (unresolved). |
|
||||
| Vermillion | Quest | ⚠️ Works with issues | Runs. |
|
||||
| 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… |
|
||||
| Deadly Delivery | Quest | ❌ Doesn't run | Quits after Meta's entitlement check fails: it needs Meta's online services. |
|
||||
| Espire 1: VR Operative (Quest Edition) | Quest | ❌ Doesn't run | Mesa GL driver crash during texture upload. |
|
||||
|
||||
+39
-70
@@ -2,7 +2,7 @@
|
||||
|
||||
# Patches
|
||||
|
||||
Every patch and game setting FramePort knows: 171 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: 169 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) (56)
|
||||
- [Game settings](#group-adapter) (69)
|
||||
- [Steam Frame patches](#group-frame) (55)
|
||||
- [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 | 41 |
|
||||
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 38 |
|
||||
| [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,7 +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 | |
|
||||
| [32-bit ARM bridge (Gear VR / Go games)](#frame-a32_bridge) | `frame.a32_bridge` | Steam Frame patches | 6 |
|
||||
| [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 | 2 |
|
||||
| [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 | 5 |
|
||||
@@ -56,22 +56,22 @@ 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 | 22 |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 19 |
|
||||
| [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 |
|
||||
| [Fix Lua startup crashes on Frame](#frame-luajit_pointers) | `frame.luajit_pointers` | Steam Frame patches | |
|
||||
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 3 |
|
||||
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 2 |
|
||||
| [Meta XR Audio: skip Quest telemetry class](#frame-metaxr_telemetry) | `frame.metaxr_telemetry` | Steam Frame patches | default |
|
||||
| [Clear android:debuggable](#frame-nodebug) | `frame.nodebug` | Steam Frame patches | 3 |
|
||||
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 17 |
|
||||
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 15 |
|
||||
| [Fix crash when the game prepares the microphone](#frame-ovr_microphone) | `frame.ovr_microphone` | Steam Frame patches | |
|
||||
| [Keep the same Meta user between starts](#frame-ovr_stable_user) | `frame.ovr_stable_user` | Steam Frame patches | default |
|
||||
| [Trace Meta platform requests (diagnostics)](#frame-ovr_trace) | `frame.ovr_trace` | Steam Frame patches | |
|
||||
| [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 | 5 |
|
||||
| [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 |
|
||||
@@ -82,30 +82,29 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Unity: skip OVROverlay layers (fades, splash screens)](#frame-unity_no_overlay_copy) | `frame.unity_no_overlay_copy` | Steam Frame patches | 2 |
|
||||
| [Unity: start VR without Meta's system apps](#frame-unity_oculus_check) | `frame.unity_oculus_check` | Steam Frame patches | 12 |
|
||||
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 28 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 107 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 102 |
|
||||
| [Unity: share thread keys (games that run out of them)](#frame-unity_tls_key_pool) | `frame.unity_tls_key_pool` | Steam Frame patches | 1 |
|
||||
| [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: reserve memory like on Quest](#frame-unreal_noreserve) | `frame.unreal_noreserve` | Steam Frame patches | |
|
||||
| [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 | 6 |
|
||||
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 5 |
|
||||
| [Unreal: skip the OBB delivery check](#frame-unreal_skip_obb_delivery) | `frame.unreal_skip_obb_delivery` | Steam Frame patches | 1 |
|
||||
| [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 |
|
||||
| [Vivox voice chat: skip Android 12 audio routing](#frame-vivox_audio_route) | `frame.vivox_audio_route` | Steam Frame patches | 2 |
|
||||
| [Vivox voice chat: skip Android 12 audio routing](#frame-vivox_audio_route) | `frame.vivox_audio_route` | Steam Frame patches | 1 |
|
||||
| [Vulkan: drop invalid render-pass pointers](#frame-vk_sanitize) | `frame.vk_sanitize` | Steam Frame patches | default · 1 |
|
||||
| [VrApi → OpenXR bridge](#frame-vrapi_bridge) | `frame.vrapi_bridge` | Steam Frame patches | 4 |
|
||||
| [VrApi → OpenXR bridge](#frame-vrapi_bridge) | `frame.vrapi_bridge` | Steam Frame patches | 3 |
|
||||
| [Quiet Meta's VrApi loader (OpenXR games)](#frame-vrapi_stub) | `frame.vrapi_stub` | Steam Frame patches | 2 |
|
||||
| [OpenGL ES: shader fixes (Vulkan layer under Zink)](#frame-zink_shader_fix) | `frame.zink_shader_fix` | Steam Frame patches | 1 |
|
||||
| [Pointer start](#adapter-aim_forward) | `adapter.aim_forward` | Game settings | |
|
||||
| [Pointer angle](#adapter-aim_pitch) | `adapter.aim_pitch` | Game settings | |
|
||||
| [Pointer turn](#adapter-aim_yaw) | `adapter.aim_yaw` | Game settings | |
|
||||
| [Use controllers](#adapter-controller_fix) | `adapter.controller_fix` | Game settings | 1 |
|
||||
| [Show Steam Frame controllers](#adapter-controller_models) | `adapter.controller_models` | Game settings | 6 |
|
||||
| [Show Steam Frame controllers](#adapter-controller_models) | `adapter.controller_models` | Game settings | 4 |
|
||||
| [Stand in for cube layers](#adapter-cube_standin) | `adapter.cube_standin` | Game settings | |
|
||||
| [Show curved menus and screens](#adapter-cylinder_strips) | `adapter.cylinder_strips` | Game settings | |
|
||||
| [Show 360° backgrounds and videos](#adapter-equirect_emul) | `adapter.equirect_emul` | Game settings | 5 |
|
||||
@@ -128,10 +127,9 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [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 | 88 |
|
||||
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 81 |
|
||||
| [Vibration strength](#adapter-haptic_scale) | `adapter.haptic_scale` | Game settings | |
|
||||
| [Turn off Quest Vulkan foveation](#adapter-hide_fb_foveation) | `adapter.hide_fb_foveation` | Game settings | |
|
||||
| [Turn off space warp](#adapter-hide_space_warp) | `adapter.hide_space_warp` | Game settings | 5 |
|
||||
| [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 | |
|
||||
| [Extra diagnostics (log)](#adapter-layer_debug) | `adapter.layer_debug` | Game settings | |
|
||||
| [Drop broken layers](#adapter-layer_fix) | `adapter.layer_fix` | Game settings | |
|
||||
@@ -139,7 +137,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Unity frame loop: wait for skipped frames](#adapter-ovrp_begin_gate) | `adapter.ovrp_begin_gate` | Game settings | 1 |
|
||||
| [Steadier hands (older Unity)](#adapter-ovrp_hold_physics) | `adapter.ovrp_hold_physics` | Game settings | 1 |
|
||||
| [See your room](#adapter-passthrough_emul) | `adapter.passthrough_emul` | Game settings | |
|
||||
| [Steadier head tracking](#adapter-pose_consistency) | `adapter.pose_consistency` | Game settings | 2 |
|
||||
| [Steadier head tracking](#adapter-pose_consistency) | `adapter.pose_consistency` | Game settings | 1 |
|
||||
| [Pose timing diagnostics (log)](#adapter-pose_debug) | `adapter.pose_debug` | Game settings | |
|
||||
| [Fix hands lagging behind controllers](#adapter-pose_time_fix) | `adapter.pose_time_fix` | Game settings | 14 |
|
||||
| [Treat newer Quest controllers as Touch](#adapter-profile_remap) | `adapter.profile_remap` | Game settings | |
|
||||
@@ -150,7 +148,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [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 | 11 |
|
||||
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 9 |
|
||||
| [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 | |
|
||||
@@ -172,12 +170,12 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [OpenGL ES shader fixes](#adapter-zink_shader_fix) | `adapter.zink_shader_fix` | Game settings | 1 |
|
||||
| [Keep the game's saved resolution in step](#device-config_sync) | `device.config_sync` | Files and settings on the Frame | 1 |
|
||||
| [Show as VR or as a flat window](#device-display_mode) | `device.display_mode` | Files and settings on the Frame | |
|
||||
| [Settings files for the game](#device-files) | `device.files` | Files and settings on the Frame | 3 |
|
||||
| [Settings files for the game](#device-files) | `device.files` | Files and settings on the Frame | 2 |
|
||||
| [Foveated rendering (Valve)](#device-foveation) | `device.foveation` | Files and settings on the Frame | 3 |
|
||||
| [Hide Android's navigation bar](#device-hide_navbar) | `device.hide_navbar` | Files and settings on the Frame | |
|
||||
| [Extra Lepton settings](#device-lepton_env) | `device.lepton_env` | Files and settings on the Frame | 3 |
|
||||
| [Controllers as a gamepad (Steam Input)](#device-steam_gamepad) | `device.steam_gamepad` | Files and settings on the Frame | |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 110 |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 105 |
|
||||
| [Launch options (SteamVR or OpenXR)](#pcvr-launch_args) | `pcvr.launch_args` | PC VR (Proton and Revive) | 2 |
|
||||
| [Provide the Oculus runtime from Revive (Frame)](#pcvr-libovr_redirect) | `pcvr.libovr_redirect` | PC VR (Proton and Revive) | |
|
||||
| [No Unreal crash reporter](#pcvr-no_crash_reporter) | `pcvr.no_crash_reporter` | PC VR (Proton and Revive) | |
|
||||
@@ -422,7 +420,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 (41):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Breathedge VR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2 and 33 more.
|
||||
- **Catalog games (38):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2, ExploreVR and 30 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)
|
||||
@@ -528,7 +526,7 @@ Lets the game find extra content it keeps next to its data (for example Star War
|
||||
- `frame.asset_files`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** The game asks Meta's platform for asset files and its data has … content file(s) (*.pak) that OVRPort's loader doesn't report.
|
||||
- **Catalog games (2):** Attack on Titan VR: Unbreakable, Star Wars: Tales from the Galaxy's Edge.
|
||||
- **Catalog games (1):** Star Wars: Tales from the Galaxy's Edge.
|
||||
- **Source:** [patches/frame/langpack.py](../src/frameport/patches/frame/langpack.py)
|
||||
|
||||
> 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.
|
||||
@@ -647,7 +645,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 (22):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Breathedge VR, Dance Central, Exploding Kittens VR, Eye of the Temple and 14 more.
|
||||
- **Catalog games (19):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Dance Central, Eye of the Temple, Fruit Ninja, Ghost Town and 11 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.
|
||||
@@ -708,7 +706,7 @@ Declares Meta's permissions some mixed-reality games ask for, so they don't stop
|
||||
- `frame.meta_permissions`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Mixed-reality game that needs the room model: its 'use spatial data' permission must be granted (for example Demeter).
|
||||
- **Catalog games (3):** Arkio, Demeter, Real VR Fishing.
|
||||
- **Catalog games (2):** Arkio, Demeter.
|
||||
- **Source:** [patches/frame/manifest_fixes.py](../src/frameport/patches/frame/manifest_fixes.py)
|
||||
|
||||
> Declares com.oculus.permission.* / horizonos.permission.* that the app uses, so Android grants them at install (for example the 'use spatial data' permission of mixed-reality games like Demeter).
|
||||
@@ -751,7 +749,7 @@ Provides stand-ins for Quest system calls some games make at start.
|
||||
- **Suggested (on):** The Unreal build of Meta XR Audio looks up com.oculus.os.AnalyticsEvent and aborts without it (for example NOPE Challenge).
|
||||
- **Suggested (on):** Several Meta SDK libraries look up com.oculus.os.AnalyticsEvent (…); games like this abort without the stub (for example Nano).
|
||||
- **Offered (off):** … references com.oculus.os.AnalyticsEvent; usually harmless (for example Batman: Arkham Shadow and LEGO Bricktales run without it). Enable if the game aborts with ClassNotFoundException.
|
||||
- **Catalog games (17):** AgeOfJoy, AllInOneSports, Caves, Deep Cuts, Does it Stack?, Nano, Neko Atsume Purrfect, NOPE CHALLENGE and 9 more.
|
||||
- **Catalog games (15):** AgeOfJoy, AllInOneSports, Caves, Deep Cuts, Does it Stack?, Nano, NOPE CHALLENGE, RC Pilot Trainer and 7 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `oculus-os-class` (fatal): The game looks up a Quest-only system class and aborts.
|
||||
- **Source:** [patches/frame/telemetry.py](../src/frameport/patches/frame/telemetry.py)
|
||||
@@ -840,7 +838,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 (5):** Crisis Zone VR, Majora's Mask VR, 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)
|
||||
@@ -906,7 +904,7 @@ Stops a gray or frozen screen in Unity games that turn on anti-aliasing while ru
|
||||
- `frame.unity_gl_shim`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity GLES game that may switch to MSAA at runtime: hide multisampled render-to-texture, which crashes the Frame's GL driver (for example The Room VR).
|
||||
- **Catalog games (23):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 15 more.
|
||||
- **Catalog games (23):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 15 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unreal-msrtt-crash` (fatal): The game's graphics crashed a few seconds after start.
|
||||
- **Source:** [patches/frame/vrapi_bridge.py](../src/frameport/patches/frame/vrapi_bridge.py)
|
||||
@@ -975,7 +973,7 @@ Keeps a smoothing setting off that the game turns on itself and that can freeze
|
||||
- `frame.unity_runtime_msaa_off` · revision 2
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled render-to-texture can hang the Frame's GPU.
|
||||
- **Catalog games (28):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 20 more.
|
||||
- **Catalog games (28):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 20 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unity-render-crash` (fatal): The game's graphics crashed, so the picture freezes or stays gray.
|
||||
- `unity-runtime-msaa` (info): The game turns MSAA on at runtime, which can hang the Frame's GPU.
|
||||
@@ -991,7 +989,7 @@ Lets you type into this app's text fields on the Frame (they'd close at once).
|
||||
- `frame.unity_text_input` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity app with text fields (…): they close at once on the Frame because it has no system keyboard.
|
||||
- **Catalog games (107):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable, Audica and 99 more.
|
||||
- **Catalog games (102):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Audica, AugmentedEmpire and 94 more.
|
||||
- **Source:** [patches/frame/unity_text_input.py](../src/frameport/patches/frame/unity_text_input.py)
|
||||
|
||||
> Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a frame later when there is none (Lepton has no on-screen keyboard; Meta's system keyboard does this on a Quest), so a selected text field only flashes a caret. Rewrites TouchScreenKeyboardShouldBeUsed -> false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -> true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with "Show the app window") or Type on Frame from the PC.
|
||||
@@ -1051,19 +1049,6 @@ Shows the full environment when its upper part is clipped but menu panels look c
|
||||
|
||||
> Some UE4 games keep the initial Android window size for their stereo depth buffer, clipping the upper part of the environment while menu panels look correct. Refreshes the cached depth dimensions from the current eye render target before allocation. Preserves multiview and multisampling. Verified in Tales from the Galaxy's Edge 1.1.9; changes one branch only when the function size and surrounding code match.
|
||||
|
||||
### <a id="frame-unreal_noreserve"></a>Unreal: reserve memory like on Quest
|
||||
|
||||
Lets the game load: it reserves more memory at start than the Frame grants in one piece and crashed before its first frame (for example Transformers Beyond Reality).
|
||||
|
||||
- `frame.unreal_noreserve` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unreal's Binned3 allocator reserves more memory at start than the Frame's kernel allows in one piece; without this the game crashes while it loads.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unreal-reserve-refused` (fatal): The game crashed while Unreal was still loading, before its first frame.
|
||||
- **Source:** [patches/frame/unreal_noreserve.py](../src/frameport/patches/frame/unreal_noreserve.py)
|
||||
|
||||
> Unreal reserves its memory ranges on Android with ordinary read/write mappings. Games built with Unreal's Binned3 allocator reserve tens of gigabytes at once while the engine loads (76 GiB in Transformers Beyond Reality); Quest allows that, the Frame's kernel refuses mappings larger than its memory, and the game crashes before its first frame (SIGSEGV at fault addr 0x10 in libUnreal.so, inside System.loadLibrary). This marks those mappings MAP_NORESERVE, as Quest effectively treats them: memory is still only used when the game touches it.
|
||||
|
||||
### <a id="frame-unreal_ovrp_entrypoints"></a>Unreal: stand-ins for OVRPlugin functions OVRPort lacks
|
||||
|
||||
Lets older Unreal games start VR on the Frame (for example Star Wars Pinball VR).
|
||||
@@ -1107,7 +1092,7 @@ Starts Unreal games without re-checking their data file (they'd seem to hang on
|
||||
- `frame.unreal_skip_obb_check` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unreal checks the OBB's contents before starting (bVerifyOBBOnStartUp); on the Frame a failed or slow check can't be seen and the game seems to hang.
|
||||
- **Catalog games (6):** Dance Central, Kill It With Fire VR, Lost Recipes, MacacoVR, Maestro, Ragnarock.
|
||||
- **Catalog games (5):** Dance Central, Kill It With Fire VR, MacacoVR, Maestro, Ragnarock.
|
||||
- **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)
|
||||
@@ -1173,7 +1158,7 @@ Lets games with Vivox voice chat start on the Frame (they'd crash on an Android
|
||||
- `frame.vivox_audio_route` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Vivox voice chat: it calls Android 12 audio methods, which Lepton (Android 11) lacks, and crashes.
|
||||
- **Catalog games (2):** Dungeons of Eternity, Real VR Fishing.
|
||||
- **Catalog games (1):** Dungeons of Eternity.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `vivox-api31` (fatal): The game's Vivox voice-chat SDK calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes (for example Green Hell VR).
|
||||
- **Source:** [patches/frame/vivox_audio_route.py](../src/frameport/patches/frame/vivox_audio_route.py)
|
||||
@@ -1204,7 +1189,7 @@ Lets games built on Meta's oldest VR interface run on the Frame.
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** The engine calls libvrapi.so directly; OVRPort can't translate it.
|
||||
- **Conflicts with:** [`frame.vrapi_stub`](#frame-vrapi_stub)
|
||||
- **Catalog games (4):** BlazeRush, Path of the Warrior, The Climb, The Climb 2.
|
||||
- **Catalog games (3):** BlazeRush, Path of the Warrior, The Climb 2.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `direct-vrapi` (warning): The game uses Meta's VrApi directly and needs the VrApi bridge.
|
||||
- `vrapi-frame-rejected` (fatal): The VrApi bridge rejected the game's frame options, so no picture reaches the headset even though frame pacing can appear in the log.
|
||||
@@ -1310,7 +1295,7 @@ For games that show the headset's own controllers. Takes effect after a reinstal
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Controllers → "Show Steam Frame controllers".
|
||||
- **Catalog games (6):** Neko Atsume Purrfect, Ocean Rift, Real VR Fishing, SynthRiders, VR HOT Quest, Walkabout Mini Golf.
|
||||
- **Catalog games (4):** Ocean Rift, SynthRiders, VR HOT Quest, Walkabout Mini Golf.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `render-model-requested` (info): The game shows Quest controllers (or none) because the Frame doesn't provide controller models.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
@@ -1648,7 +1633,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 (88):** AgeOfJoy, Ancient_Dungeon, Attack on Titan VR: Unbreakable, Audica, AugmentedEmpire, Battlemarked, BattleSisters, Beat Saber and 80 more.
|
||||
- **Catalog games (81):** AgeOfJoy, Ancient_Dungeon, Audica, AugmentedEmpire, Battlemarked, BattleSisters, Beat Saber, Caves and 73 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.
|
||||
@@ -1667,22 +1652,6 @@ Turn it down if controller vibrations are too strong.
|
||||
|
||||
> Scales every controller vibration the game asks for (0-1): plain vibrations, Meta's amplitude envelopes and PCM buffers. For games whose vibrations feel much stronger than on a Quest.
|
||||
|
||||
### <a id="adapter-hide_fb_foveation"></a>Turn off Quest Vulkan foveation
|
||||
|
||||
Diagnostic for Vulkan games that fail on their first frames with missing Quest foveation images.
|
||||
|
||||
- `adapter.hide_fb_foveation`
|
||||
- **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 → "Turn off Quest Vulkan foveation" (advanced).
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `quest-foveation-image-missing` (info): OVRPlugin enabled Quest Vulkan foveation but received no density image.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Hides Quest FB foveation extensions from the game and OVRPort, removes Vulkan foveation image/create chains, and refuses foveation profile updates. Diagnostic for first-frame failures with missing density images (for example Transformers Beyond Reality: Redux). Device verification pending; does not disable Valve foveation.
|
||||
|
||||
### <a id="adapter-hide_space_warp"></a>Turn off space warp
|
||||
|
||||
For games whose picture flickers or smears (for example Into The Radius 2).
|
||||
@@ -1693,7 +1662,7 @@ For games whose picture flickers or smears (for example Into The Radius 2).
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Troubleshooting → "Turn off space warp" (advanced).
|
||||
- **Catalog games (5):** Into The Radius 2, Lawn Mowing Simulator VR, Metro Awakening, Pinball FX VR, The Boys VR.
|
||||
- **Catalog games (4):** Into The Radius 2, Metro Awakening, Pinball FX VR, The Boys VR.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `space-warp-used` (info): The game uses Meta's space warp (it renders half of its frames plus motion vectors, and the runtime makes up the rest).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
@@ -1812,7 +1781,7 @@ For games that judder or shimmer while you hold your head still (for example I A
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Picture → "Steadier head tracking" (advanced).
|
||||
- **Catalog games (2):** I Am Cat, Lawn Mowing Simulator VR.
|
||||
- **Catalog games (1):** I Am Cat.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Repeat xrLocateViews queries for the same display time get the first, fully tracked answer again. Games that ask several times per frame got slightly different poses and rendered parts of the frame with different heads: judder (for example I Am Cat; proposed by Klownicle, GitHub #8).
|
||||
@@ -1972,7 +1941,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 (11):** Arkio, AugmentedEmpire, Battlemarked, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord, Neko Atsume Purrfect, Ocean Rift and 3 more.
|
||||
- **Catalog games (9):** Arkio, AugmentedEmpire, Battlemarked, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord, Ocean Rift, Smash Drums and 1 more.
|
||||
- **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)
|
||||
@@ -2303,7 +2272,7 @@ Puts settings files next to the game on the Frame, for example to turn off an un
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** CryEngine: variable-rate shading isn't supported on the Frame, so user.cfg turns it off (… = 0; The Climb / The Climb 2).
|
||||
- **Options:** `files`: text, default none — path relative to files/ -> content
|
||||
- **Catalog games (3):** SUPERHOT VR, The Climb, The Climb 2.
|
||||
- **Catalog games (2):** SUPERHOT VR, The Climb 2.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Writes config files into the game's Android/data/<package>/files/ on the Frame, to turn off engine features the Frame doesn't support (for example a CryEngine user.cfg with r_variable_rate_shading = 0, as The Climb 2 needs).
|
||||
@@ -2366,7 +2335,7 @@ Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard
|
||||
- `device.text_input_window`
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** App with text fields: key presses only reach a shown Android window.
|
||||
- **Catalog games (110):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable and 102 more.
|
||||
- **Catalog games (105):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Audica and 97 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as "Gamescope" (the Frame's compositor). Pairs with "Make Unity text fields work without a system keyboard".
|
||||
|
||||
@@ -63,8 +63,6 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Un-cracked Oculus PC VR game: "Initializing OVR session" then exits / signature check on the runtime | Revive's hooks don't install on Proton-arm64; the Oculus shim rejects the unsigned Revive runtime | Not runnable on the Frame without the repack's crack extracted (not done by FramePort); use the PC version |
|
||||
| PC VR game: `Failed to initialize Oculus API (-3001)` / `Unable to load LibOVRRT DLL` | Revive's LoadLibrary hook doesn't work under Proton arm64 (ARM64EC kernelbase); the game's LibOVR shim finds no runtime DLL | Symlink `LibOVRRT64_1.dll` → Revive's DLL in the prefix's system32 (manual so far) → then -3021 (runtime signature check; open) |
|
||||
| Uploads to the Frame are slow | The link, storage or transfer backend can limit throughput | Check the job's "Transfer link"; try the Frame's hotspot or USB cable. Large install files prefer system OpenSSH SFTP, retaining paired-host checks and `.part` resume; unavailable or failed native uploads fall back to Paramiko. The log names the backend. Settings → Installing → Parallel uploads accepts 1–8 (default 3; saved preferences are preserved). More is not always faster. Measure the production path with `scripts/upload_bench.py --methods app-files-3 --check-resume` (three repetitions, checksum verification) |
|
||||
| Log: `OpenSSH SFTP failed (255): … Permission denied (publickey,password)`, then Paramiko | OpenSSH offered keys the Frame doesn't authorize (GitHub #242) | The log lists the keys OpenSSH offered (`Offering public key`) and its warnings. A connection that logged in with the PC's own keys/agent makes OpenSSH use them too (copies of `~/.ssh/id_*` + the agent); a refused FramePort key is retried that way once. A Frame that accepts only another key file: Settings → Installing → SSH keys (CLI `frameport frame ssh-keys --add <file>`); passphrase keys go through the SSH agent |
|
||||
| Log: `OpenSSH SFTP failed (1): stat C://…/[…: No such file or directory` | sftp got glob escapes inside its quotes (GitHub #324, fixed): `[` in a local folder name | Update FramePort; the native_upload `quote()` passes only `\\` and `\"` escapes, Windows paths with `/` |
|
||||
| PC VR game exits right away: `Unhandled Exception: 0xc06d007e` (after `FOnlineSubsystemOculus::InitWithWindowsPlatform`) | Delay-loaded `LibOVRPlatform64_1.dll` (Oculus Platform SDK, installed with the Oculus app) is missing | None on the Frame (entitlement check; FramePort doesn't replace it). PC mode with the Oculus app |
|
||||
| Unity PC VR game (Oculus + SteamVR build: `OVRPlugin.dll` and `openvr_api.dll` in `<Name>_Data/Plugins`) opens a window and quits after ~20 s; no VR Vulkan instance after the game's DXVK device in launch.log; Unity log (`unity log … Player.log`, agent v67): `OpenVR failed initialization` / `Initialization of device … failed` | Unity's built-in VR tries its SDKs in list order (Oculus first) unless `-vrmode` picks one; a catalog recipe verified with another build of the game (for example SUPERHOT VR's OpenXR build, GitHub #105) used to drop the build's own arguments | `pcvr.launch_args` = `-vrmode OpenVR` (detected for such builds; kept when the catalog's `xr` differs from the build's). A game's Electron launcher next to it (SHVR.exe) is ranked below the game |
|
||||
| Unreal PC VR game opens the crash reporter instead of closing | UE starts CrashReportClient.exe on a crash | `pcvr.no_crash_reporter` (default for Unreal Rift games): `-nocrashreports` + CrashReportClient.exe renamed `.disabled` in the Frame copy |
|
||||
@@ -187,8 +185,6 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Unity game crashes or hangs right as Vulkan starts, after `SLZ Graphics plugin loading!` and OVRPlugin's pre-init `xrDestroyInstance`: SIGSEGV with pc 0 (or pc == fault address in an unloaded library), x16/x23 in `libSLZQuestNative.so` (BONELAB 1.2974) | Stress Level Zero's graphics plugin hooks Unity's Vulkan start-up (IUnityGraphicsVulkanV2 interception: vkCreateInstance/vkCreateDevice) and vkCreateSampler; its vkCreateInstance wrapper calls an invalid pointer on the Frame | `frame.slz_vulkan_hooks` (its two registrations become no-ops; Unity starts Vulkan itself; ~35 s of shader prewarming without its pipeline cache) |
|
||||
| Unity game shows a picture but the player body is frozen: no head tracking, the controllers stay on the model, no buttons, although OpenXR input works (BONELAB 1.2068, Oculus XR Plugin) | OVRPlugin reports the headset as not worn (`ovrp_GetUserPresent2` → 0) a few seconds after start, and the game's rig only follows a present user (Marrow `XRHMD.IsUserPresent` = Unity's HMD `UserPresence`) | `frame.unity_user_presence` (the Oculus XR Plugin's lookup → `libfp_ovrp.so`, reports present; log `user presence: OVRPlugin 0 …`) |
|
||||
| Unreal 5 game crashes at its first render pass, backtrace `vulkan.freedreno.so` ← `libVkLayer_fossilize.so` ← `libfp_vk.so (create_render_pass2)` (Metro Awakening) | a subpass without a depth attachment names a depth/stencil resolve attachment; the Frame's Turnip reads the missing depth attachment (NULL) | the Vulkan shim drops that resolve (always, FramePort after 0.11.0; log `dropped the depth resolve of subpass N (no depth attachment)`); triage `unreal-vulkan-driver-crash` |
|
||||
| Unreal game crashes before its first frame: SIGSEGV `fault addr 0x10` on the main thread, backtrace all `libUnreal.so` above the linker's `soinfo::call_constructors` (inside `System.loadLibrary`), right after `Parsing commandline` (GitHub #349, Transformers Beyond Reality) | The game is built with Unreal's Binned3 allocator, which reserves its whole range while the library loads: one `mmap(NULL, 76 GiB, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS)`. Quest grants it; the Frame's kernel (heuristic overcommit, mode 0) refuses a writable private mapping larger than RAM + swap, and Unreal's out-of-memory handler ("Ran out of memory allocating ...") dereferences its not-yet-created log | `frame.unreal_noreserve` (suggested from analysis `unreal_binned3`): every such anonymous read/write mmap in the engine library gets `MAP_NORESERVE` (one instruction each: `mov w3, #0x22` → `#0x4022`); triage `unreal-reserve-refused`. Found by disassembling the crash pc (the OOM handler; its UTF-16 format string) and the caller's `mov x0, #0x1300000000` |
|
||||
| Unreal Vulkan game fails on its first frames and OVRPlugin reports `foveationImage = 0x0, width = 0, height = 0` (for example Transformers Beyond Reality: Redux, #349) | Quest FB foveation remains advertised by OVRPort's dispatcher despite `foveation_fix=1`. This is a hypothesis for the failure, not a confirmed cause: Redux's alternate no-ForceQuit build dereferences a null graphics-pipeline-state object during Vulkan pipeline binding, not a density image | Diagnostic `adapter.hide_fb_foveation=1` (off by default, Vulkan): filters the dispatcher and runtime extension lists/instance requests, removes foveation create/image chains and refuses profile creation/updates. Rebuild with the adapter **and xrshim** artifacts, then compare first-frame survival and Unreal's original fatal log. Does not disable Valve FDM injection. Device verification pending |
|
||||
| Unreal 5 game crashes in `vulkan.freedreno.so` called straight from `libUnreal.so` right after its swapchains, or a few seconds into the first frames (Metro Awakening) | Unreal turns on fragment-density-map foveation because Turnip offers VK_EXT_fragment_density_map, then records barriers (to `FRAGMENT_DENSITY_MAP_OPTIMAL`) and image views (2D array R8G8) for a density map the Frame never provides (VK_NULL_HANDLE); Turnip dereferences it | adapter `vk_hide_fdm=1` (the game doesn't see the extension) + `vk_spec_fixes=1` (leaves out null-image barriers, refuses null-image views); triage `unreal-fdm-missing`. Found by disassembling the driver at the crash pc: the struct offsets (0x58 = VkSubpassDescription2, 0x48/0x60 = image barriers, viewType/format in registers) name the call |
|
||||
| Own-engine game crashes in `je_free` from its own library on the first frames (Roblox) | the game frees memory it doesn't own; not the Vulkan shim or the format fallback | unsupported |
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 415 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 663 KiB After Width: | Height: | Size: 668 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 103 KiB |
@@ -34,35 +34,3 @@ static int fb_foveation_storage(XrSwapchainCreateInfo *info, FbStorageNode nodes
|
||||
}
|
||||
return p ? -1 : 0;
|
||||
}
|
||||
|
||||
// Strip FB nodes behind known Vulkan allocation nodes without changing const app memory.
|
||||
// An unknown prefix may be retained only when its entire tail is free of FB create info.
|
||||
static int fb_hide_swapchain_foveation(XrSwapchainCreateInfo *info, FbStorageNode nodes[16]) {
|
||||
const XrBaseInStructure *p = info->next;
|
||||
unsigned n = 0;
|
||||
int removed = 0;
|
||||
unsigned visited = 0;
|
||||
while (p) {
|
||||
if (++visited > 256) return -1;
|
||||
if (p->type == XR_TYPE_SWAPCHAIN_CREATE_INFO_FOVEATION_FB) {
|
||||
removed = 1;
|
||||
p = p->next;
|
||||
continue;
|
||||
}
|
||||
if (p->type != XR_TYPE_VULKAN_SWAPCHAIN_CREATE_INFO_META || n == 16) {
|
||||
for (const XrBaseInStructure *tail = p; tail; tail = tail->next) {
|
||||
if (++visited > 256 || tail->type == XR_TYPE_SWAPCHAIN_CREATE_INFO_FOVEATION_FB) return -1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
nodes[n].vk = *(const FbVulkanSwapchainCreateInfo *)p;
|
||||
// Foveation-only subsampled allocations are invalid without FB foveation.
|
||||
nodes[n].vk.additionalCreateFlags &= ~0x4000u;
|
||||
if (n) nodes[n - 1].base.next = &nodes[n].base;
|
||||
++n;
|
||||
p = p->next;
|
||||
}
|
||||
if (n) nodes[n - 1].base.next = p;
|
||||
info->next = n ? &nodes[0] : (const void *)p;
|
||||
return removed;
|
||||
}
|
||||
@@ -95,9 +95,6 @@ static int foveation_storage_fix; // opt-in ordinary shared storage for native V
|
||||
static int foveation_eye; // experimental profile upgrade; does not inject Vulkan gaze offsets
|
||||
static int foveation_eye_enabled, foveation_eye_supported;
|
||||
#include "foveation_storage.h"
|
||||
#include "hide_fb_foveation.h"
|
||||
static int hide_fb_foveation;
|
||||
static int hide_fb_foveation_vulkan;
|
||||
// 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;
|
||||
@@ -177,7 +174,6 @@ static void read_settings(const char *path) {
|
||||
if (sscanf(line, "foveation_fix=%f", &value) == 1) foveation_fix = value != 0;
|
||||
if (sscanf(line, "foveation_storage_fix=%f", &value) == 1) foveation_storage_fix = value != 0;
|
||||
if (sscanf(line, "foveation_eye=%f", &value) == 1) foveation_eye = value != 0;
|
||||
if (sscanf(line, "hide_fb_foveation=%f", &value) == 1) hide_fb_foveation = 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, "fade_quad=%f", &value) == 1) fade_quad = value > 0 ? (int)value : 0;
|
||||
@@ -276,7 +272,6 @@ static void initialize(void) {
|
||||
if (input_diag) LOG("per-game: input_diag=1 (controller-input diagnostics)");
|
||||
if (proximity_emul) LOG("per-game: proximity_emul=%d (finger proximity from capacitive touch: %s)", proximity_emul,
|
||||
proximity_emul > 1 ? "thumb + index" : "thumb");
|
||||
if (hide_fb_foveation) LOG("per-game: hide_fb_foveation=1 (Quest Vulkan foveation suppressed)");
|
||||
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 (fade_quad) LOG("per-game: fade_quad=%d (colour-scale fades drawn as a black quad%s)", fade_quad,
|
||||
@@ -312,7 +307,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
if (!fn) return XR_ERROR_INITIALIZATION_FAILED;
|
||||
if (!info) return XR_ERROR_VALIDATION_FAILURE;
|
||||
|
||||
hide_fb_foveation_vulkan = hide_fb_foveation && fb_instance_uses_vulkan(info);
|
||||
int chained = 0, enabled = 0;
|
||||
for (const XrBaseInStructure *p = (const XrBaseInStructure *)info->next; p; p = p->next)
|
||||
if (p->type == XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR) {
|
||||
@@ -348,10 +342,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
int supported = !available; // if we could not enumerate, pass everything through
|
||||
for (uint32_t j = 0; available && j < available_count && !supported; ++j)
|
||||
supported = !strcmp(available[j].extensionName, ext);
|
||||
if (hide_fb_foveation_vulkan && fb_is_foveation_extension(ext)) {
|
||||
LOG("hide_fb_foveation: %s not enabled", ext);
|
||||
continue;
|
||||
}
|
||||
if (hide_space_warp && !strcmp(ext, "XR_FB_space_warp")) {
|
||||
LOG("hide_space_warp: XR_FB_space_warp not enabled");
|
||||
continue;
|
||||
@@ -392,7 +382,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
if (!requested && offered) { names[kept++] = "XR_FB_display_refresh_rate"; LOG("added XR_FB_display_refresh_rate (refresh_rate)"); }
|
||||
}
|
||||
foveation_eye_enabled = 0;
|
||||
if (foveation_eye && !foveation_fix && !hide_fb_foveation_vulkan) {
|
||||
if (foveation_eye && !foveation_fix) {
|
||||
int fb = 0, config = 0, offered = 0;
|
||||
for (uint32_t i = 0; i < kept; ++i) {
|
||||
fb |= !strcmp(names[i], "XR_FB_foveation");
|
||||
@@ -408,7 +398,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
}
|
||||
// Adapter-owned video outputs need an explicit full-rate profile even when
|
||||
// the shared injection layer enables foveation for application swapchains.
|
||||
if (surface_emul && !hide_fb_foveation_vulkan) {
|
||||
if (surface_emul) {
|
||||
const char *video_extensions[] = {"XR_FB_foveation", "XR_FB_foveation_configuration",
|
||||
"XR_FB_swapchain_update_state"};
|
||||
for (unsigned e = 0; e < 3; ++e) {
|
||||
@@ -594,7 +584,6 @@ static int known_swapchain(XrSwapchain handle) {
|
||||
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrCreateFoveationProfileFB(XrSession session,
|
||||
const XrFoveationProfileCreateInfoFB *info, XrFoveationProfileFB *profile) {
|
||||
if (hide_fb_foveation_vulkan) return XR_ERROR_FEATURE_UNSUPPORTED;
|
||||
PFN_xrCreateFoveationProfileFB fn = (PFN_xrCreateFoveationProfileFB)lookup(active_instance, "xrCreateFoveationProfileFB");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
if (!info) return XR_ERROR_VALIDATION_FAILURE;
|
||||
@@ -620,7 +609,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateFoveationProfileFB(XrSession session,
|
||||
}
|
||||
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrGetFoveationEyeTrackedStateMETA(XrSession session, XrFoveationEyeTrackedStateMETA *state) {
|
||||
if (hide_fb_foveation_vulkan) return XR_ERROR_FEATURE_UNSUPPORTED;
|
||||
PFN_xrGetFoveationEyeTrackedStateMETA fn = (PFN_xrGetFoveationEyeTrackedStateMETA)lookup(active_instance, "xrGetFoveationEyeTrackedStateMETA");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
XrResult result = fn(session, state);
|
||||
@@ -638,16 +626,11 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
|
||||
if (!info) return XR_ERROR_VALIDATION_FAILURE;
|
||||
XrSwapchainCreateInfo fixed = *info;
|
||||
FbStorageNode storage_nodes[16];
|
||||
if (!foveation_fix && !hide_fb_foveation_vulkan && foveation_storage_fix) {
|
||||
if (!foveation_fix && foveation_storage_fix) {
|
||||
int changed = fb_foveation_storage(&fixed, storage_nodes);
|
||||
LOG("foveation_storage_fix: shared allocation %s", changed > 0 ? "SUBSAMPLED cleared" :
|
||||
changed < 0 ? "unchanged (unsupported chain prefix)" : "already ordinary or no META node");
|
||||
}
|
||||
if (hide_fb_foveation_vulkan) {
|
||||
int stripped = fb_hide_swapchain_foveation(&fixed, storage_nodes);
|
||||
if (stripped < 0) return XR_ERROR_VALIDATION_FAILURE;
|
||||
if (stripped) LOG("hide_fb_foveation: removed swapchain foveation create info");
|
||||
}
|
||||
if (foveation_fix) {
|
||||
// Drop leading foveation structs; the chain is const so only a prefix can be skipped.
|
||||
const XrBaseInStructure *p = (const XrBaseInStructure *)fixed.next;
|
||||
@@ -1240,15 +1223,12 @@ XRAPI_ATTR XrResult XRAPI_CALL xrApplyHapticFeedback(XrSession session, const Xr
|
||||
return result;
|
||||
}
|
||||
|
||||
#include "hide_foveation_images.h"
|
||||
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSwapchainImages(XrSwapchain swapchain, uint32_t capacity, uint32_t *count,
|
||||
XrSwapchainImageBaseHeader *images) {
|
||||
PFN_xrEnumerateSwapchainImages fn = (PFN_xrEnumerateSwapchainImages)lookup(active_instance, "xrEnumerateSwapchainImages");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
if (is_standin(swapchain)) return standin_enumerate(swapchain, capacity, count, images);
|
||||
XrResult result = equirect_emul && emul_is_virtual(swapchain) ? emul_virtual_enumerate(swapchain, capacity, count, images)
|
||||
: hide_fb_foveation_vulkan ? fb_hide_foveation_images(fn, swapchain, capacity, count, images)
|
||||
: fn(swapchain, capacity, count, images);
|
||||
if (XR_SUCCEEDED(result) && images && capacity && count) {
|
||||
flip_on_enumerate_images(swapchain, *count, images);
|
||||
@@ -2057,7 +2037,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateInstanceExtensionProperties(const char
|
||||
XR_NULL_HANDLE, "xrEnumerateInstanceExtensionProperties");
|
||||
if (!fn) return XR_ERROR_INITIALIZATION_FAILED;
|
||||
if (!count) return XR_ERROR_VALIDATION_FAILURE;
|
||||
if ((!foveation_fix && !hide_fb_foveation && !hide_space_warp && !passthrough_emul && !scene_emul && !controller_models) || layer)
|
||||
if ((!foveation_fix && !hide_space_warp && !passthrough_emul && !scene_emul && !controller_models) || layer)
|
||||
return fn(layer, capacity, count, properties);
|
||||
|
||||
uint32_t total = 0;
|
||||
@@ -2073,7 +2053,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateInstanceExtensionProperties(const char
|
||||
for (uint32_t i = 0; i < total; ++i) {
|
||||
if (!strcmp(all[i].extensionName, "XR_FB_passthrough")) runtime_has_fb_passthrough = 1;
|
||||
if (!strcmp(all[i].extensionName, XR_FB_RENDER_MODEL_EXTENSION_NAME)) runtime_has_render_model = 1;
|
||||
if (hide_fb_foveation && fb_is_foveation_extension(all[i].extensionName)) continue;
|
||||
if (foveation_fix && strstr(all[i].extensionName, "foveation")) continue;
|
||||
if (hide_space_warp && !strcmp(all[i].extensionName, "XR_FB_space_warp")) continue;
|
||||
if (properties && kept < capacity) properties[kept] = all[i];
|
||||
@@ -2113,29 +2092,9 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateApiLayerProperties(uint32_t capacity,
|
||||
return fn ? fn(capacity, count, properties) : XR_ERROR_INITIALIZATION_FAILED;
|
||||
}
|
||||
|
||||
static XRAPI_ATTR int XRAPI_CALL framebridge_hide_fb_foveation(void) {
|
||||
pthread_once(&init_once, initialize);
|
||||
return hide_fb_foveation;
|
||||
}
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fb_hide_update_swapchain(XrSwapchain swapchain, const XrSwapchainStateBaseHeaderFB *state) {
|
||||
if (state && state->type == XR_TYPE_SWAPCHAIN_STATE_FOVEATION_FB) {
|
||||
LOG("hide_fb_foveation: refused foveation profile update");
|
||||
return XR_ERROR_FEATURE_UNSUPPORTED;
|
||||
}
|
||||
PFN_xrUpdateSwapchainFB fn = (PFN_xrUpdateSwapchainFB)lookup(active_instance, "xrUpdateSwapchainFB");
|
||||
return fn ? fn(swapchain, state) : XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
}
|
||||
|
||||
// For native/xrshim: emulated functions that overport's dispatcher doesn't know (it never asks us for them).
|
||||
__attribute__((visibility("default"))) PFN_xrVoidFunction framebridge_extension_proc(const char *name) {
|
||||
pthread_once(&init_once, initialize);
|
||||
if (name && !strcmp(name, "framebridge_hide_fb_foveation"))
|
||||
return (PFN_xrVoidFunction)framebridge_hide_fb_foveation;
|
||||
if (hide_fb_foveation_vulkan && name) {
|
||||
if (!strcmp(name, "xrCreateFoveationProfileFB")) return (PFN_xrVoidFunction)xrCreateFoveationProfileFB;
|
||||
if (!strcmp(name, "xrUpdateSwapchainFB")) return (PFN_xrVoidFunction)fb_hide_update_swapchain;
|
||||
}
|
||||
if (name && !foveation_fix && (foveation_storage_fix || foveation_eye)) {
|
||||
if (!strcmp(name, "xrCreateFoveationProfileFB")) return (PFN_xrVoidFunction)xrCreateFoveationProfileFB;
|
||||
if (!strcmp(name, "xrGetFoveationEyeTrackedStateMETA")) return (PFN_xrVoidFunction)xrGetFoveationEyeTrackedStateMETA;
|
||||
@@ -2176,10 +2135,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
|
||||
if (!emulated) emulated = render_model_emulation(name);
|
||||
if (!emulated && emulate_scene && !strcmp(name, "xrLocateSpacesKHR")) emulated = (PFN_xrVoidFunction)emu_locate_spaces_khr;
|
||||
if (emulated) { *function = emulated; return XR_SUCCESS; }
|
||||
if (hide_fb_foveation_vulkan && !strcmp(name, "xrUpdateSwapchainFB")) {
|
||||
*function = (PFN_xrVoidFunction)fb_hide_update_swapchain;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
HOOK(xrGetSystemProperties)
|
||||
HOOK(xrApplyHapticFeedback)
|
||||
HOOK(xrCreateReferenceSpace)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Shared by the adapter and its shim above OVRPort's synthetic extension list.
|
||||
static int fb_is_foveation_extension(const char *name) {
|
||||
return !strcmp(name, "XR_FB_foveation_vulkan") || !strcmp(name, "XR_FB_foveation") ||
|
||||
!strcmp(name, "XR_FB_foveation_configuration") || !strcmp(name, "XR_META_foveation_eye_tracked");
|
||||
}
|
||||
|
||||
static int fb_instance_uses_vulkan(const XrInstanceCreateInfo *info) {
|
||||
for (uint32_t i = 0; info && i < info->enabledExtensionCount; ++i)
|
||||
if (!strcmp(info->enabledExtensionNames[i], "XR_KHR_vulkan_enable") ||
|
||||
!strcmp(info->enabledExtensionNames[i], "XR_KHR_vulkan_enable2")) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static inline XrResult fb_hide_enumerate(PFN_xrEnumerateInstanceExtensionProperties fn, const char *layer,
|
||||
uint32_t capacity, uint32_t *count, XrExtensionProperties *properties) {
|
||||
if (!count || (capacity && !properties)) return XR_ERROR_VALIDATION_FAILURE;
|
||||
uint32_t total = 0;
|
||||
XrResult r = fn(layer, 0, &total, NULL);
|
||||
if (XR_FAILED(r)) return r;
|
||||
XrExtensionProperties *all = calloc(total ? total : 1, sizeof(*all));
|
||||
if (!all) return XR_ERROR_OUT_OF_MEMORY;
|
||||
for (uint32_t i = 0; i < total; ++i) all[i].type = XR_TYPE_EXTENSION_PROPERTIES;
|
||||
r = fn(layer, total, &total, all);
|
||||
if (XR_SUCCEEDED(r)) {
|
||||
uint32_t kept = 0;
|
||||
for (uint32_t i = 0; i < total; ++i) {
|
||||
if (fb_is_foveation_extension(all[i].extensionName)) continue;
|
||||
if (kept < capacity) properties[kept] = all[i];
|
||||
++kept;
|
||||
}
|
||||
*count = kept;
|
||||
if (capacity && capacity < kept) r = XR_ERROR_SIZE_INSUFFICIENT;
|
||||
}
|
||||
free(all);
|
||||
return r;
|
||||
}
|
||||
|
||||
static inline XrResult fb_hide_create_instance(PFN_xrCreateInstance fn, const XrInstanceCreateInfo *info,
|
||||
XrInstance *instance) {
|
||||
if (!info) return XR_ERROR_VALIDATION_FAILURE;
|
||||
if (!fb_instance_uses_vulkan(info)) return fn(info, instance);
|
||||
const char **names = calloc(info->enabledExtensionCount ? info->enabledExtensionCount : 1, sizeof(*names));
|
||||
if (!names) return XR_ERROR_OUT_OF_MEMORY;
|
||||
XrInstanceCreateInfo fixed = *info;
|
||||
fixed.enabledExtensionCount = 0;
|
||||
fixed.enabledExtensionNames = names;
|
||||
for (uint32_t i = 0; i < info->enabledExtensionCount; ++i)
|
||||
if (!fb_is_foveation_extension(info->enabledExtensionNames[i]))
|
||||
names[fixed.enabledExtensionCount++] = info->enabledExtensionNames[i];
|
||||
XrResult r = fn(&fixed, instance);
|
||||
free(names);
|
||||
return r;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Output chains are writable. Unlink FB nodes during enumeration and restore every
|
||||
// app-owned link on both success and failure; preserve unrelated output structures.
|
||||
static XrResult fb_hide_foveation_images(PFN_xrEnumerateSwapchainImages fn, XrSwapchain swapchain,
|
||||
uint32_t capacity, uint32_t *count, XrSwapchainImageBaseHeader *images) {
|
||||
if (!images || !capacity || images->type != XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR)
|
||||
return fn(swapchain, capacity, count, images);
|
||||
XrSwapchainImageVulkanKHR *vk_images = (void *)images;
|
||||
size_t total = 0;
|
||||
for (uint32_t i = 0; i < capacity; ++i)
|
||||
for (XrBaseOutStructure *p = vk_images[i].next; p; p = p->next)
|
||||
total += p->type == XR_TYPE_SWAPCHAIN_IMAGE_FOVEATION_VULKAN_FB;
|
||||
struct FbImageLink { XrBaseOutStructure *parent, *node; };
|
||||
struct FbImageLink *links = calloc(total ? total : 1, sizeof(*links));
|
||||
if (!links) return XR_ERROR_OUT_OF_MEMORY;
|
||||
size_t n = 0;
|
||||
for (uint32_t i = 0; i < capacity; ++i) {
|
||||
XrBaseOutStructure *parent = (void *)&vk_images[i];
|
||||
while (parent->next) {
|
||||
XrBaseOutStructure *p = parent->next;
|
||||
if (p->type == XR_TYPE_SWAPCHAIN_IMAGE_FOVEATION_VULKAN_FB) {
|
||||
links[n++] = (struct FbImageLink){parent, p};
|
||||
parent->next = p->next;
|
||||
} else parent = p;
|
||||
}
|
||||
}
|
||||
XrResult result = fn(swapchain, capacity, count, images);
|
||||
while (n) { --n; links[n].parent->next = links[n].node; }
|
||||
free(links);
|
||||
if (total) LOG("hide_fb_foveation: removed %zu Vulkan foveation image node(s)", total);
|
||||
return result;
|
||||
}
|
||||
+1
-45
@@ -22,8 +22,6 @@
|
||||
#include <math.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include "../adapter/hide_fb_foveation.h"
|
||||
|
||||
#define TAG "FrameBridge"
|
||||
#define EXPORT __attribute__((visibility("default")))
|
||||
@@ -47,42 +45,14 @@ static void resolve_overport(void) {
|
||||
static PFN_xrVoidFunction adapter_proc(const char *name) {
|
||||
static PFN_extension_proc fn;
|
||||
if (!fn) {
|
||||
// Normally loaded by OVRPort; only the global settings query may load it early.
|
||||
// Loaded by overport's dispatcher once an instance exists; NOLOAD so the shim never loads it by itself.
|
||||
void *h = dlopen("libopenxr_loader_generic.so", RTLD_NOW | RTLD_NOLOAD);
|
||||
// Global enumeration can be answered entirely by the dispatcher. Load the
|
||||
// adapter for its settings query even before an OpenXR instance exists.
|
||||
if (!h && !strcmp(name, "framebridge_hide_fb_foveation"))
|
||||
h = dlopen("libopenxr_loader_generic.so", RTLD_NOW | RTLD_LOCAL);
|
||||
if (h) fn = (PFN_extension_proc)dlsym(h, "framebridge_extension_proc");
|
||||
if (!fn) return NULL;
|
||||
}
|
||||
return fn(name);
|
||||
}
|
||||
|
||||
// OVRPort advertises FB extensions itself, above the runtime adapter. Filter the
|
||||
// synthetic list too, including when the dispatcher never enumerates the runtime.
|
||||
static int hide_fb_enabled(void) {
|
||||
typedef int (*Query)(void);
|
||||
Query query = (Query)adapter_proc("framebridge_hide_fb_foveation");
|
||||
return query && query();
|
||||
}
|
||||
static PFN_xrEnumerateInstanceExtensionProperties dispatcher_enumerate;
|
||||
static PFN_xrCreateInstance dispatcher_create;
|
||||
static XrResult XRAPI_CALL enumerate_extensions(const char *layer, uint32_t capacity,
|
||||
uint32_t *count, XrExtensionProperties *properties) {
|
||||
uint32_t ignored = 0;
|
||||
XrResult r = dispatcher_enumerate(layer, 0, &ignored, NULL);
|
||||
if (XR_FAILED(r)) return r;
|
||||
if (!hide_fb_enabled()) return dispatcher_enumerate(layer, capacity, count, properties);
|
||||
__android_log_print(ANDROID_LOG_INFO, TAG, "hide_fb_foveation: filtering dispatcher extensions");
|
||||
return fb_hide_enumerate(dispatcher_enumerate, layer, capacity, count, properties);
|
||||
}
|
||||
static XrResult XRAPI_CALL create_instance(const XrInstanceCreateInfo *info, XrInstance *instance) {
|
||||
if (!hide_fb_enabled() || !fb_instance_uses_vulkan(info)) return dispatcher_create(info, instance);
|
||||
__android_log_print(ANDROID_LOG_INFO, TAG, "hide_fb_foveation: filtering dispatcher instance extensions");
|
||||
return fb_hide_create_instance(dispatcher_create, info, instance);
|
||||
}
|
||||
|
||||
static PFN_xrApplyHapticFeedback overport_haptics;
|
||||
static int haptics_logged;
|
||||
|
||||
@@ -126,20 +96,6 @@ static XRAPI_ATTR XrResult XRAPI_CALL apply_haptic_feedback(XrSession session, c
|
||||
EXPORT XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const char *name,
|
||||
PFN_xrVoidFunction *function) {
|
||||
pthread_once(&once, resolve_overport);
|
||||
if (name && function && overport_gipa &&
|
||||
(!strcmp(name, "xrEnumerateInstanceExtensionProperties") || !strcmp(name, "xrCreateInstance"))) {
|
||||
PFN_xrVoidFunction real = NULL;
|
||||
XrResult r = overport_gipa(instance, name, &real);
|
||||
if (XR_FAILED(r) || !real) return r;
|
||||
if (!strcmp(name, "xrCreateInstance")) {
|
||||
dispatcher_create = (PFN_xrCreateInstance)real;
|
||||
*function = (PFN_xrVoidFunction)create_instance;
|
||||
} else {
|
||||
dispatcher_enumerate = (PFN_xrEnumerateInstanceExtensionProperties)real;
|
||||
*function = (PFN_xrVoidFunction)enumerate_extensions;
|
||||
}
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
if (name && function && instance != XR_NULL_HANDLE && overport_gipa && !strcmp(name, "xrApplyHapticFeedback")) {
|
||||
PFN_xrVoidFunction real = NULL;
|
||||
XrResult r = overport_gipa(instance, name, &real);
|
||||
|
||||
+1
-24
@@ -101,8 +101,6 @@ def main() -> int:
|
||||
ap.add_argument("--viewport", default="1280x820", help="browser size, e.g. 2560x1440")
|
||||
ap.add_argument("--update", action="store_true", help="pretend a new FramePort release exists (update UI: "
|
||||
"changelog dialog, what's new bar + dialog, Settings changelog)")
|
||||
ap.add_argument("--announcement", action="store_true", help="show the announcement pop-up (with a picture, "
|
||||
"then text only); nothing is fetched")
|
||||
ap.add_argument("--fake-frame", action="store_true", help="pretend a Steam Frame is connected (no device needed)")
|
||||
ap.add_argument("--hover", default=None, help="x,y: move the mouse there before the Library screenshot")
|
||||
ap.add_argument("--install-questions", nargs="+", metavar="PKG", default=None,
|
||||
@@ -139,8 +137,7 @@ def main() -> int:
|
||||
("monitor", lambda a: a.go("monitor")), ("monitor-details", open_monitor_details),
|
||||
("tools", lambda a: a.go("settings")),
|
||||
# the index on the left: "This PC" scrolls the page there
|
||||
("settings-index", lambda a: a.page.run_task(a.settings_view.show_section, "pc")),
|
||||
("settings-installing", lambda a: a.page.run_task(a.settings_view.show_section, "installing"))]
|
||||
("settings-index", lambda a: a.page.run_task(a.settings_view.show_section, "pc"))]
|
||||
if game:
|
||||
steps.insert(1, ("game", lambda a: a.open_game(game)))
|
||||
steps.insert(2, ("game-customize", lambda a: a.open_game(game, advanced=True)))
|
||||
@@ -423,26 +420,6 @@ def main() -> int:
|
||||
print(f"open dialogs: {len([d for d in a.page._dialogs.controls if d.open])}, queued: {queued}", flush=True)
|
||||
steps = [("library", lambda a: a.navigate(0)), ("questions", open_questions),
|
||||
("after-1st-double", double_click), ("after-2nd-double", double_click), ("end", report)]
|
||||
if args.announcement:
|
||||
import shutil
|
||||
|
||||
from frameport import announcements
|
||||
from frameport.core.paths import user_data_dir
|
||||
|
||||
text = ("---\nid: smoke\ntitle: FramePort 1.3 brings the Monitor to every Frame\ndate: 2026-10-14T12:00:00Z\n"
|
||||
"---\n"
|
||||
"The **Monitor** tab now shows fps, battery and temperatures for any game the Frame runs, including "
|
||||
"Steam's own. Open it from the sidebar's Frame card.\n\n"
|
||||
"- Install links from FrameDrop buttons open in FramePort\n"
|
||||
"- Uploads over the USB cable are about **3× faster**\n\n"
|
||||
"Questions? Ask in [Discussions](https://example.invalid).")
|
||||
news = announcements.parse(text)
|
||||
pic = user_data_dir() / "announcements" / "smoke.png"
|
||||
pic.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(Path(__file__).resolve().parents[1] / "docs" / "images" / "library.png", pic)
|
||||
steps = [("announcement-image", lambda a: (a.navigate(0), a.announcer.show(news, pic))),
|
||||
("announcement-text", lambda a: (a.page.pop_dialog(), time.sleep(1.5), a.announcer.show(
|
||||
announcements.parse(text.replace("id: smoke", "id: smoke2")))))]
|
||||
if args.update:
|
||||
from frameport import __version__, updates
|
||||
|
||||
|
||||
@@ -144,11 +144,6 @@
|
||||
"title": "Vibration strength",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.hide_fb_foveation": {
|
||||
"anchor": "adapter-hide_fb_foveation",
|
||||
"title": "Turn off Quest Vulkan foveation",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.hide_space_warp": {
|
||||
"anchor": "adapter-hide_space_warp",
|
||||
"title": "Turn off space warp",
|
||||
@@ -594,11 +589,6 @@
|
||||
"title": "Unreal: match multiview depth to the eye size",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unreal_noreserve": {
|
||||
"anchor": "frame-unreal_noreserve",
|
||||
"title": "Unreal: reserve memory like on Quest",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.unreal_ovrp_entrypoints": {
|
||||
"anchor": "frame-unreal_ovrp_entrypoints",
|
||||
"title": "Unreal: stand-ins for OVRPlugin functions OVRPort lacks",
|
||||
|
||||
@@ -43,10 +43,7 @@ IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
|
||||
# 17: headset (the Meta headset a VR app was made for: Gear VR / Go games run through frame.a32_bridge) (2026-10)
|
||||
# 18: headset "quest" also for 32-bit apps that require 6DoF head tracking (Santa Cruz builds, e.g. Sports
|
||||
# Scramble: Touch controllers on the a32 bridge) (2026-10)
|
||||
# 19: unreal_obb_delivery also recognises obfuscated DownloaderActivity predicates (GitHub #159, Medal of Honor)
|
||||
# 20: unreal_binned3 (Unreal built with FMallocBinned3 reserves tens of GiB at load: frame.unreal_noreserve,
|
||||
# GitHub #349) (2026-10)
|
||||
ANALYSIS_VERSION = 20
|
||||
ANALYSIS_VERSION = 19
|
||||
|
||||
|
||||
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
|
||||
@@ -90,13 +87,6 @@ def cryengine_project(data: bytes) -> bool:
|
||||
UE_VERIFY_OBB_KEY = ".GameActivity.bVerifyOBBOnStartUp"
|
||||
|
||||
|
||||
def unreal_binned3(engine: bytes) -> bool:
|
||||
"""Unreal built with FMallocBinned3 as its allocator (its own messages and source path are compiled in; other
|
||||
builds only name its console variables). It reserves its whole range while the library loads (GitHub #349)."""
|
||||
return (b"/HAL/MallocBinned3.cpp\0" in engine
|
||||
or "FMallocBinned3 Attempt to free".encode("utf-16-le") in engine)
|
||||
|
||||
|
||||
def unreal_verify_obb(meta: dict) -> bool:
|
||||
"""Unreal's DownloaderActivity checks every OBB entry's CRC at the first start (a full read; on a mismatch it waits
|
||||
on an error screen). Only a real boolean counts: GameActivity reads it with Bundle.getBoolean."""
|
||||
@@ -420,10 +410,6 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
# 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 Binned3 allocator reserves more address space than the Frame's kernel grants in one writable
|
||||
# mapping (frame.unreal_noreserve, GitHub #349)
|
||||
"unreal_binned3": (unreal_binned3(lib_bytes.get(engine_lib.rsplit("/", 1)[1], b""))
|
||||
if engine_lib else False),
|
||||
# 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 []),
|
||||
|
||||
@@ -20,23 +20,6 @@ def _elf(data: bytes) -> ELFFile:
|
||||
return ELFFile(io.BytesIO(data))
|
||||
|
||||
|
||||
def dynsym_section(elf: ELFFile):
|
||||
"""The dynamic symbol table, found by section type: some shipped libraries have a scrambled section name table
|
||||
(GitHub #349: Transformers Beyond Reality's libOVRPlugin.so calls .dynsym "text"), types and links are intact."""
|
||||
sec = elf.get_section_by_name(".dynsym")
|
||||
if isinstance(sec, SymbolTableSection) and sec["sh_type"] == "SHT_DYNSYM":
|
||||
return sec
|
||||
return next((s for s in elf.iter_sections() if s["sh_type"] == "SHT_DYNSYM"), None)
|
||||
|
||||
|
||||
def dynstr_section(elf: ELFFile, dynsym=None):
|
||||
"""The string table of the dynamic symbols (dynsym's sh_link; by name as a fallback)."""
|
||||
dynsym = dynsym if dynsym is not None else dynsym_section(elf)
|
||||
if dynsym is not None and 0 < dynsym["sh_link"] < elf.num_sections():
|
||||
return elf.get_section(dynsym["sh_link"])
|
||||
return elf.get_section_by_name(".dynstr")
|
||||
|
||||
|
||||
def needed(data: bytes) -> list[str]:
|
||||
elf = _elf(data)
|
||||
for sec in elf.iter_sections():
|
||||
@@ -65,7 +48,7 @@ def dyn_symbols(data: bytes, defined: bool) -> set[str]:
|
||||
@lru_cache(maxsize=256)
|
||||
def _dyn_symbols_cached(data: bytes, defined: bool) -> frozenset[str]:
|
||||
elf = _elf(data)
|
||||
sec = dynsym_section(elf)
|
||||
sec = elf.get_section_by_name(".dynsym")
|
||||
out = set()
|
||||
if isinstance(sec, SymbolTableSection):
|
||||
for sym in sec.iter_symbols():
|
||||
@@ -86,8 +69,7 @@ def find_symbols(data: bytes, names) -> dict[str, tuple[int, int]]:
|
||||
if not is_elf(data) or data[4] != 2 or data[5] != 1:
|
||||
return {}
|
||||
elf = _elf(data)
|
||||
dynsym = dynsym_section(elf)
|
||||
dynstr = dynstr_section(elf, dynsym)
|
||||
dynsym, dynstr = elf.get_section_by_name(".dynsym"), elf.get_section_by_name(".dynstr")
|
||||
if dynsym is None or dynstr is None:
|
||||
return {}
|
||||
sym_off, sym_size = dynsym["sh_offset"], dynsym["sh_size"]
|
||||
@@ -165,7 +147,7 @@ def hide_exports(data: bytes, names) -> tuple[bytes, list[str]]:
|
||||
(native/langpack) still finds the original address. Returns (data, names actually hidden); ELF64 only."""
|
||||
if not is_elf(data) or not is_64bit(data) or data[5] != 1:
|
||||
return data, []
|
||||
sec = dynsym_section(_elf(data))
|
||||
sec = _elf(data).get_section_by_name(".dynsym")
|
||||
if not isinstance(sec, SymbolTableSection):
|
||||
return data, []
|
||||
wanted = set(names)
|
||||
@@ -189,7 +171,7 @@ def redirect_exports(data: bytes, names) -> tuple[bytes, list[str]]:
|
||||
"""
|
||||
if not is_elf(data) or not is_64bit(data) or data[5] != 1:
|
||||
return data, []
|
||||
sec = dynsym_section(_elf(data))
|
||||
sec = _elf(data).get_section_by_name(".dynsym")
|
||||
if not isinstance(sec, SymbolTableSection):
|
||||
return data, []
|
||||
wanted = set(names)
|
||||
@@ -219,11 +201,10 @@ def rename_imports(data: bytes, renames: dict[str, str]) -> tuple[bytes, list[st
|
||||
if len(new) > len(old):
|
||||
raise ValueError(f"{new} is longer than {old}")
|
||||
elf = _elf(data)
|
||||
dynsym = dynsym_section(elf)
|
||||
dynstr = dynstr_section(elf, dynsym)
|
||||
dynsym, dynstr = elf.get_section_by_name(".dynsym"), elf.get_section_by_name(".dynstr")
|
||||
if not isinstance(dynsym, SymbolTableSection) or dynstr is None:
|
||||
return data, []
|
||||
versym = next((s for s in elf.iter_sections() if s["sh_type"] == "SHT_GNU_versym"), None)
|
||||
versym = elf.get_section_by_name(".gnu.version")
|
||||
str_off = dynstr["sh_offset"]
|
||||
targets, refs = {}, {} # dynsym index -> st_name of an import to rename; st_name -> referencing dynsym indices
|
||||
for i, sym in enumerate(dynsym.iter_symbols()):
|
||||
@@ -395,23 +376,24 @@ def add_needed(data: bytes, library: str) -> bytes:
|
||||
write_ph(i, p)
|
||||
|
||||
# section headers: point .dynamic / .dynstr at the new copies (for tools; the loader uses program headers)
|
||||
# Found by type and link, not by name: some shipped libraries have a scrambled section name table (GitHub #349,
|
||||
# Transformers Beyond Reality's libOVRPlugin.so names .dynamic "dding"), whose types and links are still right.
|
||||
if shoff and shnum:
|
||||
SHT_DYNAMIC = 6
|
||||
shstrndx = eh[13]
|
||||
def sh(i):
|
||||
return list(struct.unpack_from(sh_fmt, buf, shoff + i * shentsize))
|
||||
headers = [sh(i) for i in range(shnum)] # sh_name, sh_type, sh_flags, sh_addr, sh_offset, sh_size, sh_link
|
||||
dyn_secs = [i for i, h in enumerate(headers) if h[1] == SHT_DYNAMIC]
|
||||
str_secs = {headers[i][6] for i in dyn_secs if 0 < headers[i][6] < shnum}
|
||||
str_secs |= {i for i, h in enumerate(headers) if h[3] == strtab_va and h[3]} # .dynstr by address
|
||||
for i in dyn_secs:
|
||||
h = headers[i]
|
||||
h[3], h[4], h[5] = va_start + dyn_off, file_end + dyn_off, len(dyn_bytes)
|
||||
struct.pack_into(sh_fmt, buf, shoff + i * shentsize, *h)
|
||||
for i in str_secs - set(dyn_secs):
|
||||
h = headers[i]
|
||||
h[3], h[4], h[5] = va_start + str_off, file_end + str_off, len(new_str)
|
||||
names_hdr = sh(shstrndx)
|
||||
names_off = names_hdr[4]
|
||||
def sec_name(h):
|
||||
end = buf.index(b"\0", names_off + h[0])
|
||||
return bytes(buf[names_off + h[0]:end]).decode()
|
||||
for i in range(shnum):
|
||||
h = sh(i)
|
||||
n = sec_name(h)
|
||||
if n == ".dynstr":
|
||||
h[3], h[4], h[5] = va_start + str_off, file_end + str_off, len(new_str)
|
||||
elif n == ".dynamic":
|
||||
h[3], h[4], h[5] = va_start + dyn_off, file_end + dyn_off, len(dyn_bytes)
|
||||
else:
|
||||
continue
|
||||
struct.pack_into(sh_fmt, buf, shoff + i * shentsize, *h)
|
||||
result = bytes(buf)
|
||||
got = needed(result)
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
"""Announcements: one short message from the maintainer, shown once as a pop-up in the app.
|
||||
|
||||
Source of truth: `announcements/current.md` on GitHub `main` (front matter + Markdown, announcements/README.md), read
|
||||
through raw.githubusercontent like the catalog (cached, every 6 h), so a push reaches users without a release.
|
||||
|
||||
Who sees it: only people who were already using FramePort before the announcement's `date` (a first start never
|
||||
shows one, and nor does one published before FramePort was installed). Closing it in any way (the X, a click outside,
|
||||
Escape, its button) dismisses that `id` for good; a new announcement = a new id. No Flet here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from . import REPO_URL
|
||||
from .core import cache, library
|
||||
from .core.paths import user_data_dir
|
||||
|
||||
_log = logging.getLogger("frameport.announcements")
|
||||
|
||||
REPO = REPO_URL.removeprefix("https://github.com/").strip("/")
|
||||
PATH = "announcements/current.md"
|
||||
RAW = f"https://raw.githubusercontent.com/{REPO}/main/"
|
||||
URL = RAW + PATH
|
||||
CHECK_EVERY = 6 * 3600
|
||||
KEEP_DISMISSED = 50 # ids remembered (oldest dropped)
|
||||
IMAGE_MAX = 5 * 2**20
|
||||
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$")
|
||||
# settings only an earlier GUI run writes: one of them when announcements first run = an existing user (not
|
||||
# recipes.app_version: library.load() writes that on the very first load)
|
||||
USED_BEFORE = ("update.last_seen_version", "update.last_check", "ui.welcome_done", "ui.window")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Announcement:
|
||||
id: str
|
||||
title: str
|
||||
body: str # Markdown
|
||||
date: float # unix time it was published
|
||||
image: str | None = None # https URL (repo paths are resolved to raw GitHub)
|
||||
image_alt: str = ""
|
||||
button: str | None = None # optional action: label + url
|
||||
url: str | None = None
|
||||
expires: float | None = None
|
||||
|
||||
|
||||
class AnnouncementError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _when(value, end_of_day: bool = False) -> float:
|
||||
"""A front-matter date (YAML date/datetime or ISO text) as unix time; a bare date is UTC midnight (or the end of
|
||||
that day for `expires`). Naive times are UTC."""
|
||||
if isinstance(value, str):
|
||||
text = value.strip().replace("Z", "+00:00")
|
||||
try:
|
||||
value = dt.datetime.fromisoformat(text) if "T" in text or " " in text else dt.date.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise AnnouncementError(f"not a date: {value!r}") from exc
|
||||
if isinstance(value, dt.datetime):
|
||||
return (value if value.tzinfo else value.replace(tzinfo=dt.timezone.utc)).timestamp()
|
||||
if isinstance(value, dt.date):
|
||||
day = dt.datetime(value.year, value.month, value.day, tzinfo=dt.timezone.utc)
|
||||
return (day + dt.timedelta(days=1 if end_of_day else 0)).timestamp()
|
||||
raise AnnouncementError(f"not a date: {value!r}")
|
||||
|
||||
|
||||
def _link(value) -> str | None:
|
||||
"""An image/button target: https URLs as they are, a repo path (announcements/x.png) → its raw GitHub URL."""
|
||||
if value in (None, ""):
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if text.startswith("https://"):
|
||||
return text
|
||||
if "://" in text or text.startswith(("/", "..")) or ".." in text.split("/"):
|
||||
raise AnnouncementError(f"use an https URL or a path in the repo: {text!r}")
|
||||
return RAW + text
|
||||
|
||||
|
||||
def parse(text: str | None) -> Announcement | None:
|
||||
"""The file's announcement; None when there is none (empty file, `active: false`). Raises AnnouncementError for a
|
||||
malformed one (the app then shows nothing; tests/test_announcements.py checks the committed file)."""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
m = re.match(r"\A?---\s*\n(.*?)\n---\s*(?:\n|\Z)(.*)\Z", text, re.S)
|
||||
if not m:
|
||||
raise AnnouncementError("missing the --- front matter")
|
||||
try:
|
||||
meta = yaml.safe_load(m.group(1)) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise AnnouncementError(f"front matter: {exc}") from exc
|
||||
if not isinstance(meta, dict):
|
||||
raise AnnouncementError("front matter must be key: value lines")
|
||||
active = meta.get("active", True)
|
||||
if active is False and not meta.get("id"):
|
||||
return None
|
||||
if not isinstance(active, bool):
|
||||
raise AnnouncementError("active: true or false")
|
||||
unknown = set(meta) - {"id", "title", "date", "image", "image_alt", "button", "url", "expires", "active"}
|
||||
if unknown:
|
||||
raise AnnouncementError(f"unknown fields: {', '.join(sorted(unknown))}")
|
||||
ident, title = str(meta.get("id") or "").strip(), str(meta.get("title") or "").strip()
|
||||
if not ID_RE.match(ident):
|
||||
raise AnnouncementError("id: letters, digits, '.', '_' or '-' (change it to show a new announcement)")
|
||||
if not title:
|
||||
raise AnnouncementError("title is required")
|
||||
if "date" not in meta:
|
||||
raise AnnouncementError("date is required (only people using FramePort before it see the announcement)")
|
||||
button, url = meta.get("button"), meta.get("url")
|
||||
if bool(button) != bool(url):
|
||||
raise AnnouncementError("button and url go together")
|
||||
if not active: # switched off, but still checked (tests catch mistakes before it's switched on)
|
||||
_check_dates(meta)
|
||||
return None
|
||||
return Announcement(id=ident, title=title, body=m.group(2).strip(), date=_when(meta["date"]),
|
||||
image=_link(meta.get("image")), image_alt=str(meta.get("image_alt") or ""),
|
||||
button=str(button).strip() if button else None, url=_link(url),
|
||||
expires=_when(meta["expires"], end_of_day=True) if meta.get("expires") else None)
|
||||
|
||||
|
||||
def _check_dates(meta: dict) -> None:
|
||||
_when(meta["date"])
|
||||
if meta.get("expires"):
|
||||
_when(meta["expires"], end_of_day=True)
|
||||
_link(meta.get("image"))
|
||||
_link(meta.get("url"))
|
||||
|
||||
|
||||
def disabled() -> bool:
|
||||
return bool(os.environ.get("FRAMEPORT_NO_ANNOUNCEMENTS"))
|
||||
|
||||
|
||||
def fetch(force: bool = False) -> Announcement | None:
|
||||
"""The current announcement (cached 6 h; offline → the last copy; never raises)."""
|
||||
try:
|
||||
return parse(cache.cached_text("announcement.md", URL, max_age=0 if force else CHECK_EVERY, fallback=""))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_log.warning("announcement skipped: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ who sees it
|
||||
def since() -> float:
|
||||
"""When this user started using FramePort, as far as announcements are concerned (setting announce.since). Set
|
||||
once, the first time it's asked: 0 for someone who used FramePort before announcements existed, else now (a first
|
||||
start). Call it early at start, before anything records this start in the settings."""
|
||||
value = library.setting("announce.since")
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
value = 0.0 if any(library.setting(k) is not None for k in USED_BEFORE) or library.games() else time.time()
|
||||
library.set_setting("announce.since", value)
|
||||
return value
|
||||
|
||||
|
||||
def dismissed() -> list[str]:
|
||||
value = library.setting("announce.dismissed")
|
||||
return [v for v in value if isinstance(v, str)] if isinstance(value, list) else []
|
||||
|
||||
|
||||
def dismiss(ident: str) -> None:
|
||||
"""Never show this announcement again (any way of closing it)."""
|
||||
library.update_setting("announce.dismissed",
|
||||
lambda cur: ([v for v in (cur or []) if v != ident] + [ident])[-KEEP_DISMISSED:], [])
|
||||
|
||||
|
||||
def due(a: Announcement | None, now: float | None = None, user_since: float | None = None) -> bool:
|
||||
"""Whether to show `a` now: published, not expired, not dismissed, and published after this
|
||||
user started using FramePort."""
|
||||
if a is None:
|
||||
return False
|
||||
now = time.time() if now is None else now
|
||||
if a.expires is not None and now >= a.expires:
|
||||
return False
|
||||
if a.date > now: # scheduled for later
|
||||
return False
|
||||
if a.id in dismissed():
|
||||
return False
|
||||
return a.date > (since() if user_since is None else user_since)
|
||||
|
||||
|
||||
def image_file(a: Announcement) -> Path | None:
|
||||
"""The announcement's image, downloaded once into <data>/announcements/ and checked (Pillow); None without one or
|
||||
when it can't be loaded (the pop-up then shows no image). Shown by asset URL like artwork, never as bytes."""
|
||||
if not a.image:
|
||||
return None
|
||||
folder = user_data_dir() / "announcements"
|
||||
stem = hashlib.sha256(a.image.encode()).hexdigest()[:16]
|
||||
for done in folder.glob(f"{stem}.*"):
|
||||
if done.suffix != ".part":
|
||||
return done
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
tmp = folder / f"{stem}.part"
|
||||
cache.download(a.image, tmp)
|
||||
if tmp.stat().st_size > IMAGE_MAX:
|
||||
raise AnnouncementError("image larger than 5 MiB")
|
||||
with Image.open(tmp) as im:
|
||||
im.verify()
|
||||
fmt = (im.format or "").lower()
|
||||
ext = {"jpeg": ".jpg", "png": ".png", "webp": ".webp", "gif": ".gif"}.get(fmt)
|
||||
if not ext:
|
||||
raise AnnouncementError(f"image format {fmt or '?'} (use PNG, JPEG, WebP or GIF)")
|
||||
out = folder / f"{stem}{ext}"
|
||||
tmp.replace(out)
|
||||
return out
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_log.warning("announcement image skipped: %s", exc)
|
||||
(folder / f"{stem}.part").unlink(missing_ok=True)
|
||||
return None
|
||||
@@ -730,31 +730,6 @@ def frame_trust_key(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
typer.echo(f"connected: {info.get('hostname') or t.host} (new key saved)")
|
||||
|
||||
|
||||
@frame_app.command("ssh-keys")
|
||||
def frame_ssh_keys(add: list[str] = typer.Option([], "--add", help="a private key file to log in with too"),
|
||||
remove: list[str] = typer.Option([], "--remove", help="stop using this key file (it stays)")):
|
||||
"""Private SSH key files FramePort logs in with besides its own key (Settings → Installing → SSH keys): for a
|
||||
Frame that accepts only your own key. The Frame connection and OpenSSH uploads both use them. Lists them."""
|
||||
from .frame.connection import KeyFileError, add_extra_key, extra_key_status, remove_extra_key
|
||||
|
||||
failed = False
|
||||
for path in remove:
|
||||
typer.echo(f"removed {path}" if remove_extra_key(path) else f"not in the list: {path}")
|
||||
for path in add:
|
||||
try:
|
||||
typer.echo(f"added {add_extra_key(path)}")
|
||||
except KeyFileError as exc:
|
||||
typer.echo(f"can't use {path}: {exc}", err=True)
|
||||
failed = True
|
||||
status = extra_key_status()
|
||||
for path, problem in status:
|
||||
typer.echo(f"{path}" + (f" (not usable: {problem})" if problem else ""))
|
||||
if not status:
|
||||
typer.echo("no extra keys: FramePort's own key and your SSH agent's keys only")
|
||||
if failed:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@frame_app.command("usb-check")
|
||||
def frame_usb_check(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
no_speed: bool = typer.Option(False, help="skip the upload speed test (2 × 128 MB)")):
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""SSH connection to a Steam Frame and the remote agent protocol.
|
||||
|
||||
Authentication: FramePort's own keys first (<user data>/ssh/id_ed25519, installed by the bootstrap script, and
|
||||
id_rsa, registered through Valve's devkit pairing, which only takes RSA keys), then key files the user added in
|
||||
Settings (EXTRA_KEYS_SETTING), then the user's SSH agent/keys, then a password if given.
|
||||
id_rsa, registered through Valve's devkit pairing, which only takes RSA keys), then the user's SSH agent/keys, then a
|
||||
password if given.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -163,96 +163,6 @@ def app_keys() -> list[paramiko.PKey]:
|
||||
return keys
|
||||
|
||||
|
||||
EXTRA_KEYS_SETTING = "ssh.extra_keys" # library setting: private key files the user added (Settings → Installing)
|
||||
|
||||
|
||||
class KeyFileError(ValueError):
|
||||
"""A key file the user named can't be used (missing, not a private key, protected with a passphrase)."""
|
||||
|
||||
|
||||
def load_key_file(path) -> paramiko.PKey:
|
||||
"""A private key FramePort can log in with, or KeyFileError with a sentence saying why not."""
|
||||
p = Path(path).expanduser()
|
||||
if not p.is_file():
|
||||
raise KeyFileError(f"{p}: file not found")
|
||||
try:
|
||||
return paramiko.PKey.from_path(p)
|
||||
except (paramiko.SSHException, ValueError, OSError, UnicodeDecodeError, TypeError) as exc:
|
||||
# paramiko says so with PasswordRequiredException (PEM) or a plain SSHException (OpenSSH format)
|
||||
if isinstance(exc, paramiko.PasswordRequiredException) or "password" in str(exc).lower():
|
||||
raise KeyFileError(f"{p.name} is protected with a passphrase, which FramePort can't ask for: add the "
|
||||
"key to your SSH agent instead (FramePort also logs in with the agent's keys)") from None
|
||||
raise KeyFileError(f"{p.name} isn't a private SSH key FramePort can read "
|
||||
f"(OpenSSH or PEM format; a .pub file is the public half): {exc}") from None
|
||||
|
||||
|
||||
def extra_key_paths() -> list[Path]:
|
||||
"""The key files from Settings, as stored (each may be missing or unreadable by now: see extra_keys)."""
|
||||
try:
|
||||
from ..core import library
|
||||
|
||||
stored = library.peek_setting(EXTRA_KEYS_SETTING, [])
|
||||
except Exception: # noqa: BLE001 - no library yet
|
||||
return []
|
||||
if isinstance(stored, str):
|
||||
stored = [stored]
|
||||
paths: list[Path] = []
|
||||
for value in stored if isinstance(stored, list) else []:
|
||||
if isinstance(value, str) and value.strip():
|
||||
p = Path(value.strip()).expanduser()
|
||||
if p not in paths:
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
|
||||
def add_extra_key(path) -> Path:
|
||||
"""Check a key file (KeyFileError if FramePort can't use it) and add it to Settings. Returns the stored path."""
|
||||
p = Path(path).expanduser()
|
||||
if p.name.endswith(".pub") and p.with_name(p.name[:-4]).is_file():
|
||||
raise KeyFileError(f"{p.name} is the public half: pick {p.name[:-4]} (the file without .pub) instead")
|
||||
load_key_file(p)
|
||||
p = p.resolve()
|
||||
from ..core import library
|
||||
|
||||
library.update_setting(EXTRA_KEYS_SETTING, lambda old: [v for v in (old if isinstance(old, list) else [])
|
||||
if Path(v).expanduser() != p] + [str(p)], [])
|
||||
return p
|
||||
|
||||
|
||||
def remove_extra_key(path) -> bool:
|
||||
"""Take a key file out of Settings (the file itself stays). False if it wasn't there."""
|
||||
from ..core import library
|
||||
|
||||
p = Path(path).expanduser()
|
||||
before = extra_key_paths()
|
||||
library.update_setting(EXTRA_KEYS_SETTING, lambda old: [v for v in (old if isinstance(old, list) else [])
|
||||
if Path(v).expanduser() != p], [])
|
||||
return p in before
|
||||
|
||||
|
||||
def extra_key_status() -> list[tuple[Path, str]]:
|
||||
"""Each key file from Settings with "" when usable, else why not (for Settings and the CLI)."""
|
||||
status = []
|
||||
for path in extra_key_paths():
|
||||
try:
|
||||
load_key_file(path)
|
||||
status.append((path, ""))
|
||||
except KeyFileError as exc:
|
||||
status.append((path, str(exc)))
|
||||
return status
|
||||
|
||||
|
||||
def extra_keys() -> list[tuple[Path, paramiko.PKey]]:
|
||||
"""The usable keys from Settings; unusable ones are logged and skipped (a login still tries the others)."""
|
||||
keys = []
|
||||
for path in extra_key_paths():
|
||||
try:
|
||||
keys.append((path, load_key_file(path)))
|
||||
except KeyFileError as exc:
|
||||
log.warning("SSH key from Settings skipped: %s", exc)
|
||||
return keys
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameTarget:
|
||||
host: str
|
||||
@@ -499,10 +409,6 @@ class Frame:
|
||||
_video_codec_digest: str = "" # "<manifest digest>:<on|off>" handled on this connection (incl. a failed install)
|
||||
_video_codec_lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
home: str = ""
|
||||
# how the last login worked: "app_key" (FramePort's own key), "user_key" (a key file from Settings),
|
||||
# "agent" (this PC's SSH agent / ~/.ssh keys) or "password"; OpenSSH uploads follow it (native_upload)
|
||||
auth: str = ""
|
||||
openssh_relaxed: bool | None = None # native_upload: True once OpenSSH needed this PC's own keys/agent
|
||||
|
||||
# ------------------------------------------------------------------ connect
|
||||
def connect(self, timeout: float = 10) -> Frame:
|
||||
@@ -553,8 +459,7 @@ class Frame:
|
||||
kwargs = dict(hostname=self.target.host, port=self.target.port, username=self.target.user, timeout=timeout,
|
||||
banner_timeout=timeout, auth_timeout=timeout)
|
||||
errors = []
|
||||
attempts = ([("app_key", k) for k in app_keys()] + [("user_key", k) for _, k in extra_keys()]
|
||||
+ [("agent", None), ("password", None)])
|
||||
attempts = [("app_key", k) for k in app_keys()] + [("agent", None), ("password", None)]
|
||||
for attempt, pkey in attempts:
|
||||
if attempt == "password" and not self.password:
|
||||
continue
|
||||
@@ -562,7 +467,7 @@ class Frame:
|
||||
client.load_host_keys(str(known))
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # trust on first use (pairing)
|
||||
try:
|
||||
if attempt in ("app_key", "user_key"):
|
||||
if attempt == "app_key":
|
||||
client.connect(pkey=pkey, allow_agent=False, look_for_keys=False, **kwargs)
|
||||
elif attempt == "agent":
|
||||
client.connect(allow_agent=True, look_for_keys=True, **kwargs)
|
||||
@@ -587,7 +492,6 @@ class Frame:
|
||||
raise
|
||||
else:
|
||||
raise FrameNotPaired(bool(self.password), errors)
|
||||
self.auth = attempt
|
||||
client.save_host_keys(str(known))
|
||||
transport = client.get_transport()
|
||||
transport.set_keepalive(15)
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
@@ -12,30 +13,19 @@ import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from pathlib import Path
|
||||
|
||||
from .connection import extra_keys, sh_quote, ssh_dir
|
||||
from .connection import sh_quote, ssh_dir
|
||||
|
||||
|
||||
def quote(value, windows=False) -> str:
|
||||
"""One sftp batch argument, taken literally.
|
||||
|
||||
Inside double quotes sftp escapes glob characters (* ? [) itself and undoes that for put's destination, so
|
||||
they must NOT be escaped again: an extra backslash became part of the name (GitHub #324: "[ENG]" in a folder
|
||||
name made every OpenSSH upload fail, on Linux too). Only backslash and the quote are escaped. ``windows``: a
|
||||
local Windows path goes with forward slashes; Win32-OpenSSH's sftp turns every backslash of a local path into
|
||||
'/', escapes included ("C:\\\\Users" → "C://Users").
|
||||
"""
|
||||
def quote(value) -> str:
|
||||
value = str(value)
|
||||
if any(c in value for c in "\r\n\x00"):
|
||||
raise ValueError("SFTP paths cannot contain newlines or NUL")
|
||||
if windows:
|
||||
value = PureWindowsPath(value).as_posix()
|
||||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def quote_local(path) -> str:
|
||||
return quote(path, windows=os.name == "nt")
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
for char in "*?[":
|
||||
escaped = escaped.replace(char, "\\" + char)
|
||||
return '"' + escaped + '"'
|
||||
|
||||
|
||||
def size_of(sftp, path):
|
||||
@@ -45,74 +35,27 @@ def size_of(sftp, path):
|
||||
return 0
|
||||
|
||||
|
||||
def key_files(relaxed=False) -> list[Path]:
|
||||
"""The private keys OpenSSH offers: FramePort's own (<data>/ssh) and the usable key files from Settings;
|
||||
``relaxed`` also this PC's default keys (default_key_files)."""
|
||||
keys = [ssh_dir() / name for name in ("id_ed25519", "id_rsa") if (ssh_dir() / name).exists()]
|
||||
for path in [path for path, _ in extra_keys()] + (default_key_files() if relaxed else []):
|
||||
if path not in keys:
|
||||
keys.append(path)
|
||||
return keys
|
||||
|
||||
|
||||
def default_key_files() -> list[Path]:
|
||||
"""This PC's own keys that Paramiko's "agent" login tries (look_for_keys: ~/.ssh/id_rsa, id_dsa, id_ecdsa,
|
||||
id_ed25519; on Windows ~/ssh too), usable without a passphrase. OpenSSH needs them named: it skips its default
|
||||
keys as soon as one -i is given, and Windows OpenSSH refuses a key file others can read (a private copy can't
|
||||
be refused)."""
|
||||
from .connection import KeyFileError, load_key_file
|
||||
|
||||
folders = [Path.home() / ".ssh"] + ([Path.home() / "ssh"] if os.name == "nt" else [])
|
||||
found = []
|
||||
for folder in folders:
|
||||
for name in ("id_rsa", "id_dsa", "id_ecdsa", "id_ed25519"):
|
||||
path = folder / name
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
load_key_file(path)
|
||||
except KeyFileError:
|
||||
continue # passphrase-protected or not a key: only the agent can use it
|
||||
found.append(path)
|
||||
return found
|
||||
|
||||
|
||||
def current_sid() -> str:
|
||||
"""This Windows account's SID. whoami prints in the console code page: read bytes, so a user name with
|
||||
accents can't break decoding (only the ASCII SID is used)."""
|
||||
out = subprocess.check_output(["whoami", "/user", "/fo", "csv", "/nh"], **hidden())
|
||||
match = re.search(rb"S-1-[0-9]+(?:-[0-9]+)+", out)
|
||||
if not match:
|
||||
raise OSError("cannot find this Windows account's SID for the key copies' permissions")
|
||||
return match.group().decode()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def credentials(frame, port_flag="-P", relaxed=False):
|
||||
"""OpenSSH arguments that log in like the Frame connection and trust only the host key it pinned.
|
||||
|
||||
Keys are offered as private temporary copies (that avoids modifying pairing keys, including their Windows
|
||||
ACLs; Windows OpenSSH refuses key files others can read). Strict (default): FramePort's keys and the key
|
||||
files from Settings only (IdentitiesOnly). ``relaxed``: also this PC's default ~/.ssh keys and SSH agent, the
|
||||
way Paramiko's "agent" login does (GitHub #242: a Frame that only authorizes the user's own key). Verbose
|
||||
(-v), so a refused login can say which keys were offered (failure()).
|
||||
"""
|
||||
key_paths = key_files(relaxed)
|
||||
if not key_paths and not relaxed:
|
||||
def credentials(frame, port_flag="-P"):
|
||||
"""Private temporary copies avoid modifying pairing keys, including their Windows ACLs."""
|
||||
key_paths = [ssh_dir() / name for name in ("id_ed25519", "id_rsa") if (ssh_dir() / name).exists()]
|
||||
if not key_paths:
|
||||
raise OSError("no FramePort key available for OpenSSH")
|
||||
key = frame.client.get_transport().get_remote_server_key()
|
||||
with tempfile.TemporaryDirectory(prefix="frameport-upload-") as directory:
|
||||
directory = Path(directory)
|
||||
sid = None
|
||||
if os.name == "nt":
|
||||
sid = current_sid()
|
||||
row = next(csv.reader(io.StringIO(subprocess.check_output(
|
||||
["whoami", "/user", "/fo", "csv", "/nh"], text=True, **hidden()))))
|
||||
sid = row[-1]
|
||||
subprocess.run(["icacls", str(directory), "/inheritance:r", "/grant:r", f"*{sid}:(OI)(CI)F"],
|
||||
check=True, stdout=subprocess.DEVNULL, **hidden())
|
||||
else:
|
||||
directory.chmod(0o700)
|
||||
copies = []
|
||||
for n, path in enumerate(key_paths):
|
||||
copy = directory / f"key{n}" # own names: a key from Settings may be called id_ed25519 too
|
||||
for path in key_paths:
|
||||
copy = directory / path.name
|
||||
shutil.copyfile(path, copy)
|
||||
if sid:
|
||||
subprocess.run(["icacls", str(copy), "/inheritance:r", "/remove:g", "*S-1-3-4",
|
||||
@@ -123,72 +66,13 @@ def credentials(frame, port_flag="-P", relaxed=False):
|
||||
known = directory / "known_hosts"
|
||||
host = frame.target.host if frame.target.port == 22 else f"[{frame.target.host}]:{frame.target.port}"
|
||||
known.write_text(f"{host} {key.get_name()} {key.get_base64()}\n")
|
||||
yield ["-F", os.devnull, "-v", *copies, "-o", "BatchMode=yes",
|
||||
*([] if relaxed else ["-o", "IdentitiesOnly=yes"]),
|
||||
yield ["-F", os.devnull, *copies, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
|
||||
"-o", "StrictHostKeyChecking=yes", "-o", f'UserKnownHostsFile="{known.as_posix()}"',
|
||||
"-o", f"GlobalKnownHostsFile={os.devnull}", "-o", "ConnectTimeout=10",
|
||||
"-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3",
|
||||
port_flag, str(frame.target.port)]
|
||||
|
||||
|
||||
class OpenSSHAuthFailed(OSError):
|
||||
"""OpenSSH reached the Frame but the login was refused (the keys it offered aren't authorized there)."""
|
||||
|
||||
|
||||
AUTH_REFUSED = ("Permission denied (", "Too many authentication failures", "No more authentication methods")
|
||||
# debug lines (-v) worth keeping: which keys were found, offered, refused or unreadable
|
||||
AUTH_DEBUG = ("identity file", "Offering public key", "Server accepts key", "Will attempt key",
|
||||
"Authentications that can continue", "Authenticated to", "Load key", "no such identity",
|
||||
"Skipping", "agent", "Next authentication method")
|
||||
|
||||
|
||||
def failure(what: str, code, stderr: bytes) -> OSError:
|
||||
"""The error for a failed OpenSSH run: OpenSSH's own messages (all non-debug lines: warnings such as
|
||||
"UNPROTECTED PRIVATE KEY FILE" included) plus the -v lines about keys; OpenSSHAuthFailed for a refused login."""
|
||||
text = stderr.decode(errors="replace").replace("\r\n", "\n")
|
||||
lines = [line for line in text.splitlines() if line.strip() and (
|
||||
not line.startswith("debug") or any(k in line for k in AUTH_DEBUG))]
|
||||
detail = "\n".join(lines)
|
||||
if len(detail) > 4000:
|
||||
detail = "…" + detail[-4000:]
|
||||
refused = code == 255 and any(marker in text for marker in AUTH_REFUSED)
|
||||
return (OpenSSHAuthFailed if refused else OSError)(f"{what} failed ({code}): {detail}")
|
||||
|
||||
|
||||
def login_modes(frame) -> list[bool]:
|
||||
"""How OpenSSH logs in, in order (False = FramePort's keys only, True = also this PC's keys and agent).
|
||||
|
||||
A Frame connection that logged in with this PC's own keys/agent (Paramiko's "agent" attempt) goes straight to
|
||||
relaxed: FramePort's key isn't authorized there. Else strict first, relaxed once more after a refused login.
|
||||
A mode that worked is kept for the rest of the connection. FramePort doesn't add its key to the Frame for
|
||||
this: it only does that after a password login, where the user typed the Frame's password for it."""
|
||||
known = getattr(frame, "openssh_relaxed", None)
|
||||
if known is not None:
|
||||
return [bool(known)]
|
||||
if getattr(frame, "auth", "") == "agent":
|
||||
return [True]
|
||||
return [False, True]
|
||||
|
||||
|
||||
def with_login_modes(frame, reporter, run):
|
||||
"""run(relaxed) in each login mode until one isn't refused (see login_modes)."""
|
||||
modes = login_modes(frame)
|
||||
if modes == [True] and getattr(frame, "openssh_relaxed", None) is None:
|
||||
reporter.log("the Frame connection logged in with this PC's own SSH keys or agent: OpenSSH uses them too")
|
||||
for n, relaxed in enumerate(modes):
|
||||
try:
|
||||
result = run(relaxed)
|
||||
except OpenSSHAuthFailed as exc:
|
||||
if n == len(modes) - 1:
|
||||
raise
|
||||
reporter.log(f"OpenSSH login with FramePort's SSH keys was refused; trying this PC's own SSH keys and "
|
||||
f"agent too. {exc}")
|
||||
continue
|
||||
with contextlib.suppress(AttributeError):
|
||||
frame.openssh_relaxed = relaxed
|
||||
return result
|
||||
|
||||
|
||||
def hidden():
|
||||
return {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {}
|
||||
|
||||
@@ -215,11 +99,6 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
|
||||
Only successful batches are promoted, after every process has exited successfully. All control operations
|
||||
happen in the supervising thread, so Paramiko's SFTP client is never shared across threads.
|
||||
"""
|
||||
return with_login_modes(frame, reporter, lambda relaxed: _upload(
|
||||
frame, items, remote_root, reporter, total, sent, workers, relaxed))
|
||||
|
||||
|
||||
def _upload(frame, items, remote_root, reporter, total, sent, workers, relaxed):
|
||||
executable = shutil.which("sftp")
|
||||
if not executable:
|
||||
raise OSError("system OpenSSH SFTP is unavailable")
|
||||
@@ -232,7 +111,7 @@ def _upload(frame, items, remote_root, reporter, total, sent, workers, relaxed):
|
||||
sizes, offsets, progress = {}, {}, {}
|
||||
for path, rel, size in sorted(items, key=lambda item: item[2], reverse=True):
|
||||
remote = posixpath.join(remote_root, rel)
|
||||
quote_local(path)
|
||||
quote(path)
|
||||
quote(remote)
|
||||
offset = size_of(frame.sftp, remote + ".part")
|
||||
if offset > size:
|
||||
@@ -248,7 +127,7 @@ def _upload(frame, items, remote_root, reporter, total, sent, workers, relaxed):
|
||||
speed = Speed(total - sent - sum(offsets.values()))
|
||||
reporter.log(f"uploading large files with OpenSSH SFTP ({len(groups)} workers)")
|
||||
setup_started = time.monotonic()
|
||||
with credentials(frame, relaxed=relaxed) as args, contextlib.ExitStack() as stack:
|
||||
with credentials(frame) as args, contextlib.ExitStack() as stack:
|
||||
monitor = stack.enter_context(frame.client.open_sftp())
|
||||
monitor.get_channel().settimeout(2)
|
||||
reporter.log(f"OpenSSH credentials and progress connection ready in {time.monotonic() - setup_started:.2f}s")
|
||||
@@ -260,7 +139,7 @@ def _upload(frame, items, remote_root, reporter, total, sent, workers, relaxed):
|
||||
for path, rel, _ in group:
|
||||
remote = posixpath.join(remote_root, rel) + ".part"
|
||||
command = "reput" if offsets[rel] else "put"
|
||||
batch.write(f"{command} {quote_local(path)} {quote(remote)}\n".encode())
|
||||
batch.write(f"{command} {quote(path)} {quote(remote)}\n".encode())
|
||||
batch.seek(0)
|
||||
errors = stack.enter_context(tempfile.TemporaryFile())
|
||||
process = subprocess.Popen([executable, *args, "-b", "-",
|
||||
@@ -273,7 +152,8 @@ def _upload(frame, items, remote_root, reporter, total, sent, workers, relaxed):
|
||||
for process, errors in processes:
|
||||
if process.poll() not in (None, 0):
|
||||
errors.seek(0)
|
||||
raise failure("OpenSSH SFTP", process.returncode, errors.read())
|
||||
detail = errors.read().decode(errors="replace")[-1500:]
|
||||
raise OSError(f"OpenSSH SFTP failed ({process.returncode}): {detail}")
|
||||
for rel, size in sizes.items():
|
||||
reporter.check_cancel()
|
||||
progress[rel] = max(progress[rel], min(size, size_of(
|
||||
@@ -307,16 +187,11 @@ def _upload(frame, items, remote_root, reporter, total, sent, workers, relaxed):
|
||||
|
||||
def put_tar(frame, files, remote_root, reporter, progress):
|
||||
"""Stream a tar batch over paired OpenSSH; supervise writes so Cancel also stops a blocked pipe."""
|
||||
return with_login_modes(frame, reporter,
|
||||
lambda relaxed: _put_tar(frame, files, remote_root, reporter, progress, relaxed))
|
||||
|
||||
|
||||
def _put_tar(frame, files, remote_root, reporter, progress, relaxed):
|
||||
executable = shutil.which("ssh")
|
||||
if not executable:
|
||||
raise OSError("system OpenSSH SSH is unavailable")
|
||||
reporter.check_cancel()
|
||||
with credentials(frame, port_flag="-p", relaxed=relaxed) as args, tempfile.TemporaryFile() as errors:
|
||||
with credentials(frame, port_flag="-p") as args, tempfile.TemporaryFile() as errors:
|
||||
command = f"mkdir -p {sh_quote(remote_root)} && tar -xf - -C {sh_quote(remote_root)}"
|
||||
process = subprocess.Popen([executable, *args, f"{frame.target.user}@{frame.target.host}", command],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=errors,
|
||||
@@ -347,14 +222,12 @@ def _put_tar(frame, files, remote_root, reporter, progress, relaxed):
|
||||
if not writer.is_alive() and process.poll() is None:
|
||||
time.sleep(0.1)
|
||||
reporter.check_cancel()
|
||||
if failures and process.poll() is None: # a refused login shows as a broken pipe first
|
||||
with contextlib.suppress(subprocess.TimeoutExpired):
|
||||
process.wait(timeout=2)
|
||||
if process.poll() not in (None, 0):
|
||||
errors.seek(0)
|
||||
raise failure("OpenSSH tar", process.returncode, errors.read())
|
||||
if failures:
|
||||
raise failures[0]
|
||||
if process.poll() != 0:
|
||||
errors.seek(0)
|
||||
raise OSError(f"OpenSSH tar failed ({process.returncode}): "
|
||||
+ errors.read().decode(errors="replace")[-1500:])
|
||||
finally:
|
||||
stop_process(process)
|
||||
writer.join()
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
"AC Nexus: no AppSW at 72 Hz": "",
|
||||
"AC Nexus: no AppSW at 90 Hz": "",
|
||||
"Activity": "",
|
||||
"Add a key file…": "",
|
||||
"Add a Linux app folder…": "",
|
||||
"Add a Linux app…": "",
|
||||
"Add a PC game folder…": "",
|
||||
@@ -378,7 +377,6 @@
|
||||
"dev build": "",
|
||||
"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.": "",
|
||||
"Diagnostic for Vulkan games that fail on their first frames with missing Quest foveation images.": "",
|
||||
"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.": "",
|
||||
@@ -559,7 +557,6 @@
|
||||
"Frame OpenXR compatibility layer": "",
|
||||
"FrameBridge OpenXR adapter": "",
|
||||
"FramePort": "",
|
||||
"FramePort also logs in with {name} from the next connection.": "",
|
||||
"FramePort can't update itself in {value} (no permission) — opening the release page to download it": "",
|
||||
"FramePort changelog": "",
|
||||
"FramePort checks GitHub for a new version every few hours. Update now downloads and checks it, then restarts FramePort; your games and settings stay.": "",
|
||||
@@ -571,7 +568,6 @@
|
||||
"FramePort isn't sure which program starts this game. Open the game and use … → Change program… to pick it.": "",
|
||||
"FramePort keeps its own copies; nothing is installed system-wide": "",
|
||||
"FramePort live view": "",
|
||||
"FramePort logs in to the Frame with its own key. If your Frame accepts only one of your own keys, add that private key file here; fast uploads use it too. A key with a passphrase: add it to your SSH agent instead.": "",
|
||||
"FramePort looked at your last session of {n} game and has something to try.": [
|
||||
"",
|
||||
""
|
||||
@@ -681,7 +677,6 @@
|
||||
"Hide Vulkan foveation": "",
|
||||
"Hides a Quest-only way of saving GPU power that the Frame lacks. Leave it on.": "",
|
||||
"Hides Android's back, home and recents buttons, which cover the app's own buttons.": "",
|
||||
"Hides Quest FB foveation extensions from the game and OVRPort, removes Vulkan foveation image/create chains, and refuses foveation profile updates. Diagnostic for first-frame failures with missing density images (for example Transformers Beyond Reality: Redux). Device verification pending; does not disable Valve foveation.": "",
|
||||
"Hides Quest foveation extensions the Frame runtime lacks.": "",
|
||||
"Hides the Frame's brief focus dips (up to focus_hold_ms) once the game has been focused for a second. On by default: the Frame's wear sensor flickers \"HMD off\" for 0.5-2 s while worn, and many games pause or recenter every time (for example Blade & Sorcery, Lucky's Tale).": "",
|
||||
"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 (for example Unreal Engine 5 games such as Into The Radius 2).": "",
|
||||
@@ -842,7 +837,6 @@
|
||||
"Lets the game find extra content it keeps next to its data (for example Star Wars Tales' seasons).": "",
|
||||
"Lets the game finish loading when its separate loading splash stalls the render thread.": "",
|
||||
"Lets the game leave its loading screen: it waited for a Quest-only step that never starts on the Frame (for example Vader Immortal).": "",
|
||||
"Lets the game load: it reserves more memory at start than the Frame grants in one piece and crashed before its first frame (for example Transformers Beyond Reality).": "",
|
||||
"Lets the game use language files already in its data instead of waiting for a download that never comes.": "",
|
||||
"Lets the game use your controllers as a gamepad instead of a pointer, for 2D games with touch controls (for example SDL games).": "",
|
||||
"Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard. VR keeps working.": "",
|
||||
@@ -1035,7 +1029,6 @@
|
||||
"On your Frame": "",
|
||||
"on: {names}": "",
|
||||
"One PC game folder": "",
|
||||
"Only FramePort's own key, and your SSH agent's keys.": "",
|
||||
"Only if 360° pictures show wrongly.": "",
|
||||
"Only if 360° pictures show wrongly: 1 = upside down, 2 = mirrored, 4 = turned around (add values to combine).": "",
|
||||
"Only install software from sites you trust. FramePort downloads it from {host} and installs it on your Frame.": "",
|
||||
@@ -1148,7 +1141,6 @@
|
||||
"Pretty smooth": "",
|
||||
"Prevents a start-up crash in some Unreal games.": "",
|
||||
"Previous": "",
|
||||
"Private SSH key file (not the .pub file)": "",
|
||||
"Problems and feedback": "",
|
||||
"Processes": "",
|
||||
"Processor": "",
|
||||
@@ -1424,7 +1416,6 @@
|
||||
"Sound on": "",
|
||||
"Source": "",
|
||||
"square, 256 × 256": "",
|
||||
"SSH keys": "",
|
||||
"Stable (default)": "",
|
||||
"Stand in for cube layers": "",
|
||||
"Standard for every game": "",
|
||||
@@ -1475,7 +1466,6 @@
|
||||
"Stereo (3D) 360° layers: 0 = as the game sends them, 2 = flat (the left image to both eyes).": "",
|
||||
"Stop": "",
|
||||
"Stop objects popping in and out": "",
|
||||
"Stop using this key (the file stays)": "",
|
||||
"Stopped: {why}": "",
|
||||
"Stops a black picture in Minecraft launchers (for example QuestCraft).": "",
|
||||
"Stops a crash a few seconds after start in some Unreal games with anti-aliasing (for example Star Wars Pinball VR).": "",
|
||||
@@ -1527,7 +1517,6 @@
|
||||
"Test Proton on the Frame": "",
|
||||
"Text and layout size": "",
|
||||
"That APK is a different app, not {title}.": "",
|
||||
"That key can't be used: {error}": "",
|
||||
"That theme file can't be used: {error}": "",
|
||||
"That's me!": "",
|
||||
"The APK file is damaged or incomplete. Get the game again and rescan the folder.": "",
|
||||
@@ -1643,7 +1632,6 @@
|
||||
"Try this setting": "",
|
||||
"Turn it down if controller vibrations are too strong.": "",
|
||||
"Turn off only for games you play with your hands instead of controllers.": "",
|
||||
"Turn off Quest Vulkan foveation": "",
|
||||
"Turn off space warp": "",
|
||||
"Turn on Developer Mode first": "",
|
||||
"Turn on if your copy is already patched: the APK is installed unchanged. PC VR games are never changed on this PC.": "",
|
||||
@@ -1696,14 +1684,12 @@
|
||||
"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 reserves its memory ranges on Android with ordinary read/write mappings. Games built with Unreal's Binned3 allocator reserve tens of gigabytes at once while the engine loads (76 GiB in Transformers Beyond Reality); Quest allows that, the Frame's kernel refuses mappings larger than its memory, and the game crashes before its first frame (SIGSEGV at fault addr 0x10 in libUnreal.so, inside System.loadLibrary). This marks those mappings MAP_NORESERVE, as Quest effectively treats them: memory is still only used when the game touches it.": "",
|
||||
"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 delivery predicates return true, including recognized obfuscated versions, 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: reserve memory like on Quest": "",
|
||||
"Unreal: skip the OBB delivery check": "",
|
||||
"Unreal: stand-ins for OVRPlugin functions OVRPort lacks": "",
|
||||
"Unreal: start without the OBB check": "",
|
||||
|
||||
@@ -38,7 +38,7 @@ def _words(data: bytes | bytearray, off: int, n: int) -> list[int]:
|
||||
def _function_offset(data: bytes, size: int) -> int | None:
|
||||
"""File offset of FUNC in the loader (None: not an ELF, no such export, or shorter than `size` bytes)."""
|
||||
try:
|
||||
dynsym = elf.dynsym_section(elf._elf(data))
|
||||
dynsym = elf._elf(data).get_section_by_name(".dynsym")
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
found = dynsym.get_symbol_by_name(FUNC) if dynsym is not None else None
|
||||
|
||||
@@ -51,7 +51,7 @@ def _x0_address(data: bytes, segs, at: int, back: int = 8) -> int | None:
|
||||
|
||||
|
||||
def _function(data: bytes, name: str) -> tuple[int, int] | None:
|
||||
sec = elf.dynsym_section(elf._elf(data))
|
||||
sec = elf._elf(data).get_section_by_name(".dynsym")
|
||||
for sym in (sec.get_symbol_by_name(name) or []) if sec else []:
|
||||
if sym["st_value"] and sym["st_size"]:
|
||||
return sym["st_value"], sym["st_size"]
|
||||
|
||||
@@ -15,7 +15,7 @@ VIDEO_LIBS = ("libavcodec", "libffmpeg", "libvlc", "libmpv", "libijkffmpeg")
|
||||
def raise_swapchain_limit(data: bytes) -> tuple[bytes | None, int]:
|
||||
"""Rewrite the `cmp wN, #4096` size guards at the start of OVRPort's swapchain entry points."""
|
||||
try:
|
||||
dynsym = elf.dynsym_section(elf._elf(data))
|
||||
dynsym = elf._elf(data).get_section_by_name(".dynsym")
|
||||
except Exception: # noqa: BLE001
|
||||
return None, 0
|
||||
if dynsym is None:
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
"""Unreal's Android memory reservations without commit-charge accounting (GitHub #349, Transformers Beyond Reality).
|
||||
|
||||
Unreal reserves virtual memory on Android with `mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1,
|
||||
0)` (FPlatformVirtualMemoryBlock::AllocateVirtual: Android has no separate commit step). Games built with
|
||||
FMallocBinned3 reserve the whole allocator range up front while libUnreal.so loads, 76 GiB in Transformers Beyond
|
||||
Reality. Quest's kernel lets that through; the Frame runs with the kernel's heuristic overcommit (mode 0), which
|
||||
refuses any single writable private mapping larger than RAM + swap (16 + 8 GiB), so the reservation fails, Unreal's
|
||||
out-of-memory handler runs before its log exists and dereferences a null pointer (SIGSEGV, fault addr 0x10, from a
|
||||
static constructor inside System.loadLibrary). Adding MAP_NORESERVE to those mmap calls gives them the Quest
|
||||
behaviour: pages are still only backed when touched, the mapping just isn't charged up front.
|
||||
|
||||
Each site is `mov w3, #0x22` (MAP_PRIVATE|MAP_ANONYMOUS) a few instructions before a `bl` to mmap's PLT stub, with
|
||||
`mov x0, xzr` and `mov w2, #3` in the same window; the one instruction becomes `mov w3, #0x4022`. Nothing else changes
|
||||
and nothing moves; only arm64 engine libraries."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import struct
|
||||
|
||||
from ...analysis import elf
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
|
||||
ENGINE_LIBS = ("libUE4.so", "libUnreal.so")
|
||||
MOV_W3_FLAGS = 0x52800443 # mov w3, #0x22 MAP_PRIVATE|MAP_ANONYMOUS
|
||||
MOV_W3_NORESERVE = 0x52880443 # mov w3, #0x4022 MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE
|
||||
MOV_W2_RW = 0x52800062 # mov w2, #3 PROT_READ|PROT_WRITE
|
||||
MOV_X0_NULL = 0xAA1F03E0 # mov x0, xzr
|
||||
WINDOW_BEFORE, WINDOW_AFTER = 8, 9
|
||||
|
||||
|
||||
def _mmap_slots(data: bytes) -> set[int]:
|
||||
"""GOT slots of mmap/mmap64 (R_AARCH64_JUMP_SLOT relocations)."""
|
||||
from elftools.elf.elffile import ELFFile
|
||||
from elftools.elf.relocation import RelocationSection
|
||||
|
||||
e = ELFFile(io.BytesIO(data))
|
||||
dynsym = elf.dynsym_section(e)
|
||||
if dynsym is None:
|
||||
return set()
|
||||
slots = set()
|
||||
for sec in e.iter_sections():
|
||||
if not isinstance(sec, RelocationSection) or sec["sh_type"] != "SHT_RELA":
|
||||
continue
|
||||
for r in sec.iter_relocations():
|
||||
sym = r["r_info_sym"]
|
||||
if sym and dynsym.get_symbol(sym).name in ("mmap", "mmap64"):
|
||||
slots.add(r["r_offset"])
|
||||
return slots
|
||||
|
||||
|
||||
def _plt_slot(data: bytes, segs, va: int) -> int | None:
|
||||
"""The GOT slot a PLT stub at `va` jumps through: adrp x16, page; ldr x17, [x16, #off]."""
|
||||
off = elf.vaddr_to_offset(segs, va)
|
||||
if off is None or off + 8 > len(data):
|
||||
return None
|
||||
adrp, ldr = struct.unpack_from("<II", data, off)
|
||||
if adrp & 0x9F00001F != 0x90000010 or ldr & 0xFFC003FF != 0xF9400211:
|
||||
return None
|
||||
imm = ((adrp >> 29) & 3) | ((adrp >> 5) & 0x7FFFF) << 2
|
||||
if imm & 1 << 20:
|
||||
imm -= 1 << 21
|
||||
return (va & ~0xFFF) + (imm << 12) + ((ldr >> 10) & 0xFFF) * 8
|
||||
|
||||
|
||||
def mmap_sites(data: bytes) -> tuple[list[int], list[int]]:
|
||||
"""File offsets of the flag instructions of anonymous read/write mmap calls: (still MAP_PRIVATE|MAP_ANONYMOUS,
|
||||
already MAP_NORESERVE)."""
|
||||
if not elf.is_elf(data) or not elf.is_64bit(data) or data[5] != 1:
|
||||
return [], []
|
||||
slots = _mmap_slots(data)
|
||||
if not slots:
|
||||
return [], []
|
||||
segs = elf.load_segments(data)
|
||||
stubs: dict[int, bool] = {}
|
||||
todo, done = [], []
|
||||
for word, out in ((MOV_W3_FLAGS, todo), (MOV_W3_NORESERVE, done)):
|
||||
needle = struct.pack("<I", word)
|
||||
at = data.find(needle)
|
||||
while at >= 0:
|
||||
va = next((at - o + v for v, o, s in segs if o <= at < o + s), None) if at % 4 == 0 else None
|
||||
if va is not None:
|
||||
for k in range(1, WINDOW_AFTER + 1):
|
||||
if at + 4 * k + 4 > len(data):
|
||||
break
|
||||
w = struct.unpack_from("<I", data, at + 4 * k)[0]
|
||||
if w >> 26 != 0b100101: # bl
|
||||
continue
|
||||
imm = w & 0x3FFFFFF
|
||||
target = va + 4 * k + ((imm - (1 << 26)) if imm & 1 << 25 else imm) * 4
|
||||
if target not in stubs:
|
||||
stubs[target] = _plt_slot(data, segs, target) in slots
|
||||
if stubs[target]:
|
||||
window = {struct.unpack_from("<I", data, at + 4 * j)[0]
|
||||
for j in range(-WINDOW_BEFORE, k) if at + 4 * j >= 0}
|
||||
if MOV_W2_RW in window and MOV_X0_NULL in window:
|
||||
out.append(at)
|
||||
break
|
||||
at = data.find(needle, at + 1)
|
||||
return todo, done
|
||||
|
||||
|
||||
def patch_lib(data: bytes) -> tuple[bytes, int]:
|
||||
todo, _done = mmap_sites(data)
|
||||
if not todo:
|
||||
return data, 0
|
||||
buf = bytearray(data)
|
||||
for at in todo:
|
||||
struct.pack_into("<I", buf, at, MOV_W3_NORESERVE)
|
||||
return bytes(buf), len(todo)
|
||||
|
||||
|
||||
class UnrealNoReserve(Patch):
|
||||
id = "frame.unreal_noreserve"
|
||||
title = "Unreal: reserve memory like on Quest"
|
||||
description = ("Unreal reserves its memory ranges on Android with ordinary read/write mappings. Games built with "
|
||||
"Unreal's Binned3 allocator reserve tens of gigabytes at once while the engine loads (76 GiB in "
|
||||
"Transformers Beyond Reality); Quest allows that, the Frame's kernel refuses mappings larger "
|
||||
"than its memory, and the game crashes before its first frame (SIGSEGV at fault addr 0x10 in "
|
||||
"libUnreal.so, inside System.loadLibrary). This marks those mappings MAP_NORESERVE, as Quest "
|
||||
"effectively treats them: memory is still only used when the game touches it.")
|
||||
order = 49
|
||||
needs_vr = False
|
||||
|
||||
def applies(self, a):
|
||||
return a.engine == "Unreal" and "arm64-v8a" in a.abis
|
||||
|
||||
def detect(self, a):
|
||||
if self.applies(a) and (a.extra or {}).get("unreal_binned3"):
|
||||
return Suggestion(True, "Unreal's Binned3 allocator reserves more memory at start than the Frame's "
|
||||
"kernel allows in one piece; without this the game crashes while it loads.")
|
||||
return None
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
if ws.abi != "arm64-v8a":
|
||||
return False
|
||||
lib = next((n for n in ENGINE_LIBS if ws.has(ws.lib(n))), None)
|
||||
if not lib:
|
||||
return False
|
||||
data, count = patch_lib(ws.read_lib(lib))
|
||||
if not count:
|
||||
if not mmap_sites(data)[1]:
|
||||
ctx.notes.append(f"{self.id}: no anonymous read/write mmap calls found in {lib}, left unchanged")
|
||||
return False
|
||||
ws.put(ws.lib(lib), data)
|
||||
ctx.notes.append(f"{count} mmap call(s) in {lib} reserve with MAP_NORESERVE")
|
||||
return True
|
||||
|
||||
def validate(self, ctx: ApkContext):
|
||||
ws = ctx.ws
|
||||
lib = next((n for n in ENGINE_LIBS if ws.has(ws.lib(n))), None)
|
||||
if not lib or ws.abi != "arm64-v8a":
|
||||
return []
|
||||
todo, done = mmap_sites(ws.read_lib(lib))
|
||||
return [("Unreal memory reservations use MAP_NORESERVE", bool(done) and not todo,
|
||||
f"{len(done)} rewritten, {len(todo)} left")]
|
||||
|
||||
|
||||
register(UnrealNoReserve)
|
||||
@@ -38,10 +38,6 @@ SETTINGS = [
|
||||
"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)."),
|
||||
("hide_fb_foveation", "int", 0, "Turn off Quest Vulkan foveation",
|
||||
"Hides Quest FB foveation extensions from the game and OVRPort, removes Vulkan foveation image/create chains, "
|
||||
"and refuses foveation profile updates. Diagnostic for first-frame failures with missing density images "
|
||||
"(for example Transformers Beyond Reality: Redux). Device verification pending; does not disable Valve foveation."),
|
||||
("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 (for example Unreal Engine 5 "
|
||||
@@ -377,9 +373,6 @@ UI: dict[str, dict] = {
|
||||
"foveation_fix": dict(group="troubleshooting", level="advanced", label="Hide Quest foveation",
|
||||
help="Hides a Quest-only way of saving GPU power that the Frame lacks. Leave it on.",
|
||||
control=("switch",)),
|
||||
"hide_fb_foveation": dict(group="troubleshooting", level="advanced", label="Turn off Quest Vulkan foveation",
|
||||
help="Diagnostic for Vulkan games that fail on their first frames with missing Quest foveation images.",
|
||||
control=("switch",)),
|
||||
"hide_space_warp": dict(group="troubleshooting", level="advanced", label="Turn off space warp",
|
||||
help="For games whose picture flickers or smears (for example Into The Radius 2).",
|
||||
control=("switch",)),
|
||||
@@ -456,7 +449,7 @@ UI: dict[str, dict] = {
|
||||
# settings that need the xrshim (frame/adapter.py: OVRPlugin's own arm64 OpenXR lookups routed to the adapter), so
|
||||
# they don't work for 32-bit games on the bridge. surface_native: Batman's native video renderer hooks Vulkan
|
||||
# functions OVRPort's dispatcher doesn't know
|
||||
XRSHIM_SETTINGS = ("hide_fb_foveation", "controller_models", "haptic_fix", "surface_native")
|
||||
XRSHIM_SETTINGS = ("controller_models", "haptic_fix", "surface_native")
|
||||
|
||||
|
||||
class AdapterSetting(Patch):
|
||||
@@ -538,7 +531,6 @@ class AdapterSetting(Patch):
|
||||
"scene_depth": lambda a: ap.uses_scene(a) or a.extra.get("mr_only"),
|
||||
"passthrough_emul": lambda a: (a.extra.get("mr_only")
|
||||
or "com.oculus.feature.PASSTHROUGH" in (a.extra.get("features") or {})),
|
||||
"hide_fb_foveation": ap.is_vulkan,
|
||||
"flip_emul": ap.is_vulkan,
|
||||
"flip_quads": ap.is_vulkan,
|
||||
"mutable_fix": ap.is_vulkan,
|
||||
|
||||
@@ -118,8 +118,6 @@ SUMMARIES = {
|
||||
"empty Gear VR controls on the Frame, for example Vader Immortal).",
|
||||
"frame.unreal_splash_bypass": "Lets the game finish loading when its separate loading splash stalls the "
|
||||
"render thread.",
|
||||
"frame.unreal_noreserve": "Lets the game load: it reserves more memory at start than the Frame grants in one "
|
||||
"piece and crashed before its first frame (for example Transformers Beyond Reality).",
|
||||
"frame.unreal_multiview_depth": "Shows the full environment when its upper part is clipped but menu panels "
|
||||
"look correct.",
|
||||
"frame.unreal_thumb_touch": "Makes the hands' thumbs follow what your thumb touches (they always pointed up: the "
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
"""The announcement pop-up (frameport.announcements does the fetching and the who-sees-it rules): checked in the
|
||||
background a little after start and every 6 h, shown once when no other dialog is open and the welcome screen is done.
|
||||
Any way of closing it (the X, a click outside, Escape, its button) dismisses that announcement for good."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from .. import announcements
|
||||
from ..core import applog
|
||||
from ..i18n import tr
|
||||
from . import components as C
|
||||
from . import theme as T
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..announcements import Announcement
|
||||
from .app import FramePortApp
|
||||
|
||||
FIRST_DELAY = 20 # seconds after start: after the start-up work and the update check
|
||||
BUSY_RETRY = 15 # another dialog is open / the welcome screen is shown: look again
|
||||
LONG_BODY = 900 # characters: longer bodies scroll inside the pop-up
|
||||
|
||||
|
||||
class Announcer:
|
||||
def __init__(self, app: FramePortApp):
|
||||
self.app = app
|
||||
self.shown: str | None = None # id on screen (a check while it's open doesn't show it twice)
|
||||
try:
|
||||
announcements.since() # first start → only announcements published after it (before anything is saved)
|
||||
except Exception: # noqa: BLE001
|
||||
applog.log.exception("announcements: recording the first start failed")
|
||||
|
||||
def start(self) -> None:
|
||||
if not announcements.disabled():
|
||||
threading.Thread(target=self._loop, daemon=True).start()
|
||||
|
||||
def _loop(self) -> None:
|
||||
time.sleep(FIRST_DELAY)
|
||||
while True:
|
||||
try:
|
||||
a = announcements.fetch()
|
||||
if announcements.due(a):
|
||||
self._offer(a)
|
||||
except Exception: # noqa: BLE001
|
||||
applog.log.exception("announcement check failed")
|
||||
time.sleep(announcements.CHECK_EVERY)
|
||||
|
||||
def _offer(self, a: Announcement) -> None:
|
||||
"""Wait for a quiet moment (no dialog, not the welcome screen), then show it (background thread)."""
|
||||
image = announcements.image_file(a)
|
||||
while self.app.route[0] == "welcome" or C.dialog_open(self.app.page):
|
||||
time.sleep(BUSY_RETRY)
|
||||
if announcements.due(a) and self.shown != a.id:
|
||||
self.show(a, image)
|
||||
|
||||
# ---------------------------------------------------------------- the pop-up
|
||||
def show(self, a: Announcement, image=None) -> None:
|
||||
page = self.app.page
|
||||
closed = []
|
||||
|
||||
def dismiss(e=None) -> None:
|
||||
if closed:
|
||||
return
|
||||
closed.append(True)
|
||||
self.shown = None
|
||||
try:
|
||||
announcements.dismiss(a.id)
|
||||
except Exception: # noqa: BLE001
|
||||
applog.log.exception("announcement: saving the dismissal failed")
|
||||
|
||||
def close(e=None) -> None:
|
||||
dismiss()
|
||||
page.pop_dialog()
|
||||
|
||||
def act(e=None) -> None:
|
||||
close()
|
||||
self.app.open_url(a.url)
|
||||
|
||||
self.shown = a.id
|
||||
page.show_dialog(C.dialog(None, announcement_card(page, a, image, close, act if a.button else None),
|
||||
content_padding=0, actions_padding=0, inset_padding=self._insets(),
|
||||
clip_behavior=ft.ClipBehavior.ANTI_ALIAS,
|
||||
on_dismiss=dismiss, width=T.DIALOG_M))
|
||||
|
||||
|
||||
def _insets(self) -> ft.Padding:
|
||||
"""Centred over the content area (right of the sidebar, left of an open activity panel), not the whole
|
||||
window; where that area is narrower than the pop-up, the panel and then the sidebar are left out."""
|
||||
gap = T.S5
|
||||
try:
|
||||
left, right = self.app.content_insets()
|
||||
width = float(self.app.page.width or 0)
|
||||
except Exception: # noqa: BLE001
|
||||
return ft.Padding.all(gap)
|
||||
need = T.DIALOG_M + 2 * gap
|
||||
if width and width - left - right < need:
|
||||
right = 0
|
||||
if width and width - left - right < need:
|
||||
left = 0
|
||||
return ft.Padding(left + gap, gap, right + gap, gap)
|
||||
|
||||
|
||||
def announcement_card(page: ft.Page, a: Announcement, image, on_close, on_action=None) -> ft.Control:
|
||||
"""The pop-up's content: the portal edge, the picture (if any) with the close button over it, title, date, the
|
||||
Markdown body and the optional action."""
|
||||
from ..artwork.thumbs import asset_url
|
||||
from .updater import _notes_style, _open_url
|
||||
|
||||
width = T.DIALOG_M
|
||||
close_tip = tr("Dismiss")
|
||||
title = ft.Text(a.title, size=T.px(20), weight=ft.FontWeight.W_700, color=T.TEXT, selectable=True)
|
||||
date = C.meta(time.strftime("%Y-%m-%d", time.localtime(a.date)))
|
||||
body = ft.Markdown(a.body, selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
|
||||
md_style_sheet=_notes_style(),
|
||||
on_tap_link=lambda e: _open_url(page, e.data))
|
||||
if len(a.body) > LONG_BODY:
|
||||
body = ft.Column([body], scroll=ft.ScrollMode.AUTO, height=T.px(320))
|
||||
|
||||
head: list[ft.Control] = [ft.Container(height=T.px(3), gradient=C.portal_gradient())]
|
||||
if image is not None:
|
||||
head.append(ft.Stack([
|
||||
ft.Image(src=asset_url(image), width=width, height=_image_height(image, width), fit=ft.BoxFit.COVER,
|
||||
semantics_label=a.image_alt or None),
|
||||
ft.Container(
|
||||
ft.Icon(ft.Icons.CLOSE_ROUNDED, size=T.px(18), color=T.TEXT),
|
||||
width=T.px(34), height=T.px(34), border_radius=T.px(17), alignment=ft.Alignment.CENTER,
|
||||
bgcolor=ft.Colors.with_opacity(0.62, T.BG), border=ft.Border.all(1, T.soft(T.TEXT, 0.18)),
|
||||
tooltip=close_tip, ink=True, on_click=on_close, right=T.S3, top=T.S3),
|
||||
], width=width))
|
||||
title_row: ft.Control = title
|
||||
else:
|
||||
title_row = ft.Row([ft.Container(title, expand=True, padding=ft.Padding(0, T.px(6), 0, 0)),
|
||||
C.icon_btn(ft.Icons.CLOSE_ROUNDED, close_tip, on_click=on_close)],
|
||||
vertical_alignment=ft.CrossAxisAlignment.START, spacing=T.S2)
|
||||
|
||||
text = [ft.Column([title_row, date], spacing=T.px(2), tight=True)]
|
||||
if a.body:
|
||||
text.append(body)
|
||||
if on_action is not None:
|
||||
text.append(ft.Row([C.primary(a.button, icon=ft.Icons.OPEN_IN_NEW_ROUNDED, on_click=on_action)],
|
||||
alignment=ft.MainAxisAlignment.END))
|
||||
pad = ft.Padding(T.S5, T.S4 if image is None else T.S5, T.S4 if image is None else T.S5, T.S5)
|
||||
return ft.Container(
|
||||
ft.Column([*head, ft.Container(ft.Column(text, spacing=T.S4, tight=True), padding=pad)], spacing=0, tight=True),
|
||||
width=width)
|
||||
|
||||
|
||||
def _image_height(path, width: float) -> float:
|
||||
"""The picture's height at the pop-up's width, kept between a banner and a third of a 16:9 screen."""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(path) as im:
|
||||
ratio = im.height / im.width
|
||||
except Exception: # noqa: BLE001
|
||||
ratio = 9 / 21
|
||||
return max(T.px(140), min(width * ratio, T.px(320)))
|
||||
+1
-13
@@ -96,9 +96,6 @@ def reduce_motion_setting() -> bool:
|
||||
return bool(library.setting(REDUCE_MOTION, False))
|
||||
|
||||
|
||||
SIDEBAR_WIDTH = 236 # logical px before the UI scale (content_insets)
|
||||
|
||||
|
||||
class FramePortApp:
|
||||
def __init__(self, page: ft.Page):
|
||||
from .views.library import load_filters
|
||||
@@ -148,12 +145,9 @@ class FramePortApp:
|
||||
self.stop_live(), # and stop a live view (it would keep the Frame encoding)
|
||||
self.stop_keyboard(), self.stop_monitor(), self.monitor_hub.close(),
|
||||
_link_state.update(owner=None) if _link_state["owner"] is self else None)
|
||||
from .announcer import Announcer
|
||||
from .updater import Updater
|
||||
|
||||
self.updater = Updater(self) # new FramePort releases (sidebar card, Library bar, one-click update)
|
||||
# maintainer announcements (pop-up once, not on a first start); before updater.start() saves this start
|
||||
self.announcer = Announcer(self)
|
||||
|
||||
page.title = tr("FramePort")
|
||||
for problem in T.load_user_themes(themes_dir()): # installed theme files (Settings → Appearance)
|
||||
@@ -188,7 +182,6 @@ class FramePortApp:
|
||||
self._start_auto_pair()
|
||||
threading.Thread(target=self._backfill_covers, daemon=True).start()
|
||||
self.updater.start()
|
||||
self.announcer.start()
|
||||
self.run_bg(self._refresh_catalog) # confirmed game configs from GitHub main (no release needed)
|
||||
_link_state["owner"] = self
|
||||
if _link_state["thread"] is None:
|
||||
@@ -243,18 +236,13 @@ class FramePortApp:
|
||||
self.activity_card,
|
||||
self.conn_card,
|
||||
self.power_row, # under the Frame card (owner's choice)
|
||||
], spacing=T.S2), width=T.px(SIDEBAR_WIDTH), bgcolor=T.SIDEBAR, padding=T.S4,
|
||||
], spacing=T.S2), width=T.px(236), bgcolor=T.SIDEBAR, padding=T.S4,
|
||||
border=None if T.DUAL else ft.Border(right=ft.BorderSide(1, T.BORDER)))
|
||||
# dual themes: the sidebar's edge is the portal, blue at the top into orange at the bottom
|
||||
edge = [ft.Container(width=2, gradient=C.portal_gradient(vertical=True, opacity=0.7))] if T.DUAL else []
|
||||
self.page.controls = [ft.Row([sidebar, *edge, self.body, self.activity.root], expand=True, spacing=0,
|
||||
vertical_alignment=ft.CrossAxisAlignment.STRETCH)]
|
||||
|
||||
def content_insets(self) -> tuple[float, float]:
|
||||
"""How much of the window's left (the sidebar + its edge) and right (the activity panel, when open) isn't the
|
||||
content area: pop-ups about the content (announcements) centre over what's between."""
|
||||
return T.px(SIDEBAR_WIDTH) + (2 if T.DUAL else 0), float(self.activity.root.width or 0)
|
||||
|
||||
def restyle(self, theme: str) -> None:
|
||||
"""Switch the colour theme while running (Settings → Appearance): new tokens, the window rebuilt, the views
|
||||
FramePort keeps between visits recreated (the files/screenshots views start at their first location again;
|
||||
|
||||
@@ -62,9 +62,6 @@ HELP: dict[str, str] = _Translated({
|
||||
"hw_video_decode": "Games with the Hardware video decoding patch play their videos on the Frame's video hardware. "
|
||||
"Turn it off if videos misbehave; games pick it up at their next start. For one game only, put "
|
||||
"FRAMEPORT_NO_HW_VIDEO=1 before %command% in its Steam launch options.",
|
||||
"ssh_keys": "FramePort logs in to the Frame with its own key. If your Frame accepts only one of your own keys, "
|
||||
"add that private key file here; fast uploads use it too. A key with a passphrase: add it to your SSH "
|
||||
"agent instead.",
|
||||
"launch_test": "Starts the game on the Frame while nobody wears the headset and reads the log: did it start and "
|
||||
"draw frames. It can't check the picture.",
|
||||
"last_session": "After you play a game on the Frame, FramePort reads that session's log: crashes, frame rate and "
|
||||
|
||||
@@ -131,55 +131,9 @@ class SettingsView:
|
||||
C.meta(tr("Large files upload together using OpenSSH when available. "
|
||||
"Default: 3. Lower this on a busy network.")),
|
||||
C.ghost(tr("Remove converted copies now"), ft.Icons.CLEANING_SERVICES_OUTLINED, clean,
|
||||
tooltip=tr("Your own game files aren't touched.")),
|
||||
ft.Container(height=T.S2), self.ssh_keys()],
|
||||
tooltip=tr("Your own game files aren't touched."))],
|
||||
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
def ssh_keys(self) -> ft.Control:
|
||||
"""Settings → Installing: private key files FramePort logs in with besides its own (frame.connection
|
||||
EXTRA_KEYS_SETTING; the Frame connection and OpenSSH uploads both use them, GitHub #242)."""
|
||||
from ...frame import connection
|
||||
|
||||
app = self.app
|
||||
rows = ft.Column(spacing=T.S1)
|
||||
|
||||
def fill():
|
||||
items = []
|
||||
for path, problem in connection.extra_key_status():
|
||||
items.append(ft.Row([
|
||||
C.as_icon(ft.Icons.KEY_ROUNDED if not problem else ft.Icons.WARNING_AMBER_ROUNDED, T.px(18),
|
||||
T.TEXT_2 if not problem else T.WARN),
|
||||
ft.Column([C.body(path.name, T.TEXT), C.meta(problem or str(path), selectable=True)],
|
||||
spacing=0, expand=True),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Stop using this key (the file stays)"),
|
||||
on_click=lambda e, p=path: remove(p)),
|
||||
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER))
|
||||
rows.controls = items or [C.meta(tr("Only FramePort's own key, and your SSH agent's keys."))]
|
||||
C.update(rows)
|
||||
|
||||
def remove(path):
|
||||
connection.remove_extra_key(path)
|
||||
fill()
|
||||
|
||||
async def add(e):
|
||||
for path in await C.pick_files(app.page, dialog_title=tr("Private SSH key file (not the .pub file)"),
|
||||
multiple=True):
|
||||
try:
|
||||
stored = connection.add_extra_key(path)
|
||||
except connection.KeyFileError as exc:
|
||||
app.toast(tr("That key can't be used: {error}").format(error=exc), error=True)
|
||||
continue
|
||||
app.toast(tr("FramePort also logs in with {name} from the next connection.").format(name=stored.name))
|
||||
fill()
|
||||
|
||||
fill()
|
||||
return ft.Column([
|
||||
ft.Row([C.body(tr("SSH keys"), T.TEXT, weight=ft.FontWeight.W_500), C.help_icon("ssh_keys")],
|
||||
spacing=T.px(4), vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
rows,
|
||||
C.secondary(tr("Add a key file…"), ft.Icons.VPN_KEY_ROUNDED, add),
|
||||
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
def updates_card(self) -> ft.Control:
|
||||
"""Settings → Updates: FramePort's own updates (ui/updater.py, frameport/updates.py)."""
|
||||
import time
|
||||
|
||||
-8
@@ -25,13 +25,5 @@ int main(void) {
|
||||
// An invalid cyclic chain is bounded and left untouched.
|
||||
fov.next = &fov; ci.next = &fov;
|
||||
assert(fb_foveation_storage(&ci, nodes) == -1 && ci.next == &fov);
|
||||
tail.next = NULL; fov.next = &tail; meta.next = &fov; ci.next = &meta;
|
||||
assert(fb_hide_swapchain_foveation(&ci, nodes) == 1);
|
||||
m = ci.next;
|
||||
assert(m != &meta && m->next == &tail && meta.next == &fov);
|
||||
assert(m->additionalCreateFlags == 0x8000 && m->additionalUsageFlags == 0x123);
|
||||
tail.next = (const XrBaseInStructure *)&fov; fov.next = NULL; ci.next = &tail;
|
||||
assert(fb_hide_swapchain_foveation(&ci, nodes) == -1 && ci.next == &tail);
|
||||
ci.next = NULL; assert(fb_hide_swapchain_foveation(&ci, nodes) == 0);
|
||||
return 0;
|
||||
}
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
#include <assert.h>
|
||||
#include <dlfcn.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <openxr/openxr.h>
|
||||
static int enabled, requested;
|
||||
static int query(void) { return enabled; }
|
||||
static PFN_xrVoidFunction extension(const char *name) {
|
||||
return !strcmp(name,"framebridge_hide_fb_foveation") ? (PFN_xrVoidFunction)query : NULL;
|
||||
}
|
||||
static XrResult XRAPI_CALL enumerate(const char *layer,uint32_t capacity,uint32_t *count,XrExtensionProperties *out) {
|
||||
(void)layer; *count=2;
|
||||
if (capacity && capacity<2) return XR_ERROR_SIZE_INSUFFICIENT;
|
||||
if (capacity) {
|
||||
strcpy(out[0].extensionName,"XR_FB_foveation");
|
||||
strcpy(out[1].extensionName,"XR_KHR_vulkan_enable");
|
||||
}
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XrResult XRAPI_CALL create(const XrInstanceCreateInfo *info,XrInstance *instance) {
|
||||
(void)instance; requested=info->enabledExtensionCount; return XR_SUCCESS;
|
||||
}
|
||||
static XrResult XRAPI_CALL gipa(XrInstance instance,const char *name,PFN_xrVoidFunction *fn) {
|
||||
(void)instance;
|
||||
if (!strcmp(name,"xrCreateInstance")) *fn=(PFN_xrVoidFunction)create;
|
||||
else if (!strcmp(name,"xrEnumerateInstanceExtensionProperties")) *fn=(PFN_xrVoidFunction)enumerate;
|
||||
else return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static void *fake_dlopen(const char *name,int flags) { (void)name;(void)flags;return (void *)(uintptr_t)1; }
|
||||
static void *fake_dlsym(void *handle,const char *name) {
|
||||
(void)handle;
|
||||
if (!strcmp(name,"xrGetInstanceProcAddr")) return (void *)gipa;
|
||||
if (!strcmp(name,"framebridge_extension_proc")) return (void *)extension;
|
||||
return NULL;
|
||||
}
|
||||
int __android_log_print(int priority,const char *tag,const char *format,...) {
|
||||
(void)priority;(void)tag;(void)format;return 0;
|
||||
}
|
||||
#define dlopen fake_dlopen
|
||||
#define dlsym fake_dlsym
|
||||
#include "xrshim.c"
|
||||
int main(void) {
|
||||
PFN_xrVoidFunction fn=NULL;
|
||||
assert(xrGetInstanceProcAddr(XR_NULL_HANDLE,"xrEnumerateInstanceExtensionProperties",&fn)==XR_SUCCESS);
|
||||
PFN_xrEnumerateInstanceExtensionProperties e=(PFN_xrEnumerateInstanceExtensionProperties)fn;
|
||||
const char *names[]={"XR_FB_foveation","XR_KHR_vulkan_enable"};
|
||||
XrInstanceCreateInfo ci={XR_TYPE_INSTANCE_CREATE_INFO};ci.enabledExtensionNames=names;ci.enabledExtensionCount=2;
|
||||
for (enabled=0;enabled<2;++enabled) {
|
||||
uint32_t count=0;
|
||||
assert(e(NULL,0,&count,NULL)==XR_SUCCESS && count==(enabled ? 1u : 2u));
|
||||
XrExtensionProperties out[2]={{XR_TYPE_EXTENSION_PROPERTIES},{XR_TYPE_EXTENSION_PROPERTIES}};
|
||||
assert(e(NULL,2,&count,out)==XR_SUCCESS && count==(enabled ? 1u : 2u));
|
||||
if (enabled) assert(!strcmp(out[0].extensionName,"XR_KHR_vulkan_enable"));
|
||||
assert(xrGetInstanceProcAddr(XR_NULL_HANDLE,"xrCreateInstance",&fn)==XR_SUCCESS);
|
||||
assert(((PFN_xrCreateInstance)fn)(&ci,NULL)==XR_SUCCESS);
|
||||
assert(requested==(enabled ? 1 : 2) && ci.enabledExtensionCount==2);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <openxr/openxr.h>
|
||||
// Exact platform image layout; no Vulkan driver or host Vulkan SDK is needed.
|
||||
typedef struct { XrStructureType type; void *next; uint64_t image; } XrSwapchainImageVulkanKHR;
|
||||
#define LOG(...) ((void)0)
|
||||
#include "hide_fb_foveation.h"
|
||||
#include "hide_foveation_images.h"
|
||||
static int fail_images;
|
||||
static XrResult XRAPI_CALL enumerate(const char *layer, uint32_t capacity, uint32_t *count, XrExtensionProperties *out) {
|
||||
(void)layer;
|
||||
const char *names[] = {"XR_KHR_vulkan_enable2", "XR_FB_foveation", "XR_FB_foveation_vulkan",
|
||||
"XR_FB_foveation_configuration", "XR_META_foveation_eye_tracked", "XR_EXT_debug_utils"};
|
||||
*count = 6;
|
||||
if (capacity && capacity < 6) return XR_ERROR_SIZE_INSUFFICIENT;
|
||||
if (capacity) for (unsigned i=0;i<6;++i) {
|
||||
assert(out[i].type == XR_TYPE_EXTENSION_PROPERTIES);
|
||||
strcpy(out[i].extensionName, names[i]);
|
||||
}
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XrResult XRAPI_CALL create(const XrInstanceCreateInfo *info, XrInstance *instance) {
|
||||
(void)instance;
|
||||
assert(info->enabledExtensionCount == 1);
|
||||
assert(!fb_is_foveation_extension(info->enabledExtensionNames[0]));
|
||||
return XR_ERROR_RUNTIME_FAILURE;
|
||||
}
|
||||
static XrResult XRAPI_CALL images(XrSwapchain swapchain, uint32_t capacity, uint32_t *count, XrSwapchainImageBaseHeader *base) {
|
||||
(void)swapchain;
|
||||
XrSwapchainImageVulkanKHR *out = (void *)base;
|
||||
for (uint32_t i=0;i<capacity;++i) {
|
||||
XrBaseOutStructure *p=out[i].next;
|
||||
assert(p && p->type == (XrStructureType)123 && !p->next);
|
||||
out[i].image=1234+i;
|
||||
}
|
||||
*count=capacity;
|
||||
return fail_images ? XR_ERROR_RUNTIME_FAILURE : XR_SUCCESS;
|
||||
}
|
||||
int main(void) {
|
||||
uint32_t count=0;
|
||||
assert(fb_hide_enumerate(enumerate,NULL,0,&count,NULL)==XR_SUCCESS && count==2);
|
||||
XrExtensionProperties out[2]={{XR_TYPE_EXTENSION_PROPERTIES},{XR_TYPE_EXTENSION_PROPERTIES}};
|
||||
assert(fb_hide_enumerate(enumerate,NULL,1,&count,out)==XR_ERROR_SIZE_INSUFFICIENT && count==2);
|
||||
assert(fb_hide_enumerate(enumerate,"layer",2,&count,out)==XR_SUCCESS);
|
||||
assert(!strcmp(out[1].extensionName,"XR_EXT_debug_utils"));
|
||||
assert(fb_hide_enumerate(enumerate,NULL,1,&count,NULL)==XR_ERROR_VALIDATION_FAILURE);
|
||||
const char *names[]={"XR_FB_foveation","XR_KHR_vulkan_enable2","XR_FB_foveation_vulkan"};
|
||||
XrInstanceCreateInfo ci={XR_TYPE_INSTANCE_CREATE_INFO}; ci.enabledExtensionNames=names; ci.enabledExtensionCount=3;
|
||||
assert(fb_hide_create_instance(create,&ci,NULL)==XR_ERROR_RUNTIME_FAILURE);
|
||||
assert(ci.enabledExtensionCount==3 && ci.enabledExtensionNames==names);
|
||||
const char *gles[]={"XR_KHR_opengl_es_enable"}; ci.enabledExtensionNames=gles; ci.enabledExtensionCount=1;
|
||||
assert(!fb_instance_uses_vulkan(&ci));
|
||||
assert(fb_hide_create_instance(create,&ci,NULL)==XR_ERROR_RUNTIME_FAILURE);
|
||||
XrBaseOutStructure fov2={XR_TYPE_SWAPCHAIN_IMAGE_FOVEATION_VULKAN_FB,NULL};
|
||||
XrBaseOutStructure unknown={(XrStructureType)123,&fov2};
|
||||
XrBaseOutStructure fov1={XR_TYPE_SWAPCHAIN_IMAGE_FOVEATION_VULKAN_FB,&unknown};
|
||||
XrSwapchainImageVulkanKHR image={XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR,&fov1,0};
|
||||
for (fail_images=0;fail_images<2;++fail_images) {
|
||||
XrResult r=fb_hide_foveation_images(images,XR_NULL_HANDLE,1,&count,(void *)&image);
|
||||
assert(r==(fail_images ? XR_ERROR_RUNTIME_FAILURE : XR_SUCCESS));
|
||||
assert(image.next==&fov1 && fov1.next==&unknown && unknown.next==&fov2);
|
||||
assert(image.image==1234);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
"""Announcements: the committed file parses, who sees one (never on a first start), and dismissing is for good."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport import announcements as A
|
||||
from frameport.core import library
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
DOC = """---
|
||||
id: one
|
||||
title: Hello
|
||||
date: 2026-10-10T12:00:00Z
|
||||
---
|
||||
Some **news**.
|
||||
"""
|
||||
|
||||
|
||||
def ann(text=DOC):
|
||||
a = A.parse(text)
|
||||
assert a is not None
|
||||
return a
|
||||
|
||||
|
||||
def test_committed_announcement_is_valid():
|
||||
"""announcements/current.md must parse (an inactive one is checked too): a typo fails here, not on users."""
|
||||
A.parse((ROOT / A.PATH).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_parse_fields():
|
||||
a = A.parse("""---
|
||||
id: frameport-1.3
|
||||
title: "FramePort 1.3: the Monitor"
|
||||
date: 2026-11-02
|
||||
image: announcements/x.png
|
||||
image_alt: A picture
|
||||
button: Read more
|
||||
url: https://frameport.app/changelog/
|
||||
expires: 2026-11-30
|
||||
---
|
||||
Body *here*.""")
|
||||
assert a.id == "frameport-1.3" and a.title == "FramePort 1.3: the Monitor" and a.body == "Body *here*."
|
||||
assert a.image == A.RAW + "announcements/x.png" and a.image_alt == "A picture"
|
||||
assert a.button == "Read more" and a.url == "https://frameport.app/changelog/"
|
||||
assert a.date == 1793577600.0 # 2026-11-02 00:00 UTC
|
||||
assert a.expires == 1795996800.0 + 86400 # end of 2026-11-30 (inclusive)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["", " \n", "---\nactive: false\n---\n"])
|
||||
def test_nothing_to_show(text):
|
||||
assert A.parse(text) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("front, error", [
|
||||
("title: x\ndate: 2026-10-10", "id"),
|
||||
("id: bad id\ntitle: x\ndate: 2026-10-10", "id"),
|
||||
("id: a\ndate: 2026-10-10", "title"),
|
||||
("id: a\ntitle: x", "date"),
|
||||
("id: a\ntitle: x\ndate: soon", "date"),
|
||||
("id: a\ntitle: x\ndate: 2026-10-10\nbutton: Go", "button and url"),
|
||||
("id: a\ntitle: x\ndate: 2026-10-10\nimage: http://example.com/x.png", "https"),
|
||||
("id: a\ntitle: x\ndate: 2026-10-10\nimage: ../secret.png", "https"),
|
||||
("id: a\ntitle: x\ndate: 2026-10-10\nwhere: app", "unknown"), # the site pop-up was dropped
|
||||
("id: a\ntitle: x\ndate: 2026-10-10\ncolour: red", "unknown"),
|
||||
("active: false\nid: a\ntitle: x\ndate: tomorrow", "date"), # switched off, still checked
|
||||
])
|
||||
def test_parse_errors(front, error):
|
||||
with pytest.raises(A.AnnouncementError, match=error):
|
||||
A.parse(f"---\n{front}\n---\nbody")
|
||||
|
||||
|
||||
def test_fetch_never_raises_on_a_broken_file(monkeypatch):
|
||||
monkeypatch.setattr(A.cache, "cached_text", lambda *a, **k: "no front matter")
|
||||
assert A.fetch() is None
|
||||
|
||||
|
||||
def test_first_start_sees_no_older_announcement():
|
||||
"""A new install: `since` = now, so an announcement published before it never shows."""
|
||||
assert A.since() > 1.9e9 - 2e8 # roughly now
|
||||
assert not A.due(ann())
|
||||
|
||||
|
||||
def test_existing_user_sees_it_once():
|
||||
library.set_setting("update.last_seen_version", "1.2.0") # FramePort ran before announcements existed
|
||||
assert A.since() == 0
|
||||
a = ann()
|
||||
assert A.due(a)
|
||||
A.dismiss(a.id)
|
||||
assert not A.due(a)
|
||||
assert not A.due(a) # for good, also at the next check
|
||||
# a typo fixed under the same id stays dismissed; a new id shows again
|
||||
assert not A.due(ann(DOC.replace("Hello", "Hallo")))
|
||||
assert A.due(ann(DOC.replace("id: one", "id: two")))
|
||||
|
||||
|
||||
def test_since_is_recorded_once():
|
||||
first = A.since()
|
||||
library.set_setting("update.last_seen_version", "1.2.0") # written later by this same start
|
||||
assert A.since() == first
|
||||
|
||||
|
||||
def test_new_user_sees_later_announcements():
|
||||
start = 1_800_000_000.0
|
||||
later = ann(DOC.replace("2026-10-10T12:00:00Z", "2027-01-20T00:00:00Z")) # after `start`
|
||||
assert A.due(later, now=later.date + 60, user_since=start)
|
||||
assert not A.due(ann(), now=later.date + 60, user_since=start) # published before the install
|
||||
|
||||
|
||||
def test_schedule_and_expiry():
|
||||
a = ann(DOC.replace("---\nSome", "expires: 2026-10-12\n---\nSome"))
|
||||
assert not A.due(a, now=a.date - 60, user_since=0) # not published yet
|
||||
assert A.due(a, now=a.date + 60, user_since=0)
|
||||
assert not A.due(a, now=a.expires + 1, user_since=0) # expired
|
||||
|
||||
|
||||
def test_dismissed_list_is_bounded():
|
||||
for i in range(A.KEEP_DISMISSED + 5):
|
||||
A.dismiss(f"id{i}")
|
||||
kept = A.dismissed()
|
||||
assert len(kept) == A.KEEP_DISMISSED and kept[-1] == f"id{A.KEEP_DISMISSED + 4}"
|
||||
A.dismiss("id10") # again: moves to the end, no duplicate
|
||||
assert A.dismissed().count("id10") == 1 and A.dismissed()[-1] == "id10"
|
||||
|
||||
|
||||
def test_image_file_checks_the_picture(monkeypatch, tmp_path):
|
||||
from PIL import Image
|
||||
|
||||
src = tmp_path / "pic.png"
|
||||
Image.new("RGB", (40, 20), "orange").save(src)
|
||||
|
||||
def fake_download(url, dest, **kw):
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(src.read_bytes() if url.endswith(".png") else b"not an image")
|
||||
return dest
|
||||
monkeypatch.setattr(A.cache, "download", fake_download)
|
||||
good = A.image_file(ann(DOC.replace("---\nSome", "image: announcements/a.png\n---\nSome")))
|
||||
assert good is not None and good.suffix == ".png" and good.exists()
|
||||
assert A.image_file(ann(DOC.replace("---\nSome", "image: https://example.com/a.jpg\n---\nSome"))) is None
|
||||
assert not list(good.parent.glob("*.part"))
|
||||
@@ -47,26 +47,3 @@ def test_add_needed(variant):
|
||||
assert all(off % 16384 == va % 16384 for va, off, _ in loads)
|
||||
# idempotent
|
||||
assert elf.add_needed(out, "libglshim.so") == out
|
||||
|
||||
|
||||
def _scramble_section_names(lib: bytes) -> bytes:
|
||||
"""Rotate every section's sh_name by one, like a shipped libOVRPlugin.so whose names are shifted (GitHub #349:
|
||||
.dynamic named "dding", .dynsym "text"); types and links stay right."""
|
||||
buf = bytearray(lib)
|
||||
shoff = struct.unpack_from("<Q", buf, 40)[0]
|
||||
shentsize, shnum = struct.unpack_from("<H", buf, 58)[0], struct.unpack_from("<H", buf, 60)[0]
|
||||
names = [struct.unpack_from("<I", buf, shoff + i * shentsize)[0] for i in range(1, shnum)]
|
||||
for i, name in enumerate(names[1:] + names[:1], start=1):
|
||||
struct.pack_into("<I", buf, shoff + i * shentsize, name)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def test_scrambled_section_names():
|
||||
base = elf.add_needed(build_stub_library(["ovr_X", "ovr_Y"], soname="libgame.so"), "liblog.so")
|
||||
lib = _scramble_section_names(base)
|
||||
assert elf.dynsym_section(elf._elf(lib)) is not None
|
||||
assert elf.dyn_symbols(lib, True) == {"ovr_X", "ovr_Y"}
|
||||
assert elf.find_symbols(lib, ["ovr_Y"]).keys() == {"ovr_Y"}
|
||||
out = elf.add_needed(lib, "libfp_x.so") # raised "produced NEEDED=[...]" when sections were found by name
|
||||
assert elf.needed(out) == ["libfp_x.so", "liblog.so"]
|
||||
assert elf.dyn_symbols(out, True) == {"ovr_X", "ovr_Y"}
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Native regression: dispatcher filtering and reversible Vulkan output-chain stripping."""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INC = next(iter((ROOT / "native/.cache").glob("openxr-*")), None)
|
||||
CC = shutil.which("cc") or shutil.which("clang")
|
||||
pytestmark = [pytest.mark.native, pytest.mark.skipif(
|
||||
not os.environ.get("FRAMEPORT_NATIVE_TESTS") or not INC or not CC,
|
||||
reason="needs FRAMEPORT_NATIVE_TESTS=1, cached OpenXR headers and host C compiler")]
|
||||
|
||||
def test_foveation_dispatcher_and_images(tmp_path):
|
||||
exe = tmp_path / "storage-test"
|
||||
subprocess.run([CC, "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-missing-field-initializers", "-I", str(INC),
|
||||
"-I", str(ROOT / "native/adapter"),
|
||||
str(ROOT / "tests/fixtures/src/hide_fb_foveation_test.c"),
|
||||
"-o", str(exe)], check=True)
|
||||
subprocess.run([str(exe)], check=True)
|
||||
|
||||
|
||||
def test_shim_filters_synthetic_dispatcher_before_instance(tmp_path):
|
||||
android = tmp_path / "android"
|
||||
android.mkdir()
|
||||
(android / "log.h").write_text(
|
||||
"#define ANDROID_LOG_INFO 4\nint __android_log_print(int, const char *, const char *, ...);\n")
|
||||
exe = tmp_path / "dispatcher-test"
|
||||
subprocess.run([CC, "-std=c11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-missing-field-initializers", "-I", str(INC), "-I", str(tmp_path),
|
||||
"-I", str(ROOT / "native/xrshim"),
|
||||
str(ROOT / "tests/fixtures/src/hide_fb_dispatcher_test.c"),
|
||||
"-pthread", "-lm", "-ldl", "-o", str(exe)], check=True)
|
||||
subprocess.run([str(exe)], check=True)
|
||||
@@ -38,36 +38,8 @@ def test_quote_refuses_batch_injection(value):
|
||||
native.quote(value)
|
||||
|
||||
|
||||
def test_quote_leaves_globs_to_sftp():
|
||||
"""Inside double quotes sftp escapes * ? [ itself: an extra backslash became part of the name (GitHub #324)."""
|
||||
assert native.quote('a *?["b') == '"a *?[\\"b"'
|
||||
assert native.quote("/x/te*s?t [ENG] \\b+(1).apk") == '"/x/te*s?t [ENG] \\\\b+(1).apk"'
|
||||
|
||||
|
||||
def test_quote_windows_local_path_uses_forward_slashes():
|
||||
"""Win32-OpenSSH turns a local path's backslashes into '/', escapes included ("C://Users", "/[ENG]")."""
|
||||
path = r"C:\Users\user_name\AppData\Local\FramePort\output\All-In-One - Sports VR [ENG] v100+1.9.0 (x)\a.apk"
|
||||
assert native.quote(path, windows=True) == (
|
||||
'"C:/Users/user_name/AppData/Local/FramePort/output/All-In-One - Sports VR [ENG] v100+1.9.0 (x)/a.apk"')
|
||||
assert native.quote(r"\\server\share\*?[a].apk", windows=True) == '"//server/share/*?[a].apk"'
|
||||
assert native.quote('C:\\odd "name"\\a.apk', windows=True) == '"C:/odd \\"name\\"/a.apk"'
|
||||
|
||||
|
||||
@pytest.mark.parametrize("os_name,expected", [("nt", '"C:/games/[ENG] a/x.apk"'),
|
||||
("posix", '"C:\\\\games\\\\[ENG] a\\\\x.apk"')])
|
||||
def test_quote_local_follows_the_platform(monkeypatch, os_name, expected):
|
||||
monkeypatch.setattr(native.os, "name", os_name)
|
||||
assert native.quote_local(r"C:\games\[ENG] a\x.apk") == expected
|
||||
|
||||
|
||||
def test_batch_line_for_bracketed_folder(tmp_path, monkeypatch):
|
||||
folder = tmp_path / "All-In-One - Sports VR [ENG] v100+1.9.0"
|
||||
folder.mkdir()
|
||||
frame, items, files, commands, processes = setup_batch(folder, monkeypatch)
|
||||
monkeypatch.setattr(native.os, "name", "posix")
|
||||
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1)
|
||||
assert sorted(commands[0].splitlines()) == [f'put "{folder}/patch.obb" "/incoming/patch.obb.part"',
|
||||
f'reput "{folder}/main.obb" "/incoming/main.obb.part"']
|
||||
def test_quote_protects_spaces_quotes_globs():
|
||||
assert native.quote('a *?["b') == '"a \\*\\?\\[\\"b"'
|
||||
|
||||
|
||||
def test_size_lookup_does_not_hide_connection_errors():
|
||||
@@ -93,11 +65,10 @@ def setup_batch(tmp_path, monkeypatch, code=0):
|
||||
monitor.stat.side_effect = stat
|
||||
monitor.remove.side_effect = lambda path: files.pop(path)
|
||||
frame = SimpleNamespace(sftp=monitor, client=SimpleNamespace(open_sftp=lambda: monitor),
|
||||
target=FrameTarget("paired"), run=Mock(return_value=(0, "", "")), modes_seen=[])
|
||||
target=FrameTarget("paired"), run=Mock(return_value=(0, "", "")))
|
||||
monkeypatch.setattr(native.shutil, "which", lambda _: "sftp")
|
||||
@contextlib.contextmanager
|
||||
def creds(frame, relaxed=False):
|
||||
frame.modes_seen.append(relaxed)
|
||||
def creds(frame):
|
||||
yield ["-o", "StrictHostKeyChecking=yes"]
|
||||
monkeypatch.setattr(native, "credentials", creds)
|
||||
commands, processes = [], []
|
||||
@@ -239,7 +210,7 @@ def setup_tar(tmp_path, monkeypatch, code=0):
|
||||
|
||||
path = tmp_path / "small.bin"
|
||||
path.write_bytes(b"payload")
|
||||
frame = SimpleNamespace(target=FrameTarget("paired.example", port=2222), modes_seen=[])
|
||||
frame = SimpleNamespace(target=FrameTarget("paired.example", port=2222))
|
||||
pipe = io.BytesIO()
|
||||
pipe.close = lambda: None
|
||||
process = Mock(stdin=pipe, returncode=code)
|
||||
@@ -248,9 +219,8 @@ def setup_tar(tmp_path, monkeypatch, code=0):
|
||||
monkeypatch.setattr(native.subprocess, "Popen", popen)
|
||||
monkeypatch.setattr(native.shutil, "which", lambda _: "ssh")
|
||||
@contextlib.contextmanager
|
||||
def creds(frame, port_flag, relaxed=False):
|
||||
def creds(frame, port_flag):
|
||||
assert port_flag == "-p"
|
||||
frame.modes_seen.append(relaxed)
|
||||
yield ["-o", "StrictHostKeyChecking=yes", port_flag, "2222"]
|
||||
monkeypatch.setattr(native, "credentials", creds)
|
||||
return frame, [(path, "nested/small.bin")], process, popen, pipe
|
||||
|
||||
@@ -1029,19 +1029,3 @@ def test_ovr_stable_user_suggested_for_platform_games():
|
||||
p = base.get("frame.ovr_stable_user")
|
||||
assert p.detect(_analysis(engine="Unity", libs=["libovrplatformloader.so", "libunity.so"])).recommended
|
||||
assert p.detect(_analysis(engine="Unity", libs=["libunity.so"])) is None
|
||||
|
||||
|
||||
def test_hide_fb_foveation_setting():
|
||||
from frameport.patches.settings import UI, XRSHIM_SETTINGS
|
||||
from frameport.patches.frame.adapter import needs_xrshim
|
||||
|
||||
patch = base.get("adapter.hide_fb_foveation")
|
||||
assert patch.applies(_analysis())
|
||||
assert not patch.applies(_analysis(graphics="OpenGL ES"))
|
||||
assert patch.detect(_analysis()) is None
|
||||
assert "hide_fb_foveation" not in adapter_settings({})
|
||||
recipe = {patch.id: {"value": 1}}
|
||||
assert adapter_settings(recipe)["hide_fb_foveation"] == 1
|
||||
assert "hide_fb_foveation" in XRSHIM_SETTINGS
|
||||
assert needs_xrshim(recipe)
|
||||
assert UI["hide_fb_foveation"]["level"] == "advanced"
|
||||
@@ -1,340 +0,0 @@
|
||||
"""SSH keys beyond FramePort's own (GitHub #242): key files from Settings, the login a connection used, and OpenSSH
|
||||
uploads that fall back to this PC's own keys/agent when the Frame refuses FramePort's key."""
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import paramiko
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
from frameport.core import library
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.frame import connection as C
|
||||
from frameport.frame import native_upload as native
|
||||
|
||||
REFUSED = (b"debug1: Reading configuration data nul\r\n"
|
||||
b"debug1: identity file C:/Temp/frameport-upload-x/key0 type 3\r\n"
|
||||
b"debug1: Connecting to 10.0.0.2 [10.0.0.2] port 22.\r\n"
|
||||
b"@ WARNING: UNPROTECTED PRIVATE KEY FILE! @\r\n"
|
||||
b"debug1: Offering public key: C:/Temp/frameport-upload-x/key0 ED25519 SHA256:abc explicit\r\n"
|
||||
b"debug1: Authentications that can continue: publickey,password\r\n"
|
||||
b"debug1: kex: algorithm: curve25519-sha256\r\n"
|
||||
b"steamos@10.0.0.2: Permission denied (publickey,password).\r\n")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_real_home_keys(tmp_path, monkeypatch):
|
||||
"""Never read the real ~/.ssh (relaxed mode looks there)."""
|
||||
empty = tmp_path / "empty-home"
|
||||
empty.mkdir()
|
||||
monkeypatch.setattr(native.Path, "home", lambda: empty)
|
||||
|
||||
|
||||
def write_key(path: Path, password: bytes | None = None) -> Path:
|
||||
enc = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption()
|
||||
key = ed25519.Ed25519PrivateKey.generate()
|
||||
path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, enc))
|
||||
path.with_name(path.name + ".pub").write_bytes(key.public_key().public_bytes(
|
||||
serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH))
|
||||
return path
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- key files
|
||||
def test_key_file_validation(tmp_path):
|
||||
good = write_key(tmp_path / "id_ed25519")
|
||||
assert isinstance(C.load_key_file(good), paramiko.PKey)
|
||||
with pytest.raises(C.KeyFileError, match="passphrase"):
|
||||
C.load_key_file(write_key(tmp_path / "locked", b"secret"))
|
||||
with pytest.raises(C.KeyFileError, match="public half"):
|
||||
C.load_key_file(tmp_path / "id_ed25519.pub")
|
||||
with pytest.raises(C.KeyFileError, match="not found"):
|
||||
C.load_key_file(tmp_path / "missing")
|
||||
(tmp_path / "notes.txt").write_text("hello")
|
||||
with pytest.raises(C.KeyFileError, match="isn't a private SSH key"):
|
||||
C.load_key_file(tmp_path / "notes.txt")
|
||||
|
||||
|
||||
def test_extra_keys_come_from_settings_and_skip_unusable_ones(tmp_path):
|
||||
good = write_key(tmp_path / "mine")
|
||||
locked = write_key(tmp_path / "locked", b"secret")
|
||||
library.set_setting(C.EXTRA_KEYS_SETTING, [str(good), str(good), str(locked), str(tmp_path / "gone"), "", 3])
|
||||
assert C.extra_key_paths() == [good, locked, tmp_path / "gone"]
|
||||
assert [path for path, _ in C.extra_keys()] == [good]
|
||||
library.set_setting(C.EXTRA_KEYS_SETTING, str(good)) # a single path, e.g. edited by hand
|
||||
assert C.extra_key_paths() == [good]
|
||||
|
||||
|
||||
class _Client:
|
||||
"""paramiko.SSHClient stand-in: accepts the key `accepted` (or the agent when accepted == "agent")."""
|
||||
accepted = None
|
||||
tried: list = []
|
||||
|
||||
def load_host_keys(self, path):
|
||||
pass
|
||||
|
||||
def set_missing_host_key_policy(self, policy):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def save_host_keys(self, path):
|
||||
pass
|
||||
|
||||
def get_transport(self):
|
||||
return Mock()
|
||||
|
||||
def connect(self, pkey=None, password=None, allow_agent=False, look_for_keys=False, **kw):
|
||||
kind = "password" if password else "agent" if allow_agent else pkey
|
||||
_Client.tried.append(kind)
|
||||
if kind != _Client.accepted:
|
||||
raise paramiko.AuthenticationException("Authentication failed.")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("accepted,auth", [("app", "app_key"), ("user", "user_key"), ("agent", "agent")])
|
||||
def test_connection_records_the_login_that_worked(monkeypatch, accepted, auth):
|
||||
monkeypatch.setattr(C.paramiko, "SSHClient", _Client)
|
||||
monkeypatch.setattr(C, "app_keys", lambda: ["app"])
|
||||
monkeypatch.setattr(C, "extra_keys", lambda: [(Path("/k"), "user")])
|
||||
monkeypatch.setattr(C.Frame, "run", lambda self, cmd: (0, "/home/steamos", ""))
|
||||
_Client.accepted, _Client.tried = accepted, []
|
||||
frame = C.Frame(C.FrameTarget("10.0.0.2")).connect()
|
||||
assert frame.auth == auth
|
||||
assert _Client.tried == ["app", "user", "agent"][:["app", "user", "agent"].index(accepted) + 1]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- OpenSSH arguments
|
||||
def frame_with_key():
|
||||
key = SimpleNamespace(get_name=lambda: "ssh-ed25519", get_base64=lambda: "PAIRED_KEY")
|
||||
client = SimpleNamespace(get_transport=lambda: SimpleNamespace(get_remote_server_key=lambda: key))
|
||||
return C.Frame(C.FrameTarget("paired.example"), client=client)
|
||||
|
||||
|
||||
def test_credentials_offer_own_and_settings_keys_strict_or_relaxed(tmp_path, monkeypatch):
|
||||
own = tmp_path / "ssh"
|
||||
own.mkdir()
|
||||
write_key(own / "id_ed25519")
|
||||
mine = write_key(tmp_path / "id_ed25519") # same file name as FramePort's: copies get their own names
|
||||
monkeypatch.setattr(native, "ssh_dir", lambda: own)
|
||||
library.set_setting(C.EXTRA_KEYS_SETTING, [str(mine)])
|
||||
with native.credentials(frame_with_key()) as args:
|
||||
copies = [Path(args[i + 1]) for i, a in enumerate(args) if a == "-i"]
|
||||
assert [c.read_bytes() for c in copies] == [(own / "id_ed25519").read_bytes(), mine.read_bytes()]
|
||||
assert "IdentitiesOnly=yes" in args and "BatchMode=yes" in args and "-v" in args
|
||||
assert all(oct(c.stat().st_mode & 0o777) == "0o600" for c in copies)
|
||||
assert not any(c.exists() for c in copies)
|
||||
with native.credentials(frame_with_key(), relaxed=True) as args:
|
||||
assert "IdentitiesOnly=yes" not in args and args.count("-i") == 2
|
||||
assert "StrictHostKeyChecking=yes" in args # host key still pinned
|
||||
|
||||
|
||||
def test_relaxed_credentials_work_without_any_key_file(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(native, "ssh_dir", lambda: tmp_path)
|
||||
with pytest.raises(OSError, match="no FramePort key"):
|
||||
with native.credentials(frame_with_key()):
|
||||
pass
|
||||
with native.credentials(frame_with_key(), relaxed=True) as args:
|
||||
assert "-i" not in args
|
||||
|
||||
|
||||
def test_windows_sid_survives_a_non_ascii_user_name(monkeypatch):
|
||||
out = '"desktop-1\\josé müller","S-1-5-21-1004336348-1177238915-682003330-1001"\r\n'.encode("cp850")
|
||||
monkeypatch.setattr(native.subprocess, "check_output", lambda *a, **k: out)
|
||||
assert native.current_sid() == "S-1-5-21-1004336348-1177238915-682003330-1001"
|
||||
monkeypatch.setattr(native.subprocess, "check_output", lambda *a, **k: b"nothing")
|
||||
with pytest.raises(OSError):
|
||||
native.current_sid()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- failures and modes
|
||||
def test_failure_keeps_openssh_messages_and_key_lines():
|
||||
exc = native.failure("OpenSSH SFTP", 255, REFUSED)
|
||||
assert isinstance(exc, native.OpenSSHAuthFailed)
|
||||
text = str(exc)
|
||||
assert "UNPROTECTED PRIVATE KEY FILE" in text and "Offering public key" in text
|
||||
assert "identity file" in text and "Permission denied (publickey,password)" in text
|
||||
assert "kex: algorithm" not in text and "Reading configuration" not in text
|
||||
other = native.failure("OpenSSH SFTP", 1, b"stat x: No such file or directory\n")
|
||||
assert type(other) is OSError and "No such file" in str(other)
|
||||
assert type(native.failure("OpenSSH tar", 255, b"ssh: connect to host x port 22: Connection timed out")) is OSError
|
||||
|
||||
|
||||
@pytest.mark.parametrize("auth,known,modes", [("app_key", None, [False, True]), ("user_key", None, [False, True]),
|
||||
("password", None, [False, True]), ("agent", None, [True]),
|
||||
("app_key", True, [True]), ("agent", False, [False])])
|
||||
def test_login_modes(auth, known, modes):
|
||||
assert native.login_modes(SimpleNamespace(auth=auth, openssh_relaxed=known)) == modes
|
||||
assert native.login_modes(SimpleNamespace()) == [False, True] # stand-ins without the fields
|
||||
|
||||
|
||||
def sftp_setup(tmp_path, monkeypatch, refuse_strict=True, refuse_relaxed=False, auth="app_key"):
|
||||
"""Two .part-less files; strict/relaxed logins refused as asked; returns the frame and the Popen calls."""
|
||||
paths = [tmp_path / "main.obb", tmp_path / "patch.obb"]
|
||||
for path in paths:
|
||||
path.write_bytes(b"0123456789")
|
||||
items = [(path, path.name, 10) for path in paths]
|
||||
files = {}
|
||||
monitor = Mock()
|
||||
monitor.__enter__ = Mock(return_value=monitor)
|
||||
monitor.__exit__ = Mock(return_value=False)
|
||||
|
||||
def stat(path):
|
||||
if path not in files:
|
||||
raise FileNotFoundError(path)
|
||||
return SimpleNamespace(st_size=files[path])
|
||||
monitor.stat.side_effect = stat
|
||||
frame = SimpleNamespace(sftp=monitor, client=SimpleNamespace(open_sftp=lambda: monitor), auth=auth,
|
||||
openssh_relaxed=None, target=C.FrameTarget("paired"), run=Mock(return_value=(0, "", "")))
|
||||
monkeypatch.setattr(native.shutil, "which", lambda _: "sftp")
|
||||
mode = {}
|
||||
|
||||
@contextlib.contextmanager
|
||||
def creds(frame, port_flag="-P", relaxed=False):
|
||||
mode["relaxed"] = relaxed
|
||||
yield ["relaxed" if relaxed else "strict"]
|
||||
monkeypatch.setattr(native, "credentials", creds)
|
||||
calls = []
|
||||
|
||||
def popen(args, **kwargs):
|
||||
relaxed = mode["relaxed"]
|
||||
calls.append(relaxed)
|
||||
refused = refuse_relaxed if relaxed else refuse_strict
|
||||
if refused:
|
||||
kwargs["stderr"].write(REFUSED)
|
||||
code = 255
|
||||
else:
|
||||
for _, rel, size in items:
|
||||
files["/incoming/" + rel + ".part"] = size
|
||||
code = 0
|
||||
process = Mock(returncode=code)
|
||||
process.poll.return_value = code
|
||||
return process
|
||||
monkeypatch.setattr(native.subprocess, "Popen", popen)
|
||||
return frame, items, calls
|
||||
|
||||
|
||||
def test_refused_strict_login_retries_with_this_pcs_keys(tmp_path, monkeypatch):
|
||||
frame, items, calls = sftp_setup(tmp_path, monkeypatch)
|
||||
logs = []
|
||||
reporter = Reporter()
|
||||
reporter.log = lambda message, **k: logs.append(message)
|
||||
assert native.upload(frame, items, "/incoming", reporter, 20, 0, 2) == 20
|
||||
assert calls == [False, False, True, True] # both strict workers are refused, then both run relaxed
|
||||
assert frame.openssh_relaxed is True
|
||||
assert any("trying this PC's own SSH keys" in m and "UNPROTECTED" in m for m in logs)
|
||||
assert native.login_modes(frame) == [True] # the next upload starts in the mode that worked
|
||||
|
||||
|
||||
def test_agent_login_goes_straight_to_relaxed(tmp_path, monkeypatch):
|
||||
frame, items, calls = sftp_setup(tmp_path, monkeypatch, auth="agent")
|
||||
assert native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1) == 20
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
def test_refused_in_both_modes_raises_the_auth_failure(tmp_path, monkeypatch):
|
||||
frame, items, calls = sftp_setup(tmp_path, monkeypatch, refuse_relaxed=True)
|
||||
with pytest.raises(native.OpenSSHAuthFailed, match="Permission denied"):
|
||||
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1)
|
||||
assert calls == [False, True] and frame.openssh_relaxed is None
|
||||
assert not frame.run.called # nothing promoted
|
||||
|
||||
|
||||
def test_tar_retries_a_refused_login(tmp_path, monkeypatch):
|
||||
path = tmp_path / "small.bin"
|
||||
path.write_bytes(b"abc")
|
||||
frame = SimpleNamespace(target=C.FrameTarget("paired"), auth="app_key", openssh_relaxed=None)
|
||||
monkeypatch.setattr(native.shutil, "which", lambda _: "ssh")
|
||||
mode = {}
|
||||
|
||||
@contextlib.contextmanager
|
||||
def creds(frame, port_flag="-P", relaxed=False):
|
||||
mode["relaxed"] = relaxed
|
||||
yield []
|
||||
monkeypatch.setattr(native, "credentials", creds)
|
||||
|
||||
class Pipe:
|
||||
def write(self, data):
|
||||
if not mode["relaxed"]:
|
||||
raise BrokenPipeError(32, "Broken pipe") # ssh already exited
|
||||
return len(data)
|
||||
|
||||
def flush(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
calls = []
|
||||
|
||||
def popen(args, **kwargs):
|
||||
calls.append(mode["relaxed"])
|
||||
code = 0 if mode["relaxed"] else 255
|
||||
if code:
|
||||
kwargs["stderr"].write(REFUSED)
|
||||
process = Mock(stdin=Pipe(), returncode=code)
|
||||
process.poll.return_value = code
|
||||
return process
|
||||
monkeypatch.setattr(native.subprocess, "Popen", popen)
|
||||
native.put_tar(frame, [(path, "small.bin")], "/incoming", Reporter(), Mock())
|
||||
assert calls == [False, True] and frame.openssh_relaxed is True
|
||||
|
||||
|
||||
def test_azure_ad_sid(monkeypatch):
|
||||
out = b'"azuread\\jmueller","S-1-12-1-1234567890-1234567890-1234567890-1234567890"\r\n'
|
||||
monkeypatch.setattr(native.subprocess, "check_output", lambda *a, **k: out)
|
||||
assert native.current_sid() == "S-1-12-1-1234567890-1234567890-1234567890-1234567890"
|
||||
|
||||
|
||||
def test_relaxed_mode_names_this_pcs_default_keys(tmp_path, monkeypatch):
|
||||
"""OpenSSH skips its default keys once any -i is given, so relaxed mode names the ones Paramiko tries."""
|
||||
home = tmp_path / "home"
|
||||
(home / ".ssh").mkdir(parents=True)
|
||||
rsa = home / ".ssh" / "id_rsa"
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa as rsa_keys
|
||||
|
||||
rsa.write_bytes(rsa_keys.generate_private_key(65537, 2048).private_bytes(
|
||||
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption()))
|
||||
write_key(home / ".ssh" / "id_ecdsa", b"secret") # passphrase: only usable through the agent
|
||||
(home / ".ssh" / "id_ed25519").write_text("garbage")
|
||||
monkeypatch.setattr(native.Path, "home", lambda: home)
|
||||
own = tmp_path / "own"
|
||||
own.mkdir()
|
||||
write_key(own / "id_ed25519")
|
||||
monkeypatch.setattr(native, "ssh_dir", lambda: own)
|
||||
assert native.default_key_files() == [rsa]
|
||||
assert native.key_files() == [own / "id_ed25519"]
|
||||
assert native.key_files(relaxed=True) == [own / "id_ed25519", rsa]
|
||||
library.set_setting(C.EXTRA_KEYS_SETTING, [str(rsa)]) # a default key also named in Settings: offered once
|
||||
assert native.key_files(relaxed=True) == [own / "id_ed25519", rsa]
|
||||
|
||||
|
||||
def test_add_remove_and_status_of_settings_keys(tmp_path):
|
||||
good = write_key(tmp_path / "frame key")
|
||||
with pytest.raises(C.KeyFileError, match="public half"):
|
||||
C.add_extra_key(tmp_path / "frame key.pub")
|
||||
with pytest.raises(C.KeyFileError, match="passphrase"):
|
||||
C.add_extra_key(write_key(tmp_path / "locked", b"pw"))
|
||||
assert C.add_extra_key(good) == good.resolve()
|
||||
C.add_extra_key(good) # once only
|
||||
assert C.extra_key_paths() == [good.resolve()]
|
||||
good.unlink()
|
||||
[(path, problem)] = C.extra_key_status()
|
||||
assert path == good.resolve() and "not found" in problem
|
||||
assert C.remove_extra_key(good) is True and C.remove_extra_key(good) is False
|
||||
assert C.extra_key_paths() == []
|
||||
|
||||
|
||||
def test_cli_ssh_keys(tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport.cli import app
|
||||
|
||||
good = write_key(tmp_path / "mine")
|
||||
run = CliRunner().invoke(app, ["frame", "ssh-keys", "--add", str(good)])
|
||||
assert run.exit_code == 0 and f"added {good.resolve()}" in run.output
|
||||
run = CliRunner().invoke(app, ["frame", "ssh-keys", "--add", str(tmp_path / "nope")])
|
||||
assert run.exit_code == 1 and str(good.resolve()) in run.output
|
||||
run = CliRunner().invoke(app, ["frame", "ssh-keys", "--remove", str(good.resolve())])
|
||||
assert run.exit_code == 0 and "no extra keys" in run.output
|
||||
@@ -17,27 +17,6 @@ LOG_BAD = ("""
|
||||
""")
|
||||
|
||||
|
||||
def test_unreal_reservation_refused_while_loading():
|
||||
"""GitHub #349: Unreal's out-of-memory handler crashes inside libUnreal.so's static constructors."""
|
||||
lib = "/data/app/~~x==/com.Meta4.TFBeyondReality-y==/lib/arm64/libUnreal.so (BuildId: 57d064dc)"
|
||||
crash = (
|
||||
"10-16 14:41:01.814 1185 1185 F libc : Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x10"
|
||||
" in tid 1185 (TFBeyondReality), pid 1185 (TFBeyondReality)\n"
|
||||
"10-16 14:41:01.858 1219 1219 F DEBUG : Cause: null pointer dereference\n"
|
||||
"10-16 14:41:01.955 1219 1219 F DEBUG : backtrace:\n"
|
||||
f"10-16 14:41:01.955 1219 1219 F DEBUG : #00 pc 00000000018bbe3c {lib}\n"
|
||||
f"10-16 14:41:01.955 1219 1219 F DEBUG : #01 pc 00000000017ceccc {lib}\n"
|
||||
"10-16 14:41:01.957 1219 1219 F DEBUG : #15 pc 000000000004b048 /apex/com.android.runtime/bin/"
|
||||
"linker64 (__dl__ZN6soinfo17call_constructorsEv+380)\n")
|
||||
r = triage("", "EXITED", crash=crash)
|
||||
ids = {f.id for f in r.findings}
|
||||
assert "unreal-reserve-refused" in ids and "native-crash" not in ids
|
||||
assert next(f for f in r.findings if f.id == "unreal-reserve-refused").suggest == ["frame.unreal_noreserve"]
|
||||
# the same fault address in a later render-thread crash isn't this
|
||||
later = crash.replace("__dl__ZN6soinfo17call_constructorsEv+380", "__pthread_start(void*)+64")
|
||||
assert "unreal-reserve-refused" not in {f.id for f in triage("", "EXITED", crash=later).findings}
|
||||
|
||||
|
||||
def test_lua_pointer_error_and_cleanup_stack_are_distinguished():
|
||||
pointer = triage("PANIC: bad light userdata pointer", "EXITED")
|
||||
finding = next(f for f in pointer.findings if f.id == "lua-lightuserdata-pointer")
|
||||
@@ -560,14 +539,3 @@ def test_meta_entitlement_quest():
|
||||
"I Unity : ovr_Entitlement_GetIsViewerEntitled", "I Unity : User is entitled"):
|
||||
assert "meta-entitlement" not in {x.id for x in triage(ok + "\n" + LOG_OK, "RUNNING").findings}
|
||||
assert triage("E Unity : Entitlement check failed\n" + LOG_OK, "RUNNING").verdict == "pass"
|
||||
|
||||
|
||||
def test_missing_quest_foveation_image_is_diagnostic():
|
||||
message = "OVRPlugin: foveationImage = 0x0, width = 0, height = 0"
|
||||
result = triage(message, "EXITED")
|
||||
finding = next(f for f in result.findings if f.id == "quest-foveation-image-missing")
|
||||
assert finding.severity == "info" and finding.question
|
||||
assert finding.suggest == ["adapter.hide_fb_foveation"]
|
||||
assert "adapter.hide_fb_foveation" not in result.suggestions()
|
||||
result = triage(message + "\nFrameBridge: hide_fb_foveation: filtering dispatcher extensions", "EXITED")
|
||||
assert "quest-foveation-image-missing" not in {f.id for f in result.findings}
|
||||
@@ -1,91 +0,0 @@
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
from test_unreal_splash import context, engine
|
||||
|
||||
from frameport.analysis import detect, elf
|
||||
from frameport.apk.workspace import ApkWorkspace
|
||||
from frameport.patches.frame import unreal_noreserve as nr
|
||||
|
||||
SLOT = 0x123458 # mmap's GOT slot (any address: _mmap_slots is replaced below)
|
||||
|
||||
|
||||
def adrp(rd: int, pc: int, target: int) -> int:
|
||||
pages = (target >> 12) - (pc >> 12)
|
||||
return 0x90000000 | (pages & 3) << 29 | ((pages >> 2) & 0x7FFFF) << 5 | rd
|
||||
|
||||
|
||||
def bl(pc: int, target: int) -> int:
|
||||
return 0x94000000 | ((target - pc) >> 2) & 0x3FFFFFF
|
||||
|
||||
|
||||
def mmap_engine(prot=nr.MOV_W2_RW, flags=nr.MOV_W3_FLAGS):
|
||||
"""An engine library whose code calls mmap(NULL, size, prot, flags, -1, 0) through a PLT-style stub."""
|
||||
data, off = engine(symbol="FAndroidVirtualReserve", size=0x80, prologue=b"")
|
||||
va = next(v for v, o, s in elf.load_segments(data) if o <= off < o + s) + off - next(
|
||||
o for v, o, s in elf.load_segments(data) if o <= off < o + s)
|
||||
stub = va + 0x40
|
||||
call = [nr.MOV_X0_NULL, prot, flags, 0x12800004, 0xAA1F03E5] # mov w4, #-1; mov x5, xzr
|
||||
code = call + [bl(va + 4 * len(call), stub)]
|
||||
words = code + [0xD503201F] * (16 - len(code)) # nop up to the stub
|
||||
words += [adrp(16, stub, SLOT), 0xF9400211 | ((SLOT & 0xFFF) // 8) << 10, 0x91000210, 0xD61F0220]
|
||||
buf = bytearray(data)
|
||||
struct.pack_into(f"<{len(words)}I", buf, off, *words)
|
||||
return bytes(buf), off + 8 # file offset of the flags instruction
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_got(monkeypatch):
|
||||
monkeypatch.setattr(nr, "_mmap_slots", lambda data: {SLOT})
|
||||
|
||||
|
||||
def test_reservation_gets_map_noreserve_and_survives_rebuild(tmp_path):
|
||||
data, flags_at = mmap_engine()
|
||||
assert nr.mmap_sites(data) == ([flags_at], [])
|
||||
patch = nr.UnrealNoReserve()
|
||||
ctx = context(tmp_path, data)
|
||||
with ctx.ws:
|
||||
assert patch.apply(ctx)
|
||||
fixed = ctx.ws.read_lib("libUE4.so")
|
||||
assert fixed == data[:flags_at] + struct.pack("<I", nr.MOV_W3_NORESERVE) + data[flags_at + 4:]
|
||||
assert patch.validate(ctx)[0][1]
|
||||
assert not patch.apply(ctx)
|
||||
ctx.ws.write(tmp_path / "out.apk")
|
||||
with ApkWorkspace(tmp_path / "out.apk") as ws:
|
||||
ctx.ws = ws
|
||||
assert not patch.apply(ctx)
|
||||
assert nr.mmap_sites(ws.read_lib("libUE4.so")) == ([], [flags_at])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kwargs", [{"prot": 0x52800022}, {"flags": 0x52800043}]) # PROT_READ only; MAP_SHARED
|
||||
def test_other_mmap_calls_are_left_alone(tmp_path, kwargs):
|
||||
data, _ = mmap_engine(**kwargs)
|
||||
assert nr.mmap_sites(data) == ([], [])
|
||||
ctx = context(tmp_path, data)
|
||||
with ctx.ws:
|
||||
assert not nr.UnrealNoReserve().apply(ctx)
|
||||
|
||||
|
||||
def test_call_to_another_function_is_left_alone(monkeypatch):
|
||||
data, _ = mmap_engine()
|
||||
monkeypatch.setattr(nr, "_mmap_slots", lambda data: {SLOT + 8})
|
||||
assert nr.mmap_sites(data) == ([], [])
|
||||
|
||||
|
||||
def test_binned3_detection():
|
||||
assert detect.unreal_binned3(b"x\0./Runtime/Core/Private/HAL/MallocBinned3.cpp\0y")
|
||||
assert detect.unreal_binned3("FMallocBinned3 Attempt to free an unrecognized pointer %p".encode("utf-16-le"))
|
||||
# other UE5 builds only name Binned3's console variables
|
||||
assert not detect.unreal_binned3("MallocBinned3.FlushThreadCacheMaxWaitTime".encode("utf-16-le"))
|
||||
|
||||
|
||||
def test_suggested_only_for_binned3_unreal():
|
||||
patch = nr.UnrealNoReserve()
|
||||
|
||||
class A:
|
||||
engine, abis, extra = "Unreal", ["arm64-v8a"], {"unreal_binned3": True}
|
||||
assert patch.detect(A()).recommended
|
||||
A.extra = {}
|
||||
assert patch.detect(A()) is None
|
||||
A.engine = "Unity"
|
||||
assert not patch.applies(A())
|
||||
Reference in new issue
Block a user