Compare commits

..
Author SHA1 Message Date
spoopyghosty0 3ba49708aa FrameBridge: answer xrGetDisplayRefreshRateFB with the real rate (PreyVR black menu, #306)
The Frame's runtime has no XR_FB_display_refresh_rate (-7); OVRPort's dispatcher still
offers it and answered 0 Hz. Team Beef's idTech 4 ports run USERCMD_MSEC = 1000 / refresh,
so PreyVR had 0 ms tics: the async thread spun (~1.9M tics/s), com_frameTime stayed 0 and
the main menu's timeline never got past its first step (black backdrop + cursor only).
FrameBridge now always answers with 1 / predictedDisplayPeriod (else the refresh_rate
setting, else 72 Hz). Headless: menu drawn (snapshot mean 68 vs 0.1 before), clocks advance.

- glmv: gl_mv_debug=2 traces draws (state, GL errors per framebuffer), failed texture
  uploads/copies/compiles and idTech 4 clocks; how this was found
- triage: a single xrEndFrame -16 is info, not the black-menu error it was blamed for
- PLAYBOOK row, CLAUDE.md fact, host test for the fallback rate
2026-10-16 01:34:33 -04:00
spoopyghosty0 57f70dc63d adapter: snapshots fall back to a quad/cylinder layer and log brightness
Frames without a projection layer (e.g. PreyVR's menu, a cylinder layer only) gave no snapshot. The snapshot now
takes the first quad/cylinder layer's image instead and logs the layer type, image area, array index and the mean/max
brightness of the submitted area, so a black image shows in the log without fetching the file (#306).
2026-10-15 19:24:26 -04:00
45 changed files with 512 additions and 1388 deletions

No files matched your search

+3 -21
View File
@@ -223,13 +223,6 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
store matches and links then only update `auto_title`; a locked name gets no twin suffix); `sync_title` → Frame
agent v77 `rename` (deployment.json title, same shortcut appid: upsert matches by Exe) + art + one Steam restart, PC
`PcReviveTarget.rename`; an unreachable Frame → `steam_name_stale` (game page "Update name on Frame", next install).
- Newer versions (GitHub #161): game menu "Update with a newer APK…" (`pipeline.replace_apk`, outdated via
`build.source_version`) and, for Linux apps / PC VR games, "Update with a newer version…" (`replace_linux_app` /
`replace_rift_game` via `add_linux_app(replace=)` / `add_rift_game(replace=)`, CLI `frameport new-version`): the
entry keeps package, name, art, tags, switches, user recipe (a confirmed exe if the new folder has it); a new
`source_id` goes into the deployment's `recipe` dict at install (no agent change) → `C.source_replaced` = outdated
(Frame + `pc_outdated`); such an install re-sends same-size files changed after the last install (mtime/ctime,
`installer.changed_since`); the old Rift folder is in `replaced_dirs` (rescans skip it). Untested on the device.
- `Recipe.as_is` = install unchanged (pre-patched libraries): `pipeline.prepare_as_is`; auto for APKs that already
contain FrameBridge (`frame_patched`). For Rift it changes nothing (the dump is never modified; the Frame copy
still gets launch fixes like the crash-reporter rename — `-nocrashreports` alone doesn't stop UE 4.23) — it does
@@ -466,6 +459,9 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr
XR_KHR_composition_layer_equirect2/cylinder, XR_FB_composition_layer_image_layout (flip emulated by Vulkan blit).
- `xrConvertTimespecTimeToTimeKHR`/`xrConvertTimeToTimespecTimeKHR` return FUNCTION_UNSUPPORTED → adapter emulates
(offset = predictedDisplayTime − period − CLOCK_MONOTONIC, sampled in xrWaitFrame).
- No XR_FB_display_refresh_rate (`xrGetDisplayRefreshRateFB` -7), yet OVRPort's dispatcher offers it and answered 0 Hz
→ FrameBridge always answers with the measured rate (1/predictedDisplayPeriod, else 72). PreyVR (#306, idTech 4:
`USERCMD_MSEC = 1000 / GetRefresh()`) had 0 ms tics: async thread spinning, `com_frameTime` stuck at 0, menu black.
- Guardian STAGE bounds report 1×1 m (scene emulation uses ≥1.5 m).
- GL goes through **Zink** (Mesa GL on Vulkan). Mesa GLSL is strict: `#pragma` before `#extension` fails, implicit
int/float conversions fail (enable `GL_EXT_shader_implicit_conversions`), num_views=2 shaders on single-view FBOs
@@ -911,13 +907,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
@@ -1069,13 +1058,6 @@ GitHub identity + credentials are maintainer-local (`CLAUDE.local.md`): check `g
GitHub write. **The owner approves before posting, labelling or closing anything.** Reply style: "I"/neutral, never
we/our/us; never mention agent version numbers or internal tool versions; show the owner every draft in full with a
short recap of the thread so far; read **all** comments on a thread first (a later one often has the zip or the answer).
- **Every triage also sweeps for unanswered comments**, not just new threads: open AND recently closed issues, PRs
(incl. merged/closed ones) and discussions (top-level comments and their replies) whose latest comment isn't the
maintainer's, plus earlier user comments a later maintainer reply skipped. Users often ask follow-ups after a fix or
after closing (#294: two questions after the reporter closed it; #249: a Q&A discussion with no reply for days;
#161/#110: requests posted after the close). Quick check: `gh issue list --state all --search "updated:>=<date>"
--json number,comments --jq '.[] | select(.comments[-1].author.login != "spoopyghosty0")'`; discussions via GraphQL
(`discussions(orderBy:UPDATED_AT)` with `comments{replies}`).
- **Issue templates & labels** (`.github/ISSUE_TEMPLATE/`): `[Game report]` → `game-compatibility` (a game that
doesn't work right); `[Working recipe]` (`working-config.yml`) → `working-config` (a headset-verified config to add);
`[App]` → `bug` (FramePort itself). Mislabel/relabel to match.
+3 -3
View File
@@ -10,14 +10,14 @@ d6337034fb0bdcca7ef9b7063d85b7586b8508b63b435959e8fffaba0691091c ./arm64-v8a/li
0167c2fde5dfde62897f9d85afc4fc8e97784e988b5b8df6cea8c407240b3424 ./arm64-v8a/libfp_tkeys.so
e8c0965c188665a4e565e4a3af983ef1866d1cf9225665cc90d8df286bc96ef3 ./arm64-v8a/libfp_vk.so
383054f8b3b41dde76d062c71856cd3163655e50009c08454bc1793f393ead65 ./arm64-v8a/libfpg.so
5432674a59d02f411cd853a5dc57e3fa547d1ac518f25bf433fc54c43677177a ./arm64-v8a/libfpglmv.so
4a13a33e112c9e4e3c5ff4337ed6e988a9e5b3602f48d654df148ef5fafbf426 ./arm64-v8a/libfpglmv.so
8a8f6b1da8952cb3933a5b53558424a5fc56043b2527a16bb6dd62a00d2de01a ./arm64-v8a/libframe_xrshim.so
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
0f058921e9c1f5b9ef5847cc9e6b80a23e75028c76c1d3bb02d1610b535f81c9 ./arm64-v8a/libopenxr_loader_generic.so
86261c4d7c4b78af5e182b81ae172f6d5fd5b12d8dc6194a876eee08b589d3ce ./arm64-v8a/libopenxr_loader_generic.so
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
9e8a7ce599b62a7c607b0d3637e4ec1b1ea1974c3373a7af3f5920ad2adc5e00 ./armeabi-v7a/libopenxr_loader_generic.so
1a3b250835798f46049f527a6b32f9d0b33a2a976006d08f5997bfccc215ee89 ./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,10 +0,0 @@
package: com.FlatHeadStudio.DeadlyDelivery
title: Deadly Delivery
status: unsupported
notes: "Quits after Meta's entitlement check fails: it needs Meta's online services."
tested_version: 1.0.30
engine: Unity
xr: OpenXR
verified:
date: '2026-10-15'
issue: 322
+1 -1
View File
@@ -1,5 +1,5 @@
package: com.asg.clockworkdev
title: The Last Clockwinder
title: Clockwork
status: works
tested_version: '1.29'
engine: Unity
-18
View File
@@ -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
+6 -6
View File
@@ -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
+7 -12
View File
@@ -11,16 +11,6 @@ signatures:
severity: error
diagnosis: A DLC manager reports a Meta asset-file status error after VR starts (e.g. PixelRipped 1995). The asset-file check failed; the log does not identify the native call or asset ID. Try the experimental asset-file bypass (empty list, installed status with no file path); this does not supply DLC and has not been verified in the game.
suggest: [frame.asset_files_stub]
- id: meta-entitlement
# Quest games (the pcvr oculus-entitlement signature covers Rift games). Only explicit failure results: a bare
# ovr_PlatformInitialize error isn't matched. `error`, not fatal: offline games may log it and play on; an online
# game that then quits (e.g. Deadly Delivery, GitHub #322) fails the test through its exit.
pattern: '(?i)entitlement check failed|\bnot entitled to (use|play)|ovrPlatformInitialize_(NotEntitled|UnableToVerify|FileInvalid|SignatureInvalid)'
severity: error
diagnosis: The game's Meta licence check (entitlement) failed. It needs Meta's online login and licence check,
which the Frame doesn't have; games that depend on it (often online games) quit or stay offline. FramePort
doesn't get around such checks.
suggest: []
- id: lua-lightuserdata-pointer
pattern: 'bad light userdata pointer'
severity: fatal
@@ -111,8 +101,13 @@ signatures:
suggest: [frame.adapter]
- id: cylinder-menu-layers-rejected
pattern: 'xrEndFrame failed -16\b'
severity: error
diagnosis: The game draws a curved (cylinder) screen to each eye separately (for example the Doom3Quest / PreyVR menu). The Frame's runtime has no cylinder layers, so FrameBridge shows each as flat quad strips; a per-eye pair makes too many quad layers for one frame and the runtime rejects the whole frame (XR_ERROR_LAYER_INVALID), leaving the menu black while the game runs. FrameBridge from this build shows the left eye's strips to both eyes to halve the count; rebuild and reinstall the game to try it.
severity: info
# PreyVR (GitHub #306): one rejected frame at the start of every session (its space isn't locatable yet); the black
# menu there had another cause (a 0 Hz refresh rate stopped the game clock), so this alone is no error
diagnosis: The runtime rejected a frame's layers once (XR_ERROR_LAYER_INVALID). At the start of a session that is
harmless. If it repeats every frame the picture stays black; FrameBridge shows a curved (cylinder) screen drawn
to each eye separately (for example the Doom3Quest / PreyVR menu) as one set of flat strips for both eyes to stay
within the runtime's layer limit, so rebuild and reinstall the game with the current FrameBridge.
suggest: [frame.adapter]
- id: missing-ovr-symbol
pattern: '(UnsatisfiedLinkError|cannot locate symbol) .*"?(ovr_[A-Za-z0-9_]+|ovr[A-Z][A-Za-z0-9]*_ToString)'
+4 -22
View File
@@ -14,12 +14,10 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Asgard's Wrath 2 | Quest | ✅ Works | |
| Assassin's Creed Nexus | Quest | ✅ Works | |
| Audica | Quest | ✅ Works | |
| AugmentedEmpire | Quest | ✅ Works | |
| BAM | Quest | ✅ Works | |
| BARTENDER VR SIMULATOR | Quest | ✅ Works | |
| Batman: Arkham Shadow | Quest | ✅ Works | |
| BattleGlide | Quest | ✅ Works | |
| Battlemarked | Quest | ✅ Works | |
| BattleSisters | Quest | ✅ Works | |
| Beat Saber | Quest | ✅ Works | |
| Beat Saber | Quest | ✅ Works | |
@@ -31,15 +29,14 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| BONELAB | Quest | ✅ Works | |
| Carve Snowboarding | Quest | ✅ Works | |
| Caves | 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 | |
| Demeo | Quest | ✅ Works | |
| Demeter | Quest | ✅ Works | |
| Dinosaur Island | Quest | ✅ Works | |
| Doom3Quest | Quest | ✅ Works | |
@@ -49,12 +46,10 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Eleven Table Tennis | Quest | ✅ Works | |
| Eleven: Table Tennis VR | PC VR | ✅ Works | |
| Espire 2 | Quest | ✅ Works | |
| Eye of the Temple | Quest | ✅ Works | |
| FrenzyVR | Quest | ✅ Works | |
| Fruit Ninja | Quest | ✅ Works | |
| Fruit Salon | Quest | ✅ Works | |
| Genotype | Quest | ✅ Works | |
| Ghost Town | Quest | ✅ Works | |
| Ghostbusters: Rise of the Ghost Lord | Quest | ✅ Works | |
| GORN2 | Quest | ✅ Works | |
| Gravity Lab | Quest | ✅ Works | |
@@ -66,7 +61,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| In Death: Unchained | Quest | ✅ Works | |
| Into The Radius 2 | Quest | ✅ Works | |
| Job Simulator | Quest | ✅ Works | |
| Journey of the Gods | Quest | ✅ Works | |
| Jurassic World Aftermath Collection | Quest | ✅ Works | |
| Keep Talking and Nobody Explodes | Quest | ✅ Works | |
| Kill It With Fire VR | Quest | ✅ Works | |
@@ -74,23 +68,18 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Lambda1VR | Quest | ✅ Works | |
| LEGO® Bricktales | Quest | ✅ Works | |
| Lucky's Tale | Quest | ✅ Works | |
| Maestro | Quest | ✅ Works | |
| Marvel's Deadpool VR | Quest | ✅ Works | |
| Marvel's Iron Man VR | Quest | ✅ Works | |
| Max Mustard | Quest | ✅ Works | |
| Medieval Dynasty New Settlement | Quest | ✅ Works | |
| Metro Awakening | Quest | ✅ Works | |
| Mini Motor Racing X | Quest | ✅ Works | |
| Mobile Suit Gundam: Silver Phantom | Quest | ✅ Works | |
| Moss | Quest | ✅ Works | |
| Moss: Book II | Quest | ✅ Works | |
| Munkr Funkr (The Legacy Days) | Quest | ✅ Works | |
| Nano | Quest | ✅ Works | |
| NEX Player | Quest | ✅ Works | |
| NFL PRO ERA II | Quest | ✅ Works | |
| NOPE CHALLENGE | Quest | ✅ Works | |
| Ocean Rift | Quest | ✅ Works | |
| Operation Wolf Returns: First Mission VR | Quest | ✅ Works | |
| palazzo_santacruz | Quest | ✅ Works | |
| Path of the Warrior | Quest | ✅ Works | |
| Pistol Whip | Quest | ✅ Works | |
@@ -108,11 +97,9 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Riven | Quest | ✅ Works | |
| Robo Recall | Quest | ✅ Works | |
| RUINSMAGUS | Quest | ✅ Works | |
| Shadow Point | Quest | ✅ Works | |
| Shoot The Zombirds VR | Quest | ✅ Works | |
| Shores of Loci | Quest | ✅ Works | |
| SilentSlayer | Quest | ✅ Works | |
| Smash Drums | Quest | ✅ Works | |
| Sniper Elite VR | Quest | ✅ Works | |
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
| Space Pirate Trainer Quest | Quest | ✅ Works | |
@@ -125,11 +112,9 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| SUPERHOT VR | Quest | ✅ Works | |
| SynthRiders | Quest | ✅ Works | |
| Taskmaster | Quest | ✅ Works | |
| Tea For God | Quest | ✅ Works | |
| TetrisEffect | Quest | ✅ Works | |
| The Boys VR | 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 | |
@@ -143,8 +128,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Vader Immortal: Episode II | Quest | ✅ Works | |
| 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 | |
@@ -161,24 +144,23 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| 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. |
| MacacoVR | Quest | ⚠️ Works with issues | 2d loading screens are glitchy, otherwise main gameplay is fine |
| Majora's Mask VR | Quest | ⚠️ Works with issues | Audio on the file select screen sounds crunchy. |
| Myst | Quest | ⚠️ Works with issues | Minor graphical glitches on some objects. |
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
| Pinball FX VR | Quest | ⚠️ Works with issues | Plays; mixed reality mode not working yet. |
| Plezy | Quest | ⚠️ Works with issues | Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding. |
| Quantum Void | Quest | ⚠️ Works with issues | no hands |
| Ragnarock | Quest | ⚠️ Works with issues | |
| 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. |
| GOLF+ | Quest | ❌ Doesn't run | Black screen. |
| HITMAN 3 VR: Reloaded | Quest | ❌ Doesn't run | Vulkan driver crash (freedreno), even without Valve layers. |
| Journey of the Gods | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
| Roblox | Quest | ❌ Doesn't run | Crashes on its first VR frame on the Frame. |
| Shadow Point | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
-5
View File
@@ -167,10 +167,6 @@ FramePort downloads it, checks it, restarts and keeps your games and settings. *
- Settings → **Updates** turns the check off or turns on **Install updates automatically**.
- **Recipes** (the tested patches and settings for each game) update by themselves. A game whose recipe changed shows
**Update on Frame**.
- **A newer version of a game:** right-click it (or use **…** on its page) → **Update with a newer APK…** (Quest and
Android games) or **Update with a newer version…** (Linux apps: the new AppImage, archive or folder; PC VR games:
the new game folder). Its name, artwork, tags and your settings stay. Then click **Update on Frame** (or **on this
PC**).
- **Dev builds:** if you're asked to test a change before its release, use Settings → Updates → **Install the latest
dev build…**. The next release then arrives as a normal update.
@@ -259,7 +255,6 @@ option. The main ones:
|---|---|
| `scan <folder>` / `list` / `show <game>` | add games, list the library, show a game's patches |
| `add-linux <path> [--exe <program>]` | add a Linux app (AppImage, folder or archive) |
| `new-version <game> <path>` | point a game at its newer version (APK, Linux app file/folder, PC VR game folder) |
| `recipe <game> --enable/--disable <patch>` | change a game's patches (`patches` lists them all) |
| `build <game>` / `install <game>` / `test <game>` | build, install on the Frame (`--to pc` for this PC), launch test |
| `frame discover` / `frame connect` / `frame info` | find, connect to and describe the Frame |
+6 -6
View File
@@ -71,7 +71,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Platform compat (ovrMessageType_ToString)](#frame-ovrplatformcompat) | `frame.ovrplatformcompat` | Steam Frame patches | default |
| [Stub missing Meta platform functions](#frame-ovrstubs) | `frame.ovrstubs` | Steam Frame patches | default |
| [Unpaired pointer-authentication checks](#frame-pac_hints) | `frame.pac_hints` | Steam Frame patches | 1 |
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 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 |
@@ -511,7 +511,7 @@ FramePort's layer between the game and the Steam Frame: fills in what the Frame
- **Suggested (on):** Required for every OpenXR/overport build on the Frame.
- **Suggested by launch-test signatures:**
- `cube-swapchain-refused` (fatal): The game asked for a cube-map swapchain (for example an OVROverlay skybox or loading cube), which the Frame's runtime doesn't have.
- `cylinder-menu-layers-rejected` (error): The game draws a curved (cylinder) screen to each eye separately (for example the Doom3Quest / PreyVR menu).
- `cylinder-menu-layers-rejected` (info): The runtime rejected a frame's layers once (XR_ERROR_LAYER_INVALID).
- `graphics-requirements-missing` (fatal): The game can't start VR on the Frame; rebuild it with the current FramePort.
- `swapchain-rect-invalid` (fatal): The runtime rejects the game's frames, so it stops drawing (for example stuck at a "Waiting" box).
- `time-conversion` (warning): The Frame's runtime lacks timespec time conversion; the current adapter build emulates it.
@@ -838,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)
@@ -904,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)
@@ -973,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.
@@ -1621,7 +1621,7 @@ Logs details when flat panels stay black. No effect on the game.
- Game settings → Troubleshooting → "Flat panel diagnostics (log)" (advanced).
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> 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 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). 2 (frameport settings) also checks every draw and logs the draw state, failed texture uploads and shader compiles and, for idTech 4 engines, the game clock.
### <a id="adapter-haptic_fix"></a>Fix freezes when controllers vibrate
+1 -3
View File
@@ -24,7 +24,6 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| A Unity OpenXR game closes right after the OpenXR instance is created: `F VrApiLoader: vrapi_SetPropertyInt was called before vrapi_Initialize()!`, SIGABRT on UnityMain (GitHub #62, Jurassic World Aftermath) | the APK still ships Meta's VrApi loader; OVRPlugin (1.89, on OpenXR) calls it without starting VrApi and Meta's loader aborts. The VrApi bridge can't replace it (39 of the loader's 114 functions: OVRPlugin wouldn't link) | `frame.vrapi_stub` (same exports, each returns 0); triage `vrapi-before-init` |
| A VrApi game exits at once: `UnsatisfiedLinkError … dlopen failed: cannot locate symbol "vrapi_PollEvent"` (GitHub #57, BlazeRush) | the engine imports a VrApi function the VrApi bridge didn't implement | the bridge has `vrapi_PollEvent`, `RecenterPose`, `SetDisplayRefreshRate`, `GetSystemPropertyFloatArray` (rebuild the game); the static check "VrApi functions resolvable" names any other missing one; triage `vrapi-symbol-missing` |
| A Team Beef port (Lambda1VR, RTCWQuest, …) exits at once: `UnsatisfiedLinkError: dlopen failed: library "libopenxr_loader_valve.so" not found` (GitHub #59) | TBXR loads `openxr_loader_<Build.MANUFACTURER>` (Lepton: valve) and takes the Meta path only when `strstr(OPENXR_HMD, "meta")` matches, else Pico's | `frame.tbxr_vendor` (empty `libopenxr_loader_valve.so` + the "meta" literal → "alve"). Game data (`xash/`, …) goes to /sdcard: upload it with the Files tab to the game's storage. Black eyes with `TBXR: Incomplete frame buffer object: GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE`: TBXR always uses multisampled render-to-texture (even `--msaa 1`); `frame.tbxr_vendor` links the GL shim, which hands it single-sampled versions (log `GL shim: multisampled render-to-texture … drawn single-sampled`) |
| A Quest game renders, then logs `Entitlement check failed` / `ovrPlatformInitialize_NotEntitled` (often with retries) and quits, e.g. `SIG: 9` after `[Photon] Disconnected` (GitHub #322, Deadly Delivery) | The game's Meta licence check (entitlement) needs Meta's online login, which the Frame doesn't have; online-only games quit (triage `meta-entitlement`) | None: FramePort doesn't get around entitlement checks. Offline games that log it and play on are fine |
| Black screen after VR starts; `Asset status error (code 100)` in `DLC_Manager:AssetStatusCallback` (GitHub #203, e.g. PixelRipped 1995) | Meta asset-file status fails after entitlement succeeds; the log does not identify the native call or asset ID | Try `frame.asset_files_stub` (opt-in, triage-only): native/langpack answers GetList with an empty successful list and StatusById with installed status, an empty path and the requested ID. No DLC files are supplied; download requests retain their existing behavior. Disable with `FRAMEPORT_ASSET_FILES_STUB=0`. Host-tested only; whether this fixes startup needs a game/device test |
| Missing sound or extra content in a game whose OBB folder has separate `*.pak` files (GitHub #61, Star Wars: Tales from the Galaxy's Edge: seasons + Wwise banks) | the game asks Meta's platform for these asset files (`ovr_AssetFile_GetList` / `DownloadById`); OVRPort's loader doesn't list them | copy every content/sound-bank PAK and use `frame.asset_files` (native/langpack lists the data's `*.pak` as installed `default` assets, IAP `free`, and answers DownloadById at once with the path; off: env FRAMEPORT_ASSET_FILES=0). Tales also needs the splash and depth-size fixes below |
| A SideQuest backup has `apk/` and a sibling `obb/` with content/sound-bank PAKs, but the Frame only has the main OBB (GitHub #61, Tales from the Galaxy's Edge) | older scans treated that layout as loose expansion files and selected only the named OBBs; the asset-file patch then found no PAKs | the scanner chooses the OBB folder by version first, then keeps the whole sibling data folder when all its expansion files are selected for this APK; app updates repair affected library entries and refresh asset/language-pack detection. Reinstall to copy the missing files, or scan the backup again first. Folders with other packages or unselected OBB versions still send only the selected OBBs |
@@ -63,8 +62,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 |
@@ -171,6 +168,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Black screen, audio works, log `Unsupported VrApi layer type N` | bridge drops frames containing that layer | extend the bridge (cylinder=3 is converted to a quad already) |
| Only some draws visible (for example controllers) + `glGetError 0x502` | multiview shaders used on single-view FBOs | GL shim `gl_hide_multiview=1` (default) |
| Own GLES engine (OpenXR, not VrApi): eye view fine, but HUD / menu / PDA panels black (for example Doom3Quest, GitHub #77; no error in the log) | every shader declares `layout(num_views=2) in;`, also the ones drawn into the engine's single-layer offscreen framebuffers (2D textures/renderbuffers); OVR_multiview makes such draws INVALID_OPERATION and Mesa drops them (`draw_validate.c`), Quest's driver doesn't | `frame.gl_multiview_fbo` (opt-in, experimental: `libfpglmv.so` interposes GLES and draws those cases with a single-view twin of the program; logs `GLMV: twin built: program N -> M`, per-5-s `single-view draws` counters; `gl_mv_debug=1` adds glGetError checks; triage `gl-multiview-twin-failed`). Alternative: the game's own fix (two-layer multiview pool, Xandrix's patch in #77) |
| Team Beef idTech 4 port (PreyVR, GitHub #306): main menu black except the mouse cursor, loading screen fine, 72 fps, no GL error; one game thread at 100 % CPU | the game clock runs 1000 / refresh ms per tic (`USERCMD_MSEC`, from xrGetDisplayRefreshRateFB). The Frame's runtime lacks XR_FB_display_refresh_rate and OVRPort's dispatcher answered 0 Hz: 0 ms tics, the async thread spins (millions of tics/s) and `com_frameTime` stays 0, so the menu GUI stays at its first animation step (everything hidden) | FrameBridge (default, no setting) answers xrGetDisplayRefreshRateFB with the measured rate (1 / predictedDisplayPeriod, else 72 Hz); log `refresh rate: runtime reported 0.0 Hz (result -7), reporting 72.0 Hz`. Found with `gl_mv_debug=2` (`frameport settings <pkg> gl_mv_debug=2`, needs `frame.gl_multiview_fbo`): only 2 draws per frame (backdrop + cursor) and `trace clocks: com_frameTime=0` |
| Upside-down image in a GL bridge game | GL images start at the bottom row | fixed in the bridge (swap angleUp/Down for GL chains) |
| UI panels upside down (AC Nexus) | XrCompositionLayerImageLayoutFB VERTICAL_FLIP unsupported | adapter `flip_emul=1` (default); rotating quads makes them vanish |
| Passthrough black (BAM) | XR_FB_passthrough missing | adapter `passthrough_emul=1` (default) + `patch_force_passthrough` for MR-only games |
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

+5
View File
@@ -15,6 +15,8 @@
// * drops requested instance extensions the runtime lacks (e.g. XR_FB_passthrough);
// * retries rejected swapchains with 1 sample / a supported format (swapchain_fix, overport #71);
// * drops composition layers that reference swapchains that failed to create (layer_fix);
// * answers xrGetDisplayRefreshRateFB with the measured display rate when the runtime lacks it or reports 0 Hz
// (session_fixes.c);
// * serves cube swapchains the runtime refuses as GL/Vulkan cube maps and drops their layers (cube_standin, cube_standin.c);
// * optionally requests mutable-format swapchain images (mutable_fix, off by default);
// * optionally swaps left/right images of stereo projection layers (swap_eyes, off by default);
@@ -2177,6 +2179,9 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
if (layer_debug || refresh_rate > 0) {
HOOK_AS(xrRequestDisplayRefreshRateFB, hook_request_refresh_rate)
}
// The Frame's runtime has no XR_FB_display_refresh_rate, but OVRPort's dispatcher offers it to the game and answers
// 0 Hz without it: the measured rate instead (the runtime's own answer when it has one).
if (instance != XR_NULL_HANDLE) HOOK_AS(xrGetDisplayRefreshRateFB, hook_get_refresh_rate)
if (surface_emul) HOOK_AS(xrCreateSwapchainAndroidSurfaceKHR, hook_xrCreateSwapchainAndroidSurfaceKHR)
if (eye_debug || release_wait) HOOK_AS(xrReleaseSwapchainImage, eye_hook_xrReleaseSwapchainImage)
if (layer_debug || cube_standin) { // counters + cube stand-ins; every other wait/release hook above takes precedence
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-only
// Display refresh rate for xrGetDisplayRefreshRateFB when the runtime doesn't give one (session_fixes.c). Pure, so the
// host test (tests/fixtures/src/refresh_rate_test.c) can check it.
#pragma once
#include <stdint.h>
// The rate the frame timing shows (1 / predictedDisplayPeriod of the last xrWaitFrame, whole Hz), else the
// refresh_rate setting, else 72 Hz (the Frame's rate for Android apps).
static inline float fb_refresh_rate(int64_t display_period_ns, float setting) {
if (display_period_ns > 0) {
double hz = 1e9 / (double)display_period_ns;
if (hz >= 30.0 && hz <= 240.0) return (float)(int)(hz + 0.5);
}
return setting > 0 ? setting : 72.0f;
}
+21
View File
@@ -9,6 +9,7 @@
// * aim_pitch / aim_yaw / aim_forward: correct the aim pose of controllers (pointer rays) by a fixed offset;
// * refresh_rate: display refresh rate requested at session start (and instead of the app's own requests).
#include "layer_math.h"
#include "refresh_rate.h"
static void emul_on_begin_session(const XrSessionBeginInfo *info); // layer_emul_gl.c
@@ -552,6 +553,26 @@ static XRAPI_ATTR XrResult XRAPI_CALL hook_request_refresh_rate(XrSession sessio
return XR_FAILED(result) && refresh_rate > 0 ? XR_SUCCESS : result;
}
// Always on: the Frame's runtime has no XR_FB_display_refresh_rate; OVRPort's dispatcher still offers the extension and
// its xrGetDisplayRefreshRateFB then answers 0 Hz. idTech 4 VR ports (Team Beef's Doom3Quest engine: PreyVR, GitHub
// #306) run their game clock at 1000 / refresh ms per tic: with 0 Hz the tic length is 0, the async thread spins and
// the frame/GUI time never advances (main menu stuck at its first animation step: black, only the cursor). A rate the
// runtime doesn't give is replaced by the one the frame timing shows (refresh_rate.h).
static XRAPI_ATTR XrResult XRAPI_CALL hook_get_refresh_rate(XrSession session, float *rate) {
if (!rate) return XR_ERROR_VALIDATION_FAILURE;
PFN_xrGetDisplayRefreshRateFB fn = (PFN_xrGetDisplayRefreshRateFB)lookup(active_instance, "xrGetDisplayRefreshRateFB");
XrResult result = fn ? fn(session, rate) : XR_ERROR_FUNCTION_UNSUPPORTED;
if ((XR_SUCCEEDED(result) && *rate > 0.0f) || result == XR_ERROR_HANDLE_INVALID) return result;
float runtime = XR_SUCCEEDED(result) ? *rate : 0.0f;
*rate = fb_refresh_rate((int64_t)last_display_period, refresh_rate);
static int logs;
if (logs < 4) {
++logs;
LOG("refresh rate: runtime reported %.1f Hz (result %d), reporting %.1f Hz", runtime, result, *rate);
}
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrBeginSession(XrSession session, const XrSessionBeginInfo *info) {
PFN_xrBeginSession fn = (PFN_xrBeginSession)lookup(active_instance, "xrBeginSession");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
+30 -6
View File
@@ -1,6 +1,6 @@
// FramePort FrameBridge: eye-image snapshots for headless diagnostics (setting snapshot=<seconds>, GLES games only).
// Every <seconds>, at xrEndFrame, the left-eye image the game just submitted (projection layer, view 0: its swapchain
// image and array layer) is read back on the game's own GL context and written at a quarter of its size as
// image and array layer; without a projection layer the first quad/cylinder layer's) is read back on the game's own GL context and written at a quarter of its size as
// /sdcard/Android/data/<package>/files/fb_snap_<n>.ppm (n = 0-7, the oldest is overwritten). Nobody wears the headset
// in a launch test, so the headset view stays black: this shows what the game itself draws (e.g. which screen it is
// stuck on). The game's framebuffer, pack-buffer and pack-alignment bindings are restored afterwards.
@@ -118,18 +118,27 @@ static void snap_end_frame(const XrFrameEndInfo *info) {
int64_t t = (int64_t)now.tv_sec * 1000000000ll + now.tv_nsec;
if (t < next_ns) return;
next_ns = t + (int64_t)snapshot * 1000000000ll;
const XrCompositionLayerProjection *proj = NULL;
for (uint32_t i = 0; i < info->layerCount && !proj; ++i)
// The projection layer's left eye; a frame without one (e.g. a menu shown only as a quad or cylinder layer) gives
// its first quad/cylinder layer's image instead.
const XrSwapchainSubImage *sub = NULL;
int type = 0;
for (uint32_t i = 0; i < info->layerCount && !sub; ++i)
if (info->layers[i] && info->layers[i]->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION &&
((const XrCompositionLayerProjection *)info->layers[i])->viewCount)
proj = (const XrCompositionLayerProjection *)info->layers[i];
if (!proj || !snap_load_gl()) return;
sub = &((const XrCompositionLayerProjection *)info->layers[i])->views[0].subImage, type = info->layers[i]->type;
for (uint32_t i = 0; i < info->layerCount && !sub; ++i) {
const XrCompositionLayerBaseHeader *layer = info->layers[i];
if (layer && layer->type == XR_TYPE_COMPOSITION_LAYER_QUAD)
sub = &((const XrCompositionLayerQuad *)layer)->subImage, type = layer->type;
else if (layer && layer->type == XR_TYPE_COMPOSITION_LAYER_CYLINDER_KHR)
sub = &((const XrCompositionLayerCylinderKHR *)layer)->subImage, type = layer->type;
}
if (!sub || !snap_load_gl()) return;
if (!s_eglGetCurrentContext()) {
static int logged;
if (!logged++) LOG("snapshot: no GL context current in xrEndFrame, no snapshot");
return;
}
const XrSwapchainSubImage *sub = &proj->views[0].subImage;
pthread_mutex_lock(&snap_lock);
int i = snap_find(sub->swapchain, 0);
GLuint tex = 0;
@@ -167,6 +176,21 @@ static void snap_end_frame(const XrFrameEndInfo *info) {
s_glDeleteFramebuffers(1, &fb);
while (s_glGetError() != GL_NO_ERROR) {} // our calls must not leave errors for the game to find
if (rgba) {
// brightness of the submitted area: tells a black image from a drawn one without fetching the file
uint64_t sum = 0, n = 0;
unsigned max = 0;
const XrRect2Di *r = &sub->imageRect;
for (int32_t y = r->offset.y; y < r->offset.y + r->extent.height; y += 4)
for (int32_t x = r->offset.x; x < r->offset.x + r->extent.width; x += 4) {
if (x < 0 || y < 0 || (uint32_t)x >= w || (uint32_t)y >= h) continue;
const unsigned char *p = rgba + ((size_t)y * w + (size_t)x) * 4;
unsigned v = p[0] > p[1] ? (p[0] > p[2] ? p[0] : p[2]) : (p[1] > p[2] ? p[1] : p[2]);
sum += v, ++n;
if (v > max) max = v;
}
LOG("snapshot: layer type=%d image %u array=%u rect=%d,%d %dx%d of %ux%u: mean %.1f max %u",
type, tex, sub->imageArrayIndex, r->offset.x, r->offset.y, r->extent.width, r->extent.height, w, h,
n ? (double)sum / (double)n : 0.0, max);
snap_write(rgba, w, h, taken++ % 8);
free(rgba);
}
+277 -6
View File
@@ -16,7 +16,8 @@
// block bindings and default-block uniform values (copied before every such draw), then the original is bound again.
//
// Logcat tag GLMV. Setting gl_mv_debug=1 (libframe_settings.so / framebridge.conf / FRAMEBRIDGE_CONFIG, as the GL
// shim): glGetError after every twin draw, and per-5-s counters.
// shim): glGetError after every twin draw, and per-5-s counters. gl_mv_debug=2 (trace) also checks every draw and
// logs the state of the first draws of each 5 s window, failed texture uploads/copies and shader compiles.
#define _GNU_SOURCE
#include <EGL/egl.h>
#include <GLES3/gl32.h>
@@ -111,7 +112,27 @@
X(void, UniformMatrix2x4fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
X(void, UniformMatrix4x2fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
X(void, UniformMatrix3x4fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
X(void, UniformMatrix4x3fv, (GLint, GLsizei, GLboolean, const GLfloat *))
X(void, UniformMatrix4x3fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
TRACE_LIST(X)
// gl_mv_debug=2 (trace): the draw state and texture/shader calls behind black output
#define TRACE_LIST(X) \
X(GLboolean, IsEnabled, (GLenum)) \
X(void, GetBooleanv, (GLenum, GLboolean *)) \
X(GLenum, CheckFramebufferStatus, (GLenum)) \
X(void, GetTexLevelParameteriv, (GLenum, GLint, GLenum, GLint *)) \
X(void, GetTexParameteriv, (GLenum, GLenum, GLint *)) \
X(void, Clear, (GLbitfield)) \
X(void, Viewport, (GLint, GLint, GLsizei, GLsizei)) \
X(void, TexImage2D, (GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum, GLenum, const void *)) \
X(void, TexSubImage2D, (GLenum, GLint, GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, const void *)) \
X(void, CompressedTexImage2D, (GLenum, GLint, GLenum, GLsizei, GLsizei, GLint, GLsizei, const void *)) \
X(void, TexStorage2D, (GLenum, GLsizei, GLenum, GLsizei, GLsizei)) \
X(void, CopyTexImage2D, (GLenum, GLint, GLenum, GLint, GLint, GLsizei, GLsizei, GLint)) \
X(void, CopyTexSubImage2D, (GLenum, GLint, GLint, GLint, GLint, GLint, GLsizei, GLsizei)) \
X(void, GenerateMipmap, (GLenum)) \
X(void, BindTexture, (GLenum, GLuint)) \
X(void, TexParameteri, (GLenum, GLenum, GLint))
#define DECL(ret, name, args) static ret(*r_##name) args;
REAL_LIST(DECL)
@@ -515,6 +536,147 @@ static void stats(void) {
twin_draws = mv_draws = uniform_sets = 0;
}
// ---------------------------------------------------------------- trace (gl_mv_debug=2, diagnostics)
// Every draw is checked with glGetError (this also consumes errors the game would read), counted per draw
// framebuffer, and the first draws of every 5 s window are logged with the state that decides whether they write
// anything: framebuffer status and views, program, viewport/scissor, colour/depth/stencil/blend state and the texture
// on the active unit. Texture uploads, copies and shader compiles that fail are logged too.
#define TRACE_DETAIL 12 // detailed draws per window
#define TRACE_WINDOWS 40 // windows with detail
#define NTFB 16
struct tfb { GLint fb; unsigned long draws, errors, clears; };
static int trace_detail, trace_windows;
static unsigned long trace_draws, trace_errors, trace_clears;
static GLenum trace_first_error;
static struct tfb tfb[NTFB];
static struct timespec trace_last;
static pthread_mutex_t trace_lock = PTHREAD_MUTEX_INITIALIZER;
static struct tfb *tfb_slot(GLint fb) {
for (int i = 0; i < NTFB; ++i)
if (tfb[i].fb == fb && (tfb[i].draws || tfb[i].clears)) return &tfb[i];
for (int i = 0; i < NTFB; ++i)
if (!tfb[i].draws && !tfb[i].clears) { tfb[i].fb = fb; return &tfb[i]; }
return NULL;
}
static void trace_window(void) { // under trace_lock
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
if (!trace_last.tv_sec) trace_last = now;
if (now.tv_sec - trace_last.tv_sec < 5) return;
trace_last = now;
char buf[700];
int n = 0;
for (int i = 0; i < NTFB && n < (int)sizeof(buf) - 60; ++i)
if (tfb[i].draws || tfb[i].clears)
n += snprintf(buf + n, sizeof(buf) - n, " fb%d:d%lu/e%lu/c%lu", tfb[i].fb, tfb[i].draws, tfb[i].errors,
tfb[i].clears);
LOG("trace 5s: %lu draws, %lu with a GL error (first 0x%x), %lu clears;%s", trace_draws, trace_errors,
trace_first_error, trace_clears, n ? buf : " none");
{ // idTech 4 engines (Doom3Quest, PreyVR) export their clocks; GUI animations run on com_frameTime, so one that
// stands still leaves menus at their first step (PreyVR, GitHub #306: 0 Hz refresh rate -> 0 ms per tic)
static int *frame_time, *tic_number, *frame_number, looked;
if (!looked) {
looked = 1;
frame_time = dlsym(RTLD_DEFAULT, "com_frameTime");
tic_number = dlsym(RTLD_DEFAULT, "com_ticNumber");
frame_number = dlsym(RTLD_DEFAULT, "com_frameNumber");
}
if (frame_time && tic_number && frame_number)
LOG("trace clocks: com_frameTime=%d com_ticNumber=%d com_frameNumber=%d", *frame_time, *tic_number,
*frame_number);
}
memset(tfb, 0, sizeof(tfb));
trace_draws = trace_errors = trace_clears = 0;
trace_first_error = 0;
if (trace_windows < TRACE_WINDOWS) {
++trace_windows;
trace_detail = 0;
}
}
static void trace_state(const char *what, GLenum mode, GLsizei count, GLenum err) {
GLint fb = 0, prog = 0, vp[4] = {0}, sc[4] = {0}, unit = 0, tex = 0, dfunc = 0, bsrc = 0, bdst = 0, bsrca = 0,
bdsta = 0, cullmode = 0;
GLboolean cmask[4] = {0}, dmask = 0;
r_GetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &fb);
r_GetIntegerv(GL_CURRENT_PROGRAM, &prog);
r_GetIntegerv(GL_VIEWPORT, vp);
r_GetIntegerv(GL_SCISSOR_BOX, sc);
r_GetIntegerv(GL_ACTIVE_TEXTURE, &unit);
r_GetIntegerv(GL_DEPTH_FUNC, &dfunc);
r_GetIntegerv(GL_BLEND_SRC_RGB, &bsrc);
r_GetIntegerv(GL_BLEND_DST_RGB, &bdst);
r_GetIntegerv(GL_BLEND_SRC_ALPHA, &bsrca);
r_GetIntegerv(GL_BLEND_DST_ALPHA, &bdsta);
r_GetIntegerv(GL_CULL_FACE_MODE, &cullmode);
r_GetBooleanv(GL_COLOR_WRITEMASK, cmask);
r_GetBooleanv(GL_DEPTH_WRITEMASK, &dmask);
GLenum status = r_CheckFramebufferStatus(GL_DRAW_FRAMEBUFFER);
GLint linked = 0;
if (prog) r_GetProgramiv((GLuint)prog, GL_LINK_STATUS, &linked);
struct prog *p = prog ? find_prog((GLuint)prog, 0) : NULL;
r_GetIntegerv(GL_TEXTURE_BINDING_2D, &tex); // (on the active unit)
GLint tw = 0, th = 0, tfmt = 0, minf = 0, maxl = 0, imm = 0;
if (tex) {
r_GetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_WIDTH, &tw);
r_GetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_HEIGHT, &th);
r_GetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_INTERNAL_FORMAT, &tfmt);
r_GetTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, &minf);
r_GetTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, &maxl);
r_GetTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_IMMUTABLE_LEVELS, &imm);
}
r_GetError(); // (the queries above must not count as the game's)
LOG("trace %s(mode=0x%x n=%d) err=0x%x fb=%d status=0x%x views=%d prog=%d linked=%d mv=%d vp=%d,%d %dx%d "
"scissor=%d %d,%d %dx%d cmask=%d%d%d%d blend=%d 0x%x/0x%x,0x%x/0x%x depth=%d f=0x%x w=%d stencil=%d cull=%d "
"0x%x unit=0x%x tex2d=%d %dx%d fmt=0x%x min=0x%x maxl=%d imm=%d",
what, mode, count, err, fb, status, draw_fb_views(), prog, linked, p ? p->multiview : -1, vp[0], vp[1], vp[2],
vp[3], r_IsEnabled(GL_SCISSOR_TEST), sc[0], sc[1], sc[2], sc[3], cmask[0], cmask[1], cmask[2], cmask[3],
r_IsEnabled(GL_BLEND), bsrc, bdst, bsrca, bdsta, r_IsEnabled(GL_DEPTH_TEST), dfunc, dmask,
r_IsEnabled(GL_STENCIL_TEST), r_IsEnabled(GL_CULL_FACE), cullmode, unit, tex, tw, th, tfmt, minf, maxl, imm);
}
static void trace_before(void) {
if (debug >= 2) r_GetError();
}
static void trace_after(const char *what, GLenum mode, GLsizei count) {
if (debug < 2) return;
GLenum err = r_GetError();
GLint fb = 0;
r_GetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &fb);
int detail;
pthread_mutex_lock(&trace_lock);
++trace_draws;
if (err) {
++trace_errors;
if (!trace_first_error) trace_first_error = err;
}
struct tfb *e = tfb_slot(fb);
if (e) {
++e->draws;
if (err) ++e->errors;
}
detail = trace_windows < TRACE_WINDOWS && trace_detail < TRACE_DETAIL;
if (detail) ++trace_detail;
trace_window();
pthread_mutex_unlock(&trace_lock);
if (detail) trace_state(what, mode, count, err);
}
static int trace_logs;
#define TRACE_CALL(fmt, ...) \
do { \
if (debug >= 2) { \
GLenum e_ = r_GetError(); \
if ((e_ || trace_logs < 40) && trace_logs < 400) { \
++trace_logs; \
LOG("trace " fmt " err=0x%x", __VA_ARGS__, e_); \
} \
} \
} while (0)
// Before a draw: bind the twin when the program is multiview and the framebuffer has no views. 1 = twin bound.
static int begin_draw(void) {
struct prog *p = cur_prog;
@@ -558,6 +720,14 @@ EXPORT void glLinkProgram(GLuint program) {
INIT();
note_program(program);
r_LinkProgram(program);
if (debug >= 2) {
GLint ok = 0;
r_GetProgramiv(program, GL_LINK_STATUS, &ok);
char log[700] = {0};
r_GetProgramInfoLog(program, sizeof(log) - 1, NULL, log);
struct prog *p = find_prog(program, 0);
LOG("trace glLinkProgram(%u) ok=%d multiview=%d log: %s", program, ok, p ? p->multiview : -1, log);
}
}
EXPORT void glDeleteProgram(GLuint program) {
@@ -626,43 +796,143 @@ static void w_ftmsmv(GLenum target, GLenum attachment, GLuint texture, GLint lev
#define DRAW(name, call) \
INIT(); \
if (!r_##name) return; \
trace_before(); \
if (begin_draw()) { \
r_##name call; \
end_draw("gl" #name); \
} else { \
r_##name call; \
}
} \
trace_after("gl" #name, call_mode, call_count);
EXPORT void glDrawArrays(GLenum mode, GLint first, GLsizei count) { DRAW(DrawArrays, (mode, first, count)) }
EXPORT void glDrawArrays(GLenum mode, GLint first, GLsizei count) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawArrays, (mode, first, count))
}
EXPORT void glDrawElements(GLenum mode, GLsizei count, GLenum type, const void *indices) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawElements, (mode, count, type, indices))
}
EXPORT void glDrawArraysInstanced(GLenum mode, GLint first, GLsizei count, GLsizei instances) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawArraysInstanced, (mode, first, count, instances))
}
EXPORT void glDrawElementsInstanced(GLenum mode, GLsizei count, GLenum type, const void *indices, GLsizei instances) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawElementsInstanced, (mode, count, type, indices, instances))
}
EXPORT void glDrawRangeElements(GLenum mode, GLuint start, GLuint end, GLsizei count, GLenum type,
const void *indices) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawRangeElements, (mode, start, end, count, type, indices))
}
EXPORT void glDrawArraysIndirect(GLenum mode, const void *indirect) { DRAW(DrawArraysIndirect, (mode, indirect)) }
EXPORT void glDrawArraysIndirect(GLenum mode, const void *indirect) {
GLenum call_mode = mode; GLsizei call_count = -1;
DRAW(DrawArraysIndirect, (mode, indirect))
}
EXPORT void glDrawElementsIndirect(GLenum mode, GLenum type, const void *indirect) {
GLenum call_mode = mode; GLsizei call_count = -1;
DRAW(DrawElementsIndirect, (mode, type, indirect))
}
EXPORT void glDrawElementsBaseVertex(GLenum mode, GLsizei count, GLenum type, const void *indices, GLint base) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawElementsBaseVertex, (mode, count, type, indices, base))
}
EXPORT void glDrawRangeElementsBaseVertex(GLenum mode, GLuint start, GLuint end, GLsizei count, GLenum type,
const void *indices, GLint base) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawRangeElementsBaseVertex, (mode, start, end, count, type, indices, base))
}
EXPORT void glDrawElementsInstancedBaseVertex(GLenum mode, GLsizei count, GLenum type, const void *indices,
GLsizei instances, GLint base) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawElementsInstancedBaseVertex, (mode, count, type, indices, instances, base))
}
// ---------------------------------------------------------------- traced calls (pass-through unless gl_mv_debug=2)
EXPORT void glClear(GLbitfield mask) {
INIT();
if (debug >= 2) r_GetError();
r_Clear(mask);
if (debug >= 2) {
GLenum err = r_GetError();
GLint fb = 0;
r_GetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &fb);
pthread_mutex_lock(&trace_lock);
++trace_clears;
struct tfb *e = tfb_slot(fb);
if (e) ++e->clears;
pthread_mutex_unlock(&trace_lock);
if (err) LOG("trace glClear(0x%x) fb=%d err=0x%x", mask, fb, err);
}
}
EXPORT void glTexImage2D(GLenum t, GLint l, GLint ifmt, GLsizei w, GLsizei h, GLint b, GLenum f, GLenum ty,
const void *px) {
INIT();
if (debug >= 2) r_GetError();
r_TexImage2D(t, l, ifmt, w, h, b, f, ty, px);
GLint tex = 0;
if (debug >= 2) r_GetIntegerv(GL_TEXTURE_BINDING_2D, &tex);
TRACE_CALL("glTexImage2D(target=0x%x tex=%d level=%d ifmt=0x%x %dx%d fmt=0x%x type=0x%x data=%d)", t, tex, l, ifmt,
w, h, f, ty, px != NULL);
}
EXPORT void glTexSubImage2D(GLenum t, GLint l, GLint x, GLint y, GLsizei w, GLsizei h, GLenum f, GLenum ty,
const void *px) {
INIT();
if (debug >= 2) r_GetError();
r_TexSubImage2D(t, l, x, y, w, h, f, ty, px);
TRACE_CALL("glTexSubImage2D(target=0x%x level=%d %d,%d %dx%d fmt=0x%x type=0x%x)", t, l, x, y, w, h, f, ty);
}
EXPORT void glCompressedTexImage2D(GLenum t, GLint l, GLenum ifmt, GLsizei w, GLsizei h, GLint b, GLsizei size,
const void *data) {
INIT();
if (debug >= 2) r_GetError();
r_CompressedTexImage2D(t, l, ifmt, w, h, b, size, data);
TRACE_CALL("glCompressedTexImage2D(target=0x%x level=%d ifmt=0x%x %dx%d size=%d)", t, l, ifmt, w, h, size);
}
EXPORT void glTexStorage2D(GLenum t, GLsizei levels, GLenum ifmt, GLsizei w, GLsizei h) {
INIT();
if (debug >= 2) r_GetError();
r_TexStorage2D(t, levels, ifmt, w, h);
TRACE_CALL("glTexStorage2D(target=0x%x levels=%d ifmt=0x%x %dx%d)", t, levels, ifmt, w, h);
}
EXPORT void glCopyTexImage2D(GLenum t, GLint l, GLenum ifmt, GLint x, GLint y, GLsizei w, GLsizei h, GLint b) {
INIT();
if (debug >= 2) r_GetError();
r_CopyTexImage2D(t, l, ifmt, x, y, w, h, b);
GLint rfb = 0;
if (debug >= 2) r_GetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &rfb);
TRACE_CALL("glCopyTexImage2D(target=0x%x level=%d ifmt=0x%x %d,%d %dx%d read fb=%d)", t, l, ifmt, x, y, w, h, rfb);
}
EXPORT void glCopyTexSubImage2D(GLenum t, GLint l, GLint xo, GLint yo, GLint x, GLint y, GLsizei w, GLsizei h) {
INIT();
if (debug >= 2) r_GetError();
r_CopyTexSubImage2D(t, l, xo, yo, x, y, w, h);
GLint rfb = 0;
if (debug >= 2) r_GetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &rfb);
TRACE_CALL("glCopyTexSubImage2D(target=0x%x level=%d %d,%d from %d,%d %dx%d read fb=%d)", t, l, xo, yo, x, y, w, h,
rfb);
}
EXPORT void glGenerateMipmap(GLenum t) {
INIT();
if (debug >= 2) r_GetError();
r_GenerateMipmap(t);
TRACE_CALL("glGenerateMipmap(target=0x%x)", t);
}
EXPORT void glCompileShader(GLuint sh) {
INIT();
r_CompileShader(sh);
if (debug < 2) return;
GLint ok = 0;
r_GetShaderiv(sh, GL_COMPILE_STATUS, &ok);
char log[700] = {0};
r_GetShaderInfoLog(sh, sizeof(log) - 1, NULL, log);
if (!ok || (log[0] && trace_logs < 400)) {
++trace_logs;
LOG("trace glCompileShader(%u) ok=%d log: %s", sh, ok, log);
}
}
// A context made current on this thread (engines with a render thread move theirs between threads, e.g. Doom3Quest's
// GLimp_ActivateContext): what this thread knew about the bound program and framebuffer may be another context's.
EXPORT EGLBoolean eglMakeCurrent(EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx) {
@@ -698,7 +968,8 @@ EXPORT __eglMustCastToProperFunctionPointerType eglGetProcAddress(const char *na
OWN(glFramebufferTexture) OWN(glDrawBuffers) OWN(glDrawArrays) OWN(glDrawElements) OWN(glDrawArraysInstanced)
OWN(glDrawElementsInstanced) OWN(glDrawRangeElements) OWN(glDrawArraysIndirect) OWN(glDrawElementsIndirect)
OWN(glDrawElementsBaseVertex) OWN(glDrawRangeElementsBaseVertex) OWN(glDrawElementsInstancedBaseVertex)
OWN(eglMakeCurrent)
OWN(eglMakeCurrent) OWN(glClear) OWN(glTexImage2D) OWN(glTexSubImage2D) OWN(glCompressedTexImage2D)
OWN(glTexStorage2D) OWN(glCopyTexImage2D) OWN(glCopyTexSubImage2D) OWN(glGenerateMipmap) OWN(glCompileShader)
#undef OWN
return fn;
}
+2 -5
View File
@@ -137,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)))
@@ -151,8 +150,7 @@ def main() -> int:
if args.fake_frame: # GitHub #90: the Move to… dialog (drives load in the background)
steps.append(("move-dialog", lambda a: (a.page.pop_dialog(), a.move_game(game))))
steps.append(("uninstall-dialog", lambda a: (a.page.pop_dialog(), a.uninstall(game))))
# last: the menus stay open over whatever comes next (the page's "…" menu, then the right-click menu)
steps.append(("game-menu", lambda a: (a.show_activity(False), a.open_game_menu(game))))
# last: the right-click menu stays open over whatever comes next
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
a.library_view.open_menu(game))))
if args.docs:
@@ -280,7 +278,6 @@ def main() -> int:
("linux-change-program", lambda a: a.choose_exe(LINUX_FOLDER)),
("frame", lambda a: (a.page.pop_dialog(), a.navigate(1))),
("linux-move", lambda a: a.move_game(LINUX_APPIMAGE) if args.fake_frame else None),
("linux-game-menu", lambda a: (a.page.pop_dialog(), a.open_game_menu(LINUX_FOLDER))),
("linux-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
a.library_view.open_menu(LINUX_APPIMAGE)))]
if args.links:
-49
View File
@@ -398,30 +398,6 @@ def rename(package: str = typer.Argument(..., help="the game (package or part of
pipeline.sync_title(pkg, _target(None, to="pc"), rep)
@app.command("new-version")
def new_version(package: str = typer.Argument(..., help="the game (package or part of its title)"),
path: Path = typer.Argument(..., exists=True, help="the newer version: an APK (Quest/Android games), "
"an AppImage/archive/folder (Linux apps) or the game folder (PC VR)")):
"""Point a game at a newer version of it (GitHub #161). Its name, artwork, tags and your own recipe stay; install
it again (frameport install) to update it on the Frame."""
[pkg] = _pkgs(package, False)
try:
res = pipeline.replace_source(pkg, path, printing_reporter(verbose=False))
except pipeline.WrongPackage as exc:
raise typer.BadParameter(f"that APK is a different app: {exc}") from None
except ValueError as exc:
raise typer.BadParameter(str(exc)) from None
g = res["entry"]
typer.echo(f"{pkg}: now {path}" + (f" (program {g.get('exe')})" if g.get("kind") in ("rift", "linux") else ""))
if res.get("older"):
typer.echo("Heads-up: that APK is an older version than the one before.", err=True)
if res.get("different"):
typer.echo(f"Heads-up: {res.get('name')} looks like a different app than {g.get('title')}.", err=True)
if g.get("kind") == "rift" and g.get("exe_confirmed") is False:
typer.echo(f"More than one program could start it; FramePort picked {g.get('exe')}. Another one: "
f"frameport recipe {pkg} --exe <program>", err=True)
@app.command()
def patches():
"""List every available patch."""
@@ -730,31 +706,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)")):
+4 -100
View File
@@ -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)
+29 -156
View File
@@ -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()
+4 -31
View File
@@ -220,28 +220,6 @@ class PcvrPlan:
art_lookup: str | None = None # package whose store art to use
dest: str | None = None
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
source_id: str | None = None # which version of the game folder (GitHub #161: a newer one was added)
changed_since: float | None = None # a newer version: game files changed after this are sent again (see below)
def changed_since(path: Path, since: float | None) -> bool:
"""A newer version of a Linux app / PC VR game (GitHub #161): uploads skip files the Frame has with the same
size, so a changed file of the same size would stay old. Files written (or unpacked: ctime) after the last
install are sent again; since=None (no newer version) never, 0 (last install unknown) always."""
if since is None:
return False
try:
st = path.stat()
except OSError:
return True
return max(st.st_mtime, st.st_ctime) > since
def _recipe_record(recipe: dict | None, source_id: str | None) -> dict | None:
"""The deployment's recipe record, with the source version it was installed from (ui.components.source_replaced)."""
if not source_id:
return recipe
return {**(recipe or {}), "source_id": source_id}
def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
@@ -281,8 +259,7 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
manifests["helpers"] = {p.name: p.stat().st_size for p in sorted(helpers.iterdir()) if p.is_file()}
sources["helpers"] = helpers
to_send = [(t, rel) for t, m in manifests.items() for rel, size in m.items()
if prep["existing"].get(t, {}).get(rel) != size
or t == "game" and changed_since(plan.game_dir / rel, plan.changed_since)]
if prep["existing"].get(t, {}).get(rel) != size]
need = sum(manifests[t][rel] for t, rel in to_send)
if need > prep["free_bytes"] - (1 << 30):
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
@@ -313,8 +290,7 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
game_args=game_args(plan.recipe), no_crash_reporter="pcvr.no_crash_reporter" in plan.recipe.patches,
libovr_redirect="pcvr.libovr_redirect" in plan.recipe.patches,
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version, vr=not is_flat_windows(plan.package),
recipe=_recipe_record({"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, plan.source_id),
overlay=plan.overlay,
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, overlay=plan.overlay,
)
reporter.log(f"installed at {result['base']} (Proton {result['proton']}, Steam shortcut id {result['appid']})")
log_overlay(result.get("overlay"), reporter)
@@ -349,8 +325,6 @@ class LinuxPlan:
desktop_entry: bool = True # an entry in Desktop Mode's menu and on its desktop (GitHub #84)
desktop_icon: bool = True # with it, a copy on Desktop Mode's desktop (GitHub #212)
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
source_id: str | None = None # which version of the app (GitHub #161: a newer one was added)
changed_since: float | None = None # a newer version: files changed after this are sent again
def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
@@ -365,8 +339,7 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
else:
manifest = local_data_manifest(plan.root)
manifest = {k: v for k, v in manifest.items() if not k.startswith(".unpacked")}
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size
or changed_since(plan.root / rel, plan.changed_since)]
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size]
need = sum(manifest[rel] for rel in to_send)
if need > prep["free_bytes"] - (1 << 30):
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
@@ -385,7 +358,7 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
appimage=plan.appimage, openxr=plan.openxr, x86_64=plan.x86_64, manifests={"app": manifest},
executables=executables, tags=_tags(plan.package), dest=plan.dest,
desktop_entry=plan.desktop_entry, desktop_icon=plan.desktop_icon, overlay=plan.overlay,
recipe=_recipe_record(None, plan.source_id), timeout=900)
timeout=900)
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
log_overlay(result.get("overlay"), reporter)
apply_app_icon(frame, plan.package, prep.get("anchor"), result, reporter)
+1 -16
View File
@@ -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,7 @@
"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.": "",
"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 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). 2 (frameport settings) also checks every draw and logs the draw state, failed texture uploads and shader compiles and, for idTech 4 engines, the game clock.": "",
"Diagnostics: removes layer color fades. Some fades no longer show.": "",
"Diagnostics: the Vulkan shim (frame.vk_sanitize) adds Khronos' validation layer to the game's instance; its findings go to launch.log. The layer library (libVkLayer_khronos_validation.so) must be in the APK.": "",
"Disable application space warp if used": "",
@@ -558,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.": "",
@@ -570,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.": [
"",
""
@@ -667,7 +664,6 @@
"Hardware video decoding (H.264, HEVC, VP9)": "",
"Hardware video decoding on the Frame": "",
"Heads-up: that APK is an older version than the one before.": "",
"Heads-up: {name} looks like a different app than {title}.": "",
"headset found": "",
"Hi there!": "",
"Hide Android's navigation bar": "",
@@ -945,8 +941,6 @@
"New recipe applied. Click Update on Frame to install it.": "",
"Newer APK for {title}": "",
"Newer APK of {title}": "",
"Newer version for {title}": "",
"Newer version of {title}": "",
"Next": "",
"Nice, it works! FramePort receives install links. (Demo: nothing was installed.)": "",
"No": "",
@@ -1032,7 +1026,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.": "",
@@ -1145,7 +1138,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": "",
@@ -1181,7 +1173,6 @@
"Reading the file list from the Frame…": "",
"Reading the install link": "",
"Reading the new APK": "",
"Reading the new version": "",
"ready": "",
"Ready": "",
"Ready to play": "",
@@ -1421,7 +1412,6 @@
"Sound on": "",
"Source": "",
"square, 256 × 256": "",
"SSH keys": "",
"Stable (default)": "",
"Stand in for cube layers": "",
"Standard for every game": "",
@@ -1472,7 +1462,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).": "",
@@ -1524,7 +1513,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.": "",
@@ -1723,7 +1711,6 @@
"Update the app only?": "",
"Update tools": "",
"Update with a newer APK…": "",
"Update with a newer version…": "",
"Updates": "",
"Updates every game marked \"Update ready\", one after another.": "",
"Updates the game, frame rate and battery every 5 seconds. Turn off to save the Frame's power.": "",
@@ -1984,8 +1971,6 @@
"{title}: name updated in Steam": "",
"{title}: newer APK added": "",
"{title}: newer APK added. Press Update on Frame to install it.": "",
"{title}: newer version added": "",
"{title}: newer version added. Press Update to install it.": "",
"{title}: ready": "",
"{title}: Steam artwork updated on the Frame": "",
"{title}: {line}": "",
+3 -1
View File
@@ -215,7 +215,9 @@ SETTINGS = [
"findings go to launch.log. The layer library (libVkLayer_khronos_validation.so) must be in the APK."),
("gl_mv_debug", "int", 0, "Multiview interposer: diagnostics",
"Diagnostics for frame.gl_multiview_fbo: checks for GL errors after every single-view draw of a multiview "
"shader and logs counters every 5 seconds (tag GLMV in launch.log)."),
"shader and logs counters every 5 seconds (tag GLMV in launch.log). 2 (frameport settings) also checks every "
"draw and logs the draw state, failed texture uploads and shader compiles and, for idTech 4 engines, the game "
"clock."),
("gl_hide_msrtt", "int", 1, "GL shim: hide multisampled render-to-texture",
"GL shim only: hide GL_EXT_multisampled_render_to_texture(2) (Zink crashes rendering Unity's runtime MSAA eye "
"buffer through it, for example The Room VR)."),
+13 -123
View File
@@ -137,25 +137,18 @@ def _existing_rift(folder: Path) -> dict | None:
def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe: str | None = None,
force: bool = False, art: bool = False, replace: dict | None = None) -> dict | None:
force: bool = False, art: bool = False) -> dict | None:
"""Analyze one Rift game folder and store it. Unchanged folders (same program, size, date) are not analyzed again;
the user's exe choice, recipe and tags survive rescans. Returns None for folders without a VR runtime (unless
force). replace: the library entry this folder is a newer version of (replace_rift_game): it keeps its package and
name."""
force)."""
from .analysis import rift
from .recommend import catalog
folder = Path(folder)
if replace is None and not force and any(str(folder) in (g.get("replaced_dirs") or []) for g in library.games()
if g.get("kind") == "rift"):
if reporter: # the older copy of a game updated from another folder: a rescan must not bring it back
reporter.log(f"skipped {folder.name}: an older version of a game that was updated from another folder")
return None
old = replace or _existing_rift(folder)
old = _existing_rift(folder)
old_extra = ((old or {}).get("analysis") or {}).get("extra") or {}
forced = exe is not None or replace is not None # set_exe / a newer version: always analyze
if replace is None:
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
forced = exe is not None # set_exe: always analyze with the user's choice
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
if old and not forced and old_extra.get("fingerprint") and \
old_extra["fingerprint"] == rift.fingerprint(folder, old.get("exe") or ""):
if reporter:
@@ -184,9 +177,7 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
suggested=library.recipe_to_dict(recipe))
if not old or (old.get("recipe") or {}).get("source") != "user":
fields.update(recipe=library.recipe_to_dict(recipe), status=recipe.status)
if replace is not None:
pass # a newer version of the game: its name stays
elif not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
if not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
fields["title"] = recipe.title or a.label # (store-matched titles are kept)
elif old.get("title_locked") and old.get("art_source") not in ("oculusdb", "meta", "steam"):
fields["auto_title"] = recipe.title or a.label # the user's name stays; a reset brings this one
@@ -409,20 +400,14 @@ def missing_obb(entry: dict) -> bool:
return bool(extra.get("expects_obb")) and not (entry.get("data_dir") and entry.get("data_bytes") != 0)
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None,
replace: str | None = None) -> dict:
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None) -> dict:
"""Add a Linux app to the library (no conversion: it's installed as it is): arm64, or x86_64 (run through FEX on
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder). replace: the package of
the library entry this is a newer version of (replace_linux_app): it keeps that package, its name and its art."""
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder)."""
from .analysis import linux
from .core.models import Recipe
path = Path(path)
info = linux.inspect(path)
if replace and not exe and not info["files"]: # a folder: the program the app started before, if it's still there
before = (library.game(replace) or {}).get("exe")
if before and before != info["exe"] and (Path(info["root"]) / before).is_file():
exe = before
if exe:
info["exe"] = exe
x86 = info["machine"] == linux.EM_X86_64
@@ -432,7 +417,7 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
if x86 and reporter:
reporter.log(f"{path.name} is an x86_64 build: the Frame runs it through FEX (x86 translation; an arm64 "
"build runs faster if there is one)")
package = replace or f"linux.{linux.slug(info['title'])}"
package = f"linux.{linux.slug(info['title'])}"
if reporter:
reporter.log(f"{info['title']}: program {info['exe']}{' (AppImage)' if info['appimage'] else ''}"
f"{', OpenXR (VR)' if info['openxr'] else ''}")
@@ -454,8 +439,6 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
if not old:
fields.update(title=info["title"], recipe=library.recipe_to_dict(recipe), status="unknown")
library.upsert_game(package, **fields)
if replace: # a newer version: the artwork stays as it is
return library.game(package)
try:
from .artwork import fetch
@@ -597,7 +580,7 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
reporter, x86_64=bool(extra.get("x86_64")),
desktop_entry=entry.get("desktop_entry", True) is not False,
desktop_icon=entry.get("desktop_icon", True) is not False,
overlay=linux_overlay(entry), **_source_args(entry, target.label))
overlay=linux_overlay(entry))
if extra.get("x86_64"):
reporter.check("x86 translation", True, "runs through FEX on SteamOS's x86 system (its libraries come from "
"there; not checked ahead)")
@@ -608,23 +591,10 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
reporter.check("Libraries on the Frame", True, "everything the app needs is there")
if add_to_library:
target.add_to_library([package], reporter)
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
"source_id": entry.get("source_id")})
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
return result
def _source_args(entry: dict, label: str) -> dict:
"""source_id (recorded with the install) and changed_since for a Linux app / PC VR game install: after a newer
version was added (replace_linux_app/replace_rift_game), files changed since the last install there are sent
again even when the Frame has a file of the same size (0 = the last install is unknown: every file)."""
sid = entry.get("source_id")
if not sid:
return {}
rec = (entry.get("installs") or {}).get(label) or {}
return {"source_id": sid,
"changed_since": None if rec.get("source_id") == sid else float(rec.get("time") or 0)}
def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
if reporter:
reporter.log(f"analyzing {src.apk.name}")
@@ -692,85 +662,6 @@ def replace_apk(package: str, apk: Path, reporter: Reporter | None = None) -> di
"new_version": new_vc}
def _looks_different(before: str | None, now: str | None) -> bool:
"""A newer version whose name has little in common with the old one's (maybe another app): the caller warns."""
import difflib
a, b = _norm_title(before or ""), _norm_title(now or "")
if not a or not b or a in b or b in a:
return False
return difflib.SequenceMatcher(None, a, b).ratio() < 0.5
def _mark_source_replaced(package: str, **fields) -> dict:
"""A Linux app or PC VR game got a newer version: a new source id, which installs record (in the deployment's
recipe); an install without it shows the game as outdated (ui.components.source_replaced)."""
import uuid
return library.upsert_game(package, source_id=uuid.uuid4().hex[:12], source_updated=time.time(), **fields)
def replace_linux_app(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
"""A newer version of a Linux app already in the library (GitHub #161): an AppImage/archive or a folder, analysed
like "Add a Linux app…" (program, arm64/x86_64, AppImage, OpenXR). The app keeps its package (so its Steam
shortcut on the Frame), name, artwork, tags and switches (Desktop Mode entry, overlay app); a folder keeps the
program chosen before if it's still there. Returns {"entry", "different": its name looks like another app's,
"name": the new version's own name}."""
entry = library.game(package)
if entry is None or not is_linux(entry):
raise ValueError("only Linux apps can get a newer version this way")
before = ((entry.get("analysis") or {}).get("label")) or entry.get("title")
old_source = ((entry.get("analysis") or {}).get("extra") or {}).get("source")
if reporter:
reporter.log(f"reading {Path(path).name}")
stored = add_linux_app(path, reporter, replace=package)
name = (stored.get("analysis") or {}).get("label")
stored = _mark_source_replaced(package, replaced_source=old_source)
return {"entry": stored, "different": _looks_different(before, name), "name": name}
def replace_rift_game(package: str, folder: Path | str, reporter: Reporter | None = None) -> dict:
"""A newer version of a PC VR (Rift) game already in the library (GitHub #161): its new game folder, analysed like
a scanned one (programs, VR runtimes, fingerprint). The game keeps its package, name, artwork, tags and the user's
own recipe; a program the user confirmed stays if the new folder has it at the same place, else FramePort picks
one again (exe_confirmed False when that's unclear: the caller asks). The old folder is left out of later scans.
Returns {"entry", "different", "name", "exe_changed"}."""
entry = library.game(package)
if entry is None or not is_rift(entry):
raise ValueError("only PC VR games can get a newer version folder")
folder = Path(folder)
if not folder.is_dir():
raise ValueError(f"{folder} isn't a folder")
other = _existing_rift(folder)
if other and other["package"] != package:
raise ValueError(f"{folder.name} is already in the library as {other.get('title') or other['package']}")
before = (entry.get("analysis") or {}).get("label") or entry.get("title")
old_dir, old_exe = entry.get("game_dir"), entry.get("exe")
keep = old_exe if entry.get("exe_confirmed") and old_exe and (folder / old_exe).is_file() else None
stored = add_rift_game(folder, reporter, exe=keep, force=True, replace=entry)
if stored is None:
raise ValueError(f"no game program found in {folder.name}")
replaced = [d for d in (entry.get("replaced_dirs") or []) + [old_dir] if d and d != str(folder)]
# build=None: the next install checks the new folder first (prepare_rift); the exe's checksum comes from there
stored = _mark_source_replaced(package, build=None, replaced_dirs=list(dict.fromkeys(replaced)))
name = (stored.get("analysis") or {}).get("label")
return {"entry": stored, "different": _looks_different(before, name), "name": name,
"exe_changed": stored.get("exe") != old_exe}
def replace_source(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
"""A newer version of any game in the library: an APK (Quest/Android), an app file or folder (Linux) or a game
folder (PC VR). See replace_apk, replace_linux_app, replace_rift_game."""
entry = library.game(package)
if entry is None:
raise ValueError(f"{package} isn't in the library")
if is_linux(entry):
return replace_linux_app(package, path, reporter)
if is_rift(entry):
return replace_rift_game(package, path, reporter)
return replace_apk(package, Path(path), reporter)
def _analyze_entry(entry: dict, reporter: Reporter | None = None):
src = source_of(entry)
if reporter:
@@ -1384,11 +1275,10 @@ def install_rift(package: str, target: Target, reporter: Reporter, add_to_librar
result = target.install_pcvr(package, title, Path(entry["game_dir"]), entry["exe"], recipe, reporter,
revive_dir=rdir, revive_version=revive.installed_version() if rdir else None,
exe_sha256=entry["build"].get("sha256"), art_lookup=entry.get("quest_package"),
overlay=rift_overlay(entry, recipe), **_source_args(entry, target.label))
overlay=rift_overlay(entry, recipe))
if add_to_library:
target.add_to_library([package], reporter)
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
"source_id": entry.get("source_id")})
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
return result
+1 -2
View File
@@ -36,8 +36,7 @@ class Target(ABC):
raise NotImplementedError(f"{self.label} can't launch games")
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None,
changed_since=None) -> dict:
desktop_entry=True, overlay=None, desktop_icon=True) -> dict:
"""Install a Linux app (only the Frame runs them; x86_64 ones through FEX)."""
raise NotImplementedError(f"{self.label} can't install Linux apps")
+3 -4
View File
@@ -86,15 +86,14 @@ class FrameLeptonTarget(Target):
def install_pcvr(self, package, title, game_dir, exe, recipe, reporter, **extra):
plan = installer.PcvrPlan(package, title, Path(game_dir), exe, recipe, extra.get("revive_dir"),
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"),
dest=self.install_dest(), overlay=extra.get("overlay"),
source_id=extra.get("source_id"), changed_since=extra.get("changed_since"))
dest=self.install_dest(), overlay=extra.get("overlay"))
return installer.install_pcvr(self.connect().frame, plan, reporter)
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None, changed_since=None):
desktop_entry=True, overlay=None, desktop_icon=True):
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64,
dest=self.install_dest(), desktop_entry=desktop_entry, overlay=overlay,
desktop_icon=desktop_icon, source_id=source_id, changed_since=changed_since)
desktop_icon=desktop_icon)
return installer.install_linux(self.connect().frame, plan, reporter)
def drives(self) -> list[dict]:
+1 -3
View File
@@ -191,9 +191,7 @@ class PcReviveTarget(Target):
"tuning": dict(recipe.params("pcvr.steamvr_tuning"), enabled=True)
if "pcvr.steamvr_tuning" in recipe.patches else None,
"sha256": extra.get("exe_sha256"), "art_lookup": extra.get("art_lookup"),
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source,
**({"source_id": extra["source_id"]} if extra.get("source_id") else {})},
"time": time.time()}
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source}, "time": time.time()}
exe_field, _, _ = shortcut_fields(dep)
d = pc_dir() / package
try: # a reinstall keeps the shortcut's appid: Steam's entry is matched by its Exe, whatever its name now
+2 -58
View File
@@ -880,7 +880,7 @@ class FramePortApp:
self.toast(msg, error=True, action=tr("Details"),
on_action=lambda e: self.show_activity(True))
self.refresh_view()
if job.kind in ("scan", "task") and self.exe_queue: # (a task: a newer PC VR game version)
if job.kind == "scan" and self.exe_queue:
self.page.run_thread(self.next_exe_choice)
elif job and job.active and job.package and self.route[:2] == ("game", job.package):
pkg, transit = self.hero_transit or (None, None)
@@ -1152,7 +1152,6 @@ class FramePortApp:
"move": lambda e: self.move_game(pkg),
"update_app": lambda e: self.update_app_only(pkg),
"new_apk": lambda e: self.page.run_task(self.update_with_apk, pkg),
"new_version": lambda e: self.page.run_task(self.update_with_version, pkg),
"uninstall_frame": lambda e: self.uninstall(pkg, "frame"),
"uninstall_pc": lambda e: self.uninstall(pkg, "pc"),
"remove": lambda e: self.remove_from_library(pkg),
@@ -1169,8 +1168,7 @@ class FramePortApp:
settings=self.has_game_settings(g), programs=rift or (linux and bool(self.linux_programs(g))),
selectable=lv is not None,
media_button=C.is_media_player(g) and not rift,
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux,
new_version=rift or linux)
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux)
return menu_sections(g, st, handlers.__getitem__)
def open_game_menu(self, pkg: str) -> None:
@@ -1623,60 +1621,6 @@ class FramePortApp:
return tr("{title}: newer APK added").format(title=self._title(pkg))
self.submit(tr("Newer APK for {title}").format(title=self._title(pkg)), run, pkg, "task")
async def update_with_version(self, pkg: str) -> None:
"""Game menu "Update with a newer version…" for Linux apps and PC VR games (GitHub #161): pick the new app
file or folder (how the app was added) / the new game folder; the game keeps its name, art, tags and switches,
and its page then offers Update on Frame (or on this PC)."""
from ..analysis import linux as linux_analysis
if self.jobs.busy_with(pkg):
return
g = library.game(pkg) or {}
rift = g.get("kind") == "rift"
title = tr("Newer version of {title}").format(title=self._title(pkg))
if rift or self._linux_folder_app(g):
path = await C.pick_dir(self.page, dialog_title=title)
if not path:
return
else:
paths = await C.pick_files(self.page, dialog_title=title)
if not paths:
return
path = paths[0]
if not linux_analysis.looks_like_linux_app(Path(path)):
self.toast(tr("{name} isn't a Linux app (AppImage, Linux program or .zip/.tar archive)")
.format(name=Path(path).name), error=True)
return
on_frame = self.frame_state == "connected" and \
C.install_state(g, self.frame_info) in ("installed", "outdated")
on_pc = rift and pkg in self.pc_installs()
def run(job: Job):
job.reporter.stage(tr("Reading the new version"))
res = pipeline.replace_source(pkg, Path(path), job.reporter)
if res.get("different"):
self.toast(tr("Heads-up: {name} looks like a different app than {title}.").format(
name=res.get("name") or Path(path).name, title=self._title(pkg)))
if rift and res["entry"].get("exe_confirmed") is False: # which program starts it? (asked after the job)
self.exe_queue.append(pkg)
self.open_game(pkg)
if on_frame or on_pc:
return tr("{title}: newer version added. Press Update to install it.").format(
title=self._title(pkg))
return tr("{title}: newer version added").format(title=self._title(pkg))
self.submit(tr("Newer version for {title}").format(title=self._title(pkg)), run, pkg, "task")
@staticmethod
def _linux_folder_app(g: dict) -> bool:
"""A Linux app added from a folder (its newer version is a folder too), not from an AppImage/archive."""
from ..analysis import linux as linux_analysis
extra = (g.get("analysis") or {}).get("extra") or {}
source = Path(extra.get("source") or g.get("game_dir") or "")
if source.exists():
return source.is_dir()
return not extra.get("files") and not source.name.lower().endswith(linux_analysis.ARCHIVES)
def updatable(self) -> list[tuple[str, str]]:
"""[(package, "frame" | "pc")] installs with an update ready (a newer build or changed patch settings)."""
out = []
+1 -13
View File
@@ -178,8 +178,6 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
# still installed (GitHub #90)
if not dep or (dep.get("apk_present") is False and not dep.get("drive_missing")):
return "missing"
if source_replaced(game, dep):
return "outdated" # a newer version of the Linux app / PC VR game was added since this install (GitHub #161)
b = game.get("build") or {}
if game.get("kind") not in ("linux", "rift") and b.get("apk_recipe_fp") and dep.get("configuration_fp"):
from ..game_update import plan_update
@@ -196,16 +194,6 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
return "outdated" if settings_diff(game, frame_info) else "installed"
def source_replaced(game: dict, dep: dict | None) -> bool:
"""A Linux app or PC VR game got a newer version ("Update with a newer version…", GitHub #161) that this install
(the Frame's or the PC's deployment record) doesn't have yet."""
sid = game.get("source_id")
if not sid or game.get("kind") not in ("linux", "rift"):
return False
rec = (dep or {}).get("recipe")
return (rec.get("source_id") if isinstance(rec, dict) else None) != sid
def recipe_changed(game: dict) -> bool:
from ..patches.base import recipe_fingerprint, revised_since_unrecorded
@@ -228,7 +216,7 @@ def pc_outdated(g: dict, dep: dict) -> bool:
args = game_args(recipe_from_dict(g["recipe"]))
except Exception: # noqa: BLE001
args = dep.get("game_args") or []
return installed != want or list(dep.get("game_args") or []) != args or source_replaced(g, dep)
return installed != want or list(dep.get("game_args") or []) != args
def one_choice():
-3
View File
@@ -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 "
-5
View File
@@ -35,7 +35,6 @@ class MenuState:
media_button: bool = False # the game page shows "Add videos" already
movable: bool = False # the Frame knows which drive it's on (agent v63+, microSD): Move to…
android: bool = False # a Quest/Android game (not PC VR, not a Linux app): it can get a newer APK
new_version: bool = False # a Linux app or PC VR game: it can get a newer version (file or folder)
def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> list:
@@ -56,8 +55,6 @@ def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> lis
top.append((tr("Move to…"), ft.Icons.DRIVE_FILE_MOVE_ROUNDED, act("move")))
if st.android and not job: # GitHub #161: the game keeps its name, art and recipe
top.append((tr("Update with a newer APK…"), ft.Icons.UPGRADE_ROUNDED, act("new_apk")))
if st.new_version and not job: # the same for Linux apps and PC VR games
top.append((tr("Update with a newer version…"), ft.Icons.UPGRADE_ROUNDED, act("new_version")))
if st.on_frame and not rift and not linux and not job:
# the new build only; the Frame's game data stays (e.g. changed OBBs a normal update would replace)
top.append((tr("Update app only…"), ft.Icons.SYSTEM_UPDATE_ALT_ROUNDED, act("update_app")))
@@ -106,8 +103,6 @@ def quick_menu(st: MenuState, act: Callable[[str], Callable]) -> list:
out += st.installs[:1]
if st.android:
out.append((tr("Update with a newer APK…"), ft.Icons.UPGRADE_ROUNDED, act("new_apk")))
if st.new_version:
out.append((tr("Update with a newer version…"), ft.Icons.UPGRADE_ROUNDED, act("new_version")))
if st.settings:
out.append((tr("Game settings…"), ft.Icons.TUNE_ROUNDED, act("settings")))
out.append((tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, act("rename")))
+1 -47
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: GPL-3.0-only
// FrameBridge's xrGetDisplayRefreshRateFB fallback (native/adapter/refresh_rate.h, GitHub #306).
#include <assert.h>
#include "refresh_rate.h"
int main(void) {
assert(fb_refresh_rate(13888888, 0) == 72.0f); // 72 Hz frame timing
assert(fb_refresh_rate(11111111, 0) == 90.0f); // 90 Hz
assert(fb_refresh_rate(8333333, 0) == 120.0f); // 120 Hz
assert(fb_refresh_rate(6944444, 0) == 144.0f); // 144 Hz
assert(fb_refresh_rate(0, 0) == 72.0f); // no xrWaitFrame yet: never 0 Hz (PreyVR: 1000 / 0 ms per tic)
assert(fb_refresh_rate(0, 90.0f) == 90.0f); // ... else the refresh_rate setting
assert(fb_refresh_rate(-5, 0) == 72.0f);
assert(fb_refresh_rate(1000, 0) == 72.0f); // nonsense periods (1 MHz, 1 Hz) aren't believed
assert(fb_refresh_rate(1000000000, 80.0f) == 80.0f);
return 0;
}
+2 -18
View File
@@ -31,11 +31,9 @@ def _states():
("quest not installed", QUEST, MenuState(connected=True, installs=[_install("Install on Frame")],
settings=True, selectable=True, android=True)),
("rift on PC", RIFT, MenuState(on_pc=True, plays=[_play("this PC")], programs=True, selectable=True,
installs=[_install("Reinstall on this PC")], new_version=True)),
installs=[_install("Reinstall on this PC")])),
("linux on Frame", LINUX, MenuState(connected=True, on_frame=True, plays=[_play()], installs=[_install()],
selectable=True, new_version=True)),
("linux job running", LINUX, MenuState(connected=True, on_frame=True, job=True, selectable=True,
new_version=True)),
selectable=True)),
("job running", QUEST, MenuState(connected=True, on_frame=True, job=True, settings=True, selectable=True,
android=True)),
]
@@ -174,17 +172,3 @@ def test_newer_apk_for_quest_games_in_both_menus():
want = g is QUEST and not st.job
assert ("Update with a newer APK…" in quick) == want, name
assert ("Update with a newer APK…" in full) == want, name
def test_newer_version_for_linux_apps_and_pc_vr_games():
"""GitHub #161: Linux apps and PC VR games get "Update with a newer version…" (a file or folder), not the APK
entry, in both menus; never while a job runs."""
for name, g, st in _states():
quick = [e[0] for e in _items(menu_sections(g, st, _key))]
st.quick = False
full = [e[0] for e in _items(menu_sections(g, st, _key))]
want = g is not QUEST and not st.job
assert ("Update with a newer version…" in quick) == want, name
assert ("Update with a newer version…" in full) == want, name
if g is not QUEST:
assert "Update with a newer APK…" not in quick + full, name
+6 -36
View File
@@ -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
-230
View File
@@ -1,230 +0,0 @@
"""GitHub #161: a newer version of a Linux app or a PC VR game already in the library (game menu "Update with a newer
version…", CLI `frameport new-version`)."""
import os
import time
import pytest
from test_install_drives import FakeFrame
from test_linux_apps import elf
from test_rift import unity_game
from frameport import pipeline
from frameport.analysis import linux
from frameport.core import library
from frameport.core.events import Reporter
from frameport.install import installer
from frameport.ui import components as C
def _appimage(path, extra=b""):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(elf(linux.EM_AARCH64, appimage=True, extra=extra))
return path
def _frame(package, recipe=None):
return {"installed": [{"package": package, "sha256": None, "recipe": recipe}]}
# ------------------------------------------------------------------------------------------ Linux apps
def test_newer_appimage_keeps_name_art_and_switches(tmp_path):
old = _appimage(tmp_path / "v1" / "Venera-Prime-2.4.2-aarch64.AppImage")
pkg = pipeline.add_linux_app(old)["package"]
library.upsert_game(pkg, title="My Venera", title_locked=True, tags=["mine"], desktop_entry=False,
desktop_icon=False, vr_overlay=True)
assert C.install_state(library.game(pkg), _frame(pkg)) == "installed" # never updated: no source id needed
new = _appimage(tmp_path / "v2" / "Venera-Prime-2.5.0-aarch64.AppImage", extra=b"newer")
res = pipeline.replace_linux_app(pkg, new)
g = library.game(pkg)
assert g["package"] == pkg and g["exe"] == new.name and g["game_dir"] == str(new.parent)
assert g["analysis"]["extra"]["source"] == str(new) and g["analysis"]["extra"]["files"] == [new.name]
assert g["data_bytes"] == new.stat().st_size
assert (g["title"], g["title_locked"], g["tags"]) == ("My Venera", True, ["mine"])
assert (g["desktop_entry"], g["desktop_icon"], g["vr_overlay"]) == (False, False, True)
assert g["replaced_source"] == str(old) and g["source_id"] and res["different"] is False
# the Frame's copy (installed before) is outdated until an install records this version
assert C.install_state(g, _frame(pkg)) == "outdated"
assert C.install_state(g, _frame(pkg, {"source_id": "older"})) == "outdated"
assert C.install_state(g, _frame(pkg, {"source_id": g["source_id"]})) == "installed"
def test_newer_linux_folder_keeps_the_chosen_program(tmp_path):
def app(root, size):
(root / "bin").mkdir(parents=True)
(root / "Tool").write_bytes(elf(linux.EM_AARCH64, extra=b"\0" * size)) # bigger: FramePort's pick
(root / "bin" / "tool-vr").write_bytes(elf(linux.EM_AARCH64))
return root
pkg = pipeline.add_linux_app(app(tmp_path / "v1" / "Tool", 4096), exe="bin/tool-vr")["package"]
assert library.game(pkg)["exe"] == "bin/tool-vr"
pipeline.replace_linux_app(pkg, app(tmp_path / "v2" / "Tool", 8192))
g = library.game(pkg)
assert g["exe"] == "bin/tool-vr" and g["game_dir"] == str(tmp_path / "v2" / "Tool")
# the program isn't there any more: FramePort's pick
(tmp_path / "v3" / "Tool").mkdir(parents=True)
(tmp_path / "v3" / "Tool" / "Tool").write_bytes(elf(linux.EM_AARCH64))
pipeline.replace_linux_app(pkg, tmp_path / "v3" / "Tool")
assert library.game(pkg)["exe"] == "Tool"
def test_another_linux_app_is_added_with_a_warning(tmp_path):
pkg = pipeline.add_linux_app(_appimage(tmp_path / "Venera-Prime-1.0-aarch64.AppImage"))["package"]
res = pipeline.replace_linux_app(pkg, _appimage(tmp_path / "Blender-4.2-aarch64.AppImage"))
assert res["different"] and res["name"] == "Blender" and library.game(pkg)["exe"].startswith("Blender")
assert not library.game("linux.blender") # no second entry: the user's pick is the newer version
with pytest.raises(ValueError):
pipeline.replace_linux_app(pkg, tmp_path / "missing.AppImage")
def test_only_the_right_kind(tmp_path):
pkg = pipeline.add_linux_app(_appimage(tmp_path / "Tool-aarch64.AppImage"))["package"]
with pytest.raises(ValueError):
pipeline.replace_rift_game(pkg, tmp_path)
library.upsert_game("com.x.quest", title="Quest", apk="/x.apk")
with pytest.raises(ValueError):
pipeline.replace_linux_app("com.x.quest", tmp_path / "Tool-aarch64.AppImage")
def test_linux_update_sends_changed_files_of_the_same_size(tmp_path, monkeypatch):
(tmp_path / "app").mkdir()
for name in ("tool", "data.txt"):
(tmp_path / "app" / name).write_bytes(b"x" * 10)
old = time.time() - 3600
os.utime(tmp_path / "app" / "data.txt", (old, old))
monkeypatch.setattr(installer, "upload_steam_art", lambda *a, **k: None)
monkeypatch.setattr(installer, "apply_app_icon", lambda *a, **k: None)
monkeypatch.setattr(installer, "_tags", lambda pkg: [])
sent = []
monkeypatch.setattr(installer, "upload_files", lambda xfer, files, *a: sent.extend(f[1] for f in files))
monkeypatch.setattr(installer, "transfer_link", lambda *a: _Null())
frame = FakeFrame({"prepare_linux": {"existing": {"app": {"tool": 10, "data.txt": 10}}, "free_bytes": 1 << 40,
"incoming": "/x/incoming", "base": "/b"},
"finalize_linux": {"base": "/b", "appid": 1}})
plan = installer.LinuxPlan("linux.tool", "Tool", tmp_path / "app", "tool")
installer.install_linux(frame, plan, Reporter())
assert sent == [] and dict(frame.calls)["finalize_linux"]["recipe"] is None # same sizes: nothing sent
# (st_ctime can't be set: the file's own ctime is now) a newer version: files changed since the last install
plan = installer.LinuxPlan("linux.tool", "Tool", tmp_path / "app", "tool", source_id="v2",
changed_since=time.time() + 60)
installer.install_linux(frame, plan, Reporter())
assert sent == []
plan.changed_since = time.time() - 60
installer.install_linux(frame, plan, Reporter())
assert sorted(sent) == ["app/data.txt", "app/tool"]
assert frame.calls[-1][1]["recipe"] == {"source_id": "v2"}
class _Null:
def __enter__(self):
return None
def __exit__(self, *a):
return False
def test_install_records_the_version(tmp_path, monkeypatch):
pkg = pipeline.add_linux_app(_appimage(tmp_path / "v1" / "Tool-aarch64.AppImage"))["package"]
got = {}
class T:
label = "Frame"
def install_linux(self, *a, **k):
got.clear()
got.update(k)
return {}
monkeypatch.setattr(pipeline, "steam_title", lambda e: "Tool")
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
assert "source_id" not in got # never updated: installs as before
pipeline.replace_linux_app(pkg, _appimage(tmp_path / "v2" / "Tool-aarch64.AppImage"))
sid = library.game(pkg)["source_id"]
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
assert got["source_id"] == sid and 0 < got["changed_since"] <= time.time() # since the last install there
assert library.game(pkg)["installs"]["Frame"]["source_id"] == sid
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
assert got["changed_since"] is None # this version is there already: resumes as usual
# ------------------------------------------------------------------------------------------ PC VR (Rift) games
def _rift(tmp_path):
first = unity_game(tmp_path / "v1")
[g] = pipeline.add_path(tmp_path / "v1")
pkg = g["package"]
r = library.recipe_from_dict(g["recipe"])
r.source = "user"
r.patches["pcvr.no_crash_reporter"] = {}
pipeline.set_recipe(pkg, r)
library.upsert_game(pkg, title="My Space", tags=["mine"], exe_confirmed=True,
build={"ok": True, "sha256": "old", "kind": "rift"})
return pkg, first
def test_newer_rift_folder_keeps_name_recipe_and_program(tmp_path):
pkg, first = _rift(tmp_path)
new = unity_game(tmp_path / "v2")
res = pipeline.replace_rift_game(pkg, new)
g = library.game(pkg)
assert g["game_dir"] == str(new) and g["exe"] == "Space Game.exe" and g["exe_confirmed"] is True
assert (g["title"], g["tags"]) == ("My Space", ["mine"])
assert "pcvr.no_crash_reporter" in g["recipe"]["patches"] and g["recipe"]["source"] == "user"
assert g["build"] is None and g["replaced_dirs"] == [str(first)] and not res["different"]
from frameport.patches.pcvr import game_args
dep = {"package": pkg, "sha256": "old", "recipe": {"patches": sorted(g["recipe"]["patches"])},
"game_args": game_args(library.recipe_from_dict(g["recipe"]))}
assert C.install_state(g, {"installed": [dep]}) == "outdated"
assert C.pc_outdated(g, dep)
dep["recipe"]["source_id"] = g["source_id"]
assert C.install_state(g, {"installed": [dep]}) == "installed" and not C.pc_outdated(g, dep)
# a rescan of the folders doesn't bring the old copy back
assert pipeline.add_path(tmp_path / "v1") == [] and library.game(pkg)["game_dir"] == str(new)
def test_newer_rift_folder_without_the_program_picks_again(tmp_path):
pkg, _ = _rift(tmp_path)
new = unity_game(tmp_path / "v2", name="Space Game VR")
res = pipeline.replace_rift_game(pkg, new)
assert res["exe_changed"] and library.game(pkg)["exe"] == "Space Game VR.exe"
with pytest.raises(ValueError):
pipeline.replace_rift_game(pkg, tmp_path / "nothing")
other = unity_game(tmp_path / "other", name="Moon Game")
pipeline.add_path(tmp_path / "other")
with pytest.raises(ValueError, match="already in the library"):
pipeline.replace_rift_game(pkg, other)
def test_rift_update_resends_changed_game_files(tmp_path, monkeypatch):
game = unity_game(tmp_path)
monkeypatch.setattr(installer, "upload_steam_art", lambda *a, **k: None)
monkeypatch.setattr(installer, "_tags", lambda pkg: [])
monkeypatch.setattr(installer, "transfer_link", lambda *a: _Null())
sent = []
monkeypatch.setattr(installer, "upload_files", lambda xfer, files, *a: sent.extend(f[1] for f in files))
monkeypatch.setattr("frameport.artwork.sources.has_art", lambda pkg: True)
manifest = installer.local_data_manifest(game)
frame = FakeFrame({"prepare_pcvr": {"proton": {"display_name": "Proton"}, "openxr": None,
"existing": {"game": dict(manifest)}, "free_bytes": 1 << 40,
"incoming": "/i", "base": "/b"},
"finalize_pcvr": {"base": "/b", "proton": "p", "appid": 1}})
from frameport.core.models import Recipe
plan = installer.PcvrPlan("rift.space_game", "Space", game, "Space Game.exe", Recipe(package="rift.space_game"),
None, source_id="v2", changed_since=time.time() - 60)
installer.install_pcvr(frame, plan, Reporter())
assert sorted(sent) == sorted(f"game/{rel}" for rel in manifest)
assert frame.calls[-1][1]["recipe"]["source_id"] == "v2"
# ------------------------------------------------------------------------------------------ CLI
def test_cli_new_version(tmp_path):
from typer.testing import CliRunner
from frameport import cli
pkg = pipeline.add_linux_app(_appimage(tmp_path / "v1" / "Tool-1.0-aarch64.AppImage"))["package"]
new = _appimage(tmp_path / "v2" / "Tool-1.1-aarch64.AppImage")
out = CliRunner().invoke(cli.app, ["new-version", pkg, str(new)])
assert out.exit_code == 0, out.output
assert library.game(pkg)["exe"] == new.name and "program Tool-1.1-aarch64.AppImage" in out.output
(tmp_path / "empty").mkdir()
bad = CliRunner().invoke(cli.app, ["new-version", pkg, str(tmp_path / "empty")]) # no program in it
assert bad.exit_code == 2
+43
View File
@@ -0,0 +1,43 @@
"""FrameBridge answers xrGetDisplayRefreshRateFB with a real rate (GitHub #306, PreyVR's black main menu).
The Frame's runtime has no XR_FB_display_refresh_rate; OVRPort's dispatcher still offers it and answered 0 Hz. Team
Beef's idTech 4 ports (PreyVR, the Doom3Quest engine) run 1000 / refresh ms per tic, so 0 Hz stopped their game and
GUI clock: the main menu stayed at its first animation step (black, only the cursor)."""
import os
import re
import shutil
import subprocess
from pathlib import Path
import pytest
import yaml
from frameport.validate import triage
ROOT = Path(__file__).resolve().parents[1]
def test_fallback_rate_is_never_zero(tmp_path):
cc = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
if not cc or os.name == "nt":
pytest.skip("needs a POSIX host C compiler")
binary = tmp_path / "refresh-rate-test"
subprocess.run([cc, "-std=c11", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "native/adapter"),
str(ROOT / "tests/fixtures/src/refresh_rate_test.c"), "-o", str(binary)], check=True)
subprocess.run([str(binary)], check=True)
def test_the_hook_is_always_installed():
src = (ROOT / "native/adapter/frame_adapter.c").read_text()
# not behind a per-game setting: every game that asks gets a rate, also where the runtime lacks the function
assert re.search(r"\n if \(instance != XR_NULL_HANDLE\) HOOK_AS\(xrGetDisplayRefreshRateFB, hook_get_refresh_rate\)",
src)
assert "fb_refresh_rate(" in (ROOT / "native/adapter/session_fixes.c").read_text()
def test_a_single_rejected_frame_is_not_an_error():
entries = {e["id"]: e for e in yaml.safe_load((ROOT / "catalog/triage.yaml").read_text())["signatures"]}
assert entries["cylinder-menu-layers-rejected"]["severity"] == "info"
log = "10-16 05:06:16.011 1188 1248 I FrameBridge: xrEndFrame failed -16: blend=1 layers=7\n"
found = [f for f in triage.triage(log).findings if f.id == "cylinder-menu-layers-rejected"]
assert found and found[0].severity == "info"
-340
View File
@@ -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
-29
View File
@@ -510,32 +510,3 @@ def test_obb_and_cube_advice_describes_limits():
advice = next(f for f in cube.findings if f.id == "cube-swapchain-refused")
assert "GLES" in advice.diagnosis
assert "For a Vulkan game, the current build cannot stand in" in advice.diagnosis
# Deadly Delivery 1.0.30 (GitHub #322): renders, then its lobby's Meta entitlement check fails four times and it quits
LOG_ENTITLEMENT = """
10-15 12:45:23.964 212 240 I ActivityManager: Start proc 1214:com.FlatHeadStudio.DeadlyDelivery/u0a14 for \
pre-top-activity {com.FlatHeadStudio.DeadlyDelivery/com.unity3d.player.UnityPlayerActivity}
10-15 12:45:38.349 1214 1238 E Unity : [LobbyManager]: Entitlement check failed: .
10-15 12:45:38.349 1214 1238 E Unity : FlatHeads.<>c__DisplayClass105_0:<CheckEntitlementAndConfigureAsync>b__0(Message)
10-15 12:45:38.352 1214 1238 I Unity : [LobbyManager]: Retrying oculus authentication in 2 seconds.
10-15 12:45:43.372 1214 1286 I FrameBridge: pacing: 71.6 fps, displayTime vs predicted: avg 13.97 ms, max 83.33 ms
10-15 12:45:46.349 1214 1238 I Unity : stopping xr plugin
10-15 12:45:47.424 1214 1238 I Unity : [Photon] Disconnected from Photon (None)
10-15 12:45:48.857 1214 1214 I Process : Sending signal. PID: 1214 SIG: 9
"""
def test_meta_entitlement_quest():
r = triage(LOG_ENTITLEMENT, "EXITED", "com.FlatHeadStudio.DeadlyDelivery")
f = next(f for f in r.findings if f.id == "meta-entitlement")
assert f.severity == "error" and f.suggest == []
assert "Entitlement check failed" in f.evidence
assert "oculus-entitlement" not in {x.id for x in r.findings} # the Rift signature stays PC VR only
assert r.verdict == "fail"
assert "meta-entitlement" in {x.id for x in triage("E Unity: ovrPlatformInitialize_NotEntitled", "EXITED").findings}
# routine Platform SDK lines of games that work don't match; a game that logs it and keeps running still passes
for ok in ("I Unity : CheckEntitlementAndConfigureAsync", "I Unity : Entitlement check passed",
"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"