Compare commits

...
Author SHA1 Message Date
spoopyghosty0 1f8c86b291 Unreal Binned3 reservations with MAP_NORESERVE (frame.unreal_noreserve, GitHub #349)
Transformers Beyond Reality (UE5, FMallocBinned3) reserves 76 GiB with one
mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS) while libUnreal.so's
static constructors run. Quest grants it; the Frame's kernel (overcommit mode 0) refuses a
writable private mapping larger than RAM + swap, and Unreal's out-of-memory handler then
dereferences its not yet created log: SIGSEGV fault addr 0x10 inside System.loadLibrary.

The patch adds MAP_NORESERVE to every anonymous read/write mmap call site of the engine
library (mov w3, #0x22 -> #0x4022 before a bl to mmap's PLT stub, with x0 = 0 and w2 = 3
in the window), suggested from analysis unreal_binned3 (ANALYSIS_VERSION 20). Triage
unreal-reserve-refused, PLAYBOOK row, tests. Headless on the dev Frame the game now gets
past loading to its OpenXR session and first frames.

ELF helpers find .dynsym/.dynstr/.dynamic by section type and link instead of by name: this
game's libOVRPlugin.so has a scrambled section name table, so add_needed raised
"produced NEEDED=[...]" and the build failed in frame.unreal_ovrp_entrypoints, and
dyn_symbols returned nothing (every ovrp_* lookup then counted as missing).
2026-10-16 11:34:07 -04:00
spoopyghosty0 04faa0be65 Catalog: The Climb (works)
Closes #341
2026-10-16 09:39:49 -04:00
spoopyghosty0 1399d7c5a3 Catalog: Attack on Titan VR: Unbreakable works with its asset files (was Freedom, issues)
Closes #337
2026-10-16 09:36:24 -04:00
spoopyghosty0 fa5c5b32ce Catalog: Sclerosis (works)
Closes #339
2026-10-16 09:33:54 -04:00
spoopyghosty0 0228516a2a Catalog: 7 community recipes (Breathedge VR, Cities: VR, Lawn Mowing Simulator VR, Exploding Kittens VR, Lost Recipes, Neko Atsume Purrfect, Real VR Fishing)
Closes #347
Closes #345
Closes #344
Closes #343
Closes #340
Closes #338
Closes #333
2026-10-16 09:31:12 -04:00
spoopyghosty0 960da4469b Announcements: a one-time pop-up in the app
Source of truth: announcements/current.md on main (front matter: id, title,
date, optional image/button/expires; Markdown body), read from raw GitHub every
6 h like the catalog, so a push reaches users without a release. Ships
switched off (active: false).

