Compare commits

..
5 Commits
Author SHA1 Message Date
spoopyghosty0 94bc4c479e PC VR playability (auto SteamVR, OpenVR backend) + honest Frame routing
Make PC VR games actually reach VR, and stop mis-claiming Frame support:

- PC: auto-start SteamVR on Play/launch (winhost.start_steamvr) — Oculus games
  ran as a flat window because SteamVR wasn't up; Revive has no runtime to bind
  to without it. Default PC installs to Revive's OpenVR (SteamVR) backend
  (pcvr.revive_openvr on) instead of the OpenXR backend that was crashing; the
  Frame launcher still uses /openxr. Play toast warns if SteamVR can't come up.
- Routing: detect OpenVR (openvr_api imports/DLLs, not the universal-in-Unreal
  IVRSystem/VR_InitInternal strings). frame_native = (openxr or openvr) and not
  libovr; any LibOVR game is Oculus -> needs Revive -> PC only. The game page
  says per-game where it runs; installing an Oculus game on the Frame warns
  first. Rick and Morty runs on the Frame via its catalog recipe. Migration
  rift_frame_native re-analyzes + re-derives existing entries.

Note: Oculus/LibOVR games can't run on the Frame — Revive's hooks don't work
under Proton-arm64 (ARM64EC), and FramePort does not defeat the Oculus runtime
Authenticode signature check. Those games are PC-only (SteamVR + Revive).

Bump to 0.2.0. 133 tests pass.
2026-09-30 09:34:12 -04:00
spoopyghosty0 0cb195ec2c Oculus Rift / PC VR support, GUI overhaul, artwork and Frame tooling
Adds Oculus Rift (PC VR) games alongside Quest:
- analysis/rift.py (own PE reader: imports incl. delay-imports, GUI-subsystem
  filter, exe ranking, icons), sources/rift_dump.py (recursive scan, one game
  per folder, collections), catalog rift fields + Rick and Morty recipe.
- patches/pcvr.py (category "pcvr", shown as patches): Revive (LibOVR->OpenXR,
  default on for Oculus games), revive_openvr, no_crash_reporter (Unreal),
  oculus_unreal (OculusHMDConnected helper), xr_timefix (Frame OpenXR layer),
  proton_*. tools/revive.py (find/install Revive, never replaces the user's).
- targets/pc_revive.py (run on this Windows/WSL PC via Revive + local Steam;
  core/winhost.py helpers) and frame_lepton Proton path; agent handles Proton
  install, prepare/finalize_pcvr, launch, list_files (AGENT_VERSION 17).
- native/oculushmd (fp_oculushmd.exe) and native/xrlayer (Frame OpenXR 1.1->1.0
  + timespec-time layer); prebuilt artifacts + SHA256SUMS.

Runtime findings (Frame, SteamOS 0.4.2 / Proton 11 arm64): the SteamVR runtime
only accepts OpenXR 1.0, so Proton's vrhelper (1.1) needs the compat layer;
Oculus/LibOVR games need a VR runtime (Revive on PC with SteamVR, or SteamVR-
native builds run directly). Platform-SDK / signature-checked repacks can't run
on the Frame; those are flagged, never bypassed.

GUI overhaul (ui/): streaming persistent Library with tags/filters/sort, a
background job queue + activity panel, per-game page, Frame tab with installed
list + file browser, Settings (incl. agent version), Play + launch-test actions,
resumable/interruptible installs with up-front interventions, dark theme, help
hints, uninstaller (uninstall.py). Faster uploads via a direct link (USB /
Frame hotspot) when reachable. Artwork/details from OculusDB + Steam + ovrp,
composed Steam art sets and tags, thumbnails served by URL.

Docs (PLAYBOOK/FRAME_RUNTIME/ARCHITECTURE/INSTALL/CLAUDE.md), triage signatures,
and unit tests throughout (130 pass).
2026-09-30 00:35:19 -04:00
spoopyghosty0 69889f0ad8 Dev environment lives in .venv at the repo root 2026-09-29 12:50:54 -04:00
spoopyghosty0 182b3ad5fb CLAUDE.md: CI/release/signing notes, Frame operations, GUI testing, project status and preferences 2026-09-29 11:33:24 -04:00
spoopyghosty0 6af0c40ec8 Heuristics for uncatalogued games + hide irrelevant patches
- New analysis signals: MR-only (passthrough required + boundaryless), required hand tracking, CryEngine,
  Unreal version, which libs reference com.oculus.os, data size.
- Evidence-based detect() rules (force passthrough, scene emulation, meta permissions, controller passthrough,
  space warp for heavy OVRPlugin games, nodebug for old UE4 / Unreal Meta XR Audio, oculusos, Unity MSAA,
  CryEngine VRS config). With the catalog switched off they reproduce 33/34 verified recipes
  (scripts/eval_heuristics.py); Phantom's no-ForceQuit build stays a runtime (triage) finding.
- Patch.applies(): GUI and CLI hide patches that can't matter for a game (engine/XR/graphics), with a
  show-all toggle; enabled patches are always shown.
2026-09-29 11:25:53 -04:00
87 changed files with 11118 additions and 681 deletions

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ description: Port a Meta Quest standalone game (APK + OBB dump) to the Valve Ste
# Port a Quest game to the Steam Frame
Work from the repo root (`frameport/`). Read `CLAUDE.md` (facts) and `docs/PLAYBOOK.md` (symptom → fix) first.
Set `UV_PROJECT_ENVIRONMENT=~/.cache/frameport-venv` (the repo is on NTFS).
The dev environment is `.venv` in the repo root (`UV_LINK_MODE=copy uv sync --extra dev` if it's missing).
## 1. Setup (once)
```
+1
View File
@@ -1,6 +1,7 @@
* text=auto eol=lf
*.so binary
*.dex binary
*.exe binary
*.apk binary
*.png binary
*.jpg binary
+198 -11
View File
@@ -8,7 +8,78 @@ install over SSH (agent) → Steam shortcut → headless launch test + log triag
Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRAME_RUNTIME.md` for runtime facts.
## Layout
- `src/frameport/` — Python package. `pipeline.py` is the API the CLI (`cli.py`) and GUI (`ui/app.py`, Flet 1.0) share.
- `src/frameport/` — Python package. `pipeline.py` is the API the CLI (`cli.py`) and GUI (`ui/`, Flet 1.0) share.
- `ui/` — `app.py` shell (sidebar with Frame connection + activity cards, routing, actions, 30 s connection poll),
`theme.py` (dark design tokens; change colours/spacing only there), `components.py` (pill, card, callout,
status_row, art_fill, confirm, `update()` = safe update: in Flet 1.0 reading `.page` of an unmounted control
raises), `jobs.py` (background FIFO job queue, one at a time, cancel via Reporter; no Flet), `views/`
(library: search/filters/tags/sort as pure tested helpers; game: hero + one-click install, patches under
"Customize"; frame: device + readiness + installed, or connect wizard; settings; welcome; activity panel).
User tags live in library entries (`tags`), filters in library setting `ui.library`.
**Performance rules** (the app froze before): never put image bytes in controls — artwork is served by URL from the
GUI assets dir (= user data dir; `ft.run(assets_dir=…)`), as thumbnails (`artwork/thumbs.py`, Pillow); the
library view is persistent, streams cards in batches from a background thread and filters by toggling visibility;
job/connection events call `app.refresh_view()` (targeted), not `render()`; no I/O in render paths.
**Never recreate clickable controls on progress ticks** (sidebar, activity tiles): update their properties —
replacing them 5×/s swallowed clicks (couldn't leave the Library during an upload).
Help hints: wording for non-obvious terms lives in `ui/help.py` (`HELP`); show it with `C.help_icon(key)` or the
`help=` argument of `section`/`status_row`/`kv`, tooltips via `C.tip()` (wraps). Game actions for the Library
right-click menu (one `ft.ContextMenu` around the grid, filled on right-click) and the game page's "…" menu come
from `app.game_actions()`. Picking art (`sources.apply_choice`) downloads into a staging dir and keeps the old
art if nothing came back; "Update Steam art on Frame" re-sends the art set to the game's anchor (agent ≥ 12).
Patch descriptions/reasons describe the general case, naming games only as "e.g. …".
- Installs: queued/cancelled/failed ones are remembered (library setting `ui.installs`) → Library "Resume" bar;
uploads are interruptible (Cancel checked per MiB) and resumable (big files via SFTP `.part` append, small files
streamed in tar batches; the agent counts files already in `incoming/`). Multi-select in the Library queues
installs after asking every needed question up front. Failures end in one pop-up (Resume / Uninstall / log).
- **PC VR repacks are pre-patched to run directly** (proven: Rick and Morty, Vader Immortal run when the exe is
launched directly; Revive breaks them). So Rift games default to `as_is` = install the copy unchanged and launch
the exe directly (`pcvr.xr_timefix` for the Frame OpenXR-1.1→1.0 fix, `pcvr.no_crash_reporter` for Unreal).
**Revive is off by default, opt-in** (`pcvr.revive`, and `pcvr.oculus_unreal`): only for an un-cracked Oculus game
that fails at "Initializing OVR session". Those (Lone Echo, Robo Recall, Lies Beneath: crack .7z not extracted /
Platform SDK) hit Revive's Oculus-runtime **signature check** under Proton-arm64 — Revive's LoadLibrary/WinVerifyTrust
hooks don't install (ARM64EC), and the game's Oculus SDK shim rejects the unsigned Revive runtime (wintrust +
crypt32 signer "Oculus VR") — so they don't run on the Frame without extracting the repack's crack (which FramePort
doesn't do). `VD.bat` is Virtual Desktop's launcher: ignore it except as an exe-location hint. Library migration
`rift_run_direct` resets existing recipes.
Auto launch-test is skipped on PC installs (it would start the game on the user's desktop). Launch tests collect the Unreal
game log + crash summaries from the Proton prefix; triage `unreal-crash`. Lies Beneath via Proton without Revive
crashed (UE 4.23 "Unhandled exception").
- Rift scanning: `sources/rift_dump.scan` = the scanned folder's subfolders are games (one per folder; a folder is a
game if all candidate exes sit under one child), recursing into collections; `analysis/rift.py` walks once,
filters helpers + non-GUI PEs, `rank_exes` (Unreal *-Shipping beats its launcher, Steam builds −10, ambiguous →
`exe_confirmed=False` → GUI exe dialog), `clean_title`, fingerprint (unchanged folders aren't re-analyzed),
modular-Unreal Oculus plugin DLLs + UTF-16 markers count as LibOVR, `revive_bundled` → as-is.
- Rift VR-API routing (`analysis/rift.py`): `openvr`/`openxr`/`libovr` detected from imports + bundled DLLs (NOT the
universal-in-Unreal `IVRSystem`/`VR_InitInternal` strings). `frame_native = (openxr or openvr) and not libovr` =
confidently runs on the Frame via wineopenxr (SteamVR), no Revive. Any LibOVR game → `needs_revive` → **PC only**
on the Frame (Revive's ARM64EC hooks don't work under Proton-arm64, and FramePort does **not** defeat the Oculus
runtime Authenticode signature check). Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
not via static detection. PC installs default to Revive's **OpenVR** backend (`pcvr.revive_openvr` on) and
auto-start SteamVR on Play (`winhost.start_steamvr`); the Frame launcher always uses `/openxr`. UI (`game.py`
where()) states per-game where it runs; installing an Oculus game on the Frame shows a warning. Migration
`rift_frame_native` re-analyzes + re-derives existing entries.
- Art for Rift games: `artwork/sources.py` — Quest version package (OculusDB packageName, exact name or +
"Unplugged"-type suffix, never sequels) → Meta art; OculusDB square cover; Steam (exact names only); exe icon.
- Store details (`artwork/details.py`, entry `details`): OculusDB (description, genres, publisher, website; by Quest
package or Rift match) + Steam appdetails (exact title: developer, release date, up to 6 screenshots → artwork
`shot_N.jpg`). Meta store pages reject scraping, so Oculus exclusives have no screenshots. Genres become automatic
tags. Steam shortcuts get a complete composed art set (`artwork/steam.py`: 600×900 / 920×430 / 1920×620 / logo /
256 icon, blurred-backdrop compositing for square-only covers) and tags: how it runs, the original platform
(Meta Quest / Oculus Rift), genres, user tags — merged with tags set in Steam (non-Steam shortcuts can't hold a
description).
- Quest/Rift twins stay separate entries, shown and named in Steam "Title (Quest)"/"(Rift)" (`core/titles.py`).
- `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
**not** turn Revive off: a repack's patches (cracks, `VD.bat` = Virtual Desktop's Oculus
runtime) give a LibOVR game no Oculus runtime on the Frame; Revive is that runtime (Lone Echo without it: EXITED
in seconds). SteamVR builds (Rick and Morty) turn Revive off via their recipe (`pcvr_remove`, catalog `as_is`).
- Play: Library hover button / right-click / game page / Frame rows → agent `launch` = `steam://rungameid/<appid<<32
| 0x02000000>` through the Frame's Steam (in-headset session); PC: Windows Steam. Launch tests use a
flask icon (SCIENCE_OUTLINED) so they aren't confused with Play.
- `uninstall.py` + `frameport uninstall-app` + Settings: removes the data dir (after an optional key backup zip),
PC Steam shortcuts, the WSL Revive copy, and via agent `purge` FramePort's games/files on the Frame.
- `patches/` — **the unit of modularity**. `base.py` (Patch interface, registry), `overport.py` (overport CLI patch ids,
discovered dynamically via `overport patches`), `frame/*.py` (one module per Frame fix), `settings.py` (FrameBridge
adapter keys + device files as patches). Add a patch = add a module that calls `register(...)`.
@@ -18,25 +89,46 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
- `recommend/` — `catalog.py` (known-good recipes: user > remote `FRAMEPORT_CATALOG_URL` > bundled), `engine.py`.
- `tools/` — portable toolchain (Temurin JRE, overport jar, apksigner) downloaded dynamically into the user data dir.
- `frame/` — SSH (paramiko), mDNS discovery, pairing server; `install/installer.py`; `validate/` (static, device, triage).
- `targets/` — `Target` interface; `frame_lepton.py` now, `revive.py` placeholder (PC VR later).
- `targets/` — `Target` interface; `frame_lepton.py` (Quest via Lepton + Rift via Proton), `pc_revive.py` (Rift games
on this Windows/WSL PC via Revive + local Steam shortcut; `core/winhost.py` = Windows/WSL helpers).
- Oculus Rift (PC VR): `sources/rift_dump.py`, `analysis/rift.py` (own PE reader), `patches/pcvr.py` (category
`pcvr`, shown as patches; `base.for_game` separates Quest/PC VR patches), `tools/revive.py` (Revive: FRAMEPORT_REVIVE_DIR >
the user's installed Revive (C:\Program Files\Revive, else registry HKLM/HKCU\Software\Revive; version = GitHub
release matching the DLL build date, since Revive's version resources are stale) > portable copy unpacked from
ReviveInstaller.exe in pure Python; never replaces the user's install). Library ids `rift.<slug>`, entries have `kind: rift`.
- `parity.py` — rebuild catalog games from dumps and classify every APK entry difference vs known-good builds.
- `agent/frameport_agent.py` — runs **on the Frame** (python3 stdlib only), JSON over SSH. Owns the install layout,
launch.sh template, Steam shortcuts (binary VDF), launch tests. Bump `AGENT_VERSION` when changing it.
- `bootstrap/bootstrap.sh` — one-time Frame setup served by the pairing server (sshd, app key, avahi service, Lepton).
- `catalog/games/<package>.yaml` — 34 recipes verified 2026-09-28; `catalog/triage.yaml` — log signatures → fixes.
- `native/` — sources of the prebuilt binaries in `artifacts/` (adapter, VrApi bridge patches, GL shim, stubs).
`native/build.sh` rebuilds them with NDK r27c (downloaded on demand; never committed). Users never need the NDK.
`native/build.py` rebuilds them with NDK r27c (downloaded on demand into `native/.cache`, git-ignored; uses
`-ffile-prefix-map` so no local paths get embedded; zip symlinks are restored as copies). Users never need the NDK.
- `scripts/` — `package.py` (flet build/pack), `eval_heuristics.py` (score heuristics), `ui_smoke.py` (GUI screenshots).
- `docs/` — PLAYBOOK, FRAME_RUNTIME, ARCHITECTURE, INSTALL (end users), parity reports (offline + device).
## Dev commands
```
export UV_PROJECT_ENVIRONMENT=~/.cache/frameport-venv # keep the venv off NTFS (repo lives on D:)
uv sync --extra dev # or: uv pip install -e .[dev]
# the dev venv is .venv in the repo root (git-ignored); on the NTFS drive uv needs UV_LINK_MODE=copy
UV_LINK_MODE=copy uv sync --extra dev # creates/updates ./.venv
uv run pytest # unit tests (no device, no game files)
uv run frameport --help # CLI; uv run frameport-gui for the GUI
uv run frameport parity --known-good <PATCHED/_known-good-*> --sources "<VR CyberDeck downloads>"
```
Games/device tests: `pytest -m games` (FRAMEPORT_GAMES=<downloads dir>), `pytest -m device` (FRAMEPORT_FRAME=steamos@host).
Games/device tests: `pytest -m games` (FRAMEPORT_GAMES=<downloads dir>), `pytest -m device` (FRAMEPORT_FRAME=steamos@host);
native layer test: `FRAMEPORT_NATIVE_TESTS=1 pytest -m native` (compiles with the NDK, ~2 min on NTFS).
Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattributes`).
- `FRAMEPORT_HOME=<dir>` isolates all app data (tests use it); `FRAMEPORT_JAVA/_OVERPORT_JAR/_APKSIGNER_JAR` override
managed tools. Real app data (WSL): `~/.local/share/frameport` (tools, overport workspace + game keystores, library,
artwork, frames.json, the app's SSH key which the dev Frame authorizes).
- Device checks: `frameport test <pkg>` / `frameport parity-device --results <parity.json> --baseline <launch.txt>
[--test-only]`; the pre-FramePort baseline is `PATCHED/_known-good-2026-09-28/_frame-state/baseline-launch.txt`.
- GUI smoke test: `uv pip install flet-web playwright && playwright install chromium`, then
`FRAMEPORT_HOME=<test dir> python scripts/ui_smoke.py --out <dir> [--game <pkg>] [--frame steamos@<host>]` and look
at the PNGs. Flet 1.0 notes: `ft.run` must own the main thread; background work via `page.run_thread`; FilePicker is
awaited (`await ft.FilePicker().get_directory_path()`); dialogs via `page.show_dialog/pop_dialog`; running from
source needs `flet-desktop` (declared) and web mode needs `flet-web`.
- Stopping the GUI: `pkill -f` patterns match your own shell — use `pgrep -f "[b]in/frameport-gui|[f]let-desktop-light"`.
## Hard-won facts (don't re-learn these)
**Frame runtime (SteamOS 0.3.0, build 20260922):**
@@ -63,6 +155,56 @@ since boot every game fails with `crun: create keyring …: Disk quota exceeded`
`keyring = false` in `~/.config/containers/containers.conf` (agent `ensure_host_fixes`, bootstrap); leaked keys only
go away with a reboot. Check usage: `grep "^ *1000:" /proc/key-users` (agent `info` → kernel_keys).
**Proton on the Frame (2026-09-29):** Steam registers ARM64 tools from app "Steam Frame ARM64 Compat List"
(`proton_11-arm64` 4628740 needs SLR4-arm64 4185400; `proton-experimental-arm64` 4427310; `fex` 3127680) — not installed
by default; `steam://install/<id>` only opens a dialog in the headset (agent `install_proton` mode `unattended` =
appmanifest stubs + Steam restart; untested on device). Proton only sets up VR/wineopenxr when `SteamGameId` is set.
Host OpenXR = SteamVR (`~/.config/openxr/1/active_runtime.json`). Steam VR games on the Frame (Pistol Whip etc.) are
APKs via Lepton. Verified on the device: the app installs Proton itself (`installer.ensure_proton`: appmanifest stubs +
Steam restart, ~90 s); Proton runs x86 code; wineopenxr gets registered; headless launches need DISPLAY/
GAMESCOPE_WAYLAND_DISPLAY from Steam's environment (launch.sh imports them). The **Linux** SteamVR runtime supports
XR_KHR_convert_timespec_time (only the Android runtime lacks it). **But it only accepts OpenXR 1.0 apps**: apiVersion
1.1 → XR_ERROR_API_VERSION_UNSUPPORTED, and Proton 11's vrhelper asks for 1.1 → no VR at all (flat window; Revive:
"Unable to load LibOVRRT DLL"). FramePort's layer (`native/xrlayer`, `artifacts/linux-arm64`, patch `pcvr.xr_timefix`,
default on + one-time library migration in `core/library._migrate`) retries as 1.0. Proton's container (pressure-vessel)
**drops `XR_API_LAYER_PATH`**, so the agent registers the layer as an explicit layer in
`~/.local/share/openxr/1/api_layers/explicit.d/` (library: shared copy in `~/.local/share/frameport/xrlayer/`); it
only loads when `XR_ENABLE_API_LAYERS` names it. Unreal PC VR games get
`pcvr.no_crash_reporter` by default (`-nocrashreports` + CrashReportClient.exe renamed `.disabled` in the Frame copy)
and `pcvr.oculus_unreal` (PC VR counterpart of overport's `patch_oculus_unreal`: UE's OculusHMD needs the Windows event
`OculusHMDConnected`; launch.sh wraps the injector in `helpers/fp_oculushmd.exe` = `native/oculushmd`,
`artifacts/win-x64`, which provides it until the game's job is empty; untested on the device yet).
Rift games tried on the Frame: Rick and Morty (SteamVR build, no Revive: "OpenVR initialized!" with the layer; flat
before — confirm in the headset), Lies Beneath (Oculus Store UE 4.23 build: delay-loads `LibOVRPlatform64_1.dll` =
Platform SDK → crash 0xc06d007e; needs the Oculus app, FramePort doesn't replace it; also OVRPlugin's LibOVR shim
reports "Unable to load LibOVRRT DLL" before any OpenXR call — likely needs a real Oculus runtime install, unverified).
Launch tests ignore log files older than the launch (stale Proton/game logs used to be triaged). Revive injector CLI: `ReviveInjector.exe [/openxr] <exe path>`
(joins args; logs to `%LOCALAPPDATA%\Revive\ReviveInjector.txt`). On WSL, run Windows programs from Windows paths
(`\\wsl$` is unreliable), so PC mode copies Revive to `%LOCALAPPDATA%\FramePort`.
FramePort never bypasses Oculus entitlement checks (Platform SDK games: flagged, PC mode with the Oculus app only).
**Upload speed (measured 2026-09-29):** via the home router (PC Wi-Fi → router → Frame wlan0) only 15-18 MB/s, the
same for paramiko, OpenSSH and 4 parallel streams, so the network path is the limit, not the SSH code. Direct to the
Frame's hotspot (`wlanap` 10.35.78.1; the owner's PC has Valve's USB Wi-Fi dongle on it) 83 MB/s OpenSSH / 97 MB/s
paramiko; reading dumps from D: via WSL drvfs is then the cap (~82 MB/s). `Frame.fast_link()` (installer
`transfer_link`, uploads ≥64 MiB) uses usb0 > wlanap when reachable **and** the host key matches the paired Frame.
**Platform SDK detection** must include delay imports (`PEInfo.delay_imports`), modular Unreal's
`*-OnlineSubsystemOculus-*.dll` and Ready At Dawn's `pnsovr.dll`: Robo Recall, Lies Beneath and Lone Echo I/II all use
it; without the Oculus app they crash with 0xc06d007e (triage `delayload-missing`) — not fixable legitimately.
GUI: Frame → Installed → folder icon = file browser (agent `list_files`, flags files missing vs the install
manifest); Settings → About shows the bundled agent version and the Frame's.
**Revive under Proton arm64 (Lone Echo, 2026-09-29):** Revive's Detours hooks (LoadLibraryW/ExW, OpenEventW, the
signature check) don't take effect: Wine's kernelbase is ARM64EC. The LibOVR shim in the game then does a plain
`LoadLibrary("LibOVRRT64_1.dll")` search (exe dir, cwd, system32, windows, PATH — no registry, `OculusBase` or
`LIBOVR_DLL_DIR` used by this build) → `Failed to initialize Oculus API (-3001)` (= Lies Beneath's "Unable to load
LibOVRRT DLL"). A symlink `pfx/drive_c/windows/system32/LibOVRRT64_1.dll -> <base>/revive/LibReviveXR64.dll` (Wine
maps a symlink to the already-loaded module) gets past it to **-3021 = ovrError_LibSignCheck**: the shim only accepts
an Oculus-signed runtime. Next step (not done): make the runtime check pass without Detours, e.g. an injected helper
that patches the game's import table (IAT) for LoadLibrary*/WinVerifyTrust, or a prefix-level override. This is
runtime interop (what Revive does), not the Platform SDK licence check (which FramePort never touches).
Uninstall (Quest, saves kept) used to leave `deployment.json` → still "installed"; fixed (agent v16).
**Discovery/network:** Developer-Mode SteamOS devices announce `_steamos-devkit._tcp` (TXT `login=steamos`) — use it;
they don't publish `_ssh._tcp`. The Frame has several links: `wlan0` (home Wi-Fi), `wlanap` = its own hotspot at
10.35.78.1/24 (a PC can join it directly), `usb0` = USB gadget network 10.86.200.233/29 (up when cabled to a PC).
@@ -92,11 +234,56 @@ Output is deterministic (same input + runtime → same bytes), which is what mak
layers, depth, pacing ruled out. Sniper Elite VR (DEVICE LOST), Espire 1 (Mesa GL upload crash), HITMAN 3 (freedreno
crash): use PC versions.
## Releases
Push a `v*` tag → CI builds Windows/macOS(arm64)/Linux bundles, signs Windows binaries with the self-signed
certificate (secrets `WINDOWS_CODESIGN_PFX` base64 + `WINDOWS_CODESIGN_PASSWORD`; public cert `packaging/`,
private copy only on the maintainer's machine + backup), ad-hoc signs the macOS app, adds build attestations and
publishes a GitHub Release with SHA256SUMS. macOS x86_64 isn't built (cryptography cross-build fails).
## Releases, CI, GitHub
Public repo `github.com/spoopyghosty0/frameport` (branch `main`). Push a `v*` tag → CI (`.github/workflows/build.yml`)
tests, builds Windows x64 / macOS arm64 / Linux x64 bundles, signs, attests and publishes a GitHub Release
(`FramePort-*.zip/.tar.gz`, `SHA256SUMS.txt`, `FramePort-selfsigned.cer`, notes from `docs/INSTALL.md`). v0.1.0 exists.
- Signing is **free/self-signed by the owner's choice** (no paid certs, no SignPath): Windows binaries are signed with
a self-signed "FramePort (self-signed)" code-signing cert (RSA 3072, valid to 2031, SHA-256
`4E:12:98:91:62:C0:E4:50:FB:65:1D:34:BB:73:00:09:7B:78:BE:88:5C:A7:6C:42:23:46:9B:92:A1:59:A7:6E`); secrets
`WINDOWS_CODESIGN_PFX` (base64) + `WINDOWS_CODESIGN_PASSWORD`. Private key: `~/.config/frameport-signing/` (WSL) and
the backup `PATCHED/_signing-keys/frameport-app-codesign/` — never commit it. macOS is ad-hoc signed only (Gatekeeper
needs right-click → Open; notarization would need the paid Apple program). Users still see SmartScreen unless they
import the .cer into Trusted Root.
- CI gotchas: `flet build` needs `--yes --no-rich-output` (it prompts to install Flutter; rich output crashes the
Windows console) plus PYTHONUTF8; macOS builds need `--python-version 3.12 --arch arm64` (cryptography has no wheels
for flet's default Python / x86_64 cross-build), with a PyInstaller fallback step; `astral-sh/setup-uv` has no
floating major tags after v7 → pin the exact version; force-moving a tag starts duplicate runs (cancel one).
- `gh` is a local install at `~/.local/bin/gh` (logged in as spoopyghosty0 with `workflow` scope; token in
`~/.config/gh/hosts.yml`). Commit as `spoopyghosty0 <336754034+spoopyghosty0@users.noreply.github.com>` (set in the repo config).
- **The repo is public: never commit personal data** — the Frame's IP address, the Steam user id, the owner's email,
local home paths (native builds use `-ffile-prefix-map`). History was rewritten once to remove them.
## Heuristics (games not in the catalog)
Each patch's `detect()` suggests itself from the Analysis; `applies()` says whether it can matter at all (the UI/CLI
hide non-applicable patches; enabled ones are always shown). Rules learned from the 34 games:
MR-only (PASSTHROUGH required + BOUNDARYLESS_APP) → force_passthrough, + USE_SCENE → scene_emul + meta_permissions;
hand tracking required → controller_fix=0; OVRPlugin + ≥20 GiB → disable_space_warp; Unreal ≤4.21 or Unreal Meta XR
Audio → nodebug; Oculus-OS class referenced by the Unreal audio build or ≥2 Meta libs → oculusos; legacy-VrApi Unity
GLES with MSAA → unity_no_msaa; CryEngine → user.cfg r_variable_rate_shading=0; direct VrApi → bridge (+GL shim for
GLAD/GLES); Unreal → alternate no-ForceQuit build. Score changes with
`python scripts/eval_heuristics.py "<dumps>"` (catalog off vs verified recipes; currently 33/34 exact — Phantom's
"use the no-ForceQuit build" is only detectable at runtime via triage). Add a rule → re-run the eval + `pytest -m games`.
## Frame operations
- Find/connect: `frameport frame discover` / `frame info` (the remembered Frame is in `<user data>/frames.json`).
- Power off over SSH: plain `systemctl poweroff` is refused by polkit for remote sessions; this works:
`ssh steamos@<frame> 'systemd-run --user --wait --pipe --quiet systemctl poweroff'`. `sudo` needs the user's password
(not known to Claude). A reboot clears leaked kernel keyrings.
- Reinstalls keep one rollback copy (`<base>/previous-game.apk`, `settings.conf.previous`); `frameport frame cleanup`
removes them (and `--path ~/X` extra folders under home).
## Project status (2026-09-29)
34 Quest games ported; the owner confirmed in the headset that all FramePort-rebuilt games work: 23 work, 5 work with
issues (Arcsmith/Time Stall eye distortion, AC Nexus some flipped launch text, Phantom DLC button, Silhouette hands),
6 can't run (Sniper Elite VR, Espire 1, HITMAN, and the 32-bit Journey of the Gods / Shadow Point / Sports Scramble).
Parity: all 34 rebuilt from the dumps match the known-good builds (`docs/parity-report.md`) and were reinstalled +
launch-tested with 0 regressions (`docs/parity-device-report.md`). `PATCHED/` holds exactly the installed builds.
Owner preferences: manual installs (no FrameDrop, no Quest2Frame app), Python + Flet, dynamic data over hardcoding,
free tooling only, public repo scrubbed of personal data, keep the known-good backups.
Rift/PC VR support (2026-09-29): implemented + unit-tested, not yet tried with a real Rift game on the PC or Frame
(needs a Rift dump, and Proton installed on the Frame). Open ideas: exe-icon artwork for Rift games, macOS x86_64 bundle, USB-cable connection (Frame `usb0`, untested),
the unresolved eye distortion, and testing the release bundles on real Windows/macOS machines (never launched yet).
## Conventions
- Dynamic first: fetch live data (tool versions, overport patch list/titles, artwork, catalog) with cache + bundled
+1009 -30
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -4,3 +4,6 @@ ca27ffa2122def910697ddcaff64f25cfdbc63e2017c738155866ff2fcdb5e12 ./arm64-v8a/li
32525cd0a9ee3d9993d8871cd6905bdcf22cde4f4ba28f5db6434ce7d6554758 ./arm64-v8a/libvrapi.so
f3aa9419155b8970617fc0d68527a73c39abc82303214c159194c439bb776fd6 ./armeabi-v7a/libopenxr_loader_generic.so
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json
28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.so
c2c70f4af1a5c3e6089dff1130161dd417f8044f99ec639eeecf1083930aab16 ./win-x64/fp_oculushmd.exe
@@ -0,0 +1,11 @@
{
"file_format_version": "1.0.0",
"api_layer": {
"name": "XR_APILAYER_FRAMEPORT_timefix",
"library_path": "./libxr_frameport_timefix.so",
"api_version": "1.0",
"implementation_version": "1",
"description": "FramePort: XR_KHR_convert_timespec_time emulation for Proton games on the Steam Frame",
"instance_extensions": [{"name": "XR_KHR_convert_timespec_time", "extension_version": "1"}]
}
}
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -2,7 +2,7 @@ package: com.crytek.climb2
title: The Climb 2
tested_version: '2.2'
source_hint: The Climb 2 v974+2.2
engine: Other
engine: CryEngine
xr: VrApi
status: works
notes: 'CryEngine direct VrApi (Vulkan): VrApi bridge + platform compat; user.cfg disables VRS.'
@@ -0,0 +1,23 @@
package: rift.rick_and_morty_virtual_rick_ality
title: 'Rick and Morty: Virtual Rick-ality'
kind: rift
tested_version: v2288226
source_hint: Rick and Morty - Virtual Rick-ality v2288226
engine: Unity
xr: OpenVR
status: works
notes: Runs on the Frame under Proton without Revive (this build talks to SteamVR directly). Slight stutter.
details: The dump's executable already uses SteamVR (OpenVR), so it is installed as it is; Proton's VR bridge only
starts with FramePort's OpenXR layer (the Frame runtime accepts OpenXR 1.0 apps only). Confirmed in the headset.
as_is: true
pcvr:
- pcvr.xr_timefix
pcvr_remove:
- pcvr.revive
verified:
date: '2026-09-29'
target: frame
frame_os: SteamOS 0.4.2 (build 20260928.6175029)
proton: proton_11-arm64
exe: Rick and Morty - Virtual Rick-ality/RickAndMortyVR.exe
exe_sha256: 01678ebd02edec86258c482fbb5d00f3f0474e1a6515a905531f81df1a80ec50
+87
View File
@@ -106,7 +106,94 @@ signatures:
severity: warning
diagnosis: The game created folders it can't write (saves break). The FramePort launcher repairs permissions every 2 s.
suggest: []
# ---- PC VR (Oculus Rift games under Revive; on the Frame under Proton). kind: pcvr = only for those logs
- id: proton-missing-runtime
kind: pcvr
pattern: 'needs Steam app \d+ \(runtime\)|_v2-entry-point: (No such file|not found)'
severity: fatal
diagnosis: The Steam Linux Runtime that Proton needs isn't installed on the Frame.
suggest: []
- id: revive-inject-failed
kind: pcvr
pattern: 'Failed to create process'
severity: fatal
diagnosis: Revive's injector could not start the game (wrong exe path, or a 32-bit/64-bit mismatch).
suggest: []
- id: oculus-hmd-event
kind: pcvr
pattern: 'FramePort oculushmd: could not (create the OculusHMDConnected event|start the command)'
severity: error
diagnosis: FramePort's Oculus detection helper couldn't provide the OculusHMDConnected event (or couldn't start the game). Unreal's Oculus plugin then skips VR silently and the game runs as a flat window.
suggest: [pcvr.oculus_unreal, pcvr.proton_log]
- id: openxr-no-runtime
kind: pcvr
# (not the loader's 'xrCreateInstance failed': Proton's own OpenXR probe fails once and retries on the Frame)
pattern: 'XR_ERROR_RUNTIME_(UNAVAILABLE|FAILURE)|No OpenXR runtime|OpenXR runtime (is )?not (found|available)|failed to (create|initialize) (the )?OpenXR'
severity: fatal
diagnosis: No OpenXR runtime reached the game. On a PC start SteamVR (or set it as the OpenXR runtime); on the Frame Proton couldn't bridge to the Frame runtime.
suggest: []
- id: openxr-missing-ext
kind: pcvr
pattern: 'XR_ERROR_EXTENSION_NOT_PRESENT|XR_KHR_D3D11_enable.*(not|unsupported)'
severity: fatal
diagnosis: The OpenXR runtime lacks an extension Revive requires (XR_KHR_D3D11_enable / win32 time conversion).
suggest: [pcvr.xr_timefix, pcvr.revive_openvr]
- id: openxr-api-version
kind: pcvr
pattern: 'LoaderInstance::CreateInstance chained CreateInstance call failed|XR_ERROR_API_VERSION_UNSUPPORTED|Unable to load LibOVRRT DLL'
severity: fatal
diagnosis: Proton's VR setup couldn't create an OpenXR instance (the Frame's SteamVR runtime only accepts OpenXR 1.0 apps; Proton asks for 1.1), so the game got no VR — a flat window, or Revive fails with "Unable to load LibOVRRT DLL".
suggest: [pcvr.xr_timefix]
- id: openxr-time-conversion
kind: pcvr
pattern: 'xrConvert(TimespecTimeToTime|TimeToTimespecTime)KHR failed|XR_KHR_convert_timespec_time not available'
severity: error
diagnosis: The OpenXR runtime refused time conversion, which Proton needs for Windows games' performance-counter times.
suggest: [pcvr.xr_timefix]
- id: delayload-missing
kind: pcvr
pattern: '[Ee]xception:? 0xc06d007e|code=c06d007e'
severity: fatal
diagnosis: A DLL the game loads on demand is missing (Windows delay-load error). For Oculus Store games this is usually LibOVRPlatform64_1.dll, the Oculus Platform SDK that the Oculus app installs for the licence check; FramePort doesn't replace it, so such games need the Oculus app (PC mode).
suggest: []
- id: oculus-entitlement
kind: pcvr
pattern: '(?i)entitlement (check )?fail|ovr_PlatformInitialize.*(fail|error)|ovrPlatformInitialize_(NotEntitled|Uninitialized|PreLoaded|FileInvalid|SignatureInvalid|UnableToVerify)'
severity: fatal
diagnosis: The Oculus Platform SDK entitlement check failed. The game needs the Oculus app running with a license you own (PC mode only).
suggest: []
- id: vcruntime-missing
kind: pcvr
pattern: '(?i)(MSVCP|VCRUNTIME)\d+[_\d]*\.dll.*(not found|missing)'
severity: error
diagnosis: The game needs a Microsoft Visual C++ runtime. Install the vc_redist that ships with the game (or winetricks vcrun2019 in the prefix).
suggest: []
- id: dxvk-device-lost
kind: pcvr
pattern: 'DXVK: .*(DEVICE_LOST|Device lost)|VK_ERROR_DEVICE_LOST'
severity: fatal
diagnosis: The GPU driver lost the device while running the game (D3D through DXVK on freedreno).
suggest: [pcvr.proton_log]
- id: unreal-crash
kind: pcvr
pattern: 'CrashReportClient|UE4CC-Windows|UECC-Windows|Fatal error!|Unhandled Exception: EXCEPTION'
severity: fatal
diagnosis: The game crashed (Unreal's crash reporter started). See the game log and crash summary in the log. Common causes are that no VR runtime reached the game (Revive off) or a GPU or driver problem under Proton.
suggest: [pcvr.revive, pcvr.proton_log, pcvr.no_crash_reporter]
- id: wine-crash
kind: pcvr
pattern: 'Unhandled exception: page fault|wine: Unhandled|Backtrace:'
severity: error
diagnosis: The game crashed under Wine/Proton (set the Proton debug log and check steam-<appid>.log).
suggest: [pcvr.proton_log]
milestones: # progress markers, in order; the furthest one reached is reported
- {id: pcvr-launch, kind: pcvr, pattern: 'FramePort: launching|Launched injector with', label: Launcher started}
- {id: pcvr-oculus-hmd, kind: pcvr, pattern: 'FramePort oculushmd: OculusHMDConnected event ready', label: Oculus headset event provided}
- {id: pcvr-inject, kind: pcvr, pattern: 'Succesfully injected|Successfully injected', label: Revive injected into the game}
- {id: pcvr-window, kind: pcvr, pattern: 'Executable name: (?!steam\.exe|CrashReportClient)\S+\.exe|Creating Gamescope surface', label: Game window created}
- {id: pcvr-openxr, kind: pcvr, pattern: 'wineopenxr: .*[Ss]ession|LibReviveXR|Revive.*OpenXR|xrCreateSession', label: OpenXR reached}
- {id: pcvr-session, kind: pcvr, pattern: 'xrCreateSession|XR_SESSION_STATE_(READY|SYNCHRONIZED)', label: OpenXR session created}
- {id: container, pattern: 'Boot complete!', label: Lepton container started}
- {id: process, pattern: 'Start proc \d+:', label: App process started}
- {id: adapter, pattern: 'FrameBridge: scale=', label: FrameBridge adapter loaded}
+22 -4
View File
@@ -1,15 +1,15 @@
# Architecture
```
┌────────────── UI (Flet, ui/app.py) ─────────────┐ ┌── CLI (cli.py) ──┐
┌──────── UI (Flet, ui/: app shell + views/) ─────┐ ┌── CLI (cli.py) ──┐
└───────────────────────┬─────────────────────────┘ └────────┬─────────┘
▼ ▼
pipeline.py (add → suggest → build → install → test)
┌──────────────┬──────────────┼───────────────┬───────────────┬──────────────┐
sources/ analysis/ recommend/ build.py targets/ validate/
quest_dump detect, elf catalog, engine overport → base.Target static, device,
stubgen (+ catalog/*.yaml) patches(apk) → frame_lepton triage (+triage.yaml)
apk/sign revive (todo)
rift_dump stubgen, rift (+ catalog/*.yaml) patches(apk) → frame_lepton triage (+triage.yaml)
apk/sign pc_revive
│ │ │
patches/ registry tools/ toolchain frame/ ssh, discovery, pairing
overport, frame/*, (JRE, overport, install/installer ──► agent (on Frame)
@@ -34,9 +34,27 @@
## Extending
- **New fix**: `patches/frame/<name>.py` with a `Patch` subclass (`detect`, `apply`, optional `validate`), plus a
signature in `catalog/triage.yaml` and a PLAYBOOK row. Stages: `overport` | `apk` | `install`.
- **New heuristic**: put it in the patch's `detect()` (and `applies()` for visibility), with the evidence in the
reason text; check `scripts/eval_heuristics.py` still reproduces the catalog and add a test in
`tests/test_heuristics.py`.
- **New game recipe**: `catalog/games/<package>.yaml` (or "Save as known-good" in the GUI, which writes to the user
catalog). Publish recipes by serving a folder with `index.json` and pointing `FRAMEPORT_CATALOG_URL` at it.
- **New target** (Revive/PC VR): implement `targets/base.Target`; the pipeline and UI only use that interface.
- **New target**: implement `targets/base.Target`; the pipeline and UI only use that interface.
## Oculus Rift (PC VR) games
`sources/rift_dump` finds Windows game folders; `analysis/rift` (own PE reader) detects exe, bitness, engine, LibOVR vs
OpenXR, D3D version and Oculus Platform SDK use; ids are `rift.<slug>`. Their only patches are the `pcvr` category
(`patches/pcvr.py`: Revive, OpenVR backend, crash reporter, Oculus detection, OpenXR layer, Proton
log/version/env); `base.for_game` keeps Quest and PC VR patches
apart. "Build" = `pipeline.prepare_rift` (checks + Revive). Revive is a portable tool (`tools/revive.py` unpacks
`ReviveInstaller.exe` in pure Python: NSIS header + deflate blocks). Targets:
- `targets/pc_revive.PcReviveTarget`: Windows/WSL (`core/winhost.py`), non-Steam shortcut running
`ReviveInjector.exe /openxr <exe>` via the agent's VDF code, grid art, local records in `<user data>/pc/`.
- `FrameLeptonTarget.install_pcvr` → `installer.install_pcvr` → agent `prepare_pcvr`/`finalize_pcvr`: upload
`game/` + `revive/` (+ `xrlayer/` for `pcvr.xr_timefix`, `helpers/` for `pcvr.oculus_unreal`; size-manifest dedupe,
stale files removed), `launch.sh` running the ARM64 Proton chain (built from toolmanifest.vdf; with
`pcvr.oculus_unreal` the injector runs through `helpers/fp_oculushmd.exe`, which provides Unreal's
`OculusHMDConnected` event); `proton_status`/`install_proton` manage Proton from Valve's ARM64 compat list.
- **Native binaries** (`artifacts/`): edit `native/…`, run `native/build.sh`, commit the new artifacts + SHA256SUMS,
run `frameport parity` to see which games change.
+41
View File
@@ -36,6 +36,47 @@
VK_INSTANCE_LAYERS (set `VK_INSTANCE_LAYERS=""` through `device.lepton_env` to test without them).
- GL ES: Zink (Mesa GL on Vulkan). Strict GLSL (see PLAYBOOK) and occasional `DEVICE LOST` with MSAA render-to-texture.
## Proton / Windows games (surveyed 2026-09-29; running a Rift game under it not yet verified)
- Steam on the Frame registers ARM64 compat tools from the app **"Steam Frame ARM64 Compat List"** (appinfo
`extended.compat_tools`, found dynamically by the agent): `proton_11-arm64` (4628740, needs
`steamlinuxruntime_steamrt4-arm64` 4185400), `proton-experimental-arm64` (4427310), `fex` (3127680, FEX-Emu for
Linux x86 binaries; `/usr/bin/FEXBash` shows the chain steam-launch-wrapper → reaper → fex-compat-tool →
SteamLinuxRuntime_4). None are installed by default.
- `steam -ifrunning steam://install/<appid>` only opens a confirmation dialog in the headset. The agent's unattended
mode writes an appmanifest stub (StateFlags 1026, installdir from appinfo) and restarts Steam, which then downloads it.
- The command Steam runs is built from each tool's `toolmanifest.vdf` (`commandline`, `require_tool_appid`):
`<SLR4-arm64>/_v2-entry-point --verb=waitforexitandrun -- <Proton>/proton waitforexitandrun <exe>`.
- Proton sets up VR (vrclient/wineopenxr registry) only when `SteamGameId` is set (steam_helper `setup_vr_registry`).
- Host OpenXR runtime for Linux processes: `~/.config/openxr/1/active_runtime.json` → `/opt/steamvr/steamxr_linuxarm64.json`
("SteamVR", `bin/linuxarm64/vrclient.so`); implicit API layer `XrApiLayer_VALVE_fdm_injection`.
- The "Steam" VR games in the Frame's library (Pistol Whip, Job Simulator, SUPERHOT VR) are APKs run by Lepton, not
Proton.
- Verified on the device (agent `proton_selftest`, `xr_layer_test`): FramePort installs Proton 11 (ARM64) + SLR4 by
itself (~90 s); Proton runs x86 `cmd.exe` in ~3 s; with `SteamGameId` set it registers wineopenxr
(`HKLM\Software\Khronos\OpenXR\1` → `C:\openxr\wineopenxr64.json`). Headless launches need the display session (`DISPLAY=:0`,
`GAMESCOPE_WAYLAND_DISPLAY=gamescope-0`), else Wine can't create windows and the process hangs.
- The Linux SteamVR runtime (Proton's loader `files/lib/aarch64-linux-gnu/libopenxr_loader.so.1`) **does** support
`XR_KHR_convert_timespec_time` (unlike the Android runtime Quest games see), so wineopenxr's
`XR_KHR_win32_convert_performance_counter_time` works.
- **The Linux SteamVR runtime only accepts OpenXR 1.0 apps**: `xrCreateInstance` with `apiVersion` 1.1 returns
`XR_ERROR_API_VERSION_UNSUPPORTED` (-4), with any extensions; 1.0 works (checked 2026-09-29, SteamOS 0.4.2). Proton
11's VR helper (`proton_vrhelper`) requests 1.1, so without help VR never starts for Proton games: the log shows
`LoaderInstance::CreateInstance chained CreateInstance call failed`, games run as a flat window (Rick and Morty) and
Revive's OVRPlugin reports `ovr_Initialize failed: Unable to load LibOVRRT DLL` (Lies Beneath, then a crash).
FramePort's OpenXR layer (`native/xrlayer`, patch `pcvr.xr_timefix`, default on for PC VR games) retries such a
create as 1.0; enabled by `XR_ENABLE_API_LAYERS` from launch.sh. Proton's Steam Linux Runtime container drops
`XR_API_LAYER_PATH` (it keeps `XR_ENABLE_API_LAYERS`), so the agent registers the layer as an explicit layer in
`~/.local/share/openxr/1/api_layers/explicit.d/` (home is shared into the container) with an absolute library path.
- Oculus Store builds that delay-load `LibOVRPlatform64_1.dll` (Platform SDK, e.g. Lies Beneath) crash with
`0xc06d007e` (delay-load module not found): that DLL comes with the Oculus app, which the Frame doesn't have. (No Oculus runtime DLLs or
registry keys are needed: Revive's LibOVRRT hook works once OpenXR does.)
- Unreal's Oculus plugin (and LibOVR's `ovr_Detect`) first checks for the Windows event `OculusHMDConnected` (created
by the Oculus service on a PC) and silently skips VR without it. Revive hooks `OpenEventW` for that — Lies Beneath
got as far as OVRPlugin, so the hook evidently works there — but it depends on Detours patching Wine's kernelbase,
which is ARM64EC code under Proton arm64. Patch `pcvr.oculus_unreal` (default for Unreal Rift games) makes it
independent of that: launch.sh starts the injector through `helpers/fp_oculushmd.exe` (`native/oculushmd`), which
creates the real event and keeps it until the game (tracked by a job object) has exited.
## Steam integration
- Non-Steam shortcut: binary `userdata/<id>/config/shortcuts.vdf`; appid = crc32(exe+title) | 0x80000000;
artwork in `config/grid/<appid>p.*` (portrait), `<appid>.*` (landscape), `<appid>_hero.*`, `<appid>_logo.*`.
+22
View File
@@ -25,6 +25,28 @@ not a paid one, so your OS warns the first time:
`xattr -dr com.apple.quarantine FramePort.app`.
- **Linux:** `tar xzf FramePort-linux-x64.tar.gz && ./FramePort/FramePort`.
## Oculus Rift (PC VR) games
Scan a folder of Rift games (one folder per game; the game may sit a few levels down, next to installers and archives)
the same way as Quest dumps, or use **Add games → Add one game folder…**. FramePort finds the program that starts
each game; when it isn't sure (e.g. an Oculus and a Steam build side by side) it asks you. It also fetches artwork, descriptions, genres and
(for games also sold on Steam) screenshots, and gives each Steam library entry full artwork plus tags for how it
runs, its original platform (Meta Quest / Oculus Rift) and its genres. If your copies are already patched, switch on **Already patched** on the game page:
Quest APKs are installed unchanged, Rift games start without Revive. If you installed Revive
yourself (official installer), FramePort uses that one; otherwise it downloads its own portable copy (Tools shows which;
nothing is installed system-wide).
- **Play from this PC:** needs Windows with Steam and SteamVR. "Install on this PC" adds the game to your Steam
library (Steam closes and reopens once); stream it to the Frame with Steam Link / SteamVR.
- **Play on the Frame (experimental):** Frame → "Install Proton" (confirm the download in the headset), then "Install
on Frame (Proton)" on the game page.
- Games that use the Oculus Platform SDK (FramePort shows a note) check your Oculus license: they need the Oculus app
installed on the PC with a license you own, so they can't run on the headset.
## Uninstalling
Settings → **Uninstall FramePort…** (or `frameport uninstall-app`) removes everything FramePort created: its data
folder (tools, library, artwork, cache, builds), the Steam shortcuts it added on this PC and, optionally, its games and
files on the Frame (saves can be kept). It first saves a zip of your signing keys to Documents (game updates must be
signed with the same key). Then delete the FramePort program folder.
## Verify a download
Every archive has a GitHub build attestation proving it was built by this repository's CI from a tagged commit:
+32 -2
View File
@@ -15,6 +15,16 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|---|---|---|
| Every game suddenly fails to start: `crun: create keyring …: Disk quota exceeded`, `is not a running context` | rootless podman leaked a kernel keyring per launch; 200-key quota exhausted | `keyring = false` in `~/.config/containers/containers.conf` (FramePort agent does it), then reboot the Frame once |
| `APP_ACTIVITY is empty`, nothing starts | Manifest has category INFO only; Lepton needs LAUNCHER | `frame.launcher` (automatic) |
| PC VR game on the Frame shows as a flat window / Revive: `Unable to load LibOVRRT DLL` / `LoaderInstance::CreateInstance chained CreateInstance call failed` | Frame SteamVR runtime rejects OpenXR apiVersion 1.1 (`XR_ERROR_API_VERSION_UNSUPPORTED`), which Proton 11's VR helper requests | `pcvr.xr_timefix` (Frame OpenXR layer, default on): retries xrCreateInstance as 1.0 |
| Unreal PC VR game on the Frame runs as a flat window although OpenXR works (no `LogHMD` OVRPlugin lines; Unreal logs nothing when it skips the Oculus plugin) / launch.log: `FramePort oculushmd: could not create the OculusHMDConnected event` | UE's OculusHMD (and LibOVR's `ovr_Detect`) only start when the Windows event `OculusHMDConnected` exists and is signalled; on a PC the Oculus service creates it. Revive hooks `OpenEventW` for it, but that relies on Detours patching Wine's (ARM64EC) kernelbase | `pcvr.oculus_unreal` (default for Unreal Rift games; PC VR counterpart of overport's `patch_oculus_unreal`): launch.sh runs the injector through `fp_oculushmd.exe`, which provides the real event until the game exits |
| PC VR game runs as a flat 2D window (no VR) | SteamVR was not running, so Revive/LibOVR had no runtime to bind to | FramePort now auto-starts SteamVR on Play; make sure your headset/SteamVR come up before the game |
| PC VR game crashed launching via Steam on the OpenXR backend | Revive's newer OpenXR backend was less reliable | PC now defaults to Revive's OpenVR (SteamVR) backend; reinstall to apply |
| Oculus Rift game won't run on the Steam Frame | Oculus/LibOVR game: needs Revive, whose hooks don't work under Proton-arm64, and FramePort won't defeat the Oculus runtime signature check | Play it on this PC (SteamVR + Revive). Only OpenVR/OpenXR-native Rift games run on the Frame |
| 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 (~15 MB/s) | PC and Frame both on Wi-Fi through the home router | Connect the PC to the Frame's own hotspot (or a USB cable): FramePort uses the direct link automatically (~80-100 MB/s); job log line "Transfer link" |
| 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 |
| 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 |
| `INSTALL_FAILED_NO_MATCHING_ABIS` | 32-bit-only APK; Frame has no AArch32 | None. PC/Rift version via Revive |
| `UnsatisfiedLinkError` / `cannot locate symbol "ovr_…"` | overport's platform loader lacks Meta platform functions | `frame.ovrstubs` (automatic, generated stubs) |
| missing `ovrMessageType_ToString` | same, but the game needs a real string | `frame.ovrplatformcompat` (automatic) |
@@ -51,5 +61,25 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
## When it can't run on the Frame
Stream PC VR instead: native Steam version if one exists (Espire 1: 669290; HITMAN WoA: 1659040; Sniper Elite VR),
or the Rift version through Revive + SteamVR (Journey of the Gods, Shadow Point). The catalog stores these as
`pcvr_alternative`; a Revive target is planned (`targets/revive.py`).
or the Rift version through Revive (Journey of the Gods, Shadow Point). The catalog stores these as
`pcvr_alternative`. FramePort handles Rift games itself (next section).
## Oculus Rift (PC VR) games
Scan a folder with Rift game dumps (Windows game folders) like Quest dumps; they get ids `rift.<slug>` and the
"PC VR (Revive / Proton)" patch group. Two ways to run them:
- **This PC** (`--to pc`, GUI "Install on this PC"): a non-Steam shortcut in the local Windows Steam runs
`ReviveInjector.exe /openxr "<game.exe>"` (FramePort's portable Revive; on WSL copied to
`%LOCALAPPDATA%\FramePort`). Play on the Frame by streaming from SteamVR.
- **The Frame** (GUI "Install on Frame (Proton)"): game + Revive are uploaded to `~/Applications/quest-frame/rift.*`,
and launch.sh runs them with the Frame's ARM64 Proton (Frame → Install Proton first). Experimental.
| Symptom | Cause | Fix |
|---|---|---|
| "Proton isn't installed on the Frame yet" | ARM64 Proton / Steam Linux Runtime 4 (arm64) not downloaded | Frame → Install Proton (confirm in the headset) or "Install without confirming" (`frameport frame proton --install --unattended`) |
| Game quits at once; `ovrPlatformInitialize_NotEntitled` / entitlement failed | Oculus Platform SDK entitlement check (FramePort flags these: "Uses the Oculus Platform SDK") | PC mode with the Oculus app installed and a license you own. FramePort doesn't bypass entitlement checks |
| `Failed to create process` in ReviveInjector.txt | wrong exe, or 32/64-bit mismatch | check the detected exe on the game page; rescan |
| `XR_ERROR_RUNTIME_UNAVAILABLE` / no OpenXR runtime (PC) | SteamVR not running / not the OpenXR runtime | start SteamVR, set it as OpenXR runtime; or `pcvr.revive_openvr` |
| `XR_ERROR_EXTENSION_NOT_PRESENT` / `xrConvertTimespecTimeToTimeKHR failed` (Frame) | ReviveXR needs `XR_KHR_win32_convert_performance_counter_time`; wineopenxr builds it on the host's timespec conversion. The Frame's Linux runtime supports it (2026-09-29), unlike its Android runtime | `pcvr.xr_timefix` (off by default): FramePort's timefix OpenXR layer emulates it like the Quest adapter |
| Proton game hangs at start in a headless/SSH launch; log `no driver could be loaded` / `explorer process failed to start` | no display session (DISPLAY/GAMESCOPE_WAYLAND_DISPLAY) | the Proton launch.sh takes them from the running Steam (built in) |
| Unreal game starts (window created) then `CrashReportClient` runs | the game crashed under Proton; often no VR runtime reached it (Revive off) | keep `pcvr.revive` on (repacks' bundled LibRevive64.dll isn't loaded by itself); read the game log + crash summary in the launch log |
| `VK_ERROR_DEVICE_LOST` under DXVK | freedreno GPU hang | `pcvr.proton_log` to capture; PC mode |
+2
View File
@@ -11,6 +11,8 @@ embeds debug paths (`-g`) and the dex depends on the d8 version, so those differ
| `vrapi-bridge/` | `arm64-v8a/libvrapi.so` | VrApi → OpenXR bridge from [Android-XR-Bridge/OVRPort](https://github.com/Android-XR-Bridge/OVRPort) `native/vrapi` at 5e7df52 (GPL-3.0, `LICENSE.upstream`), with our changes in `upstream-patches/`: GLES sessions + GL texture swapchains, cylinder→quad layers, GL vertical flip, UNORM↔sRGB format twins, 30 s VR-mode deadline, VALID-only recenter, loading-icon layers skipped, diagnostics behind `-DOVP_GL_DIAG`. |
| `platformcompat/` | `arm64-v8a/libovrplatformcompat.so` | Real `ovrMessageType_ToString` (same fork, `native/platform`). |
| `glshim/` | `arm64-v8a/libglshim.so` | Mesa GLSL compatibility for GLAD engines (hooks eglGetProcAddress): comments out `#pragma` before `#extension`, enables `GL_EXT_shader_implicit_conversions`, hides GL_OVR_multiview (`gl_hide_multiview`, default 1) and logs failed shaders. `-DGLSHIM_TRACE` adds per-FBO draw/error tracing. |
| `xrlayer/` | `linux-arm64/libxr_frameport_timefix.so` + `XR_APILAYER_FRAMEPORT_timefix.json` | OpenXR API layer for Windows PC VR games under Proton on the Frame (glibc aarch64, built freestanding with the NDK's clang), on by default (patch `pcvr.xr_timefix`). Retries `xrCreateInstance` as an OpenXR 1.0 app when the runtime rejects 1.1 (the Frame's SteamVR runtime does, and Proton 11's VR helper asks for 1.1). Also emulates `xrConvertTimespecTimeToTimeKHR`/`xrConvertTimeToTimespecTimeKHR` if the runtime refuses them (the Frame's Android runtime does; its Linux runtime, used by Proton, supports them as of 2026-09-29 — so this part is only a fallback; Proton's wineopenxr needs them for `XR_KHR_win32_convert_performance_counter_time`, which Revive requires) with the adapter's xrWaitFrame-calibrated offset, and drops the extension from xrCreateInstance if the runtime rejects it. Enabled per game by the Proton launch.sh (`XR_API_LAYER_PATH`, `XR_ENABLE_API_LAYERS`). `tests/test_xrlayer.py` builds it for x86_64 and drives it via ctypes. |
| `oculushmd/` | `win-x64/fp_oculushmd.exe` | PC VR counterpart of overport's `patch_oculus_unreal` (patch `pcvr.oculus_unreal`, default for Unreal Rift games on the Frame). Unreal's OculusHMD and LibOVR's `ovr_Detect` only start when the Windows named event `OculusHMDConnected` exists and is signalled (the Oculus service creates it). `fp_oculushmd.exe <command line>` creates it (manual reset, signalled), runs the command (Revive's injector) in a job object and keeps the event until every process in the job — the game and its children — has exited (fallback without job support: 3 minutes); returns the command's exit code and logs `FramePort oculushmd: …` to stderr (launch.log). Freestanding Windows x64 console exe (no CRT, no Windows SDK): the NDK's clang + `lld-link /Brepro` with a kernel32 import library generated by `llvm-dlltool`, so it's byte-reproducible. Tested on Windows from WSL with a build whose event name is `-DEVENT_NAME=L"…"` (the Oculus service owns the real event on a PC with the Oculus app). |
| `java-stubs/` | `dex/oculusos-stubs.dex` | No-op `com.oculus.os.AnalyticsEvent` / `UnifiedTelemetryLogger` for native code that looks them up. |
The ovr_* stub library is *not* prebuilt: `src/frameport/analysis/stubgen.py` generates it per game from the exact
+65 -7
View File
@@ -5,10 +5,12 @@ Everything is fetched on demand into native/.cache (git-ignored):
- Android NDK r27c (27.2.12479018) from Google's repository (checksummed via repository2-3.xml)
- OpenXR headers at the commits each component was written against (KhronosGroup/OpenXR-SDK)
- Temurin JDK 21 (javac) and Android build-tools (d8) for the Java stub classes
Then builds: FrameBridge adapter (arm64 + arm32), VrApi bridge, platform compat, GL shim, oculusos stub dex, and
Then builds: FrameBridge adapter (arm64 + arm32), VrApi bridge, platform compat, GL shim, oculusos stub dex, the
timefix OpenXR layer for Proton games (linux-arm64, glibc; the NDK's clang builds it freestanding), the
OculusHMDConnected helper for Rift games under Proton (win-x64 PE; the NDK's clang + lld-link, no Windows SDK), and
rewrites artifacts/SHA256SUMS. Run `frameport parity` afterwards to see which games change.
python native/build.py [--only adapter,bridge,compat,glshim,dex] [--ndk PATH]
python native/build.py [--only adapter,bridge,compat,glshim,dex,xrlayer,oculushmd] [--ndk PATH]
"""
from __future__ import annotations
@@ -34,8 +36,9 @@ REPO = "https://dl.google.com/android/repository/"
OPENXR = { # component -> OpenXR-SDK commit its headers came from
"adapter": "f2448a8797c85814aa892efc1ab8707900fbcc78",
"bridge": "7f9285bce1ce8b69bb75554bf788666579d0c35e",
"xrlayer": "f2448a8797c85814aa892efc1ab8707900fbcc78",
}
OPENXR_HEADERS = ("openxr.h", "openxr_platform.h", "openxr_platform_defines.h")
OPENXR_HEADERS = ("openxr.h", "openxr_platform.h", "openxr_platform_defines.h", "openxr_loader_negotiation.h")
def log(msg):
@@ -81,13 +84,29 @@ def ndk(explicit: str | None) -> Path:
with zipfile.ZipFile(z) as zf:
zf.extractall(CACHE / "ndk-extract")
top = next((CACHE / "ndk-extract").iterdir())
top.rename(dest)
shutil.move(str(top), str(dest)) # (a plain rename can fail on NTFS while the folder is being scanned)
shutil.rmtree(CACHE / "ndk-extract", ignore_errors=True)
restore_symlinks(z, dest)
for p in (dest / "toolchains/llvm/prebuilt").rglob("bin/*"):
p.chmod(0o755)
return dest
def restore_symlinks(archive: Path, dest: Path) -> None:
"""zipfile writes symlinks (clang -> clang-18, ...) as small text files; replace them with copies of their
targets (copies also work on filesystems without symlinks)."""
with zipfile.ZipFile(archive) as zf:
links = [i for i in zf.infolist() if (i.external_attr >> 16) & 0o170000 == 0o120000]
for _ in range(3): # links to links
for info in links:
rel = Path(*Path(info.filename).parts[1:])
path = dest / rel
target = (path.parent / zf.read(info).decode()).resolve()
if target.is_file() and (not path.exists() or path.stat().st_size < 256):
path.unlink(missing_ok=True)
shutil.copy2(target, path)
def clang_dir(ndk_root: Path) -> Path:
host = {"Linux": "linux-x86_64", "Darwin": "darwin-x86_64", "Windows": "windows-x86_64"}[platform.system()]
return ndk_root / "toolchains/llvm/prebuilt" / host
@@ -124,6 +143,44 @@ def build_adapter(tc: Path):
"-o", ART / "armeabi-v7a/libopenxr_loader_generic.so"], cwd=src)
def build_xrlayer(tc: Path):
"""OpenXR API layer for Linux aarch64 (glibc), loaded by Proton's wineopenxr host loader on the Frame. Freestanding:
no libc headers or link-time libs; clock_gettime binds to the process's libc at load time."""
src = HERE / "xrlayer"
inc = openxr_include("xrlayer")
out = ART / "linux-arm64"
out.mkdir(parents=True, exist_ok=True)
run([tc / "bin/clang", "--target=aarch64-linux-gnu", "-ffreestanding", "-nostdlibinc", "-fno-stack-protector",
"-fvisibility=hidden", "-fPIC", "-O2", "-Wall", "-Wextra", "-Werror", "-I", src / "include", "-I", inc,
f"-ffile-prefix-map={HERE}=native", "-shared", "-nostdlib", "-fuse-ld=lld", "-Wl,--build-id=none",
"-Wl,-z,max-page-size=65536", "-Wl,--hash-style=both", "-Wl,-soname,libxr_frameport_timefix.so",
"timefix_layer.c", "-o", out / "libxr_frameport_timefix.so"], cwd=src)
shutil.copy(src / "XR_APILAYER_FRAMEPORT_timefix.json", out / "XR_APILAYER_FRAMEPORT_timefix.json")
OCULUSHMD_IMPORTS = ("CreateEventW", "CreateJobObjectW", "AssignProcessToJobObject", "QueryInformationJobObject",
"CreateProcessW", "ResumeThread", "WaitForSingleObject", "GetExitCodeProcess", "CloseHandle",
"Sleep", "GetCommandLineW", "GetLastError", "GetStdHandle", "WriteFile", "ExitProcess")
def build_oculushmd(tc: Path, out: Path | None = None, defines=()):
"""fp_oculushmd.exe (Windows x64, run by Proton on the Frame): provides the OculusHMDConnected event for Unreal
Rift games. Freestanding: no CRT; the kernel32 import library is generated from a .def by llvm-dlltool."""
src = HERE / "oculushmd"
out = out or ART / "win-x64"
out.mkdir(parents=True, exist_ok=True)
work = CACHE / "oculushmd"
work.mkdir(parents=True, exist_ok=True)
(work / "kernel32.def").write_text("LIBRARY kernel32.dll\nEXPORTS\n" + "".join(f" {n}\n" for n in OCULUSHMD_IMPORTS))
run([tc / "bin/llvm-dlltool", "-m", "i386:x86-64", "-d", work / "kernel32.def", "-l", work / "kernel32.lib"])
run([tc / "bin/clang", "--target=x86_64-pc-windows-msvc", "-ffreestanding", "-nostdlibinc", "-fno-stack-protector",
"-fno-builtin", "-O2", "-Wall", "-Wextra", "-Werror", f"-ffile-prefix-map={HERE}=native", *defines, "-c",
"fp_oculushmd.c", "-o", work / "fp_oculushmd.obj"], cwd=src)
run([tc / "bin/lld-link", "/nologo", "/Brepro", "/nodefaultlib", "/entry:start", "/subsystem:console",
"/dynamicbase", "/highentropyva", "/nxcompat", work / "fp_oculushmd.obj", work / "kernel32.lib",
f"/out:{out / 'fp_oculushmd.exe'}"])
def build_bridge(tc: Path):
src = HERE / "vrapi-bridge"
inc = openxr_include("bridge")
@@ -197,7 +254,7 @@ def write_sums():
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--only", default="adapter,bridge,compat,glshim,dex")
ap.add_argument("--only", default="adapter,bridge,compat,glshim,dex,xrlayer,oculushmd")
ap.add_argument("--ndk")
args = ap.parse_args()
parts = set(args.only.split(","))
@@ -205,8 +262,9 @@ def main():
(ART / d).mkdir(parents=True, exist_ok=True)
tc = clang_dir(ndk(args.ndk)) if parts - {"dex"} else None
steps = {"adapter": lambda: build_adapter(tc), "bridge": lambda: build_bridge(tc), "compat": lambda: build_compat(tc),
"glshim": lambda: build_glshim(tc), "dex": build_dex}
for name in ("adapter", "bridge", "compat", "glshim", "dex"):
"glshim": lambda: build_glshim(tc), "dex": build_dex, "xrlayer": lambda: build_xrlayer(tc),
"oculushmd": lambda: build_oculushmd(tc)}
for name in ("adapter", "bridge", "compat", "glshim", "dex", "xrlayer", "oculushmd"):
if name in parts:
log(f"build {name}")
steps[name]()
+140
View File
@@ -0,0 +1,140 @@
/* fp_oculushmd.exe — the PC VR counterpart of overport's patch_oculus_unreal, for Rift games under Proton on the Frame.
*
* Unreal's OculusVR plugin (and LibOVR's ovr_Detect) only start when the Windows named event "OculusHMDConnected"
* exists and is signalled; on a real PC the Oculus service creates it. Without it Unreal never loads OVRPlugin and the
* game runs as a flat window. Revive hooks OpenEventW for the same purpose, but that relies on Detours patching Wine's
* kernelbase, which is ARM64EC code under Proton arm64. This helper simply provides the real event:
*
* fp_oculushmd.exe <command line to run> (e.g. ReviveInjector.exe /openxr Z:\...\Game-Win64-Shipping.exe)
*
* It creates the event (manual reset, signalled), runs the command inside a job object and stays alive (keeping the
* event) until every process in the job has exited — ReviveInjector exits right after starting the game, the game
* and its children stay in the job. Returns the exit code of the command.
*
* Freestanding (no CRT, no Windows SDK headers): built by native/build.py with the NDK's clang + lld-link.
*/
typedef void *HANDLE;
typedef unsigned long DWORD;
typedef int BOOL;
typedef unsigned short WORD;
typedef unsigned short WCHAR;
typedef long long LONGLONG;
typedef struct {
DWORD cb;
WCHAR *lpReserved, *lpDesktop, *lpTitle;
DWORD dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
WORD wShowWindow, cbReserved2;
unsigned char *lpReserved2;
HANDLE hStdInput, hStdOutput, hStdError;
} STARTUPINFOW;
typedef struct {
HANDLE hProcess, hThread;
DWORD dwProcessId, dwThreadId;
} PROCESS_INFORMATION;
typedef struct {
LONGLONG TotalUserTime, TotalKernelTime, ThisPeriodTotalUserTime, ThisPeriodTotalKernelTime;
DWORD TotalPageFaultCount, TotalProcesses, ActiveProcesses, TotalTerminatedProcesses;
} JOBOBJECT_BASIC_ACCOUNTING_INFORMATION;
#define WINAPI __attribute__((ms_abi))
#define IMPORT __declspec(dllimport)
IMPORT HANDLE WINAPI CreateEventW(void *, BOOL, BOOL, const WCHAR *);
IMPORT HANDLE WINAPI CreateJobObjectW(void *, const WCHAR *);
IMPORT BOOL WINAPI AssignProcessToJobObject(HANDLE, HANDLE);
IMPORT BOOL WINAPI QueryInformationJobObject(HANDLE, int, void *, DWORD, DWORD *);
IMPORT BOOL WINAPI CreateProcessW(const WCHAR *, WCHAR *, void *, void *, BOOL, DWORD, void *, const WCHAR *,
STARTUPINFOW *, PROCESS_INFORMATION *);
IMPORT DWORD WINAPI ResumeThread(HANDLE);
IMPORT DWORD WINAPI WaitForSingleObject(HANDLE, DWORD);
IMPORT BOOL WINAPI GetExitCodeProcess(HANDLE, DWORD *);
IMPORT BOOL WINAPI CloseHandle(HANDLE);
IMPORT void WINAPI Sleep(DWORD);
IMPORT WCHAR *WINAPI GetCommandLineW(void);
IMPORT DWORD WINAPI GetLastError(void);
IMPORT HANDLE WINAPI GetStdHandle(DWORD);
IMPORT BOOL WINAPI WriteFile(HANDLE, const void *, DWORD, DWORD *, void *);
IMPORT __attribute__((noreturn)) void WINAPI ExitProcess(unsigned);
#define CREATE_SUSPENDED 0x4
#define STD_ERROR_HANDLE ((DWORD)-12)
#define JobObjectBasicAccountingInformation 1
#ifndef EVENT_NAME /* overridable for tests on a PC where the Oculus service owns the real one */
#define EVENT_NAME L"OculusHMDConnected" /* OVR_HMD_CONNECTED_EVENT_NAME */
#endif
#define GRACE_MS 180000 /* no job tracking: keep the event this long after the command exits (Unreal checks at start) */
static void say(const char *msg) {
DWORD n = 0, len = 0;
while (msg[len]) len++;
WriteFile(GetStdHandle(STD_ERROR_HANDLE), msg, len, &n, 0);
}
static void say_error(const char *msg) { /* msg + " (error N)" */
char buf[16], *p = buf + sizeof buf;
DWORD e = GetLastError();
*--p = 0, *--p = '\n', *--p = ')';
do *--p = (char)('0' + e % 10); while (e /= 10);
say(msg);
say(" (error ");
say(p);
}
/* The command line after our own program name (quoted or not), without leading blanks. */
static WCHAR *rest_of_command_line(WCHAR *p) {
if (*p == '"') {
for (p++; *p && *p != '"'; p++) {}
if (*p) p++;
} else {
while (*p && *p != ' ' && *p != '\t') p++;
}
while (*p == ' ' || *p == '\t') p++;
return p;
}
void start(void) {
WCHAR *cmd = rest_of_command_line(GetCommandLineW());
if (!*cmd) {
say("usage: fp_oculushmd.exe <command line>\n");
ExitProcess(2);
}
HANDLE event = CreateEventW(0, 1, 1, EVENT_NAME);
if (event)
say("FramePort oculushmd: OculusHMDConnected event ready\n");
else
say_error("FramePort oculushmd: could not create the OculusHMDConnected event");
static STARTUPINFOW si; /* static: zeroed without memset */
static PROCESS_INFORMATION pi;
si.cb = sizeof si;
if (!CreateProcessW(0, cmd, 0, 0, 0, CREATE_SUSPENDED, 0, 0, &si, &pi)) {
say_error("FramePort oculushmd: could not start the command");
ExitProcess(1);
}
HANDLE job = CreateJobObjectW(0, 0);
BOOL tracked = job && AssignProcessToJobObject(job, pi.hProcess);
ResumeThread(pi.hThread);
CloseHandle(pi.hThread);
WaitForSingleObject(pi.hProcess, 0xFFFFFFFF);
DWORD code = 1;
GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hProcess);
if (tracked) { /* the game started by the command (and its children) are in the job too */
say("FramePort oculushmd: waiting for the game to exit\n");
for (;;) {
static JOBOBJECT_BASIC_ACCOUNTING_INFORMATION info;
if (!QueryInformationJobObject(job, JobObjectBasicAccountingInformation, &info, sizeof info, 0)
|| info.ActiveProcesses == 0)
break;
Sleep(1000);
}
} else {
say("FramePort oculushmd: no job tracking; keeping the event for 3 minutes\n");
Sleep(GRACE_MS);
}
if (event) CloseHandle(event);
ExitProcess(code);
}
@@ -0,0 +1,11 @@
{
"file_format_version": "1.0.0",
"api_layer": {
"name": "XR_APILAYER_FRAMEPORT_timefix",
"library_path": "./libxr_frameport_timefix.so",
"api_version": "1.0",
"implementation_version": "1",
"description": "FramePort: XR_KHR_convert_timespec_time emulation for Proton games on the Steam Frame",
"instance_extensions": [{"name": "XR_KHR_convert_timespec_time", "extension_version": "1"}]
}
}
+7
View File
@@ -0,0 +1,7 @@
/* Minimal <time.h> for the freestanding timefix layer (aarch64 Linux: time_t and long are 64-bit). The layer links
* against nothing at build time; clock_gettime resolves to the process's libc when the loader dlopens it. */
#pragma once
typedef long time_t;
struct timespec { time_t tv_sec; long tv_nsec; };
#define CLOCK_MONOTONIC 1
int clock_gettime(int clock_id, struct timespec *ts);
+157
View File
@@ -0,0 +1,157 @@
/* FramePort timefix: an OpenXR API layer for Windows PC VR games running under Proton on the Steam Frame.
*
* Proton's wineopenxr implements XR_KHR_win32_convert_performance_counter_time (required by Revive's OpenXR backend)
* on top of the host's XR_KHR_convert_timespec_time, but the Frame runtime returns XR_ERROR_FUNCTION_UNSUPPORTED for
* xrConvertTimespecTimeToTimeKHR / xrConvertTimeToTimespecTimeKHR. This layer supplies them with the same emulation
* the FrameBridge adapter uses for Quest games: XrTime = CLOCK_MONOTONIC + offset, where the offset is measured at
* every xrWaitFrame (predictedDisplayTime - predictedDisplayPeriod - now). The runtime's own functions are preferred
* whenever they work. The layer also advertises XR_KHR_convert_timespec_time (manifest) and removes it from
* xrCreateInstance if the runtime rejects it.
*
* OpenXR version fallback: the Frame's SteamVR runtime rejects apiVersion 1.1 with XR_ERROR_API_VERSION_UNSUPPORTED
* (checked 2026-09-29; 1.0 works), which is what Proton 11's VR helper requests, so VR never started for Proton games
* (flat window / "Unable to load LibOVRRT DLL" under Revive). The layer retries such a create as a 1.0 app.
*
* Enabled per game by FramePort's Proton launcher: XR_API_LAYER_PATH=<game>/xrlayer,
* XR_ENABLE_API_LAYERS=XR_APILAYER_FRAMEPORT_timefix. Built freestanding (see ../build.py).
*/
#define XR_USE_TIMESPEC 1
#include <time.h>
#include <openxr/openxr.h>
#include <openxr/openxr_platform.h>
#include <openxr/openxr_loader_negotiation.h>
#define EXPORT __attribute__((visibility("default")))
#define LAYER_NAME "XR_APILAYER_FRAMEPORT_timefix"
#define TIMESPEC_EXT "XR_KHR_convert_timespec_time"
#define MAX_EXTS 256
static PFN_xrGetInstanceProcAddr next_gipa;
static PFN_xrConvertTimespecTimeToTimeKHR runtime_timespec_to_time;
static PFN_xrConvertTimeToTimespecTimeKHR runtime_time_to_timespec;
static PFN_xrWaitFrame runtime_wait_frame;
static volatile long long time_offset;
static volatile int calibrated;
static int str_eq(const char *a, const char *b) {
if (!a || !b) return 0;
while (*a && *a == *b) { ++a; ++b; }
return *a == *b;
}
static long long mono_ns(void) {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (long long)now.tv_sec * 1000000000ll + now.tv_nsec;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_timespec_to_time(XrInstance instance, const struct timespec *ts, XrTime *time) {
if (runtime_timespec_to_time) {
XrResult r = runtime_timespec_to_time(instance, ts, time);
if (r != XR_ERROR_FUNCTION_UNSUPPORTED) return r;
}
if (!ts || !time) return XR_ERROR_VALIDATION_FAILURE;
*time = (XrTime)((long long)ts->tv_sec * 1000000000ll + ts->tv_nsec + (calibrated ? time_offset : 0));
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_time_to_timespec(XrInstance instance, XrTime time, struct timespec *ts) {
if (runtime_time_to_timespec) {
XrResult r = runtime_time_to_timespec(instance, time, ts);
if (r != XR_ERROR_FUNCTION_UNSUPPORTED) return r;
}
if (!ts) return XR_ERROR_VALIDATION_FAILURE;
long long mono = (long long)time - (calibrated ? time_offset : 0);
ts->tv_sec = (time_t)(mono / 1000000000ll);
ts->tv_nsec = (long)(mono % 1000000000ll);
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL layer_wait_frame(XrSession session, const XrFrameWaitInfo *info, XrFrameState *state) {
if (!runtime_wait_frame) return XR_ERROR_FUNCTION_UNSUPPORTED;
XrResult r = runtime_wait_frame(session, info, state);
if (XR_SUCCEEDED(r) && state && state->predictedDisplayTime) {
// predictedDisplayTime is about one display period ahead of "now"
time_offset = (long long)(state->predictedDisplayTime - state->predictedDisplayPeriod) - mono_ns();
calibrated = 1;
}
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL layer_get_instance_proc_addr(XrInstance instance, const char *name,
PFN_xrVoidFunction *function) {
if (!function || !name) return XR_ERROR_VALIDATION_FAILURE;
if (!next_gipa) return XR_ERROR_HANDLE_INVALID;
if (str_eq(name, "xrConvertTimespecTimeToTimeKHR") || str_eq(name, "xrConvertTimeToTimespecTimeKHR")) {
PFN_xrVoidFunction real = 0;
if (instance == XR_NULL_HANDLE) return XR_ERROR_HANDLE_INVALID;
if (XR_FAILED(next_gipa(instance, name, &real))) real = 0;
if (str_eq(name, "xrConvertTimespecTimeToTimeKHR")) {
runtime_timespec_to_time = (PFN_xrConvertTimespecTimeToTimeKHR)real;
*function = (PFN_xrVoidFunction)emu_timespec_to_time;
} else {
runtime_time_to_timespec = (PFN_xrConvertTimeToTimespecTimeKHR)real;
*function = (PFN_xrVoidFunction)emu_time_to_timespec;
}
return XR_SUCCESS;
}
if (str_eq(name, "xrWaitFrame") && instance != XR_NULL_HANDLE) {
XrResult r = next_gipa(instance, name, (PFN_xrVoidFunction *)&runtime_wait_frame);
if (XR_FAILED(r)) return r;
*function = (PFN_xrVoidFunction)layer_wait_frame;
return XR_SUCCESS;
}
return next_gipa(instance, name, function);
}
static XrResult create_stripped(XrApiLayerNextInfo *next, const XrInstanceCreateInfo *info,
const XrApiLayerCreateInfo *chained, XrInstance *instance) {
XrResult r = next->nextCreateApiLayerInstance(info, chained, instance);
if (r != XR_ERROR_EXTENSION_NOT_PRESENT) return r;
// The runtime doesn't know XR_KHR_convert_timespec_time: we provide it, so don't pass it down.
const char *exts[MAX_EXTS];
uint32_t n = 0, dropped = 0;
for (uint32_t i = 0; i < info->enabledExtensionCount && n < MAX_EXTS; ++i) {
if (str_eq(info->enabledExtensionNames[i], TIMESPEC_EXT)) { ++dropped; continue; }
exts[n++] = info->enabledExtensionNames[i];
}
if (!dropped) return r;
XrInstanceCreateInfo stripped = *info;
stripped.enabledExtensionCount = n;
stripped.enabledExtensionNames = exts;
return next->nextCreateApiLayerInstance(&stripped, chained, instance);
}
static XRAPI_ATTR XrResult XRAPI_CALL layer_create_instance(const XrInstanceCreateInfo *info,
const XrApiLayerCreateInfo *layer_info, XrInstance *instance) {
if (!layer_info || !layer_info->nextInfo || !info) return XR_ERROR_INITIALIZATION_FAILED;
XrApiLayerNextInfo *next = layer_info->nextInfo;
next_gipa = next->nextGetInstanceProcAddr;
XrApiLayerCreateInfo chained = *layer_info;
chained.nextInfo = next->next;
XrResult r = create_stripped(next, info, &chained, instance);
if (r != XR_ERROR_API_VERSION_UNSUPPORTED || XR_VERSION_MAJOR(info->applicationInfo.apiVersion) != 1 ||
XR_VERSION_MINOR(info->applicationInfo.apiVersion) == 0)
return r;
// The Frame's SteamVR runtime only accepts OpenXR 1.0, but Proton 11's VR helper (and newer apps) ask for 1.1:
// retry as a 1.0 app (1.1 core functions the runtime lacks are simply not returned by xrGetInstanceProcAddr).
XrInstanceCreateInfo v10 = *info;
v10.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0);
return create_stripped(next, &v10, &chained, instance);
}
EXPORT XRAPI_ATTR XrResult XRAPI_CALL xrNegotiateLoaderApiLayerInterface(const XrNegotiateLoaderInfo *loader_info,
const char *layer_name,
XrNegotiateApiLayerRequest *request) {
if (!loader_info || !request || loader_info->structType != XR_LOADER_INTERFACE_STRUCT_LOADER_INFO ||
request->structType != XR_LOADER_INTERFACE_STRUCT_API_LAYER_REQUEST ||
loader_info->minInterfaceVersion > XR_CURRENT_LOADER_API_LAYER_VERSION ||
loader_info->maxInterfaceVersion < XR_CURRENT_LOADER_API_LAYER_VERSION)
return XR_ERROR_INITIALIZATION_FAILED;
if (layer_name && !str_eq(layer_name, LAYER_NAME)) return XR_ERROR_INITIALIZATION_FAILED;
request->layerInterfaceVersion = XR_CURRENT_LOADER_API_LAYER_VERSION;
request->layerApiVersion = XR_CURRENT_API_VERSION;
request->getInstanceProcAddr = layer_get_instance_proc_addr;
request->createApiLayerInstance = layer_create_instance;
return XR_SUCCESS;
}
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "frameport"
version = "0.1.0"
version = "0.2.0"
description = "Port Meta Quest standalone games to the Valve Steam Frame (Lepton): suggested patches, automated validation, one-click install."
readme = "README.md"
license = { text = "GPL-3.0-only" }
@@ -45,6 +45,7 @@ testpaths = ["tests"]
markers = [
"device: needs a reachable Steam Frame (FRAMEPORT_FRAME=user@host)",
"games: needs the local game dumps (FRAMEPORT_GAMES=<downloads dir>)",
"native: compiles native code with the NDK from native/build.py (FRAMEPORT_NATIVE_TESTS=1; slow)",
]
[tool.flet]
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Score the patch heuristics against the verified catalog: for every game dump that has a catalog recipe, suggest a
recipe with the catalog switched off and compare the recipe-relevant choices.
python scripts/eval_heuristics.py "<folder with game dumps>" [--verbose]
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from frameport.analysis.detect import analyze # noqa: E402
from frameport.patches import base # noqa: E402
from frameport.recommend import catalog, engine # noqa: E402
from frameport.sources import quest_dump # noqa: E402
def signature(recipe) -> dict:
"""The parts of a recipe that differ between games (defaults/automatic fixes are excluded)."""
pats = recipe.patches
return {
"overport": sorted(p for p in pats if base.get(p).category == "overport" and not base.get(p).default_on),
"overport_removed": sorted(p for p in (x.id for x in base.all_patches() if x.category == "overport" and x.default_on)
if p not in pats),
"frame": sorted(p for p in pats if base.get(p).category == "frame" and not base.get(p).default_on),
"adapter": {p.split(".", 1)[1]: v.get("value") for p, v in sorted(pats.items()) if p.startswith("adapter.")},
"device_files": sorted((pats.get("device.files") or {}).get("files", {})),
"alt": sorted(recipe.alt_patches),
"use_alt": recipe.use_alt,
"status_unsupported": recipe.status == "unsupported",
}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("dumps", type=Path)
ap.add_argument("--verbose", action="store_true")
args = ap.parse_args()
games = quest_dump.scan(args.dumps)
total = matched_fields = games_ok = 0
misses = []
for g in games:
a = analyze(g.apk, data_bytes=g.data_bytes())
if not catalog.lookup(a.package):
continue
want = signature(engine.suggest(a, use_catalog=True))
want["status_unsupported"] = catalog.lookup(a.package).status == "unsupported" and "32-bit" in catalog.lookup(a.package).notes
got = signature(engine.suggest(a, use_catalog=False))
diffs = {k: (want[k], got[k]) for k in want if want[k] != got[k]}
total += len(want)
matched_fields += len(want) - len(diffs)
games_ok += not diffs
if diffs:
misses.append((a.package, diffs))
if args.verbose or diffs:
print(f"{'OK ' if not diffs else 'DIFF'} {a.package}")
for k, (w, h) in diffs.items():
print(f" {k}: catalog={w} heuristics={h}")
n = games_ok + len(misses)
print(f"\n{games_ok}/{n} games reproduced exactly; {matched_fields}/{total} recipe fields match")
return 0 if not misses else 1
if __name__ == "__main__":
sys.exit(main())
+12 -2
View File
@@ -41,12 +41,16 @@ def main() -> int:
ap.add_argument("--out", type=Path, required=True)
ap.add_argument("--game", default=None)
ap.add_argument("--frame", default=None, help="steamos@host: also connect and run a launch-test job for --game")
ap.add_argument("--no-test", action="store_true", help="with --frame: skip the launch-test job")
args = ap.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
game = args.game or (library.games()[0]["package"] if library.games() else None)
steps = [("library", lambda a: a.navigate(0)), ("frame", lambda a: a.navigate(1)), ("tools", lambda a: a.navigate(2))]
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)))
# last: the right-click menu stays open over whatever comes next
steps.append(("library-menu", lambda a: (a.navigate(0), time.sleep(3), a.library_view.open_menu(game))))
if args.frame:
from frameport.frame.connection import parse_target
@@ -64,7 +68,11 @@ def main() -> int:
while a.job_running:
time.sleep(2)
steps += [("frame-connected", connect), ("launch-test-job", job)]
steps += [("frame-connected", connect), ("library-connected", lambda a: a.navigate(0))]
if game:
steps.append(("game-connected", lambda a: a.open_game(game)))
if not args.no_test:
steps.append(("launch-test-job", job))
ready, done = threading.Event(), []
def app_main(page: ft.Page):
@@ -101,7 +109,9 @@ def main() -> int:
os._exit(1 if ERRORS else 0)
threading.Thread(target=shooter, daemon=True).start()
ft.run(app_main, view=ft.AppView.WEB_BROWSER, port=PORT)
from frameport.ui.app import assets_dir
ft.run(app_main, view=ft.AppView.WEB_BROWSER, port=PORT, assets_dir=assets_dir())
return 1
+56 -6
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import re
import zipfile
from pathlib import Path
@@ -22,7 +23,40 @@ def _read_manifest_info(path: Path) -> tuple[str, str, str, str | None]:
return apk.package, apk.version_name or "", label, apk.get_main_activity()
def analyze(path: Path, deep: bool = True) -> Analysis:
def _unreal_version(z: zipfile.ZipFile, lib: str) -> str | None:
"""'4.20' from the '++UE4+Release-4.20' build string (streamed; engine libs can be >1 GB)."""
tail = b""
with z.open(lib) as f:
while True:
chunk = f.read(1 << 24)
if not chunk:
return None
m = re.search(rb"\+\+UE[45]\+Release-([0-9]+\.[0-9]+)", tail + chunk)
if m:
return m.group(1).decode()
tail = chunk[-64:]
def _features(manifest: bytes) -> dict[str, bool]:
"""uses-feature name -> required (android:required defaults to true)."""
x = axml.Axml(manifest)
names = x.strings()
out = {}
for el in x.elements():
if el.name != "uses-feature":
continue
name = x.attr_str(el, "name")
if not name:
continue
required = True
for a in el.attrs:
if a.name < len(names) and names[a.name] == "required":
required = a.value != 0
out[name] = required
return out
def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Analysis:
path = Path(path)
with zipfile.ZipFile(path) as z:
names = z.namelist()
@@ -39,12 +73,15 @@ def analyze(path: Path, deep: bool = True) -> Analysis:
info = z.getinfo(prefix + lib)
if info.file_size < 400 * 2**20:
lib_bytes[lib] = z.read(info)
engine_lib = next((prefix + n for n in ("libUE4.so", "libUnreal.so") if prefix and prefix + n in names), None)
unreal_version = _unreal_version(z, engine_lib) if deep and engine_lib else None
boot = z.read("assets/bin/Data/boot.config").decode("utf-8", "replace") if "assets/bin/Data/boot.config" in names else ""
ggm = z.read(UNITY_GGM) if deep and UNITY_GGM in names else None
package, version, label, activity = _read_manifest_info(path)
libset = set(libs)
engine = "Unreal" if libset & {"libUE4.so", "libUnreal.so"} else "Unity" if "libunity.so" in libset else "Other"
engine = ("Unreal" if libset & {"libUE4.so", "libUnreal.so"} else "Unity" if "libunity.so" in libset
else "CryEngine" if "libCrySystem.so" in libset else "Other")
has_openxr, has_vrapi = "libopenxr_loader.so" in libset, "libvrapi.so" in libset
xr = "OpenXR+VrApi" if has_openxr and has_vrapi else "OpenXR" if has_openxr else "VrApi" if has_vrapi else "?"
is_overport = "libopenxr_loader_generic.so" in libset or "liboverport.config.so" in libset
@@ -62,12 +99,12 @@ def analyze(path: Path, deep: bool = True) -> Analysis:
graphics = "GLES or unknown (no Vulkan declaration)"
uses_glad = False
oculus_os = False
oculus_os_refs = []
for name, data in lib_bytes.items():
if not elf.is_elf(data):
continue
if b"com/oculus/os/AnalyticsEvent" in data:
oculus_os = True
oculus_os_refs.append(name)
if name not in ("libvrapi.so", "libOVRPlugin.so") and b"GLAD_GL_" in data and "eglGetProcAddress" in elf.dyn_symbols(data, False):
uses_glad = True
@@ -81,6 +118,8 @@ def analyze(path: Path, deep: bool = True) -> Analysis:
msaa_levels = 0
cats = axml.categories(manifest)
features = _features(manifest)
used_perms, _ = axml.used_and_declared_permissions(manifest)
return Analysis(
package=package,
version=version,
@@ -96,10 +135,21 @@ def analyze(path: Path, deep: bool = True) -> Analysis:
meta_permissions=axml.undeclared_meta_permissions(manifest),
uses_glad_gl=uses_glad,
unity_msaa_levels=msaa_levels,
oculus_os_classes=oculus_os,
oculus_os_classes=bool(oculus_os_refs),
is_overport_output=is_overport,
debuggable=bool(axml.Axml(manifest).get_bool("application", "debuggable")),
extra={"missing_ovr_symbols": sorted(missing_ovr_symbols(lib_bytes)), "size": path.stat().st_size},
extra={
"frame_patched": "libframe_settings.so" in libset, # already has FramePort's FrameBridge adapter
"missing_ovr_symbols": sorted(missing_ovr_symbols(lib_bytes)), "size": path.stat().st_size,
"data_bytes": data_bytes or 0,
"features": features,
"meta_permissions_used": sorted(p for p in used_perms if p.startswith(("com.oculus.permission.", "horizonos."))),
# mixed-reality-only: passthrough required and no guardian (Meta's BOUNDARYLESS_APP)
"mr_only": features.get("com.oculus.feature.PASSTHROUGH", False) and "com.oculus.feature.BOUNDARYLESS_APP" in features,
"hand_tracking_only": features.get("oculus.software.handtracking", False),
"unreal_version": unreal_version,
"oculus_os_refs": sorted(oculus_os_refs),
},
)
+457
View File
@@ -0,0 +1,457 @@
"""Inspect an Oculus Rift (Windows PC VR) game folder: which program starts it, bitness, engine, XR API, graphics API,
and whether it uses the Oculus Platform SDK (entitlement checks that need the Oculus app).
Game folders come in many shapes (repacks nest the game one or more levels down, next to installers and archives):
Stormland/Stormland.exe
Asgards Wrath/WindowsNoEditor/WrathGame.exe + …/WrathGame/Binaries/Win64/WrathGame-Win64-Shipping.exe
The Climb/bin/win_x64/Climb.exe + The Climb/bin/win_x64-steam/Climb.exe
One recursive walk (walk()) collects every .exe/.dll; candidates are GUI-subsystem x86/x64 PE files that aren't
installers or helpers; rank_exes() scores them with human-readable reasons and flags ambiguous cases for the user.
A tiny PE reader (header, imports, icon resources) is enough; no third-party dependency (Pillow only for icons).
"""
from __future__ import annotations
import json
import os
import re
import struct
from dataclasses import dataclass, field
from pathlib import Path
from ..core.models import Analysis
MACHINES = {0x14C: "x86", 0x8664: "x86_64", 0xAA64: "arm64"}
GUI_SUBSYSTEM = 2
SKIP_EXE = re.compile(
r"(?i)(crash|unins|setup|install|redist|vc_?redist|dxsetup|prereq|launcher_helper|cefprocess|ueprereq|dotnet|"
r"oculus.?platform|easyanticheat|eac_|report|uploader|bssndrpt|agrepack|modinstaller|^cli(-\d+)?\.exe$|"
r"^gui(-\d+)?\.exe$|^t(32|64)\.exe$|^w(32|64)\.exe$|updater|patcher|benchmark|helper|touchup|"
r"vrmonitor|steamerrorreporter|unitycrashhandler|ue4editor|editor\.exe$)")
PRUNE_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", "site-packages", "_uninstall", "uninstallme",
"redist", "_commonredist", "directx", "support", "prereqs", "__installer", "installers", "vcredist",
"dotnetfx", "$pluginsdir", "__macosx"}
MAX_DEPTH = 8
_OVR_ASCII = (b"LibOVRRT%s_%d.dll", b"LibOVRRT64_1.dll", b"LibOVRRT32_1.dll", b"LibOVRRT%hs_%d.dll",
b"OVR_CAPI", b"ovr_Initialize", b"OculusHMD", b"OculusRift")
# Unreal stores most strings as UTF-16 (TEXT("…")), so look for both encodings
LIBOVR_MARKERS = _OVR_ASCII + tuple(m.decode().encode("utf-16-le") for m in _OVR_ASCII)
PLATFORM_DLLS = ("libovrplatform64_1.dll", "libovrplatform32_1.dll")
# engine DLLs that wrap the Platform SDK (Ready At Dawn's platform services: Lone Echo, Echo Arena)
PLATFORM_WRAPPERS = ("pnsovr.dll",)
GRAPHICS = (("d3d12.dll", "D3D12"), ("d3d11.dll", "D3D11"), ("vulkan-1.dll", "Vulkan"), ("opengl32.dll", "OpenGL"))
AMBIGUITY = 15 # a runner-up within this many points means "ask the user"
class PEError(Exception):
pass
@dataclass
class PEInfo:
machine: str
imports: list[str] = field(default_factory=list) # lower-case DLL names
subsystem: int = 0
delay_imports: list[str] = field(default_factory=list) # DLLs loaded on first use (e.g. the Oculus Platform SDK)
# ------------------------------------------------------------------------------------------ PE reading
def _pe_offsets(data: bytes) -> tuple[int, int, int, int, int]:
if data[:2] != b"MZ" or len(data) < 0x40:
raise PEError("not a PE file")
pe = struct.unpack_from("<I", data, 0x3C)[0]
if data[pe:pe + 4] != b"PE\0\0":
raise PEError("no PE header")
machine, nsec, _, _, _, opt_size, _ = struct.unpack_from("<HHIIIHH", data, pe + 4)
return pe, machine, nsec, opt_size, pe + 24
def pe_header(path: Path) -> PEInfo:
"""Machine + subsystem from the first 4 KiB (cheap: used to filter candidates)."""
with open(path, "rb") as f:
data = f.read(4096)
try:
pe, machine, _, _, opt = _pe_offsets(data)
subsystem = struct.unpack_from("<H", data, opt + 68)[0]
except (struct.error, PEError, IndexError):
raise PEError("not a PE file") from None
return PEInfo(MACHINES.get(machine, hex(machine)), [], subsystem)
def _sections(data: bytes, opt: int, opt_size: int, nsec: int) -> list[tuple[int, int, int]]:
out = []
for k in range(nsec):
s = opt + opt_size + 40 * k
vsize, va, rsize, raw = struct.unpack_from("<IIII", data, s + 8)
out.append((va, max(vsize, rsize), raw))
return out
def _rva_to_off(sections, rva):
for va, size, raw in sections:
if va <= rva < va + size:
return rva - va + raw
return None
def read_pe(path: Path, limit: int = 256 << 20) -> tuple[PEInfo, bytes]:
data = path.read_bytes() if path.stat().st_size <= limit else path.open("rb").read(limit)
pe, machine, nsec, opt_size, opt = _pe_offsets(data)
magic = struct.unpack_from("<H", data, opt)[0]
subsystem = struct.unpack_from("<H", data, opt + 68)[0]
dd = opt + (96 if magic == 0x10B else 112) # data directories (PE32 / PE32+)
imp_rva = struct.unpack_from("<I", data, dd + 8)[0] if opt_size >= dd - opt + 16 else 0
sections = _sections(data, opt, opt_size, nsec)
delay_rva = struct.unpack_from("<I", data, dd + 13 * 8)[0] if opt_size >= dd - opt + 14 * 8 else 0
def names(table_rva, entry_size, name_at):
out = []
p = _rva_to_off(sections, table_rva) if table_rva else None
while p is not None and p + entry_size <= len(data) and len(out) < 512:
name_rva = struct.unpack_from("<I", data, p + name_at)[0]
if not name_rva:
break
q = _rva_to_off(sections, name_rva)
if q is not None:
end = data.find(b"\0", q, q + 256)
out.append(data[q:end].decode("ascii", "replace").lower())
p += entry_size
return out
return PEInfo(MACHINES.get(machine, hex(machine)), names(imp_rva, 20, 12), subsystem,
names(delay_rva, 32, 4)), data
def exe_icon(path: Path, max_bytes: int = 64 << 20) -> bytes | None:
"""The largest icon image in a PE's resources (RT_GROUP_ICON → RT_ICON), as PNG bytes; None if there's none."""
try:
with open(path, "rb") as f:
data = f.read(max_bytes)
pe, machine, nsec, opt_size, opt = _pe_offsets(data)
magic = struct.unpack_from("<H", data, opt)[0]
dd = opt + (96 if magic == 0x10B else 112)
res_rva = struct.unpack_from("<I", data, dd + 16)[0]
sections = _sections(data, opt, opt_size, nsec)
base = _rva_to_off(sections, res_rva) if res_rva else None
if base is None:
return None
def entries(off):
named, ids = struct.unpack_from("<HH", data, off + 12)
for i in range(named + ids):
name, target = struct.unpack_from("<II", data, off + 16 + 8 * i)
yield name, target
def leaves(off): # (id, data offset, size) under a type directory: name → language → data
for name, t in entries(off):
if not t & 0x80000000:
continue
for _, lang in entries(base + (t & 0x7FFFFFFF)):
if lang & 0x80000000:
continue
rva, size = struct.unpack_from("<II", data, base + lang)
o = _rva_to_off(sections, rva)
if o is not None:
yield name & 0x7FFFFFFF, o, size
types = {name: t for name, t in entries(base) if t & 0x80000000}
if 14 not in types or 3 not in types:
return None
icons = {i: (o, s) for i, o, s in leaves(base + (types[3] & 0x7FFFFFFF))}
best = None
for _, o, _ in leaves(base + (types[14] & 0x7FFFFFFF)):
count = struct.unpack_from("<H", data, o + 4)[0]
for k in range(count):
w, h, colors, _, planes, bits, size, icon_id = struct.unpack_from("<BBBBHHIH", data, o + 6 + 14 * k)
area = (w or 256) * (h or 256) * max(bits, 1)
if icon_id in icons and (best is None or area > best[0]):
best = (area, icon_id, w, h, bits)
if not best:
return None
o, size = icons[best[1]]
raw = data[o:o + size]
if raw[:4] == b"\x89PNG":
return raw
# a DIB: wrap it as a one-image .ico and let Pillow decode it
w, h, bits = best[2], best[3], best[4]
ico = struct.pack("<HHH", 0, 1, 1) + struct.pack("<BBBBHHII", w, h, 0, 0, 1, bits, len(raw), 22) + raw
import io
from PIL import Image
with Image.open(io.BytesIO(ico)) as im:
out = io.BytesIO()
im.convert("RGBA").save(out, "PNG")
return out.getvalue()
except Exception: # noqa: BLE001 - any malformed resource: no icon
return None
# ------------------------------------------------------------------------------------------ folder walk
@dataclass
class Tree:
root: Path
exes: list[Path] = field(default_factory=list) # every .exe (unfiltered)
dlls: dict[str, Path] = field(default_factory=dict) # lower-case name -> first path
has_apk: bool = False
size: int = 0
files: int = 0
manifest: dict | None = None
def child_of(self, p: Path) -> str:
rel = p.relative_to(self.root).parts
return rel[0] if len(rel) > 1 else "."
def walk(root: Path, max_depth: int = MAX_DEPTH) -> Tree:
"""One pass over a folder: exes, dll names, apk presence, total size (and an Oculus manifest json if present)."""
root = Path(root)
t = Tree(root)
stack = [(root, 0)]
while stack:
d, depth = stack.pop()
try:
it = os.scandir(d)
except OSError:
continue
with it:
for e in it:
try:
if e.is_dir(follow_symlinks=False):
if depth < max_depth and e.name.lower() not in PRUNE_DIRS:
stack.append((Path(e.path), depth + 1))
continue
name = e.name.lower()
t.files += 1
try:
t.size += e.stat(follow_symlinks=False).st_size
except OSError:
pass
if name.endswith(".exe"):
t.exes.append(Path(e.path))
elif name.endswith(".dll"):
t.dlls.setdefault(name, Path(e.path))
elif name.endswith(".apk"):
t.has_apk = True
elif name.endswith(".json") and depth <= 2 and t.manifest is None and e.stat().st_size < 1 << 20:
t.manifest = _manifest(Path(e.path))
except OSError:
continue
return t
def _manifest(path: Path) -> dict | None:
"""Oculus CoreData manifest (<canonicalName>.json with launchFile)."""
try:
d = json.loads(path.read_text(encoding="utf-8", errors="replace"))
except (OSError, ValueError):
return None
if isinstance(d, dict) and d.get("launchFile") and (d.get("canonicalName") or d.get("appId")):
return d
return None
def candidates(tree: Tree) -> list[Path]:
"""Programs that could start the game: not an installer/helper, a Windows GUI program for x86/x64."""
out = []
for p in tree.exes:
if SKIP_EXE.search(p.name):
continue
try:
h = pe_header(p)
except (PEError, OSError):
continue
if h.machine in ("x86", "x86_64") and h.subsystem == GUI_SUBSYSTEM:
out.append(p)
return out
# ------------------------------------------------------------------------------------------ naming
_TAG_WORDS = r"(?:ARMGDDN|VRP|JF|FFA|Repacks?|Shipping|GOG|CODEX|PLAZA|FitGirl|DODI|ElAmigos|Oculus|Rift|PCVR)"
def clean_title(name: str) -> str:
"""'Asgards Wrath v1.6.0 -ARMGDDN' -> 'Asgards Wrath'; 'Arktika 1 (v1.0.0.7) -VRP' -> 'Arktika 1';
'Vader Immortal - Episode II v2.0.2+236948 Shipping' -> 'Vader Immortal - Episode II'."""
t = re.sub(r"\[[^\]]*\]", " ", name) # [FFA Repacks]
t = re.sub(r"\((?:[^)]*\d[^)]*|[^)]*" + _TAG_WORDS + r"[^)]*)\)", " ", t, flags=re.I) # (v1.0.0.7)
t = re.sub(r"\s-\s*" + _TAG_WORDS + r"\b.*$", " ", t, flags=re.I) # -ARMGDDN, -VRP v76
t = re.sub(r"\bv\d[\w.+\-]*", " ", t, flags=re.I) # v1.6.0, v008, v2.0.2+236948
t = re.sub(r"(?<![\w.])\d+(?:\.\d+){2,}[\w+\-]*", " ", t) # 4.15.20, 21.11.08.358012
t = re.sub(r"\b" + _TAG_WORDS + r"\b", " ", t, flags=re.I)
t = re.sub(r"[_]+", " ", t)
t = re.sub(r"\s+", " ", t).strip(" -_.")
return t or name.strip()
def slug(name: str) -> str:
s = re.sub(r"[^A-Za-z0-9]+", "_", name).strip("_").lower() or "game"
return ("g" + s) if s[0].isdigit() else s
def game_id(folder: Path, manifest: dict | None = None) -> str:
base = (manifest or {}).get("canonicalName") or clean_title(folder.name)
return "rift." + slug(base)
def _norm(s: str) -> str:
return re.sub(r"[^a-z0-9]", "", s.lower())
# ------------------------------------------------------------------------------------------ ranking
def rank_exes(folder: Path, cands: list[Path], manifest: dict | None = None,
dlls: dict[str, Path] | None = None) -> list[dict]:
"""Candidates best-first: {path (relative, /), score, reasons, size, machine}."""
folder = Path(folder)
title = _norm(clean_title(folder.name))
launch = (manifest or {}).get("launchFile", "").replace("\\", "/").lower()
out = []
for p in cands:
rel = p.relative_to(folder).as_posix()
score, reasons = 0, []
stem = p.stem.lower()
if launch and rel.lower().endswith(launch):
score += 1000
reasons.append("named in the Oculus manifest")
if stem.endswith("-win64-shipping") or stem.endswith("-win32-shipping"):
score += 50
reasons.append("Unreal game build")
elif (p.parent / "UnityPlayer.dll").exists() or (p.parent / f"{p.stem}_Data").is_dir():
score += 50
reasons.append("Unity game (next to its data)")
if list(p.parent.glob(f"*/Binaries/Win64/{p.stem}-Win64-Shipping.exe")):
score -= 30
reasons.append("Unreal launcher (starts the real game build)")
n = _norm(p.stem.replace("-Win64-Shipping", ""))
if n and title and (n in title or title in n or n[:4] == title[:4]):
score += 20
reasons.append("name matches the game")
try:
machine = pe_header(p).machine
except (PEError, OSError):
machine = "?"
if machine == "x86_64":
score += 5
reasons.append("64-bit")
parts = [x.lower() for x in p.relative_to(folder).parts[:-1]]
if any("steam" in x for x in parts):
score -= 10
reasons.append("Steam build")
if any(x in ("win7", "dx11") for x in parts):
reasons.append(parts[-1])
size = p.stat().st_size
score += min(10, size // (40 << 20)) - len(parts)
out.append({"path": rel, "score": score, "reasons": reasons, "size": size, "machine": machine})
return sorted(out, key=lambda c: (-c["score"], c["path"]))
def is_ambiguous(ranked: list[dict]) -> bool:
return len(ranked) > 1 and ranked[0]["score"] < 1000 and ranked[1]["score"] >= ranked[0]["score"] - AMBIGUITY
def find_exe(folder: Path) -> Path | None:
"""The best program to start the game (used by callers that only need one)."""
tree = walk(folder)
ranked = rank_exes(folder, candidates(tree), tree.manifest)
return Path(folder) / ranked[0]["path"] if ranked else None
# ------------------------------------------------------------------------------------------ analysis
def fingerprint(folder: Path, exe_rel: str) -> str:
p = Path(folder) / exe_rel
try:
st = p.stat()
return f"{exe_rel}:{st.st_size}:{int(st.st_mtime)}"
except OSError:
return ""
def analyze(folder: Path, exe: str | None = None, tree: Tree | None = None) -> Analysis:
"""exe: a relative path chosen by the user (else the best-ranked candidate)."""
folder = Path(folder)
tree = tree or walk(folder)
manifest = tree.manifest
ranked = rank_exes(folder, candidates(tree), manifest, tree.dlls)
if exe:
chosen = exe
confirmed = True
elif ranked:
chosen = ranked[0]["path"]
confirmed = not is_ambiguous(ranked)
else:
raise PEError(f"no game program found in {folder}")
exe_path = folder / chosen
info, data = read_pe(exe_path)
names = set(tree.dlls)
unity = (exe_path.parent / "UnityPlayer.dll").exists() or (exe_path.parent / f"{exe_path.stem}_Data").is_dir() \
or "unityplayer.dll" in names
unreal = exe_path.name.lower().endswith("-shipping.exe") or "engine" in [x.lower() for x in exe_path.parts]
engine = "Unity" if unity else "Unreal" if unreal else "Other"
blob = data
for extra in ("unityplayer.dll", "ovrplugin.dll"):
p = tree.dlls.get(extra)
if p:
try:
with open(p, "rb") as f:
blob += f.read(64 << 20)
except OSError:
pass
# monolithic engines carry the Oculus code in the exe; modular Unreal builds ship it as a plugin DLL
# (Engine/Plugins/Runtime/OculusRift/Binaries/Win64/<Game>-OculusRift-Win64-Shipping.dll)
libovr = any(m in blob for m in LIBOVR_MARKERS) or \
any(x in n for n in names for x in ("ovrplugin", "oculushmd", "libovr", "oculusrift"))
openxr = "openxr_loader.dll" in info.imports or "openxr_loader.dll" in names or b"xrCreateInstance" in data
# OpenVR/SteamVR: the game ships/links openvr_api. NOTE: an Unreal build carries SteamVR plugin binaries + strings
# whether or not it uses them, so this alone does NOT prove the game runs on SteamVR — it only matters for routing
# when the game has NO Oculus/LibOVR code (see frame_native below). We ignore the (universal in UE) IVRSystem/
# VR_InitInternal string markers for exactly this reason.
openvr = any(d in info.imports or d in names for d in ("openvr_api64.dll", "openvr_api32.dll", "openvr_api.dll"))
xr = "+".join(n for n, on in (("LibOVR", libovr), ("OpenXR", openxr), ("OpenVR", openvr)) if on) or "?"
gfx_imports = set(info.imports)
up = exe_path.parent / "UnityPlayer.dll"
if up.exists():
try:
gfx_imports |= set(read_pe(up, 64 << 20)[0].imports)
except (PEError, OSError, struct.error):
pass
graphics = next((g for dll, g in GRAPHICS if dll in gfx_imports), "D3D11 or unknown")
# the Oculus Platform SDK (licence check, needs the Oculus app): shipped DLL, (delay-)imported by the exe, or by
# a modular Unreal build's OnlineSubsystemOculus plugin DLL
platform_imports = set(info.imports + info.delay_imports)
for n, p in tree.dlls.items():
if "onlinesubsystemoculus" in n:
try:
sub = read_pe(p, 64 << 20)[0]
platform_imports |= set(sub.imports + sub.delay_imports)
except (PEError, OSError, struct.error):
pass
platform_sdk = any(n in names for n in PLATFORM_DLLS + PLATFORM_WRAPPERS) or bool(platform_imports & set(PLATFORM_DLLS)) or \
b"ovr_PlatformInitializeWindows" in blob or b"ovr_Entitlement_GetIsViewerEntitled" in blob
canonical_hint = next((part for part in exe_path.relative_to(folder).parts[:-1]
if re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+){2,}", part)), None) # Oculus Software dir name
title = (manifest or {}).get("displayName") or clean_title(folder.name)
return Analysis(
package=game_id(folder, manifest), version=str((manifest or {}).get("version") or ""), label=title,
abis=[info.machine], engine=engine, xr=xr, graphics=graphics, direct_vrapi=False, libs=sorted(names)[:200],
launcher_activity=None, has_info_category=False, meta_permissions=[], uses_glad_gl=False, unity_msaa_levels=0,
oculus_os_classes=False, is_overport_output=False, debuggable=False,
extra={"kind": "rift", "exe": chosen, "folder": str(folder), "exe_candidates": ranked[:12],
"exe_confirmed": confirmed, "fingerprint": fingerprint(folder, chosen),
"platform_sdk": platform_sdk, "openxr_native": openxr and not libovr,
"openvr_native": openvr and not libovr,
# frame_native = confidently runs on the Frame through wineopenxr (SteamVR), no Revive: only when there
# is NO Oculus/LibOVR code (pure OpenXR or pure OpenVR). A LibOVR game is treated as Oculus (needs
# Revive, PC only) unless a known-good catalog recipe overrides it (e.g. Rick and Morty, verified on
# the Frame) — static analysis can't tell which runtime a dual-API Unreal build picks at runtime.
"frame_native": (openxr or openvr) and not libovr,
"needs_revive": libovr,
"vr_found": xr != "?", "data_bytes": tree.size, "files": tree.files,
# repacks ship a LibRevive64.dll for their own (Virtual Desktop) setup; nothing loads it on its own, so
# this is informational only — the game still needs FramePort's Revive for LibOVR → OpenXR
"revive_bundled": any(n in names for n in ("librevive64.dll", "librevive32.dll", "librevivexr64.dll",
"librevivexr32.dll")),
"oculus_app_id": (manifest or {}).get("appId"),
"canonical_name": (manifest or {}).get("canonicalName") or canonical_hint},
)
def is_rift(analysis: Analysis | dict) -> bool:
extra = analysis.get("extra", {}) if isinstance(analysis, dict) else analysis.extra
return (extra or {}).get("kind") == "rift"
+136
View File
@@ -0,0 +1,136 @@
"""Store details for the game page (and Steam tags): description, genres, developer/publisher, release date, website
and screenshots.
Sources (cached 30 days; exact title matches only, like the artwork):
- OculusDB (Meta/Oculus store data): long description, genres, publisher, website — found by Quest package or by
the Rift app match; covers Quest and Rift games, including Oculus exclusives
- the Steam store (games also sold there): short description, genres, developers, publishers, release date and
screenshots
Meta's own store pages refuse automated requests, so Oculus-exclusive games get no screenshots.
Screenshots are saved as artwork/<pkg>/shot_<n>.jpg; the rest goes into the library entry's `details`.
"""
from __future__ import annotations
import html
import re
import time
import urllib.parse
from ..core import cache
from . import fetch, sources
STEAM_DETAILS = "https://store.steampowered.com/api/appdetails?appids={id}&l=en"
MAX_SHOTS = 6
def _text(s: str | None) -> str:
"""Store HTML/markdown-ish text → plain paragraphs."""
if not s:
return ""
s = re.sub(r"(?i)<br\s*/?>|</p>|</li>|</h\d>", "\n", s)
s = re.sub(r"<[^>]+>", "", s)
s = html.unescape(s)
s = re.sub(r"[ \t]+", " ", s)
return re.sub(r"\n\s*\n+", "\n\n", s).strip()
def oculusdb_app(entry: dict) -> dict | None:
"""The OculusDB record of this game: by Quest package, else by the Rift match."""
pkg = entry.get("quest_package") if entry.get("kind") == "rift" else entry["package"]
if pkg and "." in pkg and not pkg.startswith("rift."):
for r in sources.oculusdb_search(pkg, rift_only=False):
if r.get("packageName") == pkg:
return r
if entry.get("kind") == "rift":
extra = (entry.get("analysis") or {}).get("extra") or {}
return sources.match_rift(entry.get("title") or "", extra.get("canonical_name"))
return None
def steam_app(title: str) -> dict | None:
hit = sources.match_steam(title)
if not hit:
return None
data = cache.cached_json(f"steamapp-{hit['id']}.json", STEAM_DETAILS.format(id=hit["id"]), max_age=sources.MAX_AGE,
fallback={})
d = (data or {}).get(str(hit["id"])) or {}
return d.get("data") if d.get("success") else None
def fetch_details(entry: dict, screenshots: bool = True) -> dict:
"""Collect details for a library entry (doesn't store them; see pipeline.fetch_details)."""
title = entry.get("title") or entry["package"]
out: dict = {"sources": [], "fetched": time.time()}
try:
app = oculusdb_app(entry)
except Exception: # noqa: BLE001 - offline
app = None
if app:
out["sources"].append("oculusdb")
out["description"] = _text(app.get("display_long_description"))
out["genres"] = [g for g in app.get("genre_names") or [] if g]
if app.get("publisher_name"):
out["publisher"] = app["publisher_name"]
if app.get("website_url"):
out["website"] = app["website_url"]
if app.get("id"):
out["store_url"] = f"https://www.meta.com/experiences/{app['id']}/"
try:
st = steam_app(title)
except Exception: # noqa: BLE001
st = None
if st:
out["sources"].append("steam")
out["steam_appid"] = st.get("steam_appid")
out["short"] = _text(st.get("short_description"))
if not out.get("description"):
out["description"] = _text(st.get("about_the_game") or st.get("detailed_description"))
out["genres"] = list(dict.fromkeys((out.get("genres") or []) +
[g["description"] for g in st.get("genres") or []]))
out.setdefault("developer", ", ".join(st.get("developers") or []) or None)
out.setdefault("publisher", ", ".join(st.get("publishers") or []) or None)
rd = (st.get("release_date") or {}).get("date")
if rd:
out["release_date"] = rd
if st.get("website"):
out.setdefault("website", st["website"])
out["store_url_steam"] = f"https://store.steampowered.com/app/{st.get('steam_appid')}/"
if screenshots:
out["screenshots"] = save_screenshots(entry["package"], [s.get("path_full") for s in
st.get("screenshots") or []])
return {k: v for k, v in out.items() if v not in (None, "", [])}
def save_screenshots(package: str, urls: list[str]) -> list[str]:
d = fetch.artwork_dir(package)
saved = []
for i, url in enumerate([u for u in urls if u][:MAX_SHOTS], 1):
path = d / f"shot_{i}.jpg"
if not path.exists():
try:
r = cache.http_get(url, timeout=30)
if r.status_code != 200 or len(r.content) < 1000:
continue
path.write_bytes(r.content)
except Exception: # noqa: BLE001
continue
saved.append(path.name)
for old in d.glob("shot_*.jpg"):
if old.name not in saved:
old.unlink()
return saved
def screenshot_files(package: str) -> list:
return sorted(fetch.artwork_dir(package).glob("shot_*.jpg"), key=lambda p: int(re.sub(r"\D", "", p.stem) or 0))
def store_links(details: dict) -> list[tuple[str, str]]:
out = []
if details.get("store_url"):
out.append(("Meta store", details["store_url"]))
if details.get("store_url_steam"):
out.append(("Steam store", details["store_url_steam"]))
if details.get("website"):
out.append((urllib.parse.urlparse(details["website"]).netloc or "Website", details["website"]))
return out
+9 -5
View File
@@ -20,6 +20,7 @@ KINDS = {
"APP_IMG_LOGO_TRANSPARENT": "logo",
"APP_IMG_ICON": "icon",
}
EXTRA_KINDS = ("square",) # OculusDB's square cover (Rift games)
def artwork_dir(package: str) -> Path:
@@ -32,14 +33,17 @@ def _ext(data: bytes) -> str:
return ".png" if data[:4] == b"\x89PNG" else ".jpg"
def fetch(package: str, apk: Path | None = None, refresh: bool = False) -> tuple[Path, str | None]:
"""Returns (folder with <kind>.<ext> files, store display name or None)."""
def fetch(package: str, apk: Path | None = None, refresh: bool = False,
lookup: str | None = None) -> tuple[Path, str | None]:
"""Returns (folder with <kind>.<ext> files, store display name or None). `lookup` is the package whose store art
to use (a Rift game borrows its Quest version's art); Rift ids without one have no store art."""
out = artwork_dir(package)
have = {p.stem for p in out.iterdir()}
title = None
if refresh or not {"portrait", "landscape", "hero", "icon"} <= have:
lookup = lookup or (None if package.startswith("rift.") else package)
if lookup and (refresh or not {"portrait", "landscape", "hero", "icon"} <= have):
try:
data = cache.http_get(API.format(package=package), timeout=30).json()
data = cache.http_get(API.format(package=lookup), timeout=30).json()
except Exception:
data = {}
if data.get("status") == "ok":
@@ -81,4 +85,4 @@ def apk_icon(apk: Path) -> bytes | None:
def files(package: str) -> list[Path]:
return sorted(p for p in artwork_dir(package).iterdir() if p.stem in KINDS.values())
return sorted(p for p in artwork_dir(package).iterdir() if p.stem in KINDS.values() or p.stem in EXTRA_KINDS)
+238
View File
@@ -0,0 +1,238 @@
"""Find artwork for games that the Meta art service (by Android package) doesn't cover — Oculus Rift games.
Order (all lookups cached for 30 days; only exact title matches are used, so no wrong art is picked):
1. the Quest version's package (catalog, library, or OculusDB's packageName) → the full Meta art set (fetch.fetch)
2. OculusDB (community database of the Oculus store): the Rift app's square cover
3. the Steam store (games also sold on Steam): portrait, hero, landscape and logo
4. the game's own program icon
"""
from __future__ import annotations
import re
import urllib.parse
from pathlib import Path
from ..core import cache
from . import fetch
OCULUSDB = "https://oculusdb.rui2015.me"
STEAM_SEARCH = "https://store.steampowered.com/api/storesearch/?term={term}&cc=us&l=en"
STEAM_CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps/{id}/{file}"
STEAM_FILES = {"portrait": "library_600x900.jpg", "hero": "library_hero.jpg", "landscape": "header.jpg",
"logo": "logo.png"}
MAX_AGE = 30 * 86400
ROMAN = {"i": "1", "ii": "2", "iii": "3", "iv": "4", "v": "5", "vi": "6", "vii": "7", "viii": "8", "ix": "9", "x": "10"}
QUEST_SUFFIXES = {"", "unplugged", "quest", "questedition", "forquest", "vr"}
def norm(s: str) -> str:
"""Comparable title: lower-case letters and digits only, roman numerals as digits ('Lone Echo II' = 'Lone Echo 2',
"Asgard's Wrath" = 'Asgards Wrath')."""
words = re.findall(r"[a-z0-9]+", (s or "").lower().replace("'", ""))
return "".join(ROMAN.get(w, w) if i and w in ROMAN else w for i, w in enumerate(words))
def search_terms(title: str) -> list[str]:
"""The title, then shorter forms (OculusDB's search doesn't match "Asgards" to "Asgard's")."""
words = re.sub(r"[^\w\s]", " ", title).split()
terms = [title]
for n in (3, 2, 1):
if len(words) > n and len(" ".join(words[:n])) >= 4:
terms.append(" ".join(words[:n]))
if words and len(words[0]) >= 5 and words[0].lower().endswith("s"):
terms.append(words[0][:-1]) # "Asgards" -> "Asgard", "Wilsons" -> "Wilson"
return list(dict.fromkeys(terms))
def _key(term: str) -> str:
return re.sub(r"[^a-z0-9]+", "_", term.lower())[:80]
def oculusdb_search(term: str, rift_only: bool = True) -> list[dict]:
q = urllib.parse.quote(term)
url = f"{OCULUSDB}/api/v1/search/{q}" + ("?headsets=RIFT" if rift_only else "")
res = cache.cached_json(f"oculusdb-{'rift' if rift_only else 'all'}-{_key(term)}.json", url, max_age=MAX_AGE,
fallback=[])
return [r for r in res or [] if isinstance(r, dict) and r.get("__OculusDBType", "Application") == "Application"]
def steam_search(term: str) -> list[dict]:
res = cache.cached_json(f"steamsearch-{_key(term)}.json", STEAM_SEARCH.format(term=urllib.parse.quote(term)),
max_age=MAX_AGE, fallback={})
return (res or {}).get("items") or []
def match_rift(title: str, canonical: str | None = None) -> dict | None:
"""The OculusDB Rift app for this game (exact normalized name or canonicalName match)."""
want = norm(title)
terms = ([canonical.replace("-", " ")] if canonical else []) + search_terms(title)
for term in terms:
for r in oculusdb_search(term):
names = {norm(r.get("appName") or ""), norm(r.get("displayName") or "")}
if (canonical and r.get("canonicalName") == canonical) or want in names:
return r
return None
def match_quest(title: str) -> str | None:
"""Package of the game's Quest version on the Meta store (same name, or the name plus a short suffix such as
'Unplugged'), from OculusDB."""
want = norm(title)
if len(want) < 4:
return None
for term in search_terms(title)[:2]:
for r in oculusdb_search(term, rift_only=False):
pkg = r.get("packageName")
n = norm(r.get("appName") or r.get("displayName") or "")
# same name, or the name plus a platform word ("Robo Recall: Unplugged") — never a sequel/other game
if pkg and n.startswith(want) and n[len(want):] in QUEST_SUFFIXES:
return pkg
return None
def match_steam(title: str) -> dict | None:
want = norm(title)
return next((i for i in steam_search(title) if norm(i.get("name") or "") == want), None)
def _save(package: str, kind: str, data: bytes) -> None:
if not data or len(data) < 200:
return
ext = ".png" if data[:4] == b"\x89PNG" else ".webp" if data[8:12] == b"WEBP" else ".jpg"
d = fetch.artwork_dir(package)
for old in d.glob(f"{kind}.*"):
old.unlink()
(d / f"{kind}{ext}").write_bytes(data)
def _get(url: str) -> bytes | None:
try:
r = cache.http_get(url, timeout=30)
return r.content if r.status_code == 200 else None
except Exception: # noqa: BLE001
return None
def apply_oculusdb(package: str, app: dict) -> bool:
link = app.get("imageLink") or (f"/cdn/images/{app['id']}" if app.get("id") else None)
data = _get(OCULUSDB + link) if link else None
if data:
_save(package, "square", data)
return bool(data)
def apply_steam(package: str, appid: int | str) -> bool:
got = False
for kind, file in STEAM_FILES.items():
data = _get(STEAM_CDN.format(id=appid, file=file))
if data:
_save(package, kind, data)
got = True
return got
def has_art(package: str) -> bool:
stems = {p.stem for p in fetch.files(package)}
return bool(stems & {"portrait", "square", "landscape", "hero"})
def fetch_rift(package: str, title: str, canonical: str | None = None, quest_package: str | None = None,
exe: Path | None = None) -> dict:
"""Find and store artwork for a Rift game. Returns what was found: {source, quest_package, oculus_app_id,
canonical_name, steam_appid} (only the keys that apply)."""
found: dict = {}
app = None
try:
app = match_rift(title, canonical)
except Exception: # noqa: BLE001 - offline: fall through to local sources
app = None
if app:
found.update(oculus_app_id=app.get("id"), canonical_name=app.get("canonicalName"),
title=app.get("displayName") or app.get("appName"))
if not quest_package:
try:
quest_package = match_quest(found.get("title") or title)
except Exception: # noqa: BLE001
quest_package = None
if quest_package:
found["quest_package"] = quest_package
fetch.fetch(package, lookup=quest_package)
if has_art(package):
found["source"] = "meta"
if app and not found.get("source") and apply_oculusdb(package, app):
found["source"] = "oculusdb"
if found.get("source") != "meta":
try:
st = match_steam(found.get("title") or title)
except Exception: # noqa: BLE001
st = None
if st and apply_steam(package, st["id"]):
found["steam_appid"] = st["id"]
found["source"] = found.get("source") or "steam"
if not has_art(package) and exe is not None:
from ..analysis.rift import exe_icon
icon = exe_icon(exe)
if icon:
_save(package, "icon", icon)
found["source"] = "icon"
return found
def search(term: str) -> list[dict]:
"""Candidates for the artwork picker: [{source, name, preview (URL), id|package}]."""
out = []
try:
for r in oculusdb_search(term, rift_only=False)[:12]:
link = r.get("imageLink") or (f"/cdn/images/{r['id']}" if r.get("id") else None)
if link:
out.append({"source": "Meta (Quest)" if r.get("packageName") else "Oculus Rift",
"name": r.get("displayName") or r.get("appName"), "preview": OCULUSDB + link,
"id": r.get("id"), "package": r.get("packageName"), "app": r})
except Exception: # noqa: BLE001
pass
try:
for i in steam_search(term)[:8]:
out.append({"source": "Steam", "name": i.get("name"), "preview": STEAM_CDN.format(id=i["id"],
file="header.jpg"), "id": i["id"]})
except Exception: # noqa: BLE001
pass
return out
ART_STEMS = frozenset(fetch.KINDS.values()) | frozenset(fetch.EXTRA_KINDS)
def apply_choice(package: str, choice: dict) -> bool:
"""Store the artwork of a picker result, replacing the current art. It's downloaded into a staging folder first:
when the result yields no cover (dead store image, no art for that package) the current art stays and this
returns False. Screenshots and the stored title are kept either way."""
import shutil
tmp = f".pick.{package}"
stage = fetch.artwork_dir(tmp)
shutil.rmtree(stage, ignore_errors=True)
stage = fetch.artwork_dir(tmp)
try:
if choice["source"] == "Steam":
apply_steam(tmp, choice["id"])
elif choice.get("package"):
fetch.fetch(tmp, lookup=choice["package"], refresh=True)
if not has_art(tmp):
apply_oculusdb(tmp, choice["app"])
else:
apply_oculusdb(tmp, choice["app"])
if not has_art(tmp):
return False
d = fetch.artwork_dir(package)
for f in d.iterdir(): # the old art and its thumbnails (screenshot thumbnails are t_shot_*)
if f.is_file() and (f.stem in ART_STEMS or f.name.startswith("t_") and not f.name.startswith("t_shot_")):
f.unlink()
for f in stage.iterdir():
if f.is_file() and f.stem in ART_STEMS:
f.replace(d / f.name)
return True
finally:
shutil.rmtree(stage, ignore_errors=True)
+129
View File
@@ -0,0 +1,129 @@
"""A complete Steam library art set for a shortcut, composed from whatever artwork a game has.
Steam wants: portrait 600×900 (library grid), landscape 920×430 (recent/horizontal), hero 1920×620 (game page banner),
logo (transparent, drawn on the hero) and an icon. Store art rarely covers all of them — Rift games from OculusDB only
have a square cover — so missing shapes are composed: the best source image, fitted and centred over a blurred,
darkened copy of itself (or a screenshot, for the hero). Results are cached in artwork/<pkg>/steam/.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from . import fetch
SIZES = {"portrait": (600, 900), "landscape": (920, 430), "hero": (1920, 620)}
# preferred sources per Steam shape, best first ("shot" = first screenshot)
PREFER = {"portrait": ("portrait", "square", "icon", "landscape"),
"landscape": ("landscape", "hero", "shot", "square", "portrait"),
"hero": ("hero", "shot", "landscape", "square", "portrait"),
"icon": ("icon", "square", "portrait")}
def _sources(package: str) -> dict[str, Path]:
files = {p.stem: p for p in fetch.files(package)}
shots = sorted(fetch.artwork_dir(package).glob("shot_*.jpg"))
if shots:
files["shot"] = shots[0]
return files
def _fill(im, size):
"""Scale to cover `size` and centre-crop."""
from PIL import Image
w, h = size
s = max(w / im.width, h / im.height)
im = im.resize((max(1, round(im.width * s)), max(1, round(im.height * s))), Image.LANCZOS)
left, top = (im.width - w) // 2, (im.height - h) // 2
return im.crop((left, top, left + w, top + h))
def _compose(src: Path, size, exact: bool):
"""exact: the source already has (about) the right shape → cover-fit; otherwise fit over a blurred backdrop."""
from PIL import Image, ImageEnhance, ImageFilter
with Image.open(src) as im:
im = im.convert("RGB")
w, h = size
if exact:
return _fill(im, size)
bg = _fill(im, size).filter(ImageFilter.GaussianBlur(radius=max(w, h) // 30))
bg = ImageEnhance.Brightness(bg).enhance(0.45)
s = min(w / im.width, h / im.height) * 0.94
fg = im.resize((max(1, round(im.width * s)), max(1, round(im.height * s))), Image.LANCZOS)
bg.paste(fg, ((w - fg.width) // 2, (h - fg.height) // 2))
return bg
def _ratio_ok(src: Path, size) -> bool:
from PIL import Image
with Image.open(src) as im:
r = im.width / im.height
target = size[0] / size[1]
return abs(r - target) / target < 0.18
def steam_set(package: str) -> dict[str, Path]:
"""{portrait, landscape, hero, logo?, icon?} files ready for Steam (created once per source change)."""
src = _sources(package)
if not src:
return {}
out_dir = fetch.artwork_dir(package) / "steam"
out_dir.mkdir(exist_ok=True)
key = hashlib.sha1("|".join(f"{k}:{p.stat().st_size}:{p.stat().st_mtime_ns}" for k, p in sorted(src.items()))
.encode()).hexdigest()[:8]
stamp = out_dir / f".{key}"
result: dict[str, Path] = {}
if stamp.exists():
for p in out_dir.iterdir():
if not p.name.startswith("."):
result[p.stem] = p
return result
for p in out_dir.iterdir():
p.unlink()
for kind, size in SIZES.items():
choice = next((k for k in PREFER[kind] if k in src), None)
if not choice:
continue
try:
# a screenshot always fills the wide banner; other sources only when their shape is close enough
exact = _ratio_ok(src[choice], size) or (kind == "hero" and choice == "shot")
img = _compose(src[choice], size, exact)
path = out_dir / f"{kind}.jpg"
img.save(path, "JPEG", quality=90, optimize=True)
result[kind] = path
except Exception: # noqa: BLE001 - unreadable source: skip that shape
continue
if "logo" in src:
result["logo"] = out_dir / f"logo{src['logo'].suffix}"
result["logo"].write_bytes(src["logo"].read_bytes())
icon = next((src[k] for k in PREFER["icon"] if k in src), None)
if icon:
try:
from PIL import Image
with Image.open(icon) as im:
im = _fill(im.convert("RGBA"), (256, 256))
path = out_dir / "icon.png"
im.save(path, "PNG")
result["icon"] = path
except Exception: # noqa: BLE001
pass
stamp.write_text("")
return result
def original_platform(entry: dict) -> str:
return "Oculus Rift" if entry.get("kind") == "rift" else "Meta Quest"
def steam_tags(entry: dict, where: str = "frame") -> list[str]:
"""Tags for the Steam shortcut (Steam can group them into collections): how it runs, the game's original platform,
genres and the user's own tags."""
runs = ("PC VR on Frame" if entry.get("kind") == "rift" else "Quest on Frame") if where == "frame" else \
"Rift via Revive"
base = [runs, original_platform(entry)]
genres = ((entry.get("details") or {}).get("genres") or [])[:4]
return list(dict.fromkeys(base + genres + list(entry.get("tags") or [])))
+84
View File
@@ -0,0 +1,84 @@
"""Small cached thumbnails of the artwork, served to the GUI by URL (the GUI's assets folder is the user data dir).
Full-size store art is 0.3–2 MB per image; sending it as bytes on every render froze the app. Thumbnails are JPEGs named
after their source's content hash (t_<kind>_<width>_<sha8>.jpg), so the client cache stays correct when art changes.
"""
from __future__ import annotations
import hashlib
import threading
from pathlib import Path
from ..core.paths import user_data_dir
from . import fetch
WIDTHS = {"portrait": 400, "square": 400, "landscape": 1280, "hero": 1280, "icon": 96, "logo": 600}
_locks: dict[str, threading.Lock] = {}
_guard = threading.Lock()
def _lock(key: str) -> threading.Lock:
with _guard:
return _locks.setdefault(key, threading.Lock())
def _digest(path: Path) -> str:
st = path.stat()
return hashlib.sha1(f"{path.name}:{st.st_size}:{st.st_mtime_ns}".encode()).hexdigest()[:8]
def thumb(path: Path, width: int, kind: str | None = None) -> Path | None:
"""A JPEG (PNG for logos, which need transparency) at most `width` px wide; created once."""
kind = kind or path.stem
ext = ".png" if kind == "logo" else ".jpg"
out = path.parent / f"t_{kind}_{width}_{_digest(path)}{ext}"
if out.exists():
return out
with _lock(str(out)):
if out.exists():
return out
try:
from PIL import Image
with Image.open(path) as im:
im.load()
if im.width > width:
im = im.resize((width, max(1, round(im.height * width / im.width))), Image.LANCZOS)
for old in path.parent.glob(f"t_{kind}_{width}_*{ext}"):
old.unlink(missing_ok=True)
tmp = out.with_suffix(out.suffix + ".tmp")
if ext == ".png":
im.save(tmp, "PNG", optimize=True)
else:
im.convert("RGB").save(tmp, "JPEG", quality=86, optimize=True, progressive=True)
tmp.replace(out)
except Exception: # unreadable/odd image: use the original
return path
return out
def pick(package: str, kinds: tuple[str, ...]) -> Path | None:
files = {p.stem: p for p in fetch.files(package)}
return next((files[k] for k in kinds if k in files), None)
def url(package: str, kinds: tuple[str, ...], width: int | None = None) -> str | None:
"""Asset URL (for ft.Image / DecorationImage src) of the first available kind, as a thumbnail."""
src = pick(package, kinds)
if not src:
return None
t = thumb(src, width or WIDTHS.get(src.stem, 600), src.stem)
return asset_url(t)
def asset_url(path: Path) -> str:
return "/" + path.relative_to(user_data_dir()).as_posix()
def prewarm(package: str) -> None:
"""Create the thumbnails the GUI uses (cards, hero, icons) ahead of time (called after fetching art)."""
for kinds, width in ((("portrait", "square", "icon"), 400), (("hero", "landscape", "portrait", "square"), 1280),
(("icon", "square", "portrait"), 96)):
src = pick(package, kinds)
if src:
thumb(src, width, src.stem)
+121 -16
View File
@@ -8,6 +8,9 @@
frameport frame discover | pair | info --frame steamos@frame.local
frameport install <pkg> --frame steamos@frame.local [--apk-only]
frameport test <pkg> --frame ... # headless launch + triage
frameport frame proton [--install] # Proton on the Frame, for Oculus Rift (PC VR) games
frameport install rift.<game> --to pc|frame # Rift games: this PC (Revive + Steam) or the Frame (Proton)
frameport pc info # Windows Steam / SteamVR / Revive on this PC
frameport parity --known-good <PATCHED dir> # rebuild everything and diff against known-good APKs
"""
from __future__ import annotations
@@ -27,11 +30,19 @@ from .patches import base
app = typer.Typer(add_completion=False, no_args_is_help=True, help="Port Meta Quest games to the Steam Frame.")
tools_app = typer.Typer(help="Manage the portable toolchain.")
frame_app = typer.Typer(help="Find and connect to a Steam Frame.")
pc_app = typer.Typer(help="This PC as a target for Oculus Rift (PC VR) games via Revive.")
app.add_typer(tools_app, name="tools")
app.add_typer(frame_app, name="frame")
app.add_typer(pc_app, name="pc")
def _target(frame: Optional[str], password: Optional[str] = None):
def _target(frame: Optional[str], password: Optional[str] = None, to: str = "frame"):
if to == "pc":
from .targets.pc_revive import PcReviveTarget
return PcReviveTarget()
if to != "frame":
raise typer.BadParameter("--to must be 'frame' or 'pc'")
from .frame.connection import parse_target, saved_targets
from .targets.frame_lepton import FrameLeptonTarget
@@ -69,11 +80,14 @@ def tools_status(check_latest: bool = typer.Option(False, "--latest", help="also
@tools_app.command("install")
def tools_install(update: bool = typer.Option(False, help="update to the newest versions")):
def tools_install(update: bool = typer.Option(False, help="update to the newest versions"),
revive: bool = typer.Option(False, help="also install Revive (for Oculus Rift games)")):
from .tools import overport as ov
from .tools import toolchain
for s in toolchain.ensure_all(update=update):
for s in toolchain.ensure_all(update=update, optional=revive):
if s.optional and not s.installed:
continue
typer.echo(f"{s.name:10} {s.version} {s.path}")
from .patches import overport as op
@@ -110,7 +124,8 @@ def list_games():
@app.command()
def show(package: str, as_json: bool = typer.Option(False, "--json")):
def show(package: str, as_json: bool = typer.Option(False, "--json"),
all_: bool = typer.Option(False, "--all", help="also list patches that don't apply to this game")):
[pkg] = _pkgs(package, False)
g = library.game(pkg)
if as_json:
@@ -120,10 +135,16 @@ def show(package: str, as_json: bool = typer.Option(False, "--json")):
typer.echo(f"{g.get('title')} ({pkg} {a['version']})\n engine {a['engine']}, XR {a['xr']}, {a['graphics']}, "
f"ABIs {', '.join(a['abis'])}, direct VrApi: {a['direct_vrapi']}")
typer.echo(f" status: {r['status']} recipe source: {r['source']} {r['notes']}")
for pid, params in r["patches"].items():
p = base.get(pid)
typer.echo(f" [x] {pid:36} {p.title}{' ' + json.dumps(params) if params else ''}"
f"{' — ' + r['reasons'][pid] if pid in r.get('reasons', {}) else ''}")
from .recommend.engine import visible_patches
shown, hidden = visible_patches(library.analysis_from_dict(a), library.recipe_from_dict(r))
for p in (shown + hidden if all_ else shown):
on = p.id in r["patches"]
params = r["patches"].get(p.id) or {}
typer.echo(f" [{'x' if on else ' '}] {p.id:36} {p.title}{' ' + json.dumps(params) if params else ''}"
f"{' — ' + r['reasons'][p.id] if on and p.id in r.get('reasons', {}) else ''}")
if hidden and not all_:
typer.echo(f" ({len(hidden)} patches hidden as not relevant for this game; --all to list them)")
if r.get("alt_patches"):
typer.echo(f" alternate build adds: {', '.join(r['alt_patches'])} (installed: {'alt' if r['use_alt'] else 'primary'})")
@@ -139,7 +160,9 @@ def patches():
@app.command()
def recipe(package: str, enable: list[str] = typer.Option([], "--enable"), disable: list[str] = typer.Option([], "--disable"),
set_: list[str] = typer.Option([], "--set", help="adapter setting key=value"),
use_alt: Optional[bool] = typer.Option(None, "--use-alt/--no-alt"), reset: bool = False):
use_alt: Optional[bool] = typer.Option(None, "--use-alt/--no-alt"), reset: bool = False,
as_is: Optional[bool] = typer.Option(None, "--as-is/--patch", help="install unchanged (already patched)"),
exe: Optional[str] = typer.Option(None, help="Rift games: the program that starts the game (relative)")):
"""Change a game's patch selection."""
[pkg] = _pkgs(package, False)
r = pipeline.reset_recipe(pkg) if reset else library.recipe_from_dict(library.game(pkg)["recipe"])
@@ -154,7 +177,14 @@ def recipe(package: str, enable: list[str] = typer.Option([], "--enable"), disab
r.patches[f"adapter.{k}"] = {"value": float(v) if "." in v else int(v)}
if use_alt is not None:
r.use_alt = use_alt
r.source = "user" if (enable or disable or set_ or use_alt is not None) else r.source
if as_is is not None:
r.as_is = as_is
if library.game(pkg).get("kind") == "rift":
r.patches.pop("pcvr.revive", None) if as_is else r.patches.setdefault("pcvr.revive", {})
if exe:
pipeline.set_exe(pkg, exe)
r = library.recipe_from_dict(library.game(pkg)["recipe"])
r.source = "user" if (enable or disable or set_ or use_alt is not None or as_is is not None) else r.source
pipeline.set_recipe(pkg, r)
show(pkg)
@@ -169,7 +199,7 @@ def build(package: Optional[str] = typer.Argument(None), all_: bool = typer.Opti
rep = printing_reporter(verbose)
try:
info = pipeline.build_game(pkg, rep, outdir / pkg if outdir and all_ else outdir)
typer.echo(f"{pkg}: {'OK' if info['ok'] else 'CHECKS FAILED'} -> {info['apk']}")
typer.echo(f"{pkg}: {'OK' if info['ok'] else 'CHECKS FAILED'} -> {info.get('apk') or 'ready (Rift game)'}")
if not info["ok"]:
failed.append(pkg)
except Exception as exc: # noqa: BLE001
@@ -182,8 +212,9 @@ def build(package: Optional[str] = typer.Argument(None), all_: bool = typer.Opti
def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Option(False, "--all"),
frame: Optional[str] = typer.Option(None, help="steamos@host"), password: Optional[str] = None,
apk_only: bool = typer.Option(False, help="reuse game data already on the Frame"),
no_library: bool = typer.Option(False, help="don't add to the Steam library now")):
target = _target(frame, password)
no_library: bool = typer.Option(False, help="don't add to the Steam library now"),
to: str = typer.Option("frame", help="frame, or pc (Oculus Rift games only: run on this PC via Revive)")):
target = _target(frame, password, to)
pkgs = _pkgs(package, all_)
for pkg in pkgs:
pipeline.install_game(pkg, target, printing_reporter(False), apk_only, add_to_library=False)
@@ -193,9 +224,10 @@ def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Op
@app.command()
def test(package: Optional[str] = typer.Argument(None), all_: bool = typer.Option(False, "--all"),
frame: Optional[str] = None, seconds: int = 45):
"""Headless launch on the Frame + log triage."""
target = _target(frame)
frame: Optional[str] = None, seconds: int = 45,
to: str = typer.Option("frame", help="frame, or pc (Rift games installed on this PC)")):
"""Headless launch on the Frame (or this PC) + log triage."""
target = _target(frame, to=to)
installed = {g["package"] for g in target.installed()}
for pkg in _pkgs(package, all_):
if pkg not in installed:
@@ -283,6 +315,53 @@ def frame_info(frame: Optional[str] = None):
typer.echo(json.dumps(_target(frame).describe(), indent=1, default=str))
@frame_app.command("proton")
def frame_proton(frame: Optional[str] = None,
install_: bool = typer.Option(False, "--install", help="install it (Steam on the Frame restarts once "
"and downloads it; waits until done)"),
in_headset: bool = typer.Option(False, help="with --install: only ask Steam (confirm in the headset)"),
test: bool = typer.Option(False, "--test", help="run a Windows program under Proton on the Frame"),
tool: Optional[str] = typer.Option(None, help="compat tool name, e.g. proton-experimental-arm64")):
"""Proton (ARM64) on the Frame, needed for Oculus Rift (PC VR) games."""
t = _target(frame)
if test:
r = t.proton_selftest(tool)
r.pop("log_tail", None)
typer.echo(json.dumps(r, indent=1))
raise typer.Exit(0 if r.get("ran") else 1)
if install_ and in_headset:
typer.echo(json.dumps(t.install_proton(False, tool), indent=1))
return
if install_:
from .install.installer import ensure_proton
ready = ensure_proton(t.frame, printing_reporter(False), tool)
typer.echo(f"ready: {ready['display_name']}")
return
st = t.proton_status(tool)
for p in st["tools"]:
state = "installed" if p["installed"] and p["require_installed"] else \
"needs runtime" if p["installed"] else "not installed"
typer.echo(f"{p['name']:28} {p['display_name']:32} {state}")
typer.echo(f"ready: {st['ready']['name'] if st.get('ready') else 'no (frameport frame proton --install)'}")
typer.echo(f"OpenXR runtime: {(st.get('openxr') or {}).get('name') or 'none'}")
# ------------------------------------------------------------------------------------------ pc (Revive)
@pc_app.command("info")
def pc_info():
typer.echo(json.dumps(_target(None, to="pc").describe(), indent=1, default=str))
@pc_app.command("install-revive")
def pc_install_revive():
"""Download Revive's latest release and unpack it into FramePort's tools (no installer, no admin rights)."""
from .tools import revive
path = revive.install(lambda f: None)
typer.echo(f"Revive {revive.installed_version()} at {path}")
# ------------------------------------------------------------------------------------------ parity
@app.command()
def parity(known_good: Path = typer.Option(..., help="folder of known-good game folders (PATCHED layout)"),
@@ -316,6 +395,32 @@ def parity_device(results: Path = typer.Option(Path("parity-out/parity.json")),
raise typer.Exit(0 if ok else 1)
@app.command("uninstall-app")
def uninstall_app(frame: bool = typer.Option(False, help="also remove FramePort's games and files from the Frame"),
keep_frame_saves: bool = typer.Option(True, help="with --frame: keep game saves on the Frame"),
backup_keys: Optional[Path] = typer.Option(None, help="folder for a zip of the signing keys "
"(default: your Documents folder)"),
backup: bool = typer.Option(True, "--backup/--no-backup", help="back up the signing keys first"),
yes: bool = typer.Option(False, "--yes", "-y", help="don't ask for confirmation")):
"""Remove everything FramePort created on this PC (and optionally on the Frame). Then delete the app itself."""
from . import uninstall as un
target = _target(None) if frame else None
info = target.describe() if target else None
pl = un.plan(info)
typer.echo("This removes:")
for it in pl.items:
typer.echo(f" - {it.what}" + (f" ({it.path})" if it.path else "") +
(f" {it.size / 2**30:.1f} GiB" if it.size > 2**28 else ""))
dest = (backup_keys or un.default_backup_dir()) if backup else None
typer.echo(f"Signing keys: {len(pl.keys)} " + ("(not backed up!)" if dest is None else f"→ backup zip in {dest}"))
if not yes and not typer.confirm("Uninstall FramePort?", default=False):
raise typer.Exit(1)
out = un.run(printing_reporter(False), target.frame if target else None, keep_frame_saves, dest, frame)
typer.echo("Done." + (f" Keys backup: {out['backup']}" if out.get("backup") else "") +
" Delete the FramePort program folder to finish (or `uv tool uninstall frameport`).")
def main(): # pragma: no cover
app()
+96 -2
View File
@@ -19,9 +19,91 @@ def _path() -> Path:
def load() -> dict:
try:
return json.loads(_path().read_text(encoding="utf-8"))
data = json.loads(_path().read_text(encoding="utf-8"))
except (OSError, ValueError):
return {"games": {}, "settings": {}}
if _migrate(data):
save(data)
return data
def _migrate(data: dict) -> bool:
"""One-time updates of stored recipes when a new default is introduced (each runs once per library)."""
done = data.setdefault("settings", {}).setdefault("migrations", [])
changed = False
if "no_crash_reporter" not in done:
# the Unreal crash reporter is disabled by default for PC VR Unreal games, also those added before
for g in (data.get("games") or {}).values():
a, r = g.get("analysis") or {}, g.get("recipe")
if isinstance(r, dict) and (a.get("extra") or {}).get("kind") == "rift" and a.get("engine") == "Unreal":
r.setdefault("patches", {}).setdefault("pcvr.no_crash_reporter", {})
r.setdefault("reasons", {}).setdefault(
"pcvr.no_crash_reporter", "Unreal game: close on a crash instead of showing the crash reporter.")
done.append("no_crash_reporter")
changed = True
if "xr_compat_layer" not in done:
# the Frame OpenXR layer is needed by every PC VR game on the Frame (OpenXR 1.1 → 1.0 fallback)
for g in (data.get("games") or {}).values():
a, r = g.get("analysis") or {}, g.get("recipe")
if isinstance(r, dict) and (a.get("extra") or {}).get("kind") == "rift":
r.setdefault("patches", {}).setdefault("pcvr.xr_timefix", {})
r.setdefault("reasons", {}).setdefault(
"pcvr.xr_timefix", "The Frame's OpenXR runtime rejects Proton's OpenXR 1.1 request without it.")
done.append("xr_compat_layer")
changed = True
if "oculus_unreal" not in done:
# the Oculus detection patch is on by default for Unreal PC VR games that use LibOVR, also those added before
for g in (data.get("games") or {}).values():
a, r = g.get("analysis") or {}, g.get("recipe")
extra = a.get("extra") or {}
if (isinstance(r, dict) and extra.get("kind") == "rift" and a.get("engine") == "Unreal"
and not extra.get("openxr_native")):
r.setdefault("patches", {}).setdefault("pcvr.oculus_unreal", {})
r.setdefault("reasons", {}).setdefault(
"pcvr.oculus_unreal", "Unreal game: its Oculus plugin checks for the Oculus service before it starts VR.")
done.append("oculus_unreal")
changed = True
if "rift_revive_correct" not in done:
# Correct the earlier churn: re-derive every Rift recipe from the engine defaults (Oculus games get Revive for
# VR; OpenXR/SteamVR-native and catalog games keep their own settings). The dump is never modified (as_is).
from ..recommend import engine
for pkg, g in (data.get("games") or {}).items():
a = g.get("analysis") or {}
if not (isinstance(g.get("recipe"), dict) and (a.get("extra") or {}).get("kind") == "rift"):
continue
try:
rec = engine.suggest(analysis_from_dict(a))
except Exception: # noqa: BLE001 - a malformed entry keeps its recipe
continue
g["recipe"] = recipe_to_dict(rec)
done.append("rift_revive_correct")
changed = True
if "rift_frame_native" not in done:
# Re-analyze Rift games so existing entries pick up openvr_native/frame_native (honest Frame vs PC routing),
# then re-derive their recipes. Needs the game files; entries without a readable game_dir keep what they have.
from ..analysis import rift
from ..recommend import engine
for pkg, g in (data.get("games") or {}).items():
a = g.get("analysis") or {}
if not (isinstance(g.get("recipe"), dict) and (a.get("extra") or {}).get("kind") == "rift"):
continue
gd = g.get("game_dir") or (a.get("extra") or {}).get("folder")
try:
if gd and Path(gd).is_dir():
an = rift.analyze(Path(gd), exe=(a.get("extra") or {}).get("exe"))
g["analysis"] = asdict(an)
else: # no game files: derive frame_native from the old analysis flags (unknown -> Oculus/PC)
x = a.setdefault("extra", {})
x.setdefault("frame_native", bool(x.get("openxr_native") or x.get("openvr_native")))
an = analysis_from_dict(a)
g["recipe"] = recipe_to_dict(engine.suggest(an))
except Exception: # noqa: BLE001
continue
done.append("rift_frame_native")
changed = True
return changed
def save(data: dict) -> None:
@@ -72,4 +154,16 @@ def recipe_from_dict(d: dict) -> Recipe:
def analysis_from_dict(d: dict) -> Analysis:
return Analysis(**{k: v for k, v in d.items() if k in Analysis.__dataclass_fields__})
"""Build an Analysis, filling in defaults for any field a partial/legacy entry is missing (so old library data
still works with newer code)."""
import dataclasses
kwargs = {}
for f in dataclasses.fields(Analysis):
if f.name in d:
kwargs[f.name] = d[f.name]
elif f.default is not dataclasses.MISSING or f.default_factory is not dataclasses.MISSING: # type: ignore[misc]
continue # let the dataclass default apply
else:
kwargs[f.name] = "" if f.type == "str" else [] if "list" in str(f.type) else 0 if f.type == "int" else False if f.type == "bool" else None
return Analysis(**kwargs)
+1
View File
@@ -69,6 +69,7 @@ class Recipe:
status: str = "unknown" # works | issues | unsupported | unknown
notes: str = ""
source: str = "heuristics" # catalog | heuristics | user
as_is: bool = False # install the game unchanged (already patched): no overport / Frame fixes / Revive
reasons: dict[str, str] = field(default_factory=dict) # patch id -> why it was suggested
def enabled(self, patch_id: str) -> bool:
+54
View File
@@ -0,0 +1,54 @@
"""Display titles for games that exist in both a Quest and a Rift version (kept as separate library entries).
While both versions are in the library they're shown — and named in Steam — as "<Title> (Quest)" / "<Title> (Rift)".
"""
from __future__ import annotations
def _norm(t: str) -> str:
from ..pipeline import _norm_title
return _norm_title(t)
def twins(games: list[dict]) -> set[str]:
"""Packages that have a counterpart of the other platform in the library (Quest ⟷ Rift version of one game)."""
by_pkg = {g["package"]: g for g in games}
quest_by_title = {}
for g in games:
if g.get("kind") != "rift":
quest_by_title.setdefault(_norm(g.get("title") or ""), []).append(g["package"])
out = set()
for g in games:
if g.get("kind") != "rift":
continue
mates = set(quest_by_title.get(_norm(g.get("title") or ""), []))
if g.get("quest_package") in by_pkg:
mates.add(g["quest_package"])
if mates:
out |= mates | {g["package"]}
return out
def display_title(game: dict, twin_set: set[str] | None = None) -> str:
"""The title shown everywhere (cards, pages, jobs, Steam shortcut): "<Title> (Quest)" / "(Rift)" while both
versions of a game are in the library."""
title = game.get("title") or game["package"]
if twin_set is not None and game["package"] in twin_set:
return f"{title} ({'Rift' if game.get('kind') == 'rift' else 'Quest'})"
return title
def counterparts(game: dict, games: list[dict]) -> list[dict]:
"""The other-platform versions of this game in the library."""
rift = game.get("kind") == "rift"
t = _norm(game.get("title") or "")
out = []
for g in games:
if g["package"] == game["package"] or (g.get("kind") == "rift") == rift:
continue
linked = (rift and game.get("quest_package") == g["package"]) or \
(not rift and g.get("quest_package") == game["package"])
if linked or (t and _norm(g.get("title") or "") == t):
out.append(g)
return out
+225
View File
@@ -0,0 +1,225 @@
"""The Windows PC FramePort runs on: native Windows, or WSL (Windows programs via interop, C: at /mnt/c).
Used by the PC VR (Revive) target: finding Windows Steam, its active user, SteamVR, and starting/stopping Windows
processes. Everything is discovered (registry, loginusers.vdf, libraryfolders.vdf); nothing about the user is stored.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import time
from functools import lru_cache
from pathlib import Path
STEAMID64_BASE = 76561197960265728
STEAMVR_APPID = "250820"
def is_windows() -> bool:
return sys.platform == "win32"
@lru_cache(maxsize=1)
def is_wsl() -> bool:
if sys.platform != "linux":
return False
try:
return "microsoft" in Path("/proc/version").read_text().lower()
except OSError:
return False
def available() -> bool:
return is_windows() or is_wsl()
def _no_window() -> dict:
return {"creationflags": 0x08000000} if is_windows() else {} # CREATE_NO_WINDOW
def system32(exe: str) -> str:
"""Path to a Windows system tool (tasklist.exe, reg.exe, ...) callable from here."""
if is_windows():
return exe
for root in ("/mnt/c/Windows/System32", "/mnt/c/WINDOWS/system32"):
if Path(root, exe).exists():
return str(Path(root, exe))
return shutil.which(exe) or exe
def run_win(args: list[str], timeout: float = 30) -> subprocess.CompletedProcess:
return subprocess.run(args, capture_output=True, text=True, errors="replace", timeout=timeout, **_no_window())
def to_windows(path: Path | str) -> str:
"""Local path -> Windows path (D:\\Games\\X). Identity on Windows."""
if is_windows():
return str(Path(path).resolve())
p = str(Path(path).resolve())
m = re.match(r"^/mnt/([a-zA-Z])(/.*)?$", p)
if m:
return f"{m[1].upper()}:" + (m[2] or "/").replace("/", "\\")
r = subprocess.run(["wslpath", "-w", p], capture_output=True, text=True)
if r.returncode:
raise ValueError(f"{p} is not reachable from Windows")
return r.stdout.strip()
def to_local(winpath: str) -> Path:
"""Windows path -> local path (/mnt/d/... on WSL)."""
if is_windows():
return Path(winpath)
m = re.match(r"^([a-zA-Z]):[\\/](.*)$", winpath.strip())
if m:
return Path(f"/mnt/{m[1].lower()}") / m[2].replace("\\", "/")
r = subprocess.run(["wslpath", "-u", winpath], capture_output=True, text=True)
return Path(r.stdout.strip())
def reg_query(key: str, value: str) -> str | None:
"""A registry value ("" = the key's default value). 64-bit view first, then the 32-bit one."""
if is_windows():
import winreg
hive, _, sub = key.partition("\\")
root = {"HKCU": winreg.HKEY_CURRENT_USER, "HKLM": winreg.HKEY_LOCAL_MACHINE}[hive]
for view in (winreg.KEY_WOW64_64KEY, winreg.KEY_WOW64_32KEY):
try:
with winreg.OpenKey(root, sub, 0, winreg.KEY_READ | view) as k:
return str(winreg.QueryValueEx(k, value)[0])
except OSError:
continue
return None
for view in ("/reg:64", "/reg:32"):
try:
r = run_win([system32("reg.exe"), "query", key, *(["/ve"] if value == "" else ["/v", value]), view])
except (OSError, subprocess.TimeoutExpired):
return None
name = r"\(Default\)|\(Standard\)|\S*" if value == "" else re.escape(value) # "(Default)" is localized
m = re.search(rf"^\s*(?:{name})\s+REG_\w+\s+(.+?)\s*$", r.stdout, re.M)
if m:
return m[1]
return None
def env_path(name: str) -> Path | None:
"""%LOCALAPPDATA% etc. of the Windows user, as a local path."""
if is_windows():
v = os.environ.get(name)
return Path(v) if v else None
try:
r = run_win([system32("cmd.exe"), "/c", f"echo %{name}%"], timeout=15)
except (OSError, subprocess.TimeoutExpired):
return None
v = r.stdout.strip().splitlines()[-1] if r.stdout.strip() else ""
return to_local(v) if v and "%" not in v else None
# ------------------------------------------------------------------------------------------ Steam
def steam_root() -> Path | None:
candidates = []
v = reg_query(r"HKCU\Software\Valve\Steam", "SteamPath")
if v:
candidates.append(to_local(v.replace("/", "\\")))
candidates += [to_local(r"C:\Program Files (x86)\Steam"), to_local(r"C:\Program Files\Steam")]
return next((c for c in candidates if (c / "steam.exe").exists()), None)
def steam_libraries(root: Path) -> list[Path]:
libs = [root / "steamapps"]
try:
text = (root / "steamapps/libraryfolders.vdf").read_text(encoding="utf-8", errors="replace")
for p in re.findall(r'"path"\s+"([^"]+)"', text):
lib = to_local(p.replace("\\\\", "\\")) / "steamapps"
if lib not in libs and lib.is_dir():
libs.append(lib)
except OSError:
pass
return libs
def app_installed(root: Path, appid: str) -> bool:
return any((lib / f"appmanifest_{appid}.acf").exists() for lib in steam_libraries(root))
def steam_user(root: Path) -> str | None:
"""userdata folder name (account id) of the most recent Steam login; the only one if there's just one."""
users = sorted(p.name for p in (root / "userdata").glob("*") if p.name.isdigit() and p.name != "0")
try:
text = (root / "config/loginusers.vdf").read_text(encoding="utf-8", errors="replace")
except OSError:
text = ""
for sid, body in re.findall(r'"(\d{17})"\s*\{([^}]*)\}', text):
if re.search(r'"MostRecent"\s+"1"', body):
acct = str(int(sid) - STEAMID64_BASE)
if acct in users:
return acct
return users[0] if len(users) == 1 else None
def process_running(image: str) -> bool:
try:
r = run_win([system32("tasklist.exe"), "/FI", f"IMAGENAME eq {image}", "/NH"], timeout=20)
except (OSError, subprocess.TimeoutExpired):
return False
return image.lower() in r.stdout.lower()
def kill(image: str) -> None:
try:
run_win([system32("taskkill.exe"), "/IM", image, "/F", "/T"], timeout=20)
except (OSError, subprocess.TimeoutExpired):
pass
def start_detached(exe: Path, args: list[str] = (), cwd: Path | None = None) -> None:
"""Start a Windows program without waiting (Steam, the Revive injector)."""
if is_windows():
subprocess.Popen([str(exe), *args], cwd=cwd, close_fds=True,
creationflags=0x00000008 | 0x00000200) # DETACHED_PROCESS | NEW_PROCESS_GROUP
return
subprocess.Popen([str(exe), *args], cwd=str(cwd) if cwd else None, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, start_new_session=True)
def stop_steam(root: Path, wait: float = 40) -> bool:
"""Ask Steam to exit; True if it was running."""
if not process_running("steam.exe"):
return False
start_detached(root / "steam.exe", ["-shutdown"])
end = time.time() + wait
while time.time() < end:
time.sleep(1)
if not process_running("steam.exe"):
time.sleep(2) # let it finish writing its files
return True
raise RuntimeError("Steam did not close; the library was not changed")
def start_steam(root: Path) -> None:
start_detached(root / "steam.exe")
def steamvr_running() -> bool:
return process_running("vrmonitor.exe") or process_running("vrserver.exe")
def start_steamvr(root: Path, wait: float = 25) -> bool:
"""Start SteamVR (app 250820) and wait until it's up. Revive needs SteamVR running to provide VR; without it an
Oculus game falls back to a flat window. No-op (returns True) if it's already running; returns False if SteamVR
isn't installed or didn't come up in time."""
if steamvr_running():
return True
if not app_installed(root, STEAMVR_APPID):
return False
start_detached(root / "steam.exe", ["-applaunch", STEAMVR_APPID])
end = time.time() + wait
while time.time() < end:
time.sleep(1)
if steamvr_running():
time.sleep(2) # let vrserver finish coming up before the game connects
return True
return False
+78 -3
View File
@@ -72,6 +72,19 @@ def save_target(target: FrameTarget) -> None:
(user_data_dir() / "frames.json").write_text(json.dumps([t.__dict__ for t in items], indent=2))
# direct links to the Frame, fastest first (interface → what it is)
FAST_LINKS = {"usb0": "USB cable", "wlanap": "the Frame's own Wi-Fi hotspot"}
def bundled_agent_version() -> int | None:
"""AGENT_VERSION of the agent this app ships (it's uploaded to the Frame whenever it differs)."""
try:
m = re.search(r"^AGENT_VERSION = (\d+)", (agent_dir() / "frameport_agent.py").read_text(), re.M)
return int(m.group(1)) if m else None
except OSError:
return None
class AgentFailed(RuntimeError):
pass
@@ -118,6 +131,35 @@ class Frame:
self.home = self.run("printf %s \"$HOME\"")[1].strip() or f"/home/{self.target.user}"
return self
def addresses(self) -> list[tuple[str, str]]:
"""(interface, IPv4) of every link the Frame has up."""
out = self.run("ip -4 -o addr show up 2>/dev/null")[1]
return re.findall(r"^\d+:\s+(\S+)\s+inet\s+([\d.]+)/", out, re.M)
def fast_link(self, timeout: float = 1.5) -> tuple["Frame", str]:
"""A second connection over the fastest direct link, for bulk uploads: the USB cable (usb0) or the Frame's own
hotspot (wlanap; the PC joins it with a Wi-Fi adapter, e.g. Valve's USB dongle) — several times faster than
both going through the home router (measured 83-97 vs 15-18 MB/s). Used only if this PC can reach it and it
presents the same SSH host key as this connection. Returns (frame, link name); (self, "") if none."""
import socket
key = self.client.get_transport().get_remote_server_key()
for iface in FAST_LINKS:
for name, ip in self.addresses():
if name != iface or ip == self.target.host:
continue
try:
socket.create_connection((ip, self.target.port), timeout=timeout).close()
other = Frame(FrameTarget(ip, self.target.user, self.target.port), self.password)
other.connect(timeout=5)
except (OSError, ConnectionError, paramiko.SSHException):
continue
if other.client.get_transport().get_remote_server_key() != key: # not our Frame: don't use it
other.close()
continue
return other, f"{FAST_LINKS[iface]} ({ip})"
return self, ""
def close(self) -> None:
if self._sftp:
self._sftp.close()
@@ -175,12 +217,20 @@ class Frame:
return reply["result"]
# ------------------------------------------------------------------ files
def put(self, local: Path, remote: str, progress=None, resume: bool = True) -> None:
"""Upload with resume: data goes to <remote>.part, appended from the existing size, then renamed."""
def mkdirs(self, dirs) -> None:
"""Create many remote folders with one command."""
dirs = sorted(set(dirs))
if dirs:
self.run("xargs -0 mkdir -p", stdin="\0".join(dirs))
def put(self, local: Path, remote: str, progress=None, resume: bool = True, mkdir: bool = True) -> None:
"""Upload with resume: data goes to <remote>.part, appended from the existing size, then renamed. progress may
raise (e.g. Cancelled): the .part stays and the next put continues from there."""
local = Path(local)
size = local.stat().st_size
part = remote + ".part"
self.run(f"mkdir -p {sh_quote(posixpath.dirname(remote))}")
if mkdir:
self.run(f"mkdir -p {sh_quote(posixpath.dirname(remote))}")
offset = 0
if resume:
try:
@@ -203,6 +253,31 @@ class Frame:
progress(done, size)
self.run(f"mv -f {sh_quote(part)} {sh_quote(remote)}")
def put_tar(self, files: list[tuple[Path, str]], remote_root: str, progress=None) -> None:
"""Upload many small files in one stream (tar → `tar -x` on the Frame): far faster than one SFTP transfer per
file. files: (local path, path relative to remote_root). progress(done_bytes, rel) may raise to stop; files that
arrived stay (a cut-off last file has the wrong size, so it's sent again next time)."""
import tarfile
stdin, stdout, stderr = self.client.exec_command(
f"mkdir -p {sh_quote(remote_root)} && tar -xf - -C {sh_quote(remote_root)}")
done = 0
try:
with tarfile.open(fileobj=stdin, mode="w|", format=tarfile.PAX_FORMAT) as tar:
for local, rel in files:
tar.add(str(local), arcname=rel, recursive=False)
done += Path(local).stat().st_size
if progress:
progress(done, rel)
finally:
try:
stdin.channel.shutdown_write()
except Exception: # noqa: BLE001
pass
code = stdout.channel.recv_exit_status()
if code:
raise OSError(f"remote tar failed ({code}): {stderr.read().decode(errors='replace')[-300:]}")
def get_text(self, remote: str, max_bytes: int = 64 << 20) -> str:
with self.sftp.open(remote, "rb") as f:
size = f.stat().st_size
+250 -26
View File
@@ -1,6 +1,8 @@
"""Install a built game on a Steam Frame over SSH (via the FramePort agent)."""
from __future__ import annotations
import contextlib
import posixpath
from dataclasses import dataclass
from pathlib import Path
@@ -59,37 +61,30 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
total = max(need, 1)
sent = 0
def progress_for(label):
def cb(done, size):
reporter.progress((sent + done) / total, f"{label} {done / 2**20:.0f}/{size / 2**20:.0f} MiB")
return cb
with transfer_link(frame, reporter, need) as xfer:
reporter.stage("Upload APK")
if prep["same_apk"]:
reporter.log("identical APK already installed; not re-sending")
else:
def apk_cb(done, size):
reporter.check_cancel()
reporter.progress(done / total, f"APK {done / 2**20:.0f}/{size / 2**20:.0f} MiB")
xfer.put(plan.apk, posixpath.join(incoming, "game.apk"), apk_cb)
sent += plan.apk.stat().st_size
if to_send:
reporter.stage(f"Upload data ({len(to_send)} files)")
upload_files(xfer, [(plan.data_dir / rel, f"obb/{rel}", manifest[rel]) for rel in to_send], incoming,
reporter, total, sent)
elif manifest:
reporter.log("game data already on the Frame; not re-sending")
reporter.stage("Upload APK")
if prep["same_apk"]:
reporter.log("identical APK already installed; not re-sending")
else:
frame.put(plan.apk, posixpath.join(incoming, "game.apk"), progress_for("APK"))
sent += plan.apk.stat().st_size
if to_send:
reporter.stage(f"Upload data ({len(to_send)} files)")
for rel in to_send:
reporter.check_cancel()
frame.put(plan.data_dir / rel, posixpath.join(incoming, "obb", rel), progress_for(rel))
sent += manifest[rel]
elif manifest:
reporter.log("game data already on the Frame; not re-sending")
reporter.stage("Artwork")
art_dir, _ = artwork.fetch(plan.package, plan.apk)
remote_art = posixpath.join(prep["base"], "incoming-artwork")
frame.run(f"rm -rf '{remote_art}' && mkdir -p '{remote_art}'")
for f in artwork.files(plan.package):
frame.put(f, posixpath.join(remote_art, f.name), resume=False)
artwork.fetch(plan.package, plan.apk)
upload_steam_art(frame, plan.package, prep["base"], reporter)
reporter.stage("Finalize install")
ctx = install_context(plan.recipe)
result = frame.agent(
"finalize", package=plan.package, title=plan.title, dest=plan.dest, apk_sha256=apk_sha,
"finalize", package=plan.package, title=plan.title, dest=plan.dest, apk_sha256=apk_sha, tags=_tags(plan.package),
apk_name=plan.apk.name, settings=ctx.adapter_settings,
files={k: v.decode() if isinstance(v, bytes) else v for k, v in ctx.files.items()}, env=ctx.env,
obb_manifest=manifest or None,
@@ -99,6 +94,235 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
return result
@dataclass
class PcvrPlan:
package: str # "rift.<slug>"
title: str
game_dir: Path
exe: str # relative to game_dir
recipe: Recipe
revive_dir: Path | None # None: launch the exe directly (OpenXR-native game)
exe_sha256: str | None = None
revive_version: str | None = None
art_lookup: str | None = None # package whose store art to use
dest: str | None = None
def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
"""Pack a Windows PC VR game + Revive onto the Frame, run by Proton through a generated launch.sh."""
from ..patches.pcvr import game_args, launch_env
from ..tools import revive as revive_tool
from ..core.paths import artifacts_dir
tool = plan.recipe.params("pcvr.proton_tool").get("tool") or None
reporter.stage("Prepare Frame")
prep = frame.agent("prepare_pcvr", package=plan.package, title=plan.title, dest=plan.dest, tool=tool)
if not prep.get("proton"):
ensure_proton(frame, reporter, tool)
prep = frame.agent("prepare_pcvr", package=plan.package, title=plan.title, dest=plan.dest, tool=tool)
if not prep.get("proton"):
raise RuntimeError("Proton is still not ready on the Frame")
reporter.check("Proton on the Frame", True, prep["proton"]["display_name"])
if prep.get("openxr"):
reporter.check("OpenXR runtime", True, prep["openxr"].get("name") or prep["openxr"]["path"])
else:
reporter.check("OpenXR runtime", None, "no active_runtime.json found; the game may not see the headset")
manifests = {"game": local_data_manifest(plan.game_dir)}
sources = {"game": plan.game_dir}
if plan.revive_dir:
root = plan.revive_dir
manifests["revive"] = {p.relative_to(root).as_posix(): p.stat().st_size
for p in revive_tool.runtime_files(root)}
sources["revive"] = root
xr_layer = "pcvr.xr_timefix" in plan.recipe.patches
if xr_layer:
layer = artifacts_dir() / "linux-arm64"
manifests["xrlayer"] = {p.name: p.stat().st_size for p in sorted(layer.iterdir()) if p.is_file()}
sources["xrlayer"] = layer
oculus_hmd = "pcvr.oculus_unreal" in plan.recipe.patches
if oculus_hmd: # fp_oculushmd.exe: provides the OculusHMDConnected event Unreal's Oculus plugin checks for
helpers = artifacts_dir() / "win-x64"
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]
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, "
f"have {prep['free_bytes'] / 2**30:.1f} GiB")
total = max(need, 1)
if to_send:
already = sum(sum(m.values()) for m in manifests.values()) - need
reporter.stage(f"Upload game ({len(to_send)} files, {need / 2**30:.1f} GiB)"
+ (f" — resuming, {already / 2**30:.1f} GiB already there" if already > 0 else ""))
with transfer_link(frame, reporter, need) as xfer:
upload_files(xfer, [(sources[t] / rel, f"{t}/{rel}", manifests[t][rel]) for t, rel in to_send],
prep["incoming"], reporter, total, 0)
else:
reporter.log("all game files already on the Frame; not re-sending")
from ..artwork import sources
if not sources.has_art(plan.package):
artwork.fetch(plan.package, lookup=plan.art_lookup)
upload_steam_art(frame, plan.package, prep["base"], reporter)
reporter.stage("Finalize install")
result = frame.agent(
"finalize_pcvr", package=plan.package, title=plan.title, dest=plan.dest, tool=tool, exe=plan.exe,
tags=_tags(plan.package),
manifests=manifests, revive=plan.revive_dir is not None, xr_layer=xr_layer, oculus_hmd=oculus_hmd,
env=launch_env(plan.recipe),
game_args=game_args(plan.recipe), no_crash_reporter="pcvr.no_crash_reporter" in plan.recipe.patches,
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version,
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source},
)
reporter.log(f"installed at {result['base']} (Proton {result['proton']}, Steam shortcut id {result['appid']})")
return result
def ensure_proton(frame: Frame, reporter: Reporter, tool: str | None = None, timeout: float = 45 * 60) -> dict:
"""Install Proton (ARM64) + its runtime on the Frame without user interaction: the agent writes Steam appmanifest
stubs and restarts Steam, which downloads them. Waits until they're installed (progress from the appmanifests)."""
import time
st = frame.agent("proton_status", tool=tool)
if st.get("ready"):
return st["ready"]
reporter.stage("Install Proton on the Frame")
r = frame.agent("install_proton", mode="unattended", tool=tool)
reporter.log(r.get("hint") or f"installing {r.get('tool')}")
end = time.time() + timeout
last = None
while time.time() < end:
reporter.check_cancel()
time.sleep(10)
try:
st = frame.agent("proton_status", tool=tool, timeout=60)
except Exception: # SSH can hiccup while Steam restarts
continue
if st.get("ready"):
reporter.check("Proton installed", True, st["ready"]["display_name"])
return st["ready"]
dl = st.get("download") or {}
if dl.get("total"):
reporter.progress(dl["done"] / dl["total"], f"downloading Proton {dl['done'] / 2**20:.0f}/"
f"{dl['total'] / 2**20:.0f} MiB")
elif dl != last:
reporter.log("waiting for Steam to start the download…")
last = dl
raise RuntimeError("Proton didn't finish installing on the Frame in time; check Steam's downloads on the Frame")
BIG_FILE = 8 << 20 # bigger files go one by one (resumable); smaller ones are streamed in batches
BATCH_BYTES, BATCH_FILES = 256 << 20, 2000
FAST_LINK_MIN = 64 << 20 # below this a second connection isn't worth it
@contextlib.contextmanager
def transfer_link(frame: Frame, reporter: Reporter, need: int):
"""The connection bulk uploads should use: a direct link (USB cable / the Frame's hotspot) when this PC has one,
else the normal connection. Agent commands keep using `frame`; both see the same files."""
xfer, label = (frame, "")
if need >= FAST_LINK_MIN:
try:
xfer, label = frame.fast_link()
except Exception: # noqa: BLE001 - discovery is best effort
xfer, label = frame, ""
reporter.check("Transfer link", True, label or f"{frame.target.host} (connect this PC to the Frame's hotspot "
"or a USB cable for faster uploads)" if need >= FAST_LINK_MIN
else frame.target.host)
try:
yield xfer
finally:
if xfer is not frame:
xfer.close()
def upload_files(frame: Frame, items: list[tuple[Path, str, int]], remote_root: str, reporter: Reporter,
total: int, sent: int = 0) -> int:
"""Upload (local, relative path, size) items under remote_root. Interruptible (Cancel, dropped connection) and
resumable: finished files are skipped next time (the agent counts files already in incoming/), a cut-off big file
continues from its .part. Returns bytes sent."""
big = [i for i in items if i[2] >= BIG_FILE]
small = [i for i in items if i[2] < BIG_FILE]
frame.mkdirs(posixpath.dirname(posixpath.join(remote_root, rel)) for _, rel, _ in big)
state = {"sent": sent}
def show(extra: int, label: str):
reporter.check_cancel()
reporter.progress((state["sent"] + extra) / total, label)
for local, rel, size in big:
reporter.check_cancel()
frame.put(local, posixpath.join(remote_root, rel),
lambda done, size, rel=rel: show(done, f"{rel} {done / 2**20:.0f}/{size / 2**20:.0f} MiB"),
mkdir=False)
state["sent"] += size
batch: list[tuple[Path, str]] = []
batch_bytes = 0
def flush():
nonlocal batch, batch_bytes
if batch:
base = state["sent"]
frame.put_tar(batch, remote_root, lambda done, rel: show(done, f"{len(batch)} files · {rel}"))
state["sent"] = base + batch_bytes
batch, batch_bytes = [], 0
for local, rel, size in small:
batch.append((local, rel))
batch_bytes += size
if batch_bytes >= BATCH_BYTES or len(batch) >= BATCH_FILES:
flush()
flush()
return state["sent"]
def _tags(package: str) -> list[str]:
from ..artwork.steam import steam_tags
from ..core import library
entry = library.game(package)
return steam_tags(entry) if entry else []
def upload_steam_art(frame: Frame, package: str, base: str, reporter: Reporter) -> None:
"""The complete Steam art set (portrait, wide, hero, logo, icon — composed where the store has no such shape)."""
from ..artwork.steam import steam_set
reporter.stage("Artwork for the Steam library")
art = steam_set(package)
remote_art = posixpath.join(base, "incoming-artwork")
frame.run(f"rm -rf '{remote_art}' && mkdir -p '{remote_art}'")
for kind, f in art.items():
frame.put(f, posixpath.join(remote_art, f"{kind}{f.suffix}"), resume=False)
reporter.check("Steam artwork", bool(art) or None, ", ".join(sorted(art)) or "none found")
def update_steam_art(frame: Frame, package: str, reporter: Reporter) -> dict:
"""Replace an installed game's Steam library art with the current artwork (after the user picked new art):
the art set goes to the game's anchor, then the shortcut is rewritten (Steam restarts once)."""
from ..artwork.steam import steam_set
dep = next((d for d in frame.agent("list_installed")["games"] if d.get("package") == package), None)
if not dep:
raise RuntimeError(f"{package} isn't installed on the Frame")
if not dep.get("anchor"):
raise RuntimeError("the Frame's FramePort agent is too old; reinstall the game to update its art")
art = steam_set(package)
if not art:
raise RuntimeError("no artwork to send")
reporter.stage("Artwork for the Steam library")
remote = posixpath.join(dep["anchor"], "artwork")
frame.run(f"rm -rf '{remote}' && mkdir -p '{remote}'")
for kind, f in art.items():
frame.put(f, posixpath.join(remote, f"{kind}{f.suffix}"), resume=False)
reporter.check("Steam artwork", True, ", ".join(sorted(art)))
return add_to_steam(frame, [package], reporter)
def add_to_steam(frame: Frame, packages: list[str], reporter: Reporter, wait: float = 120) -> dict:
"""Add library entries + artwork for installed games; Steam restarts once (a few seconds)."""
import time
+52
View File
@@ -0,0 +1,52 @@
"""Predicates shared by patch `applies()` / `detect()` rules."""
from __future__ import annotations
from ..core.models import Analysis
def is_unreal(a: Analysis) -> bool:
return a.engine == "Unreal"
def is_unity(a: Analysis) -> bool:
return a.engine == "Unity"
def has_vrapi(a: Analysis) -> bool:
return "libvrapi.so" in a.libs
def is_gles(a: Analysis) -> bool:
return "GLES" in a.graphics
def is_vulkan(a: Analysis) -> bool:
return a.graphics.startswith("Vulkan")
def arm64(a: Analysis) -> bool:
return "arm64-v8a" in a.abis
def meta_audio_libs(a: Analysis) -> list[str]:
return [l for l in a.libs if l.lower().startswith(("libmetaxraudio", "libovraudio", "libaksoundengine", "libovravatar"))]
def uses_scene(a: Analysis) -> bool:
perms = a.extra.get("meta_permissions_used") or a.meta_permissions
return any(p.endswith(("USE_SCENE", "USE_ANCHOR_API")) for p in perms)
def needs_scene(a: Analysis) -> bool:
"""Mixed-reality-only game that builds its world from the room model."""
perms = a.extra.get("meta_permissions_used") or a.meta_permissions
return bool(a.extra.get("mr_only")) and any(p.endswith("USE_SCENE") for p in perms)
def unreal_version(a: Analysis) -> tuple[int, int] | None:
v = a.extra.get("unreal_version")
try:
major, minor = v.split(".")[:2]
return int(major), int(minor)
except (AttributeError, ValueError):
return None
+15 -2
View File
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
from ..core.models import Analysis
STAGES = ("overport", "apk", "install")
CATEGORIES = ("overport", "frame", "adapter", "device")
CATEGORIES = ("overport", "frame", "adapter", "device", "pcvr")
@dataclass
@@ -74,6 +74,13 @@ class Patch:
def detect(self, analysis: "Analysis") -> Suggestion | None:
return Suggestion(True, "Recommended for every game.") if self.default_on else None
def applies(self, analysis: "Analysis") -> bool:
"""False when the patch can't matter for this game (wrong engine, no VrApi, ...). The UI hides such patches
(they stay in the recipe if they are defaults, where they are no-ops)."""
return True
not_applicable_reason: str = ""
def apply(self, ctx: ApkContext) -> bool: # apk stage; returns True when something changed
raise NotImplementedError
@@ -110,6 +117,12 @@ def get(patch_id: str) -> Patch:
raise KeyError(f"unknown patch {patch_id!r}") from None
def for_game(patch: Patch, analysis: "Analysis") -> bool:
"""Quest patches only for Quest games, PC VR (Revive) patches only for Rift games."""
rift = (analysis.extra or {}).get("kind") == "rift"
return (patch.category == "pcvr") == rift
def all_patches() -> list[Patch]:
load_all()
return sorted(REGISTRY.values(), key=lambda p: (CATEGORIES.index(p.category), p.order, p.id))
@@ -126,7 +139,7 @@ def load_all() -> None:
import importlib
import pkgutil
from . import frame, overport, settings # noqa: F401 (registration side effects)
from . import frame, overport, pcvr, settings # noqa: F401 (registration side effects)
for mod in pkgutil.iter_modules(frame.__path__):
importlib.import_module(f"{frame.__name__}.{mod.name}")
+28 -2
View File
@@ -35,9 +35,27 @@ class NoDebuggable(Patch):
id = "frame.nodebug"
title = "Clear android:debuggable"
description = ("overport marks apps debuggable; that turns on CheckJNI, which aborts some Unreal games on sloppy "
"JNI calls (NOPE Challenge, Time Stall: 'GetStringUTFChars ... NULL'). Clear it for those.")
"JNI calls ('JNI DETECTED ERROR', e.g. 'GetStringUTFChars ... NULL' in Time Stall). Clear it for "
"those.")
order = 21
def detect(self, a):
from ..applicability import is_unreal, unreal_version
if not is_unreal(a):
return None
v = unreal_version(a)
if v and v < (4, 22):
return Suggestion(True, f"Unreal Engine {v[0]}.{v[1]}: older UE4 makes JNI calls CheckJNI rejects "
"(e.g. Time Stall aborted on GetStringUTFChars(NULL)).")
if any(l.startswith("libmetaxraudio") for l in a.libs):
return Suggestion(True, "Unreal build of Meta XR Audio: its telemetry lookup leaves a pending JNI exception "
"that CheckJNI turns into an abort (e.g. NOPE Challenge).")
return Suggestion(False, "Enable if the game aborts with 'JNI DETECTED ERROR' (CheckJNI).")
def applies(self, a):
return a.engine in ("Unreal", "Other", "CryEngine")
def apply(self, ctx):
fixed = axml.set_bool_attr(ctx.ws.read(MANIFEST), "application", "debuggable", False)
if fixed:
@@ -49,16 +67,24 @@ class MetaPermissions(Patch):
id = "frame.meta_permissions"
title = "Declare Meta-only permissions"
description = ("Declares com.oculus.permission.* / horizonos.permission.* that the app uses, so Android grants "
"them at install (e.g. Demeter's 'use spatial data' check).")
"them at install (e.g. the 'use spatial data' permission of mixed-reality games like Demeter).")
order = 22
def detect(self, a):
from ..applicability import needs_scene
if needs_scene(a):
return Suggestion(True, "Mixed-reality game that needs the room model: its 'use spatial data' permission must "
"be granted (e.g. Demeter).")
scene = [p for p in a.meta_permissions if any(k in p for k in ("SCENE", "ANCHOR", "SPATIAL", "BOUNDARY"))]
if scene:
return Suggestion(False, "Uses Meta scene/anchor permissions (" + ", ".join(p.rsplit(".", 1)[-1] for p in scene[:3])
+ "); enable if the game says it needs spatial data access.")
return None
def applies(self, a):
return bool(a.meta_permissions or a.extra.get("meta_permissions_used"))
def apply(self, ctx):
fixed = axml.define_meta_permissions(ctx.ws.read(MANIFEST))
if fixed:
+2 -2
View File
@@ -25,7 +25,7 @@ class PlatformCompat(Patch):
id = "frame.ovrplatformcompat"
title = "Platform compat (ovrMessageType_ToString)"
description = ("Adds a real ovrMessageType_ToString (from the Android-XR-Bridge fork) for games whose platform "
"loader lacks it (The Climb 2).")
"loader lacks it (e.g. The Climb 2).")
order = 30
default_on = True
@@ -47,7 +47,7 @@ class OvrStubs(Patch):
id = "frame.ovrstubs"
title = "Stub missing Meta platform functions"
description = ("Generates no-op stubs for ovr_* functions the game imports but overport's platform loader "
"lacks (Espire 1/2, Wallace & Gromit, The Climb 2). Symptom: UnsatisfiedLinkError / "
"lacks (e.g. Espire 1/2, Wallace & Gromit, The Climb 2). Symptom: UnsatisfiedLinkError / "
"'cannot locate symbol ovr_...'. Online/store features stay unavailable.")
order = 31
default_on = True
+19 -4
View File
@@ -53,7 +53,8 @@ class MetaXrTelemetry(Patch):
id = "frame.metaxr_telemetry"
title = "Meta XR Audio: skip Quest telemetry class"
description = ("Unreal builds of Meta XR Audio abort ('ClassNotFoundException com.oculus.os.AnalyticsEvent') on "
"non-Quest devices. Patches that one JNI FindClass call so the library continues (NOPE Challenge).")
"non-Quest devices. Patches that one JNI FindClass call so the library continues (e.g. NOPE "
"Challenge).")
order = 70
default_on = True
@@ -62,6 +63,9 @@ class MetaXrTelemetry(Patch):
return Suggestion(True, "Meta XR Audio present; patched automatically if it has the telemetry lookup.")
return None
def applies(self, a):
return a.engine == "Unreal" and any(l.lower().startswith("libmetaxraudio") for l in a.libs)
def apply(self, ctx: ApkContext) -> bool:
changed = False
for lib in ctx.ws.libs():
@@ -77,15 +81,26 @@ class OculusOsStubs(Patch):
id = "frame.oculusos"
title = "Oculus OS telemetry class stubs"
description = ("Adds no-op com.oculus.os.AnalyticsEvent / UnifiedTelemetryLogger classes for native code that "
"looks them up through the app class loader (Nano, NOPE Challenge).")
"looks them up through the app class loader (e.g. Nano, NOPE Challenge).")
order = 71
def detect(self, a):
refs = a.extra.get("oculus_os_refs") or []
if any(r.startswith("libmetaxraudio") for r in refs):
return Suggestion(True, "The Unreal build of Meta XR Audio looks up com.oculus.os.AnalyticsEvent and "
"aborts without it (e.g. NOPE Challenge).")
if len(refs) >= 2:
return Suggestion(True, f"Several Meta SDK libraries look up com.oculus.os.AnalyticsEvent ({', '.join(refs)}); "
"games like this abort without the stub (e.g. Nano).")
if a.oculus_os_classes:
return Suggestion(True, "Native code references com/oculus/os/AnalyticsEvent (a missing class aborts some "
"games, e.g. Nano).")
return Suggestion(False, f"{', '.join(refs) or 'Native code'} references com.oculus.os.AnalyticsEvent; usually "
"harmless (e.g. Batman: Arkham Shadow and LEGO Bricktales run without it). Enable "
"if the game aborts with ClassNotFoundException.")
return None
def applies(self, a):
return a.oculus_os_classes
def apply(self, ctx: ApkContext) -> bool:
ws = ctx.ws
uses = any(b"com/oculus/os/AnalyticsEvent" in ws.read_lib(lib) for lib in ws.libs())
+11 -5
View File
@@ -50,15 +50,21 @@ class UnityNoMsaa(Patch):
id = "frame.unity_no_msaa"
title = "Unity: disable MSAA"
description = ("Sets Unity QualitySettings antiAliasing to 0. Unity's multisampled render-to-texture path can hang "
"the Frame's GL driver ('zink: DEVICE LOST', Sniper Elite VR). Try it for GLES Unity games that "
"the Frame's GL driver ('zink: DEVICE LOST', e.g. Sniper Elite VR). Try it for GLES Unity games that "
"freeze or crash the GPU.")
order = 40
def detect(self, a):
if a.engine == "Unity" and a.unity_msaa_levels and "GLES" in a.graphics:
return Suggestion(False, f"GLES Unity game with MSAA on ({a.unity_msaa_levels} quality levels); "
"enable if it hangs the GPU.")
return None
if a.engine != "Unity" or not a.unity_msaa_levels or "GLES" not in a.graphics or a.only_32bit:
return None
if a.xr == "VrApi":
return Suggestion(True, f"Legacy VrApi Unity game on GLES with MSAA ({a.unity_msaa_levels} quality levels): "
"multisampled render-to-texture hangs the Frame's GL driver (e.g. Sniper Elite VR).")
return Suggestion(False, f"GLES Unity game with MSAA on ({a.unity_msaa_levels} quality levels); enable if it "
"hangs the GPU ('zink: DEVICE LOST').")
def applies(self, a):
return a.engine == "Unity" and "GLES" in a.graphics
def apply(self, ctx: ApkContext) -> bool:
if not ctx.ws.has(GGM):
+9 -3
View File
@@ -14,17 +14,20 @@ class VrApiBridge(Patch):
description = (
"Replaces libvrapi.so with a VrApi→OpenXR bridge (Android-XR-Bridge/OVRPort fork, GPL-3.0, patched for the "
"Frame: GLES sessions, cylinder→quad layers, sRGB format fallback, 30 s VR-mode deadline, emulated time "
"conversion). Needed when the engine calls VrApi itself instead of through OVRPlugin (The Climb 2, Path of "
"conversion). Needed when the engine calls VrApi itself instead of through OVRPlugin (e.g. The Climb 2, Path of "
"the Warrior); overport cannot translate those."
)
order = 60
experimental = True
def detect(self, a):
if a.direct_vrapi:
if a.direct_vrapi and "arm64-v8a" in a.abis:
return Suggestion(True, "The engine calls libvrapi.so directly; overport can't translate it.")
return None
def applies(self, a):
return "libvrapi.so" in a.libs and "arm64-v8a" in a.abis
def apply(self, ctx: ApkContext) -> bool:
ws = ctx.ws
if ws.abi != "arm64-v8a" or not ws.has(ws.lib("libvrapi.so")):
@@ -51,10 +54,13 @@ class GlShim(Patch):
experimental = True
def detect(self, a):
if a.direct_vrapi and a.uses_glad_gl and "GLES" in a.graphics:
if a.direct_vrapi and a.uses_glad_gl and "GLES" in a.graphics and "arm64-v8a" in a.abis:
return Suggestion(True, "GLES engine resolving GL through eglGetProcAddress (GLAD) on the VrApi bridge.")
return None
def applies(self, a):
return a.direct_vrapi and "GLES" in a.graphics and "arm64-v8a" in a.abis
def apply(self, ctx: ApkContext) -> bool:
ws = ctx.ws
if ws.abi != "arm64-v8a":
+28 -3
View File
@@ -37,7 +37,8 @@ OVERPORT_PATCHES = [
("patch_force_passthrough", "Force enable passthrough", False,
"For mixed-reality-only games. On the Frame, passthrough is emulated by the FrameBridge adapter (greyscale cameras)."),
("patch_disable_space_warp", "Disable application space warp if used", False,
"For heavy games; space warp causes artifacts/hangs on non-Quest runtimes (Asgard's Wrath 2, Batman)."),
"For heavy games that use application space warp: it causes artifacts or hangs on non-Quest runtimes "
"(e.g. Asgard's Wrath 2, Batman: Arkham Shadow)."),
("patch_disable_controller_offset", "Disable controller tracking offset", False,
"Removes overport's controller pose offset if controllers look misplaced."),
("patch_remove_vrapi", "Remove VrApi library", False,
@@ -54,12 +55,36 @@ class OverportPatch(Patch):
self.id, self.title, self.default_on, self.description = pid, title, default, detail
def detect(self, analysis: Analysis) -> Suggestion | None:
from . import applicability as ap
a = analysis
if self.default_on:
return Suggestion(True, "overport default.")
if self.id == "patch_disable_space_warp" and analysis.extra.get("size", 0) > 3 * 2**30:
return Suggestion(False, "Consider for very heavy games.")
if self.id == "patch_force_passthrough" and a.extra.get("mr_only"):
return Suggestion(True, "Mixed-reality-only game (passthrough required, no guardian): force passthrough on.")
if self.id == "patch_disable_space_warp" and "libOVRPlugin.so" in a.libs:
total = a.extra.get("data_bytes", 0) + a.extra.get("size", 0)
if total >= 20 * 2**30:
return Suggestion(True, f"Very heavy game ({total / 2**30:.0f} GiB) using OVRPlugin: application space "
"warp causes artifacts/hangs off-Quest.")
return Suggestion(False, "Enable if the game shows warping artifacts or hangs (uses OVRPlugin space warp).")
if self.id == "patch_remove_unreal_force_quit" and ap.is_unreal(a):
return Suggestion(False, "Built as the alternate APK for Unreal games (use it if the game quits itself).")
return None
def applies(self, analysis: Analysis) -> bool:
from . import applicability as ap
rules = {
"patch_oculus_unity": ap.is_unity, "patch_oculus_unreal": ap.is_unreal,
"patch_fix_unreal_crash": ap.is_unreal, "patch_remove_unreal_force_quit": ap.is_unreal,
"patch_copy_ovrplugin_vrapi": ap.has_vrapi, "patch_remove_vrapi": ap.has_vrapi,
"patch_meta_xr_audio": lambda a: bool(ap.meta_audio_libs(a)),
"patch_disable_space_warp": lambda a: "libOVRPlugin.so" in a.libs,
}
rule = rules.get(self.id)
return rule(analysis) if rule else True
STRINGS_URL = "https://raw.githubusercontent.com/ovrport/app/HEAD/composeApp/src/commonMain/composeResources/values/strings.xml"
+158
View File
@@ -0,0 +1,158 @@
"""PC VR (Oculus Rift) options, shown in the UI as patches like the overport ones.
They don't edit the game: they decide how FramePort launches it. On this PC the Steam shortcut runs
ReviveInjector.exe (Revive's OpenXR backend by default); on the Frame the launcher runs the same injector under
Proton (ARM64, x86 emulated by FEX inside Proton), which bridges OpenXR to the Frame runtime via wineopenxr.
"""
from __future__ import annotations
from .base import Param, Patch, Suggestion, register
def _rift(analysis) -> bool:
return (analysis.extra or {}).get("kind") == "rift"
class _PcvrPatch(Patch):
category = "pcvr"
stage = "install"
def applies(self, analysis) -> bool:
return _rift(analysis)
class Revive(_PcvrPatch):
id = "pcvr.revive"
title = "Revive (Oculus LibOVR → OpenXR)"
description = ("Launch the game through Revive's injector, which translates the Oculus PC SDK (LibOVR) to OpenXR. "
"Needed by every Oculus Rift game that isn't OpenXR-native. FramePort downloads Revive itself.")
order = 10
def detect(self, analysis):
# Oculus/LibOVR games need Revive to get VR (the bare exe runs flat). Off for games with no Oculus code, which
# SteamVR (PC) and the Frame's wineopenxr run directly (e.g. Rick and Morty, handled by its catalog recipe).
if not _rift(analysis):
return None
if analysis.extra.get("frame_native"):
return Suggestion(False, "No Oculus code: SteamVR / the Frame run it directly, without Revive.")
return Suggestion(True, "Oculus/LibOVR game: Revive translates it to SteamVR/OpenXR (without it it runs flat).")
class ReviveOpenVR(_PcvrPatch):
id = "pcvr.revive_openvr"
title = "Revive: SteamVR (OpenVR) backend on PC"
description = ("On this PC, launch the game through Revive's OpenVR backend, which talks straight to SteamVR — the "
"original, most reliable Revive path. On by default for PC installs. Turn it off to use Revive's "
"newer OpenXR backend instead. Ignored on the Frame (which always uses OpenXR).")
order = 20
requires = ("pcvr.revive",)
def detect(self, analysis):
if not _rift(analysis):
return None
if analysis.extra.get("frame_native"):
return Suggestion(False, "Runs on SteamVR directly; no Revive needed.")
return Suggestion(True, "PC: Revive's SteamVR/OpenVR backend is the most reliable path.")
class XrTimefix(_PcvrPatch):
id = "pcvr.xr_timefix"
title = "Frame OpenXR compatibility layer"
description = ("Loads FramePort's OpenXR layer under Proton on the Frame. The Frame's SteamVR runtime only accepts "
"OpenXR 1.0 apps, but Proton's VR helper asks for 1.1, so without the layer VR never starts (the "
"game shows as a flat window, or Revive fails with 'Unable to load LibOVRRT DLL'); the layer "
"retries as 1.0. It also emulates xrConvertTimespecTimeToTimeKHR if a runtime refuses it. "
"Ignored on this PC.")
order = 25
def detect(self, analysis):
if _rift(analysis):
return Suggestion(True, "The Frame's OpenXR runtime rejects Proton's OpenXR 1.1 request without it.")
return None
class NoCrashReporter(_PcvrPatch):
id = "pcvr.no_crash_reporter"
title = "No Unreal crash reporter"
description = ("Unreal games start CrashReportClient when they crash, which leaves a crash dialog instead of simply "
"closing. This passes -nocrashreports to the game and, on the Frame, renames the game's copy of "
"CrashReportClient.exe so it can't start (your game files on this PC aren't changed).")
order = 15
def detect(self, analysis):
if _rift(analysis) and analysis.engine == "Unreal":
return Suggestion(True, "Unreal game: close on a crash instead of showing the crash reporter.")
return None
def applies(self, analysis) -> bool:
return _rift(analysis) and analysis.engine == "Unreal"
class OculusUnreal(_PcvrPatch):
id = "pcvr.oculus_unreal"
title = "Patch Oculus detection for Unreal (Frame)"
description = ("The PC VR counterpart of overport's 'Patch Oculus detection for Unreal'. Unreal's Oculus plugin only "
"starts when the Oculus service announces a headset (the Windows event 'OculusHMDConnected'); "
"without it the game runs as a flat window. On the Frame the launcher runs the game through "
"FramePort's small helper (fp_oculushmd.exe) that provides that event while the game runs, instead "
"of relying only on Revive's hook of the check. Ignored on this PC (the Oculus app or Revive "
"handle it there).")
order = 18
def detect(self, analysis):
if not (_rift(analysis) and analysis.engine == "Unreal"):
return None
if analysis.extra.get("frame_native"):
return Suggestion(False, "No Oculus plugin in use: runs directly.")
return Suggestion(True, "Unreal game: its Oculus plugin checks for the Oculus service before it starts VR.")
def applies(self, analysis) -> bool:
return _rift(analysis) and analysis.engine == "Unreal"
class ProtonLog(_PcvrPatch):
id = "pcvr.proton_log"
title = "Proton debug log (Frame)"
description = ("Write Proton's log (PROTON_LOG=1) to steam-<appid>.log in the game folder on the Frame. Slower; "
"use while debugging a game that doesn't start.")
order = 30
class ProtonTool(_PcvrPatch):
id = "pcvr.proton_tool"
title = "Proton version (Frame)"
description = ("Which Proton build runs the game on the Frame (a Steam compat tool name from the Frame's ARM64 "
"compat list, e.g. proton_11-arm64 or proton-experimental-arm64). Empty = newest installed.")
order = 40
params = [Param("tool", "str", "", "compat tool name")]
class ProtonEnv(_PcvrPatch):
id = "pcvr.proton_env"
title = "Extra launch environment (Frame)"
description = "Environment variables for the Proton launcher on the Frame (e.g. DXVK_HUD=fps), one KEY=value per line."
order = 50
params = [Param("env", "text", "", "KEY=value lines")]
for _cls in (Revive, ReviveOpenVR, NoCrashReporter, OculusUnreal, XrTimefix, ProtonLog, ProtonTool, ProtonEnv):
register(_cls)
def game_args(recipe) -> list[str]:
"""Extra command-line arguments for the game itself."""
return ["-nocrashreports"] if "pcvr.no_crash_reporter" in recipe.patches else []
def launch_env(recipe) -> dict[str, str]:
"""Environment for the Frame launcher from the recipe's pcvr patches."""
env = {}
if "pcvr.proton_log" in recipe.patches:
env["PROTON_LOG"] = "1"
raw = recipe.params("pcvr.proton_env").get("env") or ""
items = raw.items() if isinstance(raw, dict) else (line.partition("=")[::2] for line in str(raw).splitlines())
for k, v in items:
k = k.strip()
if k:
env[k] = str(v).strip()
return env
+45 -8
View File
@@ -17,14 +17,17 @@ SETTINGS = [
"Multiplies the recommended eye-buffer width and height (0.5–2.0). 1.5 ≈ 2.25× pixels."),
("foveation_fix", "int", 1, "Hide Quest foveation", "Hides Quest foveation extensions the Frame runtime lacks."),
("controller_fix", "int", 1, "Report Touch controllers",
"Reports Frame controllers as Oculus Touch and hides synthetic hand tracking. 0 for hand-tracking games (Silhouette)."),
"Reports Frame controllers as Oculus Touch and hides synthetic hand tracking. Set 0 for games that require hand "
"tracking (e.g. Silhouette)."),
("swapchain_fix", "int", 1, "Swapchain format fallback", "Retry rejected GLES formats/MSAA with sRGB, samples=1."),
("layer_fix", "int", 1, "Drop invalid layers", "Drop layers whose swapchain failed or whose extension isn't enabled."),
("passthrough_emul", "int", 1, "Emulate passthrough", "XR_FB_passthrough via ALPHA_BLEND (Frame greyscale cameras)."),
("flip_emul", "int", 1, "Emulate flipped quads",
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan; AC Nexus UI)."),
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan). Fixes UI panels and "
"text that show upside down (e.g. Assassin's Creed Nexus)."),
("scene_emul", "int", 0, "Emulate Meta scene (room)",
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls (Demeter)."),
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality "
"games that build their level from the room (e.g. Demeter)."),
("scene_height", "float", 2.5, "Emulated room height (m)", "Ceiling height for scene_emul."),
("scene_width", "float", 0.0, "Emulated room width (m)", "Override the guardian width (0 = use guardian, min 1.5 m)."),
("scene_depth", "float", 0.0, "Emulated room depth (m)", "Override the guardian depth (0 = use guardian, min 1.5 m)."),
@@ -32,9 +35,11 @@ SETTINGS = [
("strip_depth", "int", 0, "Strip depth layers", "Remove XR_KHR_composition_layer_depth chains (diagnostic)."),
("mutable_fix", "int", 0, "Mutable swapchain fix", "Experimental Vulkan mutable-format workaround."),
("respace_kick", "int", 0, "Re-create reference space", "Recreate spaces after the first frames (diagnostic)."),
("flip_quads", "int", 0, "Rotate quads 180°", "Old AC Nexus workaround (quads are single-sided; prefer flip_emul)."),
("flip_quads", "int", 0, "Rotate quads 180°", "Older workaround for upside-down quads: rotates them 180° (quads are single-sided; prefer "
"flip_emul)."),
("gl_hide_multiview", "int", 1, "GL shim: hide multiview",
"GL shim only: hide GL_OVR_multiview so all passes use single-view shaders (Path of the Warrior)."),
"GL shim only: hide GL_OVR_multiview so all passes use single-view shaders. For GLES games whose multiview "
"shaders fail on single-view render targets (e.g. Path of the Warrior)."),
]
@@ -51,10 +56,34 @@ class AdapterSetting(Patch):
self.default = default
def detect(self, a):
from .applicability import needs_scene
if self.key == "controller_fix" and a.extra.get("hand_tracking_only"):
return Suggestion(True, "Hand-tracking game: pass hands through.", {"value": 0})
return Suggestion(True, "Hand tracking is required by the game: pass hands through instead of reporting "
"Touch controllers (e.g. Silhouette).", {"value": 0})
if self.key == "scene_emul" and needs_scene(a):
return Suggestion(True, "Mixed-reality game that builds its level from the room model: emulate a "
"guardian-sized room (e.g. Demeter).", {"value": 1})
return None
def applies(self, a):
from . import applicability as ap
rules = {
"scene_emul": lambda a: ap.uses_scene(a) or a.extra.get("mr_only"),
"scene_height": lambda a: ap.uses_scene(a) or a.extra.get("mr_only"),
"scene_width": lambda a: ap.uses_scene(a) or a.extra.get("mr_only"),
"scene_depth": lambda a: ap.uses_scene(a) or a.extra.get("mr_only"),
"passthrough_emul": lambda a: a.extra.get("mr_only") or "com.oculus.feature.PASSTHROUGH" in (a.extra.get("features") or {}),
"flip_emul": ap.is_vulkan,
"flip_quads": ap.is_vulkan,
"mutable_fix": ap.is_vulkan,
"swapchain_fix": ap.is_gles,
"gl_hide_multiview": lambda a: a.direct_vrapi and ap.is_gles(a),
}
rule = rules.get(self.key)
return bool(rule(a)) if rule else True
def install(self, ctx: InstallContext) -> None:
pass # collected by adapter_settings()
@@ -79,12 +108,20 @@ def adapter_settings(recipe_patches: dict) -> dict:
class DeviceFiles(Patch):
id = "device.files"
title = "Game config files"
description = ("Writes files into the game's Android/data/<package>/files/ on the Frame, e.g. The Climb 2's "
"user.cfg with r_variable_rate_shading = 0 (CryEngine VRS is unsupported on the Frame).")
description = ("Writes config files into the game's Android/data/<package>/files/ on the Frame, to turn off engine "
"features the Frame doesn't support (e.g. a CryEngine user.cfg with r_variable_rate_shading = 0, "
"as The Climb 2 needs).")
category = "device"
stage = "install"
params = [Param("files", "text", {}, "path relative to files/ -> content")]
def detect(self, a):
if a.engine == "CryEngine" and a.graphics.startswith("Vulkan") or (a.engine == "CryEngine" and "libCryRenderVulkan.so" in a.libs):
return Suggestion(True, "CryEngine: variable-rate shading isn't supported on the Frame, so user.cfg turns "
"it off (r_variable_rate_shading = 0; e.g. The Climb 2).",
{"files": {"user.cfg": "r_variable_rate_shading = 0\n"}})
return None
def install(self, ctx: InstallContext) -> None:
for rel, content in (ctx.params.get("files") or {}).items():
ctx.files[rel] = content.encode() if isinstance(content, str) else content
+339 -13
View File
@@ -2,9 +2,14 @@
add (scan/import) → analyze → suggest recipe → [user confirms] → build → static checks
→ install on target → add to library → launch test (+ triage suggestions)
Quest games (APK + OBB) are rebuilt with overport and installed on the Frame (Lepton). Oculus Rift games (Windows PC VR
folders, ids "rift.<slug>") aren't modified: "build" checks them and fetches Revive, and they install either on this
PC (Revive + local Steam) or on the Frame (Proton + Revive).
"""
from __future__ import annotations
import time
from pathlib import Path
from . import build as builder
@@ -15,26 +20,240 @@ from .core.events import Reporter
from .core.models import Recipe, SourceGame
from .core.paths import output_dir
from .recommend import engine
from .sources import quest_dump
from .sources import quest_dump, rift_dump
from .targets.base import Target
def add_path(path: Path, reporter: Reporter | None = None) -> list[dict]:
"""Scan a file/folder, analyze every game found and store it with a suggested recipe."""
def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_rift: bool = False,
art: bool = False) -> list[dict]:
"""Scan a file/folder (recursively), analyze every game found (Quest APKs and Rift game folders) and store it with
a suggested recipe. on_added(entry) is called as each game lands in the library (the GUI streams cards in).
force_rift: `path` is one Rift game folder (added even if no VR runtime is detected). art: fetch artwork too."""
path = Path(path)
added = []
for src in quest_dump.scan(Path(path)):
try:
added.append(add_game(src, reporter))
except Exception as exc: # keep scanning; report the broken one
def done(entry):
added.append(entry)
if on_added:
on_added(entry)
if not force_rift:
for src in quest_dump.scan(path):
if reporter:
reporter.log(f"skipped {src.apk.name}: {exc}")
reporter.check_cancel()
try:
entry = add_game(src, reporter)
if art:
try:
artwork.fetch(entry["package"], Path(entry["apk"]))
except Exception: # noqa: BLE001
pass
if not entry.get("details"):
fetch_details(entry["package"], reporter)
entry = library.game(entry["package"])
done(entry)
except Exception as exc: # keep scanning; report the broken one
if reporter:
reporter.log(f"skipped {src.apk.name}: {exc}")
if path.is_dir():
trees: dict = {}
if reporter:
reporter.stage("Looking for PC VR games")
folders = [path] if force_rift else rift_dump.scan(path, trees=trees)
for i, folder in enumerate(folders):
if reporter:
reporter.check_cancel()
reporter.progress(i / max(len(folders), 1), f"{i + 1} / {len(folders)} · {folder.name}")
try:
entry = add_rift_game(folder, reporter, tree=trees.get(folder), force=force_rift, art=art)
if entry and art and not entry.get("details"):
fetch_details(entry["package"], reporter)
entry = library.game(entry["package"])
if entry:
done(entry)
except Exception as exc:
if reporter:
reporter.log(f"skipped {folder.name}: {exc}")
return added
def _norm_title(t: str) -> str:
import re
t = re.sub(r"\b(vr|quest( edition)?|rift|oculus)\b|[^a-z0-9]+", " ", (t or "").lower())
return " ".join(t.split())
def quest_counterpart(title: str) -> str | None:
"""Package of the Quest version of a Rift game (catalog or library, by title) for linking and store artwork."""
from .recommend import catalog
want = _norm_title(title)
if not want:
return None
for e in catalog.load().values():
if e.kind != "rift" and _norm_title(e.title) == want:
return e.package
for g in library.games():
if g.get("kind") != "rift" and _norm_title(g.get("title") or "") == want:
return g["package"]
return None
def _existing_rift(folder: Path) -> dict | None:
"""The library entry for this game folder: same folder, or the same game scanned one level up/down (a repack's
wrapper folder vs. the game folder inside it)."""
rifts = [g for g in library.games() if g.get("kind") == "rift" and g.get("game_dir")]
exact = next((g for g in rifts if g["game_dir"] == str(folder)), None)
if exact:
return exact
for g in rifts:
other = Path(g["game_dir"])
if other in folder.parents or folder in other.parents:
return g
return None
def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe: str | None = 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)."""
from .analysis import rift
from .recommend import catalog
folder = Path(folder)
old = _existing_rift(folder)
old_extra = ((old or {}).get("analysis") or {}).get("extra") or {}
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:
reporter.log(f"{old.get('title')}: unchanged")
if art and not _has_art(old["package"]):
_rift_art(old, reporter)
return library.game(old["package"])
if reporter:
reporter.log(f"analyzing {folder.name}")
a = rift.analyze(folder, exe=exe, tree=tree)
if not a.extra.get("vr_found") and not force and not old:
if reporter:
reporter.log(f"skipped {folder.name}: no Oculus/OpenXR runtime found in {a.extra['exe']}")
return None
package = old["package"] if old else a.package
a.package = package
recipe = engine.suggest(a)
entry = catalog.lookup(package)
quest = (old or {}).get("quest_package") or (entry.quest_package if entry else None) or \
quest_counterpart(recipe.title or a.label)
fields = dict(kind="rift", name=folder.name, apk=None, game_dir=str(folder), exe=a.extra["exe"],
exe_confirmed=a.extra["exe_confirmed"], data_dir=None, data_bytes=a.extra["data_bytes"],
origin=str(folder.parent), quest_package=quest, analysis=a.to_dict(),
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 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)
stored = library.upsert_game(package, **fields)
if art and not _has_art(package):
_rift_art(stored, reporter)
return library.game(package)
def _has_art(package: str) -> bool:
from .artwork import sources
return sources.has_art(package)
def _rift_art(entry: dict, reporter: Reporter | None = None) -> dict:
"""Find artwork for a Rift game (Quest version / OculusDB / Steam / exe icon) and record what matched."""
from .artwork import sources, thumbs
pkg = entry["package"]
extra = (entry.get("analysis") or {}).get("extra") or {}
try:
found = sources.fetch_rift(pkg, entry.get("title") or pkg, extra.get("canonical_name"),
entry.get("quest_package"), Path(entry["game_dir"]) / entry["exe"])
except Exception as exc: # noqa: BLE001 - artwork is optional
if reporter:
reporter.log(f"{entry.get('title')}: no artwork ({exc})")
return {}
update = {"art_source": found.get("source") or "none"} # "none": don't retry on every start
if found.get("quest_package") and not entry.get("quest_package"):
update["quest_package"] = found["quest_package"]
if found.get("oculus_app_id"):
update["oculus_app_id"] = found["oculus_app_id"]
if found.get("title") and not entry.get("title_locked"):
update["title"] = found["title"]
library.upsert_game(pkg, **update)
try:
thumbs.prewarm(pkg)
except Exception: # noqa: BLE001
pass
if reporter:
reporter.log(f"{entry.get('title')}: artwork from {found.get('source') or 'nowhere'}")
return found
def fetch_art(package: str, reporter: Reporter | None = None) -> dict:
"""(Re)fetch artwork for any game in the library."""
entry = library.game(package)
if entry.get("kind") == "rift":
return _rift_art(entry, reporter)
artwork.fetch(package, Path(entry["apk"]) if entry.get("apk") else None, refresh=True)
return {"source": "meta"}
def fetch_details(package: str, reporter: Reporter | None = None) -> dict:
"""Description, genres, developer/publisher, release date, links and screenshots (see artwork/details.py)."""
from .artwork import details, thumbs
entry = library.game(package)
try:
d = details.fetch_details(entry)
except Exception as exc: # noqa: BLE001 - details are optional
if reporter:
reporter.log(f"{entry.get('title')}: no details ({exc})")
d = {"sources": [], "fetched": time.time()}
library.upsert_game(package, details=d)
for shot in details.screenshot_files(package):
try:
thumbs.thumb(shot, 480, shot.stem)
except Exception: # noqa: BLE001
pass
if reporter:
reporter.log(f"{entry.get('title')}: details from {', '.join(d.get('sources') or []) or 'nowhere'}"
+ (f", {len(d.get('screenshots') or [])} screenshots" if d.get("screenshots") else ""))
return d
def set_exe(package: str, exe: str) -> dict:
"""The user picked the program that starts a Rift game: re-analyze with it (keeps recipe choices and tags)."""
entry = library.game(package)
if not entry or entry.get("kind") != "rift":
raise ValueError(f"{package} is not a Rift game")
folder = Path(entry["game_dir"])
if not (folder / exe).is_file():
raise FileNotFoundError(folder / exe)
return add_rift_game(folder, exe=exe, force=True)
def steam_title(entry: dict) -> str:
"""Name for the Steam library: "<Title> (Quest)" / "(Rift)" while both versions of a game are in the library."""
from .core.titles import display_title, twins
return display_title(entry, twins(library.games()))
def is_rift(entry: dict) -> bool:
return entry.get("kind") == "rift"
def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
if reporter:
reporter.log(f"analyzing {src.apk.name}")
a = analyze(src.apk)
a = analyze(src.apk, data_bytes=src.data_bytes())
recipe = engine.suggest(a)
return library.upsert_game(
a.package, title=recipe.title or a.label, name=src.name, apk=str(src.apk),
@@ -55,14 +274,88 @@ def set_recipe(package: str, recipe: Recipe) -> None:
def reset_recipe(package: str) -> Recipe:
if is_rift(library.game(package) or {}):
entry = library.game(package)
a = library.analysis_from_dict(entry["analysis"])
recipe = engine.suggest(a)
set_recipe(package, recipe)
return recipe
entry = library.game(package)
recipe = engine.suggest(library.analysis_from_dict(entry["analysis"]))
set_recipe(package, recipe)
return recipe
def prepare_rift(package: str, reporter: Reporter) -> dict:
"""Rift games aren't rebuilt: check the folder still matches the analysis and make sure Revive is available."""
from .build import sha256
from .tools import revive
entry = library.game(package)
recipe = library.recipe_from_dict(entry["recipe"])
extra = entry["analysis"].get("extra", {})
reporter.stage("Check game files")
exe = Path(entry["game_dir"]) / entry["exe"]
checks = []
def check(name, ok, msg=""):
checks.append({"name": name, "ok": ok, "message": msg})
reporter.check(name, ok, msg)
check("Game executable", exe.is_file(), str(exe))
check("64-bit" if entry["analysis"]["abis"] == ["x86_64"] else "Executable type",
True if entry["analysis"]["abis"][0] in ("x86", "x86_64") else False, entry["analysis"]["abis"][0])
check("Oculus Platform SDK", None if extra.get("platform_sdk") else True,
"entitlement check: needs the Oculus app with a license you own (PC mode only)"
if extra.get("platform_sdk") else "not used")
if "pcvr.revive" in recipe.patches:
reporter.stage("Revive")
rdir = revive.revive_dir() or revive.install(lambda f: reporter.progress(f, "downloading Revive"))
check("Revive", True, f"{revive.installed_version()} ({rdir})")
art, store_title = artwork.fetch(package, lookup=entry.get("quest_package"))
info = {"sha256": sha256(exe) if exe.is_file() else None, "checks": checks,
"ok": all(c["ok"] is not False for c in checks), "revive": revive.installed_version(), "kind": "rift"}
library.upsert_game(package, build=info)
return info
def prepare_as_is(package: str, reporter: Reporter) -> dict:
"""'Install as is': the APK is used unchanged (already patched). Only checks that it can start in Lepton."""
from .apk import axml
from .build import sha256
entry = library.game(package)
apk = Path(entry["apk"])
a = entry["analysis"]
reporter.stage("Checking the game (installing it as it is)")
checks = []
def check(name, ok, msg=""):
checks.append({"name": name, "ok": ok, "message": msg})
reporter.check(name, ok, msg)
check("APK", apk.is_file(), str(apk))
check("64-bit (arm64-v8a)", "arm64-v8a" in (a.get("abis") or []) or None, ", ".join(a.get("abis") or []))
try:
import zipfile
with zipfile.ZipFile(apk) as z:
cats = axml.categories(z.read("AndroidManifest.xml"))
check("Launcher entry for Lepton", axml.LAUNCHER in cats or None,
"present" if axml.LAUNCHER in cats else "missing: Lepton may not find the game (turn off 'Install as is')")
except Exception as exc: # noqa: BLE001
check("Manifest", None, str(exc))
art, store_title = artwork.fetch(package, apk)
info = {"apk": str(apk), "alt_apk": None, "sha256": sha256(apk), "alt_sha256": None, "applied": [],
"checks": checks, "ok": all(c["ok"] is not False for c in checks), "as_is": True}
library.upsert_game(package, build=info, title=entry.get("title") or store_title)
return info
def build_game(package: str, reporter: Reporter, outdir: Path | None = None) -> dict:
entry = library.game(package)
if is_rift(entry):
return prepare_rift(package, reporter)
if library.recipe_from_dict(entry["recipe"]).as_is:
return prepare_as_is(package, reporter)
src = source_of(entry)
a = library.analysis_from_dict(entry["analysis"])
recipe = library.recipe_from_dict(entry["recipe"])
@@ -79,24 +372,57 @@ def build_game(package: str, reporter: Reporter, outdir: Path | None = None) ->
def install_game(package: str, target: Target, reporter: Reporter, apk_only: bool = False,
add_to_library: bool = True) -> dict:
entry = library.game(package)
if is_rift(entry):
return install_rift(package, target, reporter, add_to_library)
b = entry.get("build") or {}
recipe = library.recipe_from_dict(entry["recipe"])
apk = Path(b["alt_apk"] if recipe.use_alt and b.get("alt_apk") else b["apk"])
data_dir = Path(entry["data_dir"]) if entry.get("data_dir") else None
title = entry.get("title") or package
title = steam_title(entry)
result = target.install(package, title, apk, data_dir, recipe, reporter, apk_only)
if add_to_library:
target.add_to_library([package], reporter)
installs = entry.get("installs", {})
installs[target.label] = {"apk": str(apk), "result": result}
installs[target.label] = {"apk": str(apk), "result": result, "time": time.time()}
library.upsert_game(package, installs=installs)
return result
def install_rift(package: str, target: Target, reporter: Reporter, add_to_library: bool = True) -> dict:
from .tools import revive
entry = library.game(package)
if not (entry.get("build") or {}).get("ok"):
prepare_rift(package, reporter)
entry = library.game(package)
recipe = library.recipe_from_dict(entry["recipe"])
title = steam_title(entry)
rdir = revive.revive_dir() if "pcvr.revive" in recipe.patches else None
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"))
if add_to_library:
target.add_to_library([package], reporter)
installs = entry.get("installs", {})
installs[target.label] = {"exe": entry["exe"], "result": result, "time": time.time()}
library.upsert_game(package, installs=installs)
return result
def test_game(package: str, target: Target, reporter: Reporter, seconds: int = 45) -> dict:
result, _log = target.launch_test(package, reporter, seconds)
result, log = target.launch_test(package, reporter, seconds)
from .core.paths import user_data_dir
logs = user_data_dir() / "logs"
logs.mkdir(exist_ok=True)
log_path = logs / f"{package}-{time.strftime('%Y%m%d-%H%M%S')}.log"
log_path.write_text(log or "", encoding="utf-8", errors="replace")
for old in sorted(logs.glob(f"{package}-*.log"))[:-5]: # keep the last 5 per game
old.unlink(missing_ok=True)
reporter.log(f"full launch log ({len((log or '').splitlines())} lines): {log_path}")
summary = {"state": result.state, "verdict": result.verdict, "milestone": result.milestone, "fps": result.fps,
"findings": [f.__dict__ for f in result.findings], "suggestions": result.suggestions()}
"findings": [f.__dict__ for f in result.findings], "suggestions": result.suggestions(),
"time": time.time(), "target": target.label, "log_path": str(log_path)}
library.upsert_game(package, last_test=summary)
return summary
+9 -1
View File
@@ -38,6 +38,14 @@ class CatalogEntry:
device_files: dict = field(default_factory=dict)
lepton_env: dict = field(default_factory=dict)
pcvr_alternative: str | None = None
# Rift (PC VR) recipes: package is "rift.<slug>"; kind "rift"
kind: str = "quest"
quest_package: str | None = None # the Quest version of the same game (links the two in the UI)
pcvr: list[str] = field(default_factory=list) # pcvr.* patches to enable
pcvr_remove: list[str] = field(default_factory=list)
proton_env: dict = field(default_factory=dict)
proton_tool: str = ""
as_is: bool = False # Rift: the dump runs unchanged (e.g. a SteamVR build: no Revive)
verified: dict = field(default_factory=dict)
source_hint: str = ""
origin: str = "bundled"
@@ -51,7 +59,7 @@ class CatalogEntry:
out = {}
for k, f in self.__dataclass_fields__.items():
v = getattr(self, k)
if k == "origin" or v in (None, "", [], {}, False):
if k == "origin" or v in (None, "", [], {}, False) or (k == "kind" and v == "quest"):
continue
out[k] = v
return out
+74 -3
View File
@@ -9,17 +9,24 @@ from ..patches import base
from . import catalog
def suggest(analysis: Analysis) -> Recipe:
entry = catalog.lookup(analysis.package)
def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
entry = catalog.lookup(analysis.package) if use_catalog else None
recipe = Recipe(analysis.package, title=analysis.label)
# 1. heuristics / defaults from every patch module
rift = (analysis.extra or {}).get("kind") == "rift"
for patch in base.all_patches():
if not base.for_game(patch, analysis):
continue # Quest patches for Quest games, PC VR (Revive) options for Rift games
if not patch.default_on and not patch.applies(analysis):
continue # irrelevant for this game (engine, XR API, graphics API, ...)
s = patch.detect(analysis)
if s and s.recommended:
recipe.patches[patch.id] = dict(s.params)
recipe.reasons[patch.id] = s.reason
# 2. known-good catalog recipe overrides heuristics
if entry:
if rift:
_rift_recipe(analysis, recipe, entry)
elif entry:
recipe.source = f"catalog ({entry.origin})"
recipe.status, recipe.notes, recipe.title = entry.status, entry.notes, entry.title or recipe.title
why = f"Known-good recipe for {entry.title} (tested {entry.verified.get('date', '?')})."
@@ -54,6 +61,11 @@ def suggest(analysis: Analysis) -> Recipe:
if analysis.only_32bit:
recipe.status = "unsupported"
recipe.notes = "32-bit only: the Steam Frame has no AArch32 support. Consider the PC (Rift) version via Revive."
if not rift and not entry and (analysis.extra or {}).get("frame_patched"):
# already has FramePort's adapter (e.g. a PATCHED/ build): installing it unchanged is the safe default
recipe.as_is = True
recipe.notes = (recipe.notes + " " if recipe.notes else "") + \
"This APK is already patched for the Frame, so FramePort installs it as it is."
# requirements
for pid in list(recipe.patches):
for req in base.get(pid).requires:
@@ -63,8 +75,56 @@ def suggest(analysis: Analysis) -> Recipe:
return recipe
def _rift_recipe(analysis: Analysis, recipe: Recipe, entry) -> None:
extra = analysis.extra
if entry:
recipe.source = f"catalog ({entry.origin})"
recipe.status, recipe.notes, recipe.title = entry.status, entry.notes, entry.title or recipe.title
why = f"Known-good recipe for {entry.title} (tested {entry.verified.get('date', '?')})."
for pid in entry.pcvr:
recipe.patches.setdefault(pid, {})
recipe.reasons[pid] = why
for pid in entry.pcvr_remove:
recipe.patches.pop(pid, None)
if entry.proton_env:
recipe.patches["pcvr.proton_env"] = {"env": "\n".join(f"{k}={v}" for k, v in entry.proton_env.items())}
recipe.reasons["pcvr.proton_env"] = why
if entry.proton_tool:
recipe.patches["pcvr.proton_tool"] = {"tool": entry.proton_tool}
recipe.reasons["pcvr.proton_tool"] = why
recipe.as_is = entry.as_is
# drop patches whose requirement the catalog removed (e.g. revive_openvr once revive is gone), so the
# requirement pass in suggest() doesn't re-add it
for pid in list(recipe.patches):
if any(req not in recipe.patches for req in base.get(pid).requires):
recipe.patches.pop(pid, None)
recipe.reasons.pop(pid, None)
return
recipe.source = "heuristics"
recipe.as_is = True # the dump is installed unchanged; Revive (when on) is a launch-time wrapper, not a file edit
notes = []
if extra.get("frame_native"):
notes.append("No Oculus code (SteamVR/OpenXR): runs on the Frame and PC directly, without Revive.")
elif extra.get("needs_revive"):
notes.append("Oculus/LibOVR game: needs Revive, which works in PC mode with SteamVR running. Not supported on "
"the Steam Frame (Revive can't run there).")
if extra.get("platform_sdk"):
notes.append("Uses the Oculus Platform SDK (entitlement check): normally it needs the Oculus app running with a "
"license you own, so it may not start.")
if analysis.abis and analysis.abis[0] not in ("x86", "x86_64"):
recipe.status = "unsupported"
notes.append(f"Unexpected executable type {analysis.abis[0]}.")
recipe.notes = " ".join(notes)
def warnings(recipe: Recipe) -> list[str]:
out = []
if recipe.package.startswith("rift."):
for pid in recipe.patches:
for r in base.get(pid).requires:
if r not in recipe.patches:
out.append(f"{base.get(pid).title} needs {base.get(r).title}.")
return out
for pid in recipe.patches:
p = base.get(pid)
for c in p.conflicts:
@@ -78,3 +138,14 @@ def warnings(recipe: Recipe) -> list[str]:
if "frame.adapter" not in recipe.patches:
out.append("Without the FrameBridge adapter most games fail on the Frame runtime.")
return out
def visible_patches(analysis: Analysis, recipe: Recipe | None = None) -> tuple[list, list]:
"""(relevant, hidden) patches for this game. Enabled patches are always shown, so a recipe never hides a choice."""
shown, hidden = [], []
for p in base.all_patches():
if not base.for_game(p, analysis):
continue # other kind of game entirely (Quest vs Rift): not even offered under "show all"
on = recipe is not None and p.id in recipe.patches
(shown if on and not p.default_on or p.applies(analysis) else hidden).append(p)
return shown, hidden
+63
View File
@@ -0,0 +1,63 @@
"""Find Oculus Rift (Windows PC VR) games on disk.
When scanning, the chosen folder's subfolders are the games (the folder itself only if a program sits directly in
it). A subfolder is **one game** when it has candidate programs (see analysis.rift.candidates), no APKs (those are Quest
dumps) and all candidates sit at its top level or under a single subfolder — repacks put the game one or more levels
down, next to installers and archives. Otherwise it's a **collection**: every subfolder is one game (or, again, a
collection), e.g. ARMGDDN_PCVR_OCULUS/<Game vX -TAG>/<Game>/…/Game.exe. Loose programs in a collection are ignored.
"""
from __future__ import annotations
from pathlib import Path
from ..analysis import rift
def classify(path: Path, tree: rift.Tree | None = None) -> tuple[str, rift.Tree]:
"""("game" | "collection" | "none", walk result)."""
tree = tree or rift.walk(path)
cands = rift.candidates(tree)
if not cands:
return ("collection" if tree.has_apk else "none"), tree
places = {tree.child_of(p) for p in cands}
if not tree.has_apk and len(places - {"."}) <= 1:
return "game", tree
return "collection", tree
def is_game_folder(path: Path) -> bool:
return Path(path).is_dir() and classify(Path(path))[0] == "game"
def scan(root: Path, depth: int = 4, trees: dict | None = None) -> list[Path]:
"""Game folders under root (root itself if it is one game). `trees` collects the walk results by folder, so the
analysis doesn't walk again."""
root = Path(root)
if not root.is_dir():
return []
kind, tree = classify(root)
if trees is not None:
trees[root] = tree
cands = rift.candidates(tree)
# the scanned folder is itself a game only if a candidate sits directly in it; otherwise each subfolder is a game
# (or a collection) — "one folder per game"
if kind == "game" and any(tree.child_of(p) == "." for p in cands):
return [root]
if kind == "none" or depth <= 0:
return []
found = []
children_with_games = {tree.child_of(p) for p in cands} - {"."}
if tree.has_apk: # mixed downloads folder: look at every subfolder
children_with_games = {p.name for p in root.iterdir() if p.is_dir()}
for name in sorted(children_with_games):
child = root / name
if name.startswith((".", "_")) or not child.is_dir():
continue
ckind, ctree = classify(child)
if ckind == "game":
if trees is not None:
trees[child] = ctree
found.append(child)
elif ckind == "collection":
found += scan(child, depth - 1, trees)
return found
+11 -2
View File
@@ -1,5 +1,5 @@
"""Install targets. The pipeline only talks to this interface, so new targets (e.g. Revive for PC/Rift games) plug in
without touching the build, recommend or UI layers."""
"""Install targets. The pipeline only talks to this interface, so new targets plug in without touching the build,
recommend or UI layers. Quest games go through install(); Oculus Rift (PC VR) games through install_pcvr()."""
from __future__ import annotations
from abc import ABC, abstractmethod
@@ -29,8 +29,17 @@ class Target(ABC):
@abstractmethod
def launch_test(self, package: str, reporter: Reporter, seconds: int = 45): ...
def launch(self, package: str) -> dict:
"""Start an installed game for playing, through the target's Steam (Steam library shortcut)."""
raise NotImplementedError(f"{self.label} can't launch games")
@abstractmethod
def set_settings(self, package: str, settings: dict) -> dict: ...
@abstractmethod
def uninstall(self, package: str, keep_data: bool = True) -> dict: ...
def install_pcvr(self, package: str, title: str, game_dir: Path, exe: str, recipe: Recipe, reporter: Reporter,
**extra) -> dict:
"""Install an Oculus Rift (Windows PC VR) game: game_dir + its exe (relative), launched through Revive."""
raise NotImplementedError(f"{self.label} can't install PC VR games")
+38 -1
View File
@@ -1,4 +1,5 @@
"""Steam Frame target: one Lepton container per game + a Steam library shortcut."""
"""Steam Frame target: one Lepton container per Quest game, Proton for Oculus Rift (PC VR) games, plus a Steam
library shortcut for each."""
from __future__ import annotations
from pathlib import Path
@@ -37,15 +38,51 @@ class FrameLeptonTarget(Target):
def add_to_library(self, packages, reporter):
return installer.add_to_steam(self.connect().frame, packages, reporter)
def update_steam_art(self, package, reporter):
return installer.update_steam_art(self.connect().frame, package, reporter)
def launch_test(self, package, reporter, seconds=45):
return device.launch_test(self.connect().frame, package, reporter, seconds)
def launch(self, package):
return self.connect().frame.agent("launch", package=package, timeout=60)
def set_settings(self, package, settings):
return self.connect().frame.agent("set_settings", package=package, settings=settings)
def uninstall(self, package, keep_data=True):
return self.connect().frame.agent("uninstall", package=package, keep_data=keep_data, remove_shortcut=True)
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"))
return installer.install_pcvr(self.connect().frame, plan, reporter)
def proton_status(self, tool: str | None = None) -> dict:
return self.connect().frame.agent("proton_status", tool=tool)
def install_proton(self, unattended: bool = False, tool: str | None = None) -> dict:
"""Ask Steam on the Frame to install Proton (ARM64) + its runtime. unattended: no confirmation in the headset
(Steam restarts once and downloads in the background)."""
return self.connect().frame.agent("install_proton", mode="unattended" if unattended else "request", tool=tool)
def proton_selftest(self, tool: str | None = None, **opts) -> dict:
"""Runs cmd.exe under the Frame's Proton (first run creates the prefix: a minute or two). opts: vr (default
True: SteamGameId set, Proton sets up wineopenxr), log (PROTON_LOG), env, timeout."""
seconds = int(opts.pop("timeout", 600))
if opts.pop("layer", False): # also load FramePort's timefix OpenXR layer (as installed games do)
from ..core.paths import artifacts_dir
remote = "/home/" + self.target.user + "/.local/share/frameport/xrlayer"
self.frame.run(f"mkdir -p '{remote}'")
for f in sorted((artifacts_dir() / "linux-arm64").iterdir()):
self.frame.put(f, f"{remote}/{f.name}", resume=False)
env = dict(opts.get("env") or {})
env.update(XR_API_LAYER_PATH=remote, XR_ENABLE_API_LAYERS="XR_APILAYER_FRAMEPORT_timefix")
opts["env"] = env
return self.connect().frame.agent("proton_selftest", timeout=seconds + 120, tool=tool,
**{"timeout_s": seconds, **opts})
def install_lepton(self) -> dict:
return self.connect().frame.agent("install_lepton")
+289
View File
@@ -0,0 +1,289 @@
"""PC VR target: Oculus Rift games run on this Windows PC through Revive (LibOVR -> OpenXR/SteamVR), with a non-Steam
shortcut in the local Steam library (play on the Frame by streaming from Steam/SteamVR).
Works on native Windows and from WSL (Windows programs via interop). Revive is FramePort's portable copy
(tools/revive.py), so nothing is installed system-wide. Nothing is copied: the shortcut points at the game folder.
"""
from __future__ import annotations
import importlib.util
import json
import shutil
import time
from pathlib import Path
from ..core import winhost
from ..core.events import Reporter
from ..core.models import Recipe
from ..core.paths import agent_dir, user_data_dir
from ..patches.pcvr import game_args
from ..validate.triage import triage
from .base import Target
TAG = "Rift via Revive"
REVIVE_LOG = "Revive/ReviveInjector.txt" # under %LOCALAPPDATA%
def _vdf():
"""The agent's binary-VDF/shortcut code (stdlib only), reused so there's a single implementation."""
spec = importlib.util.spec_from_file_location("frameport_agent_vdf", agent_dir() / "frameport_agent.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def windows_revive(rdir: Path) -> Path:
"""Revive where Windows can run it: on WSL FramePort's data lives on the Linux filesystem (\\wsl$ paths are
unreliable for process creation / DLL injection), so a copy goes to %LOCALAPPDATA%\\FramePort\\<revive-version>."""
if not winhost.is_wsl() or str(rdir).startswith("/mnt/"):
return rdir
from ..tools import revive
local = winhost.env_path("LOCALAPPDATA")
if not local:
return rdir
dest = local / "FramePort" / rdir.name
for f in revive.runtime_files(rdir):
target = dest / f.relative_to(rdir)
if not target.exists() or target.stat().st_size != f.stat().st_size:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(f, target)
return dest
def pc_dir() -> Path:
path = user_data_dir() / "pc"
path.mkdir(parents=True, exist_ok=True)
return path
def shortcut_fields(dep: dict) -> tuple[str, str, str]:
"""(Exe, StartDir, LaunchOptions) for the Steam shortcut of an installed game."""
exe_win = dep["exe_win"]
start = '"' + exe_win.rsplit("\\", 1)[0] + '\\"'
extra = " ".join(dep.get("game_args") or [])
if dep.get("revive_win"):
args = ("" if dep.get("backend") == "openvr" else "/openxr ") + f'"{exe_win}"' + (f" {extra}" if extra else "")
return f'"{dep["revive_win"]}\\ReviveInjector.exe"', start, args
return f'"{exe_win}"', start, extra
def local_installs() -> dict[str, dict]:
"""PC installs by package (just reads FramePort's records; works without Windows)."""
out = {}
for f in sorted((user_data_dir() / "pc").glob("*/deployment.json")):
try:
dep = json.loads(f.read_text(encoding="utf-8"))
out[dep["package"]] = dep
except (OSError, ValueError, KeyError):
continue
return out
class PcReviveTarget(Target):
kind = "pc"
label = "This PC"
def __init__(self):
if not winhost.available():
raise RuntimeError("PC VR games need Windows (or FramePort running in WSL on Windows)")
self.steam = winhost.steam_root()
# ------------------------------------------------------------------ info
def describe(self) -> dict:
from ..tools import revive
root = self.steam
return {
"platform": "wsl" if winhost.is_wsl() else "windows",
"steam": str(root) if root else None,
"steam_user": bool(root and winhost.steam_user(root)),
"steam_running": winhost.process_running("steam.exe"),
"steamvr": bool(root and winhost.app_installed(root, winhost.STEAMVR_APPID)),
"revive": str(revive.revive_dir()) if revive.revive_dir() else None,
"revive_version": revive.installed_version(),
"installed": self.installed(),
}
def installed(self) -> list[dict]:
out = []
for f in sorted(pc_dir().glob("*/deployment.json")):
try:
dep = json.loads(f.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
dep["apk_present"] = Path(dep.get("exe_local", "")).exists()
out.append(dep)
return out
def _dep(self, package: str) -> dict:
try:
return json.loads((pc_dir() / package / "deployment.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
raise RuntimeError(f"{package} is not installed on this PC") from None
# ------------------------------------------------------------------ install
def install(self, package, title, apk, data_dir, recipe, reporter, apk_only=False):
raise NotImplementedError("Quest (APK) games install on the Steam Frame, not on the PC")
def install_pcvr(self, package, title, game_dir, exe, recipe: Recipe, reporter: Reporter, **extra):
from ..tools import revive
reporter.stage("Prepare this PC")
if not self.steam:
raise RuntimeError("Steam for Windows was not found on this PC")
reporter.check("Steam", True, str(self.steam))
vr = winhost.app_installed(self.steam, winhost.STEAMVR_APPID)
reporter.check("SteamVR", vr or None, "installed" if vr else "not installed: install SteamVR from Steam")
use_revive = "pcvr.revive" in recipe.patches
rdir = None
if use_revive:
rdir = revive.revive_dir() or revive.install(lambda f: reporter.progress(f, "downloading Revive"))
rdir = windows_revive(rdir)
reporter.check("Revive", True, f"{revive.installed_version()} at {winhost.to_windows(rdir)}")
game_dir = Path(game_dir)
exe_local = game_dir / exe
if not exe_local.is_file():
raise RuntimeError(f"game executable missing: {exe_local}")
dep = {"package": package, "kind": "pcvr", "title": title, "game_dir": str(game_dir),
"exe_local": str(exe_local), "exe_win": winhost.to_windows(exe_local),
"revive_win": winhost.to_windows(rdir) if rdir else None,
"revive_version": revive.installed_version() if rdir else None,
"backend": "openvr" if "pcvr.revive_openvr" in recipe.patches else "openxr",
"game_args": game_args(recipe),
"sha256": extra.get("exe_sha256"), "art_lookup": extra.get("art_lookup"),
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source}, "time": time.time()}
exe_field, _, _ = shortcut_fields(dep)
dep["appid"] = _vdf().shortcut_appid(exe_field, title)
d = pc_dir() / package
d.mkdir(parents=True, exist_ok=True)
(d / "deployment.json").write_text(json.dumps(dep, indent=2), encoding="utf-8")
reporter.log(f"ready to run from {dep['exe_win']}" + (" through Revive" if rdir else ""))
return {"ok": True, "appid": dep["appid"], "exe": dep["exe_win"]}
def add_to_library(self, packages, reporter):
from ..artwork import fetch as artwork
from ..artwork import sources
from ..artwork.steam import steam_set, steam_tags
from ..core import library
reporter.stage("Add to Steam library (this PC)")
root = self.steam
user = winhost.steam_user(root) if root else None
if not user:
raise RuntimeError("could not tell which Steam user to add the games for (log in to Steam once)")
vdf_mod = _vdf()
vdf = root / "userdata" / user / "config" / "shortcuts.vdf"
was_running = winhost.stop_steam(root)
added, errors = [], []
try:
grid = vdf.parent / "grid"
grid.mkdir(parents=True, exist_ok=True)
for pkg in packages:
try:
dep = self._dep(pkg)
exe, start, opts = shortcut_fields(dep)
entry = library.game(pkg) or {"kind": "rift"}
if not sources.has_art(pkg):
artwork.fetch(pkg, lookup=dep.get("art_lookup"))
art = steam_set(pkg)
for stale in vdf_mod.prune_shortcuts(str(vdf), dep["title"], exe, TAG):
for old in grid.glob(f"{stale}*"):
old.unlink(missing_ok=True)
reporter.log(f"removed the old Steam entry for {dep['title']} (its launch command changed)")
ident = vdf_mod.shortcut_appid(exe, dep["title"])
icon = dep["exe_win"]
if "icon" in art: # Steam wants a local path for the shortcut icon
icon_path = grid / f"{ident}_icon.png"
shutil.copy(art["icon"], icon_path)
icon = winhost.to_windows(icon_path)
got = vdf_mod.upsert_shortcut(str(vdf), exe, dep["title"], start, icon, TAG, opts,
tags=steam_tags(entry, "pc"))
for kind, f in art.items():
suffix = {"portrait": "p", "landscape": "", "hero": "_hero", "logo": "_logo"}.get(kind)
if suffix is None:
continue
for old in grid.glob(f"{got}{suffix}.*"):
old.unlink()
shutil.copy(f, grid / f"{got}{suffix}{f.suffix}")
added.append({"package": pkg, "appid": got})
reporter.check(f"Steam library (PC): {dep['title']}", True, f"shortcut {got}")
except Exception as exc: # noqa: BLE001
errors.append(f"{pkg}: {exc}")
reporter.check("Steam library (PC)", False, f"{pkg}: {exc}")
finally:
if was_running:
winhost.start_steam(root)
return {"state": "done" if not errors else "failed", "added": added, "errors": errors}
# ------------------------------------------------------------------ test / manage
def launch(self, package):
"""Through Windows Steam's shortcut, like clicking Play in Steam. Starts SteamVR first (Revive needs it, or the
game runs as a flat window)."""
dep = self._dep(package)
root = winhost.steam_root()
if not root or not dep.get("appid"):
raise RuntimeError("Steam or the game's Steam shortcut wasn't found on this PC")
vr = winhost.start_steamvr(root) # Revive binds to SteamVR; without it the game falls back to flatscreen
winhost.start_detached(root / "steam.exe", [f"steam://rungameid/{(int(dep['appid']) << 32) | 0x02000000}"])
return {"package": package, "title": dep.get("title"), "steamvr": vr}
def launch_test(self, package, reporter, seconds=45):
reporter.stage("Launch test (this PC)")
dep = self._dep(package)
image = Path(dep["exe_local"]).name
if winhost.process_running(image):
raise RuntimeError(f"{dep['title']} is already running")
root = winhost.steam_root()
if root and dep.get("revive_win"): # a direct injector start bypasses Steam, so bring SteamVR up ourselves
reporter.stage("Starting SteamVR")
if not winhost.start_steamvr(root):
reporter.log("SteamVR didn't start; the game may run as a flat window (no VR).")
exe, _, opts = shortcut_fields(dep)
exe_path = winhost.to_local(exe.strip('"'))
import shlex
args = shlex.split(opts, posix=True) if opts else []
log_path = (winhost.env_path("LOCALAPPDATA") or Path("/nonexistent")) / REVIVE_LOG
before = log_path.stat().st_mtime if log_path.exists() else 0
winhost.start_detached(exe_path, args, cwd=Path(dep["exe_local"]).parent)
start, seen, state = time.time(), False, "RUNNING"
while time.time() - start < seconds:
time.sleep(3)
running = winhost.process_running(image)
seen = seen or running
if seen and not running:
state = "EXITED"
break
if not seen:
state = "NEVER_STARTED"
winhost.kill(image)
log = ""
if log_path.exists() and log_path.stat().st_mtime >= before:
log = "===== ReviveInjector.txt\n" + log_path.read_text(encoding="utf-8", errors="replace")
result = triage(log, state, package)
reporter.check("Process", state == "RUNNING", f"{state} after {round(time.time() - start)}s")
for m in result.milestones:
reporter.check(m, True)
for f in result.findings:
reporter.check(f.id, None if f.severity in ("warning", "info") else False, f"{f.diagnosis} [{f.evidence[:160]}]")
return result, log
def set_settings(self, package, settings):
return {"settings": {}}
def uninstall(self, package, keep_data=True):
dep = self._dep(package)
root = self.steam
user = winhost.steam_user(root) if root else None
removed = False
if user:
vdf = root / "userdata" / user / "config" / "shortcuts.vdf"
was_running = winhost.stop_steam(root)
try:
removed = _vdf().remove_shortcut(str(vdf), shortcut_fields(dep)[0])
finally:
if was_running:
winhost.start_steam(root)
shutil.rmtree(pc_dir() / package, ignore_errors=True)
return {"removed": True, "shortcut_removed": removed, "kept_saves": True}
-19
View File
@@ -1,19 +0,0 @@
"""Planned: PC VR target via Revive (Rift/Meta PC store games → SteamVR, streamed to the Frame).
Not implemented yet. It will implement targets.base.Target: detect a Revive install on the PC, register the game
with SteamVR (vrmanifest), and use Steam's streaming to the Frame. Catalog entries already carry
`pcvr_alternative` notes for games that can't run standalone (32-bit titles, GPU driver crashes).
"""
from __future__ import annotations
from .base import Target
class ReviveTarget(Target): # pragma: no cover - placeholder
kind = "revive"
label = "PC VR (Revive)"
def _todo(self, *a, **k):
raise NotImplementedError("The Revive target is planned but not implemented yet.")
describe = installed = install = add_to_library = launch_test = set_settings = uninstall = _todo
+256
View File
@@ -0,0 +1,256 @@
"""Revive (LibreVR) as a portable tool: the latest ReviveInstaller.exe is downloaded from GitHub and the files FramePort
needs are unpacked from it in pure Python (no installer run, no admin rights, works on every host OS).
Revive only ships as an NSIS installer. Its data is non-solid deflate, so each file is a separate deflate stream; we
read the NSIS header (entries + string table) to map file names to data offsets. Only the runtime files are kept:
ReviveInjector.exe, LibRevive{32,64}.dll, LibReviveXR{32,64}.dll, openvr_api{32,64}.dll and the Input/ bindings.
Which Revive is used, in order: FRAMEPORT_REVIVE_DIR (a folder containing ReviveInjector.exe), a Revive the user
installed on this Windows PC (the official installer: registry HKLM/HKCU\\Software\\Revive, default
C:\\Program Files\\Revive), then FramePort's own portable copy (downloaded when first needed).
"""
from __future__ import annotations
import json
import os
import struct
import zlib
from dataclasses import dataclass
from pathlib import Path
from ..core import cache
from ..core.paths import tools_dir
REVIVE_RELEASE = "https://api.github.com/repos/LibreVR/Revive/releases/latest"
INSTALLER = "ReviveInstaller.exe"
RUNTIME_FILES = ("ReviveInjector.exe", "LibRevive32.dll", "LibRevive64.dll", "LibReviveXR32.dll", "LibReviveXR64.dll",
"openvr_api32.dll", "openvr_api64.dll", "LICENSE")
KEEP_DIRS = ("Input",)
NSIS_SIG = b"\xef\xbe\xad\xdeNullsoftInst"
EW_CREATEDIR, EW_EXTRACTFILE = 11, 20 # NSIS 3 opcodes (SetOutPath, File)
NS_LANG, NS_SHELL, NS_VAR, NS_SKIP = 1, 2, 3, 4 # Unicode NSIS 3 special codes, each followed by one 16-bit param
VAR_INSTDIR, VAR_OUTDIR_BASE = 21, 31 # $INSTDIR, $_OUTDIR (File /r base folder; equals $INSTDIR here)
class NsisError(Exception):
pass
@dataclass
class NsisFile:
path: str # relative to $INSTDIR (backslashes turned into /)
offset: int # absolute offset of the block (4-byte length prefix) in the installer
def _read_block(data: bytes, offset: int) -> bytes:
n = struct.unpack_from("<I", data, offset)[0]
raw = data[offset + 4:offset + 4 + (n & 0x7FFFFFFF)]
return zlib.decompressobj(-15).decompress(raw) if n & 0x80000000 else raw
def nsis_files(data: bytes) -> list[NsisFile]:
"""Files an NSIS 3 (Unicode, non-solid deflate) installer extracts, in script order."""
sig = data.find(NSIS_SIG)
if sig < 4:
raise NsisError("not an NSIS installer")
start = sig + 24 # after firstheader: flags, siginfo, magic, header length, total length
first = struct.unpack_from("<I", data, start)[0]
if not first & 0x80000000:
raise NsisError("unsupported NSIS layout (solid or uncompressed header)")
try:
header = _read_block(data, start)
except zlib.error as exc:
raise NsisError(f"unsupported NSIS compression: {exc}") from None
data_base = start + 4 + (first & 0x7FFFFFFF)
blocks = [struct.unpack_from("<II", header, 4 + 8 * k) for k in range(8)]
(ent_off, n_ent), (str_off, _) = blocks[2], blocks[3]
str_end = blocks[4][0]
strings = header[str_off:str_end]
if strings[:2] != b"\0\0":
raise NsisError("ANSI NSIS installers are not supported")
def text(index: int) -> str:
out, k = [], index * 2
while k + 2 <= len(strings):
c = struct.unpack_from("<H", strings, k)[0]
k += 2
if c == 0:
break
if c in (NS_LANG, NS_SHELL, NS_VAR, NS_SKIP):
param = struct.unpack_from("<H", strings, k)[0]
k += 2
if c == NS_SKIP:
out.append(chr(param))
elif c == NS_VAR and ((param & 0x7F) | ((param & 0x7F00) >> 1)) in (VAR_INSTDIR, VAR_OUTDIR_BASE):
out.append("\1") # the install folder
else:
out.append("\0") # another variable / shell folder / language string
continue
out.append(chr(c))
return "".join(out)
files, outdir = [], ""
for k in range(n_ent):
which, *parm = struct.unpack_from("<7I", header, ent_off + 28 * k)
if which == EW_CREATEDIR:
path = text(parm[0])
# only folders under the install folder; others ($PLUGINSDIR, $TEMP, ...) are ignored
ok = path.startswith("\1") and "\0" not in path and "\1" not in path[1:]
outdir = path[1:].strip("\\").replace("\\", "/") if ok else None
elif which == EW_EXTRACTFILE and outdir is not None:
name = text(parm[1])
if "\0" in name or "\1" in name:
continue
files.append(NsisFile(f"{outdir}/{name}" if outdir else name, data_base + parm[2]))
return files
def extract(installer: Path, dest: Path, wanted=None) -> list[str]:
"""Unpack the selected files (default: Revive's runtime files) into dest. Returns the relative paths written."""
data = installer.read_bytes()
keep = wanted or (lambda p: p in RUNTIME_FILES or p.split("/")[0] in KEEP_DIRS)
written = []
for f in nsis_files(data):
if not keep(f.path) or f.path in written:
continue
target = dest / f.path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(_read_block(data, f.offset))
written.append(f.path)
missing = [n for n in RUNTIME_FILES if n not in written] if wanted is None else []
if missing:
raise NsisError(f"installer is missing {', '.join(missing)}")
return written
# ------------------------------------------------------------------------------------------ tool management
def _state_file() -> Path:
return tools_dir() / "revive.json"
def _usable(path: Path | None) -> bool:
return bool(path) and (path / "ReviveInjector.exe").is_file() and (path / "LibReviveXR64.dll").is_file()
_system_cache: tuple[float, Path | None] | None = None
def system_revive(max_age: float = 60) -> Path | None:
"""A Revive installed with the official installer on this Windows PC (also found from WSL). The default folder is
checked first; the registry (slow from WSL) only when it isn't there. Cached for max_age seconds."""
import time
from ..core import winhost
global _system_cache
if _system_cache and time.time() - _system_cache[0] < max_age:
return _system_cache[1]
found = None
if winhost.available():
defaults = [Path(os.environ[v]) / "Revive" for v in ("ProgramW6432", "ProgramFiles") if os.environ.get(v)] \
if winhost.is_windows() else []
defaults.append(winhost.to_local(r"C:\Program Files\Revive"))
found = next((c for c in defaults if _usable(c)), None)
if not found:
for hive in ("HKLM", "HKCU"):
v = winhost.reg_query(hive + r"\Software\Revive", "")
if v and _usable(winhost.to_local(v.strip('"'))):
found = winhost.to_local(v.strip('"'))
break
_system_cache = (time.time(), found)
return found
def release_for(path: Path) -> str | None:
"""Revive release a folder's files came from: the first GitHub release published on/after LibReviveXR64.dll was
built (Revive's version resources are stale: the DLLs carry the LibOVR version, the dashboard says 3.0.0.0)."""
import datetime as dt
try:
built = dt.datetime.fromtimestamp((path / "LibReviveXR64.dll").stat().st_mtime, dt.timezone.utc)
except OSError:
return None
rels = cache.cached_json("revive-releases.json", REVIVE_RELEASE.rsplit("/", 1)[0] + "?per_page=50",
max_age=7 * 86400) or []
best = None
for r in rels:
try:
when = dt.datetime.fromisoformat(r["published_at"].replace("Z", "+00:00"))
except (KeyError, ValueError, AttributeError):
continue
if when >= built - dt.timedelta(days=1) and (best is None or when < best[0]):
best = (when, r.get("tag_name"))
return best[1] if best else None
def _managed() -> tuple[Path, str] | None:
try:
st = json.loads(_state_file().read_text())
except (OSError, ValueError):
return None
path = Path(st.get("path", ""))
return (path, st.get("version")) if _usable(path) else None
def source() -> str | None:
"""Where the Revive in use comes from: "env" | "system" | "managed" | None."""
if os.environ.get("FRAMEPORT_REVIVE_DIR"):
return "env" if _usable(Path(os.environ["FRAMEPORT_REVIVE_DIR"])) else None
if system_revive():
return "system"
return "managed" if _managed() else None
def revive_dir() -> Path | None:
"""Folder with ReviveInjector.exe + the Revive DLLs, or None."""
src = source()
if src == "env":
return Path(os.environ["FRAMEPORT_REVIVE_DIR"])
if src == "system":
return system_revive()
return _managed()[0] if src == "managed" else None
def installed_version() -> str | None:
src = source()
if src == "managed":
return _managed()[1]
if src in ("env", "system"):
return release_for(revive_dir()) or src
return None
def latest() -> tuple[str, str] | None:
rel = cache.cached_json("revive-release.json", REVIVE_RELEASE, max_age=24 * 3600)
if not rel:
return None
asset = next((a for a in rel.get("assets", []) if a["name"] == INSTALLER), None)
return (rel["tag_name"], asset["browser_download_url"]) if asset else None
def install(progress=None, force: bool = False) -> Path:
"""FramePort's portable copy (skipped when a Revive is already installed on the system, unless force)."""
if not force and source() in ("env", "system"):
return revive_dir()
found = latest()
if not found:
raise RuntimeError("could not reach GitHub to find the latest Revive release")
version, url = found
dest = tools_dir() / f"revive-{version}"
if not (dest / "ReviveInjector.exe").exists():
archive = cache.download(url, tools_dir() / f"ReviveInstaller-{version}.exe", progress)
tmp = dest.with_name(dest.name + ".tmp")
extract(archive, tmp)
tmp.replace(dest)
archive.unlink(missing_ok=True)
_state_file().write_text(json.dumps({"version": version, "path": str(dest)}, indent=2))
return dest
def runtime_files(root: Path | None = None) -> list[Path]:
"""Every file under the Revive folder that a game install needs (for packing onto the Frame)."""
root = root or revive_dir()
if not root:
return []
return sorted(p for p in root.rglob("*") if p.is_file() and
(p.relative_to(root).as_posix() in RUNTIME_FILES or p.relative_to(root).parts[0] in KEEP_DIRS))
+44 -5
View File
@@ -34,6 +34,7 @@ class ToolStatus:
path: Path | None
latest: str | None = None
detail: str = ""
optional: bool = False # only needed for some games (Revive: Rift/PC VR games)
def _os_arch() -> tuple[str, str]:
@@ -200,7 +201,7 @@ def install_apksigner(progress=None) -> ToolStatus:
# ------------------------------------------------------------------------------------------ status / all
def status(check_latest: bool = False) -> list[ToolStatus]:
def status(check_latest: bool = False, include_optional: bool = True) -> list[ToolStatus]:
st = _state()
out = []
for name, getter in (("java", java_path), ("overport", overport_jar), ("apksigner", apksigner_jar)):
@@ -217,17 +218,55 @@ def status(check_latest: bool = False) -> list[ToolStatus]:
except Exception:
latest = None
out.append(ToolStatus(name, bool(path and path.exists()), version, path, latest))
if not include_optional:
return out
from . import revive
rdir = revive.revive_dir()
latest = None
if check_latest:
try:
latest = (revive.latest() or (None,))[0]
except Exception:
latest = None
where = {"system": "your installed Revive", "env": "FRAMEPORT_REVIVE_DIR", "managed": "FramePort's copy"}.get(
revive.source() or "", "")
out.append(ToolStatus("revive", rdir is not None, revive.installed_version(), rdir, latest,
"Revive (LibreVR): runs Oculus Rift games on OpenXR/SteamVR"
+ (f" · using {where}" if where else ""), optional=True))
return out
def ensure_all(progress=None, update: bool = False) -> list[ToolStatus]:
"""Install whatever is missing (or outdated when update=True)."""
def revive_is_users() -> bool:
from . import revive
return revive.source() in ("env", "system")
def install_revive(progress=None) -> ToolStatus:
from . import revive
path = revive.install(progress)
return ToolStatus("revive", True, revive.installed_version(), path, optional=True)
def ensure_all(progress=None, update: bool = False, optional: bool = False) -> list[ToolStatus]:
"""Install whatever is missing (or outdated when update=True). Optional tools only with optional=True or when
already installed (then they are updated like the others)."""
results = []
for s in status(check_latest=update):
if s.installed and not (update and s.latest and s.version and s.latest != s.version):
if s.optional and not optional and not s.installed:
results.append(s)
continue
installer = {"java": install_java, "overport": install_overport, "apksigner": install_apksigner}[s.name]
if s.name == "revive" and s.installed and revive_is_users():
results.append(s) # never replace a Revive the user installed themselves
continue
if s.installed and not (update and s.latest and s.version and s.latest != s.version
and s.version != "external"):
results.append(s)
continue
installer = {"java": install_java, "overport": install_overport, "apksigner": install_apksigner,
"revive": install_revive}[s.name]
results.append(installer(progress))
return results
+1059 -489
View File
File diff suppressed because it is too large. Load diff
+278
View File
@@ -0,0 +1,278 @@
"""Reusable GUI building blocks (all styling comes from ui.theme)."""
from __future__ import annotations
from pathlib import Path
from typing import Callable
import flet as ft
from . import theme as T
from .help import HELP
STATUS_STYLE = {
"works": ("Works", T.OK),
"issues": ("Works with issues", T.WARN),
"unsupported": ("Can't run", T.ERROR),
"unknown": ("Untested", T.TEXT_3),
}
# per-game state on a target: label, icon, color
INSTALL_STYLE = {
"installed": ("On Frame", ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK),
"outdated": ("Update ready", ft.Icons.UPDATE_ROUNDED, T.WARN),
"missing": ("Not installed", ft.Icons.CLOUD_OFF_ROUNDED, T.TEXT_3),
"on_pc": ("On this PC", ft.Icons.COMPUTER_ROUNDED, T.PC),
}
def _deployment(game: dict, frame_info: dict | None) -> dict | None:
return next((d for d in (frame_info or {}).get("installed", []) if d.get("package") == game.get("package")), None)
def settings_diff(game: dict, frame_info: dict | None) -> tuple[list[str], list[str]] | None:
"""(patches turned on, patches turned off) in the app since the Frame's copy was installed; None if the same (or
unknown: installs from before FramePort recorded their recipe)."""
dep = _deployment(game, frame_info)
rec = (dep or {}).get("recipe")
if not isinstance(rec, dict) or "patches" not in rec:
return None
installed, now = set(rec["patches"]), set((game.get("recipe") or {}).get("patches") or {})
added, removed = sorted(now - installed), sorted(installed - now)
return (added, removed) if added or removed else None
def install_state(game: dict, frame_info: dict | None) -> str | None:
"""None when no Frame is connected; else 'installed', 'outdated' (the local build, or the patch settings, differ
from what the Frame runs) or 'missing'."""
if frame_info is None:
return None
dep = _deployment(game, frame_info)
if not dep or dep.get("apk_present") is False: # no record, or the game files are gone (half uninstalled)
return "missing"
b = game.get("build") or {}
built = {b.get("sha256"), b.get("alt_sha256")} - {None}
if built and dep.get("sha256") and dep["sha256"] not in built:
return "outdated"
return "outdated" if settings_diff(game, frame_info) else "installed"
def update(*controls: ft.Control) -> None:
"""Update controls that may not be on the page yet (Flet raises for those)."""
for c in controls:
try:
c.update()
except (RuntimeError, AssertionError):
pass
# ------------------------------------------------------------------------------------------ text
def title(text: str, size: int = T.T_TITLE) -> ft.Text:
return ft.Text(text, size=size, weight=ft.FontWeight.W_700, color=T.TEXT)
def h2(text: str) -> ft.Text:
return ft.Text(text, size=T.T_H2, weight=ft.FontWeight.W_600, color=T.TEXT)
def body(text: str, color: str = T.TEXT_2, size: int = T.T_BODY, **kw) -> ft.Text:
return ft.Text(text, size=size, color=color, **kw)
def meta(text: str, color: str = T.TEXT_3, **kw) -> ft.Text:
return ft.Text(text, size=T.T_META, color=color, **kw)
# ------------------------------------------------------------------------------------------ chips / badges
def pill(text: str, color: str = T.TEXT_2, icon: str | None = None, solid: bool = False,
tooltip: str | ft.Tooltip | None = None, overlay: bool = False) -> ft.Container:
"""overlay: for use on top of artwork (dark translucent backing so it reads on any image)."""
fg = T.ON_ACCENT if solid else color
items = ([ft.Icon(icon, size=13, color=fg)] if icon else []) + \
[ft.Text(text, size=T.T_SMALL, color=fg, weight=ft.FontWeight.W_600)]
return ft.Container(ft.Row(items, spacing=4, tight=True), tooltip=tooltip,
bgcolor=color if solid else T.soft("#000000", 0.62) if overlay else T.soft(color, 0.12),
border_radius=20,
padding=ft.Padding(8, 3, 9, 3))
def status_chip(status: str) -> ft.Container:
label, color = STATUS_STYLE.get(status, STATUS_STYLE["unknown"])
return pill(label, color, tooltip=tip(HELP["status"]))
def install_badge(state: str, solid: bool = True) -> ft.Container:
label, icon, color = INSTALL_STYLE[state]
return pill(label, color, icon, solid=solid, tooltip=tip(HELP["update_ready"]) if state == "outdated" else None)
def dot(color: str, size: int = 8) -> ft.Container:
return ft.Container(width=size, height=size, border_radius=size, bgcolor=color)
# ------------------------------------------------------------------------------------------ help hints
def tip(text: str) -> ft.Tooltip:
"""A tooltip that wraps long help text (plain string tooltips run across the whole window)."""
return ft.Tooltip(message=text, size_constraints=ft.BoxConstraints(max_width=340),
padding=ft.Padding(12, 8, 12, 8), prefer_below=True)
def help_icon(key_or_text: str, size: int = 15) -> ft.Container:
"""A small "?" that explains a non-obvious term on hover: a key of ui.help.HELP, or the text itself."""
return ft.Container(ft.Icon(ft.Icons.HELP_OUTLINE_ROUNDED, size=size, color=T.TEXT_3),
tooltip=tip(HELP.get(key_or_text, key_or_text)), padding=2, border_radius=size)
def with_help(control: ft.Control, key: str | None) -> ft.Control:
"""`control` followed by a help icon (or just `control` when there's no key)."""
if not key:
return control
return ft.Row([control, help_icon(key)], spacing=4, tight=True,
vertical_alignment=ft.CrossAxisAlignment.CENTER)
# ------------------------------------------------------------------------------------------ buttons
def _shape(radius=T.RADIUS_SM):
return ft.RoundedRectangleBorder(radius=radius)
def primary(label: str, icon: str | None = None, on_click: Callable | None = None, disabled: bool = False,
tooltip: str | None = None, big: bool = False) -> ft.FilledButton:
return ft.FilledButton(label, icon=icon, on_click=on_click, disabled=disabled, tooltip=tooltip,
style=ft.ButtonStyle(shape=_shape(), bgcolor={ft.ControlState.DEFAULT: T.ACCENT,
ft.ControlState.DISABLED: T.SURFACE_3},
color={ft.ControlState.DEFAULT: T.ON_ACCENT,
ft.ControlState.DISABLED: T.TEXT_3},
padding=ft.Padding(22, 18, 22, 18) if big else ft.Padding(16, 12, 16, 12),
text_style=ft.TextStyle(size=15 if big else 13,
weight=ft.FontWeight.W_600)))
def secondary(label: str, icon: str | None = None, on_click: Callable | None = None, disabled: bool = False,
tooltip: str | None = None) -> ft.OutlinedButton:
return ft.OutlinedButton(label, icon=icon, on_click=on_click, disabled=disabled, tooltip=tooltip,
style=ft.ButtonStyle(shape=_shape(), side=ft.BorderSide(1, T.BORDER_STRONG),
color={ft.ControlState.DEFAULT: T.TEXT,
ft.ControlState.DISABLED: T.TEXT_3},
padding=ft.Padding(14, 12, 14, 12)))
def ghost(label: str, icon: str | None = None, on_click: Callable | None = None, disabled: bool = False,
tooltip: str | None = None, color: str = T.TEXT_2) -> ft.TextButton:
return ft.TextButton(label, icon=icon, on_click=on_click, disabled=disabled, tooltip=tooltip,
style=ft.ButtonStyle(shape=_shape(), color={ft.ControlState.DEFAULT: color,
ft.ControlState.DISABLED: T.TEXT_3}))
def icon_btn(icon: str, tooltip: str, on_click: Callable | None = None, disabled: bool = False,
color: str = T.TEXT_2) -> ft.IconButton:
return ft.IconButton(icon, tooltip=tooltip, on_click=on_click, disabled=disabled, icon_color=color,
icon_size=20, style=ft.ButtonStyle(shape=_shape()))
# ------------------------------------------------------------------------------------------ containers
def card(content: ft.Control, padding: int = T.S4, bgcolor: str = T.SURFACE, **kw) -> ft.Container:
return ft.Container(content, padding=padding, bgcolor=bgcolor, border_radius=T.RADIUS,
border=ft.Border.all(1, T.BORDER), **kw)
def section(heading: str, *controls: ft.Control, subtitle: str | None = None,
action: ft.Control | None = None, help: str | None = None) -> ft.Column:
head = ft.Row([ft.Column([with_help(h2(heading), help)] + ([meta(subtitle)] if subtitle else []), spacing=2,
expand=True)]
+ ([action] if action else []), vertical_alignment=ft.CrossAxisAlignment.CENTER)
return ft.Column([head, *controls], spacing=T.S3)
def callout(text: str | ft.Control, kind: str = "info", icon: str | None = None) -> ft.Container:
color = {"info": T.INFO, "warn": T.WARN, "error": T.ERROR, "ok": T.OK, "pc": T.PC}[kind]
icon = icon or {"info": ft.Icons.INFO_OUTLINE_ROUNDED, "warn": ft.Icons.WARNING_AMBER_ROUNDED,
"error": ft.Icons.ERROR_OUTLINE_ROUNDED, "ok": ft.Icons.CHECK_CIRCLE_OUTLINE_ROUNDED,
"pc": ft.Icons.COMPUTER_ROUNDED}[kind]
content = body(text, T.TEXT) if isinstance(text, str) else text
return ft.Container(ft.Row([ft.Icon(icon, color=color, size=18), ft.Container(content, expand=True)],
spacing=T.S3, vertical_alignment=ft.CrossAxisAlignment.START),
bgcolor=T.soft(color, 0.08), border=ft.Border.all(1, T.soft(color, 0.35)),
border_radius=T.RADIUS_SM, padding=ft.Padding(14, 12, 14, 12))
def empty_state(icon: str, heading: str, text: str, *actions: ft.Control) -> ft.Container:
return ft.Container(ft.Column([
ft.Container(ft.Icon(icon, size=40, color=T.ACCENT), width=84, height=84, border_radius=42,
bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
ft.Container(height=T.S2),
title(heading, 22),
ft.Container(body(text, text_align=ft.TextAlign.CENTER), width=460),
ft.Container(height=T.S2),
ft.Row(list(actions), alignment=ft.MainAxisAlignment.CENTER, spacing=T.S3),
], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S2, tight=True),
alignment=ft.Alignment.CENTER, expand=True)
def kv(label: str, value: str | ft.Control, help: str | None = None) -> ft.Row:
return ft.Row([ft.Container(with_help(meta(label), help), width=130),
ft.Container(value if isinstance(value, ft.Control) else body(value, T.TEXT, selectable=True),
expand=True)], vertical_alignment=ft.CrossAxisAlignment.START)
def status_row(ok: bool | None, heading: str, detail: str = "", action: ft.Control | None = None,
help: str | None = None) -> ft.Container:
icon, color = {True: (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK), False: (ft.Icons.ERROR_ROUNDED, T.ERROR),
None: (ft.Icons.RADIO_BUTTON_UNCHECKED_ROUNDED, T.WARN)}[ok]
return ft.Container(ft.Row([
ft.Icon(icon, color=color, size=20),
ft.Column([with_help(body(heading, T.TEXT, weight=ft.FontWeight.W_500), help)]
+ ([meta(detail)] if detail else []),
spacing=1, expand=True),
*([action] if action else []),
], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S3), padding=ft.Padding(0, 6, 0, 6))
def art_fill(src: str | None, radius: int = T.RADIUS, placeholder_icon: str = ft.Icons.VIDEOGAME_ASSET_ROUNDED,
hero: bool = False, **kw) -> ft.Container:
"""Artwork that covers its box (cards, heroes), by asset URL (artwork.thumbs.url — never image bytes: those are
re-sent on every update); an accent gradient with a large faint icon when there's none."""
if src:
return ft.Container(image=ft.DecorationImage(src=src, fit=ft.BoxFit.COVER), border_radius=radius,
bgcolor=T.SURFACE_2, **kw)
icon = ft.Icon(placeholder_icon, size=220 if hero else 56, color=T.soft(T.ACCENT, 0.22 if hero else 0.45))
return ft.Container(ft.Container(icon, padding=ft.Padding(0, 0, 60, 0) if hero else 0), border_radius=radius,
alignment=ft.Alignment.CENTER_RIGHT if hero else ft.Alignment.CENTER,
gradient=ft.LinearGradient(begin=ft.Alignment.TOP_LEFT, end=ft.Alignment.BOTTOM_RIGHT,
colors=[T.ACCENT_SOFT, T.SURFACE, T.BG]), **kw)
def bottom_fade(height: int | None = None, strength: float = 0.85) -> ft.Container:
return ft.Container(height=height, border_radius=T.RADIUS,
gradient=ft.LinearGradient(begin=ft.Alignment.TOP_CENTER, end=ft.Alignment.BOTTOM_CENTER,
colors=[ft.Colors.TRANSPARENT, T.soft("#000000", strength)]))
def confirm(page: ft.Page, heading: str, text: str, ok_label: str, on_ok: Callable[[], None],
danger: bool = False) -> None:
def go(e):
page.pop_dialog()
on_ok()
page.show_dialog(ft.AlertDialog(
title=ft.Text(heading, weight=ft.FontWeight.W_600), content=ft.Container(body(text), width=420),
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
actions=[ghost("Cancel", on_click=lambda e: page.pop_dialog()),
ft.FilledButton(ok_label, on_click=go, style=ft.ButtonStyle(
shape=_shape(), bgcolor=T.ERROR if danger else T.ACCENT, color=T.ON_ACCENT))]))
def progress_bar(value: float | None = None, color: str = T.ACCENT) -> ft.ProgressBar:
return ft.ProgressBar(value=value, color=color, bgcolor=T.SURFACE_3, border_radius=4, bar_height=6)
def menu_items(actions: list[tuple | None]) -> list[ft.PopupMenuItem]:
"""[(label, icon, handler) | None] → popup menu items; None becomes a divider (never first, last or doubled)."""
out: list[ft.PopupMenuItem] = []
for a in actions:
if a is None:
if out and out[-1].content is not None:
out.append(ft.PopupMenuItem())
continue
label, icon, handler = a
out.append(ft.PopupMenuItem(content=ft.Text(label), icon=icon, on_click=handler))
while out and out[-1].content is None:
out.pop()
return out
+101
View File
@@ -0,0 +1,101 @@
"""Help texts for the GUI's non-obvious terms and actions, shown as "?" hints and tooltips (components.help_icon).
One place for the wording, so the same term is explained the same way everywhere. Plain text, no Flet."""
from __future__ import annotations
HELP: dict[str, str] = {
# ---- game status / library
"status": "How well the game runs on the Steam Frame. \"Works\" and \"Works with issues\" come from recipes tested "
"on a real Frame. \"Untested\" means FramePort suggested a recipe from the game's engine and VR API that "
"nobody has confirmed yet. \"Can't run\" means there's a known blocker (for example a 32-bit-only game).",
"update_ready": "The copy on your Frame differs from what FramePort would install now (the recipe or your game "
"files changed). Update to install the new build; saves are kept.",
"check_exe": "This game's folder has several programs and FramePort isn't sure which one starts the game. Open the "
"game (or right-click → Change executable) to pick it.",
"platform_quest": "A Meta Quest game (APK). It's rebuilt for the Frame and runs in Lepton, Valve's Android "
"container.",
"platform_pcvr": "An Oculus Rift PC VR game. It runs through Revive (Oculus → OpenXR), either on this PC or on the "
"Frame with Proton.",
"select": "Pick several games and install them in one go. FramePort asks every question first, then works through "
"the queue in the background.",
"tags": "Your own tags (filled) can be anything you like. Outlined tags are added automatically from the engine, "
"VR API and store genres. Filter the library by either.",
# ---- game page
"recipe": "A recipe is the list of patches FramePort applies to a game before installing it. Known-good recipes "
"were tested on a Frame; the others are suggested from the game's engine and VR API. Hover a patch "
"for why it's there.",
"standard_fixes": "Fixes every game of this kind gets (on by default). Customize lists them all.",
"as_is": "Turn this on if your copy is already patched. Quest: the APK is installed unchanged instead of being "
"patched again. PC VR: your folder is never modified either way; only the Frame's copy gets launch fixes, "
"and Revive still runs when the game uses the Oculus PC SDK (a repack's own Oculus runtime, such as "
"Virtual Desktop's launcher, doesn't exist on the Frame). Turn Revive off under Customize for SteamVR "
"builds.",
"alt_build": "Some games need a second build with different overport patches (for some Unreal games: one with "
"Unreal's ForceQuit removed). Turn this on to install that build instead.",
"show_all": "Patches that can't matter for this game (wrong engine or API) are hidden. Show them to force one on "
"anyway.",
"where": "Steam Frame: installed on the headset with an entry in its Steam library. This PC: a Steam shortcut on "
"this Windows PC that starts the game through Revive, for a PC-tethered headset.",
"launch_test": "Starts the game on the Frame while nobody is wearing it and reads the log: did it start, create a "
"VR session and render frames. It can't check what you'd see: tracking only runs with the "
"headset on.",
"uninstall": "Removes the game from the Frame. Saves are kept, so a reinstall picks up where you left off.",
"known_good": "Saves the current patches as a known-good recipe in your own catalog, so they're used again when "
"the game is re-added. Do this after it worked in the headset.",
"rebuild": "Applies the patches and runs the checks, without sending anything to the Frame.",
"reset_recipe": "Discards your changes to the patches and goes back to the recipe FramePort suggests.",
"experimental": "Not verified on many games yet: try it if the game doesn't work without it.",
"abis": "The CPU types the game ships code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games "
"can't run on it.",
"graphics": "OpenGL ES games run on the Frame through Zink (OpenGL on top of Vulkan), which is stricter than "
"Quest drivers. Vulkan games run natively.",
"recipe_source": "Where this game's recipe came from: catalog (tested on a Frame), heuristics (suggested by "
"FramePort) or user (changed by you).",
# ---- patch categories
"cat_pcvr": "How a Rift game starts: Revive translates the Oculus API to OpenXR, Proton runs the Windows program on "
"the Frame.",
"cat_frame": "Fixes for what the Frame's runtime does differently from a Quest (graphics formats, missing OpenXR "
"extensions, Lepton's launcher requirements).",
"cat_overport": "overport converts Quest games from Meta's own VR APIs to standard OpenXR. These are its "
"optional patches.",
"cat_adapter": "Settings of FrameBridge, the adapter FramePort adds to Quest games. They can also be changed "
"after installing, from the Frame page.",
"cat_device": "Files and environment variables placed next to the game on the Frame.",
# ---- Frame
"developer_mode": "Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on "
"your network and connect to it over SSH.",
"first_time_setup": "The command fetches a small setup script from this app over your local network. It turns on "
"SSH, lets this app's key in, makes the Frame findable and installs Lepton if needed.",
"password": "The Frame's desktop password, only needed the first time so FramePort can add its own key. After "
"that it connects with the key.",
"lepton": "Lepton is Valve's Android container on the Frame. Quest games run inside it, one container per game. "
"It needs Developer Mode.",
"proton": "Proton is Valve's Windows compatibility layer. On the Frame it runs Oculus Rift games (with Revive). "
"Its ARM64 build isn't installed by default; FramePort can install it (Steam restarts once).",
"openxr": "The VR runtime games talk to. On the Frame that's SteamVR.",
"kernel_keys": "Every game start on the Frame used to leak one kernel key, and after about 200 launches every game "
"fails to start. FramePort switches that leak off, but keys already used only come back after a "
"restart of the Frame.",
"free_space": "Deletes the rollback copies kept from each game's previous install and leftover uploads. The "
"games and saves stay.",
"adapter_settings": "Change FrameBridge settings of an installed game (render scale, controller mapping and so "
"on). They're read when the game starts.",
"frame_summary": "Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so Rift games "
"can run on the Frame.",
# ---- settings
"data_folder": "FramePort's tools, library, artwork and the signing keys of your rebuilt games. Back it up: an "
"update must be signed with the same key, or the game (and its saves) has to be reinstalled.",
"catalog": "Recipes tested on a real Frame: bundled with FramePort, fetched from the online catalog, and the ones "
"you saved as known-good.",
"revive": "Revive (by LibreVR) lets Oculus Rift games run on OpenXR headsets. FramePort uses your installed Revive "
"if you have one, and never replaces it.",
"steamvr_pc": "PC VR games on this PC run through SteamVR, so a headset connected to this PC works with them.",
"frame_agent": "The small helper program FramePort runs on the Frame (installs, launch tests, Steam entries). It "
"comes with this app and is replaced on the Frame automatically whenever it differs.",
"transfer_link": "Uploads use the fastest link to the Frame: a USB cable, or the Frame's own Wi-Fi hotspot when "
"this PC is connected to it, else your home network (several times slower: both go through "
"the router).",
}
def text(key: str) -> str:
return HELP[key]
+161
View File
@@ -0,0 +1,161 @@
"""Background jobs for the GUI: one worker runs queued jobs in order (the Frame is one SSH connection and builds share
the toolchain), each with its own Reporter. The UI subscribes to changes and renders the activity panel.
No Flet imports here, so it's unit-testable."""
from __future__ import annotations
import itertools
import threading
import time
import traceback
from dataclasses import dataclass, field
from typing import Callable
from ..core.events import Cancelled, Event, Reporter
_ids = itertools.count(1)
@dataclass
class Job:
title: str
run: Callable[["Job"], object] # does the work with job.reporter; may return a result
package: str | None = None
kind: str = "task" # install | test | build | scan | tool | task
id: int = field(default_factory=lambda: next(_ids))
state: str = "queued" # queued | running | done | failed | cancelled
stage: str = ""
message: str = ""
fraction: float | None = None
checks: list[dict] = field(default_factory=list)
log: list[str] = field(default_factory=list)
stages: list[str] = field(default_factory=list) # every stage seen, in order
result: object = None
error: str | None = None
created: float = field(default_factory=time.time)
started: float | None = None
finished: float | None = None
reporter: Reporter = field(default_factory=Reporter)
version: int = 0 # bumped on every change (the activity panel only rebuilds tiles that changed)
log_path: str | None = None # full launch log saved by a launch test
summary: dict | None = None # launch-test summary (install/test jobs)
to: str = "frame"
@property
def active(self) -> bool:
return self.state in ("queued", "running")
def cancel(self) -> None:
self.reporter.cancelled.set()
def _on_event(self, ev: Event) -> None:
self.version += 1
if ev.kind == "stage":
self.stage, self.message, self.fraction = ev.message, "", None
if not self.stages or self.stages[-1] != ev.message:
self.stages.append(ev.message)
elif ev.kind == "progress":
self.fraction = ev.fraction
if ev.message:
self.message = ev.message
elif ev.kind == "check":
self.checks.append({"name": ev.data.get("name"), "ok": ev.data.get("ok"), "detail": ev.message})
if ev.message and ev.kind in ("log", "stage"):
self.log.append(time.strftime("%H:%M:%S ", time.localtime(ev.time)) + ev.message)
if len(self.log) > 4000:
del self.log[:1000]
class JobManager:
def __init__(self, on_change: Callable[[Job | None], None] | None = None, throttle: float = 0.2):
self.jobs: list[Job] = []
self._queue: list[Job] = []
self._cv = threading.Condition()
self._listeners: list[Callable[[Job | None], None]] = [on_change] if on_change else []
self._throttle = throttle
self._last = 0.0
self._worker = threading.Thread(target=self._loop, daemon=True, name="frameport-jobs")
self._worker.start()
# ------------------------------------------------------------------ public
def subscribe(self, fn: Callable[[Job | None], None]) -> None:
self._listeners.append(fn)
def submit(self, job: Job) -> Job:
job.reporter.subscribe(lambda ev, j=job: self._event(j, ev))
with self._cv:
self.jobs.append(job)
self._queue.append(job)
self._cv.notify()
self._notify(job, force=True)
return job
def current(self) -> Job | None:
return next((j for j in self.jobs if j.state == "running"), None)
def pending(self) -> list[Job]:
return [j for j in self.jobs if j.state == "queued"]
def busy_with(self, package: str) -> Job | None:
return next((j for j in self.jobs if j.active and j.package == package), None)
def recent(self, limit: int = 20) -> list[Job]:
return sorted(self.jobs, key=lambda j: (not j.active, -j.created))[:limit]
def clear_finished(self) -> None:
with self._cv:
self.jobs = [j for j in self.jobs if j.active]
self._notify(None, force=True)
def cancel(self, job: Job) -> None:
with self._cv:
if job.state == "queued":
self._queue.remove(job)
job.state, job.finished = "cancelled", time.time()
job.version += 1
job.cancel()
self._notify(job, force=True)
def wait_idle(self, timeout: float = 30) -> bool:
end = time.time() + timeout
while time.time() < end:
if not any(j.active for j in self.jobs):
return True
time.sleep(0.05)
return False
# ------------------------------------------------------------------ internals
def _event(self, job: Job, ev: Event) -> None:
job._on_event(ev)
self._notify(job, force=ev.kind in ("stage", "check"))
def _notify(self, job: Job | None, force: bool = False) -> None:
now = time.time()
if not force and now - self._last < self._throttle:
return
self._last = now
for fn in list(self._listeners):
try:
fn(job)
except Exception: # a broken listener must not stop jobs
traceback.print_exc()
def _loop(self) -> None:
while True:
with self._cv:
while not self._queue:
self._cv.wait()
job = self._queue.pop(0)
job.state, job.started = "running", time.time()
job.version += 1
self._notify(job, force=True)
try:
job.result = job.run(job)
job.state = "cancelled" if job.reporter.cancelled.is_set() else "done"
except Cancelled:
job.state = "cancelled"
except Exception as exc: # noqa: BLE001
traceback.print_exc()
job.state, job.error = "failed", f"{exc}" or type(exc).__name__
job.finished = time.time()
job.version += 1
self._notify(job, force=True)
+63
View File
@@ -0,0 +1,63 @@
"""Design tokens for the FramePort GUI (dark only): one accent, a neutral surface ramp, semantic state colours, an
8-pt spacing scale and a small type scale. Every view takes its colours and sizes from here."""
from __future__ import annotations
import flet as ft
# surfaces (darkest → lightest)
BG = "#0E0F13" # window background
SIDEBAR = "#121319"
SURFACE = "#171920" # cards
SURFACE_2 = "#1E2029" # raised / hover
SURFACE_3 = "#262935" # inputs, chips
BORDER = "#2C2F3B"
BORDER_STRONG = "#3A3E4D"
# text
TEXT = "#ECEDF3"
TEXT_2 = "#A9ADBD" # secondary
TEXT_3 = "#737889" # meta / disabled
# accent + states
ACCENT = "#8B7CFF"
ACCENT_SOFT = "#2A2550"
ON_ACCENT = "#0E0F13"
OK = "#4ADE80"
WARN = "#FBBF24"
ERROR = "#F87171"
INFO = "#60A5FA"
PC = "#38BDF8" # PC VR / "on this PC"
# spacing / shape / type
S1, S2, S3, S4, S5, S6 = 4, 8, 12, 16, 24, 32
RADIUS = 12
RADIUS_SM = 8
T_TITLE, T_H2, T_BODY, T_META, T_SMALL = 28, 16, 13, 12, 11
def soft(color: str, opacity: float = 0.14) -> str:
return ft.Colors.with_opacity(opacity, color)
def apply(page: ft.Page) -> None:
scheme = ft.ColorScheme(
primary=ACCENT, on_primary=ON_ACCENT, primary_container=ACCENT_SOFT, on_primary_container=TEXT,
secondary=PC, on_secondary=ON_ACCENT, surface=SURFACE, on_surface=TEXT, on_surface_variant=TEXT_2,
surface_container_lowest=BG, surface_container_low=SIDEBAR, surface_container=SURFACE,
surface_container_high=SURFACE_2, surface_container_highest=SURFACE_3, outline=BORDER_STRONG,
outline_variant=BORDER, error=ERROR, on_error=ON_ACCENT,
)
theme = ft.Theme(color_scheme=scheme, visual_density=ft.VisualDensity.COMFORTABLE, use_material3=True,
divider_theme=ft.DividerTheme(color=BORDER, thickness=1, space=1),
tooltip_theme=ft.TooltipTheme(
decoration=ft.BoxDecoration(bgcolor=SURFACE_3, border_radius=6,
border=ft.Border.all(1, BORDER)),
text_style=ft.TextStyle(color=TEXT, size=12), wait_duration=400),
expansion_tile_theme=ft.ExpansionTileTheme(
shape=ft.RoundedRectangleBorder(radius=RADIUS), collapsed_shape=ft.RoundedRectangleBorder(
radius=RADIUS), icon_color=TEXT_2, collapsed_icon_color=TEXT_3, text_color=TEXT,
collapsed_text_color=TEXT),
scaffold_bgcolor=BG, card_bgcolor=SURFACE, canvas_color=SURFACE_2)
page.theme = page.dark_theme = theme
page.theme_mode = ft.ThemeMode.DARK
page.bgcolor = BG
View File
Whitespace-only changes.
+156
View File
@@ -0,0 +1,156 @@
"""Activity panel (right side): running, queued and finished background jobs with steps, checks and the log."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
import flet as ft
from .. import components as C
from .. import theme as T
from ..jobs import Job
if TYPE_CHECKING:
from ..app import FramePortApp
CHECK_ICON = {True: (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK), False: (ft.Icons.CANCEL_ROUNDED, T.ERROR),
None: (ft.Icons.WARNING_AMBER_ROUNDED, T.WARN)}
STATE_STYLE = {"queued": (ft.Icons.SCHEDULE_ROUNDED, T.TEXT_3, "Waiting"),
"running": (ft.Icons.SYNC_ROUNDED, T.ACCENT, "Working"),
"done": (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK, "Done"),
"failed": (ft.Icons.ERROR_ROUNDED, T.ERROR, "Failed"),
"cancelled": (ft.Icons.DO_NOT_DISTURB_ON_OUTLINED, T.TEXT_3, "Cancelled")}
def _dur(job: Job) -> str:
if not job.started:
return ""
s = int((job.finished or time.time()) - job.started)
return f"{s // 60}:{s % 60:02d}"
class ActivityPanel:
def __init__(self, app: "FramePortApp"):
self.app = app
self.expanded: set[int] = set()
self.logs: set[int] = set()
self._tiles: dict[int, tuple[tuple, ft.Control]] = {} # job id -> (state key, tile)
self._live: dict[int, tuple] = {} # running job id -> (progress bar, message, stage text)
self.list = ft.Column(spacing=T.S3, scroll=ft.ScrollMode.AUTO, expand=True)
self.root = ft.Container(
ft.Column([
ft.Row([C.h2("Activity"), ft.Container(expand=True),
C.ghost("Clear finished", on_click=lambda e: (app.jobs.clear_finished(), self.refresh())),
C.icon_btn(ft.Icons.CLOSE_ROUNDED, "Close", lambda e: app.show_activity(False))]),
self.list,
], spacing=T.S3, expand=True),
width=0, bgcolor=T.SIDEBAR, padding=ft.Padding(T.S4, T.S4, T.S4, T.S4),
border=ft.Border(left=ft.BorderSide(1, T.BORDER)), animate_size=ft.Animation(180, ft.AnimationCurve.EASE_OUT),
clip_behavior=ft.ClipBehavior.HARD_EDGE)
@property
def open(self) -> bool:
return bool(self.root.width)
def set_open(self, on: bool):
self.root.width = 400 if on else 0
if on:
self.refresh(update=False)
def refresh(self, update: bool = True):
if not self.open:
return
jobs = self.app.jobs.recent(30)
tiles = []
for j in jobs:
running = j.state == "running"
# a running job's tile is rebuilt only when its structure changes (new stage/check, log shown); progress
# updates go to its live controls — rebuilding on every tick swallowed clicks on Cancel
key = (j.state, len(j.stages), len(j.checks), j.id in self.expanded, j.id in self.logs,
len(j.log) if j.id in self.logs else 0) if running else \
(j.version, j.id in self.expanded, j.id in self.logs)
cached = self._tiles.get(j.id)
if not cached or cached[0] != key: # unchanged tiles are reused, so text selections survive refreshes
cached = (key, self.tile(j))
self._tiles[j.id] = cached
elif running and j.id in self._live:
bar, msg, meta = self._live[j.id]
bar.value = j.fraction
msg.value = j.message or ""
meta.value = j.stage or ""
tiles.append(cached[1])
self._tiles = {j.id: self._tiles[j.id] for j in jobs}
self.list.controls = tiles or [
ft.Container(C.body("Nothing running. Installs, launch tests and downloads show up here.",
text_align=ft.TextAlign.CENTER), padding=T.S6, alignment=ft.Alignment.CENTER)]
if update:
C.update(self.root)
def tile(self, job: Job) -> ft.Control:
icon, color, word = STATE_STYLE[job.state]
if job.state == "done" and (job.summary or {}).get("verdict") == "fail":
icon, color, word = ft.Icons.WARNING_AMBER_ROUNDED, T.WARN, "Installed · launch test failed"
running = job.state == "running"
expanded = running or job.id in self.expanded
meta = C.meta(job.stage if running and job.stage else
(job.error or word) + (f" · {_dur(job)}" if job.finished else ""),
T.ERROR if job.state == "failed" else T.TEXT_2, max_lines=2)
head = ft.Row([
ft.ProgressRing(width=18, height=18, stroke_width=2, color=T.ACCENT) if running
else ft.Icon(icon, color=color, size=20),
ft.Column([C.body(job.title, T.TEXT, weight=ft.FontWeight.W_600, max_lines=2,
overflow=ft.TextOverflow.ELLIPSIS), meta],
spacing=2, expand=True),
*([C.icon_btn(ft.Icons.CLOSE_ROUNDED, "Cancel", lambda e: self.app.jobs.cancel(job))] if job.active else []),
], vertical_alignment=ft.CrossAxisAlignment.START, spacing=T.S3)
parts: list[ft.Control] = [head]
if running:
bar = C.progress_bar(job.fraction)
msg = C.meta(job.message or "", max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)
parts += [bar, msg]
self._live[job.id] = (bar, msg, meta)
if expanded:
if job.stages:
parts.append(ft.Column([
ft.Row([ft.Icon(ft.Icons.CHECK_ROUNDED if (i < len(job.stages) - 1 or not running)
else ft.Icons.ARROW_RIGHT_ROUNDED, size=14,
color=T.OK if (i < len(job.stages) - 1 or job.state == "done") else T.ACCENT),
C.meta(s, T.TEXT_2 if i < len(job.stages) - 1 else T.TEXT)], spacing=6)
for i, s in enumerate(job.stages[-8:])], spacing=2))
if job.checks:
parts.append(ft.Column([
ft.Row([ft.Icon(CHECK_ICON[c["ok"]][0], color=CHECK_ICON[c["ok"]][1], size=14),
ft.Text(f"{c['name']}" + (f" — {c['detail']}" if c.get("detail") else ""), size=T.T_META,
color=T.TEXT_2, expand=True, selectable=True)],
spacing=6, vertical_alignment=ft.CrossAxisAlignment.START)
for c in job.checks[-40:]], spacing=3))
extra = self.app.job_followups(job)
if extra:
parts.append(ft.Row(extra, spacing=T.S2, wrap=True))
show_log = job.id in self.logs
parts.append(ft.Row([
C.ghost("Hide log" if show_log else "Show log", ft.Icons.TERMINAL_ROUNDED,
lambda e: self._toggle(self.logs, job.id)),
C.ghost("Copy log", ft.Icons.CONTENT_COPY_ROUNDED, lambda e: self.app.copy(self.job_text(job))),
*([C.ghost("Full launch log", ft.Icons.DESCRIPTION_ROUNDED,
lambda e: self.app.show_log_file(job.log_path, job.title))] if job.log_path else []),
], spacing=0, wrap=True))
if show_log:
parts.append(ft.Container(ft.Column([ft.Text(
"\n".join(job.log[-400:]), size=11, font_family="monospace", color=T.TEXT_2, selectable=True)],
scroll=ft.ScrollMode.AUTO, auto_scroll=job.state == "running"),
bgcolor=T.BG, border_radius=T.RADIUS_SM, padding=T.S2, height=240))
return ft.Container(ft.Column(parts, spacing=T.S2), bgcolor=T.SURFACE, border_radius=T.RADIUS,
border=ft.Border.all(1, T.ACCENT if running else T.BORDER), padding=T.S3,
on_click=None if running else lambda e: self._toggle(self.expanded, job.id))
@staticmethod
def job_text(job: Job) -> str:
checks = [("PASS" if c["ok"] else "FAIL" if c["ok"] is False else "WARN") + f" {c['name']}"
+ (f": {c['detail']}" if c.get("detail") else "") for c in job.checks]
return "\n".join([f"{job.title} — {job.state}" + (f": {job.error}" if job.error else ""), "", "Checks:", *checks,
"", "Log:", *job.log] + ([f"", f"Full launch log: {job.log_path}"] if job.log_path else []))
def _toggle(self, s: set[int], jid: int):
s.symmetric_difference_update({jid})
self.refresh()
+87
View File
@@ -0,0 +1,87 @@
"""'Find artwork' — search the Meta/Oculus store (via OculusDB) and Steam, pick a result, or fetch automatically."""
from __future__ import annotations
from typing import TYPE_CHECKING
import flet as ft
from ... import pipeline
from ...artwork import sources, thumbs
from ...core import library
from .. import components as C
from .. import theme as T
if TYPE_CHECKING:
from ..app import FramePortApp
def show_art_dialog(app: "FramePortApp", package: str) -> None:
g = library.game(package)
term = ft.TextField(value=g.get("title") or package, dense=True, expand=True, border_radius=T.RADIUS_SM,
bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT, focused_border_color=T.ACCENT,
content_padding=ft.Padding(12, 8, 12, 8), text_size=T.T_BODY)
results = ft.GridView(max_extent=190, child_aspect_ratio=0.8, spacing=T.S3, run_spacing=T.S3, height=400)
status = C.meta("")
def pick(choice):
app.page.pop_dialog()
def work():
if not sources.apply_choice(package, choice):
app.toast(f"No artwork could be downloaded from that {choice['source']} result; the current artwork "
"stays. Try another one.", error=True)
return
thumbs.prewarm(package)
library.upsert_game(package, art_source=choice["source"].lower())
app.refresh_view()
if C.install_state(library.game(package), app.frame_info) in ("installed", "outdated"):
app.toast(f"Artwork updated for {g.get('title')}. The Frame's Steam library still shows the old "
"art.", action="Update on Frame", on_action=lambda e: app.update_steam_art(package))
else:
app.toast(f"Artwork updated for {g.get('title')}")
app.run_bg(work)
def search(e=None):
status.value = "Searching…"
results.controls = []
C.update(status, results)
def work():
found = sources.search(term.value.strip())
results.controls = [ft.Container(ft.Column([
ft.Container(C.art_fill(r["preview"], radius=8, height=120), height=120),
C.body(r["name"] or "", T.TEXT, size=T.T_META, max_lines=2, overflow=ft.TextOverflow.ELLIPSIS),
C.meta(r["source"]),
], spacing=4), padding=T.S2, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE, ink=True,
on_click=lambda e, r=r: pick(r)) for r in found]
status.value = f"{len(found)} results" if found else "Nothing found. Try a shorter or different name."
C.update(status, results)
app.run_bg(work)
def auto(e):
app.page.pop_dialog()
def work():
found = pipeline.fetch_art(package)
thumbs.prewarm(package) # regenerate the thumbnails the cards/hero use, or the view shows the old art
app.refresh_view()
src = found.get("source")
if src and src != "none" and C.install_state(library.game(package), app.frame_info) in ("installed",
"outdated"):
app.toast(f"Artwork updated ({src}). The Frame's Steam library still shows the old art.",
action="Update on Frame", on_action=lambda e: app.update_steam_art(package))
else:
app.toast(f"Artwork: {src or 'none found'}")
app.run_bg(work)
term.on_submit = search
app.page.show_dialog(ft.AlertDialog(
title=ft.Text(f"Artwork for {g.get('title')}", weight=ft.FontWeight.W_600),
content=ft.Container(ft.Column([
ft.Row([term, C.secondary("Search", ft.Icons.SEARCH_ROUNDED, search)], spacing=T.S2),
status, results,
], spacing=T.S3, tight=True), width=660),
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
actions=[C.ghost("Find automatically", ft.Icons.AUTO_AWESOME_ROUNDED, auto),
C.ghost("Close", on_click=lambda e: app.page.pop_dialog())]))
search()
+77
View File
@@ -0,0 +1,77 @@
"""'Which program starts <game>?' — pick the executable of a Rift game when FramePort isn't sure (or to change it)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Callable
import flet as ft
from ... import pipeline
from ...core import library
from .. import components as C
from .. import theme as T
if TYPE_CHECKING:
from ..app import FramePortApp
def _size(n: int) -> str:
return f"{n / 2**20:.0f} MB" if n < 2**30 else f"{n / 2**30:.1f} GB"
def show_exe_dialog(app: "FramePortApp", package: str, remaining: int = 0,
on_done: Callable[[], None] | None = None) -> None:
g = library.game(package)
extra = (g.get("analysis") or {}).get("extra") or {}
cands = extra.get("exe_candidates") or [{"path": g.get("exe"), "score": 0, "reasons": [], "size": 0}]
current = g.get("exe") or cands[0]["path"]
group = ft.RadioGroup(value=current, content=ft.Column(spacing=T.S2))
for i, c in enumerate(cands):
tags = [C.pill(r, T.OK if r in ("Unreal game build", "Unity game (next to its data)", "name matches the game",
"named in the Oculus manifest") else
T.WARN if r in ("Steam build", "Unreal launcher (starts the real game build)") else T.TEXT_2)
for r in c.get("reasons", [])]
if i == 0:
tags.insert(0, C.pill("Best guess", T.ACCENT, ft.Icons.AUTO_AWESOME_ROUNDED))
folder, _, name = c["path"].rpartition("/")
group.content.controls.append(ft.Container(ft.Row([
ft.Radio(value=c["path"], active_color=T.ACCENT),
ft.Column([
ft.Row([C.body(name, T.TEXT, weight=ft.FontWeight.W_600), C.meta(_size(c.get("size") or 0))],
spacing=T.S2),
C.meta(folder or "(top folder)", selectable=True),
ft.Row(tags, spacing=6, wrap=True) if tags else ft.Container(),
], spacing=4, expand=True),
], vertical_alignment=ft.CrossAxisAlignment.START), padding=ft.Padding(T.S2, T.S2, T.S3, T.S2),
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE if c["path"] != current else T.ACCENT_SOFT,
on_click=lambda e, p=c["path"]: (setattr(group, "value", p), group.update())))
def use(e):
choice = group.value
app.page.pop_dialog()
def work():
try:
pipeline.set_exe(package, choice)
app.toast(f"{g.get('title')} starts with {choice.rsplit('/', 1)[-1]}")
except Exception as exc: # noqa: BLE001
app.toast(f"Couldn't use {choice}: {exc}", error=True)
app.refresh_view()
if on_done:
on_done()
app.run_bg(work)
def later(e):
app.page.pop_dialog()
if on_done:
on_done()
title = f"Which program starts {g.get('title')}?"
lead = ("FramePort found more than one program that could start this game. Pick the one you'd double-click to "
"play it. Oculus builds usually work better with Revive than Steam builds.")
app.page.show_dialog(ft.AlertDialog(
title=ft.Row([ft.Text(title, weight=ft.FontWeight.W_600, expand=True)]
+ ([C.meta(f"{remaining} more after this")] if remaining else [])),
content=ft.Container(ft.Column([C.body(lead), group], spacing=T.S4, scroll=ft.ScrollMode.AUTO, tight=True),
width=620, height=min(120 + 96 * len(cands), 520)),
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
actions=[C.ghost("Decide later", on_click=later), C.primary("Use this program", on_click=use)]))
+177
View File
@@ -0,0 +1,177 @@
"""'Files on the Frame' — a browsable tree of what an installed game actually has on the Frame (agent `list_files`):
folders with their total size, expandable; a filter box; and files that are missing or incomplete compared to what
was uploaded. The tree is built once from the flat file list; only visible rows are turned into controls."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
import flet as ft
from .. import components as C
from .. import theme as T
if TYPE_CHECKING:
from ..app import FramePortApp
MAX_CHILDREN = 300 # rows shown per folder before "… N more"
MAX_MATCHES = 500
@dataclass
class Node:
name: str
path: str = ""
size: int = 0
files: int = 0 # files below this folder (1 for a file)
children: dict[str, "Node"] = field(default_factory=dict)
is_dir: bool = True
def build_tree(name: str, files: list[list]) -> Node:
"""[[rel, size], ...] → a folder tree with aggregated sizes, children sorted folders-first by name."""
root = Node(name)
for rel, size in files:
parts = rel.split("/")
node = root
node.size += size
node.files += 1
for i, part in enumerate(parts[:-1]):
child = node.children.get(part)
if child is None:
child = node.children[part] = Node(part, "/".join(parts[:i + 1]))
child.size += size
child.files += 1
node = child
node.children[parts[-1]] = Node(parts[-1], rel, size, 1, is_dir=False)
return root
def sorted_children(node: Node) -> list[Node]:
return sorted(node.children.values(), key=lambda n: (not n.is_dir, n.name.lower()))
def visible_rows(root: Node, expanded: set[str]) -> list[tuple[int, Node | int]]:
"""(depth, node) for everything shown with `expanded` folder paths open; (depth, n) = "… n more"."""
out: list[tuple[int, Node | int]] = []
def walk(node: Node, depth: int):
kids = sorted_children(node)
for child in kids[:MAX_CHILDREN]:
out.append((depth, child))
if child.is_dir and child.path in expanded:
walk(child, depth + 1)
if len(kids) > MAX_CHILDREN:
out.append((depth, len(kids) - MAX_CHILDREN))
walk(root, 0)
return out
def matches(files: list[list], text: str) -> list[list]:
t = text.lower()
return [f for f in files if t in f[0].lower()][:MAX_MATCHES]
def human(n: int) -> str:
for unit, div in (("GiB", 2**30), ("MiB", 2**20), ("KiB", 2**10)):
if n >= div:
return f"{n / div:.1f} {unit}"
return f"{n} B"
def show_files_dialog(app: "FramePortApp", package: str, title: str) -> None:
status = C.meta("Reading the file list from the Frame…")
body = ft.Column([ft.Row([ft.ProgressRing(width=16, height=16, stroke_width=2, color=T.ACCENT), status],
spacing=T.S2)], spacing=T.S3, expand=True)
dialog = ft.AlertDialog(
title=ft.Text(f"Files on the Frame — {title}", weight=ft.FontWeight.W_600),
content=ft.Container(body, width=760, height=560),
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
actions=[C.ghost("Close", on_click=lambda e: app.page.pop_dialog())])
app.page.show_dialog(dialog)
def load():
if not app.target:
raise RuntimeError("The Frame isn't connected")
result = app.target.frame.agent("list_files", package=package, timeout=180)
trees = [(r, build_tree(r["name"], r["files"])) for r in result["roots"]]
expanded: set[str] = set()
rows = ft.ListView(spacing=0, expand=True)
search = ft.TextField(hint_text="Filter files (e.g. .pak, Binaries)", dense=True, expand=True,
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
focused_border_color=T.ACCENT, content_padding=ft.Padding(12, 8, 12, 8),
text_size=T.T_BODY, prefix_icon=ft.Icons.SEARCH_ROUNDED)
def row(depth: int, node: Node | int, root_key: str) -> ft.Control:
pad = ft.Padding(8 + depth * 18, 3, 8, 3)
if isinstance(node, int):
return ft.Container(C.meta(f"… {node} more (use the filter to find them)"), padding=pad)
key = f"{root_key}\0{node.path}"
icon = (ft.Icons.FOLDER_OPEN_ROUNDED if key in expanded else ft.Icons.FOLDER_ROUNDED) if node.is_dir \
else ft.Icons.INSERT_DRIVE_FILE_OUTLINED
chevron = ft.Icon(ft.Icons.EXPAND_MORE_ROUNDED if key in expanded else ft.Icons.CHEVRON_RIGHT_ROUNDED,
size=16, color=T.TEXT_3) if node.is_dir else ft.Container(width=16)
info = f"{human(node.size)} · {node.files} files" if node.is_dir else human(node.size)
return ft.Container(ft.Row([
chevron, ft.Icon(icon, size=16, color=T.ACCENT if node.is_dir else T.TEXT_3),
C.body(node.name, T.TEXT, expand=True, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS),
C.meta(info),
], spacing=6), padding=pad, border_radius=6, ink=node.is_dir,
on_click=(lambda e, k=key: toggle(k)) if node.is_dir else None)
def render():
text = (search.value or "").strip()
controls: list[ft.Control] = []
for r, tree in trees:
controls.append(ft.Container(ft.Row([
C.body(r["name"], T.TEXT, weight=ft.FontWeight.W_600),
C.meta(f"{human(tree.size)} · {tree.files} files"),
ft.Container(expand=True),
C.icon_btn(ft.Icons.CONTENT_COPY_ROUNDED, "Copy path", lambda e, p=r["path"]: app.copy(p)),
], spacing=T.S2), padding=ft.Padding(8, T.S2, 8, 2)))
controls.append(ft.Container(C.meta(r["path"], selectable=True), padding=ft.Padding(8, 0, 8, 4)))
if text:
found = matches(r["files"], text)
controls += [ft.Container(ft.Row([
ft.Icon(ft.Icons.INSERT_DRIVE_FILE_OUTLINED, size=16, color=T.TEXT_3),
C.body(rel, T.TEXT, expand=True, selectable=True), C.meta(human(size))], spacing=6),
padding=ft.Padding(8, 3, 8, 3)) for rel, size in found]
if not found:
controls.append(ft.Container(C.meta("No matching files"), padding=ft.Padding(8, 3, 8, 3)))
else:
visible = visible_rows(tree, {k.split("\0", 1)[1] for k in expanded
if k.split("\0", 1)[0] == r["path"]})
controls += [row(d, n, r["path"]) for d, n in visible]
rows.controls = controls
C.update(rows)
def toggle(key: str):
expanded.symmetric_difference_update({key})
render()
search.on_change = lambda e: render()
head: list[ft.Control] = []
total = sum(t.size for _, t in trees)
count = sum(t.files for _, t in trees)
summary = f"{count} files · {human(total)}" + (" (list cut off)" if result.get("truncated") else "")
missing = result.get("missing") or []
if missing:
lines = "\n".join(f"{rel}: " + ("missing" if actual is None else f"{human(actual)} of {human(size)}")
for rel, size, actual in missing[:8])
more = f"\n… and {len(missing) - 8} more" if len(missing) > 8 else ""
head.append(C.callout(f"{len(missing)} file(s) are missing or incomplete compared to what was "
f"uploaded. Install the game again to re-send them.\n{lines}{more}", "warn"))
elif result.get("kind") == "pcvr":
head.append(C.callout("Every uploaded file is present with the right size.", "ok"))
body.controls = [ft.Row([search, C.meta(summary)], spacing=T.S3), *head, rows]
render()
C.update(body)
def run():
try:
load()
except Exception as exc: # noqa: BLE001
status.value = f"Couldn't read the file list: {exc}"
body.controls = [status]
C.update(body)
app.run_bg(run)
+240
View File
@@ -0,0 +1,240 @@
"""Frame page: the connected device with a readiness checklist and installed games, or a connect wizard."""
from __future__ import annotations
from typing import TYPE_CHECKING
import flet as ft
from ...core import library
from .. import components as C
from .. import theme as T
from ..help import HELP
if TYPE_CHECKING:
from ..app import FramePortApp
class FrameView:
def __init__(self, app: "FramePortApp"):
self.app = app
# ---------------------------------------------------------------- connected
def device_card(self, info: dict) -> ft.Control:
app = self.app
t = app.target
free = (info.get("free_bytes") or 0) / 2**30
return C.card(ft.Row([
ft.Container(ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED, size=34, color=T.ACCENT), width=72, height=72,
border_radius=18, bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
ft.Column([
ft.Row([C.title(info.get("hostname") or t.label, 22), C.pill("Connected", T.OK, ft.Icons.CIRCLE)],
spacing=T.S3),
C.body(f"{t.target.user}@{t.target.host} · {info.get('os')} {info.get('os_version')} "
f"(build {info.get('build_id')})"),
C.meta(f"{free:.0f} GiB free · {len(info.get('installed') or [])} games installed"),
], spacing=4, expand=True),
ft.Column([
C.secondary("Refresh", ft.Icons.REFRESH_ROUNDED, lambda e: app.refresh_frame()),
C.ghost("Switch Frame…", ft.Icons.SWAP_HORIZ_ROUNDED, lambda e: app.disconnect()),
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.END),
], spacing=T.S4), padding=T.S5)
def readiness(self, info: dict) -> ft.Control:
app = self.app
rows = []
lepton = info.get("lepton")
rows.append(C.status_row(bool(lepton), "Quest games (Lepton)",
"Ready" if lepton else "Valve's Android runtime isn't installed (needs Developer Mode)",
None if lepton else C.secondary("Install", ft.Icons.DOWNLOAD_ROUNDED,
lambda e: app.install_lepton()),
help="lepton"))
pr = info.get("proton") or {}
ready, sug = pr.get("ready"), pr.get("suggested")
if pr:
rows.append(C.status_row(
bool(ready), "PC VR games (Proton)",
f"{ready['display_name']} installed" if ready else
(f"{sug['display_name']} can be installed (about 1 GB; Steam restarts once)" if sug else
"Not offered by Steam on this Frame yet"),
C.secondary("Test", ft.Icons.SCIENCE_OUTLINED, lambda e: app.test_proton()) if ready else
C.primary("Install", ft.Icons.DOWNLOAD_ROUNDED, lambda e: app.install_proton(), disabled=not sug),
help="proton"))
xr = (pr.get("openxr") or {}).get("name")
rows.append(C.status_row(bool(xr), "OpenXR runtime", xr or "None found", help="openxr"))
keys = info.get("kernel_keys") or {}
if keys.get("max_keys"):
high = keys["keys"] > keys["max_keys"] * 0.75
rows.append(C.status_row(not high if keys["keys"] < keys["max_keys"] else False, "Game launch capacity",
f"{keys['keys']}/{keys['max_keys']} kernel keys used"
+ (" · restart the Frame soon to reset it" if high else ""),
help="kernel_keys"))
return C.section("Ready to play", C.card(ft.Column(rows, spacing=0), padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)))
def installed(self, info: dict) -> ft.Control:
"""The list fills in the background (icon thumbnails may need creating the first time)."""
items = info.get("installed") or []
col = ft.Column([ft.Container(bgcolor=T.SURFACE_2, height=56, border_radius=T.RADIUS_SM, opacity=0.5)
for _ in items[:6]], spacing=4)
self.app.run_bg(self._fill_installed, items, col)
body = C.card(col, padding=T.S2) if items else \
C.card(C.body("Nothing installed yet. Pick a game in the Library and click Install."), padding=T.S5)
return C.section(f"Installed games ({len(items)})", body,
action=C.ghost("Free up space", ft.Icons.CLEANING_SERVICES_ROUNDED,
lambda e: self.app.cleanup_frame(), tooltip=C.tip(HELP["free_space"]))
if items else None)
def _fill_installed(self, items: list[dict], col: ft.Column) -> None:
def settings_slot() -> ft.Control:
spacer = C.icon_btn(ft.Icons.TUNE_ROUNDED, "", None, disabled=True)
spacer.opacity = 0
return spacer
from ...artwork import thumbs
from .files_dialog import show_files_dialog
from .library import display_title, twins
app = self.app
games = {g["package"]: g for g in library.games()}
tw = twins(list(games.values()))
rows = []
for d in items:
pkg = d["package"]
pcvr = d.get("kind") == "pcvr"
art = thumbs.url(pkg, ("icon", "square", "portrait"), 96)
size = d.get("apk_size", 0)
sub = ("PC VR · Proton" + (" + Revive" if d.get("revive") else "")) if pcvr else "Quest"
sub += f" · {size / 2**30:.1f} GiB" if size >= 2**30 else f" · {size / 2**20:.0f} MiB"
in_lib = pkg in games
title = display_title(games[pkg], tw) if in_lib else (d.get("title") or pkg)
rows.append(ft.Container(ft.Row([
C.art_fill(art, radius=8, width=44, height=44),
ft.Column([C.body(title, T.TEXT, weight=ft.FontWeight.W_500), C.meta(sub)],
spacing=2, expand=True),
# same slots on every row: PC VR games have no adapter settings, so theirs is an invisible spacer
(settings_slot() if pcvr else
C.icon_btn(ft.Icons.TUNE_ROUNDED, C.tip("Adapter settings. " + HELP["adapter_settings"]),
lambda e, p=pkg: app.settings_dialog(p))),
C.icon_btn(ft.Icons.FOLDER_OPEN_ROUNDED, "Files on the Frame: browse what this install contains",
lambda e, p=pkg, t=title: show_files_dialog(app, p, t)),
C.icon_btn(ft.Icons.PLAY_ARROW_ROUNDED, "Play: starts the game through the Frame's Steam (put the "
"headset on)", lambda e, p=pkg: app.play(p, "frame"), color=T.ACCENT),
C.icon_btn(ft.Icons.SCIENCE_OUTLINED, C.tip("Launch test. " + HELP["launch_test"]),
lambda e, p=pkg: app.test_game(p, "frame")),
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, C.tip("Uninstall. " + HELP["uninstall"]),
lambda e, p=pkg: app.uninstall(p, "frame")),
], spacing=T.S3), padding=ft.Padding(T.S3, 8, T.S2, 8), border_radius=T.RADIUS_SM,
on_click=(lambda e, p=pkg: app.open_game(p)) if in_lib else None, ink=in_lib))
col.controls = rows
col.spacing = 0
C.update(col)
# ---------------------------------------------------------------- not connected
def wizard(self) -> ft.Control:
from ...frame.connection import parse_target, saved_targets
app = self.app
found = ft.Column(spacing=T.S2)
searching = ft.Row([ft.ProgressRing(width=16, height=16, stroke_width=2, color=T.ACCENT),
C.meta("Looking for Frames on your network…")], spacing=T.S2)
def discover():
from ...frame.discovery import browse
found.controls = [searching]
C.update(found)
res = browse(4)
items = []
for f in res:
label = {"devkit": "SteamOS · Developer Mode", "frameport": "Set up for FramePort",
"saved": "Remembered", "scan": "SSH found by network scan"}.get(f.source, f.source)
items.append(ft.Container(ft.Row([
ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED if f.source != "scan" else ft.Icons.DEVICES_OTHER_ROUNDED,
color=T.ACCENT),
ft.Column([C.body(f.name if f.source != "scan" else f.host, T.TEXT, weight=ft.FontWeight.W_500),
C.meta(f"{f.host} · {label}")], spacing=2, expand=True),
C.primary("Connect", on_click=lambda e, f=f: app.connect(parse_target(f"{f.user}@{f.host}"))),
], spacing=T.S3), padding=ft.Padding(T.S3, 8, T.S2, 8), bgcolor=T.SURFACE_2,
border_radius=T.RADIUS_SM))
found.controls = items or [C.body("No Frames found. Turn on Developer Mode on the Frame (Settings → "
"System → Developer) and make sure it's on the same network, or use "
"first-time setup below.")]
found.controls.append(C.ghost("Search again", ft.Icons.REFRESH_ROUNDED, lambda e: app.run_bg(discover)))
C.update(found)
pair_box = ft.Column(spacing=T.S3)
def pair(e):
from ...frame.connection import FrameTarget
from ...frame.pairing import PairingServer
if app.pairing:
app.pairing.stop()
def on_paired(info):
app.connect(FrameTarget(info["host"], info["user"], 22, info["name"]))
app.pairing = PairingServer(on_paired=on_paired).start()
line = app.pairing.one_liner
pair_box.controls = [
C.body("On the Frame: Steam button → Power → Switch to Desktop, open Konsole and run:", T.TEXT),
ft.Container(ft.Row([ft.Text(line, font_family="monospace", selectable=True, size=12, color=T.TEXT,
expand=True),
C.icon_btn(ft.Icons.CONTENT_COPY_ROUNDED, "Copy", lambda e: app.copy(line))]),
padding=ft.Padding(T.S3, T.S2, T.S2, T.S2), bgcolor=T.BG, border_radius=T.RADIUS_SM,
border=ft.Border.all(1, T.BORDER)),
ft.Row([ft.ProgressRing(width=14, height=14, stroke_width=2, color=T.ACCENT),
C.meta(f"Waiting for your Frame… (code {app.pairing.code})")], spacing=T.S2),
C.meta("It turns on SSH, trusts this app, makes the Frame findable on your network and installs "
"Lepton if needed. You only do this once."),
]
pair_box.update()
style = dict(dense=True, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
focused_border_color=T.ACCENT, content_padding=ft.Padding(12, 10, 12, 10), text_size=T.T_BODY)
addr = ft.TextField(hint_text="steamos@frame.local or an IP address", width=320, **style)
pw = ft.TextField(hint_text="Password (first time only)", password=True, can_reveal_password=True, width=240,
tooltip=C.tip(HELP["password"]), **style)
saved = saved_targets()
offline = None
if saved and app.frame_state == "offline":
offline = C.callout(ft.Row([C.body(f"{saved[0].label} ({saved[0].host}) isn't reachable. Make sure it's "
"switched on and on the same network.", T.TEXT, expand=True),
C.secondary("Try again", ft.Icons.REFRESH_ROUNDED,
lambda e: app.connect(saved[0]))]), "warn")
elif app.frame_state == "connecting":
offline = C.callout(ft.Row([ft.ProgressRing(width=16, height=16, stroke_width=2, color=T.ACCENT),
C.body(f"Connecting to {saved[0].label if saved else 'your Frame'}…", T.TEXT)],
spacing=T.S3), "info")
import time as _time
if _time.time() - getattr(app, "_discovered_at", 0) > 20: # not on every redraw
app._discovered_at = _time.time()
app.run_bg(discover)
else:
found.controls = [C.meta("Searched a moment ago."),
C.ghost("Search again", ft.Icons.REFRESH_ROUNDED, lambda e: app.run_bg(discover))]
step = lambda n, head, text, *content, help=None: C.card(ft.Row([ # noqa: E731
ft.Container(ft.Text(str(n), weight=ft.FontWeight.W_700, color=T.ACCENT), width=30, height=30,
border_radius=15, bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
ft.Column([C.with_help(C.h2(head), help), C.body(text), *content], spacing=T.S3, expand=True),
], spacing=T.S4, vertical_alignment=ft.CrossAxisAlignment.START), padding=T.S5)
return ft.Column([
app.top_bar("Connect your Steam Frame", "FramePort installs games on the Frame over your network"),
*([offline] if offline else []),
step(1, "On your network", "Frames in Developer Mode show up here.", found, help="developer_mode"),
step(2, "First-time setup", "New Frame? Run one command on it and FramePort does the rest.",
C.secondary("Show setup command", ft.Icons.TERMINAL_ROUNDED, pair), pair_box,
help="first_time_setup"),
step(3, "Enter the address", "If you know the Frame's address.",
ft.Row([addr, pw, C.primary("Connect", on_click=lambda e: app.connect_manual(addr.value, pw.value))],
wrap=True, spacing=T.S3)),
], spacing=T.S4, scroll=ft.ScrollMode.AUTO, expand=True)
def build(self) -> ft.Control:
app = self.app
info = app.frame_info
if app.frame_state != "connected" or not info:
return self.wizard()
return ft.Column([
app.top_bar("Steam Frame", "Your headset, what it's ready for and what's installed"),
self.device_card(info), self.readiness(info), self.installed(info),
], spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
+508
View File
@@ -0,0 +1,508 @@
"""Game page: a hero with the one-click action, where the game is installed, what FramePort will do, and the full
patch list tucked under "Advanced"."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
import flet as ft
from ... import pipeline
from ...artwork import thumbs
from ...core import library
from ...patches import base
from ...recommend import catalog, engine
from .. import components as C
from .. import theme as T
from ..help import HELP
if TYPE_CHECKING:
from ..app import FramePortApp
CATEGORY_TITLES = {"frame": "Steam Frame fixes", "overport": "overport patches", "adapter": "Adapter settings",
"device": "Frame-side files & environment", "pcvr": "PC VR (Revive / Proton)"}
def _ago(t: float | None) -> str:
if not t:
return ""
d = time.time() - t
return "just now" if d < 90 else f"{int(d / 60)} min ago" if d < 3600 else \
f"{int(d / 3600)} h ago" if d < 86400 else time.strftime("%b %d", time.localtime(t))
class GameView:
def __init__(self, app: "FramePortApp", package: str, advanced: bool = False, show_all: bool = False):
self.app, self.package, self.advanced, self.show_all = app, package, advanced, show_all
from .library import twins
self.g = library.game(package)
self.games = library.games()
self.twins = twins(self.games)
self.rift = bool(self.g) and self.g.get("kind") == "rift"
# ---------------------------------------------------------------- hero
def hero(self) -> ft.Control:
app, g, pkg = self.app, self.g, self.package
from .library import display_title
a = g["analysis"]
art = thumbs.url(pkg, ("hero", "landscape", "portrait", "square"), 1280)
platform = "Oculus Rift · PC VR" if self.rift else "Meta Quest"
facts = " · ".join(x for x in (platform, a.get("engine"), a.get("xr")) if x and x != "?")
recipe = g.get("recipe") or {}
chips = [C.status_chip(recipe.get("status", "unknown"))]
st = C.install_state(g, app.frame_info)
if st in ("installed", "outdated"):
chips.append(C.install_badge(st))
if self.rift and pkg in app.pc_installs():
chips.append(C.install_badge("on_pc"))
actions = self.actions()
return ft.Container(
ft.Stack([
C.art_fill(art, radius=16, hero=True, left=0, right=0, top=0, bottom=0,
placeholder_icon=ft.Icons.COMPUTER_ROUNDED if self.rift else ft.Icons.VIEW_IN_AR_ROUNDED),
ft.Container(left=0, right=0, top=0, bottom=0, border_radius=16, gradient=ft.LinearGradient(
begin=ft.Alignment.CENTER_LEFT, end=ft.Alignment.CENTER_RIGHT,
colors=[T.soft(T.BG, 0.97), T.soft(T.BG, 0.80), T.soft(T.BG, 0.25)], stops=[0.0, 0.45, 1.0])),
ft.Container(ft.Row([
ft.Column([
C.ghost("Library", ft.Icons.ARROW_BACK_ROUNDED, lambda e: app.go("library")),
ft.Container(expand=True),
C.meta(facts.upper(), T.TEXT_2, weight=ft.FontWeight.W_600),
ft.Text(display_title(g, self.twins), size=34, weight=ft.FontWeight.W_800, color=T.TEXT,
max_lines=2, overflow=ft.TextOverflow.ELLIPSIS),
ft.Row(chips, spacing=T.S2, wrap=True),
ft.Container(height=T.S2),
actions,
], spacing=T.S2, expand=True),
]), padding=ft.Padding(T.S4, T.S3, T.S5, T.S5), left=0, right=0, top=0, bottom=0),
]),
height=330, border_radius=16, border=ft.Border.all(1, T.BORDER))
def actions(self) -> ft.Control:
app, g, pkg = self.app, self.g, self.package
job = app.jobs.busy_with(pkg)
if job:
pct = f" {job.fraction:.0%}" if job.fraction is not None else ""
return ft.Row([
ft.FilledButton(content=ft.Row([ft.ProgressRing(width=16, height=16, stroke_width=2, color=T.ON_ACCENT),
ft.Text(f"{job.stage or 'Queued'}{pct}", color=T.ON_ACCENT,
weight=ft.FontWeight.W_600)], spacing=10, tight=True),
on_click=lambda e: app.show_activity(True),
style=ft.ButtonStyle(bgcolor=T.ACCENT, shape=ft.RoundedRectangleBorder(radius=8),
padding=ft.Padding(22, 18, 22, 18))),
C.ghost("Cancel", ft.Icons.CLOSE_ROUNDED, lambda e: app.jobs.cancel(job)),
], spacing=T.S2)
buttons: list[ft.Control] = []
for i, (label, icon, handler, disabled, tip) in enumerate(app.play_options(g) + app.install_options(g)):
buttons.append(C.primary(label, icon, handler, disabled, tip, big=True) if i == 0 else
C.secondary(label, icon, handler, disabled, tip))
more = ft.PopupMenuButton(icon=ft.Icons.MORE_HORIZ_ROUNDED, icon_color=T.TEXT_2, bgcolor=T.SURFACE_2,
tooltip="More actions", items=C.menu_items(app.game_actions(pkg, quick=False)))
return ft.Row(buttons + [more], spacing=T.S2, wrap=True)
# ---------------------------------------------------------------- sections
def where(self) -> ft.Control:
app, g, pkg = self.app, self.g, self.package
cards = []
st = C.install_state(g, app.frame_info)
last = g.get("last_test") or {}
diff = C.settings_diff(g, app.frame_info)
if diff:
def names(ids):
return ", ".join(base.get(i).title if i in base.REGISTRY else i for i in ids)
changed = "; ".join(filter(None, [f"on: {names(diff[0])}" if diff[0] else "",
f"off: {names(diff[1])}" if diff[1] else ""]))
frame_line = {"installed": "Installed",
"outdated": (f"Installed · patch settings changed since ({changed}) — update to apply" if diff
else "Installed · a newer build is ready"),
"missing": "Not installed", None: "Frame not connected"}[st]
frame_ok = st in ("installed", "outdated")
# Oculus/LibOVR Rift games need Revive, which can't run on the Frame — be honest about it
rift_oculus = self.rift and "pcvr.revive" in (g.get("recipe") or {}).get("patches", {})
frame_color = (T.TEXT_3 if rift_oculus and not frame_ok else
T.OK if st == "installed" else T.WARN if st == "outdated" else T.TEXT_3)
frame_sub = ("Oculus game — needs Revive, which doesn't run on the Frame. Play it on this PC (SteamVR)."
if rift_oculus else
f"Last launch test: {last.get('verdict')} · furthest: {last.get('milestone') or '—'}" if last
else "Runs directly — no Revive needed" if self.rift else "")
cards.append(self.target_card(
ft.Icons.VIEW_IN_AR_ROUNDED, "Steam Frame", frame_line, frame_color, frame_sub,
[C.icon_btn(ft.Icons.SCIENCE_OUTLINED, C.tip("Launch test on the Frame. " + HELP["launch_test"]),
lambda e: app.test_game(pkg, "frame"), not frame_ok),
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, C.tip("Uninstall from the Frame. " + HELP["uninstall"]),
lambda e: app.uninstall(pkg, "frame"), not frame_ok)] if frame_ok else []))
if self.rift:
dep = app.pc_installs().get(pkg)
cards.append(self.target_card(
ft.Icons.COMPUTER_ROUNDED, "This PC (Steam + Revive)",
"In your Steam library" if dep else "Not installed", T.PC if dep else T.TEXT_3,
f"Revive {dep.get('revive_version') or ''} · {dep.get('backend', 'openxr')} backend" if dep else "",
[C.icon_btn(ft.Icons.SCIENCE_OUTLINED, "Launch test on this PC",
lambda e: app.test_game(pkg, "pc")),
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, "Remove from this PC's Steam library",
lambda e: app.uninstall(pkg, "pc"))] if dep else []))
return C.section("Where it's installed", ft.Row(cards, spacing=T.S3), help="where")
def target_card(self, icon, name, line, color, sub, buttons) -> ft.Control:
return C.card(ft.Row([
ft.Container(ft.Icon(icon, color=color, size=22), width=44, height=44, border_radius=10,
bgcolor=T.soft(color, 0.14), alignment=ft.Alignment.CENTER),
ft.Column([C.body(name, T.TEXT, weight=ft.FontWeight.W_600), C.body(line, color, size=T.T_META)]
+ ([C.meta(sub)] if sub else []), spacing=2, expand=True),
*buttons,
], spacing=T.S3), expand=True)
def notes(self) -> list[ft.Control]:
g, pkg = self.g, self.package
recipe = library.recipe_from_dict(g["recipe"])
entry = catalog.lookup(pkg)
out = []
extra = g["analysis"].get("extra", {})
if self.rift and g.get("exe_confirmed") is False:
out.append(C.callout(ft.Row([
C.body(f"FramePort picked {g.get('exe', '').rsplit('/', 1)[-1]} to start this game, but there are "
"other candidates. Check it before installing.", T.TEXT, expand=True),
C.secondary("Check", ft.Icons.TERMINAL_ROUNDED, lambda e: self.app.choose_exe(pkg))]), "warn",
ft.Icons.HELP_OUTLINE_ROUNDED))
if self.rift and extra.get("platform_sdk"):
out.append(C.callout("Uses the Oculus Platform SDK: it checks your Oculus license. Normally that needs the "
"Oculus app on this PC with a license you own, so it may quit right after starting "
"on the headset. You can still try it.", "warn"))
if recipe.status == "unsupported":
out.append(C.callout(recipe.notes or "This game can't run on the Steam Frame.", "error"))
elif recipe.notes and not (self.rift and extra.get("platform_sdk")):
out.append(C.callout(recipe.notes, "info"))
if entry and entry.pcvr_alternative:
out.append(C.callout(f"PC VR alternative: {entry.pcvr_alternative}", "pc"))
from .library import counterparts
links = []
for r in counterparts(g, self.games):
other_rift = r.get("kind") == "rift"
links.append(C.ghost(f"Also in your library: {'Rift' if other_rift else 'Quest'} version",
ft.Icons.COMPUTER_ROUNDED if other_rift else ft.Icons.VIEW_IN_AR_ROUNDED,
lambda e, p=r["package"]: self.app.open_game(p), color=T.ACCENT))
if links:
out.append(ft.Row(links, spacing=T.S2, wrap=True))
warns = engine.warnings(recipe)
if warns:
out.append(C.callout("\n".join(warns), "warn"))
return out
def about(self) -> ft.Control | None:
from ...artwork import details as det
d = self.g.get("details") or {}
shots = det.screenshot_files(self.package)
if not d.get("description") and not shots and not d.get("genres"):
if "details" in self.g:
return None
return C.section("About this game", C.card(ft.Row([
ft.ProgressRing(width=16, height=16, stroke_width=2, color=T.ACCENT),
C.meta("Looking up the store description and screenshots…")], spacing=T.S2)))
parts: list[ft.Control] = []
if shots:
strip = ft.Row(spacing=T.S3, scroll=ft.ScrollMode.AUTO)
for i, shot in enumerate(shots):
url = thumbs.asset_url(thumbs.thumb(shot, 480, shot.stem))
strip.controls.append(ft.Container(
C.art_fill(url, radius=T.RADIUS_SM, width=256, height=144), border_radius=T.RADIUS_SM,
on_click=lambda e, i=i: self.lightbox(shots, i), ink=True, tooltip="View screenshot"))
parts.append(strip)
facts = [(k, d.get(k)) for k in ("developer", "publisher", "release_date") if d.get(k)]
if facts:
parts.append(ft.Row([ft.Column([C.meta({"developer": "Developer", "publisher": "Publisher",
"release_date": "Released"}[k]), C.body(v, T.TEXT)], spacing=2)
for k, v in facts], spacing=T.S6, wrap=True))
if d.get("genres"):
parts.append(ft.Row([C.pill(g, T.TEXT_2) for g in d["genres"][:8]], spacing=6, wrap=True))
text = d.get("description") or d.get("short") or ""
if text:
long = len(text) > 480
body = C.body(text if not long else text[:480].rsplit(" ", 1)[0] + "…", T.TEXT, selectable=True)
parts.append(body)
if long:
def more(e):
body.value = text if body.value.endswith("…") else text[:480].rsplit(" ", 1)[0] + "…"
e.control.text = "Show less" if not body.value.endswith("…") else "Show more"
body.update()
e.control.update()
parts.append(ft.TextButton("Show more", on_click=more, style=ft.ButtonStyle(color=T.ACCENT)))
links = det.store_links(d)
if links:
parts.append(ft.Row([ft.TextButton(label, icon=ft.Icons.OPEN_IN_NEW_ROUNDED, url=url,
style=ft.ButtonStyle(color=T.ACCENT)) for label, url in links],
spacing=T.S2, wrap=True))
src = ", ".join({"oculusdb": "Meta store (OculusDB)", "steam": "Steam store"}.get(s, s)
for s in d.get("sources") or [])
return C.section("About this game", C.card(ft.Column(parts, spacing=T.S4)),
subtitle=f"From the {src}" if src else None)
def lightbox(self, shots: list, index: int) -> None:
page = self.app.page
state = {"i": index}
img = ft.Image(src=thumbs.asset_url(shots[index]), fit=ft.BoxFit.CONTAIN, width=1100, height=620,
border_radius=T.RADIUS_SM)
counter = C.meta(f"{index + 1} / {len(shots)}")
def show(delta):
state["i"] = (state["i"] + delta) % len(shots)
img.src = thumbs.asset_url(shots[state["i"]])
counter.value = f"{state['i'] + 1} / {len(shots)}"
img.update()
counter.update()
page.show_dialog(ft.AlertDialog(
content=ft.Container(ft.Column([img, ft.Row([
C.icon_btn(ft.Icons.CHEVRON_LEFT_ROUNDED, "Previous", lambda e: show(-1)), counter,
C.icon_btn(ft.Icons.CHEVRON_RIGHT_ROUNDED, "Next", lambda e: show(1)),
ft.Container(expand=True), C.ghost("Close", on_click=lambda e: page.pop_dialog())])],
spacing=T.S2, tight=True), width=1100),
bgcolor=T.BG, shape=ft.RoundedRectangleBorder(radius=T.RADIUS), content_padding=T.S3))
def tags(self) -> ft.Control:
from .library import all_tags, auto_tags, normalize_tag, user_tags
pkg = self.package
row = ft.Row(spacing=T.S2, run_spacing=T.S2, wrap=True)
def save(tags):
library.upsert_game(pkg, tags=tags)
self.g = library.game(pkg)
fill()
row.update()
def remove(tag):
save([t for t in user_tags(self.g) if t != tag])
def add(e):
tag = normalize_tag(e.control.value)
e.control.value = ""
if tag and tag.lower() not in {t.lower() for t in user_tags(self.g)}:
save(user_tags(self.g) + [tag])
else:
e.control.update()
field = ft.TextField(hint_text="Add a tag", dense=True, width=150, text_size=T.T_META,
border_radius=20, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
focused_border_color=T.ACCENT, content_padding=ft.Padding(12, 6, 12, 6), on_submit=add)
def fill():
mine = user_tags(self.g)
chips = [ft.Container(ft.Row([C.body(t, T.TEXT, size=T.T_META),
ft.Icon(ft.Icons.CLOSE_ROUNDED, size=13, color=T.TEXT_2)], spacing=4, tight=True),
bgcolor=T.ACCENT_SOFT, border_radius=20, padding=ft.Padding(10, 5, 8, 5),
on_click=lambda e, t=t: remove(t), tooltip="Remove tag")
for t in mine]
chips += [ft.Container(C.meta(t), border=ft.Border.all(1, T.BORDER), border_radius=20,
padding=ft.Padding(10, 5, 10, 5),
tooltip="Added automatically (engine, VR API or store genre)")
for t in auto_tags(self.g) if t.lower() not in {m.lower() for m in mine}]
used = {x for g in self.games for x in user_tags(g)}
suggestions = [t for t in all_tags(self.games) if t in used and t not in mine][:6]
chips.append(field)
chips += [ft.Container(C.meta("+ " + t, T.ACCENT), padding=ft.Padding(6, 5, 6, 5),
on_click=lambda e, t=t: save(user_tags(self.g) + [t]), tooltip="Add this tag")
for t in suggestions]
row.controls = chips
fill()
return C.section("Tags", row, subtitle="Use tags to group and filter your library", help="tags")
def recipe_summary(self) -> ft.Control:
recipe = library.recipe_from_dict(self.g["recipe"])
entry = catalog.lookup(self.package)
on = [base.get(pid) for pid in recipe.patches if pid in base.REGISTRY or _known(pid)]
visible = [p for p in on if not p.default_on or p.category in ("pcvr",)]
if entry and recipe.source.startswith("catalog"):
lead = f"Known-good recipe for this game, tested {entry.verified.get('date', '')}".strip(", ")
icon, color = ft.Icons.VERIFIED_ROUNDED, T.OK
elif recipe.source == "user":
lead, icon, color = "Your custom recipe", ft.Icons.TUNE_ROUNDED, T.ACCENT
else:
lead, icon, color = "Suggested by FramePort from the game's engine and APIs", ft.Icons.AUTO_AWESOME_ROUNDED, \
T.ACCENT
chips = [ft.Container(C.body(p.title, T.TEXT, size=T.T_META),
tooltip=C.tip(recipe.reasons.get(p.id) or p.description),
bgcolor=T.SURFACE_3, border_radius=6, padding=ft.Padding(10, 5, 10, 5))
for p in visible]
base_count = len(on) - len(visible)
if base_count > 0:
chips.append(C.with_help(C.meta(f"+ {base_count} standard fixes"), "standard_fixes"))
as_is = recipe.as_is
if as_is and self.rift:
# a pre-patched Rift copy still needs a VR runtime on the Frame: Revive is added at launch, not to its files
lead, icon, color = ("Your copy is used as it is (only the Frame's copy gets launch fixes)" +
(" · Revive provides the Oculus runtime" if "pcvr.revive" in recipe.patches else "")), \
ft.Icons.INVENTORY_2_ROUNDED, T.PC
elif as_is:
lead, icon, color = "Installs the game exactly as it is: no patches (your copy is already patched)", \
ft.Icons.INVENTORY_2_ROUNDED, T.PC
chips = []
def toggle_as_is(e):
r = library.recipe_from_dict(library.game(self.package)["recipe"])
r.as_is = bool(e.control.value) # Revive stays as it is: it isn't a change to the game's files
r.source = "user"
pipeline.set_recipe(self.package, r)
self.app.open_game(self.package, advanced=self.advanced)
switch = C.with_help(ft.Switch(value=as_is, active_color=T.PC, on_change=toggle_as_is,
label=("Already patched: don't change the game's files" if self.rift else
"Already patched: install as is (skip patching)")), "as_is")
return C.section(
"What FramePort will do",
C.card(ft.Column([
ft.Row([ft.Icon(icon, color=color, size=18), C.body(lead, T.TEXT, weight=ft.FontWeight.W_500,
expand=True)], spacing=T.S2),
ft.Row(chips, spacing=T.S2, run_spacing=T.S2, wrap=True) if chips else
(ft.Container() if as_is else C.meta("Nothing to patch: it runs as is.")),
ft.Divider(),
switch,
], spacing=T.S3)),
action=C.ghost("Hide patches" if self.advanced else "Customize", ft.Icons.TUNE_ROUNDED,
lambda e: self.app.open_game(self.package, advanced=not self.advanced),
tooltip=None if self.advanced else "See every patch and turn them on or off"),
help="recipe")
def advanced_panel(self) -> ft.Control:
app, g, package = self.app, self.g, self.package
recipe = library.recipe_from_dict(g["recipe"])
analysis = library.analysis_from_dict(g["analysis"])
shown, hidden = engine.visible_patches(analysis, recipe)
listed = {p.id for p in (shown + hidden if self.show_all else shown)}
state = {"recipe": recipe}
warn = C.body("", T.WARN)
def save(r):
r.source = "user"
pipeline.set_recipe(package, r)
warn.value = "\n".join(engine.warnings(r))
warn.update()
def toggle(pid):
def handler(e):
r = state["recipe"]
if e.control.value:
patch = base.get(pid)
r.patches[pid] = {"value": patch.params[0].default} if patch.category == "adapter" else \
{q.key: (r.patches.get(pid) or {}).get(q.key, q.default) for q in patch.params}
r.reasons[pid] = r.reasons.get(pid) or "Enabled by you."
else:
r.patches.pop(pid, None)
save(r)
return handler
def set_value(pid, kind):
def handler(e):
try:
v = float(e.control.value) if kind == "float" else int(float(e.control.value))
except ValueError:
return
state["recipe"].patches[pid] = {"value": v}
save(state["recipe"])
return handler
def set_param(pid, key):
def handler(e):
r = state["recipe"]
r.patches.setdefault(pid, {})[key] = e.control.value or ""
r.reasons[pid] = r.reasons.get(pid) or "Set by you."
save(r)
app.open_game(package, advanced=True, show_all=self.show_all) # the switch shows it's on now
return handler
sections = []
for cat in ("pcvr", "frame", "overport", "adapter", "device"):
rows = []
for p in [p for p in base.all_patches() if p.category == cat and p.id in listed]:
on = p.id in recipe.patches
reason = recipe.reasons.get(p.id, "")
sub = [C.meta(p.description, T.TEXT_2)]
if reason:
sub.insert(0, C.meta(reason, T.ACCENT))
extra = None
if cat == "adapter":
val = recipe.params(p.id).get("value", p.params[0].default)
extra = ft.TextField(value=str(val), width=90, dense=True, text_size=T.T_BODY,
border_color=T.BORDER, on_blur=set_value(p.id, p.params[0].kind))
elif p.params:
q = p.params[0]
multi = q.kind == "text"
extra = ft.TextField(value=str(recipe.params(p.id).get(q.key, q.default) or ""), hint_text=q.help,
width=260, dense=True, multiline=multi, min_lines=1,
max_lines=4 if multi else 1, text_size=T.T_BODY, border_color=T.BORDER,
on_blur=set_param(p.id, q.key))
rows.append(ft.Container(ft.Row([
ft.Column([ft.Row([C.body(p.title, T.TEXT, weight=ft.FontWeight.W_500)]
+ ([C.pill("experimental", T.WARN, tooltip=C.tip(HELP["experimental"]))]
if p.experimental else [])
+ [C.meta(p.id)], spacing=T.S2, wrap=True), *sub], spacing=3, expand=True),
*([extra] if extra else []),
ft.Switch(value=on, on_change=toggle(p.id), active_color=T.ACCENT),
], spacing=T.S3), padding=ft.Padding(T.S4, 10, T.S4, 10), border=ft.Border(
top=ft.BorderSide(1, T.BORDER))))
if not rows:
continue
count = sum(1 for p in base.all_patches() if p.category == cat and p.id in recipe.patches)
sections.append(C.card(ft.Column([
ft.Container(ft.Row([C.body(CATEGORY_TITLES[cat], T.TEXT, weight=ft.FontWeight.W_600),
C.help_icon(f"cat_{cat}"), C.meta(f"{count} on")], spacing=T.S2), padding=ft.Padding(T.S4, T.S3, T.S4, T.S3)),
*rows], spacing=0), padding=0))
def set_alt(e):
state["recipe"].use_alt = e.control.value
save(state["recipe"])
top = []
if recipe.alt_patches:
top.append(C.with_help(ft.Switch(label="Install the alternate build (" + ", ".join(recipe.alt_patches)
+ ")", value=recipe.use_alt, on_change=set_alt, active_color=T.ACCENT),
"alt_build"))
if hidden:
top.append(C.with_help(ft.Switch(
label=f"Show all patches ({len(hidden)} don't apply to this game)", value=self.show_all,
active_color=T.ACCENT, on_change=lambda e: app.open_game(package, advanced=True,
show_all=e.control.value)), "show_all"))
warn.value = "\n".join(engine.warnings(recipe))
return C.section("Patches", *top, warn, *sections,
subtitle="Changes apply to the next install. Hover a chip above for why it was suggested.")
def details(self) -> ft.Control:
g, a = self.g, self.g["analysis"]
rows = [C.kv("Package", g["package"])]
if self.rift:
rows += [C.kv("Folder", g.get("game_dir") or ""), C.kv("Executable", g.get("exe") or ""),
C.kv("Type", f"{a['abis'][0]} · {a['graphics']}"),
C.kv("Size", f"{(a.get('extra', {}).get('data_bytes') or 0) / 2**30:.1f} GiB")]
else:
rows += [C.kv("Version", a.get("version") or ""), C.kv("ABIs", ", ".join(a.get("abis") or []), "abis"),
C.kv("Graphics", a.get("graphics") or "", "graphics"), C.kv("APK", g.get("apk") or ""),
C.kv("Data", f"{(g.get('data_bytes') or 0) / 2**30:.1f} GiB"
+ (f" · {g.get('data_dir')}" if g.get("data_dir") else ""))]
b = g.get("build") or {}
if b.get("apk"):
rows.append(C.kv("Last build", b["apk"]))
rows.append(C.kv("Recipe", (g.get("recipe") or {}).get("source", ""), "recipe_source"))
return ft.ExpansionTile(title=C.body("Details", T.TEXT, weight=ft.FontWeight.W_600), controls=[
ft.Container(ft.Column(rows, spacing=T.S2), padding=ft.Padding(T.S4, 0, T.S4, T.S4))],
bgcolor=T.SURFACE, collapsed_bgcolor=T.SURFACE)
def build(self) -> ft.Control:
if not self.g:
return C.empty_state(ft.Icons.SEARCH_OFF_ROUNDED, "Game not found", "It was removed from the library.",
C.primary("Back to library", on_click=lambda e: self.app.go("library")))
about = self.about()
body = [self.hero(), *self.notes(), self.where(), *([about] if about else []), self.tags(),
self.recipe_summary()]
if self.advanced:
body.append(self.advanced_panel())
body.append(self.details())
return ft.Column([ft.Container(ft.Column(body, spacing=T.S5), padding=ft.Padding(0, 0, T.S3, T.S6))],
scroll=ft.ScrollMode.AUTO, expand=True)
def _known(pid: str) -> bool:
try:
base.get(pid)
return True
except KeyError:
return False
+520
View File
@@ -0,0 +1,520 @@
"""Library: search, filters, tags and a grid of game cards with hover quick actions.
Performance: the view is created once and re-mounted; cards are built in a background thread and streamed in batches;
search and filters only toggle visibility / order of existing cards; images are small thumbnails by URL."""
from __future__ import annotations
import threading
from typing import TYPE_CHECKING
import flet as ft
from ...artwork import thumbs
from ...core import library
from .. import components as C
from .. import theme as T
from ..help import HELP
if TYPE_CHECKING:
from ..app import FramePortApp
DEFAULT_FILTERS = {"q": "", "where": "all", "platform": "all", "status": "all", "sort": "name", "tags": []}
STATUS_ORDER = {"works": 0, "issues": 1, "unknown": 2, "unsupported": 3}
def auto_tags(game: dict) -> list[str]:
"""Tags FramePort derives from the analysis (not stored): platform, engine, XR API, mixed reality."""
a = game.get("analysis") or {}
out = ["PC VR" if game.get("kind") == "rift" else "Quest"]
if a.get("engine") and a["engine"] not in ("Other", "?"):
out.append(a["engine"])
xr = a.get("xr") or ""
out += [t for t in ("OpenXR", "VrApi", "LibOVR") if t in xr]
patches = (game.get("recipe") or {}).get("patches") or {}
if "patch_force_passthrough" in patches or "adapter.scene_emul" in patches:
out.append("Mixed reality")
out += ((game.get("details") or {}).get("genres") or [])[:4] # from the store
return out
def user_tags(game: dict) -> list[str]:
return list(game.get("tags") or [])
def game_tags(game: dict) -> list[str]:
seen, out = set(), []
for t in user_tags(game) + auto_tags(game):
if t.lower() not in seen:
seen.add(t.lower())
out.append(t)
return out
def all_tags(games: list[dict]) -> list[str]:
"""Every tag in the library: the user's own first (alphabetical), then the automatic ones."""
mine = sorted({t for g in games for t in user_tags(g)}, key=str.lower)
auto = sorted({t for g in games for t in auto_tags(g)} - set(mine), key=str.lower)
return mine + auto
def last_used(game: dict) -> float:
"""Latest play, install or launch test (seconds since epoch; 0 if never)."""
times = [(game.get("last_test") or {}).get("time") or 0, game.get("last_played") or 0]
times += [i.get("time") or 0 for i in (game.get("installs") or {}).values() if isinstance(i, dict)]
return max(times)
def game_size(game: dict) -> int:
if game.get("kind") == "rift":
return ((game.get("analysis") or {}).get("extra") or {}).get("data_bytes") or 0
return game.get("data_bytes") or 0
from ...core.titles import counterparts, display_title, twins # noqa: E402,F401 (used by the views)
def normalize_tag(text: str) -> str:
return " ".join((text or "").replace(",", " ").split())[:32]
# ------------------------------------------------------------------------------------------ pure helpers (tested)
def location(game: dict, frame_info: dict | None, pc_installs: set[str]) -> set[str]:
st = C.install_state(game, frame_info)
out = set()
if st in ("installed", "outdated"):
out.add("frame")
if game["package"] in pc_installs:
out.add("pc")
return out
def filter_games(games: list[dict], f: dict, frame_info: dict | None = None,
pc_installs: set[str] | frozenset = frozenset()) -> list[dict]:
q = (f.get("q") or "").strip().lower()
out = []
for g in games:
tags = [t.lower() for t in game_tags(g)]
if q and q not in f"{g.get('title') or ''} {g['package']} {' '.join(tags)}".lower():
continue
if any(t.lower() not in tags for t in f.get("tags") or []):
continue
rift = g.get("kind") == "rift"
if f.get("platform") == "quest" and rift or f.get("platform") == "pcvr" and not rift:
continue
if f.get("status", "all") != "all" and (g.get("recipe") or {}).get("status", "unknown") != f["status"]:
continue
where = f.get("where", "all")
loc = location(g, frame_info, set(pc_installs))
if where == "frame" and "frame" not in loc or where == "pc" and "pc" not in loc or \
where == "none" and loc:
continue
out.append(g)
key = {
"name": lambda g: (g.get("title") or g["package"]).lower(),
"recent": lambda g: -(g.get("added") or 0),
"played": lambda g: -last_used(g),
"size": lambda g: -game_size(g),
"status": lambda g: (STATUS_ORDER.get((g.get("recipe") or {}).get("status", "unknown"), 9),
(g.get("title") or g["package"]).lower()),
}.get(f.get("sort", "name"))
return sorted(out, key=key)
def load_filters() -> dict:
saved = library.setting("ui.library") or {}
return {**DEFAULT_FILTERS, "tags": [], **{k: v for k, v in saved.items() if k in DEFAULT_FILTERS and k != "q"}}
def save_filters(f: dict) -> None:
library.set_setting("ui.library", {k: v for k, v in f.items() if k != "q"})
# ------------------------------------------------------------------------------------------ view
BATCH = 8
class LibraryView:
"""Created once per app; `mount()` returns the same control tree, `reload()` refreshes it in the background."""
def __init__(self, app: "FramePortApp"):
self.app = app
self.f = app.lib_filters
self.cards: dict[str, tuple[tuple, ft.Control]] = {} # package -> (state key, card)
self.checks: dict[str, ft.Control] = {} # package -> selection checkbox overlay
self.selected: set[str] = set()
self.select_mode = False
self.sel_bar = ft.Container(visible=False)
self.resume_bar = ft.Container(visible=False)
self.games: list[dict] = []
self._lock = threading.Lock()
self._search_timer: threading.Timer | None = None
self._gen = 0
self.grid = ft.GridView(expand=True, max_extent=196, child_aspect_ratio=0.62, spacing=T.S4,
run_spacing=T.S4, padding=ft.Padding(0, T.S2, T.S2, T.S5))
self.count = C.meta("")
self.subtitle = C.body("", max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)
self.search = ft.TextField(
value=self.f["q"], hint_text="Search games and tags", prefix_icon=ft.Icons.SEARCH_ROUNDED, dense=True,
width=260, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
focused_border_color=T.ACCENT, content_padding=ft.Padding(12, 8, 12, 8), text_size=T.T_BODY,
on_change=self._on_search)
app.search_field = self.search
self.filters = ft.Container()
self.hint = ft.Container(visible=False)
# one right-click menu for every card: filled with that game's actions when it opens (open_menu)
self.menu = ft.ContextMenu(content=self.grid, secondary_trigger=None, tertiary_trigger=None, expand=True)
self.body = ft.Container(self.menu, expand=True)
add = ft.PopupMenuButton(
content=ft.Container(ft.Row([ft.Icon(ft.Icons.ADD_ROUNDED, color=T.ON_ACCENT, size=18),
ft.Text("Add games", color=T.ON_ACCENT, weight=ft.FontWeight.W_600, size=13)],
spacing=6, tight=True),
bgcolor=T.ACCENT, border_radius=T.RADIUS_SM, padding=ft.Padding(14, 9, 16, 9)),
items=[ft.PopupMenuItem(content=ft.Text("Scan a folder…"), icon=ft.Icons.FOLDER_OPEN_ROUNDED,
on_click=app.pick_folder),
ft.PopupMenuItem(content=ft.Text("Add one game folder…"), icon=ft.Icons.CREATE_NEW_FOLDER_ROUNDED,
on_click=app.pick_game_folder),
ft.PopupMenuItem(content=ft.Text("Add an APK file…"), icon=ft.Icons.ANDROID_ROUNDED,
on_click=app.pick_apk)],
bgcolor=T.SURFACE_2, tooltip="")
self.select_btn = C.secondary("Select", ft.Icons.CHECKLIST_ROUNDED, lambda e: self.set_select_mode(True),
tooltip=C.tip(HELP["select"]))
self.root = ft.Column([
ft.Row([ft.Column([ft.Text("Library", size=T.T_TITLE, weight=ft.FontWeight.W_700, color=T.TEXT,
no_wrap=True), self.subtitle], spacing=2, expand=True), self.search,
self.select_btn, add], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S3),
self.resume_bar, self.hint, self.filters, self.body, self.sel_bar,
], expand=True, spacing=T.S4)
# skeleton cards until the first batch arrives
n = min(len(library.games()), 15)
self.grid.controls = [ft.Container(bgcolor=T.SURFACE, border_radius=T.RADIUS, border=ft.Border.all(1, T.BORDER),
opacity=0.6) for _ in range(n)]
# ---------------------------------------------------------------- public
def mount(self) -> ft.Control:
self._update_hint()
self.update_resume_bar()
self._update_sel_bar()
return self.root
def reload(self) -> None:
"""Rebuild changed cards (streamed in batches), then apply filters. Call from a background thread."""
from ...targets.pc_revive import local_installs
with self._lock:
self._gen += 1
gen = self._gen
games = library.games()
self.games = games
pc = set(local_installs())
tw = twins(games)
self._update_header(games, pc)
if not games:
self.body.content = C.empty_state(
ft.Icons.LIBRARY_ADD_ROUNDED, "Add your games",
"Point FramePort at a folder with Quest game dumps (APK + OBB) or Oculus Rift PC games (one folder "
"per game, or a folder of them). It finds them, works out what each needs and fetches artwork.",
C.primary("Scan a folder", ft.Icons.FOLDER_OPEN_ROUNDED, self.app.pick_folder, big=True),
C.secondary("Add an APK file", ft.Icons.ANDROID_ROUNDED, self.app.pick_apk))
self.cards.clear()
C.update(self.root)
return
if self.body.content is not self.menu:
self.body.content = self.menu
first = not self.cards
if first:
C.update(self.root)
pending = []
for g in games:
key = self._key(g, pc, tw)
old = self.cards.get(g["package"])
if old and old[0] == key:
continue
pending.append((g, key))
for i in range(0, len(pending), BATCH):
if gen != self._gen:
return # a newer reload took over
for g, key in pending[i:i + BATCH]:
self.cards[g["package"]] = (key, self.card(g, pc, tw))
if first or i + BATCH >= len(pending):
self._apply(update=True)
gone = set(self.cards) - {g["package"] for g in games}
for pkg in gone:
self.cards.pop(pkg, None)
if gone or not pending:
self._apply(update=True)
# ---------------------------------------------------------------- selection (queue several installs)
def set_select_mode(self, on: bool) -> None:
self.select_mode = on
if not on:
self.selected.clear()
for pkg, chk in self.checks.items():
chk.visible = on
chk.content.value = pkg in self.selected
self._update_sel_bar()
C.update(self.grid, self.sel_bar, self.select_btn)
def toggle_selected(self, pkg: str) -> None:
self.selected.symmetric_difference_update({pkg})
chk = self.checks.get(pkg)
if chk:
chk.content.value = pkg in self.selected
C.update(chk)
self._update_sel_bar()
def select_visible(self) -> None:
self.selected |= {pkg for pkg, (_, card) in self.cards.items() if card.visible}
self.set_select_mode(True)
def _update_sel_bar(self) -> None:
app = self.app
n = len(self.selected)
self.select_btn.visible = not self.select_mode
rift = [p for p in self.selected if p.startswith("rift.")]
from ...core import winhost
self.sel_bar.visible = self.select_mode
self.sel_bar.content = ft.Container(ft.Row([
ft.Icon(ft.Icons.CHECKLIST_ROUNDED, color=T.ACCENT),
C.body(f"{n} selected" if n else "Select games to install", T.TEXT, weight=ft.FontWeight.W_600),
ft.Container(expand=True),
C.ghost("Select all shown", on_click=lambda e: self.select_visible()),
C.ghost("Clear", on_click=lambda e: (self.selected.clear(), self.set_select_mode(True))),
*([C.secondary(f"Install {len(rift)} on this PC", ft.Icons.COMPUTER_ROUNDED,
lambda e: app.install_many(sorted(rift), "pc"), disabled=not winhost.available())]
if rift else []),
C.primary(f"Install {n} on Frame" if n else "Install on Frame", ft.Icons.VIEW_IN_AR_ROUNDED,
lambda e: app.install_many(sorted(self.selected), "frame"),
disabled=not n or app.frame_state != "connected",
tooltip=None if app.frame_state == "connected" else "Connect your Frame first"),
C.ghost("Done", on_click=lambda e: self.set_select_mode(False)),
], spacing=T.S2), bgcolor=T.SURFACE_2, border_radius=T.RADIUS, padding=ft.Padding(T.S4, T.S2, T.S2, T.S2),
border=ft.Border.all(1, T.ACCENT))
C.update(self.sel_bar, self.select_btn)
def update_resume_bar(self) -> None:
"""Installs that didn't finish (cancelled, failed, or the app closed): resume them from here."""
app = self.app
pending = app.unfinished_installs()
self.resume_bar.visible = bool(pending)
if pending:
names = ", ".join(app._title(p) for p in list(pending)[:3]) + ("…" if len(pending) > 3 else "")
self.resume_bar.content = C.callout(ft.Row([
C.body(f"{len(pending)} install{'s' if len(pending) != 1 else ''} didn't finish: {names}. What was "
"already copied is kept, so resuming continues where it stopped.", T.TEXT, expand=True),
C.primary("Resume", ft.Icons.PLAY_ARROW_ROUNDED, lambda e: app.resume_installs()),
C.ghost("Dismiss", on_click=lambda e: app.forget_installs()),
], spacing=T.S3), "warn", ft.Icons.PAUSE_CIRCLE_OUTLINE_ROUNDED)
C.update(self.resume_bar)
def refresh_async(self) -> None:
self.app.page.run_thread(self.reload)
# ---------------------------------------------------------------- filtering (no rebuilds)
def _on_search(self, e):
self.f["q"] = e.control.value
if self._search_timer:
self._search_timer.cancel()
self._search_timer = threading.Timer(0.18, lambda: self._apply(update=True))
self._search_timer.daemon = True
self._search_timer.start()
def _set(self, key, value):
self.f[key] = value
save_filters(self.f)
self._update_header(self.games, set(self.app.pc_installs()))
self._apply(update=True)
C.update(self.filters)
def _apply(self, update: bool = False) -> None:
pc = set(self.app.pc_installs())
shown = filter_games(self.games, self.f, self.app.frame_info, pc)
order = [g["package"] for g in shown]
visible = set(order)
rest = [p for p in self.cards if p not in visible]
controls = []
for pkg in order + rest:
entry = self.cards.get(pkg)
if not entry:
continue
card = entry[1]
card.visible = pkg in visible
controls.append(card)
self.grid.controls = controls
n = len(self.games)
self.count.value = f"Showing {len(shown)} of {n}" if len(shown) != n else ""
if update:
C.update(self.grid, self.count)
# ---------------------------------------------------------------- header / filter bar
def _update_hint(self):
app = self.app
if app.frame_state != "connected":
self.hint.content = C.callout(ft.Row([
C.body("Connect your Steam Frame to install games and see what's on it.", T.TEXT, expand=True),
C.ghost("Connect", ft.Icons.ARROW_FORWARD_ROUNDED, lambda e: app.go("frame"), color=T.ACCENT)]),
"info", ft.Icons.VIEW_IN_AR_ROUNDED)
self.hint.visible = True
else:
self.hint.visible = False
def _update_header(self, games: list[dict], pc: set[str]) -> None:
n = len(games)
on = sum(1 for g in games if C.install_state(g, self.app.frame_info) in ("installed", "outdated"))
n_pc = len(pc & {g["package"] for g in games})
parts = [f"{n} game{'s' if n != 1 else ''}"]
if self.app.frame_info is not None:
parts.append(f"{on} on your Frame")
if n_pc:
parts.append(f"{n_pc} on this PC")
self.subtitle.value = " · ".join(parts)
self._update_hint()
has_rift = any(g.get("kind") == "rift" for g in games)
where = [("all", "All"), ("frame", "On Frame"), ("none", "Not installed")]
if has_rift:
where.insert(2, ("pc", "On this PC"))
self.filters.content = ft.Row([
self._seg("where", where),
self._seg("platform", [("all", "All"), ("quest", "Quest"), ("pcvr", "PC VR")]) if has_rift else
ft.Container(),
self._menu_chip("status", "Status", [("all", "Any"), ("works", "Works"), ("issues", "Works with issues"),
("unknown", "Untested"), ("unsupported", "Can't run")]),
C.help_icon("status"),
self._tag_menu(games),
ft.Container(expand=True),
self.count,
self._menu_chip("sort", "Sort", [("name", "Name"), ("recent", "Recently added"), ("played", "Recently used"),
("status", "Status"), ("size", "Size")]),
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER)
self.filters.visible = bool(games)
C.update(self.subtitle, self.hint, self.filters)
def _seg(self, key: str, options: list[tuple[str, str]]) -> ft.Control:
items = []
for value, label in options:
on = self.f.get(key) == value
items.append(ft.Container(
ft.Text(label, size=T.T_META, weight=ft.FontWeight.W_600, color=T.TEXT if on else T.TEXT_2),
padding=ft.Padding(12, 6, 12, 6), border_radius=20, bgcolor=T.SURFACE_3 if on else None,
on_click=lambda e, v=value: self._set(key, v), ink=True))
return ft.Container(ft.Row(items, spacing=2, tight=True), padding=3, border_radius=22,
border=ft.Border.all(1, T.BORDER))
def _menu_chip(self, key: str, label: str, options: list[tuple[str, str]]) -> ft.Control:
current = dict(options).get(self.f.get(key), options[0][1])
return ft.PopupMenuButton(
content=ft.Container(ft.Row([C.meta(label + ":"), C.body(current, T.TEXT, size=T.T_META),
ft.Icon(ft.Icons.EXPAND_MORE_ROUNDED, size=16, color=T.TEXT_2)],
spacing=4, tight=True),
padding=ft.Padding(12, 7, 8, 7), border_radius=20, border=ft.Border.all(1, T.BORDER)),
items=[ft.PopupMenuItem(content=ft.Text(text), checked=self.f.get(key) == value,
on_click=lambda e, v=value: self._set(key, v)) for value, text in options],
bgcolor=T.SURFACE_2, tooltip="")
def _toggle_tag(self, tag: str):
tags = list(self.f.get("tags") or [])
tags.remove(tag) if tag in tags else tags.append(tag)
self._set("tags", tags)
def _tag_menu(self, games: list[dict]) -> ft.Control:
chosen = self.f.get("tags") or []
label = ", ".join(chosen) if chosen else "Any"
items = [ft.PopupMenuItem(content=ft.Text(t), checked=t in chosen, on_click=lambda e, t=t: self._toggle_tag(t))
for t in all_tags(games)]
if chosen:
items.append(ft.PopupMenuItem(content=ft.Text("Clear tags"), icon=ft.Icons.CLEAR_ROUNDED,
on_click=lambda e: self._set("tags", [])))
return ft.PopupMenuButton(
content=ft.Container(ft.Row([ft.Icon(ft.Icons.SELL_OUTLINED, size=14, color=T.TEXT_2), C.meta("Tags:"),
C.body(label if len(label) < 28 else f"{len(chosen)} selected", T.TEXT,
size=T.T_META),
ft.Icon(ft.Icons.EXPAND_MORE_ROUNDED, size=16, color=T.TEXT_2)],
spacing=4, tight=True),
padding=ft.Padding(12, 7, 8, 7), border_radius=20,
border=ft.Border.all(1, T.ACCENT if chosen else T.BORDER)),
items=items, bgcolor=T.SURFACE_2, tooltip=C.tip(HELP["tags"]))
# ---------------------------------------------------------------- cards
def _key(self, g: dict, pc: set[str], tw: set[str]) -> tuple:
pkg = g["package"]
job = self.app.jobs.busy_with(pkg)
return (display_title(g, tw), (g.get("recipe") or {}).get("status"), C.install_state(g, self.app.frame_info),
pkg in pc, bool(job), self.app.quick_action(g)[0], thumbs.url(pkg, ("portrait", "square", "icon")),
self.app.frame_state)
def card(self, g: dict, pc: set[str], tw: set[str]) -> ft.Control:
app = self.app
pkg = g["package"]
rift = g.get("kind") == "rift"
art = thumbs.url(pkg, ("portrait", "square", "icon"))
state = C.install_state(g, app.frame_info)
on_pc = pkg in pc
status = (g.get("recipe") or {}).get("status", "unknown")
s_label, s_color = C.STATUS_STYLE.get(status, C.STATUS_STYLE["unknown"])
job = app.jobs.busy_with(pkg)
badges = []
if job:
badges.append(C.pill("Working…", T.ACCENT, ft.Icons.SYNC_ROUNDED, solid=True))
elif state in ("installed", "outdated"):
badges.append(C.install_badge(state))
if on_pc:
badges.append(C.install_badge("on_pc"))
if rift and g.get("exe_confirmed") is False:
badges.append(C.pill("Check exe", T.WARN, ft.Icons.HELP_OUTLINE_ROUNDED, overlay=True,
tooltip=C.tip(HELP["check_exe"])))
platform = C.pill("PC VR" if rift else "Quest", T.PC if rift else T.TEXT,
ft.Icons.COMPUTER_ROUNDED if rift else ft.Icons.VIEW_IN_AR_ROUNDED, overlay=True,
tooltip=C.tip(HELP["platform_pcvr" if rift else "platform_quest"]))
check = ft.Container(ft.Checkbox(value=pkg in self.selected, active_color=T.ACCENT, check_color=T.ON_ACCENT,
on_change=lambda e: self.toggle_selected(pkg)),
bgcolor=T.soft("#000000", 0.6), border_radius=8, left=6, top=40,
visible=self.select_mode)
self.checks[pkg] = check
quick_label, quick_icon = app.quick_action(g)
quick = ft.Container(
ft.FilledButton(quick_label, icon=quick_icon, on_click=lambda e: app.primary_action(pkg),
style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=20), bgcolor=T.ACCENT,
color=T.ON_ACCENT, padding=ft.Padding(14, 8, 14, 8),
text_style=ft.TextStyle(size=12, weight=ft.FontWeight.W_600))),
left=0, right=0, bottom=64, alignment=ft.Alignment.CENTER, opacity=0,
animate_opacity=ft.Animation(160, ft.AnimationCurve.EASE_OUT)) if quick_label else None
dim = state == "missing" and not on_pc
tile = ft.Container(
ft.Stack([
C.art_fill(art, left=0, right=0, top=0, bottom=0, opacity=0.5 if dim else 1.0,
placeholder_icon=ft.Icons.COMPUTER_ROUNDED if rift else ft.Icons.VIEW_IN_AR_ROUNDED),
ft.Container(C.bottom_fade(None, 0.92), left=0, right=0, bottom=0, top=90),
ft.Container(ft.Row([platform], spacing=4), left=10, top=10),
ft.Container(ft.Column(badges, spacing=4, horizontal_alignment=ft.CrossAxisAlignment.END),
right=10, top=10),
*([quick] if quick else []),
check,
ft.Container(ft.Column([
ft.Text(display_title(g, tw), size=14, weight=ft.FontWeight.W_700, color=T.TEXT, max_lines=2,
overflow=ft.TextOverflow.ELLIPSIS),
ft.Container(ft.Row([C.dot(s_color, 7), C.meta(s_label, T.TEXT_2)], spacing=6, tight=True),
tooltip=C.tip(HELP["status"])),
], spacing=4), left=12, right=12, bottom=12),
], expand=True),
border_radius=T.RADIUS, bgcolor=T.SURFACE, border=ft.Border.all(1, T.BORDER), expand=True,
scale=1.0, animate_scale=ft.Animation(140, ft.AnimationCurve.EASE_OUT),
shadow=ft.BoxShadow(blur_radius=18, color=T.soft("#000000", 0.35), offset=ft.Offset(0, 6)),
tooltip=ft.Tooltip(message="Click to open · right-click for quick actions", wait_duration=1500),
on_click=lambda e: self.toggle_selected(pkg) if self.select_mode else app.open_game(pkg))
def hover(e):
on = e.data in (True, "true")
tile.scale = 1.03 if on else 1.0
tile.border = ft.Border.all(1, T.ACCENT if on else T.BORDER)
if quick:
quick.opacity = 1 if on and not self.select_mode else 0
tile.update()
tile.on_hover = hover
return ft.GestureDetector(content=tile, expand=True,
on_secondary_tap_down=lambda e: self.open_menu(pkg, e.global_position))
def open_menu(self, pkg: str, position=None) -> None:
"""Right-click on a card: that game's quick actions (built now, so they match the game's current state)."""
self.menu.items = C.menu_items(self.app.game_actions(pkg))
C.update(self.menu)
self.app.page.run_task(self.menu.open, global_position=position)
+130
View File
@@ -0,0 +1,130 @@
"""Settings: the portable toolchain (incl. Revive), this PC for PC VR games, data folder, about."""
from __future__ import annotations
from typing import TYPE_CHECKING
import flet as ft
from ...core.paths import user_data_dir
from ...recommend import catalog
from .. import components as C
from .. import theme as T
if TYPE_CHECKING:
from ..app import FramePortApp
TOOL_TITLES = {"java": "Java runtime", "overport": "overport", "apksigner": "apksigner", "revive": "Revive"}
TOOL_WHY = {"java": "Runs overport and apksigner", "overport": "Converts Quest games to OpenXR",
"apksigner": "Signs rebuilt games", "revive": "Runs Oculus Rift games on OpenXR"}
class SettingsView:
def __init__(self, app: "FramePortApp"):
self.app = app
self.tools = ft.Column(spacing=0)
self.pc = ft.Column(spacing=0)
def fill_tools(self, check_latest=False):
from ...tools import toolchain
rows = []
for s in toolchain.status(check_latest):
newer = s.latest and s.version and s.latest != s.version and s.version not in ("external", "system")
detail = TOOL_WHY.get(s.name, "")
if s.installed:
detail += f" · {s.version or 'installed'}" + (f" (update: {s.latest})" if newer else "")
if s.name == "revive" and "using" in (s.detail or ""):
detail += " · " + s.detail.split("·")[-1].strip()
else:
detail += " · downloaded when first needed" if s.optional else " · not installed yet"
rows.append(C.status_row(True if s.installed else (None if s.optional else False),
TOOL_TITLES.get(s.name, s.name), detail,
help="revive" if s.name == "revive" else None))
self.tools.controls = rows
C.update(self.tools)
def fill_pc(self):
from ...core import winhost
if not winhost.available():
self.pc.controls = [C.status_row(None, "Windows not detected",
"PC VR games can run on this PC only with Windows (or WSL on Windows)")]
else:
try:
from ...targets.pc_revive import PcReviveTarget
d = PcReviveTarget().describe()
self.pc.controls = [
C.status_row(bool(d["steam"]), "Steam", "Found" if d["steam"] else "Steam for Windows not found"),
C.status_row(d["steamvr"] or None, "SteamVR",
"Installed" if d["steamvr"] else "Install SteamVR from Steam to play PC VR games",
help="steamvr_pc"),
C.status_row(bool(d["revive"]), "Revive", (f"{d['revive_version']} · {d['revive']}"
if d["revive"] else "Downloaded when first needed"),
help="revive"),
]
except Exception as exc: # noqa: BLE001
self.pc.controls = [C.status_row(False, "Couldn't check this PC", str(exc))]
C.update(self.pc)
def agent_text(self) -> str:
"""The agent version this app ships, and the one on the connected Frame (it's replaced on the next command
whenever the files differ)."""
from ...frame.connection import bundled_agent_version
mine = bundled_agent_version()
text = f"v{mine}" if mine else "unknown"
info = self.app.frame_info or {}
remote = info.get("agent_version")
if self.app.frame_state == "connected" and remote:
text += " · on the Frame: " + (f"v{remote}" if remote == mine else f"v{remote} (updates on the next action)")
else:
text += " · Frame not connected"
return text
def build(self) -> ft.Control:
app = self.app
self.tools.controls = [ft.Row([ft.ProgressRing(width=16, height=16, stroke_width=2, color=T.ACCENT),
C.meta("Checking tools…")], spacing=T.S2)]
app.run_bg(self.fill_tools)
self.pc.controls = [ft.Row([ft.ProgressRing(width=16, height=16, stroke_width=2, color=T.ACCENT),
C.meta("Checking this PC…")], spacing=T.S2)]
app.run_bg(self.fill_pc)
try:
from importlib.metadata import version
ver = version("frameport")
except Exception: # noqa: BLE001
ver = "dev"
data = str(user_data_dir())
return ft.Column([
app.top_bar("Settings", "Tools, this PC and where FramePort keeps its data"),
C.section("Tools", C.card(self.tools, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)),
subtitle="FramePort manages its own copies; nothing is installed system-wide",
action=ft.Row([C.ghost("Check for updates", ft.Icons.UPDATE_ROUNDED,
lambda e: app.update_tools(update=True)),
C.secondary("Install missing", ft.Icons.DOWNLOAD_ROUNDED,
lambda e: app.update_tools())], spacing=T.S2)),
C.section("This PC (for PC VR games)", C.card(self.pc, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2))),
C.section("Data", C.card(ft.Column([
C.kv("Data folder", ft.Row([C.body(data, T.TEXT, selectable=True, expand=True),
C.icon_btn(ft.Icons.CONTENT_COPY_ROUNDED, "Copy path",
lambda e: app.copy(data))]), "data_folder"),
C.kv("Catalog", f"{len(catalog.load())} known-good recipes (bundled, remote and yours)", "catalog"),
], spacing=T.S2))),
C.section("Remove FramePort", C.card(ft.Row([
C.body("Removes everything FramePort created: its data and tools on this PC, the Steam entries it "
"added, and (optionally) its games and files on the Frame. Your game dumps aren't touched.",
expand=True),
ft.OutlinedButton("Uninstall FramePort…", icon=ft.Icons.DELETE_FOREVER_ROUNDED,
on_click=lambda e: app.uninstall_app(),
style=ft.ButtonStyle(color=T.ERROR, side=ft.BorderSide(1, T.soft(T.ERROR, 0.6)),
shape=ft.RoundedRectangleBorder(radius=T.RADIUS_SM))),
], spacing=T.S4))),
C.section("About", C.card(ft.Column([
C.kv("Version", ver),
C.kv("Frame agent", self.agent_text(), "frame_agent"),
C.kv("Source", "github.com/spoopyghosty0/frameport"),
C.meta("Uses overport, Revive (LibreVR), Valve's Lepton and Proton. Not affiliated with Valve or Meta."),
], spacing=T.S2))),
], spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
+84
View File
@@ -0,0 +1,84 @@
"""First run: get the toolchain, find the Frame, add games — each step starts by itself and can be skipped."""
from __future__ import annotations
from typing import TYPE_CHECKING
import flet as ft
from ...core import library
from .. import components as C
from .. import theme as T
if TYPE_CHECKING:
from ..app import FramePortApp
def needed() -> bool:
"""Show the welcome screen: never finished it and something basic is missing."""
from ...frame.connection import saved_targets
from ...tools import toolchain
if library.setting("ui.welcome_done"):
return False
tools_ok = all(s.installed for s in toolchain.status(include_optional=False))
return not tools_ok or (not saved_targets() and not library.games())
class WelcomeView:
def __init__(self, app: "FramePortApp"):
self.app = app
def step(self, n: int, head: str, text: str, state: str, *content: ft.Control) -> ft.Control:
icon = {"done": (ft.Icons.CHECK_ROUNDED, T.OK), "busy": (None, T.ACCENT),
"todo": (None, T.TEXT_3), "error": (ft.Icons.PRIORITY_HIGH_ROUNDED, T.ERROR)}[state]
marker = ft.ProgressRing(width=20, height=20, stroke_width=2, color=T.ACCENT) if state == "busy" else \
ft.Icon(icon[0], size=18, color=icon[1]) if icon[0] else ft.Text(str(n), weight=ft.FontWeight.W_700,
color=T.TEXT_2)
return C.card(ft.Row([
ft.Container(marker, width=36, height=36, border_radius=18, alignment=ft.Alignment.CENTER,
bgcolor=T.soft(icon[1], 0.15)),
ft.Column([C.h2(head), C.body(text), *content], spacing=T.S2, expand=True),
], spacing=T.S4, vertical_alignment=ft.CrossAxisAlignment.START), padding=T.S5)
def build(self) -> ft.Control:
from ...tools import toolchain
app = self.app
tools = toolchain.status(include_optional=False)
tools_done = all(s.installed for s in tools)
tool_job = next((j for j in app.jobs.jobs if j.kind == "tools" and j.active), None)
if not tools_done and not tool_job and not app.welcome_started:
app.welcome_started = True
tool_job = app.update_tools(quiet=True)
t_state = "done" if tools_done else "busy" if tool_job else "error"
t_text = ("Java, overport and apksigner are ready." if tools_done else
(tool_job.message or tool_job.stage or "Downloading…") if tool_job else
"The download didn't finish. Check your internet connection.")
f_state = {"connected": "done", "connecting": "busy"}.get(app.frame_state, "todo")
f_text = (f"Connected to {app.target.label}." if app.frame_state == "connected" else
"Looking for your Frame…" if app.frame_state == "connecting" else
"FramePort installs games on the Frame over your network.")
g_state = "done" if library.games() else "todo"
return ft.Column([
ft.Container(height=T.S5),
ft.Row([ft.Container(ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED, size=30, color=T.ON_ACCENT), width=56,
height=56, border_radius=16, bgcolor=T.ACCENT, alignment=ft.Alignment.CENTER),
ft.Column([C.title("Welcome to FramePort"),
C.body("Play your Quest and Oculus Rift games on the Steam Frame. Three steps and "
"you're set.")], spacing=2)], spacing=T.S4),
ft.Container(height=T.S3),
self.step(1, "Getting FramePort ready", t_text, t_state,
*([C.progress_bar(tool_job.fraction)] if tool_job else []),
*([C.secondary("Try again", ft.Icons.REFRESH_ROUNDED, lambda e: app.update_tools())]
if t_state == "error" else [])),
self.step(2, "Connect your Steam Frame", f_text, f_state,
*([] if f_state == "done" else [C.secondary("Set up the Frame", ft.Icons.ARROW_FORWARD_ROUNDED,
lambda e: app.go("frame"))])),
self.step(3, "Add your games", "A folder with Quest game dumps (APK + OBB) or Oculus Rift PC games.",
g_state, ft.Row([C.primary("Scan a folder", ft.Icons.FOLDER_OPEN_ROUNDED, app.pick_folder),
C.ghost("Add an APK file", ft.Icons.ANDROID_ROUNDED, app.pick_apk)],
spacing=T.S2)),
ft.Row([ft.Container(expand=True),
C.primary("Go to my library", ft.Icons.ARROW_FORWARD_ROUNDED, lambda e: app.finish_welcome())
if g_state == "done" else C.ghost("Skip for now", on_click=lambda e: app.finish_welcome())]),
], spacing=T.S4, scroll=ft.ScrollMode.AUTO, expand=True, width=760)
+193
View File
@@ -0,0 +1,193 @@
"""Uninstall FramePort: remove every file it generated.
This PC:
- the data folder (user_data_dir: tools, library, artwork, cache, builds, logs, remembered Frames, the app's SSH key
and the per-game signing keys — back those up first: updates must be signed with the same key)
- Steam shortcuts FramePort added for PC VR games (Windows Steam) and their grid artwork
- the Revive copy under %LOCALAPPDATA%\\FramePort (WSL only; on Windows that *is* the data folder)
The Frame (optional; needs a connection): FramePort's games and their Steam entries (saves optionally kept),
~/Applications/quest-frame and ~/.local/share/frameport. Proton/Lepton stay (Steam apps you can remove in Steam).
The FramePort program itself: delete its folder (portable app) or `uv tool uninstall` / `pip uninstall`.
"""
from __future__ import annotations
import shutil
import time
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from .core.events import Reporter
from .core.paths import user_data_dir
@dataclass
class Item:
what: str
path: str | None = None
size: int = 0
kind: str = "pc" # pc | steam | frame
@dataclass
class Plan:
items: list[Item] = field(default_factory=list)
keys: list[Path] = field(default_factory=list) # signing keystores (backup candidates)
@property
def size(self) -> int:
return sum(i.size for i in self.items)
def _tree_size(p: Path) -> int:
try:
return sum(f.stat().st_size for f in p.rglob("*") if f.is_file())
except OSError:
return 0
def keystores() -> list[Path]:
root = user_data_dir()
return sorted({*root.glob("overport-workspace/signatures/*.keystore"), *root.glob("keystores/**/*.keystore"),
*root.glob("keystores/**/*.jks")})
def wsl_revive_copy() -> Path | None:
from .core import winhost
if not winhost.is_wsl():
return None
local = winhost.env_path("LOCALAPPDATA")
p = local / "FramePort" if local else None
return p if p and p.exists() else None
def plan(frame_info: dict | None = None) -> Plan:
from .targets.pc_revive import local_installs
pl = Plan(keys=keystores())
data = user_data_dir()
pl.items.append(Item("FramePort data (tools, library, artwork, cache, builds, keys)", str(data), _tree_size(data)))
for pkg, dep in local_installs().items():
pl.items.append(Item(f"Steam shortcut on this PC: {dep.get('title') or pkg}", kind="steam"))
rv = wsl_revive_copy()
if rv:
pl.items.append(Item("Revive copy for Windows", str(rv), _tree_size(rv)))
for d in (frame_info or {}).get("installed") or []:
pl.items.append(Item(f"On the Frame: {d.get('title') or d['package']}", d.get("base"), d.get("apk_size") or 0,
kind="frame"))
return pl
def backup_keys(dest: Path) -> Path | None:
"""Zip the signing keystores (and the app's SSH key) to dest (a folder or a .zip path)."""
keys = keystores()
ssh = list((user_data_dir() / "ssh").glob("*"))
if not keys and not ssh:
return None
dest = Path(dest)
if dest.suffix.lower() != ".zip":
dest.mkdir(parents=True, exist_ok=True)
dest = dest / f"FramePort-keys-{time.strftime('%Y%m%d-%H%M%S')}.zip"
root = user_data_dir()
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z:
for f in keys + ssh:
if f.is_file():
z.write(f, f.relative_to(root).as_posix())
z.writestr("README.txt", "FramePort signing keys (password 'password', alias 'key') and the app's SSH key.\n"
"Restore: `frameport tools import-keys <folder with the .keystore files>`.\n")
return dest
def default_backup_dir() -> Path:
from .core import winhost
if winhost.is_wsl():
home = winhost.env_path("USERPROFILE")
if home:
return home / "Documents"
docs = Path.home() / "Documents"
return docs if docs.is_dir() else Path.home()
def remove_pc_shortcuts(reporter: Reporter) -> None:
"""All PC VR shortcuts FramePort added to Windows Steam, with one Steam restart."""
from .core import winhost
from .targets.pc_revive import _vdf, local_installs, shortcut_fields
deps = local_installs()
if not deps or not winhost.available():
return
root = winhost.steam_root()
user = winhost.steam_user(root) if root else None
if not user:
reporter.check("Steam shortcuts on this PC", None, "Steam user not found; remove them in Steam")
return
vdf_mod = _vdf()
cfg = root / "userdata" / user / "config"
was_running = winhost.stop_steam(root)
try:
for pkg, dep in deps.items():
exe = shortcut_fields(dep)[0]
removed = vdf_mod.remove_shortcut(str(cfg / "shortcuts.vdf"), exe)
for art in (cfg / "grid").glob(f"{dep.get('appid')}*"):
art.unlink(missing_ok=True)
reporter.check(f"Steam shortcut: {dep.get('title') or pkg}", True if removed else None,
"removed" if removed else "wasn't there")
finally:
if was_running:
winhost.start_steam(root)
def purge_frame(frame, reporter: Reporter, keep_saves: bool = True, wait: float = 180) -> dict:
reporter.stage("Removing FramePort from the Frame")
frame.agent("purge", keep_saves=keep_saves)
end = time.time() + wait
st = {}
while time.time() < end:
time.sleep(3)
try:
st = frame.agent("purge_status", timeout=30)
except Exception: # noqa: BLE001 - the agent deletes itself at the end; SSH may hiccup while Steam restarts
st = {"state": "done?"}
break
if st.get("state") in ("done", "failed"):
break
for r in st.get("removed", []):
reporter.check(r, True)
for e in st.get("errors", []):
reporter.check("Frame", False, e)
if st.get("kept"):
reporter.log(f"kept saves on the Frame in: {', '.join(st['kept'])}")
return st
def run(reporter: Reporter, frame=None, keep_frame_saves: bool = True, backup_dir: Path | None = None,
remove_frame: bool = False) -> dict:
"""Uninstall. Returns {backup, removed}. The data folder goes last."""
out: dict = {"backup": None}
if backup_dir is not None:
reporter.stage("Backing up signing keys")
z = backup_keys(backup_dir)
out["backup"] = str(z) if z else None
reporter.check("Signing keys backup", True, str(z) if z else "no keys to back up")
if remove_frame and frame is not None:
purge_frame(frame, reporter, keep_frame_saves)
try:
frame.close()
except Exception: # noqa: BLE001
pass
reporter.stage("Removing Steam shortcuts on this PC")
try:
remove_pc_shortcuts(reporter)
except Exception as exc: # noqa: BLE001
reporter.check("Steam shortcuts on this PC", False, str(exc))
rv = wsl_revive_copy()
if rv:
shutil.rmtree(rv, ignore_errors=True)
reporter.check("Revive copy for Windows", True, str(rv))
reporter.stage("Removing FramePort's data")
data = user_data_dir()
shutil.rmtree(data, ignore_errors=True)
reporter.check("FramePort data", not data.exists(), str(data))
return out
+6 -1
View File
@@ -80,14 +80,19 @@ def game_lines(log: str, package: str | None = None) -> list[str]:
def triage(log: str, state: str = "UNKNOWN", package: str | None = None) -> TriageResult:
db = database()
lines = game_lines(log, package)
kind = "pcvr" if package and package.startswith("rift.") else "quest" # Proton/Revive logs vs Lepton logcat
lines = game_lines(log, package) if kind == "quest" else [ANSI.sub("", l) for l in log.splitlines()]
text = "\n".join(lines)
res = TriageResult(state, None)
for m in db["milestones"]:
if m.get("kind", "quest") != kind:
continue
if re.search(m["pattern"], text):
res.milestones.append(m["label"])
res.milestone = m["label"]
for sig in db["signatures"]:
if sig.get("kind", "quest") != kind:
continue
hit = re.search(sig["pattern"], text)
if hit:
line = next((l for l in lines if re.search(sig["pattern"], l)), hit.group(0))
+317
View File
@@ -1,6 +1,7 @@
"""The Frame-side agent (stdlib only) — pieces that don't need a Frame."""
import importlib.util
import json
from types import SimpleNamespace
import subprocess
import sys
from pathlib import Path
@@ -78,3 +79,319 @@ def test_cleanup_refuses_outside_paths(monkeypatch, tmp_path):
for bad in ("/etc", "~/Applications/quest-frame", "~/.."):
with pytest.raises(a.AgentError):
a.cmd_cleanup({"paths": [bad]})
# ------------------------------------------------------------------------------------------ PC VR under Proton
def fake_steam_tools(a, tmp_path):
"""A Steam library with an ARM64 Proton (needing a runtime) installed, like the Frame's."""
apps = tmp_path / ".local/share/Steam/steamapps"
(apps / "common/Proton 11.0 (ARM64)").mkdir(parents=True)
(apps / "common/SteamLinuxRuntime_4-arm64").mkdir(parents=True)
(apps / "common/Proton 11.0 (ARM64)/toolmanifest.vdf").write_text(
'"manifest"\n{\n "version" "2"\n "commandline" "/proton %verb%"\n "require_tool_appid" "4185400"\n}\n')
(apps / "common/SteamLinuxRuntime_4-arm64/toolmanifest.vdf").write_text(
'"manifest"\n{\n "commandline" "/_v2-entry-point --verb=%verb% --"\n}\n')
for appid, name, d in ((4628740, "Proton 11.0 (ARM64)", "Proton 11.0 (ARM64)"),
(4185400, "Steam Linux Runtime 4.0 - Arm64", "SteamLinuxRuntime_4-arm64")):
(apps / f"appmanifest_{appid}.acf").write_text(
f'"AppState"\n{{\n\t"appid"\t\t"{appid}"\n\t"name"\t\t"{name}"\n\t"StateFlags"\t\t"4"\n'
f'\t"installdir"\t\t"{d}"\n}}\n')
a.arm64_compat_tools = lambda: {
"proton_11-arm64": {"appid": 4628740, "display_name": "Proton 11.0-2 (ARM64)", "from_oslist": "windows",
"require_tool_appid": 4185400, "aliases": "proton-stable-arm64"},
"proton-experimental-arm64": {"appid": 4427310, "display_name": "Proton Experimental (ARM64)",
"from_oslist": "windows", "aliases": "proton-experimental"},
"steamlinuxruntime_steamrt4-arm64": {"appid": 4185400, "from_oslist": "linux"},
}
return apps
def test_proton_status_and_command(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
apps = fake_steam_tools(a, tmp_path)
st = a.cmd_proton_status({})
assert st["ready"]["name"] == "proton_11-arm64"
assert [t["name"] for t in st["tools"]] == ["proton_11-arm64", "proton-experimental-arm64"] # stable first
cmd = a.compat_command(st["ready"]["dir"])
assert cmd == [str(apps / "common/SteamLinuxRuntime_4-arm64/_v2-entry-point"), "--verb=waitforexitandrun", "--",
str(apps / "common/Proton 11.0 (ARM64)/proton"), "waitforexitandrun"]
assert a.pick_proton(st["tools"], "proton-experimental")["installed"] is False
def test_install_proton_request_mode(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
fake_steam_tools(a, tmp_path)
calls = []
monkeypatch.setattr(a.subprocess, "Popen", lambda args, **k: calls.append(args))
r = a.cmd_install_proton({"tool": "proton-experimental-arm64"})
assert r["requested"] == [4427310] and calls == [["steam", "-ifrunning", "steam://install/4427310"]]
assert a.cmd_install_proton({})["installed"] is True
def test_stub_manifest(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
lib = tmp_path / "lib"
lib.mkdir()
assert a.write_stub_manifest(4427310, "Proton Experimental (ARM64)", "Proton Experimental (ARM64)", str(lib))
text = (lib / "appmanifest_4427310.acf").read_text()
assert '"StateFlags"\t\t"1026"' in text and '"installdir"\t\t"Proton Experimental (ARM64)"' in text
assert not a.write_stub_manifest(4427310, "x", "x", str(lib)) # never overwrites a real manifest
def test_pcvr_install_flow(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
fake_steam_tools(a, tmp_path)
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
prep = a.cmd_prepare_pcvr({"package": "rift.space_game", "title": "Space Game"})
inc = Path(prep["incoming"])
(inc / "game/Space Game_Data").mkdir(parents=True)
(inc / "game/Space Game.exe").write_bytes(b"MZexe")
(inc / "game/Space Game_Data/level0").write_bytes(b"1234")
(inc / "revive").mkdir(exist_ok=True)
(inc / "revive/ReviveInjector.exe").write_bytes(b"MZ")
(inc / "xrlayer").mkdir(exist_ok=True)
layer_json = '{"api_layer": {"name": "XR_APILAYER_FRAMEPORT_timefix", "library_path": "./libxr_frameport_timefix.so"}}'
(inc / "xrlayer/XR_APILAYER_FRAMEPORT_timefix.json").write_text(layer_json)
(inc / "xrlayer/libxr_frameport_timefix.so").write_bytes(b"ELF")
manifests = {"game": {"Space Game.exe": 5, "Space Game_Data/level0": 4}, "revive": {"ReviveInjector.exe": 2},
"xrlayer": {"XR_APILAYER_FRAMEPORT_timefix.json": len(layer_json), "libxr_frameport_timefix.so": 3}}
r = a.cmd_finalize_pcvr({"package": "rift.space_game", "title": "Space Game", "exe": "Space Game.exe",
"manifests": manifests, "env": {"PROTON_LOG": "1", "bad key": "x"}, "xr_layer": True})
assert r["ok"] and r["proton"] == "proton_11-arm64"
launch = Path(prep["anchor"]) / "launch.sh"
text = launch.read_text()
assert subprocess.run(["bash", "-n", str(launch)]).returncode == 0
assert "SteamGameId=" in text and "export PROTON_LOG=1" in text and "bad key" not in text
assert "ReviveInjector.exe /openxr 'Z:" in text and "Space Game.exe'" in text
assert 'XR_ENABLE_API_LAYERS="XR_APILAYER_FRAMEPORT_timefix' in text and 'XR_API_LAYER_PATH="$base/xrlayer' in text
env = subprocess.run(["bash", "-c", text.split("export XDG_RUNTIME_DIR")[0].replace("set -euo pipefail", "set -eu")
.split("[[ -d")[0] + 'base=/b\n' + text.split("export PROTON_LOG_DIR=\"$base\"\n")[1]
.split("export XDG_RUNTIME_DIR")[0] + 'echo "$XR_API_LAYER_PATH|$XR_ENABLE_API_LAYERS"'],
capture_output=True, text=True)
assert env.stdout.strip() == "/b/xrlayer|XR_APILAYER_FRAMEPORT_timefix", env.stderr
# the layer is registered as an explicit layer in the user's XDG data dir (Proton's container drops
# XR_API_LAYER_PATH), pointing at a shared absolute copy
reg = json.loads((tmp_path / ".local/share/openxr/1/api_layers/explicit.d/XR_APILAYER_FRAMEPORT_timefix.json")
.read_text())
lib = Path(reg["api_layer"]["library_path"])
assert lib.is_absolute() and lib.read_bytes() == b"ELF"
dep = a.deployment("rift.space_game")
assert dep["kind"] == "pcvr" and dep["files"]["game"] == manifests["game"]
listed = a.cmd_list_installed({})["games"]
assert listed[0]["kind"] == "pcvr" and listed[0]["apk_present"] and "files" not in listed[0]
# an update that drops a file removes it, and a new prepare sees what's there
prep2 = a.cmd_prepare_pcvr({"package": "rift.space_game", "title": "Space Game"})
assert prep2["existing"]["game"] == manifests["game"] and prep2["appid"] == prep["appid"]
a.cmd_finalize_pcvr({"package": "rift.space_game", "title": "Space Game", "exe": "Space Game.exe",
"manifests": {"game": {"Space Game.exe": 5}, "revive": manifests["revive"]}})
assert not (Path(prep["base"]) / "game/Space Game_Data/level0").exists()
# uninstall keeps the Proton prefix (saves)
(Path(prep["base"]) / "compatdata/pfx").mkdir(parents=True)
assert a.cmd_uninstall({"package": "rift.space_game", "keep_data": True})["removed"]
assert (Path(prep["base"]) / "compatdata/pfx").is_dir() and not (Path(prep["base"]) / "game").exists()
def test_pcvr_finalize_rejects_bad_exe(monkeypatch, tmp_path):
import pytest
a = load_agent(monkeypatch, tmp_path)
fake_steam_tools(a, tmp_path)
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
with pytest.raises(a.AgentError):
a.cmd_finalize_pcvr({"package": "rift.x", "title": "X", "exe": "../../etc/passwd", "manifests": {}})
def test_shortcut_tag_and_launch_options(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
vdf = tmp_path / "shortcuts.vdf"
a.upsert_shortcut(str(vdf), '"C:\\Revive\\ReviveInjector.exe"', "Game", '"D:\\G\\"', "", "Rift via Revive",
'/openxr "D:\\G\\Game.exe"')
e = a.vdf_decode(vdf.read_bytes())["shortcuts"]["0"]
assert e["tags"] == {"0": "Rift via Revive"} and e["LaunchOptions"] == '/openxr "D:\\G\\Game.exe"'
def test_appinfo_parser(monkeypatch, tmp_path):
"""appinfo.vdf v29: header, one app (binary KV with string-table keys), string table."""
import struct
a = load_agent(monkeypatch, tmp_path)
keys = ["appinfo", "appid", "common", "name", "extended", "compat_tools", "proton-x-arm64", "from_oslist"]
k = {n: i for i, n in enumerate(keys)}
def s(key, val):
return b"\x01" + struct.pack("<I", k[key]) + val.encode() + b"\0"
def m(key, body):
return b"\x00" + struct.pack("<I", k[key]) + body + b"\x08"
kv = m("appinfo", b"\x02" + struct.pack("<Ii", k["appid"], 7) + m("common", s("name", "Compat List")) +
m("extended", m("compat_tools", m("proton-x-arm64", s("from_oslist", "windows") +
b"\x02" + struct.pack("<Ii", k["appid"], 99))))) + b"\x08"
app = struct.pack("<I", 7) + struct.pack("<I", 60 + len(kv)) + b"\0" * 60 + kv
body = app + struct.pack("<I", 0)
str_off = 16 + len(body)
table = struct.pack("<I", len(keys)) + b"".join(n.encode() + b"\0" for n in keys)
data = struct.pack("<II", 0x07564429, 1) + struct.pack("<q", str_off) + body + table
f = tmp_path / "appinfo.vdf"
f.write_bytes(data)
out = a.appinfo_entries(path=str(f))
assert out[7]["common"]["name"] == "Compat List"
assert out[7]["extended"]["compat_tools"]["proton-x-arm64"] == {"from_oslist": "windows", "appid": 99}
def test_run_tree_kills_the_whole_group(monkeypatch, tmp_path):
"""Wine leaves children holding the output open; a timeout must still return and kill them."""
import time
a = load_agent(monkeypatch, tmp_path)
start = time.time()
out, code = a.run_tree(["bash", "-c", "echo started; (sleep 60 &) ; sleep 60"], dict(a.os.environ), str(tmp_path),
str(tmp_path / "log"), 2)
assert code is None and "started" in out and time.time() - start < 20
out, code = a.run_tree(["bash", "-c", "echo ok; exit 3"], dict(a.os.environ), str(tmp_path), str(tmp_path / "l2"), 10)
assert (out.strip(), code) == ("ok", 3)
def test_purge_removes_frameport_and_keeps_saves(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
fake_steam_tools(a, tmp_path)
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
monkeypatch.setattr(a, "stop_steam", lambda: True)
monkeypatch.setattr(a, "start_steam", lambda s: None)
users = tmp_path / ".local/share/Steam/userdata/42/config"
(users / "grid").mkdir(parents=True)
# a Quest-style install (base = anchor, saves in lepton-data) and a PC VR one (saves in compatdata)
q = Path(a.ANCHORS) / "com.x.y"
(q / "lepton-app").mkdir(parents=True)
(q / "lepton-data" / "external").mkdir(parents=True)
(q / "launch.sh").write_text("#!/bin/sh")
(q / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 7, "base": str(q), "title": "Q"}))
a.upsert_shortcut(str(users / "shortcuts.vdf"), f'"{q}/launch.sh"', "Q", str(q))
(users / "grid" / "7p.png").write_bytes(b"x")
agent_home = Path(a.AGENT_HOME)
(agent_home / "agent").mkdir(parents=True)
a.purge_worker(json.dumps({"keep_saves": True, "status": str(tmp_path / "st.json")}))
st = json.loads((tmp_path / "st.json").read_text())
assert st["state"] == "done", st
assert (q / "lepton-data").is_dir() and not (q / "lepton-app").exists() and not (q / "launch.sh").exists()
assert a.vdf_decode((users / "shortcuts.vdf").read_bytes())["shortcuts"] == {}
assert not (users / "grid" / "7p.png").exists() and not agent_home.exists()
# without keeping saves everything goes
a.purge_worker(json.dumps({"keep_saves": False, "status": str(tmp_path / "st.json")}))
assert not Path(a.ANCHORS).exists()
def test_shortcut_tags_merge(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
vdf = tmp_path / "shortcuts.vdf"
exe = '"/x/launch.sh"'
a.upsert_shortcut(str(vdf), exe, "G", "/x", tags=["Meta Quest", "Action"])
root = a.vdf_decode(vdf.read_bytes())
root["shortcuts"]["0"]["tags"]["9"] = "My collection" # a tag the user set in Steam
vdf.write_bytes(a.vdf_encode(root))
a.upsert_shortcut(str(vdf), exe, "G", "/x", tags=["Meta Quest", "Action", "Indie"])
tags = list(a.vdf_decode(vdf.read_bytes())["shortcuts"]["0"]["tags"].values())
assert tags == ["Quest on Frame", "Meta Quest", "Action", "My collection", "Indie"]
def test_pcvr_launcher_oculus_hmd_helper(monkeypatch, tmp_path):
"""pcvr.oculus_unreal: launch.sh runs Revive's injector (or the game) through fp_oculushmd.exe."""
import pytest
a = load_agent(monkeypatch, tmp_path)
fake_steam_tools(a, tmp_path)
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
def install(pkg, helper=True, revive=True, oculus_hmd=True):
prep = a.cmd_prepare_pcvr({"package": pkg, "title": "UE Game"})
inc = Path(prep["incoming"])
(inc / "game").mkdir(parents=True, exist_ok=True)
(inc / "game/UEGame.exe").write_bytes(b"MZexe")
manifests = {"game": {"UEGame.exe": 5}}
if revive:
(inc / "revive").mkdir(exist_ok=True)
(inc / "revive/ReviveInjector.exe").write_bytes(b"MZ")
manifests["revive"] = {"ReviveInjector.exe": 2}
if helper:
(inc / "helpers").mkdir(exist_ok=True)
(inc / "helpers/fp_oculushmd.exe").write_bytes(b"MZhelp")
manifests["helpers"] = {"fp_oculushmd.exe": 6}
a.cmd_finalize_pcvr({"package": pkg, "title": "UE Game", "exe": "UEGame.exe", "manifests": manifests,
"revive": revive, "oculus_hmd": oculus_hmd, "game_args": ["-nocrashreports"]})
launch = Path(prep["anchor"]) / "launch.sh"
assert subprocess.run(["bash", "-n", str(launch)]).returncode == 0
return launch.read_text().splitlines()[-1], prep["base"]
cmd, base = install("rift.ue_game")
helper = f"{base}/helpers/fp_oculushmd.exe"
assert helper in cmd and cmd.index(helper) < cmd.index("ReviveInjector.exe")
# everything after the helper is a Windows command line: the injector by its Z: path, then the game + args
assert f"'Z:{base}/revive/ReviveInjector.exe'".replace("/", "\\") in cmd
assert cmd.index("ReviveInjector.exe") < cmd.index("/openxr") < cmd.index("UEGame.exe") < cmd.index("-nocrashreports")
assert a.deployment("rift.ue_game")["oculus_hmd"] is True
cmd, base = install("rift.ue_direct", revive=False)
assert f"{base}/helpers/fp_oculushmd.exe" in cmd and "ReviveInjector" not in cmd and "'Z:" in cmd
cmd, base = install("rift.ue_plain", helper=False, oculus_hmd=False)
assert "fp_oculushmd" not in cmd and f"{base}/revive/ReviveInjector.exe /openxr" in cmd
with pytest.raises(a.AgentError, match="fp_oculushmd.exe missing"):
install("rift.ue_nohelper", helper=False)
def test_list_files(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
base = tmp_path / "Applications/quest-frame/rift.g"
(base / "game/Bin").mkdir(parents=True)
(base / "game/Bin/Game.exe").write_bytes(b"MZ12")
(base / "game/Bin/CrashReportClient.exe.disabled").write_bytes(b"MZ")
(base / "launch.sh").write_text("#!/bin/sh\n")
(base / "deployment.json").write_text(json.dumps({
"package": "rift.g", "kind": "pcvr", "base": str(base),
"files": {"game": {"Bin/Game.exe": 4, "Bin/Data.pak": 10, "Bin/CrashReportClient.exe": 2}}}))
r = a.cmd_list_files({"package": "rift.g"})
assert len(r["roots"]) == 1 and r["roots"][0]["name"] == "Install folder" # the anchor is the same folder
files = dict(map(tuple, r["roots"][0]["files"]))
assert files["game/Bin/Game.exe"] == 4 and "launch.sh" in files
assert r["missing"] == [["game/Bin/Data.pak", 10, None]] # the renamed crash reporter isn't "missing"
assert not r["truncated"]
def test_launch_uses_steam_shortcut(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
anchor = tmp_path / "Applications/quest-frame/com.x.y"
anchor.mkdir(parents=True)
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 2546384938, "base": str(anchor)}))
calls = []
monkeypatch.setattr(a, "run", lambda cmd, **k: (calls.append(cmd), SimpleNamespace(returncode=0, stdout=""))[1])
r = a.cmd_launch({"package": "com.x.y"})
assert r["gameid"] == (2546384938 << 32) | 0x02000000
assert calls[-1][-1] == f"steam://rungameid/{r['gameid']}" and calls[-1][0] == "systemd-run"
def test_uninstall_quest_keeping_saves_drops_the_install_record(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
anchor = tmp_path / "Applications/quest-frame/com.x.y"
(anchor / "lepton-app").mkdir(parents=True)
(anchor / "lepton-app/game.apk").write_bytes(b"PK")
(anchor / "lepton-data/saves").mkdir(parents=True)
(anchor / "lepton-data/saves/slot1").write_text("progress")
(anchor / "launch.sh").write_text("#!/bin/sh\n")
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "base": str(anchor), "appid": 1}))
monkeypatch.setattr(a, "container_running", lambda appid: False)
assert a.cmd_uninstall({"package": "com.x.y"})["removed"]
assert not a.cmd_list_installed({})["games"] # no longer reported as installed
assert (anchor / "lepton-data/saves/slot1").read_text() == "progress" # saves kept
def test_prune_shortcuts_on_relaunch_change(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
vdf = str(tmp_path / "shortcuts.vdf")
old = a.upsert_shortcut(vdf, '"C:\\Revive\\ReviveInjector.exe"', "Vader", "/d", tag="Rift via Revive",
tags=["Rift via Revive"])
a.upsert_shortcut(vdf, '"C:\\Other.exe"', "Other game", "/d", tag="Rift via Revive", tags=["Rift via Revive"])
removed = a.prune_shortcuts(vdf, "Vader", '"C:\\game\\WKND.exe"', "Rift via Revive") # new direct launch
assert removed == [old]
root = a.vdf_decode(open(vdf, "rb").read())
names = {v.get("appname") for v in root["shortcuts"].values() if isinstance(v, dict)}
assert names == {"Other game"} # the stale Vader entry is gone, the unrelated one stays
# re-adding Vader directly, then pruning again, is a no-op for the kept entry
a.upsert_shortcut(vdf, '"C:\\game\\WKND.exe"', "Vader", "/d", tag="Rift via Revive", tags=["Rift via Revive"])
assert a.prune_shortcuts(vdf, "Vader", '"C:\\game\\WKND.exe"', "Rift via Revive") == []
+217
View File
@@ -0,0 +1,217 @@
"""Artwork for Rift games (mocked network), thumbnails, Quest/Rift twin titles, uninstall."""
import io
import json
import zipfile
from pathlib import Path
import pytest
from frameport.artwork import fetch, sources, thumbs
from frameport.core import library
def png(w=800, h=1200, color=(40, 80, 160)) -> bytes:
from PIL import Image
b = io.BytesIO()
Image.new("RGB", (w, h), color).save(b, "PNG")
return b.getvalue()
class Resp:
def __init__(self, content=b"", status=200, data=None):
self.content, self.status_code, self._data = content, status, data
def json(self):
return self._data
@pytest.fixture
def net(monkeypatch):
"""Fake OculusDB / Steam / Meta art: search results by URL substring, images by URL."""
db = {"rift": [], "all": [], "steam": [], "images": {}, "meta": {}}
def cached_json(name, url, max_age=0, fallback=None):
if "oculusdb" in url:
term = url.split("/search/")[1].split("?")[0]
rows = db["rift"] if "headsets=RIFT" in url else db["all"]
return [r for r in rows if r.get("_term", "").lower() in term.lower().replace("%20", " ")]
if "storesearch" in url:
return {"items": db["steam"]}
return fallback
def http_get(url, timeout=30):
if "by_package" in url:
pkg = url.split("package=")[1]
if pkg in db["meta"]:
return Resp(data={"status": "ok", "displayName": "Meta name", "images": [
{"image_type": "APP_IMG_COVER_PORTRAIT", "uri": __import__("base64").b64encode(png()).decode()}]})
return Resp(data={"status": "not found"})
if url in db["images"]:
return Resp(db["images"][url])
return Resp(b"", 404)
monkeypatch.setattr(sources.cache, "cached_json", cached_json)
monkeypatch.setattr(sources.cache, "http_get", http_get)
monkeypatch.setattr(fetch.cache, "http_get", http_get)
return db
def test_norm_and_terms():
assert sources.norm("Asgard's Wrath") == sources.norm("Asgards Wrath")
assert sources.norm("Lone Echo II") == sources.norm("Lone Echo 2")
assert sources.norm("Vader Immortal: Episode III") == sources.norm("Vader Immortal - Episode III")
assert "Asgard" in sources.search_terms("Asgards Wrath")
def test_quest_version_rules(net):
net["all"] = [{"_term": "robo recall", "appName": "Robo Recall: Unplugged", "packageName": "com.YC.RoboRecall"},
{"_term": "the climb", "appName": "The Climb 2", "packageName": "com.crytek.climb2"},
{"_term": "chronos", "appName": "Chrono Strike", "packageName": "com.greensky.chronostrike"}]
assert sources.match_quest("Robo Recall") == "com.YC.RoboRecall"
assert sources.match_quest("The Climb") is None # a sequel isn't the Quest version
assert sources.match_quest("Chronos") is None
def test_rift_art_chain(net):
# 1. OculusDB square cover, exact (apostrophe-insensitive) match found through a shorter search term
net["rift"] = [{"_term": "asgard", "appName": "Asgard's Wrath", "id": "111", "imageLink": "/cdn/images/111",
"canonicalName": "sanzaru-asgards-wrath"}]
net["images"][sources.OCULUSDB + "/cdn/images/111"] = png(720, 720)
found = sources.fetch_rift("rift.asgards_wrath", "Asgards Wrath")
assert found["source"] == "oculusdb" and found["title"] == "Asgard's Wrath"
assert {p.stem for p in fetch.files("rift.asgards_wrath")} == {"square"}
# 2. the Quest version's full Meta art wins when there is one
net["all"] = [{"_term": "robo recall", "appName": "Robo Recall: Unplugged", "packageName": "com.YC.RoboRecall"}]
net["meta"]["com.YC.RoboRecall"] = True
found = sources.fetch_rift("rift.robo_recall", "Robo Recall")
assert found["source"] == "meta" and found["quest_package"] == "com.YC.RoboRecall"
# 3. Steam, exact names only ("Stormlander" isn't "Stormland")
net["steam"] = [{"id": 1, "name": "Stormlander"}, {"id": 469610, "name": "Rick and Morty: Virtual Rick-ality"}]
for f in sources.STEAM_FILES.values():
net["images"][sources.STEAM_CDN.format(id=469610, file=f)] = png()
assert sources.fetch_rift("rift.stormland", "Stormland").get("source") is None
found = sources.fetch_rift("rift.rm", "Rick and Morty - Virtual Rick-ality")
assert found["source"] == "steam" and {"portrait", "hero", "landscape", "logo"} <= \
{p.stem for p in fetch.files("rift.rm")}
def test_exe_icon_fallback(net, tmp_path, monkeypatch):
exe = tmp_path / "g.exe"
exe.write_bytes(b"MZ")
monkeypatch.setattr("frameport.analysis.rift.exe_icon", lambda p: png(64, 64))
assert sources.fetch_rift("rift.none", "Totally Unknown Game", exe=exe)["source"] == "icon"
def test_thumbnails_and_urls():
d = fetch.artwork_dir("com.x")
(d / "portrait.png").write_bytes(png(1000, 1500))
url = thumbs.url("com.x", ("portrait",), 400)
assert url.startswith("/artwork/com.x/t_portrait_400_") and url.endswith(".jpg")
from PIL import Image
t = next(d.glob("t_portrait_400_*.jpg"))
assert Image.open(t).size == (400, 600)
assert thumbs.url("com.x", ("portrait",), 400) == url # cached
assert "t_portrait" not in "".join(p.name for p in fetch.files("com.x")) # thumbnails aren't artwork kinds
def test_twin_titles():
from frameport.core.titles import counterparts, display_title, twins
games = [{"package": "com.YC.RoboRecall", "title": "Robo Recall"},
{"package": "rift.robo_recall", "kind": "rift", "title": "Robo Recall", "quest_package": None},
{"package": "rift.lone_echo", "kind": "rift", "title": "Lone Echo"},
{"package": "com.x.vader", "title": "Vader Immortal: Episode I"},
{"package": "rift.vader1", "kind": "rift", "title": "Vader Immortal - Episode I",
"quest_package": "com.x.vader"}]
tw = twins(games)
assert tw == {"com.YC.RoboRecall", "rift.robo_recall", "com.x.vader", "rift.vader1"}
assert display_title(games[0], tw) == "Robo Recall (Quest)" and display_title(games[1], tw) == "Robo Recall (Rift)"
assert display_title(games[2], tw) == "Lone Echo"
assert [g["package"] for g in counterparts(games[3], games)] == ["rift.vader1"]
def test_steam_title_uses_twin_suffix():
from frameport import pipeline
library.upsert_game("com.YC.RoboRecall", title="Robo Recall")
library.upsert_game("rift.robo_recall", kind="rift", title="Robo Recall")
assert pipeline.steam_title(library.game("rift.robo_recall")) == "Robo Recall (Rift)"
# ------------------------------------------------------------------------------------------ uninstall
def test_uninstall_backs_up_keys_and_removes_data(tmp_path, monkeypatch):
from frameport import uninstall
from frameport.core.events import Reporter
from frameport.core.paths import user_data_dir
data = user_data_dir()
ks = data / "overport-workspace" / "signatures"
ks.mkdir(parents=True)
(ks / "com.x.keystore").write_bytes(b"key")
(data / "ssh").mkdir()
(data / "ssh" / "id_ed25519").write_text("secret")
library.upsert_game("com.x", title="X")
monkeypatch.setattr(uninstall, "wsl_revive_copy", lambda: None)
pl = uninstall.plan()
assert pl.keys and pl.items[0].path == str(data)
out = uninstall.run(Reporter(), backup_dir=tmp_path / "backup")
z = zipfile.ZipFile(out["backup"])
assert {"overport-workspace/signatures/com.x.keystore", "ssh/id_ed25519", "README.txt"} <= set(z.namelist())
assert not data.exists()
# ------------------------------------------------------------------------------------------ details / Steam art
def test_details_from_oculusdb_and_steam(net, monkeypatch):
from frameport import pipeline
from frameport.artwork import details
net["all"] = [{"_term": "com.x.game", "appName": "Game", "packageName": "com.x.game", "id": "9",
"display_long_description": "Fight <b>robots</b>.<br/>Save the day.", "genre_names": ["Action"],
"publisher_name": "Pub", "website_url": "https://game.example"}]
net["steam"] = [{"id": 5, "name": "Game"}]
steam = {"5": {"success": True, "data": {"steam_appid": 5, "short_description": "Robots!",
"genres": [{"description": "Action"}, {"description": "Indie"}],
"developers": ["Dev"], "publishers": ["Pub"],
"release_date": {"date": "Apr 20, 2017"},
"screenshots": [{"path_full": "https://img/ss1.jpg"},
{"path_full": "https://img/ss2.jpg"}]}}}
real = sources.cache.cached_json
monkeypatch.setattr(details.cache, "cached_json",
lambda name, url, **k: steam if "appdetails" in url else real(name, url, **k))
net["images"]["https://img/ss1.jpg"] = png(1920, 1080)
net["images"]["https://img/ss2.jpg"] = png(1920, 1080)
monkeypatch.setattr(details.cache, "http_get", sources.cache.http_get)
library.upsert_game("com.x.game", title="Game")
d = pipeline.fetch_details("com.x.game")
assert d["description"] == "Fight robots.\nSave the day." and d["genres"] == ["Action", "Indie"]
assert (d["developer"], d["publisher"], d["release_date"]) == ("Dev", "Pub", "Apr 20, 2017")
assert d["screenshots"] == ["shot_1.jpg", "shot_2.jpg"] and sorted(d["sources"]) == ["oculusdb", "steam"]
assert [x[0] for x in details.store_links(d)] == ["Meta store", "Steam store", "game.example"]
from frameport.ui.views.library import auto_tags
assert "Indie" in auto_tags(library.game("com.x.game"))
def test_steam_set_from_square_cover_and_screenshot():
from PIL import Image
from frameport.artwork import steam
d = fetch.artwork_dir("rift.sq")
(d / "square.webp").write_bytes(png(720, 720))
(d / "shot_1.jpg").write_bytes(png(1920, 1080))
art = steam.steam_set("rift.sq")
assert set(art) == {"portrait", "landscape", "hero", "icon"}
assert Image.open(art["portrait"]).size == (600, 900) and Image.open(art["hero"]).size == (1920, 620)
assert Image.open(art["landscape"]).size == (920, 430) and Image.open(art["icon"]).size == (256, 256)
assert steam.steam_set("rift.sq") == art # cached
def test_steam_tags():
from frameport.artwork.steam import steam_tags
rift = {"kind": "rift", "details": {"genres": ["Action", "Shooter"]}, "tags": ["Favorite"]}
assert steam_tags(rift) == ["PC VR on Frame", "Oculus Rift", "Action", "Shooter", "Favorite"]
assert steam_tags(rift, "pc")[:2] == ["Rift via Revive", "Oculus Rift"]
assert steam_tags({"package": "com.x"})[:2] == ["Quest on Frame", "Meta Quest"]
+31
View File
@@ -0,0 +1,31 @@
"""Picking artwork in the GUI (artwork.sources.apply_choice): never leaves a game without art, keeps screenshots."""
import pytest
from frameport.artwork import fetch, sources
JPEG = b"\xff\xd8\xff" + b"x" * 400
@pytest.fixture
def art(tmp_path, monkeypatch):
monkeypatch.setenv("FRAMEPORT_HOME", str(tmp_path))
d = fetch.artwork_dir("com.x")
for name in ("portrait.jpg", "t_portrait_400_aaaa.jpg", "shot_0.jpg", "t_shot_0_480_bbbb.jpg", "title.txt"):
(d / name).write_bytes(b"old")
return d
def test_failed_pick_keeps_the_current_art(art, monkeypatch):
monkeypatch.setattr(sources, "apply_oculusdb", lambda pkg, app: False) # dead store image
monkeypatch.setattr(fetch, "fetch", lambda pkg, **kw: (fetch.artwork_dir(pkg), None)) # no art for the package
assert sources.apply_choice("com.x", {"source": "Meta (Quest)", "package": "com.other", "app": {}}) is False
assert (art / "portrait.jpg").read_bytes() == b"old"
assert not any(p.name.startswith(".pick") for p in art.parent.iterdir())
def test_pick_replaces_art_but_keeps_screenshots_and_title(art, monkeypatch):
monkeypatch.setattr(sources, "apply_oculusdb", lambda pkg, app: (sources._save(pkg, "square", JPEG), True)[1])
assert sources.apply_choice("com.x", {"source": "Oculus Rift", "app": {"id": "1"}}) is True
names = {p.name for p in art.iterdir() if p.is_file()}
assert names == {"square.jpg", "shot_0.jpg", "t_shot_0_480_bbbb.jpg", "title.txt"}
assert not any(p.name.startswith(".pick") for p in art.parent.iterdir())
+35
View File
@@ -0,0 +1,35 @@
"""Frame.fast_link: bulk uploads go over a direct link (USB / the Frame's hotspot) only when it is reachable and
presents the same SSH host key as the paired connection."""
import socket
from types import SimpleNamespace
from frameport.frame import connection as conn
def fake_frame(host, key):
f = conn.Frame(conn.FrameTarget(host, "steamos", 22))
f.client = SimpleNamespace(get_transport=lambda: SimpleNamespace(get_remote_server_key=lambda: key))
return f
def test_fast_link_prefers_usb_then_hotspot_and_checks_host_key(monkeypatch):
main = fake_frame("192.168.1.5", "KEY")
monkeypatch.setattr(main, "addresses", lambda: [("lo", "127.0.0.1"), ("wlan0", "192.168.1.5"),
("wlanap", "10.35.78.1"), ("usb0", "10.86.200.233")])
keys = {"10.86.200.233": "OTHER", "10.35.78.1": "KEY"} # something else answers on the USB address
reachable = set(keys)
monkeypatch.setattr(socket, "create_connection",
lambda addr, timeout: SimpleNamespace(close=lambda: None) if addr[0] in reachable
else (_ for _ in ()).throw(OSError("unreachable")))
def connect(self, timeout=10):
self.client = SimpleNamespace(get_transport=lambda: SimpleNamespace(
get_remote_server_key=lambda: keys[self.target.host]), close=lambda: None)
return self
monkeypatch.setattr(conn.Frame, "connect", connect)
link, label = main.fast_link()
assert link.target.host == "10.35.78.1" and "hotspot" in label # the USB impostor was rejected
reachable.clear() # PC not on any direct link: the normal connection
link, label = main.fast_link()
assert link is main and label == ""
+24
View File
@@ -33,3 +33,27 @@ def test_every_catalog_game_found_and_detected(dumps):
if entry.status == "unsupported" and "32-bit" in entry.notes:
assert a.only_32bit, pkg
print(f"checked {len(matched)} catalog games")
def test_heuristics_reproduce_catalog(dumps):
"""Without the catalog, heuristics must reproduce the verified recipes (Phantom's use-the-no-ForceQuit-build is a
runtime finding the triage makes, not a static one)."""
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location("ev", Path(__file__).resolve().parents[1] / "scripts/eval_heuristics.py")
ev = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ev)
exact = total = 0
for g in dumps:
a = analyze(g.apk, data_bytes=g.data_bytes())
entry = catalog.lookup(a.package)
if not entry:
continue
from frameport.recommend import engine
want, got = ev.signature(engine.suggest(a)), ev.signature(engine.suggest(a, use_catalog=False))
want["status_unsupported"] = got["status_unsupported"] = False
total += 1
exact += want == got
assert exact >= total - 1, f"only {exact}/{total} catalog recipes reproduced by heuristics"
+73
View File
@@ -0,0 +1,73 @@
"""Heuristics for games without a catalog recipe (catalog switched off)."""
from frameport.recommend import engine
from test_patches import _analysis
def suggest(**kw):
kw.setdefault("package", "x.y.unknown")
a = _analysis(**kw)
return a, engine.suggest(a, use_catalog=False)
def test_mixed_reality_only_scene_game():
_, r = suggest(extra={"mr_only": True, "meta_permissions_used": ["com.oculus.permission.USE_SCENE"], "size": 1},
meta_permissions=["com.oculus.permission.USE_SCENE"])
assert "patch_force_passthrough" in r.patches
assert r.params("adapter.scene_emul") == {"value": 1}
assert "frame.meta_permissions" in r.patches
def test_mixed_reality_without_scene():
_, r = suggest(extra={"mr_only": True, "meta_permissions_used": ["com.oculus.permission.USE_ANCHOR_API"], "size": 1})
assert "patch_force_passthrough" in r.patches and "adapter.scene_emul" not in r.patches
def test_hand_tracking_only():
_, r = suggest(extra={"hand_tracking_only": True, "size": 1})
assert r.params("adapter.controller_fix") == {"value": 0}
def test_heavy_ovrplugin_game_disables_space_warp():
_, r = suggest(libs=["libOVRPlugin.so", "libunity.so"], extra={"size": 2**30, "data_bytes": 25 * 2**30})
assert "patch_disable_space_warp" in r.patches
_, r = suggest(libs=["libOVRPlugin.so", "libunity.so"], extra={"size": 2**30, "data_bytes": 2**30})
assert "patch_disable_space_warp" not in r.patches
def test_old_unreal_nodebug_and_alt_build():
_, r = suggest(engine="Unreal", xr="VrApi", libs=["libUE4.so", "libOVRPlugin.so", "libvrapi.so"],
extra={"unreal_version": "4.20", "size": 1})
assert "frame.nodebug" in r.patches and r.alt_patches == ["patch_remove_unreal_force_quit"]
_, r = suggest(engine="Unreal", libs=["libUE4.so"], extra={"unreal_version": "4.27", "size": 1})
assert "frame.nodebug" not in r.patches
def test_cryengine_disables_vrs():
_, r = suggest(engine="CryEngine", xr="VrApi", direct_vrapi=True, libs=["libCrySystem.so", "libCryRenderVulkan.so", "libvrapi.so"],
graphics="Vulkan (declared in manifest)")
assert r.params("device.files")["files"]["user.cfg"].startswith("r_variable_rate_shading = 0")
assert "frame.vrapi_bridge" in r.patches
def test_legacy_vrapi_unity_gles_msaa():
_, r = suggest(xr="VrApi", graphics="GLES or unknown", unity_msaa_levels=3)
assert "frame.unity_no_msaa" in r.patches
def test_irrelevant_patches_hidden_for_unity():
a, r = suggest(engine="Unity", libs=["libunity.so", "libOVRPlugin.so"])
shown, hidden = engine.visible_patches(a, r)
hidden_ids = {p.id for p in hidden}
assert {"patch_oculus_unreal", "patch_remove_unreal_force_quit", "frame.vrapi_bridge", "frame.gl_shim",
"frame.metaxr_telemetry"} <= hidden_ids
assert "patch_oculus_unity" not in hidden_ids and "frame.adapter" not in hidden_ids
# hidden overport defaults stay in the recipe (no-ops), so builds are unchanged
assert "patch_oculus_unreal" in r.patches
def test_enabled_patch_is_never_hidden():
a, r = suggest(engine="Unity", libs=["libunity.so"])
r.patches["frame.vrapi_bridge"] = {}
shown, _ = engine.visible_patches(a, r)
assert "frame.vrapi_bridge" in {p.id for p in shown}
+4 -1
View File
@@ -26,4 +26,7 @@ def test_settings_classification():
def test_report_counts():
text = render()
assert "23 work" in text and "Path of the Warrior" in text
from frameport.recommend import catalog
works = sum(1 for e in catalog.load().values() if e.status == "works")
assert f"{works} work" in text and "Path of the Warrior" in text
+259
View File
@@ -0,0 +1,259 @@
"""PC VR target (Revive + local Windows Steam) against a fake Steam folder; no Windows needed."""
import importlib.util
from pathlib import Path
from types import SimpleNamespace
from frameport.core import winhost
from frameport.core.events import Reporter
from frameport.core.models import Recipe
AGENT = Path(__file__).resolve().parents[1] / "agent" / "frameport_agent.py"
def vdf_mod():
spec = importlib.util.spec_from_file_location("agent_for_test", AGENT)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
def fake_steam(tmp_path):
root = tmp_path / "Steam"
(root / "userdata/12345/config").mkdir(parents=True)
(root / "userdata/999/config").mkdir(parents=True)
(root / "config").mkdir()
(root / "steam.exe").write_bytes(b"MZ")
(root / "config/loginusers.vdf").write_text(
'"users"\n{\n\t"76561197960278073"\n\t{\n\t\t"AccountName"\t\t"a"\n\t\t"MostRecent"\t\t"1"\n\t}\n'
'\t"76561197960266727"\n\t{\n\t\t"MostRecent"\t\t"0"\n\t}\n}\n')
return root
def test_steam_user_most_recent(tmp_path):
assert winhost.steam_user(fake_steam(tmp_path)) == "12345"
def test_path_translation():
if not winhost.is_wsl():
return
assert winhost.to_windows("/mnt/d/Games/X") == "D:\\Games\\X"
assert winhost.to_local("C:\\Program Files\\Revive") == Path("/mnt/c/Program Files/Revive")
def test_install_and_shortcut(tmp_path, monkeypatch):
from frameport.targets import pc_revive
from frameport.tools import revive
root = fake_steam(tmp_path)
rv = tmp_path / "revive"
rv.mkdir()
for n in revive.RUNTIME_FILES:
(rv / n).write_bytes(b"x")
monkeypatch.setenv("FRAMEPORT_REVIVE_DIR", str(rv))
events = []
monkeypatch.setattr(winhost, "available", lambda: True)
monkeypatch.setattr(winhost, "is_wsl", lambda: False) # never copy into the real %LOCALAPPDATA%
monkeypatch.setattr(winhost, "env_path", lambda name: None)
monkeypatch.setattr(winhost, "steam_root", lambda: root)
monkeypatch.setattr(winhost, "stop_steam", lambda r: events.append("stop") or True)
monkeypatch.setattr(winhost, "start_steam", lambda r: events.append("start"))
monkeypatch.setattr(winhost, "to_windows", lambda p: "W:" + str(p).replace("/", "\\"))
monkeypatch.setattr("frameport.artwork.fetch.cache.http_get", lambda *a, **k: (_ for _ in ()).throw(OSError()))
game = tmp_path / "Games" / "Space Game"
game.mkdir(parents=True)
(game / "Space Game.exe").write_bytes(b"MZ")
t = pc_revive.PcReviveTarget()
r = Recipe("rift.space_game", {"pcvr.revive": {}})
res = t.install_pcvr("rift.space_game", "Space Game", game, "Space Game.exe", r, Reporter())
assert res["ok"] and [d["package"] for d in t.installed()] == ["rift.space_game"]
t.add_to_library(["rift.space_game"], Reporter())
assert events == ["stop", "start"]
sc = vdf_mod().vdf_decode((root / "userdata/12345/config/shortcuts.vdf").read_bytes())["shortcuts"]["0"]
exe_win = "W:" + str(game / "Space Game.exe").replace("/", "\\")
assert sc["Exe"] == '"' + "W:" + str(rv).replace("/", "\\") + '\\ReviveInjector.exe"'
assert sc["LaunchOptions"] == f'/openxr "{exe_win}"'
assert sc["tags"] == {"0": "Rift via Revive", "1": "Oculus Rift"} and sc["appid"] == res["appid"]
assert sc["StartDir"].endswith('Space Game\\"')
# OpenVR backend drops /openxr
r2 = Recipe("rift.space_game", {"pcvr.revive": {}, "pcvr.revive_openvr": {}})
t.install_pcvr("rift.space_game", "Space Game", game, "Space Game.exe", r2, Reporter())
assert pc_revive.shortcut_fields(t._dep("rift.space_game"))[2] == f'"{exe_win}"'
assert t.uninstall("rift.space_game")["removed"] and t.installed() == []
def test_openxr_native_runs_exe_directly():
from frameport.targets.pc_revive import shortcut_fields
exe, start, opts = shortcut_fields({"exe_win": "D:\\G\\Game.exe", "revive_win": None})
assert (exe, start, opts) == ('"D:\\G\\Game.exe"', '"D:\\G\\"', "")
def test_no_crash_reporter_patch(tmp_path):
from types import SimpleNamespace
from frameport.core.models import Recipe
from frameport.patches import pcvr
from frameport.patches.base import get
p = get("pcvr.no_crash_reporter")
ue = SimpleNamespace(engine="Unreal", extra={"kind": "rift"})
unity = SimpleNamespace(engine="Unity", extra={"kind": "rift"})
assert p.detect(ue).recommended and p.applies(ue)
assert p.detect(unity) is None and not p.applies(unity)
assert pcvr.game_args(Recipe(package="rift.x", patches=["pcvr.no_crash_reporter"])) == ["-nocrashreports"]
assert pcvr.game_args(Recipe(package="rift.x", patches=[])) == []
def test_agent_crash_reporter_toggle(tmp_path):
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location("fpa", Path(__file__).parents[1] / "agent" / "frameport_agent.py")
agent = importlib.util.module_from_spec(spec)
spec.loader.exec_module(agent)
exe = tmp_path / "game" / "Engine" / "Binaries" / "Win64" / "CrashReportClient.exe"
exe.parent.mkdir(parents=True)
exe.write_bytes(b"MZ")
agent.set_crash_reporter(str(tmp_path), enabled=False)
assert not exe.exists() and exe.with_name("CrashReportClient.exe.disabled").exists()
# still counts as uploaded (not re-sent on the next install)
assert "Engine/Binaries/Win64/CrashReportClient.exe" in agent.tree_manifest(str(tmp_path / "game"))
agent.set_crash_reporter(str(tmp_path), enabled=True)
assert exe.exists()
def test_library_migration_adds_no_crash_reporter(tmp_path, monkeypatch):
import json
from frameport.core import library
monkeypatch.setattr(library, "_path", lambda: tmp_path / "library.json")
(tmp_path / "library.json").write_text(json.dumps({"games": {
"rift.ue": {"analysis": {"engine": "Unreal", "extra": {"kind": "rift"}}, "recipe": {"patches": {}}},
"rift.unity": {"analysis": {"engine": "Unity", "extra": {"kind": "rift"}}, "recipe": {"patches": {}}},
}, "settings": {}}))
games = library.load()["games"]
# the run-correct migration re-derives from engine defaults: Oculus games get Revive + as-is
assert set(games["rift.ue"]["recipe"]["patches"]) == {"pcvr.revive", "pcvr.revive_openvr",
"pcvr.no_crash_reporter", "pcvr.oculus_unreal", "pcvr.xr_timefix"}
assert set(games["rift.unity"]["recipe"]["patches"]) == {"pcvr.revive", "pcvr.revive_openvr", "pcvr.xr_timefix"}
assert games["rift.ue"]["recipe"]["as_is"] and games["rift.unity"]["recipe"]["as_is"]
# runs once: a user who turns it off keeps it off
data = library.load()
data["games"]["rift.ue"]["recipe"]["patches"].pop("pcvr.no_crash_reporter")
library.save(data)
assert "pcvr.no_crash_reporter" not in library.load()["games"]["rift.ue"]["recipe"]["patches"]
def test_oculus_unreal_patch():
from types import SimpleNamespace
from frameport.patches.base import get
p = get("pcvr.oculus_unreal")
ue = SimpleNamespace(engine="Unreal", extra={"kind": "rift"})
ue_xr = SimpleNamespace(engine="Unreal", extra={"kind": "rift", "frame_native": True})
unity = SimpleNamespace(engine="Unity", extra={"kind": "rift"})
quest_ue = SimpleNamespace(engine="Unreal", extra={})
assert p.category == "pcvr" and p.detect(ue).recommended and p.applies(ue)
assert not p.detect(ue_xr).recommended and p.applies(ue_xr)
assert p.detect(unity) is None and not p.applies(unity)
assert p.detect(quest_ue) is None and not p.applies(quest_ue)
def test_migration_rederives_rift_recipes(tmp_path, monkeypatch):
import json
"""The run-correct migration re-derives every Rift recipe from engine defaults: Oculus games get Revive for VR."""
from frameport.core import library
monkeypatch.setattr(library, "_path", lambda: tmp_path / "library.json")
(tmp_path / "library.json").write_text(json.dumps({"games": {
"rift.ue": {"analysis": {"engine": "Unreal", "abis": ["x86_64"], "extra": {"kind": "rift", "needs_revive": True}},
"recipe": {"as_is": True, "patches": {}}}, # Revive wrongly dropped -> restored
"com.q.ue": {"analysis": {"engine": "Unreal", "extra": {}}, "recipe": {"patches": {}}},
}, "settings": {}}))
games = library.load()["games"]
r = games["rift.ue"]["recipe"]
assert r["as_is"] and "pcvr.revive" in r["patches"] and "pcvr.oculus_unreal" in r["patches"]
assert games["com.q.ue"]["recipe"]["patches"] == {} # Quest games untouched
def test_oculus_hmd_helper_artifact():
"""The prebuilt helper is a freestanding Windows x64 console exe that only imports kernel32."""
import struct
from frameport.core.paths import artifacts_dir
data = (artifacts_dir() / "win-x64" / "fp_oculushmd.exe").read_bytes()
assert data[:2] == b"MZ"
pe = struct.unpack_from("<I", data, 0x3C)[0]
assert data[pe:pe + 4] == b"PE\0\0"
assert struct.unpack_from("<H", data, pe + 4)[0] == 0x8664 # AMD64
assert struct.unpack_from("<H", data, pe + 24)[0] == 0x20B # PE32+
assert struct.unpack_from("<H", data, pe + 24 + 68)[0] == 3 # console subsystem (stderr -> launch.log)
assert b"kernel32.dll" in data and b"CreateEventW" in data
assert "OculusHMDConnected".encode("utf-16-le") in data
assert not any(dll in data.lower() for dll in (b"msvcrt", b"vcruntime", b"ucrtbase"))
def test_oculus_hmd_triage():
from frameport.validate.triage import triage
ok = triage("FramePort: launching rift.x with proton_11-arm64\n"
"FramePort oculushmd: OculusHMDConnected event ready\n", package="rift.x")
assert "Oculus headset event provided" in ok.milestones and not ok.findings
bad = triage("FramePort oculushmd: could not create the OculusHMDConnected event (error 5)\n", package="rift.x")
assert [f.id for f in bad.findings] == ["oculus-hmd-event"]
assert "pcvr.oculus_unreal" in bad.findings[0].suggest
def test_migration_respects_catalog_openxr_native(tmp_path, monkeypatch):
import json
"""A catalog SteamVR/OpenXR-native recipe (Revive removed) keeps Revive off after the re-derive."""
from frameport.core import library
monkeypatch.setattr(library, "_path", lambda: tmp_path / "library.json")
(tmp_path / "library.json").write_text(json.dumps({"games": {
# openxr_native -> engine.suggest doesn't add Revive
"rift.xr": {"analysis": {"engine": "Unity", "abis": ["x86_64"],
"extra": {"kind": "rift", "openxr_native": True}},
"recipe": {"as_is": True, "patches": {"pcvr.revive": {}}}},
}, "settings": {}}))
r = library.load()["games"]["rift.xr"]["recipe"]
assert "pcvr.revive" not in r["patches"]
def test_pc_defaults_to_openvr_backend():
from frameport.core.models import Recipe
from frameport.patches import base
from frameport.targets.pc_revive import shortcut_fields
p = base.get("pcvr.revive_openvr")
assert p.detect(SimpleNamespace(extra={"kind": "rift"})).recommended # on by default (PC)
assert not p.detect(SimpleNamespace(extra={"kind": "rift", "frame_native": True})).recommended
# OpenVR backend -> no /openxr flag in the launch command
dep_ovr = {"exe_win": "C:\\g\\Game.exe", "revive_win": "C:\\Revive", "backend": "openvr", "game_args": []}
dep_xr = {**dep_ovr, "backend": "openxr"}
assert "/openxr" not in shortcut_fields(dep_ovr)[2] and "Game.exe" in shortcut_fields(dep_ovr)[2]
assert "/openxr" in shortcut_fields(dep_xr)[2]
def test_start_steamvr(monkeypatch, tmp_path):
from frameport.core import winhost
running = {"vr": False}
monkeypatch.setattr(winhost, "process_running", lambda img: running["vr"] if "vr" in img.lower() else False)
monkeypatch.setattr(winhost, "app_installed", lambda root, appid: True)
started = []
monkeypatch.setattr(winhost, "start_detached", lambda exe, args=(), cwd=None: (started.append(list(args)),
running.__setitem__("vr", True))[0])
assert winhost.start_steamvr(tmp_path, wait=5) is True and started == [["-applaunch", "250820"]]
# already running -> no-op
started.clear()
assert winhost.start_steamvr(tmp_path) is True and started == []
# not installed -> False, nothing started
running["vr"] = False
monkeypatch.setattr(winhost, "app_installed", lambda root, appid: False)
assert winhost.start_steamvr(tmp_path, wait=2) is False and started == []
+531
View File
@@ -0,0 +1,531 @@
"""Oculus Rift (PC VR) support: PE detection, scanning, recipes, Revive unpacking, triage."""
import struct
import zlib
from pathlib import Path
import pytest
from frameport.analysis import rift
from frameport.core import library
from frameport.recommend import engine
from frameport.sources import rift_dump
def make_pe(machine=0x8664, imports=(), extra=b"", subsystem=2, delay=()) -> bytes:
"""A minimal PE (one section holding the import directory + name strings) — enough for rift.read_pe."""
pe32plus = machine == 0x8664
opt_size = 240 if pe32plus else 224
sect_rva, sect_raw = 0x1000, 0x400
body = bytearray()
desc_size = 20 * (len(imports) + 1)
names = bytearray()
descs = bytearray()
for name in imports:
descs += struct.pack("<IIIII", 0, 0, 0, sect_rva + desc_size + len(names), 0)
names += name.encode() + b"\0"
descs += b"\0" * 20
delay_off = desc_size + len(names) + sum(len(n) + 1 for n in delay)
delay_descs = bytearray()
for name in delay: # ImgDelayDescr: attributes, then the DLL name RVA
delay_descs += struct.pack("<II", 1, sect_rva + desc_size + len(names)) + b"\0" * 24
names += name.encode() + b"\0"
if delay:
delay_descs += b"\0" * 32
body += descs + names + delay_descs + extra
hdr = bytearray(b"MZ" + b"\0" * 0x3A + struct.pack("<I", 0x80))
hdr += b"\0" * (0x80 - len(hdr))
hdr += b"PE\0\0" + struct.pack("<HHIIIHH", machine, 1, 0, 0, 0, opt_size, 0x22)
opt = bytearray(opt_size)
struct.pack_into("<H", opt, 0, 0x20B if pe32plus else 0x10B)
struct.pack_into("<H", opt, 68, subsystem) # 2 = Windows GUI (games), 3 = console
dd = 112 if pe32plus else 96
struct.pack_into("<II", opt, dd + 8, sect_rva if imports else 0, desc_size)
if delay:
struct.pack_into("<II", opt, dd + 13 * 8, sect_rva + delay_off, len(delay_descs))
hdr += opt
hdr += b".idata\0\0" + struct.pack("<IIII", len(body), sect_rva, len(body), sect_raw) + b"\0" * 16
hdr += b"\0" * (sect_raw - len(hdr))
return bytes(hdr + body)
def unity_game(root: Path, name="Space Game", imports=("d3d11.dll", "kernel32.dll"), platform=False, extra=b"") -> Path:
g = root / name
(g / f"{name}_Data" / "Plugins").mkdir(parents=True)
(g / f"{name}.exe").write_bytes(make_pe(imports=("kernel32.dll",)))
(g / "UnityPlayer.dll").write_bytes(make_pe(imports=imports, extra=extra))
(g / "UnityCrashHandler64.exe").write_bytes(make_pe())
(g / f"{name}_Data" / "Plugins" / "OVRPlugin.dll").write_bytes(make_pe(extra=b"ovr_Initialize"))
if platform:
(g / f"{name}_Data" / "Plugins" / "LibOVRPlatform64_1.dll").write_bytes(make_pe())
return g
def test_read_pe_machine_and_imports(tmp_path):
p = tmp_path / "a.exe"
p.write_bytes(make_pe(0x14C, ("d3d11.dll", "LibOVRPlatform32_1.dll")))
info, _ = rift.read_pe(p)
assert info.machine == "x86"
assert info.imports == ["d3d11.dll", "libovrplatform32_1.dll"]
def test_unity_rift_game(tmp_path):
g = unity_game(tmp_path)
a = rift.analyze(g)
assert a.package == "rift.space_game"
assert (a.engine, a.xr, a.graphics, a.abis) == ("Unity", "LibOVR", "D3D11", ["x86_64"])
assert a.extra["exe"] == "Space Game.exe" and a.extra["kind"] == "rift"
assert not a.extra["platform_sdk"] and a.extra["needs_revive"]
def test_platform_sdk_and_openxr_native(tmp_path):
a = rift.analyze(unity_game(tmp_path, "Owned", platform=True))
assert a.extra["platform_sdk"]
g = tmp_path / "XR"
g.mkdir()
(g / "XR.exe").write_bytes(make_pe(imports=("openxr_loader.dll", "d3d12.dll")))
b = rift.analyze(g)
assert b.xr == "OpenXR" and b.graphics == "D3D12" and b.extra["openxr_native"]
def test_unreal_layout_picks_shipping_exe(tmp_path):
g = tmp_path / "Climb Game"
(g / "Climb" / "Binaries" / "Win64").mkdir(parents=True)
(g / "Engine").mkdir()
(g / "Climb.exe").write_bytes(make_pe())
(g / "Climb" / "Binaries" / "Win64" / "Climb-Win64-Shipping.exe").write_bytes(
make_pe(imports=("d3d11.dll",), extra=b"LibOVRRT%hs_%d.dll" + b"\0" * 4096))
a = rift.analyze(g)
assert a.engine == "Unreal" and a.xr == "LibOVR"
assert a.extra["exe"] == "Climb/Binaries/Win64/Climb-Win64-Shipping.exe"
r = engine.suggest(a) # Oculus/LibOVR Unreal: Revive on (PC OpenVR backend), files unchanged, crash reporter off
assert r.as_is and set(r.patches) == {"pcvr.revive", "pcvr.revive_openvr", "pcvr.no_crash_reporter",
"pcvr.oculus_unreal", "pcvr.xr_timefix"}
def test_scan_finds_rift_games_not_parents_or_quest(tmp_path):
unity_game(tmp_path / "dumps")
q = tmp_path / "dumps" / "Quest Game"
q.mkdir()
(q / "game.apk").write_bytes(b"PK")
(q / "setup.exe").write_bytes(make_pe())
found = rift_dump.scan(tmp_path / "dumps")
assert [p.name for p in found] == ["Space Game"]
assert [p.name for p in rift_dump.scan(tmp_path)] == ["Space Game"] # dumps/Space Game: 2 levels down
assert rift_dump.scan(tmp_path, depth=1) == []
def test_recipe_only_pcvr_patches(tmp_path):
a = rift.analyze(unity_game(tmp_path))
r = engine.suggest(a)
assert r.as_is and "pcvr.revive" in r.patches # Oculus game: Revive gives it VR
assert all(pid.startswith("pcvr.") for pid in r.patches), r.patches
shown, hidden = engine.visible_patches(a, r)
assert shown and all(p.category == "pcvr" for p in shown + hidden)
assert engine.warnings(r) == []
def test_quest_games_never_see_pcvr_patches(quest_manifest, tmp_path):
import zipfile
from frameport.analysis.detect import analyze
apk = tmp_path / "q.apk"
with zipfile.ZipFile(apk, "w") as z:
z.writestr("AndroidManifest.xml", quest_manifest)
z.writestr("lib/arm64-v8a/libunity.so", b"\x7fELF")
a = analyze(apk, deep=False)
r = engine.suggest(a)
shown, hidden = engine.visible_patches(a, r)
assert not any(p.category == "pcvr" for p in shown + hidden)
assert not any(pid.startswith("pcvr.") for pid in r.patches)
def test_platform_sdk_note(tmp_path):
r = engine.suggest(rift.analyze(unity_game(tmp_path, "Owned", platform=True)))
assert "Oculus app" in r.notes
def test_pipeline_add_path_and_prepare(tmp_path, monkeypatch):
from frameport import pipeline
from frameport.core.events import Reporter
unity_game(tmp_path / "dumps")
added = pipeline.add_path(tmp_path / "dumps")
assert [g["package"] for g in added] == ["rift.space_game"]
g = library.game("rift.space_game")
assert g["kind"] == "rift" and g["exe"] == "Space Game.exe"
monkeypatch.setenv("FRAMEPORT_REVIVE_DIR", str(fake_revive(tmp_path / "revive")))
monkeypatch.setattr("frameport.artwork.fetch.cache.http_get", lambda *a, **k: (_ for _ in ()).throw(OSError()))
info = pipeline.build_game("rift.space_game", Reporter())
assert info["ok"] and info["sha256"] and info["kind"] == "rift"
def test_launch_env():
from frameport.core.models import Recipe
from frameport.patches.pcvr import launch_env
r = Recipe("rift.x", {"pcvr.proton_log": {}, "pcvr.proton_env": {"env": "DXVK_HUD=fps\n\nA = b"}})
assert launch_env(r) == {"PROTON_LOG": "1", "DXVK_HUD": "fps", "A": "b"}
# ------------------------------------------------------------------------------------------ Revive / NSIS
def fake_revive(d: Path) -> Path:
from frameport.tools import revive
d.mkdir(parents=True, exist_ok=True)
for n in revive.RUNTIME_FILES:
(d / n).write_bytes(b"x")
return d
def make_nsis(files: dict[str, bytes], outdir_var=21) -> bytes:
"""A tiny Unicode NSIS 3 installer: SetOutPath $INSTDIR[\\sub] + File entries, non-solid deflate."""
strings = bytearray(b"\0\0")
def add(s, var=None):
off = len(strings) // 2
if var is not None:
strings.extend(struct.pack("<HH", 3, (var & 0x7F) | ((var & 0x3F80) << 1) | 0x8080))
strings.extend(s.encode("utf-16-le") + b"\0\0")
return off
data = bytearray()
entries = []
def block(raw):
comp = zlib.compressobj(9, zlib.DEFLATED, -15)
c = comp.compress(raw) + comp.flush()
off = len(data)
data.extend(struct.pack("<I", len(c) | 0x80000000) + c)
return off
dirs = {}
for path, content in files.items():
folder, _, name = path.rpartition("/")
if folder not in dirs:
dirs[folder] = add("\\" + folder.replace("/", "\\") if folder else "", outdir_var)
entries.append((11, dirs[folder], 0, 0, 0, 0, 0))
entries.append((20, 0, add(name), block(content), 0, 0, 0))
entries.append((11, add("", 26), 0, 0, 0, 0, 0)) # $PLUGINSDIR: ignored
entries.append((20, 0, add("plugin.dll"), block(b"p"), 0, 0, 0))
ent = b"".join(struct.pack("<7I", *e) for e in entries)
head_len = 4 + 8 * 8
ent_off, str_off = head_len, head_len + len(ent)
lang_off = str_off + len(strings)
header = struct.pack("<I", 0) + struct.pack("<16I", 0, 0, 0, 0, ent_off, len(entries), str_off, 0, lang_off, 0,
lang_off, 0, 0, 0, 0, 0) + ent + bytes(strings)
comp = zlib.compressobj(9, zlib.DEFLATED, -15)
ch = comp.compress(header) + comp.flush()
body = struct.pack("<I", len(ch) | 0x80000000) + ch + data
first = struct.pack("<I", 0) + b"\xef\xbe\xad\xdeNullsoftInst" + struct.pack("<II", len(header), len(body) + 28)
return b"MZ" + b"\0" * 510 + first + body
def test_nsis_extract(tmp_path):
from frameport.tools import revive
files = {n: n.encode() for n in revive.RUNTIME_FILES}
files["Input/oculus_touch_default.json"] = b"{}"
files["Qt5Core.dll"] = b"qt"
inst = tmp_path / "ReviveInstaller.exe"
inst.write_bytes(make_nsis(files))
written = revive.extract(inst, tmp_path / "out")
assert set(written) == set(revive.RUNTIME_FILES) | {"Input/oculus_touch_default.json"}
assert (tmp_path / "out" / "ReviveInjector.exe").read_bytes() == b"ReviveInjector.exe"
assert not (tmp_path / "out" / "plugin.dll").exists()
assert [p.name for p in revive.runtime_files(tmp_path / "out")][:1] == ["oculus_touch_default.json"]
def test_nsis_missing_runtime_files(tmp_path):
from frameport.tools import revive
inst = tmp_path / "i.exe"
inst.write_bytes(make_nsis({"LICENSE": b"l"}))
with pytest.raises(revive.NsisError):
revive.extract(inst, tmp_path / "out")
@pytest.mark.skipif(not Path(__file__).with_name("fixtures").joinpath("ReviveInstaller.exe").exists(),
reason="real installer not present")
def test_real_revive_installer(tmp_path): # pragma: no cover - optional
from frameport.tools import revive
written = revive.extract(Path(__file__).with_name("fixtures") / "ReviveInstaller.exe", tmp_path)
assert "LibReviveXR64.dll" in written
# ------------------------------------------------------------------------------------------ triage
def test_pcvr_triage_uses_pcvr_signatures_only():
from frameport.validate.triage import triage
log = ("FramePort: launching rift.x with proton_11-arm64\nLaunched injector with: x\nSuccesfully injected!\n"
"ovrPlatformInitialize_NotEntitled\n")
r = triage(log, "EXITED", "rift.x")
assert r.milestone == "Revive injected into the game"
assert [f.id for f in r.findings] == ["oculus-entitlement"]
q = triage("Failed to create process\nFrameBridge: scale=1", "RUNNING", "com.x.y")
assert "revive-inject-failed" not in [f.id for f in q.findings]
def test_system_revive_preferred(tmp_path, monkeypatch):
"""A Revive the user installed (official installer) wins over FramePort's copy and is never replaced."""
from frameport.core import winhost
from frameport.tools import revive, toolchain
managed = fake_revive(tmp_path / "managed")
revive._state_file().write_text('{"version": "3.2.0", "path": "%s"}' % managed.as_posix())
system = fake_revive(tmp_path / "Program Files" / "Revive")
monkeypatch.delenv("FRAMEPORT_REVIVE_DIR", raising=False)
monkeypatch.setattr(winhost, "available", lambda: True)
monkeypatch.setattr(winhost, "is_windows", lambda: False)
monkeypatch.setattr(winhost, "to_local", lambda p: system if "Program Files" in p else tmp_path / "nope")
monkeypatch.setattr(winhost, "reg_query", lambda key, value: None)
monkeypatch.setattr(revive, "_system_cache", None)
monkeypatch.setattr(revive, "release_for", lambda p: "3.2.0")
assert revive.source() == "system" and revive.revive_dir() == system
assert revive.install() == system # no download when the user has Revive
assert revive.installed_version() == "3.2.0"
assert toolchain.revive_is_users()
# not in the default folder: found through the registry (HKLM\Software\Revive default value)
other = fake_revive(tmp_path / "D" / "Tools" / "Revive")
monkeypatch.setattr(winhost, "to_local", lambda p: other if p.startswith("D:") else tmp_path / "nope")
monkeypatch.setattr(winhost, "reg_query", lambda key, value: "D:\\Tools\\Revive" if key.startswith("HKLM") else None)
monkeypatch.setattr(revive, "_system_cache", None)
assert revive.revive_dir() == other
# nothing installed on the system: FramePort's copy
monkeypatch.setattr(winhost, "reg_query", lambda key, value: None)
monkeypatch.setattr(revive, "_system_cache", None)
assert revive.source() == "managed" and revive.revive_dir() == managed
def test_release_for_matches_build_date(tmp_path, monkeypatch):
import os
from frameport.tools import revive
d = fake_revive(tmp_path / "rv")
os.utime(d / "LibReviveXR64.dll", (1679184000, 1679184000)) # 2023-03-19
rels = [{"tag_name": "3.2.0", "published_at": "2023-03-20T01:56:30Z"},
{"tag_name": "3.1.2", "published_at": "2022-10-01T00:00:00Z"},
{"tag_name": "3.3.0", "published_at": "2024-01-01T00:00:00Z"}]
monkeypatch.setattr(revive.cache, "cached_json", lambda *a, **k: rels)
assert revive.release_for(d) == "3.2.0"
# ------------------------------------------------------------------------------------------ nested collections
def armgddn_tree(root: Path) -> Path:
"""A collection like ARMGDDN_PCVR_OCULUS: one folder per game, the game nested inside, installers around it."""
col = root / "PCVR"
# Unity game one level down, with an uninstaller and a top-level Setup.exe next to archive parts
a = col / "Space Game v1.6.0 -ARMGDDN"
(a / "Space Game" / "Space Game_Data").mkdir(parents=True)
(a / "Setup.exe").write_bytes(make_pe())
(a / "Space Game v1.6.0 -ARMGDDN.7z.001").write_bytes(b"7z")
(a / "Space Game" / "Space Game.exe").write_bytes(make_pe(extra=b"ovr_Initialize"))
(a / "Space Game" / "UnityPlayer.dll").write_bytes(make_pe(imports=("d3d11.dll",)))
(a / "Space Game" / "_UnInstall").mkdir()
(a / "Space Game" / "_UnInstall" / "unins000.exe").write_bytes(make_pe())
# Unreal: bootstrap exe + the shipping build that really runs (UTF-16 marker, as Unreal stores strings)
b = col / "Wrath Game v008 [FFA Repacks]" / "Wrath" / "WindowsNoEditor"
(b / "WrathGame" / "Binaries" / "Win64").mkdir(parents=True)
(b / "WrathGame.exe").write_bytes(make_pe())
(b / "WrathGame" / "Binaries" / "Win64" / "WrathGame-Win64-Shipping.exe").write_bytes(
make_pe(imports=("d3d11.dll",), extra="LibOVRRT%hs_%d.dll".encode("utf-16-le")))
(b.parent / "Engine" / "Binaries" / "Win64").mkdir(parents=True)
(b.parent / "Engine" / "Binaries" / "Win64" / "CrashReportClient.exe").write_bytes(make_pe())
# two builds of the same game: ambiguous
c = col / "The Climber v1.5.0.16 -ARMGDDN" / "The Climber" / "bin"
for build in ("win_x64", "win_x64-steam"):
(c / build).mkdir(parents=True)
(c / build / "Climber.exe").write_bytes(make_pe(extra=b"LibOVRRT64_1.dll"))
# not games: a console tool and a Quest dump
tool = col / "Some Tool"
tool.mkdir()
(tool / "tool.exe").write_bytes(make_pe(subsystem=3))
q = col / "Quest Game -VRP"
q.mkdir()
(q / "game.apk").write_bytes(b"PK")
return col
def test_scan_collection_finds_nested_games(tmp_path):
col = armgddn_tree(tmp_path)
games = rift_dump.scan(col)
assert sorted(p.name for p in games) == ["Space Game v1.6.0 -ARMGDDN", "The Climber v1.5.0.16 -ARMGDDN",
"Wrath Game v008 [FFA Repacks]"]
assert [p.name for p in rift_dump.scan(tmp_path)] == [p.name for p in games] # a level above works too
def test_ranking_and_ambiguity(tmp_path):
col = armgddn_tree(tmp_path)
a = rift.analyze(col / "Space Game v1.6.0 -ARMGDDN")
assert a.extra["exe"] == "Space Game/Space Game.exe" and a.extra["exe_confirmed"] and a.label == "Space Game"
w = rift.analyze(col / "Wrath Game v008 [FFA Repacks]")
assert w.extra["exe"].endswith("WrathGame-Win64-Shipping.exe") and w.xr == "LibOVR" and w.engine == "Unreal"
assert "Unreal launcher (starts the real game build)" in w.extra["exe_candidates"][1]["reasons"]
c = rift.analyze(col / "The Climber v1.5.0.16 -ARMGDDN")
assert c.extra["exe_confirmed"] is False and c.extra["exe"] == "The Climber/bin/win_x64/Climber.exe"
assert "Steam build" in c.extra["exe_candidates"][1]["reasons"]
assert c.label == "The Climber" and c.package == "rift.the_climber"
@pytest.mark.parametrize("name,title", [
("Asgards Wrath v1.6.0 -ARMGDDN", "Asgards Wrath"),
("Arktika 1 (v1.0.0.7) -VRP", "Arktika 1"),
("Vader Immortal - Episode II v2.0.2+236948 Shipping", "Vader Immortal - Episode II"),
("Vader Immortal - Episode I v1.1.0+236956 Shipping -[VRP Repacks]", "Vader Immortal - Episode I"),
("Vader Immortal - Episode III v3.0.2+236944 [FFA Repacks]", "Vader Immortal - Episode III"),
("Lies Beneath 4.15.20 -ARMGDDN", "Lies Beneath"),
("Defector v21.11.08.358012 -ARMGDDN", "Defector"),
("Lone Echo 2", "Lone Echo 2"),
("Rick and Morty - Virtual Rick-ality v2288226 -ARMGDDN", "Rick and Morty - Virtual Rick-ality"),
])
def test_clean_title(name, title):
assert rift.clean_title(name) == title
def test_set_exe_and_rescan_keep_choice(tmp_path):
from frameport import pipeline
col = armgddn_tree(tmp_path)
added = {g["title"]: g for g in pipeline.add_path(col)}
assert set(added) == {"Space Game", "Wrath Game", "The Climber"} # the fake Quest dump isn't a real APK
climber = added["The Climber"]
assert climber["exe_confirmed"] is False
pipeline.set_exe(climber["package"], "The Climber/bin/win_x64-steam/Climber.exe")
g = library.game(climber["package"])
assert g["exe_confirmed"] is True and g["exe"].endswith("win_x64-steam/Climber.exe")
library.upsert_game(g["package"], tags=["Mine"])
pipeline.add_path(col) # rescan: the choice and the tags stay
g = library.game(climber["package"])
assert g["exe"].endswith("win_x64-steam/Climber.exe") and g["tags"] == ["Mine"]
def test_unchanged_folder_not_reanalyzed(tmp_path, monkeypatch):
from frameport import pipeline
col = armgddn_tree(tmp_path)
pipeline.add_path(col)
calls = []
real = rift.analyze
monkeypatch.setattr(rift, "analyze", lambda *a, **k: calls.append(a) or real(*a, **k))
pipeline.add_path(col)
assert [c for c in calls if "Space Game" in str(c[0])] == [] # skipped via the fingerprint
def test_bundled_revive_dll_still_uses_framework_revive(tmp_path):
"""Repacks carry a LibRevive64.dll for their own launcher; it isn't loaded by itself, so Revive stays on."""
g = unity_game(tmp_path, "Repacked Game")
(g / "LibRevive64.dll").write_bytes(make_pe())
a = rift.analyze(g)
assert a.extra["revive_bundled"]
r = engine.suggest(a)
assert r.as_is and "pcvr.revive" in r.patches # don't reuse the repack's DLL; Revive provides the runtime
def test_quest_as_is_skips_patching(quest_manifest, tmp_path, monkeypatch):
import zipfile
from frameport import pipeline
from frameport.core.events import Reporter
apk = tmp_path / "patched.apk"
with zipfile.ZipFile(apk, "w") as z:
z.writestr("AndroidManifest.xml", quest_manifest)
z.writestr("lib/arm64-v8a/libframe_settings.so", b"\x7fELF")
monkeypatch.setattr("frameport.artwork.fetch.cache.http_get", lambda *a, **k: (_ for _ in ()).throw(OSError()))
from frameport.analysis.detect import analyze
a = analyze(apk, deep=False)
assert a.extra["frame_patched"] and engine.suggest(a).as_is
library.upsert_game(a.package, apk=str(apk), analysis=a.to_dict(),
recipe=library.recipe_to_dict(engine.suggest(a)))
called = []
monkeypatch.setattr(pipeline.builder, "build", lambda *x, **k: called.append(1))
info = pipeline.build_game(a.package, Reporter())
assert info["as_is"] and info["apk"] == str(apk) and not called # the original APK, overport never runs
def test_same_game_scanned_from_wrapper_or_inner_folder(tmp_path):
from frameport import pipeline
col = armgddn_tree(tmp_path)
pipeline.add_path(col)
pkg = "rift.space_game"
library.upsert_game(pkg, title="Space Game: Remastered", art_source="oculusdb")
pipeline.add_path(col / "Space Game v1.6.0 -ARMGDDN") # scanning the game's own folder finds the inner one
games = [g for g in library.games() if g["package"].startswith("rift.space")]
assert len(games) == 1 and games[0]["title"] == "Space Game: Remastered"
def test_upload_files_big_small_and_cancel(tmp_path):
from frameport.core.events import Cancelled, Reporter
from frameport.install import installer
class FakeFrame:
def __init__(self):
self.puts, self.tars, self.dirs = [], [], []
def mkdirs(self, dirs):
self.dirs += list(dirs)
def put(self, local, remote, progress=None, resume=True, mkdir=True):
progress(Path(local).stat().st_size // 2, Path(local).stat().st_size)
self.puts.append(remote)
def put_tar(self, files, root, progress=None):
for local, rel in files:
progress(0, rel)
self.tars.append([rel for _, rel in files])
big = tmp_path / "big.bin"
big.write_bytes(b"x" * (installer.BIG_FILE + 1))
smalls = []
for i in range(3):
p = tmp_path / f"s{i}.txt"
p.write_bytes(b"y" * 10)
smalls.append(p)
items = [(big, "game/big.bin", big.stat().st_size)] + [(p, f"game/sub/{p.name}", 10) for p in smalls]
f = FakeFrame()
installer.upload_files(f, items, "/r/incoming", Reporter(), 10 ** 9)
assert f.puts == ["/r/incoming/game/big.bin"] and f.tars == [[f"game/sub/s{i}.txt" for i in range(3)]]
assert f.dirs == ["/r/incoming/game"]
rep = Reporter()
rep.cancelled.set()
with pytest.raises(Cancelled):
installer.upload_files(FakeFrame(), items, "/r/incoming", rep, 10 ** 9)
def test_platform_sdk_delay_loaded(tmp_path):
"""Oculus Store Unreal builds delay-load the Platform SDK (the exe or a modular build's OnlineSubsystemOculus
plugin): it has to count as the Platform SDK (the game crashes with 0xc06d007e without the Oculus app)."""
exe = tmp_path / "a.exe"
exe.write_bytes(make_pe(imports=("kernel32.dll",), delay=("LibOVRPlatform64_1.dll", "OVRPlugin.dll")))
info, _ = rift.read_pe(exe)
assert info.imports == ["kernel32.dll"] and info.delay_imports == ["libovrplatform64_1.dll", "ovrplugin.dll"]
g = tmp_path / "Game"
(g / "Game/Binaries/Win64").mkdir(parents=True)
(g / "Game/Binaries/Win64/Game-Win64-Shipping.exe").write_bytes(make_pe(imports=("kernel32.dll",)))
plug = g / "Engine/Plugins/Online/OnlineSubsystemOculus/Binaries/Win64"
plug.mkdir(parents=True)
(plug / "Game-OnlineSubsystemOculus-Win64-Shipping.dll").write_bytes(make_pe(delay=("LibOVRPlatform64_1.dll",)))
(g / "Engine/Plugins/Runtime/OculusVR/Binaries/Win64").mkdir(parents=True)
(g / "Engine/Plugins/Runtime/OculusVR/Binaries/Win64/Game-OculusHMD-Win64-Shipping.dll").write_bytes(make_pe())
assert rift.analyze(g).extra["platform_sdk"]
def test_openvr_native_routing(tmp_path):
"""A game that links OpenVR (openvr_api) and has no Oculus/LibOVR code is Frame-native (no Revive). A game that
also has LibOVR is treated as Oculus (needs Revive) — static analysis can't tell which runtime a dual build picks."""
g = tmp_path / "SteamVR Game"
(g / f"{g.name}_Data").mkdir(parents=True)
(g / "SteamVR Game.exe").write_bytes(make_pe(imports=("openvr_api64.dll", "kernel32.dll")))
(g / "openvr_api64.dll").write_bytes(make_pe())
a = rift.analyze(g)
assert a.extra["openvr_native"] and a.extra["frame_native"] and not a.extra["needs_revive"]
r = engine.suggest(a)
assert "pcvr.revive" not in r.patches and "OpenVR" in a.xr
# add LibOVR markers -> Oculus, needs Revive, not Frame-native
(g / "OVRPlugin.dll").write_bytes(make_pe(extra=b"LibOVRRT%hs_%d.dll"))
a2 = rift.analyze(g)
assert not a2.extra["frame_native"] and a2.extra["needs_revive"]
assert "pcvr.revive" in engine.suggest(a2).patches
+85
View File
@@ -0,0 +1,85 @@
"""Help hints and the game action menus (library right-click, game page "…")."""
import re
from pathlib import Path
from types import SimpleNamespace
from frameport.ui import components as C
from frameport.ui.help import HELP
UI = Path(__file__).resolve().parents[1] / "src" / "frameport" / "ui"
def test_every_help_key_used_in_the_ui_exists():
used = set()
for f in UI.rglob("*.py"):
src = f.read_text(encoding="utf-8")
used |= set(re.findall(r'help="(\w+)"', src))
used |= set(re.findall(r'HELP\["(\w+)"\]', src))
used |= set(re.findall(r'help_icon\("(\w+)"\)', src))
used |= set(re.findall(r'with_help\([^\n]*?, "(\w+)"\)', src))
from frameport.ui.views.game import CATEGORY_TITLES
used |= {f"cat_{c}" for c in CATEGORY_TITLES}
assert used, "the pattern scan found nothing"
assert used <= set(HELP), sorted(used - set(HELP))
def test_help_icon_accepts_a_key_or_text():
assert C.help_icon("lepton").tooltip.message == HELP["lepton"]
assert C.help_icon("Some text").tooltip.message == "Some text"
plain = C.meta("x")
assert C.with_help(plain, None) is plain
def test_menu_items_dividers():
act = ("A", None, None)
items = C.menu_items([None, act, None, None, act, None])
assert [i.content is not None for i in items] == [True, False, True]
def _app(monkeypatch, game, frame_info=None, busy=None, pc=()):
from frameport.core import library
from frameport.ui.app import FramePortApp
monkeypatch.setattr(library, "game", lambda p: game if p == game["package"] else None)
app = object.__new__(FramePortApp)
app.frame_state = "connected" if frame_info is not None else "none"
app.frame_info = frame_info
app.jobs = SimpleNamespace(busy_with=lambda p: busy)
app.library_view = None
app._pc_cache = (float("inf"), {p: {} for p in pc})
return app
def _labels(actions):
return [a[0] if a else "—" for a in actions]
def test_right_click_menu_follows_install_state(monkeypatch):
g = {"package": "com.q", "title": "Q", "recipe": {"status": "works"}, "build": {"sha256": "new"}}
installed = _labels(_app(monkeypatch, g, {"installed": [{"package": "com.q", "sha256": "old"}]}).game_actions("com.q"))
assert installed[:3] == ["Open", "Play on Frame", "Update on Frame"]
assert {"Launch test on Frame", "Adapter settings…", "Uninstall from Frame", "Remove from library"} <= set(installed)
missing = _labels(_app(monkeypatch, g, {"installed": []}).game_actions("com.q"))
assert "Install on Frame" in missing and "Uninstall from Frame" not in missing and "Play on Frame" not in missing
offline = _labels(_app(monkeypatch, g).game_actions("com.q"))
assert "Connect your Frame" in offline and "Launch test on Frame" not in offline
def test_right_click_menu_while_busy_and_on_game_page(monkeypatch):
g = {"package": "rift.r", "kind": "rift", "title": "R", "recipe": {"status": "unknown"}}
busy = _labels(_app(monkeypatch, g, {"installed": []}, busy=object()).game_actions("rift.r"))
assert "Cancel" in busy and "Install on Frame" not in busy and "Check game files" not in busy
page = _labels(_app(monkeypatch, g, {"installed": []}).game_actions("rift.r", quick=False))
assert page[0] == "Change executable…" and "Open" not in page and "Install on Frame" not in page
def test_play_is_the_quick_action_when_installed(monkeypatch):
g = {"package": "com.q", "title": "Q", "recipe": {"status": "works"}, "build": {"sha256": "x"}}
app = _app(monkeypatch, g, {"installed": [{"package": "com.q", "sha256": "x"}]})
assert app.quick_action(g)[0] == "Play"
assert app.quick_action(g) != _app(monkeypatch, g, {"installed": []}).quick_action(g)
r = {"package": "rift.r", "kind": "rift", "title": "R", "recipe": {}}
assert [o[0] for o in _app(monkeypatch, r, {"installed": []}, pc=("rift.r",)).play_options(r)] == ["Play on this PC"]
+174
View File
@@ -0,0 +1,174 @@
from frameport.ui.app import install_state
def game(sha=None, alt=None):
return {"package": "com.x", "build": {"sha256": sha, "alt_sha256": alt} if sha else {}}
def frame(*deps):
return {"installed": list(deps)}
def test_not_connected():
assert install_state(game("a"), None) is None
def test_missing():
assert install_state(game("a"), frame({"package": "com.other", "sha256": "a"})) == "missing"
def test_installed_same_build_or_alt():
assert install_state(game("a", "b"), frame({"package": "com.x", "sha256": "a"})) == "installed"
assert install_state(game("a", "b"), frame({"package": "com.x", "sha256": "b"})) == "installed"
def test_installed_without_local_build():
assert install_state(game(), frame({"package": "com.x", "sha256": "z"})) == "installed"
def test_outdated():
assert install_state(game("a"), frame({"package": "com.x", "sha256": "old"})) == "outdated"
# ------------------------------------------------------------------------------------------ library filters / tags
def lib_games():
return [
{"package": "com.a", "title": "Alpha", "added": 1, "recipe": {"status": "works", "patches": {}},
"analysis": {"engine": "Unity", "xr": "OpenXR"}, "tags": ["Favorite"], "data_bytes": 5},
{"package": "rift.b", "kind": "rift", "title": "Bravo", "added": 3, "recipe": {"status": "unknown"},
"analysis": {"engine": "Unreal", "xr": "LibOVR", "extra": {"data_bytes": 50}},
"last_test": {"time": 100}},
{"package": "com.c", "title": "charlie", "added": 2, "recipe": {"status": "issues",
"patches": {"patch_force_passthrough": {}}},
"analysis": {"engine": "Unreal", "xr": "VrApi"}, "installs": {"frame": {"time": 200}}, "data_bytes": 20},
]
def test_filters_and_sort():
from frameport.ui.views.library import DEFAULT_FILTERS, filter_games
games = lib_games()
f = dict(DEFAULT_FILTERS)
names = lambda fl, **kw: [g["title"] for g in filter_games(games, {**f, **fl}, **kw)] # noqa: E731
assert names({}) == ["Alpha", "Bravo", "charlie"]
assert names({"platform": "pcvr"}) == ["Bravo"] and names({"platform": "quest"}) == ["Alpha", "charlie"]
assert names({"status": "issues"}) == ["charlie"]
assert names({"q": "unreal"}) == ["Bravo", "charlie"] # search covers tags
assert names({"tags": ["favorite"]}) == ["Alpha"] and names({"tags": ["Unreal", "Mixed reality"]}) == ["charlie"]
assert names({"sort": "recent"}) == ["Bravo", "charlie", "Alpha"]
assert names({"sort": "played"}) == ["charlie", "Bravo", "Alpha"]
assert names({"sort": "size"}) == ["Bravo", "charlie", "Alpha"]
assert names({"sort": "status"}) == ["Alpha", "charlie", "Bravo"]
frame = {"installed": [{"package": "com.c"}]}
assert names({"where": "frame"}, frame_info=frame) == ["charlie"]
assert names({"where": "pc"}, pc_installs={"rift.b"}) == ["Bravo"]
assert names({"where": "none"}, frame_info=frame, pc_installs={"rift.b"}) == ["Alpha"]
def test_tags():
from frameport.ui.views.library import all_tags, auto_tags, game_tags, normalize_tag
a, b, c = lib_games()
assert auto_tags(b) == ["PC VR", "Unreal", "LibOVR"]
assert "Mixed reality" in auto_tags(c)
assert game_tags(a)[0] == "Favorite"
assert all_tags([a, b, c])[0] == "Favorite" # the user's own tags first
assert normalize_tag(" my, tag ") == "my tag"
# ------------------------------------------------------------------------------------------ job queue
def test_jobs_run_in_order_one_at_a_time():
import threading
import time
from frameport.ui.jobs import Job, JobManager
events, running = [], []
lock = threading.Lock()
def work(name):
def run(job):
with lock:
running.append(name)
assert len(running) == 1, "two jobs ran at once"
job.reporter.stage(f"{name} stage")
job.reporter.check("thing", True, "ok")
time.sleep(0.05)
with lock:
running.remove(name)
events.append(name)
return name
return run
m = JobManager(throttle=0)
jobs = [m.submit(Job(n, work(n), package="p" if n == "a" else None)) for n in "abc"]
assert m.wait_idle(5)
assert events == ["a", "b", "c"] and [j.state for j in jobs] == ["done"] * 3
assert jobs[0].stages == ["a stage"] and jobs[0].checks[0]["ok"] is True and jobs[0].result == "a"
assert m.busy_with("p") is None
def test_job_cancel_and_failure():
import time
from frameport.ui.jobs import Job, JobManager
def slow(job):
for _ in range(100):
job.reporter.check_cancel()
time.sleep(0.02)
def boom(job):
raise RuntimeError("no Frame")
m = JobManager(throttle=0)
first = m.submit(Job("slow", slow, package="x"))
queued = m.submit(Job("later", slow))
failing = m.submit(Job("boom", boom))
time.sleep(0.1)
assert m.busy_with("x") is first and first.state == "running"
m.cancel(queued) # still queued: never runs
m.cancel(first)
assert m.wait_idle(5)
assert (first.state, queued.state, failing.state) == ("cancelled", "cancelled", "failed")
assert failing.error == "no Frame"
m.clear_finished()
assert m.jobs == []
def test_files_tree():
from frameport.ui.views import files_dialog as fd
files = [["game/Bin/Game.exe", 4], ["game/Bin/Data.pak", 10], ["launch.sh", 1], ["game/Readme.txt", 2]]
root = fd.build_tree("Install folder", files)
assert (root.size, root.files) == (17, 4)
game = root.children["game"]
assert (game.size, game.files) == (16, 3)
# folders first, then files; only expanded folders show their children
assert [(d, n.name) for d, n in fd.visible_rows(root, set())] == [(0, "game"), (0, "launch.sh")]
rows = fd.visible_rows(root, {"game", "game/Bin"})
assert [(d, n.name) for d, n in rows] == [(0, "game"), (1, "Bin"), (2, "Data.pak"), (2, "Game.exe"),
(1, "Readme.txt"), (0, "launch.sh")]
assert fd.matches(files, ".PAK") == [["game/Bin/Data.pak", 10]]
assert fd.human(3 * 2**30) == "3.0 GiB" and fd.human(512) == "512 B"
def test_files_tree_caps_children():
from frameport.ui.views import files_dialog as fd
root = fd.build_tree("x", [[f"f{i:04}", 1] for i in range(fd.MAX_CHILDREN + 5)])
rows = fd.visible_rows(root, set())
assert len(rows) == fd.MAX_CHILDREN + 1 and rows[-1] == (0, 5)
def test_install_state_tracks_patch_settings():
from frameport.ui import components as C
g = {"package": "rift.g", "kind": "rift", "recipe": {"patches": {"pcvr.revive": {}, "pcvr.xr_timefix": {}}}}
fi = {"installed": [{"package": "rift.g", "recipe": {"patches": ["pcvr.revive", "pcvr.xr_timefix"]}}]}
assert C.install_state(g, fi) == "installed" and C.settings_diff(g, fi) is None
g["recipe"]["patches"].pop("pcvr.revive")
g["recipe"]["patches"]["pcvr.no_crash_reporter"] = {}
assert C.install_state(g, fi) == "outdated"
assert C.settings_diff(g, fi) == (["pcvr.no_crash_reporter"], ["pcvr.revive"])
old = {"installed": [{"package": "rift.g"}]} # installed before recipes were recorded: no false alarm
assert C.install_state(g, old) == "installed"
assert C.install_state(g, {"installed": []}) == "missing"
+189
View File
@@ -0,0 +1,189 @@
"""The timefix OpenXR layer (native/xrlayer), compiled for this host and driven through ctypes as a fake loader +
runtime: negotiation, extension stripping, time conversion emulation and calibration at xrWaitFrame.
Opt-in (FRAMEPORT_NATIVE_TESTS=1, -m native): needs the NDK in native/.cache (from native/build.py)."""
import ctypes as C
import os
import platform
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
CLANG = next(iter((ROOT / "native/.cache").glob("ndk-*/toolchains/llvm/prebuilt/*/bin/clang")), None)
INC = next(iter((ROOT / "native/.cache").glob("openxr-*")), None)
pytestmark = [pytest.mark.native, pytest.mark.skipif(
not os.environ.get("FRAMEPORT_NATIVE_TESTS") or not CLANG or not INC or platform.machine() != "x86_64",
reason="set FRAMEPORT_NATIVE_TESTS=1 (needs the NDK from native/build.py on an x86_64 host)")]
XR_SUCCESS, XR_ERROR_FUNCTION_UNSUPPORTED, XR_ERROR_EXTENSION_NOT_PRESENT = 0, -7, -9
LOADER_INFO, API_LAYER_REQUEST, CREATE_INFO, NEXT_INFO = 1, 2, 4, 5 # XrLoaderInterfaceStructs
class Timespec(C.Structure):
_fields_ = [("tv_sec", C.c_long), ("tv_nsec", C.c_long)]
class InstanceCreateInfo(C.Structure):
_fields_ = [("type", C.c_int), ("next", C.c_void_p), ("createFlags", C.c_uint64),
("applicationInfo", C.c_char * 272), ("enabledApiLayerCount", C.c_uint32),
("enabledApiLayerNames", C.c_void_p), ("enabledExtensionCount", C.c_uint32),
("enabledExtensionNames", C.POINTER(C.c_char_p))]
PFN = C.c_void_p
GIPA = C.CFUNCTYPE(C.c_int, C.c_uint64, C.c_char_p, C.POINTER(C.c_void_p))
CREATE = C.CFUNCTYPE(C.c_int, C.POINTER(InstanceCreateInfo), C.c_void_p, C.POINTER(C.c_uint64))
class NextInfo(C.Structure):
pass
NextInfo._fields_ = [("structType", C.c_int), ("structVersion", C.c_uint32), ("structSize", C.c_size_t),
("layerName", C.c_char * 256), ("nextGetInstanceProcAddr", GIPA),
("nextCreateApiLayerInstance", CREATE), ("next", C.POINTER(NextInfo))]
class LayerCreateInfo(C.Structure):
_fields_ = [("structType", C.c_int), ("structVersion", C.c_uint32), ("structSize", C.c_size_t),
("loaderInstance", C.c_void_p), ("settings_file_location", C.c_char * 512),
("nextInfo", C.POINTER(NextInfo))]
class LoaderInfo(C.Structure):
_fields_ = [("structType", C.c_int), ("structVersion", C.c_uint32), ("structSize", C.c_size_t),
("minInterfaceVersion", C.c_uint32), ("maxInterfaceVersion", C.c_uint32),
("minApiVersion", C.c_uint64), ("maxApiVersion", C.c_uint64)]
class LayerRequest(C.Structure):
_fields_ = [("structType", C.c_int), ("structVersion", C.c_uint32), ("structSize", C.c_size_t),
("layerInterfaceVersion", C.c_uint32), ("layerApiVersion", C.c_uint64),
("getInstanceProcAddr", C.c_void_p), ("createApiLayerInstance", C.c_void_p)]
class FrameState(C.Structure):
_fields_ = [("type", C.c_int), ("next", C.c_void_p), ("predictedDisplayTime", C.c_int64),
("predictedDisplayPeriod", C.c_int64), ("shouldRender", C.c_uint32)]
@pytest.fixture(scope="module")
def layer(tmp_path_factory):
out = tmp_path_factory.mktemp("xrlayer") / "timefix.so"
src = ROOT / "native/xrlayer"
subprocess.run([str(CLANG), "--target=x86_64-linux-gnu", "-ffreestanding", "-nostdlibinc", "-fno-stack-protector",
"-fvisibility=hidden", "-fPIC", "-O2", "-Wall", "-Werror", "-I", str(src / "include"), "-I",
str(INC), "-shared", "-nostdlib", "-fuse-ld=lld", str(src / "timefix_layer.c"), "-o", str(out)],
check=True)
return C.CDLL(str(out), mode=C.RTLD_GLOBAL)
def negotiate(lib):
li = LoaderInfo(LOADER_INFO, 1, C.sizeof(LoaderInfo), 1, 1, 0, 0)
req = LayerRequest(API_LAYER_REQUEST, 1, C.sizeof(LayerRequest))
assert lib.xrNegotiateLoaderApiLayerInterface(C.byref(li), b"XR_APILAYER_FRAMEPORT_timefix", C.byref(req)) == 0
assert lib.xrNegotiateLoaderApiLayerInterface(C.byref(li), b"other", C.byref(req)) != 0
layer_create = C.CFUNCTYPE(C.c_int, C.POINTER(InstanceCreateInfo), C.POINTER(LayerCreateInfo),
C.POINTER(C.c_uint64))
return GIPA(req.getInstanceProcAddr), layer_create(req.createApiLayerInstance)
def test_layer_end_to_end(layer):
gipa, create = negotiate(layer)
seen = []
@CREATE
def runtime_create(info, layer_info, instance):
names = [info.contents.enabledExtensionNames[i] for i in range(info.contents.enabledExtensionCount)]
seen.append(names)
if b"XR_KHR_convert_timespec_time" in names:
return XR_ERROR_EXTENSION_NOT_PRESENT # like the Frame runtime
instance[0] = 42
return XR_SUCCESS
CONV = C.CFUNCTYPE(C.c_int, C.c_uint64, C.POINTER(Timespec), C.POINTER(C.c_int64))
WAIT = C.CFUNCTYPE(C.c_int, C.c_uint64, C.c_void_p, C.POINTER(FrameState))
@CONV
def runtime_convert(inst, ts, t):
return XR_ERROR_FUNCTION_UNSUPPORTED # like the Frame runtime
@WAIT
def runtime_wait(session, info, state):
state[0].predictedDisplayTime = 10**15 # the runtime's clock: far from CLOCK_MONOTONIC
state[0].predictedDisplayPeriod = 13_888_888
return XR_SUCCESS
@GIPA
def runtime_gipa(inst, name, fn):
table = {b"xrConvertTimespecTimeToTimeKHR": runtime_convert, b"xrWaitFrame": runtime_wait}
if name in table:
fn[0] = C.cast(table[name], C.c_void_p).value
return XR_SUCCESS
return XR_ERROR_FUNCTION_UNSUPPORTED
nxt = NextInfo(NEXT_INFO, 1, C.sizeof(NextInfo), b"runtime", runtime_gipa, runtime_create, None)
li = LayerCreateInfo(CREATE_INFO, 1, C.sizeof(LayerCreateInfo), None, b"", C.pointer(nxt))
exts = (C.c_char_p * 2)(b"XR_KHR_vulkan_enable", b"XR_KHR_convert_timespec_time")
info = InstanceCreateInfo(3, None, 0, b"", 0, None, 2, exts)
inst = C.c_uint64()
assert create(C.byref(info), C.byref(li), C.byref(inst)) == XR_SUCCESS and inst.value == 42
assert seen == [[b"XR_KHR_vulkan_enable", b"XR_KHR_convert_timespec_time"], [b"XR_KHR_vulkan_enable"]]
fn = C.c_void_p()
assert gipa(42, b"xrConvertTimespecTimeToTimeKHR", C.byref(fn)) == XR_SUCCESS
to_time = CONV(fn.value)
assert gipa(42, b"xrConvertTimeToTimespecTimeKHR", C.byref(fn)) == XR_SUCCESS
to_ts = C.CFUNCTYPE(C.c_int, C.c_uint64, C.c_int64, C.POINTER(Timespec))(fn.value)
assert gipa(42, b"xrWaitFrame", C.byref(fn)) == XR_SUCCESS
wait = WAIT(fn.value)
t = C.c_int64()
assert to_time(42, C.byref(Timespec(5, 7)), C.byref(t)) == XR_SUCCESS and t.value == 5_000_000_007 # uncalibrated
st = FrameState()
assert wait(42, None, C.byref(st)) == XR_SUCCESS
now = Timespec()
C.CDLL(None).clock_gettime(1, C.byref(now))
assert to_time(42, C.byref(now), C.byref(t)) == XR_SUCCESS
expected = 10**15 - 13_888_888 # "now" in runtime time right after xrWaitFrame
assert abs(t.value - expected) < 50_000_000, t.value - expected
back = Timespec()
assert to_ts(42, t.value, C.byref(back)) == XR_SUCCESS
assert (back.tv_sec, back.tv_nsec) == (now.tv_sec, now.tv_nsec)
# everything else passes through to the next layer/runtime
assert gipa(42, b"xrEndFrame", C.byref(fn)) == XR_ERROR_FUNCTION_UNSUPPORTED
def test_layer_api_version_fallback(layer):
"""The Frame's SteamVR runtime rejects apiVersion 1.1 (XR_ERROR_API_VERSION_UNSUPPORTED): retried as 1.0."""
import struct
_, create = negotiate(layer)
versions = []
@CREATE
def runtime_create(info, layer_info, instance):
app_addr = C.addressof(info.contents) + InstanceCreateInfo.applicationInfo.offset
v = C.c_uint64.from_address(app_addr + 264).value # XrApplicationInfo.apiVersion
versions.append((v >> 48, (v >> 32) & 0xFFFF))
if versions[-1] != (1, 0):
return -4 # XR_ERROR_API_VERSION_UNSUPPORTED
instance[0] = 7
return XR_SUCCESS
@GIPA
def runtime_gipa(inst, name, fn):
return XR_ERROR_FUNCTION_UNSUPPORTED
nxt = NextInfo(NEXT_INFO, 1, C.sizeof(NextInfo), b"runtime", runtime_gipa, runtime_create, None)
li = LayerCreateInfo(CREATE_INFO, 1, C.sizeof(LayerCreateInfo), None, b"", C.pointer(nxt))
for requested, expect in (((1, 1), [(1, 1), (1, 0)]), ((1, 0), [(1, 0)])):
versions.clear()
app = bytearray(272)
struct.pack_into("<Q", app, 264, (requested[0] << 48) | (requested[1] << 32))
info = InstanceCreateInfo(3, None, 0, b"", 0, None, 0, None)
C.memmove(C.addressof(info) + InstanceCreateInfo.applicationInfo.offset, bytes(app), len(app))
inst = C.c_uint64()
assert create(C.byref(info), C.byref(li), C.byref(inst)) == XR_SUCCESS and inst.value == 7
assert versions == expect