mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-16 18:01:23 +02:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2103d9fb5 | ||
|
|
35eb12df08 | ||
|
|
570a98b91a | ||
|
|
f2f92cce70 | ||
|
|
dd74c17212 | ||
|
|
5647e266bd | ||
|
|
a5a86e591d | ||
|
|
7cd78cfa26 | ||
|
|
e04edf3600 | ||
|
|
c98aad5056 | ||
|
|
ae228ea4ea | ||
|
|
0599e55831 | ||
|
|
2e83970ad5 | ||
|
|
33a778c195 | ||
|
|
b9a1fe67bc |
No files matched your search
@@ -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/)
|
||||
|
||||
@@ -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
|
||||
@@ -1050,6 +1057,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 +1114,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`).
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
+19
-2
@@ -14,10 +14,12 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Asgard's Wrath 2 | Quest | ✅ Works | |
|
||||
| Assassin's Creed Nexus | Quest | ✅ Works | |
|
||||
| Audica | Quest | ✅ Works | |
|
||||
| AugmentedEmpire | Quest | ✅ Works | |
|
||||
| BAM | Quest | ✅ Works | |
|
||||
| BARTENDER VR SIMULATOR | Quest | ✅ Works | |
|
||||
| Batman: Arkham Shadow | Quest | ✅ Works | |
|
||||
| BattleGlide | Quest | ✅ Works | |
|
||||
| Battlemarked | Quest | ✅ Works | |
|
||||
| BattleSisters | Quest | ✅ Works | |
|
||||
| Beat Saber | Quest | ✅ Works | |
|
||||
| Beat Saber | Quest | ✅ Works | |
|
||||
@@ -37,6 +39,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| 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 +49,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,6 +66,7 @@ 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 | |
|
||||
@@ -68,18 +74,23 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Lambda1VR | Quest | ✅ Works | |
|
||||
| LEGO® Bricktales | Quest | ✅ Works | |
|
||||
| Lucky's Tale | Quest | ✅ Works | |
|
||||
| Maestro | Quest | ✅ Works | |
|
||||
| Marvel's Deadpool VR | Quest | ✅ Works | |
|
||||
| Marvel's Iron Man VR | Quest | ✅ Works | |
|
||||
| Max Mustard | Quest | ✅ Works | |
|
||||
| Medieval Dynasty New Settlement | Quest | ✅ Works | |
|
||||
| Metro Awakening | Quest | ✅ Works | |
|
||||
| Mini Motor Racing X | Quest | ✅ Works | |
|
||||
| Mobile Suit Gundam: Silver Phantom | Quest | ✅ Works | |
|
||||
| Moss | Quest | ✅ Works | |
|
||||
| Moss: Book II | Quest | ✅ Works | |
|
||||
| Munkr Funkr (The Legacy Days) | Quest | ✅ Works | |
|
||||
| Nano | Quest | ✅ Works | |
|
||||
| NEX Player | Quest | ✅ Works | |
|
||||
| NFL PRO ERA II | Quest | ✅ Works | |
|
||||
| NOPE CHALLENGE | Quest | ✅ Works | |
|
||||
| Ocean Rift | Quest | ✅ Works | |
|
||||
| Operation Wolf Returns: First Mission VR | Quest | ✅ Works | |
|
||||
| palazzo_santacruz | Quest | ✅ Works | |
|
||||
| Path of the Warrior | Quest | ✅ Works | |
|
||||
| Pistol Whip | Quest | ✅ Works | |
|
||||
@@ -97,9 +108,11 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Riven | Quest | ✅ Works | |
|
||||
| Robo Recall | Quest | ✅ Works | |
|
||||
| RUINSMAGUS | Quest | ✅ Works | |
|
||||
| Shadow Point | Quest | ✅ Works | |
|
||||
| Shoot The Zombirds VR | Quest | ✅ Works | |
|
||||
| Shores of Loci | Quest | ✅ Works | |
|
||||
| SilentSlayer | Quest | ✅ Works | |
|
||||
| Smash Drums | Quest | ✅ Works | |
|
||||
| Sniper Elite VR | Quest | ✅ Works | |
|
||||
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
|
||||
| Space Pirate Trainer Quest | Quest | ✅ Works | |
|
||||
@@ -112,6 +125,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| SUPERHOT VR | Quest | ✅ Works | |
|
||||
| SynthRiders | Quest | ✅ Works | |
|
||||
| Taskmaster | Quest | ✅ Works | |
|
||||
| Tea For God | Quest | ✅ Works | |
|
||||
| TetrisEffect | Quest | ✅ Works | |
|
||||
| The Boys VR | Quest | ✅ Works | |
|
||||
| The Climb 2 | Quest | ✅ Works | |
|
||||
@@ -128,6 +142,7 @@ 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 | |
|
||||
| VR HOT Quest | Quest | ✅ Works | |
|
||||
| VR4 | Quest | ✅ Works | |
|
||||
| Walkabout Mini Golf | Quest | ✅ Works | |
|
||||
@@ -144,12 +159,15 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| ExploreVR | Quest | ⚠️ Works with issues | The 3D world warps and shifts with head movement (wrong perspective). |
|
||||
| Fisherman | Quest | ⚠️ Works with issues | Loading screens have corruption, but everything works otherwise. |
|
||||
| Freedom | Quest | ⚠️ Works with issues | Volume can't be changed in the game. |
|
||||
| Homeworld: Vast Reaches | Quest | ⚠️ Works with issues | The game starts but after selecting to start a new game it seems to crash. |
|
||||
| MacacoVR | Quest | ⚠️ Works with issues | 2d loading screens are glitchy, otherwise main gameplay is fine |
|
||||
| Majora's Mask VR | Quest | ⚠️ Works with issues | Audio on the file select screen sounds crunchy. |
|
||||
| Myst | Quest | ⚠️ Works with issues | Minor graphical glitches on some objects. |
|
||||
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
|
||||
| Pinball FX VR | Quest | ⚠️ Works with issues | Plays; mixed reality mode not working yet. |
|
||||
| Plezy | Quest | ⚠️ Works with issues | Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding. |
|
||||
| Quantum Void | Quest | ⚠️ Works with issues | no hands |
|
||||
| Ragnarock | Quest | ⚠️ Works with issues | |
|
||||
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
|
||||
| Spunky Dungeon | Quest | ⚠️ Works with issues | Gear VR / Oculus Go game (32-bit only), runs through FramePort's 32-bit bridge (experimental). |
|
||||
| Swarm 2 | Quest | ⚠️ Works with issues | MSAA creates visible seams around edge geometry and also the skybox edge |
|
||||
@@ -158,9 +176,8 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| 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. |
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
+5
-4
@@ -82,7 +82,7 @@ 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 | 102 |
|
||||
| [Unity: share thread keys (games that run out of them)](#frame-unity_tls_key_pool) | `frame.unity_tls_key_pool` | Steam Frame patches | 1 |
|
||||
| [Unity: headset always counts as worn](#frame-unity_user_presence) | `frame.unity_user_presence` | Steam Frame patches | 1 |
|
||||
| [GL shim for Unreal (no multisampled render-to-texture)](#frame-unreal_gl_shim) | `frame.unreal_gl_shim` | Steam Frame patches | 1 |
|
||||
@@ -175,7 +175,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [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 | 105 |
|
||||
| [Launch options (SteamVR or OpenXR)](#pcvr-launch_args) | `pcvr.launch_args` | PC VR (Proton and Revive) | 2 |
|
||||
| [Provide the Oculus runtime from Revive (Frame)](#pcvr-libovr_redirect) | `pcvr.libovr_redirect` | PC VR (Proton and Revive) | |
|
||||
| [No Unreal crash reporter](#pcvr-no_crash_reporter) | `pcvr.no_crash_reporter` | PC VR (Proton and Revive) | |
|
||||
@@ -989,7 +989,7 @@ Lets you type into this app's text fields on the Frame (they'd close at once).
|
||||
- `frame.unity_text_input` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity app with text fields (…): they close at once on the Frame because it has no system keyboard.
|
||||
- **Catalog games (85):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, AugmentedEmpire, Battlemarked, BattleSisters and 77 more.
|
||||
- **Catalog games (102):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Audica, AugmentedEmpire and 94 more.
|
||||
- **Source:** [patches/frame/unity_text_input.py](../src/frameport/patches/frame/unity_text_input.py)
|
||||
|
||||
> Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a frame later when there is none (Lepton has no on-screen keyboard; Meta's system keyboard does this on a Quest), so a selected text field only flashes a caret. Rewrites TouchScreenKeyboardShouldBeUsed -> false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -> true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with "Show the app window") or Type on Frame from the PC.
|
||||
@@ -1000,6 +1000,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:**
|
||||
@@ -2334,7 +2335,7 @@ Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard
|
||||
- `device.text_input_window`
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** App with text fields: key presses only reach a shown Android window.
|
||||
- **Catalog games (88):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, AugmentedEmpire, Battlemarked and 80 more.
|
||||
- **Catalog games (105):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Audica and 97 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as "Gamescope" (the Frame's compositor). Pairs with "Make Unity text fields work without a system keyboard".
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+477
-38
@@ -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__":
|
||||
|
||||
@@ -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())
|
||||
+3
-1
@@ -150,7 +150,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 +279,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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -664,6 +664,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": "",
|
||||
@@ -941,6 +942,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": "",
|
||||
@@ -1173,6 +1176,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": "",
|
||||
@@ -1711,6 +1715,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 +1976,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}": "",
|
||||
|
||||
@@ -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).")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "
|
||||
|
||||
+123
-13
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
+58
-2
@@ -880,7 +880,7 @@ class FramePortApp:
|
||||
self.toast(msg, error=True, action=tr("Details"),
|
||||
on_action=lambda e: self.show_activity(True))
|
||||
self.refresh_view()
|
||||
if job.kind == "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 +1152,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 +1169,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 +1623,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 = []
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -510,3 +510,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"
|
||||
Reference in new issue
Block a user