Shown only to people who used FramePort before the announcement's date, never
on a first start; any close (X, click outside, Escape) dismisses that id for
good, and a new announcement = a new id. Centred over the content area, right
of the sidebar. announcements/README.md explains the file;
ui_smoke.py --announcement renders it.
2026-10-16 09:23:46 -04:00
spoopyghosty0 e9720f83d2 Catalog: Crisis Zone VR (works)
Closes #331
2026-10-16 00:14:17 -04:00
spoopyghosty0 729f77a9e3 Docs: update screenshots and videos 2026-10-16 00:08:46 -04:00
spoopyghosty0 4824b30e94 Catalog: Vermillion works (confirmed in the headset, #200) 2026-10-15 23:54:32 -04:00
spoopyghosty0 39e1762e53 Game menu: Update with a newer version… for Linux apps and PC VR games (#161)
Follow-up to "Update with a newer APK…": Linux apps take their new AppImage/archive or folder (a file or a folder
picker, like the app was added), PC VR games their new game folder. pipeline.replace_linux_app / replace_rift_game
re-run the add path (add_linux_app(replace=) / add_rift_game(replace=)): the entry keeps its package (so the Steam
shortcut), name, artwork, tags, switches and the user's recipe; a folder keeps the program chosen before if it's still
there, else FramePort picks again (and asks, for ambiguous PC VR games). A name that looks like another app's is
allowed with a heads-up.

The entry gets a new source_id, recorded in the deployment's recipe at install (no agent change):
components.source_replaced shows the Frame copy (and a PC install, pc_outdated) as outdated until then. Such an
install also re-sends same-size files changed after the last install (installer.changed_since), since uploads
otherwise skip files the Frame has with the same size. The old PC VR folder is left out of later scans
(replaced_dirs). CLI: frameport new-version <game> <path> (APKs too). Docs: INSTALL.md.
2026-10-15 20:10:50 -04:00
spoopyghosty0 3d260dccb1 OpenSSH uploads log in like the Frame connection; SSH key files in Settings (#242)
A Frame that only authorizes the user's own key refused OpenSSH, which
offered FramePort's keys alone (IdentitiesOnly), so every upload fell back
to Paramiko. The connection now records which login worked (Frame.auth):
after a login with this PC's own keys/agent OpenSSH also offers private
copies of ~/.ssh/id_* (OpenSSH skips its defaults once -i is given) and the
agent; a refused strict login is retried that way once. FramePort doesn't
add its key to the Frame for this.

New: Settings > Installing > SSH keys (library ssh.extra_keys, CLI
frameport frame ssh-keys): extra private key files for the connection and
OpenSSH, checked with Paramiko (passphrase keys: use the agent). OpenSSH
runs with -v and a refused login's error lists the keys offered and its
warnings. whoami's SID is read from bytes (non-ASCII user names).
Checked with Windows ssh.exe/sftp.exe against the Frame.
2026-10-15 19:58:58 -04:00
spoopyghosty0 26a34ddadb OpenSSH uploads: don't escape glob characters inside sftp's quotes (#324)
sftp escapes * ? [ inside double quotes itself, so FramePort's extra
backslash became part of the path: a folder named "... [ENG] ..." failed
every OpenSSH upload (on Linux too) and fell back to Paramiko. On Windows
the local path now goes with forward slashes: Win32-OpenSSH turned every
backslash, escapes included, into '/' ("C://Users", "/[ENG]").
Checked with Windows sftp.exe and Linux sftp against the Frame (put and
reput, sha256 match).
2026-10-15 19:58:57 -04:00
spoopyghosty0 939845d379 Catalog: Clockwork is The Last Clockwinder (Meta's store name; reported in #110) 2026-10-15 19:51:45 -04:00
spoopyghosty0 35eb12df08 CLAUDE.md: every triage sweeps for unanswered comments (closed issues, PRs, discussion replies) 2026-10-15 19:41:32 -04:00
spoopyghosty0 570a98b91a Triage: meta-entitlement for Quest games; catalog: Deadly Delivery unsupported (#322)
Deadly Delivery 1.0.30 renders (72 fps), then its lobby's Meta entitlement check
fails four times and it quits. The oculus-entitlement signature is PC VR only,
so the Quest log had no finding. New Quest signature meta-entitlement (error:
offline games may log it and play on; an online game's exit fails the test).
FramePort doesn't get around entitlement checks. docs/GAMES.md regenerated
(it was also behind on other catalog entries).
2026-10-15 19:34:51 -04:00
spoopyghosty0 f2f92cce70 Catalog: NEX Player typing verified in the headset (keyboard opened by hand) 2026-10-15 19:07:48 -04:00
spoopyghosty0 dd74c17212 Heuristics eval: docs for the dated score, CLAUDE.md notes, ratchet floors from the final run 2026-10-15 19:07:47 -04:00
spoopyghosty0 5647e266bd Catalog: Unity text-input fix for 17 recipes verified before it existed (from detection, not headset-verified) 2026-10-15 19:07:46 -04:00
spoopyghosty0 a5a86e591d eval_heuristics: adapter/device picks a catalog recipe inherits aren't errors in the dated score 2026-10-15 19:07:45 -04:00
spoopyghosty0 7cd78cfa26 eval_heuristics: date-aware score (patches newer than the entry's verified date are catalog gaps, not errors); fit report dates by verified date 2026-10-15 19:07:44 -04:00
spoopyghosty0 e04edf3600 unity_oculus_check: keep suggesting it with the Oculus XR Plugin (holdout game com.coatsink.alone needs it) 2026-10-15 19:07:43 -04:00
spoopyghosty0 c98aad5056 Use built-in Oculus evidence for adapter pose timing suggestions 2026-10-15 19:07:41 -04:00
spoopyghosty0 ae228ea4ea Refine Unity heuristics for IL2CPP support, XR routing and thread keys 2026-10-15 19:07:40 -04:00
spoopyghosty0 0599e55831 eval_heuristics: catalog truth goes through the 32-bit bridge / 2D-app transforms like suggest(); floors from the full snapshot 2026-10-15 19:07:39 -04:00
spoopyghosty0 2e83970ad5 Add catalog-only heuristics scoring and new patch fit reports 2026-10-15 19:07:38 -04:00
spoopyghosty0 33a778c195 scripts: heuristics_snapshot.py saves dump analyses as JSON for heuristics evals 2026-10-15 19:07:37 -04:00
spoopyghosty0 b9a1fe67bc CLAUDE.md: document the GitHub triage workflow
Consolidates the reusable mechanics: issue templates/labels, the
working-recipe -> catalog path (scripts/catalog_from_issue.py), handling
diagnostics zips (and the attachment-vs-placeholder gotcha), how a change
reaches users (catalog ~6 h vs code needing a release/dev build, and not
bumping a shared revision for a narrow fix), and verifying a device fix
headless. Identity/credentials stay in CLAUDE.local.md.
2026-10-15 17:34:39 -04:00
88 changed files with 3987 additions and 218 deletions

No files matched your search

+2
View File
@@ -19,6 +19,8 @@ CLAUDE.local.md
# outputs of `frameport report` / `parity` / ruff
REPORT.md
.ruff_cache/
.eval-home/
.eval-results/
.claude/worktrees/
# project page (site/)
+82 -3
View File
@@ -223,6 +223,13 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
store matches and links then only update `auto_title`; a locked name gets no twin suffix); `sync_title` → Frame
agent v77 `rename` (deployment.json title, same shortcut appid: upsert matches by Exe) + art + one Steam restart, PC
`PcReviveTarget.rename`; an unreachable Frame → `steam_name_stale` (game page "Update name on Frame", next install).
- Newer versions (GitHub #161): game menu "Update with a newer APK…" (`pipeline.replace_apk`, outdated via
`build.source_version`) and, for Linux apps / PC VR games, "Update with a newer version…" (`replace_linux_app` /
`replace_rift_game` via `add_linux_app(replace=)` / `add_rift_game(replace=)`, CLI `frameport new-version`): the
entry keeps package, name, art, tags, switches, user recipe (a confirmed exe if the new folder has it); a new
`source_id` goes into the deployment's `recipe` dict at install (no agent change) → `C.source_replaced` = outdated
(Frame + `pc_outdated`); such an install re-sends same-size files changed after the last install (mtime/ctime,
`installer.changed_since`); the old Rift folder is in `replaced_dirs` (rescans skip it). Untested on the device.
- `Recipe.as_is` = install unchanged (pre-patched libraries): `pipeline.prepare_as_is`; auto for APKs that already
contain FrameBridge (`frame_patched`). For Rift it changes nothing (the dump is never modified; the Frame copy
still gets launch fixes like the crash-reporter rename — `-nocrashreports` alone doesn't stop UE 4.23) — it does
@@ -267,6 +274,15 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
`working-config.yml` issue → maintainer label `catalog-accepted` (a plain issue needs `working-config` added by
hand) → `catalog-from-issue.yml` workflow (`scripts/catalog_from_issue.py` validates) opens a catalog PR. App log:
`core/applog.py` (`<data>/logs/app.log`, finished GUI jobs in `<data>/logs/jobs/`).
- Announcements (2026-10-15, announcements/README.md; app only, the owner dropped the site pop-up): source of
truth `announcements/current.md` on `main` (front matter id/title/date + optional image/image_alt/button+url/
expires/active, Markdown body; ships `active: false`). `announcements.py` (no Flet; raw GitHub via
`cache.cached_text`, 6 h) + `ui/announcer.py` (pop-up 20 s after start / every 6 h when no dialog is open and not
on welcome; `C.dialog` with the portal edge, image downloaded + Pillow-checked into `<data>/announcements/`, shown
by asset URL). Shown only if the user was around before `date` (setting `announce.since`, set once at the GUI's
first start: 0 when `USED_BEFORE` settings exist, else now), once: any close (X, outside click, Escape, button)
stores the id (`announce.dismissed`, last 50). New announcement = new id; same id = typo fix.
`FRAMEPORT_NO_ANNOUNCEMENTS`; `ui_smoke.py --announcement` renders it.
- Self-update (docs/ARCHITECTURE.md "Self-update", docs/INSTALL.md "Updating"): `_version.py` = the only version
(`frameport.__version__`; pyproject reads it via hatch `dynamic`; app log, diagnostics, User-Agent, Settings use
it — never `importlib.metadata`, bundles have no dist-info). `updates.py` (no Flet): `check()` = GitHub
@@ -904,6 +920,13 @@ hide_space_warp / vk_hide_fdm / no Valve layers; FNAF HW (#135) "freeze" = first
on Zink (threads busy in zink/disk cache), not a hang; VR4 (#199) reporter's patch.203 OBB truncated. Upload benchmark
(`scripts/upload_bench.py`, #242): home Wi-Fi caps every method at ~11 MB/s; paramiko uses ~3x OpenSSH's CPU; Windows
OpenSSH refuses FramePort's key file (ACL "bad permissions") until its permissions are tightened.
**OpenSSH uploads (2026-10-15, #324/#242):** sftp escapes `* ? [` inside double quotes itself (and undoes it for
put's destination), so `native_upload.quote` escapes only `\\`/`"`; Win32 sftp turns every local backslash into `/`
(escapes too) → local paths go as `C:/…`. Logins: `Frame.auth` = the attempt that worked (app_key / user_key = a key
file from Settings → Installing → SSH keys, library `ssh.extra_keys`, CLI `frame ssh-keys` / agent / password).
OpenSSH strict (FramePort + Settings keys, IdentitiesOnly) unless auth was "agent"; a refused strict login retries
relaxed (+ private copies of ~/.ssh/id_{rsa,dsa,ecdsa,ed25519}: OpenSSH skips its defaults once any -i is given, + the
agent); `-v` key lines go into the error. FramePort doesn't add its key to the Frame for this. Windows-tested.
**Meta user id (GitHub #182, 2026-10-11):** OVRPort's platform loader (runtimes 3.4.3-23204ea and -aa54c3f) answers
`ovr_GetLoggedInUserID` (and two other callers) without a Meta account with a made-up id from a helper (`stp; mov x29,
sp; bl random; …; mov/movk x9 = 0x53444835ec580000 (6e18); add x0, x8, x9; ldp; ret`), kept only in memory → a new id
@@ -1050,6 +1073,56 @@ Installed apps find the release themselves (self-update), so the notes are what
- **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.
## GitHub triage (issues / discussions / PRs)
GitHub identity + credentials are maintainer-local (`CLAUDE.local.md`): check `gh api user --jq .login` before any
GitHub write. **The owner approves before posting, labelling or closing anything.** Reply style: "I"/neutral, never
we/our/us; never mention agent version numbers or internal tool versions; show the owner every draft in full with a
short recap of the thread so far; read **all** comments on a thread first (a later one often has the zip or the answer).
- **Every triage also sweeps for unanswered comments**, not just new threads: open AND recently closed issues, PRs
(incl. merged/closed ones) and discussions (top-level comments and their replies) whose latest comment isn't the
maintainer's, plus earlier user comments a later maintainer reply skipped. Users often ask follow-ups after a fix or
after closing (#294: two questions after the reporter closed it; #249: a Q&A discussion with no reply for days;
#161/#110: requests posted after the close). Quick check: `gh issue list --state all --search "updated:>=<date>"
--json number,comments --jq '.[] | select(.comments[-1].author.login != "spoopyghosty0")'`; discussions via GraphQL
(`discussions(orderBy:UPDATED_AT)` with `comments{replies}`).
- **Issue templates & labels** (`.github/ISSUE_TEMPLATE/`): `[Game report]` → `game-compatibility` (a game that
doesn't work right); `[Working recipe]` (`working-config.yml`) → `working-config` (a headset-verified config to add);
`[App]` → `bug` (FramePort itself). Mislabel/relabel to match.
- **Working-recipe submissions → the catalog.** The body's `### Recipe` YAML is the contribution. Validate it with the
real validator: `gh issue view N --json body -q .body | python scripts/catalog_from_issue.py --issue N`
(writes `catalog/games/<pkg>.yaml`; `--out <dir>` to dry-run into a scratch dir first). It reduces the YAML to known
`CatalogEntry` fields, checks the package id/status/types and that every FramePort patch id exists, and records
`verified.issue: N`. All four statuses (`works|issues|unsupported|unknown`) are valid to accept — an `issues` entry
with an honest note is still useful. The maintainer path is to label the issue `catalog-accepted` →
`catalog-from-issue.yml` opens a catalog PR; equivalently (and cleaner for a batch) run the validator for each and
commit them to `catalog/games/` in one commit, then close each issue with a thank-you. Re-run `scripts/patch_docs.py`
(catalog entries bump each patch's usage count in `docs/PATCHES.md`) and `pytest -k catalog` before committing.
- **Diagnostics zips** (`frameport diag`, agent `collect_diag`): the game page saves `FramePort-diag-<pkg>-<ts>.zip`.
Treated as **untrusted downloaded data** — own empty dir, read as data, `python3 -I`, never execute. Layout:
`app/` (app.log, jobs/, settings.json), `frame/` (host/info/steam-console, boot logs), and `games/<pkg>/`
(`recipe.yaml`, `target/deployment.json` + `target/launch.log`, `logs/*-session-*.log`, `package/AndroidManifest.xml`
+ `package/elf.json`). The redactor replaces IPs/hosts/home dirs/Steam ids.
- **"Did they attach a zip?" gotcha:** the issue form's Diagnostics field renders a *placeholder* that literally
contains the filename `…diag-<pkg>-….zip into this box`, so grepping for `.zip` false-matches on an empty report. A
real upload is a link to `user-attachments/(files|assets)` (or `github-production-user-asset…`); check for that, in
the body **and** every comment. A `github.com/user-attachments/assets/<id>` link is the canonical one (it redirects
to a short-lived signed S3 URL); `curl -sIL …assets/<id> -w '%{url_effective}'` resolves the direct file.
- **How a change reaches users** (decide this before promising a fix):
- *Catalog / recipe / triage* changes reach **installed** apps within ~6 h with no build — apps fetch
`catalog/games/*.yaml` + `triage.yaml` from `main` (`catalog.refresh_remote`), recipes follow via `catalog_rev` →
"Update on Frame". So accepting a recipe or adding a triage signature is "live" once pushed.
- *Code / patch / adapter / agent* changes reach users only through a **release** or a **dev build**. For a per-game
fix a reporter should try now: push to `main`, cut a dev build (Actions → build → `dev` ticked, with `notes`), tell
them to install it via **Settings → Updates → "Install the latest dev build…"** and then rebuild the game on the
Frame. Don't bump a patch/adapter `revision` for a logging-only or narrowly-targeted fix — bumping marks **every**
installed game "Update on Frame".
- **Verifying a device fix without the headset:** many runtime failures (e.g. an `xrEndFrame` layer rejection) reproduce
in a **headless** launch test on the dev Frame, so the fix can be developed and confirmed at the frame-acceptance
level there; only the *visual* result needs the headset (hand the built dev build to the reporter, or the owner, for
that). `frameport settings <pkg> <key>=<value>` toggles adapter settings (e.g. `layer_debug=1`) live, no rebuild.
To reproduce a reporter's exact build, grab their APK from its upstream source (e.g. the linked GitHub release), scan
→ build → install → test in an isolated `FRAMEPORT_HOME` (see "Dev commands" / the Frame-busy rules).
## 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:
@@ -1057,9 +1130,15 @@ MR-only (PASSTHROUGH required + BOUNDARYLESS_APP) → force_passthrough, + USE_S
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`.
GLAD/GLES); Unreal → alternate no-ForceQuit build. Scoring (docs/HEURISTICS_EVAL.md, 2026-10-15): analyse the dumps
once (`scripts/heuristics_snapshot.py <out.json> "<VR CyberDeck Portable>" "<PATCHED>"`, ~30 min, keep out of git),
then `scripts/eval_heuristics.py --snapshot <out.json> --split dev|holdout|all [--fit-report]` (catalog-only truth,
per-patch precision/recall, a fixed 20% holdout to catch rules fitted to one game). Read the **dated** score: an
"extra" on an entry verified before the patch existed is a catalog gap, and adapter/device picks are inherited by
catalog recipes anyway. 2026-10-15: strict 5/65 exact, F1 73.8%; dated 39/65, F1 88.6%; remaining misses are
one-game runtime fixes. Don't key a rule on one game's code fingerprint (an Unreal splash/depth "detect" matching
only Star Wars Tales + FNAF HW was rejected: it would also have bumped ANALYSIS_VERSION for every user). Add a rule →
re-run dev + holdout + `pytest -m games`.
## Frame operations
- Find/connect: `frameport frame discover` / `frame info` (the remembered Frame is in `<user data>/frames.json`).
+40
View File
@@ -0,0 +1,40 @@
# Announcements
`current.md` is the one announcement FramePort shows as a pop-up in the app. Edit it on `main`: the app reads it
from GitHub `main` (raw.githubusercontent.com) at most every 6 hours, so a push reaches everyone without a release.
## Who sees it
- **Only people who already used FramePort before the announcement's `date`.** A new install never shows one, and
nor does an announcement published before FramePort was installed.
- **Once.** Closing it in any way (the X, a click outside it, Escape, or its button) dismisses it for good. The app
remembers dismissals in its settings (`announce.dismissed`).
- **A new announcement = a new `id`.** Fixing a typo with the same `id` doesn't show it again to anyone who closed it.
A new `id` shows to everyone who used FramePort before its `date`, whether they closed the earlier one or not.
## The file
```markdown
---
active: true # false = nothing is shown (the fields are still checked)
id: meetup-2026-11 # letters, digits, . _ - — change it for every new announcement
title: Steam Frame meetup on Discord # required
date: 2026-11-02T18:00:00Z # required: when it was published (UTC; a bare date = midnight UTC)
image: announcements/meetup.png # optional: a file in this folder, or an https URL (PNG, JPEG, WebP, GIF; ≤ 5 MiB)
image_alt: The meetup poster # optional: what the picture shows (screen readers)
button: Join the meetup # optional: an action button, with url
url: https://example.com/meetup
expires: 2026-11-30 # optional: not shown after this day (UTC, inclusive)
---
The body, in Markdown: **bold**, *italic*, `code`, [links](https://frameport.app/), lists, headings.
```
The body is Markdown (no HTML). A `date` in the future works as a schedule: nobody sees it before then.
Images look best at about 2:1 (e.g. 1200×600). The pop-up is 600 px wide and shows between 140 and 320 px of the
picture's height.
`pytest tests/test_announcements.py` checks this file, so a broken announcement fails CI instead of silently showing
nothing.
To turn announcements off for testing: `FRAMEPORT_NO_ANNOUNCEMENTS=1`.
+16
View File
@@ -0,0 +1,16 @@
---
# The announcement FramePort shows once (announcements/README.md). Switched off: set active: true.
active: false
id: example-2026-10
title: Example announcement
date: 2026-10-15T12:00:00Z
# image: announcements/example.png
# image_alt: What the picture shows
# button: Read more
# url: https://frameport.app/changelog/
# expires: 2026-11-30
---
This is where the text goes, in **Markdown**: *emphasis*, `code`, [links](https://frameport.app/) and lists.
- Keep it short: one or two paragraphs.
- Change `id` for every new announcement.
+6
View File
@@ -7,8 +7,14 @@ xr: OpenXR
status: works
notes: Uses Meta XR Audio via FMOD (not covered by patch_meta_xr_audio); if audio is missing, that is the likely
cause.
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: 63fff470777f2753d55ad50f8355573ced8fa48bcaee0f5e6a0c10369f951a38
updated: '2026-10-15'
@@ -6,9 +6,15 @@ engine: Unity
xr: VrApi
status: issues
notes: Right eye distorts during movement (unresolved; swap, tracking, Valve layers, depth and pacing ruled out).
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
details: Legacy VrApi via OVRPlugin; overport swaps in an OpenXR OVRPlugin. Do not remove VrApi.
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: 192ff250f5fbec38b9c4e898f0b8408edc25901f46bb4b227f7ca60bbd9f0bba
updated: '2026-10-15'
@@ -6,9 +6,15 @@ engine: Unity
xr: OpenXR
status: works
notes: Needed the GLES/sRGB swapchain fallback.
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
details: Also available as a native PC SteamVR title.
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: 158f7c4f5e8558029dc8cc5e8dcb4e67e846b261bf34e3e448fee7f657c74597
updated: '2026-10-15'
+20
View File
@@ -0,0 +1,20 @@
package: com.CreaTeam.Sclerosis
title: Sclerosis
status: works
notes: Copy the "Amnesia The Dark Descent" folder from a Windows/Proton install to "FramePort/com.CreaTeam.Sclerosis/lepton-data/external".
Default hand positions are rather askew and should be amended via VR Controller Mappings in Steam itself. FOV
warping effects are EXTREMELY disorienting in VR, beware. Otherwise seems to work without issue.
tested_version: 1.10.7
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-10-16'
known_good_sha256: e17421ad9894f5e57fb9b1b8110544715166756ef924b5a86831f6f20e70a6d5
app: 1.2.0
overport_cli: 1.2.5
issue: 339
source_hint: Sclerosis_v1.10.7
@@ -0,0 +1,24 @@
package: com.Curve.LawnMowingSimulatorVR
title: Lawn Mowing Simulator VR
status: issues
notes: Left eye textures flicker only when mowing in specific areas.
tested_version: 1.0.50
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
adapter:
haptic_fix: 1
pose_consistency: 1
hide_space_warp: 1
verified:
date: '2026-10-16'
known_good_sha256: 2653e9b1dfbd11d607b5dd17b9c07ab19d9e98e1004ef9b14cb52867b5f959dc
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 344
source_hint: com.Curve.LawnMowingSimulatorVR
@@ -0,0 +1,10 @@
package: com.FlatHeadStudio.DeadlyDelivery
title: Deadly Delivery
status: unsupported
notes: "Quits after Meta's entitlement check fails: it needs Meta's online services."
tested_version: 1.0.30
engine: Unity
xr: OpenXR
verified:
date: '2026-10-15'
issue: 322
@@ -6,8 +6,14 @@ engine: Unity
xr: OpenXR
status: works
notes: Recommended pilot title.
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: 2300f628ce04c1da60b52fed18335d6a31b88cc88313fd6d2c0e1a8e7785126f
updated: '2026-10-15'
@@ -0,0 +1,22 @@
package: com.HypetrainDigitalLtd.Breathedge
title: Breathedge VR
status: works
notes: Loading fade is broken and only shows a small square but the game works!
tested_version: 1.0.7
engine: Unreal
xr: VrApi
alt_overport:
- patch_remove_unreal_force_quit
frame:
- frame.hw_video_decode
adapter:
haptic_fix: 1
verified:
date: '2026-10-16'
known_good_sha256: 4975a32fbf36b4d417142a944bf7d771c18a184dc0320b5937ac2c67bd013575
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 347
source_hint: Breathadge
@@ -5,9 +5,14 @@ source_hint: 'Sniper Elite VR- Winter Warrior'
engine: Unity
xr: OpenXR
status: works
notes: ''
notes: frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: 6b8170da5826b9a35326a20967a0e5a242d46fb569a0a3c669e1cbd368102dd3
updated: '2026-10-15'
@@ -0,0 +1,28 @@
package: com.Miragesoft.RealVrFishingQ
title: Real VR Fishing
status: issues
notes: Seems to be almost 100% functional with meta permissions enabled. In the new Mixed Reality mode, the room
scan doesn't seem to do anything and I can't seem to manually place anything either.
tested_version: '3.06096'
engine: Unity
xr: OpenXR
frame:
- frame.vivox_audio_route
- frame.unity_text_input
- frame.oculusos
- frame.meta_permissions
device:
- device.text_input_window
adapter:
haptic_fix: 1
scene_emul: 1
controller_models: 1
verified:
date: '2026-10-16'
known_good_sha256: 8b17dd26ef0486618ec006f9d948a60dabea4d7763bbe6061db07ee8aad27c76
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 91
issue: 333
source_hint: Real VR Fishing
+5
View File
@@ -6,11 +6,14 @@ engine: Unity
xr: OpenXR
status: works
notes: Passthrough + Meta scene/room emulated from the Steam guardian (scene_emul=1).
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
details: Mixed-reality-only; builds its level from the room model, which the adapter emulates from the Steam guardian.
overport_extra:
- patch_force_passthrough
adapter:
scene_emul: 1
device:
- device.text_input_window
verified:
date: '2026-09-28'
overport_cli: 1.2.3
@@ -18,3 +21,5 @@ verified:
known_good_sha256: 9f900fc86fbb2663abe83c82ab79cc72bbf1f93fea54f7583fd274dd07e3d09c
frame:
- frame.meta_permissions
- frame.unity_text_input
updated: '2026-10-15'
@@ -0,0 +1,22 @@
package: com.SchellGames.LostRecipes
title: Lost Recipes
status: works
tested_version: 1.0.1+273175
engine: Unreal
xr: VrApi
alt_overport:
- patch_remove_unreal_force_quit
frame:
- frame.unreal_skip_obb_check
- frame.hw_video_decode
adapter:
haptic_fix: 1
verified:
date: '2026-10-16'
known_good_sha256: f071a1deea3e3b6dce0c3a923127f540a48b88532106536c1838f496e9d3e6c9
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 340
source_hint: Lost Recipes
@@ -6,12 +6,18 @@ engine: Unity
xr: OpenXR
status: issues
notes: Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky.
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
details: Hand-tracking-only game; Steam Frame has no optical hand tracking. Built with controller_fix=0 so hand
data passes through if the runtime ever provides it.
adapter:
controller_fix: 0
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: a19b80db0c300d422cbba35a4924ab59211b39bcb6485e200d310e3e1372ddb7
updated: '2026-10-15'
+12 -10
View File
@@ -1,22 +1,24 @@
package: com.UNIVRS.Freedom
title: Freedom
status: issues
notes: Volume can't be changed in the game.
title: 'Attack on Titan VR: Unbreakable'
status: works
notes: Keep the extra files that come with the APK and OBB next to the OBB (they hold the audio). Cutscenes are
detached from the head but still viewable.
tested_version: 1.0.2950
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
- frame.asset_files
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-10'
known_good_sha256: 24662272c4cbe5dc7c4f3c803bdb9dead9661ac3d59bf80cca2e260256acc4bc
app: 0.12.0
date: '2026-10-16'
known_good_sha256: 1b3ced1a24b3b708f66aba6aa879863b667c0e502ef153d8a8cfa03e529d21fd
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 59
issue: 132
source_hint: AOT VR
frame_build: '20261015.6141207'
agent: 91
issue: 337
source_hint: Aot
+6 -1
View File
@@ -7,10 +7,15 @@ xr: OpenXR
status: works
notes: FrameBridge turns its upside-down UI panels upright (flip emulation, in place since FramePort 1.2.0). Ubisoft
login may not work.
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
details: Ubisoft online login may not work. Data folder holds raw assets (start0..2, .climb, bundles), not .obb
files; install to obb as-is.
overport_extra:
- patch_ac_nexus_no_appsw_90
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-09-28'
overport_cli: 1.2.3
@@ -18,5 +23,5 @@ verified:
known_good_sha256: 89644a12bf8df956fa3d3a9a40540ba9e1fb62f63494714a05c9f05984383ac8
no_appsw_90: '2026-10-02, OVRPort 1.2.5 (owner: 90 Hz felt better than 72 Hz; same known issues)'
flip_in_place: '2026-10-14, owner in the headset: launch warnings and tutorial pop-ups upright (GitHub #138)'
updated: '2026-10-14'
updated: '2026-10-15'
min_app: 1.2.0
+1 -1
View File
@@ -1,5 +1,5 @@
package: com.asg.clockworkdev
title: Clockwork
title: The Last Clockwinder
status: works
tested_version: '1.29'
engine: Unity
+5 -1
View File
@@ -2,6 +2,7 @@ package: com.camouflaj.manta
title: 'Batman: Arkham Shadow'
status: works
notes: Large data set (~27 GiB) incl. all language packs.
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
details: 'Uses Meta XR Audio (Wwise), handled by patch_meta_xr_audio. Cutscenes are 8K HEVC panoramas: frame.hw_video_decode
decodes them on the Frame''s hardware, adapter.surface_native shows them in stereo (PRs #96, #128). Includes all
language packs (~28 GB). sync_guard (to test, GitHub #102) - a reporter''s game quit to Steam''s waiting screen
@@ -13,15 +14,18 @@ overport_extra:
- patch_disable_space_warp
frame:
- frame.hw_video_decode
- frame.unity_text_input
adapter:
sync_guard: 1
surface_native: 1
fade_quad: 1
device:
- device.text_input_window
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: 796d1cab08f614d72c50d9702974c719c0908f5de91183cfe02d1fd5eb09551a
updated: '2026-10-13'
updated: '2026-10-15'
min_app: 1.1.0
source_hint: Batman- Arkham Shadow
@@ -6,9 +6,15 @@ engine: Unity
xr: OpenXR
status: works
notes: Co-op unavailable.
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
details: 'Release notes: for DLC use an older version. Co-op likely unavailable.'
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: 06428a6328a49b3ca769e070dd0d2bb55c2122c8fba5d07a69b1b206a532c212
updated: '2026-10-15'
+21
View File
@@ -0,0 +1,21 @@
package: com.crytek.climb
title: The Climb
status: works
tested_version: '666.0'
engine: CryEngine
xr: VrApi
frame:
- frame.vrapi_bridge
device_files:
user.cfg: 'r_vr_foveated = 0
'
verified:
date: '2026-10-16'
known_good_sha256: 3899967dd6d3ee0ce3409e94d543405c779e9c282ead29536abe93b3531ca285
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 341
source_hint: TheClimb
@@ -0,0 +1,22 @@
package: com.fasttravelgames.mascot
title: 'Cities: VR'
status: works
notes: Loading screen Cities VR Logo doesn't load right.
tested_version: 1.00.502
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-16'
known_good_sha256: 5537e4e9333a04eda8c1b2f96cdc9572c1bbed52a9d3af42269ba442ce9ba801
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 345
source_hint: CitiesSkylines
@@ -6,11 +6,17 @@ engine: Unity
xr: OpenXR
status: works
notes: Passthrough emulated with the Frame cameras (greyscale).
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
details: Mixed-reality-only title; relies on passthrough (emulated).
overport_extra:
- patch_force_passthrough
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: 65ca3a8f46cc9acfd268949418f9adeecb0cf9f59d11591ba95fe2ba9ea966d8
updated: '2026-10-15'
+8 -1
View File
@@ -2,10 +2,17 @@ package: com.nexxr.nexplayer
title: NEX Player
status: works
notes: 'Confirmed in the headset by the owner (2026-10-01): 72 fps, local 2D/360/3D videos play (it browses /sdcard/Movies
itself). USB-drive browsing can''t work in Lepton.'
itself). USB-drive browsing can''t work in Lepton. Typing (frame.unity_text_input + device.text_input_window,
owner in the headset 2026-10-15): SteamVR''s keyboard button works and typed text reaches the fields; the keyboard
doesn''t open by itself when a field is selected.'
tested_version: 1.3.1.0
frame:
- frame.unity_text_input
device:
- device.text_input_window
engine: Unity
xr: OpenXR
verified:
date: '2026-10-01'
source_hint: NEX - NEXt-Gen Video Player
updated: '2026-10-15'
@@ -0,0 +1,22 @@
package: com.s3d.ExplodingKittensVR.TestRelease
title: Exploding Kittens VR
status: issues
notes: Requires online for most modes. Practice lobby does work though.
tested_version: 1.4.25779
engine: Unreal
xr: OpenXR
alt_overport:
- patch_remove_unreal_force_quit
frame:
- frame.hw_video_decode
adapter:
haptic_fix: 1
verified:
date: '2026-10-16'
known_good_sha256: 4b0a2348a5553851dcec820835e02c93a1a64927a30533d554943a9b81b3baa0
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 343
source_hint: com.s3d.ExplodingKittensVR.TestRelease
@@ -6,8 +6,14 @@ engine: Unity
xr: OpenXR
status: works
notes: Layers with failed swapchains are dropped by the adapter.
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: c3f8cba1c22762231b6a6fbd881668db859d83224884ebeae0c1a3ba716f298c
updated: '2026-10-15'
@@ -6,9 +6,15 @@ engine: Unity
xr: OpenXR
status: works
notes: Use VR mode.
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
details: Has VR and MR modes; use VR mode (passthrough not forced).
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: 5ba09a2bc0046c8cb587272ab81e8d32739334fef523b10ab26531a733d54f12
updated: '2026-10-15'
+24
View File
@@ -0,0 +1,24 @@
package: jp.co.hit_point.vneko
title: Neko Atsume Purrfect
status: works
tested_version: 2.3.0
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
- frame.oculusos
device:
- device.text_input_window
adapter:
controller_models: 1
haptic_fix: 1
scene_emul: 1
verified:
date: '2026-10-16'
known_good_sha256: da0196efa495d6ea7b34be2cea5b279acf23489134bb1dd1acef7cf54aa57da6
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 338
source_hint: jp.co.hit_point.vneko
+18
View File
@@ -0,0 +1,18 @@
package: org.crisiszone.vr
title: Crisis Zone VR
status: works
notes: Played the beginning of the first mission without issues.
tested_version: 0.1.4
engine: Other
xr: OpenXR
frame:
- frame.sdl_clipboard
verified:
date: '2026-10-15'
known_good_sha256: 747d1e6613ae443ef03e509b509e4ec89039eec1aecf3689643db4057e53266a
app: 1.2.1.dev433
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 91
issue: 331
source_hint: CrisisZoneVR-v0.1.4-quest3
@@ -5,9 +5,14 @@ source_hint: Toy Master
engine: Unity
xr: OpenXR
status: works
notes: ''
notes: frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: e951595c67b6e448f3fb4afd07e6b83e98e589094bb3b73374cfb28aa01e96ee
updated: '2026-10-15'
@@ -7,6 +7,9 @@ xr: OpenXR
status: works
notes: Needs Oculus OS telemetry class stubs and the no-ForceQuit build (the normal build quits right after
starting).
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
device:
- device.text_input_window
verified:
date: '2026-10-02' # owner-confirmed with the no-ForceQuit build
overport_cli: 1.2.3
@@ -14,6 +17,8 @@ verified:
known_good_sha256: 31469d50b8ee292597291db7cc3bc3eca8e851f533cd92bba0d64214893580cc
frame:
- frame.oculusos
- frame.unity_text_input
alt_overport:
- patch_remove_unreal_force_quit
use_alt: true
updated: '2026-10-15'
+6 -6
View File
@@ -1,9 +1,9 @@
package: xyz.TheAviary.Vermillion
title: Vermillion
status: issues
notes: Runs. The canvas used to hide the hand and brush while painting (the Frame draws the game's canvas layer in
front of everything); the recipe makes the game draw canvases in the scene instead (fix found by Klownicle, GitHub
#200). Not yet confirmed in the headset with this patch.
status: works
notes: "The canvas used to hide the hand and brush while painting (the Frame draws the game's canvas layer in front
of everything); the recipe makes the game draw canvases in the scene instead (fix found by Klownicle, GitHub #200).
Confirmed in the headset by Klownicle."
tested_version: 1.7.2
engine: Unity
xr: OpenXR
@@ -16,10 +16,10 @@ adapter:
haptic_fix: 1
scene_emul: 1
verified:
date: '2026-10-11'
date: '2026-10-15'
app: 1.0.1
frame_build: '20261007.6125817'
agent: 79
issue: 200
updated: '2026-10-12'
updated: '2026-10-15'
source_hint: Vermillion - VR Painting
+6 -1
View File
@@ -5,9 +5,14 @@ source_hint: H.U.N.T
engine: Unity
xr: OpenXR
status: works
notes: ''
notes: frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: c35373d0f7c870ade148e3259abcc74098b6bc89fe2c2f3a2e55168df642e391
updated: '2026-10-15'
+20
View File
@@ -11,6 +11,16 @@ signatures:
severity: error
diagnosis: A DLC manager reports a Meta asset-file status error after VR starts (e.g. PixelRipped 1995). The asset-file check failed; the log does not identify the native call or asset ID. Try the experimental asset-file bypass (empty list, installed status with no file path); this does not supply DLC and has not been verified in the game.
suggest: [frame.asset_files_stub]
- id: meta-entitlement
# Quest games (the pcvr oculus-entitlement signature covers Rift games). Only explicit failure results: a bare
# ovr_PlatformInitialize error isn't matched. `error`, not fatal: offline games may log it and play on; an online
# game that then quits (e.g. Deadly Delivery, GitHub #322) fails the test through its exit.
pattern: '(?i)entitlement check failed|\bnot entitled to (use|play)|ovrPlatformInitialize_(NotEntitled|UnableToVerify|FileInvalid|SignatureInvalid)'
severity: error
diagnosis: The game's Meta licence check (entitlement) failed. It needs Meta's online login and licence check,
which the Frame doesn't have; games that depend on it (often online games) quit or stay offline. FramePort
doesn't get around such checks.
suggest: []
- id: lua-lightuserdata-pointer
pattern: 'bad light userdata pointer'
severity: fatal
@@ -233,6 +243,16 @@ signatures:
severity: error
diagnosis: The screen stays black because the game submits a layer the VrApi bridge can't show. Whole frames with that VrApi layer type are dropped.
suggest: []
- id: unreal-reserve-refused
# GitHub #349: Unreal's out-of-memory handler dereferences its not yet created log while libUnreal.so's static
# constructors run (System.loadLibrary), after the Binned3 allocator's up-front reservation failed
pattern: '(?s)fault addr 0x10\b(?:(?!Fatal signal).){0,8000}?#00 pc [0-9a-f]+\s+\S*lib(?:Unreal|UE4)\.so(?:(?!Fatal signal).){0,8000}?soinfo17call_constructors'
severity: fatal
diagnosis: The game crashed while Unreal was still loading, before its first frame. Its memory allocator (Binned3)
reserves tens of gigabytes of address space at once, which Quest allows and the Frame's kernel refuses in one
piece (for example Transformers Beyond Reality). Rebuild with the reservation marked as not committed.
suggest: [frame.unreal_noreserve]
supersedes: [native-crash]
- id: graphics-requirements-missing
pattern: '(XR_ERROR_GRAPHICS_REQUIREMENTS_CALL_MISSING|Failed to create XR session: -50\b|xrCreateSession[^\n]*-50\b)'
severity: fatal
+32 -5
View File
@@ -13,11 +13,14 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Arkio | Quest | ✅ Works | |
| Asgard's Wrath 2 | Quest | ✅ Works | |
| Assassin's Creed Nexus | Quest | ✅ Works | |
| Attack on Titan VR: Unbreakable | Quest | ✅ Works | |
| Audica | Quest | ✅ Works | |
| AugmentedEmpire | Quest | ✅ Works | |
| BAM | Quest | ✅ Works | |
| BARTENDER VR SIMULATOR | Quest | ✅ Works | |
| Batman: Arkham Shadow | Quest | ✅ Works | |
| BattleGlide | Quest | ✅ Works | |
| Battlemarked | Quest | ✅ Works | |
| BattleSisters | Quest | ✅ Works | |
| Beat Saber | Quest | ✅ Works | |
| Beat Saber | Quest | ✅ Works | |
@@ -27,16 +30,19 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| BlazeRush | Quest | ✅ Works | |
| BodyCombat | Quest | ✅ Works | |
| BONELAB | Quest | ✅ Works | |
| Breathedge VR | Quest | ✅ Works | |
| Carve Snowboarding | Quest | ✅ Works | |
| Caves | Quest | ✅ Works | |
| Clockwork | Quest | ✅ Works | |
| Cities: VR | Quest | ✅ Works | |
| COMPOUND | Quest | ✅ Works | |
| Cook-Out | Quest | ✅ Works | |
| Cosmodread | Quest | ✅ Works | |
| Creed | Quest | ✅ Works | |
| Crisis Zone VR | Quest | ✅ Works | |
| cubism | Quest | ✅ Works | |
| Dance Central | Quest | ✅ Works | |
| Deep Cuts | Quest | ✅ Works | |
| Demeo | Quest | ✅ Works | |
| Demeter | Quest | ✅ Works | |
| Dinosaur Island | Quest | ✅ Works | |
| Doom3Quest | Quest | ✅ Works | |
@@ -46,10 +52,12 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Eleven Table Tennis | Quest | ✅ Works | |
| Eleven: Table Tennis VR | PC VR | ✅ Works | |
| Espire 2 | Quest | ✅ Works | |
| Eye of the Temple | Quest | ✅ Works | |
| FrenzyVR | Quest | ✅ Works | |
| Fruit Ninja | Quest | ✅ Works | |
| Fruit Salon | Quest | ✅ Works | |
| Genotype | Quest | ✅ Works | |
| Ghost Town | Quest | ✅ Works | |
| Ghostbusters: Rise of the Ghost Lord | Quest | ✅ Works | |
| GORN2 | Quest | ✅ Works | |
| Gravity Lab | Quest | ✅ Works | |
@@ -61,25 +69,33 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| In Death: Unchained | Quest | ✅ Works | |
| Into The Radius 2 | Quest | ✅ Works | |
| Job Simulator | Quest | ✅ Works | |
| Journey of the Gods | Quest | ✅ Works | |
| Jurassic World Aftermath Collection | Quest | ✅ Works | |
| Keep Talking and Nobody Explodes | Quest | ✅ Works | |
| Kill It With Fire VR | Quest | ✅ Works | |
| Ladder | Quest | ✅ Works | |
| Lambda1VR | Quest | ✅ Works | |
| LEGO® Bricktales | Quest | ✅ Works | |
| Lost Recipes | Quest | ✅ Works | |
| Lucky's Tale | Quest | ✅ Works | |
| Maestro | Quest | ✅ Works | |
| Marvel's Deadpool VR | Quest | ✅ Works | |
| Marvel's Iron Man VR | Quest | ✅ Works | |
| Max Mustard | Quest | ✅ Works | |
| Medieval Dynasty New Settlement | Quest | ✅ Works | |
| Metro Awakening | Quest | ✅ Works | |
| Mini Motor Racing X | Quest | ✅ Works | |
| Mobile Suit Gundam: Silver Phantom | Quest | ✅ Works | |
| Moss | Quest | ✅ Works | |
| Moss: Book II | Quest | ✅ Works | |
| Munkr Funkr (The Legacy Days) | Quest | ✅ Works | |
| Nano | Quest | ✅ Works | |
| Neko Atsume Purrfect | Quest | ✅ Works | |
| NEX Player | Quest | ✅ Works | |
| NFL PRO ERA II | Quest | ✅ Works | |
| NOPE CHALLENGE | Quest | ✅ Works | |
| Ocean Rift | Quest | ✅ Works | |
| Operation Wolf Returns: First Mission VR | Quest | ✅ Works | |
| palazzo_santacruz | Quest | ✅ Works | |
| Path of the Warrior | Quest | ✅ Works | |
| Pistol Whip | Quest | ✅ Works | |
@@ -97,9 +113,12 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Riven | Quest | ✅ Works | |
| Robo Recall | Quest | ✅ Works | |
| RUINSMAGUS | Quest | ✅ Works | |
| Sclerosis | Quest | ✅ Works | |
| Shadow Point | Quest | ✅ Works | |
| Shoot The Zombirds VR | Quest | ✅ Works | |
| Shores of Loci | Quest | ✅ Works | |
| SilentSlayer | Quest | ✅ Works | |
| Smash Drums | Quest | ✅ Works | |
| Sniper Elite VR | Quest | ✅ Works | |
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
| Space Pirate Trainer Quest | Quest | ✅ Works | |
@@ -112,9 +131,12 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| SUPERHOT VR | Quest | ✅ Works | |
| SynthRiders | Quest | ✅ Works | |
| Taskmaster | Quest | ✅ Works | |
| Tea For God | Quest | ✅ Works | |
| TetrisEffect | Quest | ✅ Works | |
| The Boys VR | Quest | ✅ Works | |
| The Climb | Quest | ✅ Works | |
| The Climb 2 | Quest | ✅ Works | |
| The Last Clockwinder | Quest | ✅ Works | |
| The Room VR | Quest | ✅ Works | |
| The Tale of Onogoro | Quest | ✅ Works | |
| Time Crisis II VR (Test) | Quest | ✅ Works | |
@@ -128,6 +150,8 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Vader Immortal: Episode II | Quest | ✅ Works | |
| Vader Immortal: Episode III | Quest | ✅ Works | |
| Vampire Survivors | Quest | ✅ Works | |
| Vendetta Forever | Quest | ✅ Works | |
| Vermillion | Quest | ✅ Works | |
| VR HOT Quest | Quest | ✅ Works | |
| VR4 | Quest | ✅ Works | |
| Walkabout Mini Golf | Quest | ✅ Works | |
@@ -141,26 +165,29 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Does it Stack? | Quest | ⚠️ Works with issues | Mixed reality mode does not work (works on Demeo), everything else seems to be OK |
| Dungeons of Eternity | Quest | ⚠️ Works with issues | It can not connect to servers |
| End Space | Quest | ⚠️ Works with issues | Controls stop responding after the first mission loads. |
| Exploding Kittens VR | Quest | ⚠️ Works with issues | Requires online for most modes. |
| ExploreVR | Quest | ⚠️ Works with issues | The 3D world warps and shifts with head movement (wrong perspective). |
| Fisherman | Quest | ⚠️ Works with issues | Loading screens have corruption, but everything works otherwise. |
| Freedom | Quest | ⚠️ Works with issues | Volume can't be changed in the game. |
| Homeworld: Vast Reaches | Quest | ⚠️ Works with issues | The game starts but after selecting to start a new game it seems to crash. |
| Lawn Mowing Simulator VR | Quest | ⚠️ Works with issues | Left eye textures flicker only when mowing in specific areas. |
| MacacoVR | Quest | ⚠️ Works with issues | 2d loading screens are glitchy, otherwise main gameplay is fine |
| Majora's Mask VR | Quest | ⚠️ Works with issues | Audio on the file select screen sounds crunchy. |
| Myst | Quest | ⚠️ Works with issues | Minor graphical glitches on some objects. |
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
| Pinball FX VR | Quest | ⚠️ Works with issues | Plays; mixed reality mode not working yet. |
| Plezy | Quest | ⚠️ Works with issues | Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding. |
| Quantum Void | Quest | ⚠️ Works with issues | no hands |
| Ragnarock | Quest | ⚠️ Works with issues | |
| Real VR Fishing | Quest | ⚠️ Works with issues | Seems to be almost 100% functional with meta permissions enabled. |
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
| Spunky Dungeon | Quest | ⚠️ Works with issues | Gear VR / Oculus Go game (32-bit only), runs through FramePort's 32-bit bridge (experimental). |
| Swarm 2 | Quest | ⚠️ Works with issues | MSAA creates visible seams around edge geometry and also the skybox edge |
| The Light Brigade | Quest | ⚠️ Works with issues | Judder in weapons |
| The Thrill Of The Fight | Quest | ⚠️ Works with issues | Right eye doesn't draw the opponent or the referee. |
| Time Stall | Quest | ⚠️ Works with issues | Both eyes distort during movement (unresolved). |
| Vermillion | Quest | ⚠️ Works with issues | Runs. |
| WiiCompiled VR | Quest | ⚠️ Works with issues | To add a game: in FramePort's Files tab, upload your .wcgame file to this game's storage, folder Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR… |
| Deadly Delivery | Quest | ❌ Doesn't run | Quits after Meta's entitlement check fails: it needs Meta's online services. |
| Espire 1: VR Operative (Quest Edition) | Quest | ❌ Doesn't run | Mesa GL driver crash during texture upload. |
| GOLF+ | Quest | ❌ Doesn't run | Black screen. |
| HITMAN 3 VR: Reloaded | Quest | ❌ Doesn't run | Vulkan driver crash (freedreno), even without Valve layers. |
| Journey of the Gods | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
| Roblox | Quest | ❌ Doesn't run | Crashes on its first VR frame on the Frame. |
| Shadow Point | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
+83
View File
@@ -0,0 +1,83 @@
# Evaluating patch heuristics
`scripts/eval_heuristics.py` compares `engine.suggest(analysis, use_catalog=False)` with the bundled
`catalog/games/*.yaml` entries. It does not merge personal or remote catalog recipes into the benchmark.
It supports the original dumps command, or a precomputed `scripts/heuristics_snapshot.py` snapshot:
```sh
.venv/bin/python scripts/eval_heuristics.py '<dumps folder>' --verbose
.venv/bin/python scripts/eval_heuristics.py --snapshot analyses.json --split all --json baseline.json
.venv/bin/python scripts/eval_heuristics.py --snapshot analyses.json --fit-report --out fit-report.md
```
Use a scratch `FRAMEPORT_HOME`, `FRAMEPORT_NO_CATALOG_UPDATE=1`, and an empty `FRAMEPORT_WINDOWS_DATA` for offline
measurement. Snapshot mode reads no APKs or game data. Each snapshot read copies the source to a temporary file,
then parses the copy, retrying incomplete JSON. To compare multiple splits against an actively growing snapshot,
freeze one valid copy first and use that file for every run.
The default split is `dev`. `split_packages({package: analysis_engine})` sorts packages by SHA-256 within Unity,
Unreal, and Other strata, then holds out the nearest 20% (half rounded up). Pass only packages with both an analysis
and a catalog entry. The same input set always yields the same split regardless of iteration order; adding packages
can move the cutoff. Very small strata can have no holdout. Unsupported entries participate in splitting.
## What is scored
Ground truth contains catalog choices and patch registry defaults, with no inherited heuristic adapter settings.
Default expectations are determined per analysis, using each default patch's own detection in the same way as the
engine's initial heuristic pass. Thus a conditional default that is not detected (e.g. LTW on a game without LTW)
is not expected. Explicit catalog `overport_remove` and `frame_remove` entries remove expected defaults. An absent
expected default is represented as `remove:<patch id>`; enabled defaults do not inflate patch scores.
Opt-in overport/frame/device/PC VR IDs, default removals, adapter values, device-file names, foveation parameters,
and alternate-build patch IDs are compared. Alternate-build identities use `alt:<patch id>` so choosing one in a
fallback is distinct from choosing it in the primary build. Device-file content is not compared. Adapter values
must match: a wrong value contributes both an FP and an FN for `adapter.<key>`. Patch precision, recall and F1 are
micro-averaged across games; an empty denominator is reported as zero. Exact-game accuracy also compares the
unsupported status flag. Fields report matching games, including empty-field matches.
`RUNTIME_ONLY` lists choices excluded from the headline: `use_alt`, selected runtime adapter settings, Lepton
environment variables, and config-sync settings. They have their own exact, field and patch metrics. Catalog
entries with status `unsupported` are excluded from both headline and runtime supported-game metrics, and have
separate metrics and per-game diffs. Converted analyses are included and marked. JSON contains all metrics per
engine, all game signatures, split assignments, and missing/extra decisions.
## Dated score: catalog gaps vs. detection errors
The strict score treats the catalog as complete, but older entries were verified before many patches existed, and
a catalog recipe inherits every heuristic adapter setting and device patch (except `device.text_input_window`)
without listing them (`engine.suggest`), so the verified build had those. The dated score (`headline_dated`, printed
under "Dated") sets aside, on both sides:
- patches first committed after the entry's `verified.date` (the verifier couldn't have chosen them), and
- inherited adapter/device picks (`inherited_decisions`).
What remains are real disagreements: an extra on an entry verified after the patch existed, or a catalog choice
the heuristics don't make. Gaps are counted per patch ("Catalog gaps by patch"). The dated score needs the intro
date cache that `--fit-report` writes; without it only inherited picks are set aside.
Before tuning a rule to remove an "extra", check which kind it is: a gap on an old entry is a candidate for the
catalog (fit report), not a detection error. Before adding a patch to a working entry, weigh its benefit (does it
fix something the game hits?) against a rebuild prompt for everyone who installed it; adapter settings never need
adding (inherited). Results 2026-10-15: strict 5/65 exact, F1 73.8%; dated 39/65, F1 88.6% (dev 90.5%, holdout
79.5%). Remaining misses are one-game fixes found at runtime (e.g. Vader's gl_error_clear, BlazeRush's GL settings).
An evaluation writes JSON even when its headline has differences, then exits 1 for compatibility with the original
script. It exits 0 when exact, or after producing a fit report.
## New patch fit
The fit report covers all catalog entries, independently of `--split`. Candidates come from the final heuristic
recipe, preserving platform, flat-app, dependency, and 32-bit normalization rules. A candidate must also be opt-in,
applicable and recommended by its own `detect()`. Entries already listing or explicitly removing the patch are
excluded; a listed adapter key is excluded regardless of its value. This report proposes additions, not value edits.
Each candidate includes its reason, experimental/strict flags, first local git introduction date, catalog entry
date (`verified.date`; `updated` only without one, since it also moves for unrelated edits), and whether the
patch is newer. Unknown dates are marked unknown. Warnings
come from the full catalog recipe plus that one candidate; they may include pre-existing conflicts. Missing-analysis
entries appear as metadata-only rows. Intro dates, including unknown results, are cached in
`patch-intro-dates.json` beside the snapshot (or dumps folder); subsequent runs only query uncached patch IDs.
`tests/test_eval_heuristics.py` uses synthetic data. The opt-in real-dump test in `tests/test_games.py` ratchets the
supported all-split exact ratio and patch F1 against measured, rounded-down baseline constants. Machine-local
`.eval-home/` and `.eval-results/` scratch outputs are ignored by git.
+5
View File
@@ -167,6 +167,10 @@ FramePort downloads it, checks it, restarts and keeps your games and settings. *
- Settings → **Updates** turns the check off or turns on **Install updates automatically**.
- **Recipes** (the tested patches and settings for each game) update by themselves. A game whose recipe changed shows
**Update on Frame**.
- **A newer version of a game:** right-click it (or use **…** on its page) → **Update with a newer APK…** (Quest and
Android games) or **Update with a newer version…** (Linux apps: the new AppImage, archive or folder; PC VR games:
the new game folder). Its name, artwork, tags and your settings stay. Then click **Update on Frame** (or **on this
PC**).
- **Dev builds:** if you're asked to test a change before its release, use Settings → Updates → **Install the latest
dev build…**. The next release then arrives as a normal update.
@@ -255,6 +259,7 @@ option. The main ones:
|---|---|
| `scan <folder>` / `list` / `show <game>` | add games, list the library, show a game's patches |
| `add-linux <path> [--exe <program>]` | add a Linux app (AppImage, folder or archive) |
| `new-version <game> <path>` | point a game at its newer version (APK, Linux app file/folder, PC VR game folder) |
| `recipe <game> --enable/--disable <patch>` | change a game's patches (`patches` lists them all) |
| `build <game>` / `install <game>` / `test <game>` | build, install on the Frame (`--to pc` for this PC), launch test |
| `frame discover` / `frame connect` / `frame info` | find, connect to and describe the Frame |
+53 -38
View File
@@ -2,7 +2,7 @@
# Patches
Every patch and game setting FramePort knows: 169 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
Every patch and game setting FramePort knows: 170 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
Each entry says what the patch does, whether it changes the APK or only the install, when FramePort suggests it, its options, the catalog games whose recipe names it and the launch-test signatures that suggest it. Patches that are on by default are on for every game, so the catalog rarely names them.
@@ -11,7 +11,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
## Groups
- [Patches from OVRPort](#group-overport) (25)
- [Steam Frame patches](#group-frame) (55)
- [Steam Frame patches](#group-frame) (56)
- [Game settings](#group-adapter) (68)
- [Files and settings on the Frame](#group-device) (8)
- [PC VR (Proton and Revive)](#group-pcvr) (13)
@@ -39,7 +39,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Patch Oculus detection for Unity](#patch_oculus_unity) | `patch_oculus_unity` | Patches from OVRPort | default |
| [Patch Oculus detection for Unreal](#patch_oculus_unreal) | `patch_oculus_unreal` | Patches from OVRPort | default |
| [Remove localized app names](#patch_remove_localized_names) | `patch_remove_localized_names` | Patches from OVRPort | default |
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 38 |
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 41 |
| [Remove uses-library](#patch_remove_uses_library) | `patch_remove_uses_library` | Patches from OVRPort | default |
| [Remove VrApi library](#patch_remove_vrapi) | `patch_remove_vrapi` | Patches from OVRPort | |
| [Replace application label and icon](#patch_replace_icon_label) | `patch_replace_icon_label` | Patches from OVRPort | default |
@@ -47,7 +47,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [VrApi → OpenXR adapter (OVRPort)](#patch_vrapi_openxr) | `patch_vrapi_openxr` | Patches from OVRPort | |
| [32-bit ARM bridge (Gear VR / Go games)](#frame-a32_bridge) | `frame.a32_bridge` | Steam Frame patches | 6 |
| [FrameBridge OpenXR adapter](#frame-adapter) | `frame.adapter` | Steam Frame patches | default |
| [Content files from the game's data](#frame-asset_files) | `frame.asset_files` | Steam Frame patches | 1 |
| [Content files from the game's data](#frame-asset_files) | `frame.asset_files` | Steam Frame patches | 2 |
| [Asset-file service bypass](#frame-asset_files_stub) | `frame.asset_files_stub` | Steam Frame patches | |
| [Skip Meta avatars](#frame-avatar_stub) | `frame.avatar_stub` | Steam Frame patches | 1 |
| [bHaptics: skip the Bluetooth permission request](#frame-bhaptics_permission) | `frame.bhaptics_permission` | Steam Frame patches | 5 |
@@ -56,22 +56,22 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [OpenGL ES: multiview shaders on flat panels (HUD, menus)](#frame-gl_multiview_fbo) | `frame.gl_multiview_fbo` | Steam Frame patches | 1 |
| [GL shim (Mesa GLSL compatibility)](#frame-gl_shim) | `frame.gl_shim` | Steam Frame patches | 2 |
| [Godot apps: start without a clipboard service](#frame-godot_clipboard) | `frame.godot_clipboard` | Steam Frame patches | |
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 19 |
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 22 |
| [Language packs from the game's files](#frame-langpacks) | `frame.langpacks` | Steam Frame patches | |
| [Launcher category (INFO → LAUNCHER)](#frame-launcher) | `frame.launcher` | Steam Frame patches | default |
| [GL wrapper: OpenGL ES depth formats (Minecraft)](#frame-ltw_depth) | `frame.ltw_depth` | Steam Frame patches | default |
| [Fix Lua startup crashes on Frame](#frame-luajit_pointers) | `frame.luajit_pointers` | Steam Frame patches | |
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 2 |
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 3 |
| [Meta XR Audio: skip Quest telemetry class](#frame-metaxr_telemetry) | `frame.metaxr_telemetry` | Steam Frame patches | default |
| [Clear android:debuggable](#frame-nodebug) | `frame.nodebug` | Steam Frame patches | 3 |
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 15 |
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 17 |
| [Fix crash when the game prepares the microphone](#frame-ovr_microphone) | `frame.ovr_microphone` | Steam Frame patches | |
| [Keep the same Meta user between starts](#frame-ovr_stable_user) | `frame.ovr_stable_user` | Steam Frame patches | default |
| [Trace Meta platform requests (diagnostics)](#frame-ovr_trace) | `frame.ovr_trace` | Steam Frame patches | |
| [Platform compat (ovrMessageType_ToString)](#frame-ovrplatformcompat) | `frame.ovrplatformcompat` | Steam Frame patches | default |
| [Stub missing Meta platform functions](#frame-ovrstubs) | `frame.ovrstubs` | Steam Frame patches | default |
| [Unpaired pointer-authentication checks](#frame-pac_hints) | `frame.pac_hints` | Steam Frame patches | 1 |
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 4 |
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 5 |
| [Stress Level Zero: let Unity start Vulkan itself](#frame-slz_vulkan_hooks) | `frame.slz_vulkan_hooks` | Steam Frame patches | 1 |
| [Start straight in VR (skip the 2D launcher)](#frame-start_activity) | `frame.start_activity` | Steam Frame patches | |
| [Allow swapchains larger than 4096 px](#frame-swapchain_limit) | `frame.swapchain_limit` | Steam Frame patches | default |
@@ -82,29 +82,30 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Unity: skip OVROverlay layers (fades, splash screens)](#frame-unity_no_overlay_copy) | `frame.unity_no_overlay_copy` | Steam Frame patches | 2 |
| [Unity: start VR without Meta's system apps](#frame-unity_oculus_check) | `frame.unity_oculus_check` | Steam Frame patches | 12 |
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 28 |
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 85 |
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 107 |
| [Unity: share thread keys (games that run out of them)](#frame-unity_tls_key_pool) | `frame.unity_tls_key_pool` | Steam Frame patches | 1 |
| [Unity: headset always counts as worn](#frame-unity_user_presence) | `frame.unity_user_presence` | Steam Frame patches | 1 |
| [GL shim for Unreal (no multisampled render-to-texture)](#frame-unreal_gl_shim) | `frame.unreal_gl_shim` | Steam Frame patches | 1 |
| [Unreal: match multiview depth to the eye size](#frame-unreal_multiview_depth) | `frame.unreal_multiview_depth` | Steam Frame patches | 1 |
| [Unreal: reserve memory like on Quest](#frame-unreal_noreserve) | `frame.unreal_noreserve` | Steam Frame patches | |
| [Unreal: stand-ins for OVRPlugin functions OVRPort lacks](#frame-unreal_ovrp_entrypoints) | `frame.unreal_ovrp_entrypoints` | Steam Frame patches | 2 |
| [Unreal: Quest controller bindings (RPOC key map)](#frame-unreal_quest_keymap) | `frame.unreal_quest_keymap` | Steam Frame patches | 3 |
| [Unreal: don't wait for the Quest shader precompile](#frame-unreal_quest_precompile) | `frame.unreal_quest_precompile` | Steam Frame patches | 3 |
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 5 |
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 6 |
| [Unreal: skip the OBB delivery check](#frame-unreal_skip_obb_delivery) | `frame.unreal_skip_obb_delivery` | Steam Frame patches | 1 |
| [Unreal: bypass the stalled loading splash](#frame-unreal_splash_bypass) | `frame.unreal_splash_bypass` | Steam Frame patches | 1 |
| [Unreal: thumb follows the capacitive touches](#frame-unreal_thumb_touch) | `frame.unreal_thumb_touch` | Steam Frame patches | 7 |
| [Vermillion: paint canvases in the scene](#frame-vermillion_scene_canvas) | `frame.vermillion_scene_canvas` | Steam Frame patches | 1 |
| [Vivox voice chat: skip Android 12 audio routing](#frame-vivox_audio_route) | `frame.vivox_audio_route` | Steam Frame patches | 1 |
| [Vivox voice chat: skip Android 12 audio routing](#frame-vivox_audio_route) | `frame.vivox_audio_route` | Steam Frame patches | 2 |
| [Vulkan: drop invalid render-pass pointers](#frame-vk_sanitize) | `frame.vk_sanitize` | Steam Frame patches | default · 1 |
| [VrApi → OpenXR bridge](#frame-vrapi_bridge) | `frame.vrapi_bridge` | Steam Frame patches | 3 |
| [VrApi → OpenXR bridge](#frame-vrapi_bridge) | `frame.vrapi_bridge` | Steam Frame patches | 4 |
| [Quiet Meta's VrApi loader (OpenXR games)](#frame-vrapi_stub) | `frame.vrapi_stub` | Steam Frame patches | 2 |
| [OpenGL ES: shader fixes (Vulkan layer under Zink)](#frame-zink_shader_fix) | `frame.zink_shader_fix` | Steam Frame patches | 1 |
| [Pointer start](#adapter-aim_forward) | `adapter.aim_forward` | Game settings | |
| [Pointer angle](#adapter-aim_pitch) | `adapter.aim_pitch` | Game settings | |
| [Pointer turn](#adapter-aim_yaw) | `adapter.aim_yaw` | Game settings | |
| [Use controllers](#adapter-controller_fix) | `adapter.controller_fix` | Game settings | 1 |
| [Show Steam Frame controllers](#adapter-controller_models) | `adapter.controller_models` | Game settings | 4 |
| [Show Steam Frame controllers](#adapter-controller_models) | `adapter.controller_models` | Game settings | 6 |
| [Stand in for cube layers](#adapter-cube_standin) | `adapter.cube_standin` | Game settings | |
| [Show curved menus and screens](#adapter-cylinder_strips) | `adapter.cylinder_strips` | Game settings | |
| [Show 360° backgrounds and videos](#adapter-equirect_emul) | `adapter.equirect_emul` | Game settings | 5 |
@@ -127,9 +128,9 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [GL shim: high-precision vertex shaders](#adapter-gl_highp_markers) | `adapter.gl_highp_markers` | Game settings | 1 |
| [GL shim: integer vertex inputs](#adapter-gl_int_attribs) | `adapter.gl_int_attribs` | Game settings | 1 |
| [Flat panel diagnostics (log)](#adapter-gl_mv_debug) | `adapter.gl_mv_debug` | Game settings | |
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 81 |
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 88 |
| [Vibration strength](#adapter-haptic_scale) | `adapter.haptic_scale` | Game settings | |
| [Turn off space warp](#adapter-hide_space_warp) | `adapter.hide_space_warp` | Game settings | 4 |
| [Turn off space warp](#adapter-hide_space_warp) | `adapter.hide_space_warp` | Game settings | 5 |
| [Controller input diagnostics (log)](#adapter-input_diag) | `adapter.input_diag` | Game settings | |
| [Extra diagnostics (log)](#adapter-layer_debug) | `adapter.layer_debug` | Game settings | |
| [Drop broken layers](#adapter-layer_fix) | `adapter.layer_fix` | Game settings | |
@@ -137,7 +138,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Unity frame loop: wait for skipped frames](#adapter-ovrp_begin_gate) | `adapter.ovrp_begin_gate` | Game settings | 1 |
| [Steadier hands (older Unity)](#adapter-ovrp_hold_physics) | `adapter.ovrp_hold_physics` | Game settings | 1 |
| [See your room](#adapter-passthrough_emul) | `adapter.passthrough_emul` | Game settings | |
| [Steadier head tracking](#adapter-pose_consistency) | `adapter.pose_consistency` | Game settings | 1 |
| [Steadier head tracking](#adapter-pose_consistency) | `adapter.pose_consistency` | Game settings | 2 |
| [Pose timing diagnostics (log)](#adapter-pose_debug) | `adapter.pose_debug` | Game settings | |
| [Fix hands lagging behind controllers](#adapter-pose_time_fix) | `adapter.pose_time_fix` | Game settings | 14 |
| [Treat newer Quest controllers as Touch](#adapter-profile_remap) | `adapter.profile_remap` | Game settings | |
@@ -148,7 +149,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Re-create play space](#adapter-respace_kick) | `adapter.respace_kick` | Game settings | |
| [Sharpness](#adapter-scale) | `adapter.scale` | Game settings | 4 |
| [Room depth](#adapter-scene_depth) | `adapter.scene_depth` | Game settings | |
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 9 |
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 11 |
| [Room height](#adapter-scene_height) | `adapter.scene_height` | Game settings | |
| [Room width](#adapter-scene_width) | `adapter.scene_width` | Game settings | |
| [Eye-image snapshots (diagnostics)](#adapter-snapshot) | `adapter.snapshot` | Game settings | |
@@ -170,12 +171,12 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [OpenGL ES shader fixes](#adapter-zink_shader_fix) | `adapter.zink_shader_fix` | Game settings | 1 |
| [Keep the game's saved resolution in step](#device-config_sync) | `device.config_sync` | Files and settings on the Frame | 1 |
| [Show as VR or as a flat window](#device-display_mode) | `device.display_mode` | Files and settings on the Frame | |
| [Settings files for the game](#device-files) | `device.files` | Files and settings on the Frame | 2 |
| [Settings files for the game](#device-files) | `device.files` | Files and settings on the Frame | 3 |
| [Foveated rendering (Valve)](#device-foveation) | `device.foveation` | Files and settings on the Frame | 3 |
| [Hide Android's navigation bar](#device-hide_navbar) | `device.hide_navbar` | Files and settings on the Frame | |
| [Extra Lepton settings](#device-lepton_env) | `device.lepton_env` | Files and settings on the Frame | 3 |
| [Controllers as a gamepad (Steam Input)](#device-steam_gamepad) | `device.steam_gamepad` | Files and settings on the Frame | |
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 88 |
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 110 |
| [Launch options (SteamVR or OpenXR)](#pcvr-launch_args) | `pcvr.launch_args` | PC VR (Proton and Revive) | 2 |
| [Provide the Oculus runtime from Revive (Frame)](#pcvr-libovr_redirect) | `pcvr.libovr_redirect` | PC VR (Proton and Revive) | |
| [No Unreal crash reporter](#pcvr-no_crash_reporter) | `pcvr.no_crash_reporter` | PC VR (Proton and Revive) | |
@@ -420,7 +421,7 @@ Stops some Unreal games from closing themselves right after starting.
- `patch_remove_unreal_force_quit`
- **Changes:** Changes the APK (during the OVRPort conversion).
- **Offered (off):** Built as the alternate APK for Unreal games (use it if the game quits itself).
- **Catalog games (38):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2, ExploreVR and 30 more.
- **Catalog games (41):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Breathedge VR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2 and 33 more.
- **Suggested by launch-test signatures:**
- `force-quit` (error): The game quit itself right after starting.
- **Source:** [patches/overport.py](../src/frameport/patches/overport.py)
@@ -526,7 +527,7 @@ Lets the game find extra content it keeps next to its data (for example Star War
- `frame.asset_files`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** The game asks Meta's platform for asset files and its data has … content file(s) (*.pak) that OVRPort's loader doesn't report.
- **Catalog games (1):** Star Wars: Tales from the Galaxy's Edge.
- **Catalog games (2):** Attack on Titan VR: Unbreakable, Star Wars: Tales from the Galaxy's Edge.
- **Source:** [patches/frame/langpack.py](../src/frameport/patches/frame/langpack.py)
> Some games keep content in separate files next to their OBB (for example Star Wars: Tales from the Galaxy's Edge: its seasons and sound banks) and ask Meta's platform for them as asset files. OVRPort's platform loader doesn't know them, so the game waits for content that never arrives (black screen after loading). Lists the content files shipped with the game's data (*.pak) as installed and answers their download request at once with the file's path; nothing is copied or downloaded. Shares the language-pack library.
@@ -645,7 +646,7 @@ Plays the game's videos on the Frame's video hardware, so 4K and 8K video runs s
- `frame.hw_video_decode` · also for 2D apps
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
- **Suggested (on):** The game plays video through Android's decoders (MediaCodec, ExoPlayer or VLC): decode it on the Frame's hardware.
- **Catalog games (19):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Dance Central, Eye of the Temple, Fruit Ninja, Ghost Town and 11 more.
- **Catalog games (22):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Breathedge VR, Dance Central, Exploding Kittens VR, Eye of the Temple and 14 more.
- **Source:** [patches/frame/hw_video_decode.py](../src/frameport/patches/frame/hw_video_decode.py)
> Decodes the game's videos on the Frame's hardware video decoder instead of Android's software decoders, which stutter or fall behind with 4K/8K video (for example video players, Batman: Arkham Shadow's cutscenes). Adds FramePort's codec plugin (OMX.frameport.avc/hevc/vp9.decoder) to this game's container only; the APK isn't changed. When the hardware is busy (for example Steam's own hardware video decoding is on) the video is decoded in software as before.
@@ -706,7 +707,7 @@ Declares Meta's permissions some mixed-reality games ask for, so they don't stop
- `frame.meta_permissions`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Mixed-reality game that needs the room model: its 'use spatial data' permission must be granted (for example Demeter).
- **Catalog games (2):** Arkio, Demeter.
- **Catalog games (3):** Arkio, Demeter, Real VR Fishing.
- **Source:** [patches/frame/manifest_fixes.py](../src/frameport/patches/frame/manifest_fixes.py)
> Declares com.oculus.permission.* / horizonos.permission.* that the app uses, so Android grants them at install (for example the 'use spatial data' permission of mixed-reality games like Demeter).
@@ -749,7 +750,7 @@ Provides stand-ins for Quest system calls some games make at start.
- **Suggested (on):** The Unreal build of Meta XR Audio looks up com.oculus.os.AnalyticsEvent and aborts without it (for example NOPE Challenge).
- **Suggested (on):** Several Meta SDK libraries look up com.oculus.os.AnalyticsEvent (…); games like this abort without the stub (for example Nano).
- **Offered (off):** … references com.oculus.os.AnalyticsEvent; usually harmless (for example Batman: Arkham Shadow and LEGO Bricktales run without it). Enable if the game aborts with ClassNotFoundException.
- **Catalog games (15):** AgeOfJoy, AllInOneSports, Caves, Deep Cuts, Does it Stack?, Nano, NOPE CHALLENGE, RC Pilot Trainer and 7 more.
- **Catalog games (17):** AgeOfJoy, AllInOneSports, Caves, Deep Cuts, Does it Stack?, Nano, Neko Atsume Purrfect, NOPE CHALLENGE and 9 more.
- **Suggested by launch-test signatures:**
- `oculus-os-class` (fatal): The game looks up a Quest-only system class and aborts.
- **Source:** [patches/frame/telemetry.py](../src/frameport/patches/frame/telemetry.py)
@@ -838,7 +839,7 @@ Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboar
- `frame.sdl_clipboard` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** SDL app: it crashes at start in Lepton, which has no clipboard service.
- **Catalog games (4):** Majora's Mask VR, Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
- **Catalog games (5):** Crisis Zone VR, Majora's Mask VR, Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
- **Suggested by launch-test signatures:**
- `sdl-no-clipboard` (fatal): The app crashed at start because the Frame's Android has no clipboard.
- **Source:** [patches/frame/sdl_clipboard.py](../src/frameport/patches/frame/sdl_clipboard.py)
@@ -904,7 +905,7 @@ Stops a gray or frozen screen in Unity games that turn on anti-aliasing while ru
- `frame.unity_gl_shim`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unity GLES game that may switch to MSAA at runtime: hide multisampled render-to-texture, which crashes the Frame's GL driver (for example The Room VR).
- **Catalog games (23):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 15 more.
- **Catalog games (23):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 15 more.
- **Suggested by launch-test signatures:**
- `unreal-msrtt-crash` (fatal): The game's graphics crashed a few seconds after start.
- **Source:** [patches/frame/vrapi_bridge.py](../src/frameport/patches/frame/vrapi_bridge.py)
@@ -973,7 +974,7 @@ Keeps a smoothing setting off that the game turns on itself and that can freeze
- `frame.unity_runtime_msaa_off` · revision 2
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled render-to-texture can hang the Frame's GPU.
- **Catalog games (28):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 20 more.
- **Catalog games (28):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 20 more.
- **Suggested by launch-test signatures:**
- `unity-render-crash` (fatal): The game's graphics crashed, so the picture freezes or stays gray.
- `unity-runtime-msaa` (info): The game turns MSAA on at runtime, which can hang the Frame's GPU.
@@ -989,7 +990,7 @@ Lets you type into this app's text fields on the Frame (they'd close at once).
- `frame.unity_text_input` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unity app with text fields (…): they close at once on the Frame because it has no system keyboard.
- **Catalog games (85):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, AugmentedEmpire, Battlemarked, BattleSisters and 77 more.
- **Catalog games (107):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable, Audica and 99 more.
- **Source:** [patches/frame/unity_text_input.py](../src/frameport/patches/frame/unity_text_input.py)
> Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a frame later when there is none (Lepton has no on-screen keyboard; Meta's system keyboard does this on a Quest), so a selected text field only flashes a caret. Rewrites TouchScreenKeyboardShouldBeUsed -&gt; false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -&gt; true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with "Show the app window") or Type on Frame from the PC.
@@ -1000,6 +1001,7 @@ Lets Unity 6 games with many native libraries start when they run out of thread
- `frame.unity_tls_key_pool` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unity 6 with Firebase Firestore and Gecko: these native runtimes can exhaust Android's 128 thread keys alongside the Frame's graphics and XR libraries. Pool Unity's keys to leave room for them.
- **Offered (off):** Unity 6 with …: turn on if the game aborts at start with "cannot create thread specific key" (for example Wander).
- **Catalog games (1):** Wander.
- **Suggested by launch-test signatures:**
@@ -1048,6 +1050,19 @@ Shows the full environment when its upper part is clipped but menu panels look c
> Some UE4 games keep the initial Android window size for their stereo depth buffer, clipping the upper part of the environment while menu panels look correct. Refreshes the cached depth dimensions from the current eye render target before allocation. Preserves multiview and multisampling. Verified in Tales from the Galaxy's Edge 1.1.9; changes one branch only when the function size and surrounding code match.
### <a id="frame-unreal_noreserve"></a>Unreal: reserve memory like on Quest
Lets the game load: it reserves more memory at start than the Frame grants in one piece and crashed before its first frame (for example Transformers Beyond Reality).
- `frame.unreal_noreserve` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unreal's Binned3 allocator reserves more memory at start than the Frame's kernel allows in one piece; without this the game crashes while it loads.
- **Suggested by launch-test signatures:**
- `unreal-reserve-refused` (fatal): The game crashed while Unreal was still loading, before its first frame.
- **Source:** [patches/frame/unreal_noreserve.py](../src/frameport/patches/frame/unreal_noreserve.py)
> Unreal reserves its memory ranges on Android with ordinary read/write mappings. Games built with Unreal's Binned3 allocator reserve tens of gigabytes at once while the engine loads (76 GiB in Transformers Beyond Reality); Quest allows that, the Frame's kernel refuses mappings larger than its memory, and the game crashes before its first frame (SIGSEGV at fault addr 0x10 in libUnreal.so, inside System.loadLibrary). This marks those mappings MAP_NORESERVE, as Quest effectively treats them: memory is still only used when the game touches it.
### <a id="frame-unreal_ovrp_entrypoints"></a>Unreal: stand-ins for OVRPlugin functions OVRPort lacks
Lets older Unreal games start VR on the Frame (for example Star Wars Pinball VR).
@@ -1091,7 +1106,7 @@ Starts Unreal games without re-checking their data file (they'd seem to hang on
- `frame.unreal_skip_obb_check` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unreal checks the OBB's contents before starting (bVerifyOBBOnStartUp); on the Frame a failed or slow check can't be seen and the game seems to hang.
- **Catalog games (5):** Dance Central, Kill It With Fire VR, MacacoVR, Maestro, Ragnarock.
- **Catalog games (6):** Dance Central, Kill It With Fire VR, Lost Recipes, MacacoVR, Maestro, Ragnarock.
- **Suggested by launch-test signatures:**
- `unreal-obb-check-stuck` (fatal): The game stays on Unreal's OBB check screen (DownloaderActivity), which the Frame doesn't show, and never starts VR (for example Contractors, GitHub #159).
- **Source:** [patches/frame/unreal_obb_check.py](../src/frameport/patches/frame/unreal_obb_check.py)
@@ -1157,7 +1172,7 @@ Lets games with Vivox voice chat start on the Frame (they'd crash on an Android
- `frame.vivox_audio_route` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Vivox voice chat: it calls Android 12 audio methods, which Lepton (Android 11) lacks, and crashes.
- **Catalog games (1):** Dungeons of Eternity.
- **Catalog games (2):** Dungeons of Eternity, Real VR Fishing.
- **Suggested by launch-test signatures:**
- `vivox-api31` (fatal): The game's Vivox voice-chat SDK calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes (for example Green Hell VR).
- **Source:** [patches/frame/vivox_audio_route.py](../src/frameport/patches/frame/vivox_audio_route.py)
@@ -1188,7 +1203,7 @@ Lets games built on Meta's oldest VR interface run on the Frame.
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** The engine calls libvrapi.so directly; OVRPort can't translate it.
- **Conflicts with:** [`frame.vrapi_stub`](#frame-vrapi_stub)
- **Catalog games (3):** BlazeRush, Path of the Warrior, The Climb 2.
- **Catalog games (4):** BlazeRush, Path of the Warrior, The Climb, The Climb 2.
- **Suggested by launch-test signatures:**
- `direct-vrapi` (warning): The game uses Meta's VrApi directly and needs the VrApi bridge.
- `vrapi-frame-rejected` (fatal): The VrApi bridge rejected the game's frame options, so no picture reaches the headset even though frame pacing can appear in the log.
@@ -1294,7 +1309,7 @@ For games that show the headset's own controllers. Takes effect after a reinstal
- **Options:**
- Value: int, default `0`.
- Game settings → Controllers → "Show Steam Frame controllers".
- **Catalog games (4):** Ocean Rift, SynthRiders, VR HOT Quest, Walkabout Mini Golf.
- **Catalog games (6):** Neko Atsume Purrfect, Ocean Rift, Real VR Fishing, SynthRiders, VR HOT Quest, Walkabout Mini Golf.
- **Suggested by launch-test signatures:**
- `render-model-requested` (info): The game shows Quest controllers (or none) because the Frame doesn't provide controller models.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
@@ -1632,7 +1647,7 @@ For games that freeze the Frame when a controller vibrates (for example Lucky's
- **Options:**
- Value: int, default `0`.
- Game settings → Controllers → "Fix freezes when controllers vibrate" (advanced).
- **Catalog games (81):** AgeOfJoy, Ancient_Dungeon, Audica, AugmentedEmpire, Battlemarked, BattleSisters, Beat Saber, Caves and 73 more.
- **Catalog games (88):** AgeOfJoy, Ancient_Dungeon, Attack on Titan VR: Unbreakable, Audica, AugmentedEmpire, Battlemarked, BattleSisters, Beat Saber and 80 more.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the Frame when the game vibrates a controller (for example Lucky's Tale's save slots, GitHub #9). Needs a rebuild.
@@ -1661,7 +1676,7 @@ For games whose picture flickers or smears (for example Into The Radius 2).
- **Options:**
- Value: int, default `0`.
- Game settings → Troubleshooting → "Turn off space warp" (advanced).
- **Catalog games (4):** Into The Radius 2, Metro Awakening, Pinball FX VR, The Boys VR.
- **Catalog games (5):** Into The Radius 2, Lawn Mowing Simulator VR, Metro Awakening, Pinball FX VR, The Boys VR.
- **Suggested by launch-test signatures:**
- `space-warp-used` (info): The game uses Meta's space warp (it renders half of its frames plus motion vectors, and the runtime makes up the rest).
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
@@ -1780,7 +1795,7 @@ For games that judder or shimmer while you hold your head still (for example I A
- **Options:**
- Value: int, default `0`.
- Game settings → Picture → "Steadier head tracking" (advanced).
- **Catalog games (1):** I Am Cat.
- **Catalog games (2):** I Am Cat, Lawn Mowing Simulator VR.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Repeat xrLocateViews queries for the same display time get the first, fully tracked answer again. Games that ask several times per frame got slightly different poses and rendered parts of the frame with different heads: judder (for example I Am Cat; proposed by Klownicle, GitHub #8).
@@ -1940,7 +1955,7 @@ Mixed-reality games that build their level from your room get a simple room with
- **Options:**
- Value: int, default `0`.
- Game settings → Mixed reality → "Pretend room layout".
- **Catalog games (9):** Arkio, AugmentedEmpire, Battlemarked, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord, Ocean Rift, Smash Drums and 1 more.
- **Catalog games (11):** Arkio, AugmentedEmpire, Battlemarked, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord, Neko Atsume Purrfect, Ocean Rift and 3 more.
- **Suggested by launch-test signatures:**
- `scene-missing` (warning): The game wants Meta's room data (scene API), which the Frame lacks.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
@@ -2271,7 +2286,7 @@ Puts settings files next to the game on the Frame, for example to turn off an un
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
- **Suggested (on):** CryEngine: variable-rate shading isn't supported on the Frame, so user.cfg turns it off (… = 0; The Climb / The Climb 2).
- **Options:** `files`: text, default none — path relative to files/ -&gt; content
- **Catalog games (2):** SUPERHOT VR, The Climb 2.
- **Catalog games (3):** SUPERHOT VR, The Climb, The Climb 2.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Writes config files into the game's Android/data/&lt;package&gt;/files/ on the Frame, to turn off engine features the Frame doesn't support (for example a CryEngine user.cfg with r_variable_rate_shading = 0, as The Climb 2 needs).
@@ -2334,7 +2349,7 @@ Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard
- `device.text_input_window`
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
- **Suggested (on):** App with text fields: key presses only reach a shown Android window.
- **Catalog games (88):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, AugmentedEmpire, Battlemarked and 80 more.
- **Catalog games (110):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable and 102 more.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as "Gamescope" (the Frame's compositor). Pairs with "Make Unity text fields work without a system keyboard".
+4
View File
@@ -24,6 +24,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| A Unity OpenXR game closes right after the OpenXR instance is created: `F VrApiLoader: vrapi_SetPropertyInt was called before vrapi_Initialize()!`, SIGABRT on UnityMain (GitHub #62, Jurassic World Aftermath) | the APK still ships Meta's VrApi loader; OVRPlugin (1.89, on OpenXR) calls it without starting VrApi and Meta's loader aborts. The VrApi bridge can't replace it (39 of the loader's 114 functions: OVRPlugin wouldn't link) | `frame.vrapi_stub` (same exports, each returns 0); triage `vrapi-before-init` |
| A VrApi game exits at once: `UnsatisfiedLinkError … dlopen failed: cannot locate symbol "vrapi_PollEvent"` (GitHub #57, BlazeRush) | the engine imports a VrApi function the VrApi bridge didn't implement | the bridge has `vrapi_PollEvent`, `RecenterPose`, `SetDisplayRefreshRate`, `GetSystemPropertyFloatArray` (rebuild the game); the static check "VrApi functions resolvable" names any other missing one; triage `vrapi-symbol-missing` |
| A Team Beef port (Lambda1VR, RTCWQuest, …) exits at once: `UnsatisfiedLinkError: dlopen failed: library "libopenxr_loader_valve.so" not found` (GitHub #59) | TBXR loads `openxr_loader_<Build.MANUFACTURER>` (Lepton: valve) and takes the Meta path only when `strstr(OPENXR_HMD, "meta")` matches, else Pico's | `frame.tbxr_vendor` (empty `libopenxr_loader_valve.so` + the "meta" literal → "alve"). Game data (`xash/`, …) goes to /sdcard: upload it with the Files tab to the game's storage. Black eyes with `TBXR: Incomplete frame buffer object: GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE`: TBXR always uses multisampled render-to-texture (even `--msaa 1`); `frame.tbxr_vendor` links the GL shim, which hands it single-sampled versions (log `GL shim: multisampled render-to-texture … drawn single-sampled`) |
| A Quest game renders, then logs `Entitlement check failed` / `ovrPlatformInitialize_NotEntitled` (often with retries) and quits, e.g. `SIG: 9` after `[Photon] Disconnected` (GitHub #322, Deadly Delivery) | The game's Meta licence check (entitlement) needs Meta's online login, which the Frame doesn't have; online-only games quit (triage `meta-entitlement`) | None: FramePort doesn't get around entitlement checks. Offline games that log it and play on are fine |
| Black screen after VR starts; `Asset status error (code 100)` in `DLC_Manager:AssetStatusCallback` (GitHub #203, e.g. PixelRipped 1995) | Meta asset-file status fails after entitlement succeeds; the log does not identify the native call or asset ID | Try `frame.asset_files_stub` (opt-in, triage-only): native/langpack answers GetList with an empty successful list and StatusById with installed status, an empty path and the requested ID. No DLC files are supplied; download requests retain their existing behavior. Disable with `FRAMEPORT_ASSET_FILES_STUB=0`. Host-tested only; whether this fixes startup needs a game/device test |
| Missing sound or extra content in a game whose OBB folder has separate `*.pak` files (GitHub #61, Star Wars: Tales from the Galaxy's Edge: seasons + Wwise banks) | the game asks Meta's platform for these asset files (`ovr_AssetFile_GetList` / `DownloadById`); OVRPort's loader doesn't list them | copy every content/sound-bank PAK and use `frame.asset_files` (native/langpack lists the data's `*.pak` as installed `default` assets, IAP `free`, and answers DownloadById at once with the path; off: env FRAMEPORT_ASSET_FILES=0). Tales also needs the splash and depth-size fixes below |
| A SideQuest backup has `apk/` and a sibling `obb/` with content/sound-bank PAKs, but the Frame only has the main OBB (GitHub #61, Tales from the Galaxy's Edge) | older scans treated that layout as loose expansion files and selected only the named OBBs; the asset-file patch then found no PAKs | the scanner chooses the OBB folder by version first, then keeps the whole sibling data folder when all its expansion files are selected for this APK; app updates repair affected library entries and refresh asset/language-pack detection. Reinstall to copy the missing files, or scan the backup again first. Folders with other packages or unselected OBB versions still send only the selected OBBs |
@@ -62,6 +63,8 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Un-cracked Oculus PC VR game: "Initializing OVR session" then exits / signature check on the runtime | Revive's hooks don't install on Proton-arm64; the Oculus shim rejects the unsigned Revive runtime | Not runnable on the Frame without the repack's crack extracted (not done by FramePort); use the PC version |
| PC VR game: `Failed to initialize Oculus API (-3001)` / `Unable to load LibOVRRT DLL` | Revive's LoadLibrary hook doesn't work under Proton arm64 (ARM64EC kernelbase); the game's LibOVR shim finds no runtime DLL | Symlink `LibOVRRT64_1.dll` → Revive's DLL in the prefix's system32 (manual so far) → then -3021 (runtime signature check; open) |
| Uploads to the Frame are slow | The link, storage or transfer backend can limit throughput | Check the job's "Transfer link"; try the Frame's hotspot or USB cable. Large install files prefer system OpenSSH SFTP, retaining paired-host checks and `.part` resume; unavailable or failed native uploads fall back to Paramiko. The log names the backend. Settings → Installing → Parallel uploads accepts 1–8 (default 3; saved preferences are preserved). More is not always faster. Measure the production path with `scripts/upload_bench.py --methods app-files-3 --check-resume` (three repetitions, checksum verification) |
| Log: `OpenSSH SFTP failed (255): … Permission denied (publickey,password)`, then Paramiko | OpenSSH offered keys the Frame doesn't authorize (GitHub #242) | The log lists the keys OpenSSH offered (`Offering public key`) and its warnings. A connection that logged in with the PC's own keys/agent makes OpenSSH use them too (copies of `~/.ssh/id_*` + the agent); a refused FramePort key is retried that way once. A Frame that accepts only another key file: Settings → Installing → SSH keys (CLI `frameport frame ssh-keys --add <file>`); passphrase keys go through the SSH agent |
| Log: `OpenSSH SFTP failed (1): stat C://…/[…: No such file or directory` | sftp got glob escapes inside its quotes (GitHub #324, fixed): `[` in a local folder name | Update FramePort; the native_upload `quote()` passes only `\\` and `\"` escapes, Windows paths with `/` |
| PC VR game exits right away: `Unhandled Exception: 0xc06d007e` (after `FOnlineSubsystemOculus::InitWithWindowsPlatform`) | Delay-loaded `LibOVRPlatform64_1.dll` (Oculus Platform SDK, installed with the Oculus app) is missing | None on the Frame (entitlement check; FramePort doesn't replace it). PC mode with the Oculus app |
| Unity PC VR game (Oculus + SteamVR build: `OVRPlugin.dll` and `openvr_api.dll` in `<Name>_Data/Plugins`) opens a window and quits after ~20 s; no VR Vulkan instance after the game's DXVK device in launch.log; Unity log (`unity log … Player.log`, agent v67): `OpenVR failed initialization` / `Initialization of device … failed` | Unity's built-in VR tries its SDKs in list order (Oculus first) unless `-vrmode` picks one; a catalog recipe verified with another build of the game (for example SUPERHOT VR's OpenXR build, GitHub #105) used to drop the build's own arguments | `pcvr.launch_args` = `-vrmode OpenVR` (detected for such builds; kept when the catalog's `xr` differs from the build's). A game's Electron launcher next to it (SHVR.exe) is ranked below the game |
| Unreal PC VR game opens the crash reporter instead of closing | UE starts CrashReportClient.exe on a crash | `pcvr.no_crash_reporter` (default for Unreal Rift games): `-nocrashreports` + CrashReportClient.exe renamed `.disabled` in the Frame copy |
@@ -184,6 +187,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Unity game crashes or hangs right as Vulkan starts, after `SLZ Graphics plugin loading!` and OVRPlugin's pre-init `xrDestroyInstance`: SIGSEGV with pc 0 (or pc == fault address in an unloaded library), x16/x23 in `libSLZQuestNative.so` (BONELAB 1.2974) | Stress Level Zero's graphics plugin hooks Unity's Vulkan start-up (IUnityGraphicsVulkanV2 interception: vkCreateInstance/vkCreateDevice) and vkCreateSampler; its vkCreateInstance wrapper calls an invalid pointer on the Frame | `frame.slz_vulkan_hooks` (its two registrations become no-ops; Unity starts Vulkan itself; ~35 s of shader prewarming without its pipeline cache) |
| Unity game shows a picture but the player body is frozen: no head tracking, the controllers stay on the model, no buttons, although OpenXR input works (BONELAB 1.2068, Oculus XR Plugin) | OVRPlugin reports the headset as not worn (`ovrp_GetUserPresent2` → 0) a few seconds after start, and the game's rig only follows a present user (Marrow `XRHMD.IsUserPresent` = Unity's HMD `UserPresence`) | `frame.unity_user_presence` (the Oculus XR Plugin's lookup → `libfp_ovrp.so`, reports present; log `user presence: OVRPlugin 0 …`) |
| Unreal 5 game crashes at its first render pass, backtrace `vulkan.freedreno.so` ← `libVkLayer_fossilize.so` ← `libfp_vk.so (create_render_pass2)` (Metro Awakening) | a subpass without a depth attachment names a depth/stencil resolve attachment; the Frame's Turnip reads the missing depth attachment (NULL) | the Vulkan shim drops that resolve (always, FramePort after 0.11.0; log `dropped the depth resolve of subpass N (no depth attachment)`); triage `unreal-vulkan-driver-crash` |
| Unreal game crashes before its first frame: SIGSEGV `fault addr 0x10` on the main thread, backtrace all `libUnreal.so` above the linker's `soinfo::call_constructors` (inside `System.loadLibrary`), right after `Parsing commandline` (GitHub #349, Transformers Beyond Reality) | The game is built with Unreal's Binned3 allocator, which reserves its whole range while the library loads: one `mmap(NULL, 76 GiB, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS)`. Quest grants it; the Frame's kernel (heuristic overcommit, mode 0) refuses a writable private mapping larger than RAM + swap, and Unreal's out-of-memory handler ("Ran out of memory allocating ...") dereferences its not-yet-created log | `frame.unreal_noreserve` (suggested from analysis `unreal_binned3`): every such anonymous read/write mmap in the engine library gets `MAP_NORESERVE` (one instruction each: `mov w3, #0x22` → `#0x4022`); triage `unreal-reserve-refused`. Found by disassembling the crash pc (the OOM handler; its UTF-16 format string) and the caller's `mov x0, #0x1300000000` |
| Unreal 5 game crashes in `vulkan.freedreno.so` called straight from `libUnreal.so` right after its swapchains, or a few seconds into the first frames (Metro Awakening) | Unreal turns on fragment-density-map foveation because Turnip offers VK_EXT_fragment_density_map, then records barriers (to `FRAGMENT_DENSITY_MAP_OPTIMAL`) and image views (2D array R8G8) for a density map the Frame never provides (VK_NULL_HANDLE); Turnip dereferences it | adapter `vk_hide_fdm=1` (the game doesn't see the extension) + `vk_spec_fixes=1` (leaves out null-image barriers, refuses null-image views); triage `unreal-fdm-missing`. Found by disassembling the driver at the crash pc: the struct offsets (0x58 = VkSubpassDescription2, 0x48/0x60 = image barriers, viewType/format in registers) name the call |
| Own-engine game crashes in `je_free` from its own library on the first frames (Roblox) | the game frees memory it doesn't own; not the Vulkan shim or the format fallback | unsupported |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 KiB

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 99 KiB

+477 -38
View File
@@ -1,69 +1,508 @@
#!/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.
"""Evaluate static patch suggestions against catalog-only ground truth (no catalog-assisted suggestions).
python scripts/eval_heuristics.py "<folder with game dumps>" [--verbose]
eval_heuristics.py <dumps folder> [--verbose]
eval_heuristics.py --snapshot analyses.json --split all --json baseline.json
eval_heuristics.py --snapshot analyses.json --fit-report --out fit-report.md
Snapshots can be in progress: each read copies to a temporary file and retries incomplete JSON. The split is
stratified by analysis engine and stable for a fixed set of packages. Converted inputs are included and labelled.
Patch scores are micro-averaged across games, excluding enabled automatic defaults. Expected defaults are detected
per analysis before applying explicit catalog removals; inapplicable defaults create no removal decisions. A wrong
adapter value is both an FP and an FN. Removal decisions and alternate-build patches have their own identities
("remove:<id>", "alt:<id>").
Unsupported catalog entries and runtime-only choices are reported separately from the headline.
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import shutil
import subprocess
import sys
import tempfile
import time
from collections import Counter, defaultdict
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.core.library import analysis_from_dict # noqa: E402
from frameport.core.models import Recipe # 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
RUNTIME_ONLY = [
"use_alt", "adapter.vk_shader_fix", "adapter.focus_hold", "adapter.focus_hold_ms", "adapter.sync_guard",
"adapter.scale", "adapter.layer_debug", "adapter.refresh_rate", "adapter.pose_consistency",
"lepton_env", "config_sync",
]
REPO = Path(__file__).resolve().parents[1]
DEVICE_FIELDS = {"device.files", "device.lepton_env", "device.config_sync", "device.foveation"}
def signature(recipe) -> dict:
"""The parts of a recipe that differ between games (defaults/automatic fixes are excluded)."""
def expected_defaults(analysis) -> set[str]:
"""Defaults recommended by their own detection, before recipe normalization/removals.
As in suggest's initial pass, default_on patches bypass the opt-in applies gate; their own detect() decides.
This masks e.g. LTW defaults in games without LTW, without importing any heuristic opt-in/adapter choices.
"""
return {p.id for p in base.all_patches() if p.default_on and base.for_game(p, analysis)
and (s := p.detect(analysis)) and s.recommended}
def signature(recipe: Recipe, defaults: set[str] | None = None) -> dict:
"""Recipe decisions, excluding enabled defaults but retaining explicit removal of defaults."""
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),
rift = recipe.package.startswith("rift.")
patches = [p for p in base.all_patches() if (p.category == "pcvr") == rift]
out = {}
for category in ("overport", "frame", "pcvr"):
out[category] = sorted(p.id for p in patches if p.category == category and not p.default_on and p.id in pats)
out[category + "_removed"] = sorted(p.id for p in patches
if p.category == category and p.default_on and p.id not in pats
and (defaults is None or p.id in defaults))
out.update({
"adapter": {p.split(".", 1)[1]: v.get("value") for p, v in sorted(pats.items()) if p.startswith("adapter.")},
"device": sorted(p for p in pats if p.startswith("device.") and p not in DEVICE_FIELDS
and not base.get(p).default_on),
"device_files": sorted((pats.get("device.files") or {}).get("files", {})),
"foveation": dict(pats.get("device.foveation") or {}),
"lepton_env": dict((pats.get("device.lepton_env") or {}).get("env", {})),
"config_sync": dict((pats.get("device.config_sync") or {}).get("files", {})),
"alt": sorted(recipe.alt_patches),
"use_alt": recipe.use_alt,
"status_unsupported": recipe.status == "unsupported",
}
})
return out
def catalog_recipe(entry: catalog.CatalogEntry, defaults: set[str] | None = None, analysis=None) -> Recipe:
"""Materialize catalog choices plus expected defaults; never import heuristic opt-in choices. With the game's
analysis, the engine's structural transforms apply as in suggest(): a 32-bit game goes through the bridge (its
entry relies on that instead of listing what the bridge replaces), a 2D app keeps only its non-VR patches."""
r = Recipe(entry.package, status=entry.status, as_is=entry.as_is,
alt_patches=list(entry.alt_overport), use_alt=entry.use_alt)
rift = entry.kind == "rift" or entry.package.startswith("rift.")
r.patches = {p.id: {} for p in base.all_patches() if p.default_on and (p.category == "pcvr") == rift
and (defaults is None or p.id in defaults)}
for pid in (*entry.overport_extra, *entry.frame, *entry.device, *entry.pcvr):
r.patches[pid] = {}
for pid in (*entry.overport_remove, *entry.frame_remove, *entry.pcvr_remove):
r.patches.pop(pid, None)
for key, value in entry.adapter.items():
r.patches[f"adapter.{key}"] = {"value": value}
for pid, param, values in (("device.files", "files", entry.device_files),
("device.lepton_env", "env", entry.lepton_env),
("device.config_sync", "files", entry.config_sync)):
if values:
r.patches[pid] = {param: copy.deepcopy(values)}
if entry.foveation:
r.patches["device.foveation"] = {"mode": entry.foveation}
if entry.foveation_radius:
r.patches["device.foveation"]["radius"] = entry.foveation_radius
if entry.foveation_test:
r.patches["device.foveation"]["test"] = True
if analysis is not None:
if analysis.only_32bit and "frame.a32_bridge" not in entry.frame_remove:
r.patches.setdefault("frame.a32_bridge", {})
engine.a32_recipe(analysis, r, newly=False)
elif analysis.vr_kind == "none":
needed = {pid for pid in r.patches if not base.get(pid).needs_vr and pid != "frame.launcher"}
needed |= {"frame.launcher"} if analysis.has_info_category else set()
r.patches = {pid: v for pid, v in r.patches.items() if pid in needed}
r.alt_patches = []
return r
def catalog_signature(entry: catalog.CatalogEntry, defaults: set[str] | None = None, analysis=None) -> dict:
return signature(catalog_recipe(entry, defaults, analysis), defaults)
def engine_group(name: str) -> str:
return name if name in ("Unity", "Unreal") else "Other"
def split_packages(engines: dict[str, str]) -> dict[str, str]:
"""package -> dev/holdout; nearest 20% per engine (half rounded up), lowest sha256 first.
Only pass the intersection of catalog and analyses. This is stable under input reordering; adding packages can
change membership at a stratum's cutoff. Tiny strata may have no holdout. Unsupported entries participate too.
"""
groups = defaultdict(list)
for pkg, name in engines.items():
groups[engine_group(name)].append(pkg)
result = {}
for packages in groups.values():
ordered = sorted(packages, key=lambda p: (hashlib.sha256(p.encode()).digest(), p))
n = (len(ordered) + 2) // 5
result.update((p, "holdout" if i < n else "dev") for i, p in enumerate(ordered))
return dict(sorted(result.items()))
def _parts(sig: dict) -> tuple[dict, dict]:
headline, runtime = {}, {}
for key, value in sig.items():
if key == "adapter":
headline[key] = {k: v for k, v in value.items() if f"adapter.{k}" not in RUNTIME_ONLY}
runtime[key] = {k: v for k, v in value.items() if f"adapter.{k}" in RUNTIME_ONLY}
else:
(runtime if key in RUNTIME_ONLY else headline)[key] = value
return headline, runtime
def _decisions(sig: dict) -> dict:
"""Patch/action identities -> values. Status is a field metric, not a patch."""
out = {}
for field, value in sig.items():
if field == "status_unsupported":
continue
if field in ("adapter", "lepton_env", "config_sync"):
prefix = "adapter" if field == "adapter" else f"device.{field}"
out.update((f"{prefix}.{k}", v) for k, v in value.items())
elif field == "foveation":
if value:
out["device.foveation"] = value
elif field == "use_alt":
if value:
out["use_alt"] = value
else:
prefix = ("remove:" if field.endswith("_removed") else "alt:" if field == "alt"
else "device.files:" if field == "device_files" else "")
out.update((prefix + p, True) for p in value)
return out
def _rates(tp: int, fp: int, fn: int) -> dict:
# No positive decisions yields 0, rather than inflating scores for empty recipes/strata.
return {"tp": tp, "fp": fp, "fn": fn, "precision": tp / (tp + fp) if tp + fp else 0.0,
"recall": tp / (tp + fn) if tp + fn else 0.0,
"f1": 2 * tp / (2 * tp + fp + fn) if 2 * tp + fp + fn else 0.0}
def score_signatures(want: dict, got: dict, gaps: frozenset | set = frozenset()) -> dict:
"""Compare one pair of signatures; value mismatches count as both missing and extra. `gaps` = decisions the
catalog entry couldn't have made (the patch didn't exist when it was verified): left out of both sides."""
result = {}
for scope, w, g in zip(("headline", "runtime"), _parts(want), _parts(got), strict=True):
wanted, actual = _decisions(w), _decisions(g)
gap = {k: actual.pop(k) for k in list(actual) if k in gaps and k not in wanted}
missing = {k: v for k, v in wanted.items() if k not in actual or actual[k] != v}
extra = {k: v for k, v in actual.items() if k not in wanted or wanted[k] != v}
per_patch = {k: _rates(int(k in wanted and k in actual and wanted[k] == actual[k]),
int(k in extra), int(k in missing)) for k in sorted(wanted.keys() | actual.keys())}
fields = {k: w.get(k) == g.get(k) for k in sorted(w.keys() | g.keys())}
exact = all(fields.values()) if not gap else not missing and not extra
result[scope] = {"exact": exact, "fields": fields, "missing": missing, "extra": extra,
"per_patch": per_patch, "gap": gap}
return result
def _aggregate(rows: list[dict], scope: str, score_key: str = "score") -> dict:
fields = defaultdict(lambda: {"matched": 0, "total": 0})
counts = defaultdict(Counter)
gaps = Counter()
for row in rows:
s = row[score_key][scope]
gaps.update(s.get("gap") or {})
for key, match in s["fields"].items():
fields[key]["matched"] += int(match)
fields[key]["total"] += 1
for key, stats in s["per_patch"].items():
counts[key].update({k: stats[k] for k in ("tp", "fp", "fn")})
per_patch = {k: _rates(**v) for k, v in sorted(counts.items())}
total = Counter()
for stats in counts.values():
total.update(stats)
exact = sum(row[score_key][scope]["exact"] for row in rows)
return {"games": len(rows), "exact": exact, "exact_ratio": exact / len(rows) if rows else 0.0,
"fields": dict(sorted(fields.items())), "per_patch": per_patch, "gaps": dict(sorted(gaps.items())),
"patches": _rates(*(total[k] for k in ("tp", "fp", "fn")))}
def score_records(records: list[dict]) -> dict:
"""Score {package, engine, status, converted, want, got, ...} records; no filesystem needed."""
rows = [{**r, "score": score_signatures(r["want"], r["got"]),
"score_dated": score_signatures(r["want"], r["got"], set(r.get("gaps") or ()))}
for r in sorted(records, key=lambda r: r["package"])]
def metrics(group):
supported = [r for r in group if r["status"] != "unsupported"]
unsupported = [r for r in group if r["status"] == "unsupported"]
return {"total_games": len(group), "converted_games": sum(bool(r.get("converted")) for r in group),
"excluded_unsupported": len(unsupported),
"headline": _aggregate(supported, "headline"), "runtime": _aggregate(supported, "runtime"),
"headline_dated": _aggregate(supported, "headline", "score_dated"),
"unsupported": {scope: _aggregate(unsupported, scope) for scope in ("headline", "runtime")}}
return {**metrics(rows), "by_engine": {name: metrics([r for r in rows if engine_group(r["engine"]) == name])
for name in ("Unity", "Unreal", "Other")}, "games": rows}
def read_snapshot(path: Path, attempts: int = 8) -> dict:
"""Copy before parsing, even on retries; never write to the source or read APK/data paths in it."""
with tempfile.TemporaryDirectory(prefix="frameport-eval-") as tmp:
dest = Path(tmp) / "snapshot.json"
for attempt in range(attempts):
shutil.copyfile(path, dest)
try:
return json.loads(dest.read_text(encoding="utf-8"))
except json.JSONDecodeError:
if attempt == attempts - 1:
raise
time.sleep(0.25 * (attempt + 1))
raise AssertionError("unreachable")
def read_dumps(path: Path) -> dict:
records = {}
for g in quest_dump.scan(path):
a = analyze(g.apk, data_bytes=g.data_bytes())
row = {"analysis": a.to_dict(), "converted": a.is_overport_output, "name": g.name}
if a.package not in records or records[a.package]["converted"] and not row["converted"]:
records[a.package] = row
return records
def gap_decisions(entry, intro_dates: dict | None) -> set[str]:
"""Decision keys for patches introduced after the entry was verified: the catalog can't be wrong about them."""
verified = str((entry.verified or {}).get("date") or "")
if not intro_dates or not verified:
return set()
return {(f"alt:{pid}" if pid.startswith("patch_remove_unreal") else pid)
for pid, date in intro_dates.items() if date and date > verified}
def inherited_decisions() -> set[str]:
"""Heuristic picks a catalog recipe keeps even when the entry doesn't name them (engine.suggest: adapter
settings and device patches other than catalog.TOGGLED_DEVICE): the verified build had them, so an "extra" one
isn't a detection error. A catalog value that differs still counts (the key is in both sides then)."""
return {p.id for p in base.all_patches()
if p.category == "adapter" or (p.category == "device" and p.id not in catalog.TOGGLED_DEVICE)}
def evaluate(snapshot: dict, entries: dict, split: str = "dev", intro_dates: dict | None = None) -> dict:
analyses = {pkg: analysis_from_dict(v["analysis"]) for pkg, v in snapshot.items() if pkg in entries}
assignments = split_packages({pkg: a.engine for pkg, a in analyses.items()})
records = []
for pkg, a in sorted(analyses.items()):
if split != "all" and assignments[pkg] != split:
continue
entry = entries[pkg]
defaults = expected_defaults(a)
records.append({"package": pkg, "title": entry.title, "engine": a.engine, "status": entry.status,
"converted": bool(snapshot[pkg].get("converted", a.is_overport_output)),
"analysis_version": snapshot[pkg].get("analysis_version"), "split": assignments[pkg],
"want": catalog_signature(entry, defaults, a),
"got": signature(engine.suggest(a, use_catalog=False), defaults),
"gaps": sorted(gap_decisions(entry, intro_dates) | inherited_decisions())})
return {"schema_version": 1, "split": split, "runtime_only": RUNTIME_ONLY, "snapshot_packages": len(snapshot),
"default_policy": "per-analysis default detection, then explicit catalog removals",
"catalog_packages": len(entries), "matched_packages": len(analyses),
"split_assignments": assignments, **score_records(records)}
def fit_candidates(analysis, entry: catalog.CatalogEntry) -> list[dict]:
"""Use suggest's final heuristic pass, including platform filtering, non-VR and a32 normalization.
Requiring a recommended detect() as well excludes dependencies and synthetic recipe additions. No engine
refactor is needed: suggest() remains untouched, and catalog data is used only for the candidate exclusion and
the hypothetical warning recipe.
"""
suggested = engine.suggest(analysis, use_catalog=False)
chosen = catalog_recipe(entry, expected_defaults(analysis), analysis)
listed = set(chosen.patches) | set(entry.alt_overport)
removed = set(entry.overport_remove) | set(entry.frame_remove) | set(entry.pcvr_remove)
result = []
for pid, params in sorted(suggested.patches.items()):
p = base.get(pid)
if (p.default_on or pid in listed or pid in removed or not base.for_game(p, analysis)
or not p.applies(analysis)):
continue
s = p.detect(analysis)
if not s or not s.recommended:
continue
trial = copy.deepcopy(chosen)
trial.patches[pid] = dict(params)
result.append({"patch": pid, "category": p.category, "experimental": p.experimental,
"strict": bool(getattr(p, "strict", False)), "reason": s.reason,
"warnings": engine.warnings(trial, analysis)})
return result
def patch_intro_dates(cache_path: Path) -> dict:
"""One local git pickaxe per uncached patch (including missing-history results); never use the network."""
try:
cached = json.loads(cache_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
cached = {}
changed = False
for p in base.all_patches():
if p.id in cached:
continue
needle = p.id.split(".", 1)[1] if p.category == "adapter" else p.id
paths = ["src/frameport/patches/settings.py"] if p.category == "adapter" else ["src/frameport"]
log = subprocess.run(["git", "log", f'-S"{needle}"', "--reverse", "--format=%ad", "--date=short", "--",
*paths], cwd=REPO, text=True, capture_output=True, check=True)
dates = log.stdout.splitlines()
cached[p.id] = dates[0] if dates else None
changed = True
if changed:
cache_path.write_text(json.dumps(cached, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return cached
def entry_date(entry) -> str:
"""When the recipe was verified; `updated` only when there is no verified date (it also moves for unrelated
edits, e.g. a note, so it says nothing about which patches the verifier could pick)."""
return str(entry.verified.get("date") or "") or str(entry.updated or "")
def fit_report(snapshot: dict, entries: dict, intro_dates: dict) -> dict:
games, metadata = [], []
totals = Counter()
for pkg, entry in sorted(entries.items()):
common = {"package": pkg, "title": entry.title, "engine": entry.engine, "xr": entry.xr,
"status": entry.status, "verified_date": str(entry.verified.get("date") or ""),
"updated": str(entry.updated or ""), "entry_date": entry_date(entry)}
if pkg not in snapshot:
metadata.append(common)
continue
candidates = fit_candidates(analysis_from_dict(snapshot[pkg]["analysis"]), entry)
for c in candidates:
intro = intro_dates.get(c["patch"])
c.update(intro_date=intro,
newer_patch=bool(intro and common["entry_date"] and intro > common["entry_date"]))
totals[c["patch"]] += 1
games.append({**common, "converted": bool(snapshot[pkg].get("converted")), "candidates": candidates})
return {"games": games, "metadata_only": metadata, "totals_by_patch": dict(sorted(totals.items())),
"candidate_count": sum(totals.values()), "games_with_candidates": sum(bool(g["candidates"]) for g in games)}
def _md(value) -> str:
return str(value if value is not None else "unknown").replace("|", "\\|").replace("\n", " ")
def render_fit_report(report: dict) -> str:
lines = ["# New patch fit", "", f"{len(report['games'])} catalog games with analyses; "
f"{report['games_with_candidates']} with candidates; {report['candidate_count']} candidate additions. "
f"{len(report['metadata_only'])} metadata-only entries.", "",
"Candidates are opt-in recommended patches absent from the entry, after the heuristic engine's platform "
"and a32 rules. A listed adapter key is excluded even if its value differs. Warnings are for the full "
"catalog recipe plus that one patch; they can include pre-existing conflicts. Unknown history/date "
"does not imply a newer patch. Default-on patches are not candidates. All splits and statuses included.",
"", "## Totals by patch", "", "| Patch | Games |", "| --- | ---: |"]
lines.extend(f"| {pid} | {n} |" for pid, n in
sorted(report["totals_by_patch"].items(), key=lambda kv: (-kv[1], kv[0])))
for game in report["games"]:
lines += ["", f"## {_md(game['title'])} (`{game['package']}`)", "",
f"{game['engine']} / {game['xr']}; status: {game['status']}; "
f"entry date: {game['entry_date'] or 'unknown'}; converted: {game['converted']}.", ""]
if not game["candidates"]:
lines += ["No candidates."]
continue
lines += ["| Patch | Category | Experimental | Strict | Reason | Introduced | Newer patch | Warnings |",
"| --- | --- | --- | --- | --- | --- | --- | --- |"]
for c in game["candidates"]:
cells = [c["patch"], c["category"], c["experimental"], c["strict"], c["reason"], c["intro_date"],
c["newer_patch"], "; ".join(c["warnings"]) or "none"]
lines.append("| " + " | ".join(_md(v) for v in cells) + " |")
lines += ["", "## Catalog entries without analyses (metadata only)", "",
"| Package | Title | Engine | XR | Status | Verified | Updated | Entry date |",
"| --- | --- | --- | --- | --- | --- | --- | --- |"]
for g in report["metadata_only"]:
lines.append("| " + " | ".join(_md(g[k]) for k in
("package", "title", "engine", "xr", "status", "verified_date", "updated", "entry_date")) + " |")
return "\n".join(lines) + "\n"
def print_metrics(report: dict, verbose: bool = False) -> None:
print(f"Split: {report['split']}; catalog/analysis intersection: {report['matched_packages']}; "
f"converted: {report['converted_games']}; unsupported excluded: {report['excluded_unsupported']}")
print(f"{'Engine':10} {'Games':>5} {'Exact':>9} {'Fields':>11} {'Prec':>7} {'Recall':>7} {'F1':>7} {'Runtime':>9}")
for name, metrics in [("All", report), *report["by_engine"].items()]:
h, r = metrics["headline"], metrics["runtime"]
fields = h["fields"].values()
f = f"{sum(x['matched'] for x in fields)}/{sum(x['total'] for x in h['fields'].values())}"
p = h["patches"]
print(f"{name:10} {h['games']:5} {h['exact']:4}/{h['games']:<4} {f:>11} "
f"{p['precision']:7.1%} {p['recall']:7.1%} {p['f1']:7.1%} {r['exact']:4}/{r['games']:<4}")
print("\nDated (extras the entry couldn't have listed - patch newer than its verified date - or that catalog "
"recipes inherit anyway - adapter/device picks - are not errors):")
for name, metrics in [("All", report), *report["by_engine"].items()]:
d = metrics["headline_dated"]
p = d["patches"]
print(f"{name:10} {d['games']:5} {d['exact']:4}/{d['games']:<4} {'':>11} "
f"{p['precision']:7.1%} {p['recall']:7.1%} {p['f1']:7.1%}")
gaps = report["headline_dated"]["gaps"]
if gaps:
print("Catalog gaps by patch: " + ", ".join(f"{k}={v}" for k, v in sorted(gaps.items(), key=lambda kv: -kv[1])))
for name, metrics in [("All", report), *report["by_engine"].items()]:
print(f"\n{name}: field matches (headline / runtime)")
for scope in ("headline", "runtime"):
matches = metrics[scope]["fields"].items()
print(scope + ": " + ", ".join(f"{k}={v['matched']}/{v['total']}" for k, v in matches))
print(f"{'Scope / patch':55} {'TP':>5} {'FP':>5} {'FN':>5} {'Prec':>7} {'Recall':>7} {'F1':>7}")
for scope in ("headline", "runtime"):
for pid, p in metrics[scope]["per_patch"].items():
print(f"{scope + ' / ' + pid:55} {p['tp']:5} {p['fp']:5} {p['fn']:5} "
f"{p['precision']:7.1%} {p['recall']:7.1%} {p['f1']:7.1%}")
u = report["unsupported"]["headline"]
print(f"\nUnsupported (excluded): {u['exact']}/{u['games']} exact; patch F1={u['patches']['f1']:.1%}")
for game in report["games"]:
s = game["score"]
if verbose or not s["headline"]["exact"] or not s["runtime"]["exact"] or game["status"] == "unsupported":
print(f"\n{game['package']} [{game['engine']}, {game['status']}, {game['split']}"
+ (", converted" if game["converted"] else "") + "]")
for scope in ("headline", "runtime"):
for diff in ("missing", "extra"):
if s[scope][diff]:
print(f" {scope} {diff}: {json.dumps(s[scope][diff], sort_keys=True)}")
if not s[scope]["fields"].get("status_unsupported", True):
print(f" {scope} status_unsupported: catalog={game['want']['status_unsupported']} "
f"heuristics={game['got']['status_unsupported']}")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("dumps", type=Path)
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("dumps", type=Path, nargs="?")
ap.add_argument("--snapshot", type=Path)
ap.add_argument("--split", choices=("dev", "holdout", "all"), default="dev")
ap.add_argument("--json", type=Path, dest="json_out")
ap.add_argument("--verbose", action="store_true")
ap.add_argument("--fit-report", action="store_true")
ap.add_argument("--out", type=Path, help="Markdown destination for --fit-report (otherwise stdout)")
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 not args.snapshot and not args.dumps:
ap.error("provide a dumps folder or --snapshot")
if args.out and not args.fit_report:
ap.error("--out requires --fit-report")
snapshot = read_snapshot(args.snapshot) if args.snapshot else read_dumps(args.dumps)
# Explicitly bundled entries: remote caches and personal recipes must not affect this benchmark.
entries = catalog._load_dir(REPO / "catalog/games", "bundled")
if args.fit_report:
cache = (args.snapshot.parent if args.snapshot else args.dumps) / "patch-intro-dates.json"
report = fit_report(snapshot, entries, patch_intro_dates(cache))
markdown = render_fit_report(report)
if args.out:
args.out.write_text(markdown, encoding="utf-8")
print(f"{report['candidate_count']} candidates in {report['games_with_candidates']} games -> {args.out}")
else:
print(markdown, end="")
else:
cache = (args.snapshot.parent if args.snapshot else args.dumps) / "patch-intro-dates.json"
# dated scoring reads the cache --fit-report writes; a plain run never shells out for dates
dates = json.loads(cache.read_text(encoding="utf-8")) if cache.is_file() else None
report = evaluate(snapshot, entries, args.split, dates)
print_metrics(report, args.verbose)
if args.json_out:
args.json_out.write_text(json.dumps(report, indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8")
# Retain the legacy evaluator's nonzero exit for recipe differences; JSON is still written.
return 0 if args.fit_report or report["headline"]["games"] == report["headline"]["exact"] else 1
if __name__ == "__main__":
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Analyse every Quest dump below the given folders once and save the analyses as JSON, so heuristics evaluations
(`eval_heuristics.py --snapshot`) don't re-read gigabytes of APKs each run.
python scripts/heuristics_snapshot.py <out.json> <dumps folder> [<dumps folder> ...]
An original APK beats an already converted one (OVRPort output, e.g. PATCHED/ copies): converted inputs change what
the heuristics suggest, so they are only kept when no original exists and are marked `converted`. The output holds
local paths: keep it out of git.
"""
from __future__ import annotations
import argparse
import dataclasses
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from frameport.analysis.detect import ANALYSIS_VERSION, analyze # noqa: E402
from frameport.sources import quest_dump # noqa: E402
def _skip(game, roots: list[Path]) -> bool:
"""Folders starting with "_" (known-good backups, test builds, keys) are not dumps."""
for root in roots:
try:
rel = game.apk.relative_to(root)
except ValueError:
continue
return any(part.startswith("_") for part in rel.parts[:-1])
return False
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("out", type=Path)
ap.add_argument("roots", type=Path, nargs="+")
args = ap.parse_args()
roots = [r.resolve() for r in args.roots]
old = json.loads(args.out.read_text()) if args.out.is_file() else {}
snap: dict[str, dict] = {}
for root in roots:
for g in quest_dump.scan(root):
if _skip(g, roots):
continue
key = str(g.apk)
cached = next((v for v in old.values() if v.get("apk") == key
and v.get("analysis_version") == ANALYSIS_VERSION
and v.get("apk_size") == g.apk.stat().st_size), None)
t = time.time()
if cached:
entry = cached
else:
try:
data_bytes = g.data_bytes()
a = analyze(g.apk, data_bytes=data_bytes)
except Exception as e: # noqa: BLE001 - one unreadable dump mustn't stop the snapshot
print(f"FAIL {g.apk}: {e}", file=sys.stderr, flush=True)
continue
entry = {"analysis": dataclasses.asdict(a), "data_bytes": data_bytes, "apk": key,
"apk_size": g.apk.stat().st_size, "name": g.name, "root": str(root),
"converted": bool(a.is_overport_output), "analysis_version": ANALYSIS_VERSION}
pkg = entry["analysis"]["package"]
have = snap.get(pkg)
# originals beat converted copies; otherwise the first root given wins
if have is None or (have["converted"] and not entry["converted"]):
snap[pkg] = entry
print(f"{'cache' if cached else f'{time.time() - t:5.1f}s'} {pkg}"
f"{' (converted)' if entry['converted'] else ''}", flush=True)
args.out.write_text(json.dumps(snap, indent=1, default=str)) # partial results survive a crash
print(f"{len(snap)} packages ({sum(v['converted'] for v in snap.values())} converted only) -> {args.out}")
return 0
if __name__ == "__main__":
sys.exit(main())
+27 -2
View File
@@ -101,6 +101,8 @@ def main() -> int:
ap.add_argument("--viewport", default="1280x820", help="browser size, e.g. 2560x1440")
ap.add_argument("--update", action="store_true", help="pretend a new FramePort release exists (update UI: "
"changelog dialog, what's new bar + dialog, Settings changelog)")
ap.add_argument("--announcement", action="store_true", help="show the announcement pop-up (with a picture, "
"then text only); nothing is fetched")
ap.add_argument("--fake-frame", action="store_true", help="pretend a Steam Frame is connected (no device needed)")
ap.add_argument("--hover", default=None, help="x,y: move the mouse there before the Library screenshot")
ap.add_argument("--install-questions", nargs="+", metavar="PKG", default=None,
@@ -137,7 +139,8 @@ def main() -> int:
("monitor", lambda a: a.go("monitor")), ("monitor-details", open_monitor_details),
("tools", lambda a: a.go("settings")),
# the index on the left: "This PC" scrolls the page there
("settings-index", lambda a: a.page.run_task(a.settings_view.show_section, "pc"))]
("settings-index", lambda a: a.page.run_task(a.settings_view.show_section, "pc")),
("settings-installing", lambda a: a.page.run_task(a.settings_view.show_section, "installing"))]
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)))
@@ -150,7 +153,8 @@ def main() -> int:
if args.fake_frame: # GitHub #90: the Move to… dialog (drives load in the background)
steps.append(("move-dialog", lambda a: (a.page.pop_dialog(), a.move_game(game))))
steps.append(("uninstall-dialog", lambda a: (a.page.pop_dialog(), a.uninstall(game))))
# last: the right-click menu stays open over whatever comes next
# last: the menus stay open over whatever comes next (the page's "…" menu, then the right-click menu)
steps.append(("game-menu", lambda a: (a.show_activity(False), a.open_game_menu(game))))
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
a.library_view.open_menu(game))))
if args.docs:
@@ -278,6 +282,7 @@ def main() -> int:
("linux-change-program", lambda a: a.choose_exe(LINUX_FOLDER)),
("frame", lambda a: (a.page.pop_dialog(), a.navigate(1))),
("linux-move", lambda a: a.move_game(LINUX_APPIMAGE) if args.fake_frame else None),
("linux-game-menu", lambda a: (a.page.pop_dialog(), a.open_game_menu(LINUX_FOLDER))),
("linux-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
a.library_view.open_menu(LINUX_APPIMAGE)))]
if args.links:
@@ -418,6 +423,26 @@ def main() -> int:
print(f"open dialogs: {len([d for d in a.page._dialogs.controls if d.open])}, queued: {queued}", flush=True)
steps = [("library", lambda a: a.navigate(0)), ("questions", open_questions),
("after-1st-double", double_click), ("after-2nd-double", double_click), ("end", report)]
if args.announcement:
import shutil
from frameport import announcements
from frameport.core.paths import user_data_dir
text = ("---\nid: smoke\ntitle: FramePort 1.3 brings the Monitor to every Frame\ndate: 2026-10-14T12:00:00Z\n"
"---\n"
"The **Monitor** tab now shows fps, battery and temperatures for any game the Frame runs, including "
"Steam's own. Open it from the sidebar's Frame card.\n\n"
"- Install links from FrameDrop buttons open in FramePort\n"
"- Uploads over the USB cable are about **3× faster**\n\n"
"Questions? Ask in [Discussions](https://example.invalid).")
news = announcements.parse(text)
pic = user_data_dir() / "announcements" / "smoke.png"
pic.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(Path(__file__).resolve().parents[1] / "docs" / "images" / "library.png", pic)
steps = [("announcement-image", lambda a: (a.navigate(0), a.announcer.show(news, pic))),
("announcement-text", lambda a: (a.page.pop_dialog(), time.sleep(1.5), a.announcer.show(
announcements.parse(text.replace("id: smoke", "id: smoke2")))))]
if args.update:
from frameport import __version__, updates
+5
View File
@@ -589,6 +589,11 @@
"title": "Unreal: match multiview depth to the eye size",
"group": "Steam Frame patches"
},
"frame.unreal_noreserve": {
"anchor": "frame-unreal_noreserve",
"title": "Unreal: reserve memory like on Quest",
"group": "Steam Frame patches"
},
"frame.unreal_ovrp_entrypoints": {
"anchor": "frame-unreal_ovrp_entrypoints",
"title": "Unreal: stand-ins for OVRPlugin functions OVRPort lacks",
+15 -1
View File
@@ -43,7 +43,10 @@ IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
# 17: headset (the Meta headset a VR app was made for: Gear VR / Go games run through frame.a32_bridge) (2026-10)
# 18: headset "quest" also for 32-bit apps that require 6DoF head tracking (Santa Cruz builds, e.g. Sports
# Scramble: Touch controllers on the a32 bridge) (2026-10)
ANALYSIS_VERSION = 19
# 19: unreal_obb_delivery also recognises obfuscated DownloaderActivity predicates (GitHub #159, Medal of Honor)
# 20: unreal_binned3 (Unreal built with FMallocBinned3 reserves tens of GiB at load: frame.unreal_noreserve,
# GitHub #349) (2026-10)
ANALYSIS_VERSION = 20
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
@@ -87,6 +90,13 @@ def cryengine_project(data: bytes) -> bool:
UE_VERIFY_OBB_KEY = ".GameActivity.bVerifyOBBOnStartUp"
def unreal_binned3(engine: bytes) -> bool:
"""Unreal built with FMallocBinned3 as its allocator (its own messages and source path are compiled in; other
builds only name its console variables). It reserves its whole range while the library loads (GitHub #349)."""
return (b"/HAL/MallocBinned3.cpp\0" in engine
or "FMallocBinned3 Attempt to free".encode("utf-16-le") in engine)
def unreal_verify_obb(meta: dict) -> bool:
"""Unreal's DownloaderActivity checks every OBB entry's CRC at the first start (a full read; on a mismatch it waits
on an error screen). Only a real boolean counts: GameActivity reads it with Bundle.getBoolean."""
@@ -410,6 +420,10 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
# Unreal CRC-checks the OBB before the game starts (frame.unreal_skip_obb_check, GitHub #159)
"unreal_verify_obb": unreal_verify_obb(meta),
"unreal_obb_delivery": unreal_obb_delivery,
# Unreal's Binned3 allocator reserves more address space than the Frame's kernel grants in one writable
# mapping (frame.unreal_noreserve, GitHub #349)
"unreal_binned3": (unreal_binned3(lib_bytes.get(engine_lib.rsplit("/", 1)[1], b""))
if engine_lib else False),
# Unreal's Oculus module needs every OVRPlugin function it looks up (frame.unreal_ovrp_entrypoints)
"unreal_ovrp_lookups": (ovrp_lookups(lib_bytes.get(engine_lib.rsplit("/", 1)[1], b""))
if engine_lib and "libOVRPlugin.so" in libset else []),
+39 -21
View File
@@ -20,6 +20,23 @@ def _elf(data: bytes) -> ELFFile:
return ELFFile(io.BytesIO(data))
def dynsym_section(elf: ELFFile):
"""The dynamic symbol table, found by section type: some shipped libraries have a scrambled section name table
(GitHub #349: Transformers Beyond Reality's libOVRPlugin.so calls .dynsym "text"), types and links are intact."""
sec = elf.get_section_by_name(".dynsym")
if isinstance(sec, SymbolTableSection) and sec["sh_type"] == "SHT_DYNSYM":
return sec
return next((s for s in elf.iter_sections() if s["sh_type"] == "SHT_DYNSYM"), None)
def dynstr_section(elf: ELFFile, dynsym=None):
"""The string table of the dynamic symbols (dynsym's sh_link; by name as a fallback)."""
dynsym = dynsym if dynsym is not None else dynsym_section(elf)
if dynsym is not None and 0 < dynsym["sh_link"] < elf.num_sections():
return elf.get_section(dynsym["sh_link"])
return elf.get_section_by_name(".dynstr")
def needed(data: bytes) -> list[str]:
elf = _elf(data)
for sec in elf.iter_sections():
@@ -48,7 +65,7 @@ def dyn_symbols(data: bytes, defined: bool) -> set[str]:
@lru_cache(maxsize=256)
def _dyn_symbols_cached(data: bytes, defined: bool) -> frozenset[str]:
elf = _elf(data)
sec = elf.get_section_by_name(".dynsym")
sec = dynsym_section(elf)
out = set()
if isinstance(sec, SymbolTableSection):
for sym in sec.iter_symbols():
@@ -69,7 +86,8 @@ def find_symbols(data: bytes, names) -> dict[str, tuple[int, int]]:
if not is_elf(data) or data[4] != 2 or data[5] != 1:
return {}
elf = _elf(data)
dynsym, dynstr = elf.get_section_by_name(".dynsym"), elf.get_section_by_name(".dynstr")
dynsym = dynsym_section(elf)
dynstr = dynstr_section(elf, dynsym)
if dynsym is None or dynstr is None:
return {}
sym_off, sym_size = dynsym["sh_offset"], dynsym["sh_size"]
@@ -147,7 +165,7 @@ def hide_exports(data: bytes, names) -> tuple[bytes, list[str]]:
(native/langpack) still finds the original address. Returns (data, names actually hidden); ELF64 only."""
if not is_elf(data) or not is_64bit(data) or data[5] != 1:
return data, []
sec = _elf(data).get_section_by_name(".dynsym")
sec = dynsym_section(_elf(data))
if not isinstance(sec, SymbolTableSection):
return data, []
wanted = set(names)
@@ -171,7 +189,7 @@ def redirect_exports(data: bytes, names) -> tuple[bytes, list[str]]:
"""
if not is_elf(data) or not is_64bit(data) or data[5] != 1:
return data, []
sec = _elf(data).get_section_by_name(".dynsym")
sec = dynsym_section(_elf(data))
if not isinstance(sec, SymbolTableSection):
return data, []
wanted = set(names)
@@ -201,10 +219,11 @@ def rename_imports(data: bytes, renames: dict[str, str]) -> tuple[bytes, list[st
if len(new) > len(old):
raise ValueError(f"{new} is longer than {old}")
elf = _elf(data)
dynsym, dynstr = elf.get_section_by_name(".dynsym"), elf.get_section_by_name(".dynstr")
dynsym = dynsym_section(elf)
dynstr = dynstr_section(elf, dynsym)
if not isinstance(dynsym, SymbolTableSection) or dynstr is None:
return data, []
versym = elf.get_section_by_name(".gnu.version")
versym = next((s for s in elf.iter_sections() if s["sh_type"] == "SHT_GNU_versym"), None)
str_off = dynstr["sh_offset"]
targets, refs = {}, {} # dynsym index -> st_name of an import to rename; st_name -> referencing dynsym indices
for i, sym in enumerate(dynsym.iter_symbols()):
@@ -376,24 +395,23 @@ def add_needed(data: bytes, library: str) -> bytes:
write_ph(i, p)
# section headers: point .dynamic / .dynstr at the new copies (for tools; the loader uses program headers)
# Found by type and link, not by name: some shipped libraries have a scrambled section name table (GitHub #349,
# Transformers Beyond Reality's libOVRPlugin.so names .dynamic "dding"), whose types and links are still right.
if shoff and shnum:
shstrndx = eh[13]
SHT_DYNAMIC = 6
def sh(i):
return list(struct.unpack_from(sh_fmt, buf, shoff + i * shentsize))
names_hdr = sh(shstrndx)
names_off = names_hdr[4]
def sec_name(h):
end = buf.index(b"\0", names_off + h[0])
return bytes(buf[names_off + h[0]:end]).decode()
for i in range(shnum):
h = sh(i)
n = sec_name(h)
if n == ".dynstr":
h[3], h[4], h[5] = va_start + str_off, file_end + str_off, len(new_str)
elif n == ".dynamic":
h[3], h[4], h[5] = va_start + dyn_off, file_end + dyn_off, len(dyn_bytes)
else:
continue
headers = [sh(i) for i in range(shnum)] # sh_name, sh_type, sh_flags, sh_addr, sh_offset, sh_size, sh_link
dyn_secs = [i for i, h in enumerate(headers) if h[1] == SHT_DYNAMIC]
str_secs = {headers[i][6] for i in dyn_secs if 0 < headers[i][6] < shnum}
str_secs |= {i for i, h in enumerate(headers) if h[3] == strtab_va and h[3]} # .dynstr by address
for i in dyn_secs:
h = headers[i]
h[3], h[4], h[5] = va_start + dyn_off, file_end + dyn_off, len(dyn_bytes)
struct.pack_into(sh_fmt, buf, shoff + i * shentsize, *h)
for i in str_secs - set(dyn_secs):
h = headers[i]
h[3], h[4], h[5] = va_start + str_off, file_end + str_off, len(new_str)
struct.pack_into(sh_fmt, buf, shoff + i * shentsize, *h)
result = bytes(buf)
got = needed(result)
+218
View File
@@ -0,0 +1,218 @@
"""Announcements: one short message from the maintainer, shown once as a pop-up in the app.
Source of truth: `announcements/current.md` on GitHub `main` (front matter + Markdown, announcements/README.md), read
through raw.githubusercontent like the catalog (cached, every 6 h), so a push reaches users without a release.
Who sees it: only people who were already using FramePort before the announcement's `date` (a first start never
shows one, and nor does one published before FramePort was installed). Closing it in any way (the X, a click outside,
Escape, its button) dismisses that `id` for good; a new announcement = a new id. No Flet here.
"""
from __future__ import annotations
import datetime as dt
import hashlib
import logging
import os
import re
import time
from dataclasses import dataclass
from pathlib import Path
import yaml
from . import REPO_URL
from .core import cache, library
from .core.paths import user_data_dir
_log = logging.getLogger("frameport.announcements")
REPO = REPO_URL.removeprefix("https://github.com/").strip("/")
PATH = "announcements/current.md"
RAW = f"https://raw.githubusercontent.com/{REPO}/main/"
URL = RAW + PATH
CHECK_EVERY = 6 * 3600
KEEP_DISMISSED = 50 # ids remembered (oldest dropped)
IMAGE_MAX = 5 * 2**20
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$")
# settings only an earlier GUI run writes: one of them when announcements first run = an existing user (not
# recipes.app_version: library.load() writes that on the very first load)
USED_BEFORE = ("update.last_seen_version", "update.last_check", "ui.welcome_done", "ui.window")
@dataclass(frozen=True)
class Announcement:
id: str
title: str
body: str # Markdown
date: float # unix time it was published
image: str | None = None # https URL (repo paths are resolved to raw GitHub)
image_alt: str = ""
button: str | None = None # optional action: label + url
url: str | None = None
expires: float | None = None
class AnnouncementError(ValueError):
pass
def _when(value, end_of_day: bool = False) -> float:
"""A front-matter date (YAML date/datetime or ISO text) as unix time; a bare date is UTC midnight (or the end of
that day for `expires`). Naive times are UTC."""
if isinstance(value, str):
text = value.strip().replace("Z", "+00:00")
try:
value = dt.datetime.fromisoformat(text) if "T" in text or " " in text else dt.date.fromisoformat(text)
except ValueError as exc:
raise AnnouncementError(f"not a date: {value!r}") from exc
if isinstance(value, dt.datetime):
return (value if value.tzinfo else value.replace(tzinfo=dt.timezone.utc)).timestamp()
if isinstance(value, dt.date):
day = dt.datetime(value.year, value.month, value.day, tzinfo=dt.timezone.utc)
return (day + dt.timedelta(days=1 if end_of_day else 0)).timestamp()
raise AnnouncementError(f"not a date: {value!r}")
def _link(value) -> str | None:
"""An image/button target: https URLs as they are, a repo path (announcements/x.png) → its raw GitHub URL."""
if value in (None, ""):
return None
text = str(value).strip()
if text.startswith("https://"):
return text
if "://" in text or text.startswith(("/", "..")) or ".." in text.split("/"):
raise AnnouncementError(f"use an https URL or a path in the repo: {text!r}")
return RAW + text
def parse(text: str | None) -> Announcement | None:
"""The file's announcement; None when there is none (empty file, `active: false`). Raises AnnouncementError for a
malformed one (the app then shows nothing; tests/test_announcements.py checks the committed file)."""
if not text or not text.strip():
return None
m = re.match(r"\A?---\s*\n(.*?)\n---\s*(?:\n|\Z)(.*)\Z", text, re.S)
if not m:
raise AnnouncementError("missing the --- front matter")
try:
meta = yaml.safe_load(m.group(1)) or {}
except yaml.YAMLError as exc:
raise AnnouncementError(f"front matter: {exc}") from exc
if not isinstance(meta, dict):
raise AnnouncementError("front matter must be key: value lines")
active = meta.get("active", True)
if active is False and not meta.get("id"):
return None
if not isinstance(active, bool):
raise AnnouncementError("active: true or false")
unknown = set(meta) - {"id", "title", "date", "image", "image_alt", "button", "url", "expires", "active"}
if unknown:
raise AnnouncementError(f"unknown fields: {', '.join(sorted(unknown))}")
ident, title = str(meta.get("id") or "").strip(), str(meta.get("title") or "").strip()
if not ID_RE.match(ident):
raise AnnouncementError("id: letters, digits, '.', '_' or '-' (change it to show a new announcement)")
if not title:
raise AnnouncementError("title is required")
if "date" not in meta:
raise AnnouncementError("date is required (only people using FramePort before it see the announcement)")
button, url = meta.get("button"), meta.get("url")
if bool(button) != bool(url):
raise AnnouncementError("button and url go together")
if not active: # switched off, but still checked (tests catch mistakes before it's switched on)
_check_dates(meta)
return None
return Announcement(id=ident, title=title, body=m.group(2).strip(), date=_when(meta["date"]),
image=_link(meta.get("image")), image_alt=str(meta.get("image_alt") or ""),
button=str(button).strip() if button else None, url=_link(url),
expires=_when(meta["expires"], end_of_day=True) if meta.get("expires") else None)
def _check_dates(meta: dict) -> None:
_when(meta["date"])
if meta.get("expires"):
_when(meta["expires"], end_of_day=True)
_link(meta.get("image"))
_link(meta.get("url"))
def disabled() -> bool:
return bool(os.environ.get("FRAMEPORT_NO_ANNOUNCEMENTS"))
def fetch(force: bool = False) -> Announcement | None:
"""The current announcement (cached 6 h; offline → the last copy; never raises)."""
try:
return parse(cache.cached_text("announcement.md", URL, max_age=0 if force else CHECK_EVERY, fallback=""))
except Exception as exc: # noqa: BLE001
_log.warning("announcement skipped: %s", exc)
return None
# ------------------------------------------------------------------------------------------ who sees it
def since() -> float:
"""When this user started using FramePort, as far as announcements are concerned (setting announce.since). Set
once, the first time it's asked: 0 for someone who used FramePort before announcements existed, else now (a first
start). Call it early at start, before anything records this start in the settings."""
value = library.setting("announce.since")
if isinstance(value, (int, float)):
return float(value)
value = 0.0 if any(library.setting(k) is not None for k in USED_BEFORE) or library.games() else time.time()
library.set_setting("announce.since", value)
return value
def dismissed() -> list[str]:
value = library.setting("announce.dismissed")
return [v for v in value if isinstance(v, str)] if isinstance(value, list) else []
def dismiss(ident: str) -> None:
"""Never show this announcement again (any way of closing it)."""
library.update_setting("announce.dismissed",
lambda cur: ([v for v in (cur or []) if v != ident] + [ident])[-KEEP_DISMISSED:], [])
def due(a: Announcement | None, now: float | None = None, user_since: float | None = None) -> bool:
"""Whether to show `a` now: published, not expired, not dismissed, and published after this
user started using FramePort."""
if a is None:
return False
now = time.time() if now is None else now
if a.expires is not None and now >= a.expires:
return False
if a.date > now: # scheduled for later
return False
if a.id in dismissed():
return False
return a.date > (since() if user_since is None else user_since)
def image_file(a: Announcement) -> Path | None:
"""The announcement's image, downloaded once into <data>/announcements/ and checked (Pillow); None without one or
when it can't be loaded (the pop-up then shows no image). Shown by asset URL like artwork, never as bytes."""
if not a.image:
return None
folder = user_data_dir() / "announcements"
stem = hashlib.sha256(a.image.encode()).hexdigest()[:16]
for done in folder.glob(f"{stem}.*"):
if done.suffix != ".part":
return done
try:
from PIL import Image
tmp = folder / f"{stem}.part"
cache.download(a.image, tmp)
if tmp.stat().st_size > IMAGE_MAX:
raise AnnouncementError("image larger than 5 MiB")
with Image.open(tmp) as im:
im.verify()
fmt = (im.format or "").lower()
ext = {"jpeg": ".jpg", "png": ".png", "webp": ".webp", "gif": ".gif"}.get(fmt)
if not ext:
raise AnnouncementError(f"image format {fmt or '?'} (use PNG, JPEG, WebP or GIF)")
out = folder / f"{stem}{ext}"
tmp.replace(out)
return out
except Exception as exc: # noqa: BLE001
_log.warning("announcement image skipped: %s", exc)
(folder / f"{stem}.part").unlink(missing_ok=True)
return None
+49
View File
@@ -398,6 +398,30 @@ def rename(package: str = typer.Argument(..., help="the game (package or part of
pipeline.sync_title(pkg, _target(None, to="pc"), rep)
@app.command("new-version")
def new_version(package: str = typer.Argument(..., help="the game (package or part of its title)"),
path: Path = typer.Argument(..., exists=True, help="the newer version: an APK (Quest/Android games), "
"an AppImage/archive/folder (Linux apps) or the game folder (PC VR)")):
"""Point a game at a newer version of it (GitHub #161). Its name, artwork, tags and your own recipe stay; install
it again (frameport install) to update it on the Frame."""
[pkg] = _pkgs(package, False)
try:
res = pipeline.replace_source(pkg, path, printing_reporter(verbose=False))
except pipeline.WrongPackage as exc:
raise typer.BadParameter(f"that APK is a different app: {exc}") from None
except ValueError as exc:
raise typer.BadParameter(str(exc)) from None
g = res["entry"]
typer.echo(f"{pkg}: now {path}" + (f" (program {g.get('exe')})" if g.get("kind") in ("rift", "linux") else ""))
if res.get("older"):
typer.echo("Heads-up: that APK is an older version than the one before.", err=True)
if res.get("different"):
typer.echo(f"Heads-up: {res.get('name')} looks like a different app than {g.get('title')}.", err=True)
if g.get("kind") == "rift" and g.get("exe_confirmed") is False:
typer.echo(f"More than one program could start it; FramePort picked {g.get('exe')}. Another one: "
f"frameport recipe {pkg} --exe <program>", err=True)
@app.command()
def patches():
"""List every available patch."""
@@ -706,6 +730,31 @@ def frame_trust_key(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
typer.echo(f"connected: {info.get('hostname') or t.host} (new key saved)")
@frame_app.command("ssh-keys")
def frame_ssh_keys(add: list[str] = typer.Option([], "--add", help="a private key file to log in with too"),
remove: list[str] = typer.Option([], "--remove", help="stop using this key file (it stays)")):
"""Private SSH key files FramePort logs in with besides its own key (Settings → Installing → SSH keys): for a
Frame that accepts only your own key. The Frame connection and OpenSSH uploads both use them. Lists them."""
from .frame.connection import KeyFileError, add_extra_key, extra_key_status, remove_extra_key
failed = False
for path in remove:
typer.echo(f"removed {path}" if remove_extra_key(path) else f"not in the list: {path}")
for path in add:
try:
typer.echo(f"added {add_extra_key(path)}")
except KeyFileError as exc:
typer.echo(f"can't use {path}: {exc}", err=True)
failed = True
status = extra_key_status()
for path, problem in status:
typer.echo(f"{path}" + (f" (not usable: {problem})" if problem else ""))
if not status:
typer.echo("no extra keys: FramePort's own key and your SSH agent's keys only")
if failed:
raise typer.Exit(1)
@frame_app.command("usb-check")
def frame_usb_check(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
no_speed: bool = typer.Option(False, help="skip the upload speed test (2 × 128 MB)")):
+100 -4
View File
@@ -1,8 +1,8 @@
"""SSH connection to a Steam Frame and the remote agent protocol.
Authentication: FramePort's own keys first (<user data>/ssh/id_ed25519, installed by the bootstrap script, and
id_rsa, registered through Valve's devkit pairing, which only takes RSA keys), then the user's SSH agent/keys, then a
password if given.
id_rsa, registered through Valve's devkit pairing, which only takes RSA keys), then key files the user added in
Settings (EXTRA_KEYS_SETTING), then the user's SSH agent/keys, then a password if given.
"""
from __future__ import annotations
@@ -163,6 +163,96 @@ def app_keys() -> list[paramiko.PKey]:
return keys
EXTRA_KEYS_SETTING = "ssh.extra_keys" # library setting: private key files the user added (Settings → Installing)
class KeyFileError(ValueError):
"""A key file the user named can't be used (missing, not a private key, protected with a passphrase)."""
def load_key_file(path) -> paramiko.PKey:
"""A private key FramePort can log in with, or KeyFileError with a sentence saying why not."""
p = Path(path).expanduser()
if not p.is_file():
raise KeyFileError(f"{p}: file not found")
try:
return paramiko.PKey.from_path(p)
except (paramiko.SSHException, ValueError, OSError, UnicodeDecodeError, TypeError) as exc:
# paramiko says so with PasswordRequiredException (PEM) or a plain SSHException (OpenSSH format)
if isinstance(exc, paramiko.PasswordRequiredException) or "password" in str(exc).lower():
raise KeyFileError(f"{p.name} is protected with a passphrase, which FramePort can't ask for: add the "
"key to your SSH agent instead (FramePort also logs in with the agent's keys)") from None
raise KeyFileError(f"{p.name} isn't a private SSH key FramePort can read "
f"(OpenSSH or PEM format; a .pub file is the public half): {exc}") from None
def extra_key_paths() -> list[Path]:
"""The key files from Settings, as stored (each may be missing or unreadable by now: see extra_keys)."""
try:
from ..core import library
stored = library.peek_setting(EXTRA_KEYS_SETTING, [])
except Exception: # noqa: BLE001 - no library yet
return []
if isinstance(stored, str):
stored = [stored]
paths: list[Path] = []
for value in stored if isinstance(stored, list) else []:
if isinstance(value, str) and value.strip():
p = Path(value.strip()).expanduser()
if p not in paths:
paths.append(p)
return paths
def add_extra_key(path) -> Path:
"""Check a key file (KeyFileError if FramePort can't use it) and add it to Settings. Returns the stored path."""
p = Path(path).expanduser()
if p.name.endswith(".pub") and p.with_name(p.name[:-4]).is_file():
raise KeyFileError(f"{p.name} is the public half: pick {p.name[:-4]} (the file without .pub) instead")
load_key_file(p)
p = p.resolve()
from ..core import library
library.update_setting(EXTRA_KEYS_SETTING, lambda old: [v for v in (old if isinstance(old, list) else [])
if Path(v).expanduser() != p] + [str(p)], [])
return p
def remove_extra_key(path) -> bool:
"""Take a key file out of Settings (the file itself stays). False if it wasn't there."""
from ..core import library
p = Path(path).expanduser()
before = extra_key_paths()
library.update_setting(EXTRA_KEYS_SETTING, lambda old: [v for v in (old if isinstance(old, list) else [])
if Path(v).expanduser() != p], [])
return p in before
def extra_key_status() -> list[tuple[Path, str]]:
"""Each key file from Settings with "" when usable, else why not (for Settings and the CLI)."""
status = []
for path in extra_key_paths():
try:
load_key_file(path)
status.append((path, ""))
except KeyFileError as exc:
status.append((path, str(exc)))
return status
def extra_keys() -> list[tuple[Path, paramiko.PKey]]:
"""The usable keys from Settings; unusable ones are logged and skipped (a login still tries the others)."""
keys = []
for path in extra_key_paths():
try:
keys.append((path, load_key_file(path)))
except KeyFileError as exc:
log.warning("SSH key from Settings skipped: %s", exc)
return keys
@dataclass
class FrameTarget:
host: str
@@ -409,6 +499,10 @@ class Frame:
_video_codec_digest: str = "" # "<manifest digest>:<on|off>" handled on this connection (incl. a failed install)
_video_codec_lock: threading.Lock = field(default_factory=threading.Lock)
home: str = ""
# how the last login worked: "app_key" (FramePort's own key), "user_key" (a key file from Settings),
# "agent" (this PC's SSH agent / ~/.ssh keys) or "password"; OpenSSH uploads follow it (native_upload)
auth: str = ""
openssh_relaxed: bool | None = None # native_upload: True once OpenSSH needed this PC's own keys/agent
# ------------------------------------------------------------------ connect
def connect(self, timeout: float = 10) -> Frame:
@@ -459,7 +553,8 @@ class Frame:
kwargs = dict(hostname=self.target.host, port=self.target.port, username=self.target.user, timeout=timeout,
banner_timeout=timeout, auth_timeout=timeout)
errors = []
attempts = [("app_key", k) for k in app_keys()] + [("agent", None), ("password", None)]
attempts = ([("app_key", k) for k in app_keys()] + [("user_key", k) for _, k in extra_keys()]
+ [("agent", None), ("password", None)])
for attempt, pkey in attempts:
if attempt == "password" and not self.password:
continue
@@ -467,7 +562,7 @@ class Frame:
client.load_host_keys(str(known))
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # trust on first use (pairing)
try:
if attempt == "app_key":
if attempt in ("app_key", "user_key"):
client.connect(pkey=pkey, allow_agent=False, look_for_keys=False, **kwargs)
elif attempt == "agent":
client.connect(allow_agent=True, look_for_keys=True, **kwargs)
@@ -492,6 +587,7 @@ class Frame:
raise
else:
raise FrameNotPaired(bool(self.password), errors)
self.auth = attempt
client.save_host_keys(str(known))
transport = client.get_transport()
transport.set_keepalive(15)
+156 -29
View File
@@ -2,10 +2,9 @@
from __future__ import annotations
import contextlib
import csv
import io
import os
import posixpath
import re
import shutil
import signal
import subprocess
@@ -13,19 +12,30 @@ import tarfile
import tempfile
import threading
import time
from pathlib import Path
from pathlib import Path, PureWindowsPath
from .connection import sh_quote, ssh_dir
from .connection import extra_keys, sh_quote, ssh_dir
def quote(value) -> str:
def quote(value, windows=False) -> str:
"""One sftp batch argument, taken literally.
Inside double quotes sftp escapes glob characters (* ? [) itself and undoes that for put's destination, so
they must NOT be escaped again: an extra backslash became part of the name (GitHub #324: "[ENG]" in a folder
name made every OpenSSH upload fail, on Linux too). Only backslash and the quote are escaped. ``windows``: a
local Windows path goes with forward slashes; Win32-OpenSSH's sftp turns every backslash of a local path into
'/', escapes included ("C:\\\\Users" → "C://Users").
"""
value = str(value)
if any(c in value for c in "\r\n\x00"):
raise ValueError("SFTP paths cannot contain newlines or NUL")
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
for char in "*?[":
escaped = escaped.replace(char, "\\" + char)
return '"' + escaped + '"'
if windows:
value = PureWindowsPath(value).as_posix()
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def quote_local(path) -> str:
return quote(path, windows=os.name == "nt")
def size_of(sftp, path):
@@ -35,27 +45,74 @@ def size_of(sftp, path):
return 0
def key_files(relaxed=False) -> list[Path]:
"""The private keys OpenSSH offers: FramePort's own (<data>/ssh) and the usable key files from Settings;
``relaxed`` also this PC's default keys (default_key_files)."""
keys = [ssh_dir() / name for name in ("id_ed25519", "id_rsa") if (ssh_dir() / name).exists()]
for path in [path for path, _ in extra_keys()] + (default_key_files() if relaxed else []):
if path not in keys:
keys.append(path)
return keys
def default_key_files() -> list[Path]:
"""This PC's own keys that Paramiko's "agent" login tries (look_for_keys: ~/.ssh/id_rsa, id_dsa, id_ecdsa,
id_ed25519; on Windows ~/ssh too), usable without a passphrase. OpenSSH needs them named: it skips its default
keys as soon as one -i is given, and Windows OpenSSH refuses a key file others can read (a private copy can't
be refused)."""
from .connection import KeyFileError, load_key_file
folders = [Path.home() / ".ssh"] + ([Path.home() / "ssh"] if os.name == "nt" else [])
found = []
for folder in folders:
for name in ("id_rsa", "id_dsa", "id_ecdsa", "id_ed25519"):
path = folder / name
if not path.is_file():
continue
try:
load_key_file(path)
except KeyFileError:
continue # passphrase-protected or not a key: only the agent can use it
found.append(path)
return found
def current_sid() -> str:
"""This Windows account's SID. whoami prints in the console code page: read bytes, so a user name with
accents can't break decoding (only the ASCII SID is used)."""
out = subprocess.check_output(["whoami", "/user", "/fo", "csv", "/nh"], **hidden())
match = re.search(rb"S-1-[0-9]+(?:-[0-9]+)+", out)
if not match:
raise OSError("cannot find this Windows account's SID for the key copies' permissions")
return match.group().decode()
@contextlib.contextmanager
def credentials(frame, port_flag="-P"):
"""Private temporary copies avoid modifying pairing keys, including their Windows ACLs."""
key_paths = [ssh_dir() / name for name in ("id_ed25519", "id_rsa") if (ssh_dir() / name).exists()]
if not key_paths:
def credentials(frame, port_flag="-P", relaxed=False):
"""OpenSSH arguments that log in like the Frame connection and trust only the host key it pinned.
Keys are offered as private temporary copies (that avoids modifying pairing keys, including their Windows
ACLs; Windows OpenSSH refuses key files others can read). Strict (default): FramePort's keys and the key
files from Settings only (IdentitiesOnly). ``relaxed``: also this PC's default ~/.ssh keys and SSH agent, the
way Paramiko's "agent" login does (GitHub #242: a Frame that only authorizes the user's own key). Verbose
(-v), so a refused login can say which keys were offered (failure()).
"""
key_paths = key_files(relaxed)
if not key_paths and not relaxed:
raise OSError("no FramePort key available for OpenSSH")
key = frame.client.get_transport().get_remote_server_key()
with tempfile.TemporaryDirectory(prefix="frameport-upload-") as directory:
directory = Path(directory)
sid = None
if os.name == "nt":
row = next(csv.reader(io.StringIO(subprocess.check_output(
["whoami", "/user", "/fo", "csv", "/nh"], text=True, **hidden()))))
sid = row[-1]
sid = current_sid()
subprocess.run(["icacls", str(directory), "/inheritance:r", "/grant:r", f"*{sid}:(OI)(CI)F"],
check=True, stdout=subprocess.DEVNULL, **hidden())
else:
directory.chmod(0o700)
copies = []
for path in key_paths:
copy = directory / path.name
for n, path in enumerate(key_paths):
copy = directory / f"key{n}" # own names: a key from Settings may be called id_ed25519 too
shutil.copyfile(path, copy)
if sid:
subprocess.run(["icacls", str(copy), "/inheritance:r", "/remove:g", "*S-1-3-4",
@@ -66,13 +123,72 @@ def credentials(frame, port_flag="-P"):
known = directory / "known_hosts"
host = frame.target.host if frame.target.port == 22 else f"[{frame.target.host}]:{frame.target.port}"
known.write_text(f"{host} {key.get_name()} {key.get_base64()}\n")
yield ["-F", os.devnull, *copies, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
yield ["-F", os.devnull, "-v", *copies, "-o", "BatchMode=yes",
*([] if relaxed else ["-o", "IdentitiesOnly=yes"]),
"-o", "StrictHostKeyChecking=yes", "-o", f'UserKnownHostsFile="{known.as_posix()}"',
"-o", f"GlobalKnownHostsFile={os.devnull}", "-o", "ConnectTimeout=10",
"-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3",
port_flag, str(frame.target.port)]
class OpenSSHAuthFailed(OSError):
"""OpenSSH reached the Frame but the login was refused (the keys it offered aren't authorized there)."""
AUTH_REFUSED = ("Permission denied (", "Too many authentication failures", "No more authentication methods")
# debug lines (-v) worth keeping: which keys were found, offered, refused or unreadable
AUTH_DEBUG = ("identity file", "Offering public key", "Server accepts key", "Will attempt key",
"Authentications that can continue", "Authenticated to", "Load key", "no such identity",
"Skipping", "agent", "Next authentication method")
def failure(what: str, code, stderr: bytes) -> OSError:
"""The error for a failed OpenSSH run: OpenSSH's own messages (all non-debug lines: warnings such as
"UNPROTECTED PRIVATE KEY FILE" included) plus the -v lines about keys; OpenSSHAuthFailed for a refused login."""
text = stderr.decode(errors="replace").replace("\r\n", "\n")
lines = [line for line in text.splitlines() if line.strip() and (
not line.startswith("debug") or any(k in line for k in AUTH_DEBUG))]
detail = "\n".join(lines)
if len(detail) > 4000:
detail = "…" + detail[-4000:]
refused = code == 255 and any(marker in text for marker in AUTH_REFUSED)
return (OpenSSHAuthFailed if refused else OSError)(f"{what} failed ({code}): {detail}")
def login_modes(frame) -> list[bool]:
"""How OpenSSH logs in, in order (False = FramePort's keys only, True = also this PC's keys and agent).
A Frame connection that logged in with this PC's own keys/agent (Paramiko's "agent" attempt) goes straight to
relaxed: FramePort's key isn't authorized there. Else strict first, relaxed once more after a refused login.
A mode that worked is kept for the rest of the connection. FramePort doesn't add its key to the Frame for
this: it only does that after a password login, where the user typed the Frame's password for it."""
known = getattr(frame, "openssh_relaxed", None)
if known is not None:
return [bool(known)]
if getattr(frame, "auth", "") == "agent":
return [True]
return [False, True]
def with_login_modes(frame, reporter, run):
"""run(relaxed) in each login mode until one isn't refused (see login_modes)."""
modes = login_modes(frame)
if modes == [True] and getattr(frame, "openssh_relaxed", None) is None:
reporter.log("the Frame connection logged in with this PC's own SSH keys or agent: OpenSSH uses them too")
for n, relaxed in enumerate(modes):
try:
result = run(relaxed)
except OpenSSHAuthFailed as exc:
if n == len(modes) - 1:
raise
reporter.log(f"OpenSSH login with FramePort's SSH keys was refused; trying this PC's own SSH keys and "
f"agent too. {exc}")
continue
with contextlib.suppress(AttributeError):
frame.openssh_relaxed = relaxed
return result
def hidden():
return {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {}
@@ -99,6 +215,11 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
Only successful batches are promoted, after every process has exited successfully. All control operations
happen in the supervising thread, so Paramiko's SFTP client is never shared across threads.
"""
return with_login_modes(frame, reporter, lambda relaxed: _upload(
frame, items, remote_root, reporter, total, sent, workers, relaxed))
def _upload(frame, items, remote_root, reporter, total, sent, workers, relaxed):
executable = shutil.which("sftp")
if not executable:
raise OSError("system OpenSSH SFTP is unavailable")
@@ -111,7 +232,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
sizes, offsets, progress = {}, {}, {}
for path, rel, size in sorted(items, key=lambda item: item[2], reverse=True):
remote = posixpath.join(remote_root, rel)
quote(path)
quote_local(path)
quote(remote)
offset = size_of(frame.sftp, remote + ".part")
if offset > size:
@@ -127,7 +248,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
speed = Speed(total - sent - sum(offsets.values()))
reporter.log(f"uploading large files with OpenSSH SFTP ({len(groups)} workers)")
setup_started = time.monotonic()
with credentials(frame) as args, contextlib.ExitStack() as stack:
with credentials(frame, relaxed=relaxed) as args, contextlib.ExitStack() as stack:
monitor = stack.enter_context(frame.client.open_sftp())
monitor.get_channel().settimeout(2)
reporter.log(f"OpenSSH credentials and progress connection ready in {time.monotonic() - setup_started:.2f}s")
@@ -139,7 +260,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
for path, rel, _ in group:
remote = posixpath.join(remote_root, rel) + ".part"
command = "reput" if offsets[rel] else "put"
batch.write(f"{command} {quote(path)} {quote(remote)}\n".encode())
batch.write(f"{command} {quote_local(path)} {quote(remote)}\n".encode())
batch.seek(0)
errors = stack.enter_context(tempfile.TemporaryFile())
process = subprocess.Popen([executable, *args, "-b", "-",
@@ -152,8 +273,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
for process, errors in processes:
if process.poll() not in (None, 0):
errors.seek(0)
detail = errors.read().decode(errors="replace")[-1500:]
raise OSError(f"OpenSSH SFTP failed ({process.returncode}): {detail}")
raise failure("OpenSSH SFTP", process.returncode, errors.read())
for rel, size in sizes.items():
reporter.check_cancel()
progress[rel] = max(progress[rel], min(size, size_of(
@@ -187,11 +307,16 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
def put_tar(frame, files, remote_root, reporter, progress):
"""Stream a tar batch over paired OpenSSH; supervise writes so Cancel also stops a blocked pipe."""
return with_login_modes(frame, reporter,
lambda relaxed: _put_tar(frame, files, remote_root, reporter, progress, relaxed))
def _put_tar(frame, files, remote_root, reporter, progress, relaxed):
executable = shutil.which("ssh")
if not executable:
raise OSError("system OpenSSH SSH is unavailable")
reporter.check_cancel()
with credentials(frame, port_flag="-p") as args, tempfile.TemporaryFile() as errors:
with credentials(frame, port_flag="-p", relaxed=relaxed) as args, tempfile.TemporaryFile() as errors:
command = f"mkdir -p {sh_quote(remote_root)} && tar -xf - -C {sh_quote(remote_root)}"
process = subprocess.Popen([executable, *args, f"{frame.target.user}@{frame.target.host}", command],
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=errors,
@@ -222,12 +347,14 @@ def put_tar(frame, files, remote_root, reporter, progress):
if not writer.is_alive() and process.poll() is None:
time.sleep(0.1)
reporter.check_cancel()
if failures and process.poll() is None: # a refused login shows as a broken pipe first
with contextlib.suppress(subprocess.TimeoutExpired):
process.wait(timeout=2)
if process.poll() not in (None, 0):
errors.seek(0)
raise failure("OpenSSH tar", process.returncode, errors.read())
if failures:
raise failures[0]
if process.poll() != 0:
errors.seek(0)
raise OSError(f"OpenSSH tar failed ({process.returncode}): "
+ errors.read().decode(errors="replace")[-1500:])
finally:
stop_process(process)
writer.join()
+31 -4
View File
@@ -220,6 +220,28 @@ class PcvrPlan:
art_lookup: str | None = None # package whose store art to use
dest: str | None = None
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
source_id: str | None = None # which version of the game folder (GitHub #161: a newer one was added)
changed_since: float | None = None # a newer version: game files changed after this are sent again (see below)
def changed_since(path: Path, since: float | None) -> bool:
"""A newer version of a Linux app / PC VR game (GitHub #161): uploads skip files the Frame has with the same
size, so a changed file of the same size would stay old. Files written (or unpacked: ctime) after the last
install are sent again; since=None (no newer version) never, 0 (last install unknown) always."""
if since is None:
return False
try:
st = path.stat()
except OSError:
return True
return max(st.st_mtime, st.st_ctime) > since
def _recipe_record(recipe: dict | None, source_id: str | None) -> dict | None:
"""The deployment's recipe record, with the source version it was installed from (ui.components.source_replaced)."""
if not source_id:
return recipe
return {**(recipe or {}), "source_id": source_id}
def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
@@ -259,7 +281,8 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
manifests["helpers"] = {p.name: p.stat().st_size for p in sorted(helpers.iterdir()) if p.is_file()}
sources["helpers"] = helpers
to_send = [(t, rel) for t, m in manifests.items() for rel, size in m.items()
if prep["existing"].get(t, {}).get(rel) != size]
if prep["existing"].get(t, {}).get(rel) != size
or t == "game" and changed_since(plan.game_dir / rel, plan.changed_since)]
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, "
@@ -290,7 +313,8 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
game_args=game_args(plan.recipe), no_crash_reporter="pcvr.no_crash_reporter" in plan.recipe.patches,
libovr_redirect="pcvr.libovr_redirect" in plan.recipe.patches,
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version, vr=not is_flat_windows(plan.package),
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, overlay=plan.overlay,
recipe=_recipe_record({"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, plan.source_id),
overlay=plan.overlay,
)
reporter.log(f"installed at {result['base']} (Proton {result['proton']}, Steam shortcut id {result['appid']})")
log_overlay(result.get("overlay"), reporter)
@@ -325,6 +349,8 @@ class LinuxPlan:
desktop_entry: bool = True # an entry in Desktop Mode's menu and on its desktop (GitHub #84)
desktop_icon: bool = True # with it, a copy on Desktop Mode's desktop (GitHub #212)
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
source_id: str | None = None # which version of the app (GitHub #161: a newer one was added)
changed_since: float | None = None # a newer version: files changed after this are sent again
def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
@@ -339,7 +365,8 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
else:
manifest = local_data_manifest(plan.root)
manifest = {k: v for k, v in manifest.items() if not k.startswith(".unpacked")}
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size]
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size
or changed_since(plan.root / rel, plan.changed_since)]
need = sum(manifest[rel] for rel in to_send)
if need > prep["free_bytes"] - (1 << 30):
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
@@ -358,7 +385,7 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
appimage=plan.appimage, openxr=plan.openxr, x86_64=plan.x86_64, manifests={"app": manifest},
executables=executables, tags=_tags(plan.package), dest=plan.dest,
desktop_entry=plan.desktop_entry, desktop_icon=plan.desktop_icon, overlay=plan.overlay,
timeout=900)
recipe=_recipe_record(None, plan.source_id), timeout=900)
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
log_overlay(result.get("overlay"), reporter)
apply_app_icon(frame, plan.package, prep.get("anchor"), result, reporter)
+18
View File
@@ -94,6 +94,7 @@
"AC Nexus: no AppSW at 72 Hz": "",
"AC Nexus: no AppSW at 90 Hz": "",
"Activity": "",
"Add a key file…": "",
"Add a Linux app folder…": "",
"Add a Linux app…": "",
"Add a PC game folder…": "",
@@ -557,6 +558,7 @@
"Frame OpenXR compatibility layer": "",
"FrameBridge OpenXR adapter": "",
"FramePort": "",
"FramePort also logs in with {name} from the next connection.": "",
"FramePort can't update itself in {value} (no permission) — opening the release page to download it": "",
"FramePort changelog": "",
"FramePort checks GitHub for a new version every few hours. Update now downloads and checks it, then restarts FramePort; your games and settings stay.": "",
@@ -568,6 +570,7 @@
"FramePort isn't sure which program starts this game. Open the game and use … → Change program… to pick it.": "",
"FramePort keeps its own copies; nothing is installed system-wide": "",
"FramePort live view": "",
"FramePort logs in to the Frame with its own key. If your Frame accepts only one of your own keys, add that private key file here; fast uploads use it too. A key with a passphrase: add it to your SSH agent instead.": "",
"FramePort looked at your last session of {n} game and has something to try.": [
"",
""
@@ -664,6 +667,7 @@
"Hardware video decoding (H.264, HEVC, VP9)": "",
"Hardware video decoding on the Frame": "",
"Heads-up: that APK is an older version than the one before.": "",
"Heads-up: {name} looks like a different app than {title}.": "",
"headset found": "",
"Hi there!": "",
"Hide Android's navigation bar": "",
@@ -836,6 +840,7 @@
"Lets the game find extra content it keeps next to its data (for example Star Wars Tales' seasons).": "",
"Lets the game finish loading when its separate loading splash stalls the render thread.": "",
"Lets the game leave its loading screen: it waited for a Quest-only step that never starts on the Frame (for example Vader Immortal).": "",
"Lets the game load: it reserves more memory at start than the Frame grants in one piece and crashed before its first frame (for example Transformers Beyond Reality).": "",
"Lets the game use language files already in its data instead of waiting for a download that never comes.": "",
"Lets the game use your controllers as a gamepad instead of a pointer, for 2D games with touch controls (for example SDL games).": "",
"Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard. VR keeps working.": "",
@@ -941,6 +946,8 @@
"New recipe applied. Click Update on Frame to install it.": "",
"Newer APK for {title}": "",
"Newer APK of {title}": "",
"Newer version for {title}": "",
"Newer version of {title}": "",
"Next": "",
"Nice, it works! FramePort receives install links. (Demo: nothing was installed.)": "",
"No": "",
@@ -1026,6 +1033,7 @@
"On your Frame": "",
"on: {names}": "",
"One PC game folder": "",
"Only FramePort's own key, and your SSH agent's keys.": "",
"Only if 360° pictures show wrongly.": "",
"Only if 360° pictures show wrongly: 1 = upside down, 2 = mirrored, 4 = turned around (add values to combine).": "",
"Only install software from sites you trust. FramePort downloads it from {host} and installs it on your Frame.": "",
@@ -1138,6 +1146,7 @@
"Pretty smooth": "",
"Prevents a start-up crash in some Unreal games.": "",
"Previous": "",
"Private SSH key file (not the .pub file)": "",
"Problems and feedback": "",
"Processes": "",
"Processor": "",
@@ -1173,6 +1182,7 @@
"Reading the file list from the Frame…": "",
"Reading the install link": "",
"Reading the new APK": "",
"Reading the new version": "",
"ready": "",
"Ready": "",
"Ready to play": "",
@@ -1412,6 +1422,7 @@
"Sound on": "",
"Source": "",
"square, 256 × 256": "",
"SSH keys": "",
"Stable (default)": "",
"Stand in for cube layers": "",
"Standard for every game": "",
@@ -1462,6 +1473,7 @@
"Stereo (3D) 360° layers: 0 = as the game sends them, 2 = flat (the left image to both eyes).": "",
"Stop": "",
"Stop objects popping in and out": "",
"Stop using this key (the file stays)": "",
"Stopped: {why}": "",
"Stops a black picture in Minecraft launchers (for example QuestCraft).": "",
"Stops a crash a few seconds after start in some Unreal games with anti-aliasing (for example Star Wars Pinball VR).": "",
@@ -1513,6 +1525,7 @@
"Test Proton on the Frame": "",
"Text and layout size": "",
"That APK is a different app, not {title}.": "",
"That key can't be used: {error}": "",
"That theme file can't be used: {error}": "",
"That's me!": "",
"The APK file is damaged or incomplete. Get the game again and rescan the folder.": "",
@@ -1680,12 +1693,14 @@
"Unreal builds of Meta XR Audio abort ('ClassNotFoundException com.oculus.os.AnalyticsEvent') on non-Quest devices. Patches that one JNI FindClass call so the library continues (for example NOPE Challenge).": "",
"Unreal games can check their whole data file (.obb) before starting (bVerifyOBBOnStartUp). On the Frame that check's progress and error screens are invisible (Lepton runs VR games without an Android window), so the game seems to hang on its DownloaderActivity, for example Contractors. This turns the check off in the manifest; Unreal still checks that the OBB is there with the right size, so a missing or wrong-version OBB still stops the game.": "",
"Unreal games start CrashReportClient when they crash, which leaves a crash dialog instead of simply closing. This passes -nocrashreports to the game and, on the Frame, renames the game's copy of CrashReportClient.exe so it can't start (your game files on this PC aren't changed).": "",
"Unreal reserves its memory ranges on Android with ordinary read/write mappings. Games built with Unreal's Binned3 allocator reserve tens of gigabytes at once while the engine loads (76 GiB in Transformers Beyond Reality); Quest allows that, the Frame's kernel refuses mappings larger than its memory, and the game crashes before its first frame (SIGSEGV at fault addr 0x10 in libUnreal.so, inside System.loadLibrary). This marks those mappings MAP_NORESERVE, as Quest effectively treats them: memory is still only used when the game touches it.": "",
"Unreal's Java OBB delivery check can leave a game on an invisible DownloaderActivity screen even with the manifest CRC check off (e.g. Into The Radius). Makes known Unreal delivery predicates return true, including recognized obfuscated versions, bypassing the Java name and size gate. Experimental: the native engine still needs complete, matching, readable OBB data.": "",
"Unreal's Oculus module looks up every OVRPlugin function it was built against and starts no VR when one is missing; OVRPort's OpenXR OVRPlugin lacks some old ones (for example ovrp_GetPTWNear, which Unreal 4.25 builds like Star Wars Pinball VR look up). The game then never starts an OpenXR session and crashes a few seconds in. Adds a generated library to libOVRPlugin.so with a stand-in for each missing function that reports failure (ovrpFailure), so Unreal's Oculus support starts. The functions OVRPort has are untouched.": "",
"Unreal: bypass the stalled loading splash": "",
"Unreal: don't wait for the Quest shader precompile": "",
"Unreal: match multiview depth to the eye size": "",
"Unreal: Quest controller bindings (RPOC key map)": "",
"Unreal: reserve memory like on Quest": "",
"Unreal: skip the OBB delivery check": "",
"Unreal: stand-ins for OVRPlugin functions OVRPort lacks": "",
"Unreal: start without the OBB check": "",
@@ -1711,6 +1726,7 @@
"Update the app only?": "",
"Update tools": "",
"Update with a newer APK…": "",
"Update with a newer version…": "",
"Updates": "",
"Updates every game marked \"Update ready\", one after another.": "",
"Updates the game, frame rate and battery every 5 seconds. Turn off to save the Frame's power.": "",
@@ -1971,6 +1987,8 @@
"{title}: name updated in Steam": "",
"{title}: newer APK added": "",
"{title}: newer APK added. Press Update on Frame to install it.": "",
"{title}: newer version added": "",
"{title}: newer version added. Press Update to install it.": "",
"{title}: ready": "",
"{title}: Steam artwork updated on the Frame": "",
"{title}: {line}": "",
@@ -38,7 +38,7 @@ def _words(data: bytes | bytearray, off: int, n: int) -> list[int]:
def _function_offset(data: bytes, size: int) -> int | None:
"""File offset of FUNC in the loader (None: not an ELF, no such export, or shorter than `size` bytes)."""
try:
dynsym = elf._elf(data).get_section_by_name(".dynsym")
dynsym = elf.dynsym_section(elf._elf(data))
except Exception: # noqa: BLE001
return None
found = dynsym.get_symbol_by_name(FUNC) if dynsym is not None else None
@@ -51,7 +51,7 @@ def _x0_address(data: bytes, segs, at: int, back: int = 8) -> int | None:
def _function(data: bytes, name: str) -> tuple[int, int] | None:
sec = elf._elf(data).get_section_by_name(".dynsym")
sec = elf.dynsym_section(elf._elf(data))
for sym in (sec.get_symbol_by_name(name) or []) if sec else []:
if sym["st_value"] and sym["st_size"]:
return sym["st_value"], sym["st_size"]
@@ -15,7 +15,7 @@ VIDEO_LIBS = ("libavcodec", "libffmpeg", "libvlc", "libmpv", "libijkffmpeg")
def raise_swapchain_limit(data: bytes) -> tuple[bytes | None, int]:
"""Rewrite the `cmp wN, #4096` size guards at the start of OVRPort's swapchain entry points."""
try:
dynsym = elf._elf(data).get_section_by_name(".dynsym")
dynsym = elf.dynsym_section(elf._elf(data))
except Exception: # noqa: BLE001
return None, 0
if dynsym is None:
@@ -112,6 +112,7 @@ class UnityOculusCheck(Patch):
return a.engine == "Unity" and "libOVRPlugin.so" in a.libs and bool((a.extra or {}).get("unity_oculus_check"))
def detect(self, a):
# not narrowed by libOculusXRPlugin.so: a verified game ships it and still needs this fix
if self.applies(a):
return Suggestion(True, "Unity with built-in Oculus support: it checks for Meta's system apps before "
"starting VR, else it runs as a 2D app (for example Accounting+, BattleSisters).")
+3 -2
View File
@@ -12,7 +12,7 @@ frame.unity_text_input).
from __future__ import annotations
from ..base import Suggestion, register
from .unity_text_input import RET_FALSE, Il2cppReturnPatch
from .unity_text_input import RET_FALSE, Il2cppReturnPatch, can_rewrite_il2cpp
RET_ZERO = RET_FALSE # mov w0, #0 ; ret
@@ -40,7 +40,8 @@ class UnityRuntimeMsaa(Il2cppReturnPatch):
return a.engine == "Unity" and "libil2cpp.so" in a.libs and not a.only_32bit
def detect(self, a):
if self.applies(a) and (a.extra or {}).get("ovr_runtime_msaa") and "GLES" in a.graphics:
if (self.applies(a) and can_rewrite_il2cpp(a)
and (a.extra or {}).get("ovr_runtime_msaa") and "GLES" in a.graphics):
return Suggestion(True, "GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled "
"render-to-texture can hang the Frame's GPU.")
return None
@@ -85,6 +85,19 @@ def patch_field_loads(lib: bytes, method: tuple[int, int], field_offset: int, va
ALL_TARGETS: dict[str, list[str]] = {} # every IL2CPP patch's methods: one Cpp2IL run (and cache entry) serves all
def can_rewrite_il2cpp(a) -> bool:
"""Automatic return patches need AArch64 code and metadata Cpp2IL can read.
Unity 2017's metadata is unreadable by our Cpp2IL workflow (AGENTS.md, Accounting+). Unknown versions keep the
existing fallback: apply() reads the version from the APK. This does not restrict manually selected patches.
"""
try:
major = int(str((a.extra or {}).get("unity_version") or "0").split(".")[0])
except ValueError:
major = 0
return "arm64-v8a" in a.abis and not 0 < major < 2018
class Il2cppReturnPatch(Patch):
"""Make IL2CPP methods return a constant (targets), found per game with Cpp2IL (analysis/il2cpp.py)."""
targets: dict[str, dict[str, bytes]] = {}
@@ -164,7 +177,7 @@ class UnityTextInput(Il2cppReturnPatch):
return a.engine == "Unity" and "libil2cpp.so" in a.libs and bool((a.extra or {}).get("text_fields"))
def detect(self, a):
if self.applies(a):
if self.applies(a) and can_rewrite_il2cpp(a):
kinds = " and ".join((a.extra or {}).get("text_fields"))
return Suggestion(True, f"Unity app with text fields ({kinds}): they close at once on the Frame because "
"it has no system keyboard.")
@@ -40,6 +40,13 @@ class UnityTlsKeyPool(Patch):
def detect(self, a):
heavy = heavy_native_libs(a)
if self.applies(a) and _unity_major(a) >= 6000 and heavy:
# Firebase's Firestore and Gecko both consume keys alongside Unity 6 and the Frame's graphics/XR
# runtime. This is the documented exhaustion case (AGENTS.md, #198), rather than just any Firebase
# library: keep a lone SDK as an optional diagnostic fix below.
if "libFirebaseCppFirestore.so" in heavy and "libxul.so" in heavy:
return Suggestion(True, "Unity 6 with Firebase Firestore and Gecko: these native runtimes can "
"exhaust Android's 128 thread keys alongside the Frame's graphics and XR "
"libraries. Pool Unity's keys to leave room for them.")
return Suggestion(False, f"Unity 6 with {', '.join(heavy)}: turn on if the game aborts at start with "
"\"cannot create thread specific key\" (for example Wander).")
return None
@@ -0,0 +1,159 @@
"""Unreal's Android memory reservations without commit-charge accounting (GitHub #349, Transformers Beyond Reality).
Unreal reserves virtual memory on Android with `mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1,
0)` (FPlatformVirtualMemoryBlock::AllocateVirtual: Android has no separate commit step). Games built with
FMallocBinned3 reserve the whole allocator range up front while libUnreal.so loads, 76 GiB in Transformers Beyond
Reality. Quest's kernel lets that through; the Frame runs with the kernel's heuristic overcommit (mode 0), which
refuses any single writable private mapping larger than RAM + swap (16 + 8 GiB), so the reservation fails, Unreal's
out-of-memory handler runs before its log exists and dereferences a null pointer (SIGSEGV, fault addr 0x10, from a
static constructor inside System.loadLibrary). Adding MAP_NORESERVE to those mmap calls gives them the Quest
behaviour: pages are still only backed when touched, the mapping just isn't charged up front.
Each site is `mov w3, #0x22` (MAP_PRIVATE|MAP_ANONYMOUS) a few instructions before a `bl` to mmap's PLT stub, with
`mov x0, xzr` and `mov w2, #3` in the same window; the one instruction becomes `mov w3, #0x4022`. Nothing else changes
and nothing moves; only arm64 engine libraries."""
from __future__ import annotations
import io
import struct
from ...analysis import elf
from ..base import ApkContext, Patch, Suggestion, register
ENGINE_LIBS = ("libUE4.so", "libUnreal.so")
MOV_W3_FLAGS = 0x52800443 # mov w3, #0x22 MAP_PRIVATE|MAP_ANONYMOUS
MOV_W3_NORESERVE = 0x52880443 # mov w3, #0x4022 MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE
MOV_W2_RW = 0x52800062 # mov w2, #3 PROT_READ|PROT_WRITE
MOV_X0_NULL = 0xAA1F03E0 # mov x0, xzr
WINDOW_BEFORE, WINDOW_AFTER = 8, 9
def _mmap_slots(data: bytes) -> set[int]:
"""GOT slots of mmap/mmap64 (R_AARCH64_JUMP_SLOT relocations)."""
from elftools.elf.elffile import ELFFile
from elftools.elf.relocation import RelocationSection
e = ELFFile(io.BytesIO(data))
dynsym = elf.dynsym_section(e)
if dynsym is None:
return set()
slots = set()
for sec in e.iter_sections():
if not isinstance(sec, RelocationSection) or sec["sh_type"] != "SHT_RELA":
continue
for r in sec.iter_relocations():
sym = r["r_info_sym"]
if sym and dynsym.get_symbol(sym).name in ("mmap", "mmap64"):
slots.add(r["r_offset"])
return slots
def _plt_slot(data: bytes, segs, va: int) -> int | None:
"""The GOT slot a PLT stub at `va` jumps through: adrp x16, page; ldr x17, [x16, #off]."""
off = elf.vaddr_to_offset(segs, va)
if off is None or off + 8 > len(data):
return None
adrp, ldr = struct.unpack_from("<II", data, off)
if adrp & 0x9F00001F != 0x90000010 or ldr & 0xFFC003FF != 0xF9400211:
return None
imm = ((adrp >> 29) & 3) | ((adrp >> 5) & 0x7FFFF) << 2
if imm & 1 << 20:
imm -= 1 << 21
return (va & ~0xFFF) + (imm << 12) + ((ldr >> 10) & 0xFFF) * 8
def mmap_sites(data: bytes) -> tuple[list[int], list[int]]:
"""File offsets of the flag instructions of anonymous read/write mmap calls: (still MAP_PRIVATE|MAP_ANONYMOUS,
already MAP_NORESERVE)."""
if not elf.is_elf(data) or not elf.is_64bit(data) or data[5] != 1:
return [], []
slots = _mmap_slots(data)
if not slots:
return [], []
segs = elf.load_segments(data)
stubs: dict[int, bool] = {}
todo, done = [], []
for word, out in ((MOV_W3_FLAGS, todo), (MOV_W3_NORESERVE, done)):
needle = struct.pack("<I", word)
at = data.find(needle)
while at >= 0:
va = next((at - o + v for v, o, s in segs if o <= at < o + s), None) if at % 4 == 0 else None
if va is not None:
for k in range(1, WINDOW_AFTER + 1):
if at + 4 * k + 4 > len(data):
break
w = struct.unpack_from("<I", data, at + 4 * k)[0]
if w >> 26 != 0b100101: # bl
continue
imm = w & 0x3FFFFFF
target = va + 4 * k + ((imm - (1 << 26)) if imm & 1 << 25 else imm) * 4
if target not in stubs:
stubs[target] = _plt_slot(data, segs, target) in slots
if stubs[target]:
window = {struct.unpack_from("<I", data, at + 4 * j)[0]
for j in range(-WINDOW_BEFORE, k) if at + 4 * j >= 0}
if MOV_W2_RW in window and MOV_X0_NULL in window:
out.append(at)
break
at = data.find(needle, at + 1)
return todo, done
def patch_lib(data: bytes) -> tuple[bytes, int]:
todo, _done = mmap_sites(data)
if not todo:
return data, 0
buf = bytearray(data)
for at in todo:
struct.pack_into("<I", buf, at, MOV_W3_NORESERVE)
return bytes(buf), len(todo)
class UnrealNoReserve(Patch):
id = "frame.unreal_noreserve"
title = "Unreal: reserve memory like on Quest"
description = ("Unreal reserves its memory ranges on Android with ordinary read/write mappings. Games built with "
"Unreal's Binned3 allocator reserve tens of gigabytes at once while the engine loads (76 GiB in "
"Transformers Beyond Reality); Quest allows that, the Frame's kernel refuses mappings larger "
"than its memory, and the game crashes before its first frame (SIGSEGV at fault addr 0x10 in "
"libUnreal.so, inside System.loadLibrary). This marks those mappings MAP_NORESERVE, as Quest "
"effectively treats them: memory is still only used when the game touches it.")
order = 49
needs_vr = False
def applies(self, a):
return a.engine == "Unreal" and "arm64-v8a" in a.abis
def detect(self, a):
if self.applies(a) and (a.extra or {}).get("unreal_binned3"):
return Suggestion(True, "Unreal's Binned3 allocator reserves more memory at start than the Frame's "
"kernel allows in one piece; without this the game crashes while it loads.")
return None
def apply(self, ctx: ApkContext) -> bool:
ws = ctx.ws
if ws.abi != "arm64-v8a":
return False
lib = next((n for n in ENGINE_LIBS if ws.has(ws.lib(n))), None)
if not lib:
return False
data, count = patch_lib(ws.read_lib(lib))
if not count:
if not mmap_sites(data)[1]:
ctx.notes.append(f"{self.id}: no anonymous read/write mmap calls found in {lib}, left unchanged")
return False
ws.put(ws.lib(lib), data)
ctx.notes.append(f"{count} mmap call(s) in {lib} reserve with MAP_NORESERVE")
return True
def validate(self, ctx: ApkContext):
ws = ctx.ws
lib = next((n for n in ENGINE_LIBS if ws.has(ws.lib(n))), None)
if not lib or ws.abi != "arm64-v8a":
return []
todo, done = mmap_sites(ws.read_lib(lib))
return [("Unreal memory reservations use MAP_NORESERVE", bool(done) and not todo,
f"{len(done)} rewritten, {len(todo)} left")]
register(UnrealNoReserve)
+5
View File
@@ -486,6 +486,11 @@ class AdapterSetting(Patch):
"Tale, BattleSisters): vibrations are converted before they reach it.",
{"value": 1})
if (self.key == "pose_time_fix" and a.engine == "Unity" and "libOVRPlugin.so" in a.libs
# Absence of the Oculus XR Plugin isn't proof of Unity's built-in VR: Unity OpenXR games can
# bundle OVRPlugin too. The system-app check in libunity is positive evidence of built-in Oculus
# (analysis/detect.py, frame/unity_oculus_check.py). Older analyses lack this signal; converted
# inputs may have had the check rewritten to "android", so keep the legacy fallback for those.
and (a.extra.get("unity_oculus_check", True) or a.is_overport_output)
and not a.extra.get("oculus_xr_plugin") and "libOculusXRPlugin.so" not in a.libs):
return Suggestion(True, "Unity's built-in Oculus support reads controller poses through OVRPlugin at its "
"monotonic \"now\", seconds in the past on the Frame: they are located at the "
+2
View File
@@ -118,6 +118,8 @@ SUMMARIES = {
"empty Gear VR controls on the Frame, for example Vader Immortal).",
"frame.unreal_splash_bypass": "Lets the game finish loading when its separate loading splash stalls the "
"render thread.",
"frame.unreal_noreserve": "Lets the game load: it reserves more memory at start than the Frame grants in one "
"piece and crashed before its first frame (for example Transformers Beyond Reality).",
"frame.unreal_multiview_depth": "Shows the full environment when its upper part is clipped but menu panels "
"look correct.",
"frame.unreal_thumb_touch": "Makes the hands' thumbs follow what your thumb touches (they always pointed up: the "
+123 -13
View File
@@ -137,18 +137,25 @@ def _existing_rift(folder: Path) -> dict | None:
def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe: str | None = None,
force: bool = False, art: bool = False) -> dict | None:
force: bool = False, art: bool = False, replace: dict | None = None) -> 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)."""
force). replace: the library entry this folder is a newer version of (replace_rift_game): it keeps its package and
name."""
from .analysis import rift
from .recommend import catalog
folder = Path(folder)
old = _existing_rift(folder)
if replace is None and not force and any(str(folder) in (g.get("replaced_dirs") or []) for g in library.games()
if g.get("kind") == "rift"):
if reporter: # the older copy of a game updated from another folder: a rescan must not bring it back
reporter.log(f"skipped {folder.name}: an older version of a game that was updated from another folder")
return None
old = replace or _existing_rift(folder)
old_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)
forced = exe is not None or replace is not None # set_exe / a newer version: always analyze
if replace is None:
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
if old and not forced and old_extra.get("fingerprint") and \
old_extra["fingerprint"] == rift.fingerprint(folder, old.get("exe") or ""):
if reporter:
@@ -177,7 +184,9 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
suggested=library.recipe_to_dict(recipe))
if not old or (old.get("recipe") or {}).get("source") != "user":
fields.update(recipe=library.recipe_to_dict(recipe), status=recipe.status)
if not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
if replace is not None:
pass # a newer version of the game: its name stays
elif not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
fields["title"] = recipe.title or a.label # (store-matched titles are kept)
elif old.get("title_locked") and old.get("art_source") not in ("oculusdb", "meta", "steam"):
fields["auto_title"] = recipe.title or a.label # the user's name stays; a reset brings this one
@@ -400,14 +409,20 @@ def missing_obb(entry: dict) -> bool:
return bool(extra.get("expects_obb")) and not (entry.get("data_dir") and entry.get("data_bytes") != 0)
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None) -> dict:
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None,
replace: str | None = None) -> dict:
"""Add a Linux app to the library (no conversion: it's installed as it is): arm64, or x86_64 (run through FEX on
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder)."""
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder). replace: the package of
the library entry this is a newer version of (replace_linux_app): it keeps that package, its name and its art."""
from .analysis import linux
from .core.models import Recipe
path = Path(path)
info = linux.inspect(path)
if replace and not exe and not info["files"]: # a folder: the program the app started before, if it's still there
before = (library.game(replace) or {}).get("exe")
if before and before != info["exe"] and (Path(info["root"]) / before).is_file():
exe = before
if exe:
info["exe"] = exe
x86 = info["machine"] == linux.EM_X86_64
@@ -417,7 +432,7 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
if x86 and reporter:
reporter.log(f"{path.name} is an x86_64 build: the Frame runs it through FEX (x86 translation; an arm64 "
"build runs faster if there is one)")
package = f"linux.{linux.slug(info['title'])}"
package = replace or f"linux.{linux.slug(info['title'])}"
if reporter:
reporter.log(f"{info['title']}: program {info['exe']}{' (AppImage)' if info['appimage'] else ''}"
f"{', OpenXR (VR)' if info['openxr'] else ''}")
@@ -439,6 +454,8 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
if not old:
fields.update(title=info["title"], recipe=library.recipe_to_dict(recipe), status="unknown")
library.upsert_game(package, **fields)
if replace: # a newer version: the artwork stays as it is
return library.game(package)
try:
from .artwork import fetch
@@ -580,7 +597,7 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
reporter, x86_64=bool(extra.get("x86_64")),
desktop_entry=entry.get("desktop_entry", True) is not False,
desktop_icon=entry.get("desktop_icon", True) is not False,
overlay=linux_overlay(entry))
overlay=linux_overlay(entry), **_source_args(entry, target.label))
if extra.get("x86_64"):
reporter.check("x86 translation", True, "runs through FEX on SteamOS's x86 system (its libraries come from "
"there; not checked ahead)")
@@ -591,10 +608,23 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
reporter.check("Libraries on the Frame", True, "everything the app needs is there")
if add_to_library:
target.add_to_library([package], reporter)
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
"source_id": entry.get("source_id")})
return result
def _source_args(entry: dict, label: str) -> dict:
"""source_id (recorded with the install) and changed_since for a Linux app / PC VR game install: after a newer
version was added (replace_linux_app/replace_rift_game), files changed since the last install there are sent
again even when the Frame has a file of the same size (0 = the last install is unknown: every file)."""
sid = entry.get("source_id")
if not sid:
return {}
rec = (entry.get("installs") or {}).get(label) or {}
return {"source_id": sid,
"changed_since": None if rec.get("source_id") == sid else float(rec.get("time") or 0)}
def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
if reporter:
reporter.log(f"analyzing {src.apk.name}")
@@ -662,6 +692,85 @@ def replace_apk(package: str, apk: Path, reporter: Reporter | None = None) -> di
"new_version": new_vc}
def _looks_different(before: str | None, now: str | None) -> bool:
"""A newer version whose name has little in common with the old one's (maybe another app): the caller warns."""
import difflib
a, b = _norm_title(before or ""), _norm_title(now or "")
if not a or not b or a in b or b in a:
return False
return difflib.SequenceMatcher(None, a, b).ratio() < 0.5
def _mark_source_replaced(package: str, **fields) -> dict:
"""A Linux app or PC VR game got a newer version: a new source id, which installs record (in the deployment's
recipe); an install without it shows the game as outdated (ui.components.source_replaced)."""
import uuid
return library.upsert_game(package, source_id=uuid.uuid4().hex[:12], source_updated=time.time(), **fields)
def replace_linux_app(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
"""A newer version of a Linux app already in the library (GitHub #161): an AppImage/archive or a folder, analysed
like "Add a Linux app…" (program, arm64/x86_64, AppImage, OpenXR). The app keeps its package (so its Steam
shortcut on the Frame), name, artwork, tags and switches (Desktop Mode entry, overlay app); a folder keeps the
program chosen before if it's still there. Returns {"entry", "different": its name looks like another app's,
"name": the new version's own name}."""
entry = library.game(package)
if entry is None or not is_linux(entry):
raise ValueError("only Linux apps can get a newer version this way")
before = ((entry.get("analysis") or {}).get("label")) or entry.get("title")
old_source = ((entry.get("analysis") or {}).get("extra") or {}).get("source")
if reporter:
reporter.log(f"reading {Path(path).name}")
stored = add_linux_app(path, reporter, replace=package)
name = (stored.get("analysis") or {}).get("label")
stored = _mark_source_replaced(package, replaced_source=old_source)
return {"entry": stored, "different": _looks_different(before, name), "name": name}
def replace_rift_game(package: str, folder: Path | str, reporter: Reporter | None = None) -> dict:
"""A newer version of a PC VR (Rift) game already in the library (GitHub #161): its new game folder, analysed like
a scanned one (programs, VR runtimes, fingerprint). The game keeps its package, name, artwork, tags and the user's
own recipe; a program the user confirmed stays if the new folder has it at the same place, else FramePort picks
one again (exe_confirmed False when that's unclear: the caller asks). The old folder is left out of later scans.
Returns {"entry", "different", "name", "exe_changed"}."""
entry = library.game(package)
if entry is None or not is_rift(entry):
raise ValueError("only PC VR games can get a newer version folder")
folder = Path(folder)
if not folder.is_dir():
raise ValueError(f"{folder} isn't a folder")
other = _existing_rift(folder)
if other and other["package"] != package:
raise ValueError(f"{folder.name} is already in the library as {other.get('title') or other['package']}")
before = (entry.get("analysis") or {}).get("label") or entry.get("title")
old_dir, old_exe = entry.get("game_dir"), entry.get("exe")
keep = old_exe if entry.get("exe_confirmed") and old_exe and (folder / old_exe).is_file() else None
stored = add_rift_game(folder, reporter, exe=keep, force=True, replace=entry)
if stored is None:
raise ValueError(f"no game program found in {folder.name}")
replaced = [d for d in (entry.get("replaced_dirs") or []) + [old_dir] if d and d != str(folder)]
# build=None: the next install checks the new folder first (prepare_rift); the exe's checksum comes from there
stored = _mark_source_replaced(package, build=None, replaced_dirs=list(dict.fromkeys(replaced)))
name = (stored.get("analysis") or {}).get("label")
return {"entry": stored, "different": _looks_different(before, name), "name": name,
"exe_changed": stored.get("exe") != old_exe}
def replace_source(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
"""A newer version of any game in the library: an APK (Quest/Android), an app file or folder (Linux) or a game
folder (PC VR). See replace_apk, replace_linux_app, replace_rift_game."""
entry = library.game(package)
if entry is None:
raise ValueError(f"{package} isn't in the library")
if is_linux(entry):
return replace_linux_app(package, path, reporter)
if is_rift(entry):
return replace_rift_game(package, path, reporter)
return replace_apk(package, Path(path), reporter)
def _analyze_entry(entry: dict, reporter: Reporter | None = None):
src = source_of(entry)
if reporter:
@@ -1275,10 +1384,11 @@ def install_rift(package: str, target: Target, reporter: Reporter, add_to_librar
result = target.install_pcvr(package, title, Path(entry["game_dir"]), entry["exe"], recipe, reporter,
revive_dir=rdir, revive_version=revive.installed_version() if rdir else None,
exe_sha256=entry["build"].get("sha256"), art_lookup=entry.get("quest_package"),
overlay=rift_overlay(entry, recipe))
overlay=rift_overlay(entry, recipe), **_source_args(entry, target.label))
if add_to_library:
target.add_to_library([package], reporter)
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
"source_id": entry.get("source_id")})
return result
+2 -1
View File
@@ -36,7 +36,8 @@ class Target(ABC):
raise NotImplementedError(f"{self.label} can't launch games")
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
desktop_entry=True, overlay=None, desktop_icon=True) -> dict:
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None,
changed_since=None) -> dict:
"""Install a Linux app (only the Frame runs them; x86_64 ones through FEX)."""
raise NotImplementedError(f"{self.label} can't install Linux apps")
+4 -3
View File
@@ -86,14 +86,15 @@ class FrameLeptonTarget(Target):
def install_pcvr(self, package, title, game_dir, exe, recipe, reporter, **extra):
plan = installer.PcvrPlan(package, title, Path(game_dir), exe, recipe, extra.get("revive_dir"),
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"),
dest=self.install_dest(), overlay=extra.get("overlay"))
dest=self.install_dest(), overlay=extra.get("overlay"),
source_id=extra.get("source_id"), changed_since=extra.get("changed_since"))
return installer.install_pcvr(self.connect().frame, plan, reporter)
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
desktop_entry=True, overlay=None, desktop_icon=True):
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None, changed_since=None):
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64,
dest=self.install_dest(), desktop_entry=desktop_entry, overlay=overlay,
desktop_icon=desktop_icon)
desktop_icon=desktop_icon, source_id=source_id, changed_since=changed_since)
return installer.install_linux(self.connect().frame, plan, reporter)
def drives(self) -> list[dict]:
+3 -1
View File
@@ -191,7 +191,9 @@ class PcReviveTarget(Target):
"tuning": dict(recipe.params("pcvr.steamvr_tuning"), enabled=True)
if "pcvr.steamvr_tuning" in recipe.patches else None,
"sha256": extra.get("exe_sha256"), "art_lookup": extra.get("art_lookup"),
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source}, "time": time.time()}
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source,
**({"source_id": extra["source_id"]} if extra.get("source_id") else {})},
"time": time.time()}
exe_field, _, _ = shortcut_fields(dep)
d = pc_dir() / package
try: # a reinstall keeps the shortcut's appid: Steam's entry is matched by its Exe, whatever its name now
+160
View File
@@ -0,0 +1,160 @@
"""The announcement pop-up (frameport.announcements does the fetching and the who-sees-it rules): checked in the
background a little after start and every 6 h, shown once when no other dialog is open and the welcome screen is done.
Any way of closing it (the X, a click outside, Escape, its button) dismisses that announcement for good."""
from __future__ import annotations
import threading
import time
from typing import TYPE_CHECKING
import flet as ft
from .. import announcements
from ..core import applog
from ..i18n import tr
from . import components as C
from . import theme as T
if TYPE_CHECKING:
from ..announcements import Announcement
from .app import FramePortApp
FIRST_DELAY = 20 # seconds after start: after the start-up work and the update check
BUSY_RETRY = 15 # another dialog is open / the welcome screen is shown: look again
LONG_BODY = 900 # characters: longer bodies scroll inside the pop-up
class Announcer:
def __init__(self, app: FramePortApp):
self.app = app
self.shown: str | None = None # id on screen (a check while it's open doesn't show it twice)
try:
announcements.since() # first start → only announcements published after it (before anything is saved)
except Exception: # noqa: BLE001
applog.log.exception("announcements: recording the first start failed")
def start(self) -> None:
if not announcements.disabled():
threading.Thread(target=self._loop, daemon=True).start()
def _loop(self) -> None:
time.sleep(FIRST_DELAY)
while True:
try:
a = announcements.fetch()
if announcements.due(a):
self._offer(a)
except Exception: # noqa: BLE001
applog.log.exception("announcement check failed")
time.sleep(announcements.CHECK_EVERY)
def _offer(self, a: Announcement) -> None:
"""Wait for a quiet moment (no dialog, not the welcome screen), then show it (background thread)."""
image = announcements.image_file(a)
while self.app.route[0] == "welcome" or C.dialog_open(self.app.page):
time.sleep(BUSY_RETRY)
if announcements.due(a) and self.shown != a.id:
self.show(a, image)
# ---------------------------------------------------------------- the pop-up
def show(self, a: Announcement, image=None) -> None:
page = self.app.page
closed = []
def dismiss(e=None) -> None:
if closed:
return
closed.append(True)
self.shown = None
try:
announcements.dismiss(a.id)
except Exception: # noqa: BLE001
applog.log.exception("announcement: saving the dismissal failed")
def close(e=None) -> None:
dismiss()
page.pop_dialog()
def act(e=None) -> None:
close()
self.app.open_url(a.url)
self.shown = a.id
page.show_dialog(C.dialog(None, announcement_card(page, a, image, close, act if a.button else None),
content_padding=0, actions_padding=0, inset_padding=self._insets(),
clip_behavior=ft.ClipBehavior.ANTI_ALIAS,
on_dismiss=dismiss, width=T.DIALOG_M))
def _insets(self) -> ft.Padding:
"""Centred over the content area (right of the sidebar, left of an open activity panel), not the whole
window; where that area is narrower than the pop-up, the panel and then the sidebar are left out."""
gap = T.S5
try:
left, right = self.app.content_insets()
width = float(self.app.page.width or 0)
except Exception: # noqa: BLE001
return ft.Padding.all(gap)
need = T.DIALOG_M + 2 * gap
if width and width - left - right < need:
right = 0
if width and width - left - right < need:
left = 0
return ft.Padding(left + gap, gap, right + gap, gap)
def announcement_card(page: ft.Page, a: Announcement, image, on_close, on_action=None) -> ft.Control:
"""The pop-up's content: the portal edge, the picture (if any) with the close button over it, title, date, the
Markdown body and the optional action."""
from ..artwork.thumbs import asset_url
from .updater import _notes_style, _open_url
width = T.DIALOG_M
close_tip = tr("Dismiss")
title = ft.Text(a.title, size=T.px(20), weight=ft.FontWeight.W_700, color=T.TEXT, selectable=True)
date = C.meta(time.strftime("%Y-%m-%d", time.localtime(a.date)))
body = ft.Markdown(a.body, selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
md_style_sheet=_notes_style(),
on_tap_link=lambda e: _open_url(page, e.data))
if len(a.body) > LONG_BODY:
body = ft.Column([body], scroll=ft.ScrollMode.AUTO, height=T.px(320))
head: list[ft.Control] = [ft.Container(height=T.px(3), gradient=C.portal_gradient())]
if image is not None:
head.append(ft.Stack([
ft.Image(src=asset_url(image), width=width, height=_image_height(image, width), fit=ft.BoxFit.COVER,
semantics_label=a.image_alt or None),
ft.Container(
ft.Icon(ft.Icons.CLOSE_ROUNDED, size=T.px(18), color=T.TEXT),
width=T.px(34), height=T.px(34), border_radius=T.px(17), alignment=ft.Alignment.CENTER,
bgcolor=ft.Colors.with_opacity(0.62, T.BG), border=ft.Border.all(1, T.soft(T.TEXT, 0.18)),
tooltip=close_tip, ink=True, on_click=on_close, right=T.S3, top=T.S3),
], width=width))
title_row: ft.Control = title
else:
title_row = ft.Row([ft.Container(title, expand=True, padding=ft.Padding(0, T.px(6), 0, 0)),
C.icon_btn(ft.Icons.CLOSE_ROUNDED, close_tip, on_click=on_close)],
vertical_alignment=ft.CrossAxisAlignment.START, spacing=T.S2)
text = [ft.Column([title_row, date], spacing=T.px(2), tight=True)]
if a.body:
text.append(body)
if on_action is not None:
text.append(ft.Row([C.primary(a.button, icon=ft.Icons.OPEN_IN_NEW_ROUNDED, on_click=on_action)],
alignment=ft.MainAxisAlignment.END))
pad = ft.Padding(T.S5, T.S4 if image is None else T.S5, T.S4 if image is None else T.S5, T.S5)
return ft.Container(
ft.Column([*head, ft.Container(ft.Column(text, spacing=T.S4, tight=True), padding=pad)], spacing=0, tight=True),
width=width)
def _image_height(path, width: float) -> float:
"""The picture's height at the pop-up's width, kept between a banner and a third of a 16:9 screen."""
try:
from PIL import Image
with Image.open(path) as im:
ratio = im.height / im.width
except Exception: # noqa: BLE001
ratio = 9 / 21
return max(T.px(140), min(width * ratio, T.px(320)))
+71 -3
View File
@@ -96,6 +96,9 @@ def reduce_motion_setting() -> bool:
return bool(library.setting(REDUCE_MOTION, False))
SIDEBAR_WIDTH = 236 # logical px before the UI scale (content_insets)
class FramePortApp:
def __init__(self, page: ft.Page):
from .views.library import load_filters
@@ -145,9 +148,12 @@ class FramePortApp:
self.stop_live(), # and stop a live view (it would keep the Frame encoding)
self.stop_keyboard(), self.stop_monitor(), self.monitor_hub.close(),
_link_state.update(owner=None) if _link_state["owner"] is self else None)
from .announcer import Announcer
from .updater import Updater
self.updater = Updater(self) # new FramePort releases (sidebar card, Library bar, one-click update)
# maintainer announcements (pop-up once, not on a first start); before updater.start() saves this start
self.announcer = Announcer(self)
page.title = tr("FramePort")
for problem in T.load_user_themes(themes_dir()): # installed theme files (Settings → Appearance)
@@ -182,6 +188,7 @@ class FramePortApp:
self._start_auto_pair()
threading.Thread(target=self._backfill_covers, daemon=True).start()
self.updater.start()
self.announcer.start()
self.run_bg(self._refresh_catalog) # confirmed game configs from GitHub main (no release needed)
_link_state["owner"] = self
if _link_state["thread"] is None:
@@ -236,13 +243,18 @@ class FramePortApp:
self.activity_card,
self.conn_card,
self.power_row, # under the Frame card (owner's choice)
], spacing=T.S2), width=T.px(236), bgcolor=T.SIDEBAR, padding=T.S4,
], spacing=T.S2), width=T.px(SIDEBAR_WIDTH), bgcolor=T.SIDEBAR, padding=T.S4,
border=None if T.DUAL else ft.Border(right=ft.BorderSide(1, T.BORDER)))
# dual themes: the sidebar's edge is the portal, blue at the top into orange at the bottom
edge = [ft.Container(width=2, gradient=C.portal_gradient(vertical=True, opacity=0.7))] if T.DUAL else []
self.page.controls = [ft.Row([sidebar, *edge, self.body, self.activity.root], expand=True, spacing=0,
vertical_alignment=ft.CrossAxisAlignment.STRETCH)]
def content_insets(self) -> tuple[float, float]:
"""How much of the window's left (the sidebar + its edge) and right (the activity panel, when open) isn't the
content area: pop-ups about the content (announcements) centre over what's between."""
return T.px(SIDEBAR_WIDTH) + (2 if T.DUAL else 0), float(self.activity.root.width or 0)
def restyle(self, theme: str) -> None:
"""Switch the colour theme while running (Settings → Appearance): new tokens, the window rebuilt, the views
FramePort keeps between visits recreated (the files/screenshots views start at their first location again;
@@ -880,7 +892,7 @@ class FramePortApp:
self.toast(msg, error=True, action=tr("Details"),
on_action=lambda e: self.show_activity(True))
self.refresh_view()
if job.kind == "scan" and self.exe_queue:
if job.kind in ("scan", "task") and self.exe_queue: # (a task: a newer PC VR game version)
self.page.run_thread(self.next_exe_choice)
elif job and job.active and job.package and self.route[:2] == ("game", job.package):
pkg, transit = self.hero_transit or (None, None)
@@ -1152,6 +1164,7 @@ class FramePortApp:
"move": lambda e: self.move_game(pkg),
"update_app": lambda e: self.update_app_only(pkg),
"new_apk": lambda e: self.page.run_task(self.update_with_apk, pkg),
"new_version": lambda e: self.page.run_task(self.update_with_version, pkg),
"uninstall_frame": lambda e: self.uninstall(pkg, "frame"),
"uninstall_pc": lambda e: self.uninstall(pkg, "pc"),
"remove": lambda e: self.remove_from_library(pkg),
@@ -1168,7 +1181,8 @@ class FramePortApp:
settings=self.has_game_settings(g), programs=rift or (linux and bool(self.linux_programs(g))),
selectable=lv is not None,
media_button=C.is_media_player(g) and not rift,
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux)
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux,
new_version=rift or linux)
return menu_sections(g, st, handlers.__getitem__)
def open_game_menu(self, pkg: str) -> None:
@@ -1621,6 +1635,60 @@ class FramePortApp:
return tr("{title}: newer APK added").format(title=self._title(pkg))
self.submit(tr("Newer APK for {title}").format(title=self._title(pkg)), run, pkg, "task")
async def update_with_version(self, pkg: str) -> None:
"""Game menu "Update with a newer version…" for Linux apps and PC VR games (GitHub #161): pick the new app
file or folder (how the app was added) / the new game folder; the game keeps its name, art, tags and switches,
and its page then offers Update on Frame (or on this PC)."""
from ..analysis import linux as linux_analysis
if self.jobs.busy_with(pkg):
return
g = library.game(pkg) or {}
rift = g.get("kind") == "rift"
title = tr("Newer version of {title}").format(title=self._title(pkg))
if rift or self._linux_folder_app(g):
path = await C.pick_dir(self.page, dialog_title=title)
if not path:
return
else:
paths = await C.pick_files(self.page, dialog_title=title)
if not paths:
return
path = paths[0]
if not linux_analysis.looks_like_linux_app(Path(path)):
self.toast(tr("{name} isn't a Linux app (AppImage, Linux program or .zip/.tar archive)")
.format(name=Path(path).name), error=True)
return
on_frame = self.frame_state == "connected" and \
C.install_state(g, self.frame_info) in ("installed", "outdated")
on_pc = rift and pkg in self.pc_installs()
def run(job: Job):
job.reporter.stage(tr("Reading the new version"))
res = pipeline.replace_source(pkg, Path(path), job.reporter)
if res.get("different"):
self.toast(tr("Heads-up: {name} looks like a different app than {title}.").format(
name=res.get("name") or Path(path).name, title=self._title(pkg)))
if rift and res["entry"].get("exe_confirmed") is False: # which program starts it? (asked after the job)
self.exe_queue.append(pkg)
self.open_game(pkg)
if on_frame or on_pc:
return tr("{title}: newer version added. Press Update to install it.").format(
title=self._title(pkg))
return tr("{title}: newer version added").format(title=self._title(pkg))
self.submit(tr("Newer version for {title}").format(title=self._title(pkg)), run, pkg, "task")
@staticmethod
def _linux_folder_app(g: dict) -> bool:
"""A Linux app added from a folder (its newer version is a folder too), not from an AppImage/archive."""
from ..analysis import linux as linux_analysis
extra = (g.get("analysis") or {}).get("extra") or {}
source = Path(extra.get("source") or g.get("game_dir") or "")
if source.exists():
return source.is_dir()
return not extra.get("files") and not source.name.lower().endswith(linux_analysis.ARCHIVES)
def updatable(self) -> list[tuple[str, str]]:
"""[(package, "frame" | "pc")] installs with an update ready (a newer build or changed patch settings)."""
out = []
+13 -1
View File
@@ -178,6 +178,8 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
# still installed (GitHub #90)
if not dep or (dep.get("apk_present") is False and not dep.get("drive_missing")):
return "missing"
if source_replaced(game, dep):
return "outdated" # a newer version of the Linux app / PC VR game was added since this install (GitHub #161)
b = game.get("build") or {}
if game.get("kind") not in ("linux", "rift") and b.get("apk_recipe_fp") and dep.get("configuration_fp"):
from ..game_update import plan_update
@@ -194,6 +196,16 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
return "outdated" if settings_diff(game, frame_info) else "installed"
def source_replaced(game: dict, dep: dict | None) -> bool:
"""A Linux app or PC VR game got a newer version ("Update with a newer version…", GitHub #161) that this install
(the Frame's or the PC's deployment record) doesn't have yet."""
sid = game.get("source_id")
if not sid or game.get("kind") not in ("linux", "rift"):
return False
rec = (dep or {}).get("recipe")
return (rec.get("source_id") if isinstance(rec, dict) else None) != sid
def recipe_changed(game: dict) -> bool:
from ..patches.base import recipe_fingerprint, revised_since_unrecorded
@@ -216,7 +228,7 @@ def pc_outdated(g: dict, dep: dict) -> bool:
args = game_args(recipe_from_dict(g["recipe"]))
except Exception: # noqa: BLE001
args = dep.get("game_args") or []
return installed != want or list(dep.get("game_args") or []) != args
return installed != want or list(dep.get("game_args") or []) != args or source_replaced(g, dep)
def one_choice():
+3
View File
@@ -62,6 +62,9 @@ HELP: dict[str, str] = _Translated({
"hw_video_decode": "Games with the Hardware video decoding patch play their videos on the Frame's video hardware. "
"Turn it off if videos misbehave; games pick it up at their next start. For one game only, put "
"FRAMEPORT_NO_HW_VIDEO=1 before %command% in its Steam launch options.",
"ssh_keys": "FramePort logs in to the Frame with its own key. If your Frame accepts only one of your own keys, "
"add that private key file here; fast uploads use it too. A key with a passphrase: add it to your SSH "
"agent instead.",
"launch_test": "Starts the game on the Frame while nobody wears the headset and reads the log: did it start and "
"draw frames. It can't check the picture.",
"last_session": "After you play a game on the Frame, FramePort reads that session's log: crashes, frame rate and "
+5
View File
@@ -35,6 +35,7 @@ class MenuState:
media_button: bool = False # the game page shows "Add videos" already
movable: bool = False # the Frame knows which drive it's on (agent v63+, microSD): Move to…
android: bool = False # a Quest/Android game (not PC VR, not a Linux app): it can get a newer APK
new_version: bool = False # a Linux app or PC VR game: it can get a newer version (file or folder)
def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> list:
@@ -55,6 +56,8 @@ def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> lis
top.append((tr("Move to…"), ft.Icons.DRIVE_FILE_MOVE_ROUNDED, act("move")))
if st.android and not job: # GitHub #161: the game keeps its name, art and recipe
top.append((tr("Update with a newer APK…"), ft.Icons.UPGRADE_ROUNDED, act("new_apk")))
if st.new_version and not job: # the same for Linux apps and PC VR games
top.append((tr("Update with a newer version…"), ft.Icons.UPGRADE_ROUNDED, act("new_version")))
if st.on_frame and not rift and not linux and not job:
# the new build only; the Frame's game data stays (e.g. changed OBBs a normal update would replace)
top.append((tr("Update app only…"), ft.Icons.SYSTEM_UPDATE_ALT_ROUNDED, act("update_app")))
@@ -103,6 +106,8 @@ def quick_menu(st: MenuState, act: Callable[[str], Callable]) -> list:
out += st.installs[:1]
if st.android:
out.append((tr("Update with a newer APK…"), ft.Icons.UPGRADE_ROUNDED, act("new_apk")))
if st.new_version:
out.append((tr("Update with a newer version…"), ft.Icons.UPGRADE_ROUNDED, act("new_version")))
if st.settings:
out.append((tr("Game settings…"), ft.Icons.TUNE_ROUNDED, act("settings")))
out.append((tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, act("rename")))
+47 -1
View File
@@ -131,9 +131,55 @@ class SettingsView:
C.meta(tr("Large files upload together using OpenSSH when available. "
"Default: 3. Lower this on a busy network.")),
C.ghost(tr("Remove converted copies now"), ft.Icons.CLEANING_SERVICES_OUTLINED, clean,
tooltip=tr("Your own game files aren't touched."))],
tooltip=tr("Your own game files aren't touched.")),
ft.Container(height=T.S2), self.ssh_keys()],
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
def ssh_keys(self) -> ft.Control:
"""Settings → Installing: private key files FramePort logs in with besides its own (frame.connection
EXTRA_KEYS_SETTING; the Frame connection and OpenSSH uploads both use them, GitHub #242)."""
from ...frame import connection
app = self.app
rows = ft.Column(spacing=T.S1)
def fill():
items = []
for path, problem in connection.extra_key_status():
items.append(ft.Row([
C.as_icon(ft.Icons.KEY_ROUNDED if not problem else ft.Icons.WARNING_AMBER_ROUNDED, T.px(18),
T.TEXT_2 if not problem else T.WARN),
ft.Column([C.body(path.name, T.TEXT), C.meta(problem or str(path), selectable=True)],
spacing=0, expand=True),
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Stop using this key (the file stays)"),
on_click=lambda e, p=path: remove(p)),
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER))
rows.controls = items or [C.meta(tr("Only FramePort's own key, and your SSH agent's keys."))]
C.update(rows)
def remove(path):
connection.remove_extra_key(path)
fill()
async def add(e):
for path in await C.pick_files(app.page, dialog_title=tr("Private SSH key file (not the .pub file)"),
multiple=True):
try:
stored = connection.add_extra_key(path)
except connection.KeyFileError as exc:
app.toast(tr("That key can't be used: {error}").format(error=exc), error=True)
continue
app.toast(tr("FramePort also logs in with {name} from the next connection.").format(name=stored.name))
fill()
fill()
return ft.Column([
ft.Row([C.body(tr("SSH keys"), T.TEXT, weight=ft.FontWeight.W_500), C.help_icon("ssh_keys")],
spacing=T.px(4), vertical_alignment=ft.CrossAxisAlignment.CENTER),
rows,
C.secondary(tr("Add a key file…"), ft.Icons.VPN_KEY_ROUNDED, add),
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
def updates_card(self) -> ft.Control:
"""Settings → Updates: FramePort's own updates (ui/updater.py, frameport/updates.py)."""
import time
+140
View File
@@ -0,0 +1,140 @@
"""Announcements: the committed file parses, who sees one (never on a first start), and dismissing is for good."""
from pathlib import Path
import pytest
from frameport import announcements as A
from frameport.core import library
ROOT = Path(__file__).resolve().parents[1]
DOC = """---
id: one
title: Hello
date: 2026-10-10T12:00:00Z
---
Some **news**.
"""
def ann(text=DOC):
a = A.parse(text)
assert a is not None
return a
def test_committed_announcement_is_valid():
"""announcements/current.md must parse (an inactive one is checked too): a typo fails here, not on users."""
A.parse((ROOT / A.PATH).read_text(encoding="utf-8"))
def test_parse_fields():
a = A.parse("""---
id: frameport-1.3
title: "FramePort 1.3: the Monitor"
date: 2026-11-02
image: announcements/x.png
image_alt: A picture
button: Read more
url: https://frameport.app/changelog/
expires: 2026-11-30
---
Body *here*.""")
assert a.id == "frameport-1.3" and a.title == "FramePort 1.3: the Monitor" and a.body == "Body *here*."
assert a.image == A.RAW + "announcements/x.png" and a.image_alt == "A picture"
assert a.button == "Read more" and a.url == "https://frameport.app/changelog/"
assert a.date == 1793577600.0 # 2026-11-02 00:00 UTC
assert a.expires == 1795996800.0 + 86400 # end of 2026-11-30 (inclusive)
@pytest.mark.parametrize("text", ["", " \n", "---\nactive: false\n---\n"])
def test_nothing_to_show(text):
assert A.parse(text) is None
@pytest.mark.parametrize("front, error", [
("title: x\ndate: 2026-10-10", "id"),
("id: bad id\ntitle: x\ndate: 2026-10-10", "id"),
("id: a\ndate: 2026-10-10", "title"),
("id: a\ntitle: x", "date"),
("id: a\ntitle: x\ndate: soon", "date"),
("id: a\ntitle: x\ndate: 2026-10-10\nbutton: Go", "button and url"),
("id: a\ntitle: x\ndate: 2026-10-10\nimage: http://example.com/x.png", "https"),
("id: a\ntitle: x\ndate: 2026-10-10\nimage: ../secret.png", "https"),
("id: a\ntitle: x\ndate: 2026-10-10\nwhere: app", "unknown"), # the site pop-up was dropped
("id: a\ntitle: x\ndate: 2026-10-10\ncolour: red", "unknown"),
("active: false\nid: a\ntitle: x\ndate: tomorrow", "date"), # switched off, still checked
])
def test_parse_errors(front, error):
with pytest.raises(A.AnnouncementError, match=error):
A.parse(f"---\n{front}\n---\nbody")
def test_fetch_never_raises_on_a_broken_file(monkeypatch):
monkeypatch.setattr(A.cache, "cached_text", lambda *a, **k: "no front matter")
assert A.fetch() is None
def test_first_start_sees_no_older_announcement():
"""A new install: `since` = now, so an announcement published before it never shows."""
assert A.since() > 1.9e9 - 2e8 # roughly now
assert not A.due(ann())
def test_existing_user_sees_it_once():
library.set_setting("update.last_seen_version", "1.2.0") # FramePort ran before announcements existed
assert A.since() == 0
a = ann()
assert A.due(a)
A.dismiss(a.id)
assert not A.due(a)
assert not A.due(a) # for good, also at the next check
# a typo fixed under the same id stays dismissed; a new id shows again
assert not A.due(ann(DOC.replace("Hello", "Hallo")))
assert A.due(ann(DOC.replace("id: one", "id: two")))
def test_since_is_recorded_once():
first = A.since()
library.set_setting("update.last_seen_version", "1.2.0") # written later by this same start
assert A.since() == first
def test_new_user_sees_later_announcements():
start = 1_800_000_000.0
later = ann(DOC.replace("2026-10-10T12:00:00Z", "2027-01-20T00:00:00Z")) # after `start`
assert A.due(later, now=later.date + 60, user_since=start)
assert not A.due(ann(), now=later.date + 60, user_since=start) # published before the install
def test_schedule_and_expiry():
a = ann(DOC.replace("---\nSome", "expires: 2026-10-12\n---\nSome"))
assert not A.due(a, now=a.date - 60, user_since=0) # not published yet
assert A.due(a, now=a.date + 60, user_since=0)
assert not A.due(a, now=a.expires + 1, user_since=0) # expired
def test_dismissed_list_is_bounded():
for i in range(A.KEEP_DISMISSED + 5):
A.dismiss(f"id{i}")
kept = A.dismissed()
assert len(kept) == A.KEEP_DISMISSED and kept[-1] == f"id{A.KEEP_DISMISSED + 4}"
A.dismiss("id10") # again: moves to the end, no duplicate
assert A.dismissed().count("id10") == 1 and A.dismissed()[-1] == "id10"
def test_image_file_checks_the_picture(monkeypatch, tmp_path):
from PIL import Image
src = tmp_path / "pic.png"
Image.new("RGB", (40, 20), "orange").save(src)
def fake_download(url, dest, **kw):
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(src.read_bytes() if url.endswith(".png") else b"not an image")
return dest
monkeypatch.setattr(A.cache, "download", fake_download)
good = A.image_file(ann(DOC.replace("---\nSome", "image: announcements/a.png\n---\nSome")))
assert good is not None and good.suffix == ".png" and good.exists()
assert A.image_file(ann(DOC.replace("---\nSome", "image: https://example.com/a.jpg\n---\nSome"))) is None
assert not list(good.parent.glob("*.part"))
+23
View File
@@ -47,3 +47,26 @@ def test_add_needed(variant):
assert all(off % 16384 == va % 16384 for va, off, _ in loads)
# idempotent
assert elf.add_needed(out, "libglshim.so") == out
def _scramble_section_names(lib: bytes) -> bytes:
"""Rotate every section's sh_name by one, like a shipped libOVRPlugin.so whose names are shifted (GitHub #349:
.dynamic named "dding", .dynsym "text"); types and links stay right."""
buf = bytearray(lib)
shoff = struct.unpack_from("<Q", buf, 40)[0]
shentsize, shnum = struct.unpack_from("<H", buf, 58)[0], struct.unpack_from("<H", buf, 60)[0]
names = [struct.unpack_from("<I", buf, shoff + i * shentsize)[0] for i in range(1, shnum)]
for i, name in enumerate(names[1:] + names[:1], start=1):
struct.pack_into("<I", buf, shoff + i * shentsize, name)
return bytes(buf)
def test_scrambled_section_names():
base = elf.add_needed(build_stub_library(["ovr_X", "ovr_Y"], soname="libgame.so"), "liblog.so")
lib = _scramble_section_names(base)
assert elf.dynsym_section(elf._elf(lib)) is not None
assert elf.dyn_symbols(lib, True) == {"ovr_X", "ovr_Y"}
assert elf.find_symbols(lib, ["ovr_Y"]).keys() == {"ovr_Y"}
out = elf.add_needed(lib, "libfp_x.so") # raised "produced NEEDED=[...]" when sections were found by name
assert elf.needed(out) == ["libfp_x.so", "liblog.so"]
assert elf.dyn_symbols(out, True) == {"ovr_X", "ovr_Y"}
+326
View File
@@ -0,0 +1,326 @@
"""Synthetic evaluator tests: no APKs, snapshots, git history, or network needed."""
import importlib.util
import json
from pathlib import Path
import pytest
from frameport.core.library import analysis_from_dict
from frameport.patches import base
from frameport.recommend import catalog, engine
script = Path(__file__).parents[1] / "scripts/eval_heuristics.py"
spec = importlib.util.spec_from_file_location("eval_heuristics", script)
ev = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ev)
def _analysis(**kwargs):
fields = dict(package="com.example.game", engine="Unity", xr="OpenXR", abis=["arm64-v8a"],
libs=[], extra={}, graphics="Vulkan")
fields.update(kwargs)
return analysis_from_dict(fields)
def _entry(**kwargs):
return catalog.CatalogEntry(**(dict(package="com.example.game", title="Example", status="works") | kwargs))
def _patch(monkeypatch, pid, *, category="frame", default=False, applies=True, recommended=True,
a32=None, requires=(), conflicts=()):
base.load_all()
p = base.Patch()
p.id, p.title, p.category, p.default_on = pid, pid, category, default
p.a32, p.requires, p.conflicts = a32, requires, conflicts
p.experimental, p.strict = True, True
p.applies = lambda _: applies
params = {"value": 3} if category == "adapter" else {}
p.detect = lambda _: base.Suggestion(recommended, f"reason for {pid}", params)
monkeypatch.setitem(base.REGISTRY, pid, p)
return p
def test_catalog_signature_is_catalog_only(monkeypatch):
def forbidden(*args, **kwargs):
pytest.fail("catalog signature must not call suggest")
monkeypatch.setattr(engine, "suggest", forbidden)
a = _analysis()
defaults = ev.expected_defaults(a)
entry = _entry(overport_extra=["patch_remove_unreal_force_quit"], overport_remove=["patch_copy_libraries"],
frame=["frame.unity_text_input"], frame_remove=["frame.adapter"],
adapter={"scene_emul": 0, "scale": 1.5}, device=["device.text_input_window"],
device_files={"user.cfg": "r_vsync=0"}, alt_overport=["patch_fix_ue4_crash"], use_alt=True,
foveation="fixed", foveation_radius=0.5, foveation_test=True,
lepton_env={"X": "1"}, config_sync={"user.cfg": {"width": "w"}})
want = ev.catalog_signature(entry, defaults)
assert want["overport"] == ["patch_remove_unreal_force_quit"]
assert want["overport_removed"] == ["patch_copy_libraries"]
assert want["frame"] == ["frame.unity_text_input"]
assert want["frame_removed"] == ["frame.adapter"]
assert want["adapter"] == {"scene_emul": 0, "scale": 1.5}
assert want["device"] == ["device.text_input_window"]
assert want["device_files"] == ["user.cfg"]
assert want["alt"] == ["patch_fix_ue4_crash"]
assert want["use_alt"] is True
assert want["lepton_env"] == {"X": "1"}
assert want["config_sync"] == {"user.cfg": {"width": "w"}}
assert want["foveation"] == {"mode": "fixed", "radius": 0.5, "test": True}
assert not want["status_unsupported"]
def test_mask_conditional_defaults_and_score_explicit_removals():
a = _analysis()
defaults = ev.expected_defaults(a)
assert "frame.ltw_depth" not in defaults
assert "frame.vk_sanitize" not in defaults
assert "frame.adapter" in defaults
r = ev.catalog_recipe(_entry(), defaults)
assert ev.signature(r, defaults)["frame_removed"] == []
removed = ev.catalog_signature(_entry(frame_remove=["frame.adapter", "frame.ltw_depth"]), defaults)
assert removed["frame_removed"] == ["frame.adapter"]
scored = ev.score_signatures(removed, ev.signature(r, defaults))["headline"]
assert scored["missing"] == {"remove:frame.adapter": True}
assert not scored["exact"]
r.patches.pop("frame.adapter")
dropped = ev.score_signatures(ev.catalog_signature(_entry(), defaults), ev.signature(r, defaults))["headline"]
assert dropped["extra"] == {"remove:frame.adapter": True}
def test_catalog_adapter_does_not_inherit_heuristics():
a = _analysis(libs=["libOVRPlugin.so"])
got = engine.suggest(a, use_catalog=False)
assert "adapter.haptic_fix" in got.patches
assert ev.catalog_signature(_entry(), ev.expected_defaults(a))["adapter"] == {}
def test_scorer_values_counts_and_exact():
want = ev.catalog_signature(_entry(frame=["frame.unity_text_input"], adapter={"scene_emul": 1}))
got = ev.catalog_signature(_entry(frame=["frame.unity_text_input", "frame.hw_video_decode"],
adapter={"scene_emul": 2}))
score = ev.score_signatures(want, got)["headline"]
assert not score["exact"]
assert score["per_patch"]["frame.unity_text_input"] == ev._rates(1, 0, 0)
assert score["per_patch"]["frame.hw_video_decode"] == ev._rates(0, 1, 0)
assert score["per_patch"]["adapter.scene_emul"] == ev._rates(0, 1, 1)
assert score["missing"] == {"adapter.scene_emul": 1}
assert score["extra"] == {"adapter.scene_emul": 2, "frame.hw_video_decode": True}
same = ev.score_signatures(want, want)
assert same["headline"]["exact"] and same["runtime"]["exact"]
assert same["headline"]["per_patch"]["adapter.scene_emul"] == ev._rates(1, 0, 0)
def test_runtime_separate_from_headline():
want = ev.catalog_signature(_entry(adapter={"scale": 1.5, "sync_guard": 1}, use_alt=True,
lepton_env={"X": "Y"}, config_sync={"foo": {"w": "w"}}))
got = ev.catalog_signature(_entry())
score = ev.score_signatures(want, got)
assert score["headline"]["exact"]
assert not score["headline"]["missing"]
assert not score["runtime"]["exact"]
assert score["runtime"]["per_patch"]["adapter.scale"] == ev._rates(0, 0, 1)
assert score["runtime"]["missing"]["use_alt"]
def test_aggregate_excludes_unsupported_and_breaks_down_engines():
want = ev.catalog_signature(_entry(frame=["frame.unity_text_input"]))
empty = ev.catalog_signature(_entry())
rows = [dict(package="com.a", status="works", engine="Unity", converted=True, want=want, got=want),
dict(package="com.b", status="issues", engine="Unreal", want=want, got=empty),
dict(package="com.c", status="unsupported", engine="Other", want=want, got=empty)]
result = ev.score_records(rows)
assert result["headline"]["games"] == 2
assert result["headline"]["exact"] == 1
assert result["headline"]["exact_ratio"] == 0.5
assert result["headline"]["patches"] == ev._rates(1, 0, 1)
assert result["headline"]["fields"]["frame"] == {"matched": 1, "total": 2}
assert result["runtime"]["exact"] == 2
assert result["excluded_unsupported"] == 1
assert result["unsupported"]["headline"]["patches"] == ev._rates(0, 0, 1)
assert result["by_engine"]["Unreal"]["headline"]["patches"] == ev._rates(0, 0, 1)
assert result["converted_games"] == 1
assert len(result["games"]) == 3
def test_split_deterministic_stratified_and_disjoint():
engines = {f"com.example.{name}{i}": name for name in ("Unity", "Unreal", "Other") for i in range(10)}
assignments = ev.split_packages(engines)
assert assignments == ev.split_packages(dict(reversed(list(engines.items()))))
for name in ("Unity", "Unreal", "Other"):
mine = [pkg for pkg in engines if engines[pkg] == name]
ranked = sorted(mine, key=lambda p: ev.hashlib.sha256(p.encode()).digest())
assert {p for p in mine if assignments[p] == "holdout"} == set(ranked[:2])
dev = {p for p, s in assignments.items() if s == "dev"}
holdout = {p for p, s in assignments.items() if s == "holdout"}
assert not dev & holdout
assert dev | holdout == engines.keys()
assert ev.split_packages({}) == {}
assert ev.split_packages({"com.one": "CryEngine"}) == {"com.one": "dev"}
def test_fit_selects_only_recommended_opt_ins_not_listed_or_removed(monkeypatch):
_patch(monkeypatch, "frame.eval_candidate", conflicts=("frame.adapter",), requires=("frame.eval_unrecommended",))
_patch(monkeypatch, "frame.eval_default", default=True)
_patch(monkeypatch, "frame.eval_unrecommended", recommended=False)
_patch(monkeypatch, "frame.eval_inapplicable", applies=False)
_patch(monkeypatch, "frame.eval_listed")
_patch(monkeypatch, "frame.eval_removed")
_patch(monkeypatch, "frame.eval_alternate")
_patch(monkeypatch, "adapter.eval_setting", category="adapter")
_patch(monkeypatch, "pcvr.eval_wrong_platform", category="pcvr")
entry = _entry(frame=["frame.eval_listed"], frame_remove=["frame.eval_removed"],
alt_overport=["frame.eval_alternate"], adapter={"eval_setting": 9})
candidates = {r["patch"]: r for r in ev.fit_candidates(_analysis(), entry)}
assert "frame.eval_candidate" in candidates
assert not set(candidates) & {"frame.eval_default", "frame.eval_unrecommended", "frame.eval_inapplicable",
"frame.eval_listed", "frame.eval_removed", "frame.eval_alternate",
"adapter.eval_setting", "pcvr.eval_wrong_platform"}
c = candidates["frame.eval_candidate"]
assert c["reason"] == "reason for frame.eval_candidate"
assert c["category"] == "frame" and c["experimental"] and c["strict"]
assert any("conflicts" in w for w in c["warnings"])
def test_fit_uses_a32_and_flat_rules(monkeypatch):
_patch(monkeypatch, "frame.eval_64_only", a32=False)
_patch(monkeypatch, "frame.eval_32_ok", a32=True)
candidates = {r["patch"] for r in ev.fit_candidates(_analysis(abis=["armeabi-v7a"]), _entry())}
assert "frame.eval_32_ok" in candidates
assert "frame.eval_64_only" not in candidates
assert "frame.a32_bridge" not in candidates # implied for 32-bit entries, as engine._a32_predates_bridge does
flat = {r["patch"] for r in ev.fit_candidates(_analysis(extra={"vr_kind": "none"}), _entry())}
assert "frame.eval_32_ok" not in flat # needs_vr defaults True, removed by engine._non_quest
def test_fit_metadata_dates_and_converted(monkeypatch):
monkeypatch.setattr(ev, "fit_candidates", lambda a, e: [dict(patch="frame.new", category="frame",
reason="x", experimental=False, strict=False,
warnings=[])])
entries = {"com.a": _entry(package="com.a", verified={"date": "2026-01-01"}, updated="2026-02-01"),
"com.b": _entry(package="com.b", status="unsupported", engine="Other", xr="VrApi")}
report = ev.fit_report({"com.a": {"analysis": _analysis(package="com.a").to_dict(), "converted": True}}, entries,
{"frame.new": "2026-03-01"})
assert report["games"][0]["candidates"][0]["newer_patch"] is True
assert report["games"][0]["entry_date"] == "2026-01-01" # verified, not updated
assert report["games"][0]["converted"]
assert report["metadata_only"][0]["package"] == "com.b"
assert report["metadata_only"][0]["xr"] == "VrApi"
assert report["totals_by_patch"] == {"frame.new": 1}
md = ev.render_fit_report(report)
assert "metadata only" in md and "com.b" in md and "frame.new" in md
def test_snapshot_copied_before_read_and_retried(tmp_path, monkeypatch):
source = tmp_path / "snapshot.json"
source.write_text('{"complete": true}')
calls = []
real_copy = ev.shutil.copyfile
def copying(src, dest):
calls.append((src, dest))
real_copy(src, dest)
if len(calls) == 1:
dest.write_text('{"partial":')
monkeypatch.setattr(ev.shutil, "copyfile", copying)
monkeypatch.setattr(ev.time, "sleep", lambda _: None)
assert ev.read_snapshot(source) == {"complete": True}
assert len(calls) == 2
assert all(src == source and dest != source for src, dest in calls)
assert json.loads(source.read_text()) == {"complete": True}
def test_intro_dates_cached_and_adapter_searches_bare_key(tmp_path, monkeypatch):
calls = []
patch = base.get("adapter.scene_emul")
monkeypatch.setattr(base, "all_patches", lambda: [patch])
def log(args, **kwargs):
calls.append(args)
return type("Result", (), {"stdout": "2026-01-02\n2026-02-03\n"})()
monkeypatch.setattr(ev.subprocess, "run", log)
cache = tmp_path / "patch-intro-dates.json"
assert ev.patch_intro_dates(cache) == {"adapter.scene_emul": "2026-01-02"}
assert '-S"scene_emul"' in calls[0]
assert calls[0][-1] == "src/frameport/patches/settings.py"
assert ev.patch_intro_dates(cache) == {"adapter.scene_emul": "2026-01-02"}
assert len(calls) == 1
def test_legacy_dumps_cli_remains_available(tmp_path, monkeypatch, capsys):
a = _analysis()
snapshot = {a.package: {"analysis": a.to_dict()}}
entry = _entry()
calls = []
monkeypatch.setattr(ev, "read_dumps", lambda p: calls.append(p) or snapshot)
monkeypatch.setattr(catalog, "_load_dir", lambda *args: {a.package: entry})
monkeypatch.setattr(ev.sys, "argv", ["eval_heuristics.py", str(tmp_path), "--verbose"])
assert ev.main() in (0, 1) # a difference exit is compatible with the original CLI
assert calls == [tmp_path]
output = capsys.readouterr().out
assert "Split: dev" in output and a.package in output
def test_snapshot_fit_cli_writes_markdown_and_json(tmp_path, monkeypatch):
a = _analysis()
snapshot = tmp_path / "synthetic.json"
snapshot.write_text(json.dumps({a.package: {"analysis": a.to_dict(), "converted": True}}))
md, js = tmp_path / "fit.md", tmp_path / "fit.json"
monkeypatch.setattr(catalog, "_load_dir", lambda *args: {a.package: _entry()})
monkeypatch.setattr(ev, "patch_intro_dates", lambda p: {})
monkeypatch.setattr(ev.quest_dump, "scan", lambda *a: pytest.fail("must not scan with --snapshot"))
monkeypatch.setattr(ev.sys, "argv", ["eval_heuristics.py", "--snapshot", str(snapshot), "--fit-report",
"--out", str(md), "--json", str(js)])
assert ev.main() == 0
assert "# New patch fit" in md.read_text()
assert json.loads(js.read_text())["games"][0]["converted"]
def test_evaluate_split_coverage_and_no_snapshot_analysis_mutation():
snapshot = {f"com.game.n{i}": {"analysis": _analysis(package=f"com.game.n{i}").to_dict(), "converted": i == 0}
for i in range(10)}
entries = {p: _entry(package=p) for p in snapshot}
before = json.dumps(snapshot, sort_keys=True)
reports = {s: ev.evaluate(snapshot, entries, s) for s in ("all", "dev", "holdout")}
assert reports["dev"]["total_games"] == 8
assert reports["holdout"]["total_games"] == 2
assert reports["all"]["total_games"] == 10
assert json.dumps(snapshot, sort_keys=True) == before
assert reports["all"]["converted_games"] == 1
def test_catalog_truth_applies_the_a32_bridge_transform():
"""A 32-bit game's entry relies on the bridge switching OVRPort and the 64-bit fixes off; the heuristics do the
same, so neither side may count those as removals."""
a = _analysis(abis=["armeabi-v7a"], engine="Unreal", graphics="GLES")
defaults = ev.expected_defaults(a)
want = ev.catalog_signature(_entry(), defaults, a)
got = ev.signature(engine.suggest(a, use_catalog=False), defaults)
assert want["overport_removed"] == got["overport_removed"]
assert want["frame_removed"] == got["frame_removed"]
def test_dated_score_sets_aside_patches_newer_than_the_entry():
want = ev.catalog_signature(_entry(frame=["frame.unity_text_input"]))
got = ev.catalog_signature(_entry(frame=["frame.unity_text_input", "frame.hw_video_decode"]))
strict = ev.score_signatures(want, got)["headline"]
dated = ev.score_signatures(want, got, {"frame.hw_video_decode"})["headline"]
assert strict["extra"] and not strict["exact"]
assert dated["exact"] and dated["gap"] == {"frame.hw_video_decode": True}
assert "frame.hw_video_decode" not in dated["per_patch"]
def test_inherited_picks_are_adapter_and_untoggled_device_patches():
inherited = ev.inherited_decisions()
assert "adapter.haptic_fix" in inherited
assert "device.text_input_window" not in inherited # catalog.TOGGLED_DEVICE: dropped unless the entry names it
assert not any(pid.startswith("frame.") for pid in inherited)
def test_gap_decisions_use_the_verified_date_not_updated():
e = _entry(verified={"date": "2026-09-28"}, updated="2026-10-14")
gaps = ev.gap_decisions(e, {"frame.unity_text_input": "2026-10-03", "frame.adapter": "2026-09-28",
"patch_remove_unreal_force_quit": "2026-10-01"})
assert gaps == {"frame.unity_text_input", "alt:patch_remove_unreal_force_quit"}
assert ev.entry_date(e) == "2026-09-28"
+17 -13
View File
@@ -8,6 +8,12 @@ from frameport.sources import quest_dump
pytestmark = pytest.mark.games
# 2026-10-15 full snapshot (80 dumps, 65 supported catalog games), strict score: 5/65 exact, patch F1 73.8%;
# rounded down. The dated score (--fit-report's date cache) was 39/65, F1 88.6%. See docs/HEURISTICS_EVAL.md;
# runtime-only choices and unsupported catalog entries are excluded.
MIN_HEURISTICS_EXACT_RATIO = 0.07
MIN_HEURISTICS_PATCH_F1 = 0.73
@pytest.fixture(scope="module")
def dumps():
@@ -36,8 +42,7 @@ def test_every_catalog_game_found_and_detected(dumps):
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)."""
"""Ratchet catalog-only static accuracy without requiring runtime findings to be detectable from APKs."""
import importlib.util
from pathlib import Path
@@ -45,16 +50,15 @@ def test_heuristics_reproduce_catalog(dumps):
spec = importlib.util.spec_from_file_location("ev", script)
ev = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ev)
exact = total = 0
snapshot = {}
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"
row = {"analysis": a.to_dict(), "converted": a.is_overport_output}
if a.package not in snapshot or snapshot[a.package]["converted"] and not row["converted"]:
snapshot[a.package] = row
entries = catalog._load_dir(script.parents[1] / "catalog/games", "bundled")
result = ev.evaluate(snapshot, entries, split="all")["headline"]
assert result["games"], "no supported catalog games found in the dumps"
assert result["exact_ratio"] >= MIN_HEURISTICS_EXACT_RATIO, (
f"only {result['exact']}/{result['games']} static catalog recipes reproduced exactly")
assert result["patches"]["f1"] >= MIN_HEURISTICS_PATCH_F1, result["patches"]
+51
View File
@@ -0,0 +1,51 @@
"""Adapter recommendations use the detected runtime path, without a catalog."""
import pytest
from test_patches import _analysis
from frameport.patches import base
from frameport.recommend.engine import suggest
@pytest.mark.parametrize(("extra", "other_libs", "converted", "expected"), [
# Built-in Oculus has the system-app check; modern Unity OpenXR can still ship OVRPlugin.
({"unity_oculus_check": True}, [], False, True),
({"unity_oculus_check": False}, ["libUnityOpenXR.so"], False, False),
({"unity_oculus_check": False}, [], False, False),
# Neither the version nor the graphics API is the reason to select this fix.
({"unity_oculus_check": False, "unity_version": "2019.4.20f1"}, [], False, False),
({"unity_oculus_check": True, "unity_version": "2019.4.20f1"}, [], False, True),
# The built-in check can coexist with XR Plugin Management: retain both exclusions.
({"unity_oculus_check": True, "oculus_xr_plugin": True}, [], False, False),
({"unity_oculus_check": True}, ["libOculusXRPlugin.so"], False, False),
# Old analyses have no check signal. Converted builds may have the check patched out.
({}, [], False, True),
({"unity_oculus_check": False}, [], True, True),
({"unity_oculus_check": False}, ["libOculusXRPlugin.so"], True, False),
])
def test_pose_time_fix_uses_builtin_oculus_evidence(extra, other_libs, converted, expected):
a = _analysis(libs=["libunity.so", "libOVRPlugin.so", *other_libs],
extra=extra, is_overport_output=converted)
patch = base.get("adapter.pose_time_fix")
detected = patch.detect(a)
assert patch.applies(a) # still available for manual investigation in any OVRPlugin game
assert bool(detected and detected.recommended) is expected
recipe = suggest(a, use_catalog=False)
assert (patch.id in recipe.patches) is expected
if expected:
assert recipe.patches[patch.id] == {"value": 1}
# The new evidence is specific to pose timing: OVRPlugin's haptic bug affects both paths.
assert recipe.patches["adapter.haptic_fix"] == {"value": 1}
def test_pose_time_fix_still_requires_ovrplugin():
a = _analysis(libs=["libunity.so"], extra={"unity_oculus_check": True}, is_overport_output=False)
patch = base.get("adapter.pose_time_fix")
assert not patch.applies(a)
assert patch.detect(a) is None
assert patch.id not in suggest(a, use_catalog=False).patches
def test_unreal_pose_evidence_is_independent_of_unity():
a = _analysis(engine="Unreal", libs=["libUE4.so", "libOVRPlugin.so"], is_overport_output=False,
extra={"unity_oculus_check": False, "unreal_thumb_touch": True})
assert suggest(a, use_catalog=False).patches["adapter.pose_time_fix"] == {"value": 1}
+136
View File
@@ -0,0 +1,136 @@
"""Unity heuristics from executable paths and tool support, without catalog/package matching."""
import pytest
from test_patches import _analysis
from frameport.patches import base
from frameport.recommend.engine import suggest
IL2CPP_FIXES = ("frame.unity_text_input", "frame.unity_runtime_msaa_off")
def _il2cpp(**changes):
values = dict(libs=["libunity.so", "libil2cpp.so", "libOVRPlugin.so"], graphics="GLES",
extra={"unity_version": "2019.4.10f1", "text_fields": ["TMP_InputField"],
"ovr_runtime_msaa": True})
values.update(changes)
return _analysis(**values)
@pytest.mark.parametrize("pid", IL2CPP_FIXES)
@pytest.mark.parametrize("version", ["2017.4.23f1", "2017.1.0f1", "5.6.7f1"])
def test_unreadable_old_metadata_does_not_recommend_il2cpp_rewrites(pid, version):
a = _il2cpp(extra={"unity_version": version, "text_fields": ["TMP_InputField"], "ovr_runtime_msaa": True})
p = base.get(pid)
assert p.detect(a) is None
assert pid not in suggest(a, use_catalog=False).patches
assert p.applies(a) # still available for an explicit recipe; only automatic detection changes
@pytest.mark.parametrize("pid", IL2CPP_FIXES)
@pytest.mark.parametrize("version", ["2018.4.0f1", "2019.4.1f1", "2022.3.1f1", "6000.5.6f1", None, "unknown"])
def test_supported_or_unknown_metadata_keeps_il2cpp_recommendations(pid, version):
a = _il2cpp(extra={"unity_version": version, "text_fields": ["InputField"], "ovr_runtime_msaa": True})
assert base.get(pid).detect(a).recommended
assert pid in suggest(a, use_catalog=False).patches
@pytest.mark.parametrize("pid", IL2CPP_FIXES)
@pytest.mark.parametrize("abis", [["armeabi-v7a"], ["x86_64"], []])
def test_aarch64_return_instructions_are_not_recommended_for_other_abis(pid, abis):
assert base.get(pid).detect(_il2cpp(abis=abis)) is None
@pytest.mark.parametrize("pid", IL2CPP_FIXES)
def test_il2cpp_rewrites_still_need_unity_and_il2cpp(pid):
assert base.get(pid).detect(_il2cpp(engine="Unreal")) is None
assert base.get(pid).detect(_il2cpp(libs=["libunity.so", "libmonobdwgc-2.0.so"])) is None
def test_text_classes_remain_a_signal_for_both_unity_field_implementations():
p = base.get("frame.unity_text_input")
for fields in (["TMP_InputField"], ["InputField"], ["TMP_InputField", "InputField"]):
a = _il2cpp(extra={"text_fields": fields})
assert p.detect(a).recommended
assert base.get("device.text_input_window").detect(a).recommended
assert p.detect(_il2cpp(extra={"text_fields": []})) is None
@pytest.mark.parametrize("graphics", ["Vulkan (declared in manifest)", "Vulkan (Unity boot.config)"])
def test_runtime_msaa_does_not_guess_gles_from_a_vulkan_label(graphics):
assert base.get("frame.unity_runtime_msaa_off").detect(_il2cpp(graphics=graphics)) is None
def test_runtime_msaa_needs_the_runtime_msaa_signal():
assert base.get("frame.unity_runtime_msaa_off").detect(_il2cpp(extra={}, unity_msaa_levels=4)) is None
@pytest.mark.parametrize("version", ["2017.4.23f1", "2019.4.20f1", None])
def test_built_in_oculus_path_still_needs_system_app_bypass(version):
a = _il2cpp(extra={"unity_version": version, "unity_oculus_check": True})
p = base.get("frame.unity_oculus_check")
assert p.detect(a).recommended
assert p.id in suggest(a, use_catalog=False).patches
def test_oculus_xr_plugin_still_gets_the_builtin_startup_check():
"""A verified game (com.coatsink.alone) ships libOculusXRPlugin.so and still needs the bypass."""
a = _il2cpp(libs=["libunity.so", "libil2cpp.so", "libOVRPlugin.so", "libOculusXRPlugin.so"],
extra={"unity_version": "2019.4.35f1", "unity_oculus_check": True})
assert base.get("frame.unity_oculus_check").detect(a).recommended
def test_oculus_metadata_field_alone_does_not_prove_a_native_plugin_path():
a = _il2cpp(extra={"unity_oculus_check": True, "oculus_xr_plugin": True})
assert base.get("frame.unity_oculus_check").detect(a).recommended
def test_oculus_bypass_needs_both_the_check_and_ovrplugin():
p = base.get("frame.unity_oculus_check")
assert p.detect(_il2cpp(extra={})) is None
assert p.detect(_il2cpp(libs=["libunity.so"], extra={"unity_oculus_check": True})) is None
assert p.detect(_il2cpp(engine="Other", extra={"unity_oculus_check": True})) is None
FIRESTORE = "libFirebaseCppFirestore.so"
GECKO = "libxul.so"
def _tls(version="6000.5.6f1", heavy=(FIRESTORE, GECKO), **changes):
return _analysis(libs=["libunity.so", *heavy], extra={"unity_version": version}, **changes)
@pytest.mark.parametrize("version", ["6000.0.1f1", "6000.5.6f1"])
def test_unity6_firestore_and_gecko_recommend_thread_key_pool(version):
a = _tls(version)
p = base.get("frame.unity_tls_key_pool")
assert p.detect(a).recommended
assert p.id in suggest(a, use_catalog=False).patches
@pytest.mark.parametrize("heavy", [(FIRESTORE,), (GECKO,), ("libFirebaseCppApp-9_6_0.so", GECKO)])
def test_less_thread_key_pressure_remains_an_optional_diagnostic(heavy):
a = _tls(heavy=heavy)
p = base.get("frame.unity_tls_key_pool")
assert not p.detect(a).recommended
assert p.id not in suggest(a, use_catalog=False).patches
@pytest.mark.parametrize("version", ["2019.4.1f1", "2022.3.1f1", None, "unknown"])
def test_thread_key_pressure_rule_requires_unity6(version):
assert base.get("frame.unity_tls_key_pool").detect(_tls(version)) is None
def test_thread_key_pool_requires_native_unity_for_arm64():
p = base.get("frame.unity_tls_key_pool")
assert p.detect(_tls(engine="Unreal")) is None
assert p.detect(_tls(abis=["armeabi-v7a"])) is None
assert p.detect(_analysis(libs=[FIRESTORE, GECKO], extra={"unity_version": "6000.5.6f1"})) is None
def test_rendering_tradeoffs_stay_opt_in_without_the_runtime_symptom():
a = _il2cpp(libs=["libunity.so", "libil2cpp.so", "libOculusXRPlugin.so"], unity_msaa_levels=4,
extra={"oculus_xr_plugin": True, "ovr_runtime_msaa": True})
for pid in ("frame.unity_multipass", "frame.unity_no_overlay_copy"):
p = base.get(pid)
assert p.applies(a) and p.detect(a) is None
assert pid not in suggest(a, use_catalog=False).patches
+18 -2
View File
@@ -31,9 +31,11 @@ def _states():
("quest not installed", QUEST, MenuState(connected=True, installs=[_install("Install on Frame")],
settings=True, selectable=True, android=True)),
("rift on PC", RIFT, MenuState(on_pc=True, plays=[_play("this PC")], programs=True, selectable=True,
installs=[_install("Reinstall on this PC")])),
installs=[_install("Reinstall on this PC")], new_version=True)),
("linux on Frame", LINUX, MenuState(connected=True, on_frame=True, plays=[_play()], installs=[_install()],
selectable=True)),
selectable=True, new_version=True)),
("linux job running", LINUX, MenuState(connected=True, on_frame=True, job=True, selectable=True,
new_version=True)),
("job running", QUEST, MenuState(connected=True, on_frame=True, job=True, settings=True, selectable=True,
android=True)),
]
@@ -172,3 +174,17 @@ def test_newer_apk_for_quest_games_in_both_menus():
want = g is QUEST and not st.job
assert ("Update with a newer APK…" in quick) == want, name
assert ("Update with a newer APK…" in full) == want, name
def test_newer_version_for_linux_apps_and_pc_vr_games():
"""GitHub #161: Linux apps and PC VR games get "Update with a newer version…" (a file or folder), not the APK
entry, in both menus; never while a job runs."""
for name, g, st in _states():
quick = [e[0] for e in _items(menu_sections(g, st, _key))]
st.quick = False
full = [e[0] for e in _items(menu_sections(g, st, _key))]
want = g is not QUEST and not st.job
assert ("Update with a newer version…" in quick) == want, name
assert ("Update with a newer version…" in full) == want, name
if g is not QUEST:
assert "Update with a newer APK…" not in quick + full, name
+36 -6
View File
@@ -38,8 +38,36 @@ def test_quote_refuses_batch_injection(value):
native.quote(value)
def test_quote_protects_spaces_quotes_globs():
assert native.quote('a *?["b') == '"a \\*\\?\\[\\"b"'
def test_quote_leaves_globs_to_sftp():
"""Inside double quotes sftp escapes * ? [ itself: an extra backslash became part of the name (GitHub #324)."""
assert native.quote('a *?["b') == '"a *?[\\"b"'
assert native.quote("/x/te*s?t [ENG] \\b+(1).apk") == '"/x/te*s?t [ENG] \\\\b+(1).apk"'
def test_quote_windows_local_path_uses_forward_slashes():
"""Win32-OpenSSH turns a local path's backslashes into '/', escapes included ("C://Users", "/[ENG]")."""
path = r"C:\Users\user_name\AppData\Local\FramePort\output\All-In-One - Sports VR [ENG] v100+1.9.0 (x)\a.apk"
assert native.quote(path, windows=True) == (
'"C:/Users/user_name/AppData/Local/FramePort/output/All-In-One - Sports VR [ENG] v100+1.9.0 (x)/a.apk"')
assert native.quote(r"\\server\share\*?[a].apk", windows=True) == '"//server/share/*?[a].apk"'
assert native.quote('C:\\odd "name"\\a.apk', windows=True) == '"C:/odd \\"name\\"/a.apk"'
@pytest.mark.parametrize("os_name,expected", [("nt", '"C:/games/[ENG] a/x.apk"'),
("posix", '"C:\\\\games\\\\[ENG] a\\\\x.apk"')])
def test_quote_local_follows_the_platform(monkeypatch, os_name, expected):
monkeypatch.setattr(native.os, "name", os_name)
assert native.quote_local(r"C:\games\[ENG] a\x.apk") == expected
def test_batch_line_for_bracketed_folder(tmp_path, monkeypatch):
folder = tmp_path / "All-In-One - Sports VR [ENG] v100+1.9.0"
folder.mkdir()
frame, items, files, commands, processes = setup_batch(folder, monkeypatch)
monkeypatch.setattr(native.os, "name", "posix")
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1)
assert sorted(commands[0].splitlines()) == [f'put "{folder}/patch.obb" "/incoming/patch.obb.part"',
f'reput "{folder}/main.obb" "/incoming/main.obb.part"']
def test_size_lookup_does_not_hide_connection_errors():
@@ -65,10 +93,11 @@ def setup_batch(tmp_path, monkeypatch, code=0):
monitor.stat.side_effect = stat
monitor.remove.side_effect = lambda path: files.pop(path)
frame = SimpleNamespace(sftp=monitor, client=SimpleNamespace(open_sftp=lambda: monitor),
target=FrameTarget("paired"), run=Mock(return_value=(0, "", "")))
target=FrameTarget("paired"), run=Mock(return_value=(0, "", "")), modes_seen=[])
monkeypatch.setattr(native.shutil, "which", lambda _: "sftp")
@contextlib.contextmanager
def creds(frame):
def creds(frame, relaxed=False):
frame.modes_seen.append(relaxed)
yield ["-o", "StrictHostKeyChecking=yes"]
monkeypatch.setattr(native, "credentials", creds)
commands, processes = [], []
@@ -210,7 +239,7 @@ def setup_tar(tmp_path, monkeypatch, code=0):
path = tmp_path / "small.bin"
path.write_bytes(b"payload")
frame = SimpleNamespace(target=FrameTarget("paired.example", port=2222))
frame = SimpleNamespace(target=FrameTarget("paired.example", port=2222), modes_seen=[])
pipe = io.BytesIO()
pipe.close = lambda: None
process = Mock(stdin=pipe, returncode=code)
@@ -219,8 +248,9 @@ def setup_tar(tmp_path, monkeypatch, code=0):
monkeypatch.setattr(native.subprocess, "Popen", popen)
monkeypatch.setattr(native.shutil, "which", lambda _: "ssh")
@contextlib.contextmanager
def creds(frame, port_flag):
def creds(frame, port_flag, relaxed=False):
assert port_flag == "-p"
frame.modes_seen.append(relaxed)
yield ["-o", "StrictHostKeyChecking=yes", port_flag, "2222"]
monkeypatch.setattr(native, "credentials", creds)
return frame, [(path, "nested/small.bin")], process, popen, pipe
+230
View File
@@ -0,0 +1,230 @@
"""GitHub #161: a newer version of a Linux app or a PC VR game already in the library (game menu "Update with a newer
version…", CLI `frameport new-version`)."""
import os
import time
import pytest
from test_install_drives import FakeFrame
from test_linux_apps import elf
from test_rift import unity_game
from frameport import pipeline
from frameport.analysis import linux
from frameport.core import library
from frameport.core.events import Reporter
from frameport.install import installer
from frameport.ui import components as C
def _appimage(path, extra=b""):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(elf(linux.EM_AARCH64, appimage=True, extra=extra))
return path
def _frame(package, recipe=None):
return {"installed": [{"package": package, "sha256": None, "recipe": recipe}]}
# ------------------------------------------------------------------------------------------ Linux apps
def test_newer_appimage_keeps_name_art_and_switches(tmp_path):
old = _appimage(tmp_path / "v1" / "Venera-Prime-2.4.2-aarch64.AppImage")
pkg = pipeline.add_linux_app(old)["package"]
library.upsert_game(pkg, title="My Venera", title_locked=True, tags=["mine"], desktop_entry=False,
desktop_icon=False, vr_overlay=True)
assert C.install_state(library.game(pkg), _frame(pkg)) == "installed" # never updated: no source id needed
new = _appimage(tmp_path / "v2" / "Venera-Prime-2.5.0-aarch64.AppImage", extra=b"newer")
res = pipeline.replace_linux_app(pkg, new)
g = library.game(pkg)
assert g["package"] == pkg and g["exe"] == new.name and g["game_dir"] == str(new.parent)
assert g["analysis"]["extra"]["source"] == str(new) and g["analysis"]["extra"]["files"] == [new.name]
assert g["data_bytes"] == new.stat().st_size
assert (g["title"], g["title_locked"], g["tags"]) == ("My Venera", True, ["mine"])
assert (g["desktop_entry"], g["desktop_icon"], g["vr_overlay"]) == (False, False, True)
assert g["replaced_source"] == str(old) and g["source_id"] and res["different"] is False
# the Frame's copy (installed before) is outdated until an install records this version
assert C.install_state(g, _frame(pkg)) == "outdated"
assert C.install_state(g, _frame(pkg, {"source_id": "older"})) == "outdated"
assert C.install_state(g, _frame(pkg, {"source_id": g["source_id"]})) == "installed"
def test_newer_linux_folder_keeps_the_chosen_program(tmp_path):
def app(root, size):
(root / "bin").mkdir(parents=True)
(root / "Tool").write_bytes(elf(linux.EM_AARCH64, extra=b"\0" * size)) # bigger: FramePort's pick
(root / "bin" / "tool-vr").write_bytes(elf(linux.EM_AARCH64))
return root
pkg = pipeline.add_linux_app(app(tmp_path / "v1" / "Tool", 4096), exe="bin/tool-vr")["package"]
assert library.game(pkg)["exe"] == "bin/tool-vr"
pipeline.replace_linux_app(pkg, app(tmp_path / "v2" / "Tool", 8192))
g = library.game(pkg)
assert g["exe"] == "bin/tool-vr" and g["game_dir"] == str(tmp_path / "v2" / "Tool")
# the program isn't there any more: FramePort's pick
(tmp_path / "v3" / "Tool").mkdir(parents=True)
(tmp_path / "v3" / "Tool" / "Tool").write_bytes(elf(linux.EM_AARCH64))
pipeline.replace_linux_app(pkg, tmp_path / "v3" / "Tool")
assert library.game(pkg)["exe"] == "Tool"
def test_another_linux_app_is_added_with_a_warning(tmp_path):
pkg = pipeline.add_linux_app(_appimage(tmp_path / "Venera-Prime-1.0-aarch64.AppImage"))["package"]
res = pipeline.replace_linux_app(pkg, _appimage(tmp_path / "Blender-4.2-aarch64.AppImage"))
assert res["different"] and res["name"] == "Blender" and library.game(pkg)["exe"].startswith("Blender")
assert not library.game("linux.blender") # no second entry: the user's pick is the newer version
with pytest.raises(ValueError):
pipeline.replace_linux_app(pkg, tmp_path / "missing.AppImage")
def test_only_the_right_kind(tmp_path):
pkg = pipeline.add_linux_app(_appimage(tmp_path / "Tool-aarch64.AppImage"))["package"]
with pytest.raises(ValueError):
pipeline.replace_rift_game(pkg, tmp_path)
library.upsert_game("com.x.quest", title="Quest", apk="/x.apk")
with pytest.raises(ValueError):
pipeline.replace_linux_app("com.x.quest", tmp_path / "Tool-aarch64.AppImage")
def test_linux_update_sends_changed_files_of_the_same_size(tmp_path, monkeypatch):
(tmp_path / "app").mkdir()
for name in ("tool", "data.txt"):
(tmp_path / "app" / name).write_bytes(b"x" * 10)
old = time.time() - 3600
os.utime(tmp_path / "app" / "data.txt", (old, old))
monkeypatch.setattr(installer, "upload_steam_art", lambda *a, **k: None)
monkeypatch.setattr(installer, "apply_app_icon", lambda *a, **k: None)
monkeypatch.setattr(installer, "_tags", lambda pkg: [])
sent = []
monkeypatch.setattr(installer, "upload_files", lambda xfer, files, *a: sent.extend(f[1] for f in files))
monkeypatch.setattr(installer, "transfer_link", lambda *a: _Null())
frame = FakeFrame({"prepare_linux": {"existing": {"app": {"tool": 10, "data.txt": 10}}, "free_bytes": 1 << 40,
"incoming": "/x/incoming", "base": "/b"},
"finalize_linux": {"base": "/b", "appid": 1}})
plan = installer.LinuxPlan("linux.tool", "Tool", tmp_path / "app", "tool")
installer.install_linux(frame, plan, Reporter())
assert sent == [] and dict(frame.calls)["finalize_linux"]["recipe"] is None # same sizes: nothing sent
# (st_ctime can't be set: the file's own ctime is now) a newer version: files changed since the last install
plan = installer.LinuxPlan("linux.tool", "Tool", tmp_path / "app", "tool", source_id="v2",
changed_since=time.time() + 60)
installer.install_linux(frame, plan, Reporter())
assert sent == []
plan.changed_since = time.time() - 60
installer.install_linux(frame, plan, Reporter())
assert sorted(sent) == ["app/data.txt", "app/tool"]
assert frame.calls[-1][1]["recipe"] == {"source_id": "v2"}
class _Null:
def __enter__(self):
return None
def __exit__(self, *a):
return False
def test_install_records_the_version(tmp_path, monkeypatch):
pkg = pipeline.add_linux_app(_appimage(tmp_path / "v1" / "Tool-aarch64.AppImage"))["package"]
got = {}
class T:
label = "Frame"
def install_linux(self, *a, **k):
got.clear()
got.update(k)
return {}
monkeypatch.setattr(pipeline, "steam_title", lambda e: "Tool")
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
assert "source_id" not in got # never updated: installs as before
pipeline.replace_linux_app(pkg, _appimage(tmp_path / "v2" / "Tool-aarch64.AppImage"))
sid = library.game(pkg)["source_id"]
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
assert got["source_id"] == sid and 0 < got["changed_since"] <= time.time() # since the last install there
assert library.game(pkg)["installs"]["Frame"]["source_id"] == sid
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
assert got["changed_since"] is None # this version is there already: resumes as usual
# ------------------------------------------------------------------------------------------ PC VR (Rift) games
def _rift(tmp_path):
first = unity_game(tmp_path / "v1")
[g] = pipeline.add_path(tmp_path / "v1")
pkg = g["package"]
r = library.recipe_from_dict(g["recipe"])
r.source = "user"
r.patches["pcvr.no_crash_reporter"] = {}
pipeline.set_recipe(pkg, r)
library.upsert_game(pkg, title="My Space", tags=["mine"], exe_confirmed=True,
build={"ok": True, "sha256": "old", "kind": "rift"})
return pkg, first
def test_newer_rift_folder_keeps_name_recipe_and_program(tmp_path):
pkg, first = _rift(tmp_path)
new = unity_game(tmp_path / "v2")
res = pipeline.replace_rift_game(pkg, new)
g = library.game(pkg)
assert g["game_dir"] == str(new) and g["exe"] == "Space Game.exe" and g["exe_confirmed"] is True
assert (g["title"], g["tags"]) == ("My Space", ["mine"])
assert "pcvr.no_crash_reporter" in g["recipe"]["patches"] and g["recipe"]["source"] == "user"
assert g["build"] is None and g["replaced_dirs"] == [str(first)] and not res["different"]
from frameport.patches.pcvr import game_args
dep = {"package": pkg, "sha256": "old", "recipe": {"patches": sorted(g["recipe"]["patches"])},
"game_args": game_args(library.recipe_from_dict(g["recipe"]))}
assert C.install_state(g, {"installed": [dep]}) == "outdated"
assert C.pc_outdated(g, dep)
dep["recipe"]["source_id"] = g["source_id"]
assert C.install_state(g, {"installed": [dep]}) == "installed" and not C.pc_outdated(g, dep)
# a rescan of the folders doesn't bring the old copy back
assert pipeline.add_path(tmp_path / "v1") == [] and library.game(pkg)["game_dir"] == str(new)
def test_newer_rift_folder_without_the_program_picks_again(tmp_path):
pkg, _ = _rift(tmp_path)
new = unity_game(tmp_path / "v2", name="Space Game VR")
res = pipeline.replace_rift_game(pkg, new)
assert res["exe_changed"] and library.game(pkg)["exe"] == "Space Game VR.exe"
with pytest.raises(ValueError):
pipeline.replace_rift_game(pkg, tmp_path / "nothing")
other = unity_game(tmp_path / "other", name="Moon Game")
pipeline.add_path(tmp_path / "other")
with pytest.raises(ValueError, match="already in the library"):
pipeline.replace_rift_game(pkg, other)
def test_rift_update_resends_changed_game_files(tmp_path, monkeypatch):
game = unity_game(tmp_path)
monkeypatch.setattr(installer, "upload_steam_art", lambda *a, **k: None)
monkeypatch.setattr(installer, "_tags", lambda pkg: [])
monkeypatch.setattr(installer, "transfer_link", lambda *a: _Null())
sent = []
monkeypatch.setattr(installer, "upload_files", lambda xfer, files, *a: sent.extend(f[1] for f in files))
monkeypatch.setattr("frameport.artwork.sources.has_art", lambda pkg: True)
manifest = installer.local_data_manifest(game)
frame = FakeFrame({"prepare_pcvr": {"proton": {"display_name": "Proton"}, "openxr": None,
"existing": {"game": dict(manifest)}, "free_bytes": 1 << 40,
"incoming": "/i", "base": "/b"},
"finalize_pcvr": {"base": "/b", "proton": "p", "appid": 1}})
from frameport.core.models import Recipe
plan = installer.PcvrPlan("rift.space_game", "Space", game, "Space Game.exe", Recipe(package="rift.space_game"),
None, source_id="v2", changed_since=time.time() - 60)
installer.install_pcvr(frame, plan, Reporter())
assert sorted(sent) == sorted(f"game/{rel}" for rel in manifest)
assert frame.calls[-1][1]["recipe"]["source_id"] == "v2"
# ------------------------------------------------------------------------------------------ CLI
def test_cli_new_version(tmp_path):
from typer.testing import CliRunner
from frameport import cli
pkg = pipeline.add_linux_app(_appimage(tmp_path / "v1" / "Tool-1.0-aarch64.AppImage"))["package"]
new = _appimage(tmp_path / "v2" / "Tool-1.1-aarch64.AppImage")
out = CliRunner().invoke(cli.app, ["new-version", pkg, str(new)])
assert out.exit_code == 0, out.output
assert library.game(pkg)["exe"] == new.name and "program Tool-1.1-aarch64.AppImage" in out.output
(tmp_path / "empty").mkdir()
bad = CliRunner().invoke(cli.app, ["new-version", pkg, str(tmp_path / "empty")]) # no program in it
assert bad.exit_code == 2
+340
View File
@@ -0,0 +1,340 @@
"""SSH keys beyond FramePort's own (GitHub #242): key files from Settings, the login a connection used, and OpenSSH
uploads that fall back to this PC's own keys/agent when the Frame refuses FramePort's key."""
import contextlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import paramiko
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
from frameport.core import library
from frameport.core.events import Reporter
from frameport.frame import connection as C
from frameport.frame import native_upload as native
REFUSED = (b"debug1: Reading configuration data nul\r\n"
b"debug1: identity file C:/Temp/frameport-upload-x/key0 type 3\r\n"
b"debug1: Connecting to 10.0.0.2 [10.0.0.2] port 22.\r\n"
b"@ WARNING: UNPROTECTED PRIVATE KEY FILE! @\r\n"
b"debug1: Offering public key: C:/Temp/frameport-upload-x/key0 ED25519 SHA256:abc explicit\r\n"
b"debug1: Authentications that can continue: publickey,password\r\n"
b"debug1: kex: algorithm: curve25519-sha256\r\n"
b"steamos@10.0.0.2: Permission denied (publickey,password).\r\n")
@pytest.fixture(autouse=True)
def no_real_home_keys(tmp_path, monkeypatch):
"""Never read the real ~/.ssh (relaxed mode looks there)."""
empty = tmp_path / "empty-home"
empty.mkdir()
monkeypatch.setattr(native.Path, "home", lambda: empty)
def write_key(path: Path, password: bytes | None = None) -> Path:
enc = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption()
key = ed25519.Ed25519PrivateKey.generate()
path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, enc))
path.with_name(path.name + ".pub").write_bytes(key.public_key().public_bytes(
serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH))
return path
# ------------------------------------------------------------------------------------------- key files
def test_key_file_validation(tmp_path):
good = write_key(tmp_path / "id_ed25519")
assert isinstance(C.load_key_file(good), paramiko.PKey)
with pytest.raises(C.KeyFileError, match="passphrase"):
C.load_key_file(write_key(tmp_path / "locked", b"secret"))
with pytest.raises(C.KeyFileError, match="public half"):
C.load_key_file(tmp_path / "id_ed25519.pub")
with pytest.raises(C.KeyFileError, match="not found"):
C.load_key_file(tmp_path / "missing")
(tmp_path / "notes.txt").write_text("hello")
with pytest.raises(C.KeyFileError, match="isn't a private SSH key"):
C.load_key_file(tmp_path / "notes.txt")
def test_extra_keys_come_from_settings_and_skip_unusable_ones(tmp_path):
good = write_key(tmp_path / "mine")
locked = write_key(tmp_path / "locked", b"secret")
library.set_setting(C.EXTRA_KEYS_SETTING, [str(good), str(good), str(locked), str(tmp_path / "gone"), "", 3])
assert C.extra_key_paths() == [good, locked, tmp_path / "gone"]
assert [path for path, _ in C.extra_keys()] == [good]
library.set_setting(C.EXTRA_KEYS_SETTING, str(good)) # a single path, e.g. edited by hand
assert C.extra_key_paths() == [good]
class _Client:
"""paramiko.SSHClient stand-in: accepts the key `accepted` (or the agent when accepted == "agent")."""
accepted = None
tried: list = []
def load_host_keys(self, path):
pass
def set_missing_host_key_policy(self, policy):
pass
def close(self):
pass
def save_host_keys(self, path):
pass
def get_transport(self):
return Mock()
def connect(self, pkey=None, password=None, allow_agent=False, look_for_keys=False, **kw):
kind = "password" if password else "agent" if allow_agent else pkey
_Client.tried.append(kind)
if kind != _Client.accepted:
raise paramiko.AuthenticationException("Authentication failed.")
@pytest.mark.parametrize("accepted,auth", [("app", "app_key"), ("user", "user_key"), ("agent", "agent")])
def test_connection_records_the_login_that_worked(monkeypatch, accepted, auth):
monkeypatch.setattr(C.paramiko, "SSHClient", _Client)
monkeypatch.setattr(C, "app_keys", lambda: ["app"])
monkeypatch.setattr(C, "extra_keys", lambda: [(Path("/k"), "user")])
monkeypatch.setattr(C.Frame, "run", lambda self, cmd: (0, "/home/steamos", ""))
_Client.accepted, _Client.tried = accepted, []
frame = C.Frame(C.FrameTarget("10.0.0.2")).connect()
assert frame.auth == auth
assert _Client.tried == ["app", "user", "agent"][:["app", "user", "agent"].index(accepted) + 1]
# ------------------------------------------------------------------------------------------- OpenSSH arguments
def frame_with_key():
key = SimpleNamespace(get_name=lambda: "ssh-ed25519", get_base64=lambda: "PAIRED_KEY")
client = SimpleNamespace(get_transport=lambda: SimpleNamespace(get_remote_server_key=lambda: key))
return C.Frame(C.FrameTarget("paired.example"), client=client)
def test_credentials_offer_own_and_settings_keys_strict_or_relaxed(tmp_path, monkeypatch):
own = tmp_path / "ssh"
own.mkdir()
write_key(own / "id_ed25519")
mine = write_key(tmp_path / "id_ed25519") # same file name as FramePort's: copies get their own names
monkeypatch.setattr(native, "ssh_dir", lambda: own)
library.set_setting(C.EXTRA_KEYS_SETTING, [str(mine)])
with native.credentials(frame_with_key()) as args:
copies = [Path(args[i + 1]) for i, a in enumerate(args) if a == "-i"]
assert [c.read_bytes() for c in copies] == [(own / "id_ed25519").read_bytes(), mine.read_bytes()]
assert "IdentitiesOnly=yes" in args and "BatchMode=yes" in args and "-v" in args
assert all(oct(c.stat().st_mode & 0o777) == "0o600" for c in copies)
assert not any(c.exists() for c in copies)
with native.credentials(frame_with_key(), relaxed=True) as args:
assert "IdentitiesOnly=yes" not in args and args.count("-i") == 2
assert "StrictHostKeyChecking=yes" in args # host key still pinned
def test_relaxed_credentials_work_without_any_key_file(tmp_path, monkeypatch):
monkeypatch.setattr(native, "ssh_dir", lambda: tmp_path)
with pytest.raises(OSError, match="no FramePort key"):
with native.credentials(frame_with_key()):
pass
with native.credentials(frame_with_key(), relaxed=True) as args:
assert "-i" not in args
def test_windows_sid_survives_a_non_ascii_user_name(monkeypatch):
out = '"desktop-1\\josé müller","S-1-5-21-1004336348-1177238915-682003330-1001"\r\n'.encode("cp850")
monkeypatch.setattr(native.subprocess, "check_output", lambda *a, **k: out)
assert native.current_sid() == "S-1-5-21-1004336348-1177238915-682003330-1001"
monkeypatch.setattr(native.subprocess, "check_output", lambda *a, **k: b"nothing")
with pytest.raises(OSError):
native.current_sid()
# ------------------------------------------------------------------------------------------- failures and modes
def test_failure_keeps_openssh_messages_and_key_lines():
exc = native.failure("OpenSSH SFTP", 255, REFUSED)
assert isinstance(exc, native.OpenSSHAuthFailed)
text = str(exc)
assert "UNPROTECTED PRIVATE KEY FILE" in text and "Offering public key" in text
assert "identity file" in text and "Permission denied (publickey,password)" in text
assert "kex: algorithm" not in text and "Reading configuration" not in text
other = native.failure("OpenSSH SFTP", 1, b"stat x: No such file or directory\n")
assert type(other) is OSError and "No such file" in str(other)
assert type(native.failure("OpenSSH tar", 255, b"ssh: connect to host x port 22: Connection timed out")) is OSError
@pytest.mark.parametrize("auth,known,modes", [("app_key", None, [False, True]), ("user_key", None, [False, True]),
("password", None, [False, True]), ("agent", None, [True]),
("app_key", True, [True]), ("agent", False, [False])])
def test_login_modes(auth, known, modes):
assert native.login_modes(SimpleNamespace(auth=auth, openssh_relaxed=known)) == modes
assert native.login_modes(SimpleNamespace()) == [False, True] # stand-ins without the fields
def sftp_setup(tmp_path, monkeypatch, refuse_strict=True, refuse_relaxed=False, auth="app_key"):
"""Two .part-less files; strict/relaxed logins refused as asked; returns the frame and the Popen calls."""
paths = [tmp_path / "main.obb", tmp_path / "patch.obb"]
for path in paths:
path.write_bytes(b"0123456789")
items = [(path, path.name, 10) for path in paths]
files = {}
monitor = Mock()
monitor.__enter__ = Mock(return_value=monitor)
monitor.__exit__ = Mock(return_value=False)
def stat(path):
if path not in files:
raise FileNotFoundError(path)
return SimpleNamespace(st_size=files[path])
monitor.stat.side_effect = stat
frame = SimpleNamespace(sftp=monitor, client=SimpleNamespace(open_sftp=lambda: monitor), auth=auth,
openssh_relaxed=None, target=C.FrameTarget("paired"), run=Mock(return_value=(0, "", "")))
monkeypatch.setattr(native.shutil, "which", lambda _: "sftp")
mode = {}
@contextlib.contextmanager
def creds(frame, port_flag="-P", relaxed=False):
mode["relaxed"] = relaxed
yield ["relaxed" if relaxed else "strict"]
monkeypatch.setattr(native, "credentials", creds)
calls = []
def popen(args, **kwargs):
relaxed = mode["relaxed"]
calls.append(relaxed)
refused = refuse_relaxed if relaxed else refuse_strict
if refused:
kwargs["stderr"].write(REFUSED)
code = 255
else:
for _, rel, size in items:
files["/incoming/" + rel + ".part"] = size
code = 0
process = Mock(returncode=code)
process.poll.return_value = code
return process
monkeypatch.setattr(native.subprocess, "Popen", popen)
return frame, items, calls
def test_refused_strict_login_retries_with_this_pcs_keys(tmp_path, monkeypatch):
frame, items, calls = sftp_setup(tmp_path, monkeypatch)
logs = []
reporter = Reporter()
reporter.log = lambda message, **k: logs.append(message)
assert native.upload(frame, items, "/incoming", reporter, 20, 0, 2) == 20
assert calls == [False, False, True, True] # both strict workers are refused, then both run relaxed
assert frame.openssh_relaxed is True
assert any("trying this PC's own SSH keys" in m and "UNPROTECTED" in m for m in logs)
assert native.login_modes(frame) == [True] # the next upload starts in the mode that worked
def test_agent_login_goes_straight_to_relaxed(tmp_path, monkeypatch):
frame, items, calls = sftp_setup(tmp_path, monkeypatch, auth="agent")
assert native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1) == 20
assert calls == [True]
def test_refused_in_both_modes_raises_the_auth_failure(tmp_path, monkeypatch):
frame, items, calls = sftp_setup(tmp_path, monkeypatch, refuse_relaxed=True)
with pytest.raises(native.OpenSSHAuthFailed, match="Permission denied"):
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1)
assert calls == [False, True] and frame.openssh_relaxed is None
assert not frame.run.called # nothing promoted
def test_tar_retries_a_refused_login(tmp_path, monkeypatch):
path = tmp_path / "small.bin"
path.write_bytes(b"abc")
frame = SimpleNamespace(target=C.FrameTarget("paired"), auth="app_key", openssh_relaxed=None)
monkeypatch.setattr(native.shutil, "which", lambda _: "ssh")
mode = {}
@contextlib.contextmanager
def creds(frame, port_flag="-P", relaxed=False):
mode["relaxed"] = relaxed
yield []
monkeypatch.setattr(native, "credentials", creds)
class Pipe:
def write(self, data):
if not mode["relaxed"]:
raise BrokenPipeError(32, "Broken pipe") # ssh already exited
return len(data)
def flush(self):
pass
def close(self):
pass
calls = []
def popen(args, **kwargs):
calls.append(mode["relaxed"])
code = 0 if mode["relaxed"] else 255
if code:
kwargs["stderr"].write(REFUSED)
process = Mock(stdin=Pipe(), returncode=code)
process.poll.return_value = code
return process
monkeypatch.setattr(native.subprocess, "Popen", popen)
native.put_tar(frame, [(path, "small.bin")], "/incoming", Reporter(), Mock())
assert calls == [False, True] and frame.openssh_relaxed is True
def test_azure_ad_sid(monkeypatch):
out = b'"azuread\\jmueller","S-1-12-1-1234567890-1234567890-1234567890-1234567890"\r\n'
monkeypatch.setattr(native.subprocess, "check_output", lambda *a, **k: out)
assert native.current_sid() == "S-1-12-1-1234567890-1234567890-1234567890-1234567890"
def test_relaxed_mode_names_this_pcs_default_keys(tmp_path, monkeypatch):
"""OpenSSH skips its default keys once any -i is given, so relaxed mode names the ones Paramiko tries."""
home = tmp_path / "home"
(home / ".ssh").mkdir(parents=True)
rsa = home / ".ssh" / "id_rsa"
from cryptography.hazmat.primitives.asymmetric import rsa as rsa_keys
rsa.write_bytes(rsa_keys.generate_private_key(65537, 2048).private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption()))
write_key(home / ".ssh" / "id_ecdsa", b"secret") # passphrase: only usable through the agent
(home / ".ssh" / "id_ed25519").write_text("garbage")
monkeypatch.setattr(native.Path, "home", lambda: home)
own = tmp_path / "own"
own.mkdir()
write_key(own / "id_ed25519")
monkeypatch.setattr(native, "ssh_dir", lambda: own)
assert native.default_key_files() == [rsa]
assert native.key_files() == [own / "id_ed25519"]
assert native.key_files(relaxed=True) == [own / "id_ed25519", rsa]
library.set_setting(C.EXTRA_KEYS_SETTING, [str(rsa)]) # a default key also named in Settings: offered once
assert native.key_files(relaxed=True) == [own / "id_ed25519", rsa]
def test_add_remove_and_status_of_settings_keys(tmp_path):
good = write_key(tmp_path / "frame key")
with pytest.raises(C.KeyFileError, match="public half"):
C.add_extra_key(tmp_path / "frame key.pub")
with pytest.raises(C.KeyFileError, match="passphrase"):
C.add_extra_key(write_key(tmp_path / "locked", b"pw"))
assert C.add_extra_key(good) == good.resolve()
C.add_extra_key(good) # once only
assert C.extra_key_paths() == [good.resolve()]
good.unlink()
[(path, problem)] = C.extra_key_status()
assert path == good.resolve() and "not found" in problem
assert C.remove_extra_key(good) is True and C.remove_extra_key(good) is False
assert C.extra_key_paths() == []
def test_cli_ssh_keys(tmp_path):
from typer.testing import CliRunner
from frameport.cli import app
good = write_key(tmp_path / "mine")
run = CliRunner().invoke(app, ["frame", "ssh-keys", "--add", str(good)])
assert run.exit_code == 0 and f"added {good.resolve()}" in run.output
run = CliRunner().invoke(app, ["frame", "ssh-keys", "--add", str(tmp_path / "nope")])
assert run.exit_code == 1 and str(good.resolve()) in run.output
run = CliRunner().invoke(app, ["frame", "ssh-keys", "--remove", str(good.resolve())])
assert run.exit_code == 0 and "no extra keys" in run.output
+50
View File
@@ -17,6 +17,27 @@ LOG_BAD = ("""
""")
def test_unreal_reservation_refused_while_loading():
"""GitHub #349: Unreal's out-of-memory handler crashes inside libUnreal.so's static constructors."""
lib = "/data/app/~~x==/com.Meta4.TFBeyondReality-y==/lib/arm64/libUnreal.so (BuildId: 57d064dc)"
crash = (
"10-16 14:41:01.814 1185 1185 F libc : Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x10"
" in tid 1185 (TFBeyondReality), pid 1185 (TFBeyondReality)\n"
"10-16 14:41:01.858 1219 1219 F DEBUG : Cause: null pointer dereference\n"
"10-16 14:41:01.955 1219 1219 F DEBUG : backtrace:\n"
f"10-16 14:41:01.955 1219 1219 F DEBUG : #00 pc 00000000018bbe3c {lib}\n"
f"10-16 14:41:01.955 1219 1219 F DEBUG : #01 pc 00000000017ceccc {lib}\n"
"10-16 14:41:01.957 1219 1219 F DEBUG : #15 pc 000000000004b048 /apex/com.android.runtime/bin/"
"linker64 (__dl__ZN6soinfo17call_constructorsEv+380)\n")
r = triage("", "EXITED", crash=crash)
ids = {f.id for f in r.findings}
assert "unreal-reserve-refused" in ids and "native-crash" not in ids
assert next(f for f in r.findings if f.id == "unreal-reserve-refused").suggest == ["frame.unreal_noreserve"]
# the same fault address in a later render-thread crash isn't this
later = crash.replace("__dl__ZN6soinfo17call_constructorsEv+380", "__pthread_start(void*)+64")
assert "unreal-reserve-refused" not in {f.id for f in triage("", "EXITED", crash=later).findings}
def test_lua_pointer_error_and_cleanup_stack_are_distinguished():
pointer = triage("PANIC: bad light userdata pointer", "EXITED")
finding = next(f for f in pointer.findings if f.id == "lua-lightuserdata-pointer")
@@ -510,3 +531,32 @@ def test_obb_and_cube_advice_describes_limits():
advice = next(f for f in cube.findings if f.id == "cube-swapchain-refused")
assert "GLES" in advice.diagnosis
assert "For a Vulkan game, the current build cannot stand in" in advice.diagnosis
# Deadly Delivery 1.0.30 (GitHub #322): renders, then its lobby's Meta entitlement check fails four times and it quits
LOG_ENTITLEMENT = """
10-15 12:45:23.964 212 240 I ActivityManager: Start proc 1214:com.FlatHeadStudio.DeadlyDelivery/u0a14 for \
pre-top-activity {com.FlatHeadStudio.DeadlyDelivery/com.unity3d.player.UnityPlayerActivity}
10-15 12:45:38.349 1214 1238 E Unity : [LobbyManager]: Entitlement check failed: .
10-15 12:45:38.349 1214 1238 E Unity : FlatHeads.<>c__DisplayClass105_0:<CheckEntitlementAndConfigureAsync>b__0(Message)
10-15 12:45:38.352 1214 1238 I Unity : [LobbyManager]: Retrying oculus authentication in 2 seconds.
10-15 12:45:43.372 1214 1286 I FrameBridge: pacing: 71.6 fps, displayTime vs predicted: avg 13.97 ms, max 83.33 ms
10-15 12:45:46.349 1214 1238 I Unity : stopping xr plugin
10-15 12:45:47.424 1214 1238 I Unity : [Photon] Disconnected from Photon (None)
10-15 12:45:48.857 1214 1214 I Process : Sending signal. PID: 1214 SIG: 9
"""
def test_meta_entitlement_quest():
r = triage(LOG_ENTITLEMENT, "EXITED", "com.FlatHeadStudio.DeadlyDelivery")
f = next(f for f in r.findings if f.id == "meta-entitlement")
assert f.severity == "error" and f.suggest == []
assert "Entitlement check failed" in f.evidence
assert "oculus-entitlement" not in {x.id for x in r.findings} # the Rift signature stays PC VR only
assert r.verdict == "fail"
assert "meta-entitlement" in {x.id for x in triage("E Unity: ovrPlatformInitialize_NotEntitled", "EXITED").findings}
# routine Platform SDK lines of games that work don't match; a game that logs it and keeps running still passes
for ok in ("I Unity : CheckEntitlementAndConfigureAsync", "I Unity : Entitlement check passed",
"I Unity : ovr_Entitlement_GetIsViewerEntitled", "I Unity : User is entitled"):
assert "meta-entitlement" not in {x.id for x in triage(ok + "\n" + LOG_OK, "RUNNING").findings}
assert triage("E Unity : Entitlement check failed\n" + LOG_OK, "RUNNING").verdict == "pass"
+91
View File
@@ -0,0 +1,91 @@
import struct
import pytest
from test_unreal_splash import context, engine
from frameport.analysis import detect, elf
from frameport.apk.workspace import ApkWorkspace
from frameport.patches.frame import unreal_noreserve as nr
SLOT = 0x123458 # mmap's GOT slot (any address: _mmap_slots is replaced below)
def adrp(rd: int, pc: int, target: int) -> int:
pages = (target >> 12) - (pc >> 12)
return 0x90000000 | (pages & 3) << 29 | ((pages >> 2) & 0x7FFFF) << 5 | rd
def bl(pc: int, target: int) -> int:
return 0x94000000 | ((target - pc) >> 2) & 0x3FFFFFF
def mmap_engine(prot=nr.MOV_W2_RW, flags=nr.MOV_W3_FLAGS):
"""An engine library whose code calls mmap(NULL, size, prot, flags, -1, 0) through a PLT-style stub."""
data, off = engine(symbol="FAndroidVirtualReserve", size=0x80, prologue=b"")
va = next(v for v, o, s in elf.load_segments(data) if o <= off < o + s) + off - next(
o for v, o, s in elf.load_segments(data) if o <= off < o + s)
stub = va + 0x40
call = [nr.MOV_X0_NULL, prot, flags, 0x12800004, 0xAA1F03E5] # mov w4, #-1; mov x5, xzr
code = call + [bl(va + 4 * len(call), stub)]
words = code + [0xD503201F] * (16 - len(code)) # nop up to the stub
words += [adrp(16, stub, SLOT), 0xF9400211 | ((SLOT & 0xFFF) // 8) << 10, 0x91000210, 0xD61F0220]
buf = bytearray(data)
struct.pack_into(f"<{len(words)}I", buf, off, *words)
return bytes(buf), off + 8 # file offset of the flags instruction
@pytest.fixture(autouse=True)
def fake_got(monkeypatch):
monkeypatch.setattr(nr, "_mmap_slots", lambda data: {SLOT})
def test_reservation_gets_map_noreserve_and_survives_rebuild(tmp_path):
data, flags_at = mmap_engine()
assert nr.mmap_sites(data) == ([flags_at], [])
patch = nr.UnrealNoReserve()
ctx = context(tmp_path, data)
with ctx.ws:
assert patch.apply(ctx)
fixed = ctx.ws.read_lib("libUE4.so")
assert fixed == data[:flags_at] + struct.pack("<I", nr.MOV_W3_NORESERVE) + data[flags_at + 4:]
assert patch.validate(ctx)[0][1]
assert not patch.apply(ctx)
ctx.ws.write(tmp_path / "out.apk")
with ApkWorkspace(tmp_path / "out.apk") as ws:
ctx.ws = ws
assert not patch.apply(ctx)
assert nr.mmap_sites(ws.read_lib("libUE4.so")) == ([], [flags_at])
@pytest.mark.parametrize("kwargs", [{"prot": 0x52800022}, {"flags": 0x52800043}]) # PROT_READ only; MAP_SHARED
def test_other_mmap_calls_are_left_alone(tmp_path, kwargs):
data, _ = mmap_engine(**kwargs)
assert nr.mmap_sites(data) == ([], [])
ctx = context(tmp_path, data)
with ctx.ws:
assert not nr.UnrealNoReserve().apply(ctx)
def test_call_to_another_function_is_left_alone(monkeypatch):
data, _ = mmap_engine()
monkeypatch.setattr(nr, "_mmap_slots", lambda data: {SLOT + 8})
assert nr.mmap_sites(data) == ([], [])
def test_binned3_detection():
assert detect.unreal_binned3(b"x\0./Runtime/Core/Private/HAL/MallocBinned3.cpp\0y")
assert detect.unreal_binned3("FMallocBinned3 Attempt to free an unrecognized pointer %p".encode("utf-16-le"))
# other UE5 builds only name Binned3's console variables
assert not detect.unreal_binned3("MallocBinned3.FlushThreadCacheMaxWaitTime".encode("utf-16-le"))
def test_suggested_only_for_binned3_unreal():
patch = nr.UnrealNoReserve()
class A:
engine, abis, extra = "Unreal", ["arm64-v8a"], {"unreal_binned3": True}
assert patch.detect(A()).recommended
A.extra = {}
assert patch.detect(A()) is None
A.engine = "Unity"
assert not patch.applies(A())