mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-16 14:01:25 +02:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/)
|
||||
|
||||
@@ -1050,6 +1050,49 @@ 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).
|
||||
- **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 +1100,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'
|
||||
@@ -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'
|
||||
@@ -0,0 +1,83 @@
|
||||
# Evaluating patch heuristics
|
||||
|
||||
`scripts/eval_heuristics.py` compares `engine.suggest(analysis, use_catalog=False)` with the bundled
|
||||
`catalog/games/*.yaml` entries. It does not merge personal or remote catalog recipes into the benchmark.
|
||||
It supports the original dumps command, or a precomputed `scripts/heuristics_snapshot.py` snapshot:
|
||||
|
||||
```sh
|
||||
.venv/bin/python scripts/eval_heuristics.py '<dumps folder>' --verbose
|
||||
.venv/bin/python scripts/eval_heuristics.py --snapshot analyses.json --split all --json baseline.json
|
||||
.venv/bin/python scripts/eval_heuristics.py --snapshot analyses.json --fit-report --out fit-report.md
|
||||
```
|
||||
|
||||
Use a scratch `FRAMEPORT_HOME`, `FRAMEPORT_NO_CATALOG_UPDATE=1`, and an empty `FRAMEPORT_WINDOWS_DATA` for offline
|
||||
measurement. Snapshot mode reads no APKs or game data. Each snapshot read copies the source to a temporary file,
|
||||
then parses the copy, retrying incomplete JSON. To compare multiple splits against an actively growing snapshot,
|
||||
freeze one valid copy first and use that file for every run.
|
||||
|
||||
The default split is `dev`. `split_packages({package: analysis_engine})` sorts packages by SHA-256 within Unity,
|
||||
Unreal, and Other strata, then holds out the nearest 20% (half rounded up). Pass only packages with both an analysis
|
||||
and a catalog entry. The same input set always yields the same split regardless of iteration order; adding packages
|
||||
can move the cutoff. Very small strata can have no holdout. Unsupported entries participate in splitting.
|
||||
|
||||
## What is scored
|
||||
|
||||
Ground truth contains catalog choices and patch registry defaults, with no inherited heuristic adapter settings.
|
||||
Default expectations are determined per analysis, using each default patch's own detection in the same way as the
|
||||
engine's initial heuristic pass. Thus a conditional default that is not detected (e.g. LTW on a game without LTW)
|
||||
is not expected. Explicit catalog `overport_remove` and `frame_remove` entries remove expected defaults. An absent
|
||||
expected default is represented as `remove:<patch id>`; enabled defaults do not inflate patch scores.
|
||||
|
||||
Opt-in overport/frame/device/PC VR IDs, default removals, adapter values, device-file names, foveation parameters,
|
||||
and alternate-build patch IDs are compared. Alternate-build identities use `alt:<patch id>` so choosing one in a
|
||||
fallback is distinct from choosing it in the primary build. Device-file content is not compared. Adapter values
|
||||
must match: a wrong value contributes both an FP and an FN for `adapter.<key>`. Patch precision, recall and F1 are
|
||||
micro-averaged across games; an empty denominator is reported as zero. Exact-game accuracy also compares the
|
||||
unsupported status flag. Fields report matching games, including empty-field matches.
|
||||
|
||||
`RUNTIME_ONLY` lists choices excluded from the headline: `use_alt`, selected runtime adapter settings, Lepton
|
||||
environment variables, and config-sync settings. They have their own exact, field and patch metrics. Catalog
|
||||
entries with status `unsupported` are excluded from both headline and runtime supported-game metrics, and have
|
||||
separate metrics and per-game diffs. Converted analyses are included and marked. JSON contains all metrics per
|
||||
engine, all game signatures, split assignments, and missing/extra decisions.
|
||||
|
||||
## Dated score: catalog gaps vs. detection errors
|
||||
|
||||
The strict score treats the catalog as complete, but older entries were verified before many patches existed, and
|
||||
a catalog recipe inherits every heuristic adapter setting and device patch (except `device.text_input_window`)
|
||||
without listing them (`engine.suggest`), so the verified build had those. The dated score (`headline_dated`, printed
|
||||
under "Dated") sets aside, on both sides:
|
||||
|
||||
- patches first committed after the entry's `verified.date` (the verifier couldn't have chosen them), and
|
||||
- inherited adapter/device picks (`inherited_decisions`).
|
||||
|
||||
What remains are real disagreements: an extra on an entry verified after the patch existed, or a catalog choice
|
||||
the heuristics don't make. Gaps are counted per patch ("Catalog gaps by patch"). The dated score needs the intro
|
||||
date cache that `--fit-report` writes; without it only inherited picks are set aside.
|
||||
|
||||
Before tuning a rule to remove an "extra", check which kind it is: a gap on an old entry is a candidate for the
|
||||
catalog (fit report), not a detection error. Before adding a patch to a working entry, weigh its benefit (does it
|
||||
fix something the game hits?) against a rebuild prompt for everyone who installed it; adapter settings never need
|
||||
adding (inherited). Results 2026-10-15: strict 5/65 exact, F1 73.8%; dated 39/65, F1 88.6% (dev 90.5%, holdout
|
||||
79.5%). Remaining misses are one-game fixes found at runtime (e.g. Vader's gl_error_clear, BlazeRush's GL settings).
|
||||
|
||||
An evaluation writes JSON even when its headline has differences, then exits 1 for compatibility with the original
|
||||
script. It exits 0 when exact, or after producing a fit report.
|
||||
|
||||
## New patch fit
|
||||
|
||||
The fit report covers all catalog entries, independently of `--split`. Candidates come from the final heuristic
|
||||
recipe, preserving platform, flat-app, dependency, and 32-bit normalization rules. A candidate must also be opt-in,
|
||||
applicable and recommended by its own `detect()`. Entries already listing or explicitly removing the patch are
|
||||
excluded; a listed adapter key is excluded regardless of its value. This report proposes additions, not value edits.
|
||||
|
||||
Each candidate includes its reason, experimental/strict flags, first local git introduction date, catalog entry
|
||||
date (`verified.date`; `updated` only without one, since it also moves for unrelated edits), and whether the
|
||||
patch is newer. Unknown dates are marked unknown. Warnings
|
||||
come from the full catalog recipe plus that one candidate; they may include pre-existing conflicts. Missing-analysis
|
||||
entries appear as metadata-only rows. Intro dates, including unknown results, are cached in
|
||||
`patch-intro-dates.json` beside the snapshot (or dumps folder); subsequent runs only query uncached patch IDs.
|
||||
|
||||
`tests/test_eval_heuristics.py` uses synthetic data. The opt-in real-dump test in `tests/test_games.py` ratchets the
|
||||
supported all-split exact ratio and patch F1 against measured, rounded-down baseline constants. Machine-local
|
||||
`.eval-home/` and `.eval-results/` scratch outputs are ignored by git.
|
||||
+5
-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".
|
||||
|
||||
+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())
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user