mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-16 12:01:34 +02:00
Compare commits
23
Commits
dev
...
obb-mount-emul
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81a015e19b | ||
|
|
4b0b47d305 | ||
|
|
e9720f83d2 | ||
|
|
729f77a9e3 | ||
|
|
4824b30e94 | ||
|
|
39e1762e53 | ||
|
|
3d260dccb1 | ||
|
|
26a34ddadb | ||
|
|
939845d379 | ||
|
|
35eb12df08 | ||
|
|
570a98b91a | ||
|
|
f2f92cce70 | ||
|
|
dd74c17212 | ||
|
|
5647e266bd | ||
|
|
a5a86e591d | ||
|
|
7cd78cfa26 | ||
|
|
e04edf3600 | ||
|
|
c98aad5056 | ||
|
|
ae228ea4ea | ||
|
|
0599e55831 | ||
|
|
2e83970ad5 | ||
|
|
33a778c195 | ||
|
|
b9a1fe67bc |
No files matched your search
@@ -19,6 +19,8 @@ CLAUDE.local.md
|
||||
# outputs of `frameport report` / `parity` / ruff
|
||||
REPORT.md
|
||||
.ruff_cache/
|
||||
.eval-home/
|
||||
.eval-results/
|
||||
.claude/worktrees/
|
||||
|
||||
# project page (site/)
|
||||
|
||||
@@ -223,6 +223,13 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
store matches and links then only update `auto_title`; a locked name gets no twin suffix); `sync_title` → Frame
|
||||
agent v77 `rename` (deployment.json title, same shortcut appid: upsert matches by Exe) + art + one Steam restart, PC
|
||||
`PcReviveTarget.rename`; an unreachable Frame → `steam_name_stale` (game page "Update name on Frame", next install).
|
||||
- Newer versions (GitHub #161): game menu "Update with a newer APK…" (`pipeline.replace_apk`, outdated via
|
||||
`build.source_version`) and, for Linux apps / PC VR games, "Update with a newer version…" (`replace_linux_app` /
|
||||
`replace_rift_game` via `add_linux_app(replace=)` / `add_rift_game(replace=)`, CLI `frameport new-version`): the
|
||||
entry keeps package, name, art, tags, switches, user recipe (a confirmed exe if the new folder has it); a new
|
||||
`source_id` goes into the deployment's `recipe` dict at install (no agent change) → `C.source_replaced` = outdated
|
||||
(Frame + `pc_outdated`); such an install re-sends same-size files changed after the last install (mtime/ctime,
|
||||
`installer.changed_since`); the old Rift folder is in `replaced_dirs` (rescans skip it). Untested on the device.
|
||||
- `Recipe.as_is` = install unchanged (pre-patched libraries): `pipeline.prepare_as_is`; auto for APKs that already
|
||||
contain FrameBridge (`frame_patched`). For Rift it changes nothing (the dump is never modified; the Frame copy
|
||||
still gets launch fixes like the crash-reporter rename — `-nocrashreports` alone doesn't stop UE 4.23) — it does
|
||||
@@ -904,6 +911,13 @@ hide_space_warp / vk_hide_fdm / no Valve layers; FNAF HW (#135) "freeze" = first
|
||||
on Zink (threads busy in zink/disk cache), not a hang; VR4 (#199) reporter's patch.203 OBB truncated. Upload benchmark
|
||||
(`scripts/upload_bench.py`, #242): home Wi-Fi caps every method at ~11 MB/s; paramiko uses ~3x OpenSSH's CPU; Windows
|
||||
OpenSSH refuses FramePort's key file (ACL "bad permissions") until its permissions are tightened.
|
||||
**OpenSSH uploads (2026-10-15, #324/#242):** sftp escapes `* ? [` inside double quotes itself (and undoes it for
|
||||
put's destination), so `native_upload.quote` escapes only `\\`/`"`; Win32 sftp turns every local backslash into `/`
|
||||
(escapes too) → local paths go as `C:/…`. Logins: `Frame.auth` = the attempt that worked (app_key / user_key = a key
|
||||
file from Settings → Installing → SSH keys, library `ssh.extra_keys`, CLI `frame ssh-keys` / agent / password).
|
||||
OpenSSH strict (FramePort + Settings keys, IdentitiesOnly) unless auth was "agent"; a refused strict login retries
|
||||
relaxed (+ private copies of ~/.ssh/id_{rsa,dsa,ecdsa,ed25519}: OpenSSH skips its defaults once any -i is given, + the
|
||||
agent); `-v` key lines go into the error. FramePort doesn't add its key to the Frame for this. Windows-tested.
|
||||
**Meta user id (GitHub #182, 2026-10-11):** OVRPort's platform loader (runtimes 3.4.3-23204ea and -aa54c3f) answers
|
||||
`ovr_GetLoggedInUserID` (and two other callers) without a Meta account with a made-up id from a helper (`stp; mov x29,
|
||||
sp; bl random; …; mov/movk x9 = 0x53444835ec580000 (6e18); add x0, x8, x9; ldp; ret`), kept only in memory → a new id
|
||||
@@ -1050,6 +1064,56 @@ Installed apps find the release themselves (self-update), so the notes are what
|
||||
- **The repo is public: never commit personal data** — the Frame's IP address, the Steam user id, the owner's email,
|
||||
local home paths (native builds use `-ffile-prefix-map`). History was rewritten once to remove them.
|
||||
|
||||
## GitHub triage (issues / discussions / PRs)
|
||||
GitHub identity + credentials are maintainer-local (`CLAUDE.local.md`): check `gh api user --jq .login` before any
|
||||
GitHub write. **The owner approves before posting, labelling or closing anything.** Reply style: "I"/neutral, never
|
||||
we/our/us; never mention agent version numbers or internal tool versions; show the owner every draft in full with a
|
||||
short recap of the thread so far; read **all** comments on a thread first (a later one often has the zip or the answer).
|
||||
- **Every triage also sweeps for unanswered comments**, not just new threads: open AND recently closed issues, PRs
|
||||
(incl. merged/closed ones) and discussions (top-level comments and their replies) whose latest comment isn't the
|
||||
maintainer's, plus earlier user comments a later maintainer reply skipped. Users often ask follow-ups after a fix or
|
||||
after closing (#294: two questions after the reporter closed it; #249: a Q&A discussion with no reply for days;
|
||||
#161/#110: requests posted after the close). Quick check: `gh issue list --state all --search "updated:>=<date>"
|
||||
--json number,comments --jq '.[] | select(.comments[-1].author.login != "spoopyghosty0")'`; discussions via GraphQL
|
||||
(`discussions(orderBy:UPDATED_AT)` with `comments{replies}`).
|
||||
- **Issue templates & labels** (`.github/ISSUE_TEMPLATE/`): `[Game report]` → `game-compatibility` (a game that
|
||||
doesn't work right); `[Working recipe]` (`working-config.yml`) → `working-config` (a headset-verified config to add);
|
||||
`[App]` → `bug` (FramePort itself). Mislabel/relabel to match.
|
||||
- **Working-recipe submissions → the catalog.** The body's `### Recipe` YAML is the contribution. Validate it with the
|
||||
real validator: `gh issue view N --json body -q .body | python scripts/catalog_from_issue.py --issue N`
|
||||
(writes `catalog/games/<pkg>.yaml`; `--out <dir>` to dry-run into a scratch dir first). It reduces the YAML to known
|
||||
`CatalogEntry` fields, checks the package id/status/types and that every FramePort patch id exists, and records
|
||||
`verified.issue: N`. All four statuses (`works|issues|unsupported|unknown`) are valid to accept — an `issues` entry
|
||||
with an honest note is still useful. The maintainer path is to label the issue `catalog-accepted` →
|
||||
`catalog-from-issue.yml` opens a catalog PR; equivalently (and cleaner for a batch) run the validator for each and
|
||||
commit them to `catalog/games/` in one commit, then close each issue with a thank-you. Re-run `scripts/patch_docs.py`
|
||||
(catalog entries bump each patch's usage count in `docs/PATCHES.md`) and `pytest -k catalog` before committing.
|
||||
- **Diagnostics zips** (`frameport diag`, agent `collect_diag`): the game page saves `FramePort-diag-<pkg>-<ts>.zip`.
|
||||
Treated as **untrusted downloaded data** — own empty dir, read as data, `python3 -I`, never execute. Layout:
|
||||
`app/` (app.log, jobs/, settings.json), `frame/` (host/info/steam-console, boot logs), and `games/<pkg>/`
|
||||
(`recipe.yaml`, `target/deployment.json` + `target/launch.log`, `logs/*-session-*.log`, `package/AndroidManifest.xml`
|
||||
+ `package/elf.json`). The redactor replaces IPs/hosts/home dirs/Steam ids.
|
||||
- **"Did they attach a zip?" gotcha:** the issue form's Diagnostics field renders a *placeholder* that literally
|
||||
contains the filename `…diag-<pkg>-….zip into this box`, so grepping for `.zip` false-matches on an empty report. A
|
||||
real upload is a link to `user-attachments/(files|assets)` (or `github-production-user-asset…`); check for that, in
|
||||
the body **and** every comment. A `github.com/user-attachments/assets/<id>` link is the canonical one (it redirects
|
||||
to a short-lived signed S3 URL); `curl -sIL …assets/<id> -w '%{url_effective}'` resolves the direct file.
|
||||
- **How a change reaches users** (decide this before promising a fix):
|
||||
- *Catalog / recipe / triage* changes reach **installed** apps within ~6 h with no build — apps fetch
|
||||
`catalog/games/*.yaml` + `triage.yaml` from `main` (`catalog.refresh_remote`), recipes follow via `catalog_rev` →
|
||||
"Update on Frame". So accepting a recipe or adding a triage signature is "live" once pushed.
|
||||
- *Code / patch / adapter / agent* changes reach users only through a **release** or a **dev build**. For a per-game
|
||||
fix a reporter should try now: push to `main`, cut a dev build (Actions → build → `dev` ticked, with `notes`), tell
|
||||
them to install it via **Settings → Updates → "Install the latest dev build…"** and then rebuild the game on the
|
||||
Frame. Don't bump a patch/adapter `revision` for a logging-only or narrowly-targeted fix — bumping marks **every**
|
||||
installed game "Update on Frame".
|
||||
- **Verifying a device fix without the headset:** many runtime failures (e.g. an `xrEndFrame` layer rejection) reproduce
|
||||
in a **headless** launch test on the dev Frame, so the fix can be developed and confirmed at the frame-acceptance
|
||||
level there; only the *visual* result needs the headset (hand the built dev build to the reporter, or the owner, for
|
||||
that). `frameport settings <pkg> <key>=<value>` toggles adapter settings (e.g. `layer_debug=1`) live, no rebuild.
|
||||
To reproduce a reporter's exact build, grab their APK from its upstream source (e.g. the linked GitHub release), scan
|
||||
→ build → install → test in an isolated `FRAMEPORT_HOME` (see "Dev commands" / the Frame-busy rules).
|
||||
|
||||
## Heuristics (games not in the catalog)
|
||||
Each patch's `detect()` suggests itself from the Analysis; `applies()` says whether it can matter at all (the UI/CLI
|
||||
hide non-applicable patches; enabled ones are always shown). Rules learned from the 34 games:
|
||||
@@ -1057,9 +1121,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`).
|
||||
|
||||
+323
-14
@@ -44,7 +44,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 91
|
||||
AGENT_VERSION = 92
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -2933,12 +2933,7 @@ def cmd_prepare(args):
|
||||
incoming = os.path.join(base, "incoming")
|
||||
os.makedirs(os.path.join(incoming, "obb"), exist_ok=True)
|
||||
app = os.path.join(base, "lepton-app")
|
||||
existing = {}
|
||||
obb = os.path.join(app, "obb")
|
||||
for root, _, files in os.walk(obb):
|
||||
for name in files:
|
||||
p = os.path.join(root, name)
|
||||
existing[os.path.relpath(p, obb)] = os.path.getsize(p)
|
||||
existing = obb_manifest(os.path.join(app, "obb")) # an unpacked OBB image counts as the image (unpack_obb)
|
||||
existing.update(tree_manifest(os.path.join(incoming, "obb"))) # uploaded before an interruption
|
||||
apk = os.path.join(app, "game.apk")
|
||||
want_sha = args.get("apk_sha256")
|
||||
@@ -3937,13 +3932,7 @@ def cmd_finalize(args):
|
||||
inc_obb = os.path.join(incoming, "obb")
|
||||
expected = args.get("obb_manifest") # rel path -> size
|
||||
if expected: # check the data as it will be after the move, before replacing anything (a failure keeps the old)
|
||||
have = {}
|
||||
for top in (os.path.join(app, "obb"), inc_obb):
|
||||
for root, _, files in os.walk(top):
|
||||
for name in files:
|
||||
if not name.endswith(".part"):
|
||||
p = os.path.join(root, name)
|
||||
have[os.path.relpath(p, top)] = os.path.getsize(p)
|
||||
have = {**obb_manifest(os.path.join(app, "obb")), **obb_manifest(inc_obb)}
|
||||
bad = [k for k, v in expected.items() if have.get(k) != v]
|
||||
if bad:
|
||||
raise AgentError(f"{len(bad)} data file(s) missing or incomplete, e.g. {bad[0]}")
|
||||
@@ -3972,6 +3961,8 @@ def cmd_finalize(args):
|
||||
continue # an older interrupted upload is not part of this install
|
||||
dst = os.path.join(app, "obb", rel)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
if os.path.isdir(dst) and obb_unpacked_size(dst) is not None:
|
||||
shutil.rmtree(dst) # a new version of an unpacked OBB image (unpacked again: unpack_obb)
|
||||
os.replace(src, dst)
|
||||
moved += 1
|
||||
shutil.rmtree(incoming, ignore_errors=True)
|
||||
@@ -4432,6 +4423,324 @@ def tree_manifest(root):
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------- OBB disk images (frame.obb_mount_emul)
|
||||
# Some games mount their OBBs as FAT disk images (StorageManager.mountObb; jobb-made, e.g. GRID Legends, GitHub #308).
|
||||
# Lepton has no loop devices, so with the patch the game reads a folder with the image's name instead: each image is
|
||||
# unpacked here into `obb/<name>/` (a marker file inside keeps the image's size, so the PC's "already on the Frame"
|
||||
# check and finalize's data check still see `<name>` = that size) and the image file is removed.
|
||||
OBB_IMAGE_PATCH = "frame.obb_mount_emul"
|
||||
OBB_MARKER = ".frameport-obb-image.json"
|
||||
OBB_UNPACKING = ".fp-unpack"
|
||||
OBB_FOOTER_SIG = 0x01059983 # Android ObbFile footer signature (last 4 bytes), jobb
|
||||
OBB_SALTED = 1 # footer flag: the image is encrypted (jobb -k: PBKDF2 + Twofish), not supported
|
||||
|
||||
|
||||
class FatImage:
|
||||
"""Read-only FAT12/16/32 reader for OBB images (stdlib only; the image is untrusted: names are checked, cluster
|
||||
chains are bounded and loop-checked)."""
|
||||
|
||||
def __init__(self, path):
|
||||
self.f = open(path, "rb")
|
||||
try:
|
||||
self._parse()
|
||||
except Exception:
|
||||
self.f.close()
|
||||
raise
|
||||
|
||||
def _parse(self):
|
||||
self.f.seek(0, 2)
|
||||
size = self.f.tell()
|
||||
self.f.seek(0)
|
||||
bs = self.f.read(512)
|
||||
if len(bs) < 512 or bs[510:512] != b"\x55\xaa":
|
||||
raise ValueError("not a FAT image (no boot sector signature)")
|
||||
bps, spc, res, nfat, root_n, tot16, _media, fat16 = struct.unpack_from("<HBHBHHBH", bs, 11)
|
||||
tot32, fat32 = struct.unpack_from("<I", bs, 32)[0], struct.unpack_from("<I", bs, 36)[0]
|
||||
if bps not in (512, 1024, 2048, 4096) or spc == 0 or spc & (spc - 1) or not res or not nfat:
|
||||
raise ValueError("not a FAT image (bad BIOS parameter block)")
|
||||
fat_sectors = fat16 or fat32
|
||||
total = tot16 or tot32
|
||||
root_sectors = (root_n * 32 + bps - 1) // bps
|
||||
self.data_off = (res + nfat * fat_sectors + root_sectors) * bps
|
||||
self.cluster = bps * spc
|
||||
clusters = (total * bps - self.data_off) // self.cluster
|
||||
if fat_sectors == 0 or total * bps > size + bps or clusters <= 0:
|
||||
raise ValueError("not a FAT image (sizes don't fit the file)")
|
||||
self.bits = 12 if clusters < 4085 else 16 if clusters < 65525 else 32
|
||||
self.max_cluster = clusters + 1
|
||||
self.f.seek(res * bps)
|
||||
self.fat = self.f.read(fat_sectors * bps)
|
||||
if self.bits == 32:
|
||||
self.root = struct.unpack_from("<I", bs, 44)[0]
|
||||
self.root_region = None
|
||||
else:
|
||||
self.root = 0
|
||||
self.root_region = ((res + nfat * fat_sectors) * bps, root_sectors * bps)
|
||||
|
||||
def close(self):
|
||||
self.f.close()
|
||||
|
||||
def next_cluster(self, c):
|
||||
if self.bits == 32:
|
||||
return struct.unpack_from("<I", self.fat, 4 * c)[0] & 0x0FFFFFFF if 4 * c + 4 <= len(self.fat) else None
|
||||
if self.bits == 16:
|
||||
return struct.unpack_from("<H", self.fat, 2 * c)[0] if 2 * c + 2 <= len(self.fat) else None
|
||||
off = c + c // 2
|
||||
if off + 2 > len(self.fat):
|
||||
return None
|
||||
v = struct.unpack_from("<H", self.fat, off)[0]
|
||||
return v >> 4 if c & 1 else v & 0xFFF
|
||||
|
||||
def chain(self, first, limit):
|
||||
"""Clusters of a chain, at most `limit` (loops and out-of-range links are errors)."""
|
||||
end = {12: 0xFF8, 16: 0xFFF8, 32: 0x0FFFFFF8}[self.bits]
|
||||
out, seen, c = [], set(), first
|
||||
while 2 <= c < end and len(out) < limit:
|
||||
if c > self.max_cluster or c in seen:
|
||||
raise ValueError(f"broken cluster chain at {c}")
|
||||
seen.add(c)
|
||||
out.append(c)
|
||||
c = self.next_cluster(c)
|
||||
if c is None:
|
||||
raise ValueError("cluster chain runs past the FAT")
|
||||
return out
|
||||
|
||||
def runs(self, first, size):
|
||||
"""(image offset, length) of the contiguous pieces holding `size` bytes from cluster `first`."""
|
||||
need = (size + self.cluster - 1) // self.cluster
|
||||
clusters = self.chain(first, need) if size else []
|
||||
if len(clusters) < need:
|
||||
raise ValueError("a file is longer than its cluster chain")
|
||||
runs = []
|
||||
for c in clusters:
|
||||
off = self.data_off + (c - 2) * self.cluster
|
||||
if runs and runs[-1][0] + runs[-1][1] == off:
|
||||
runs[-1][1] += self.cluster
|
||||
else:
|
||||
runs.append([off, self.cluster])
|
||||
left, out = size, []
|
||||
for off, n in runs:
|
||||
out.append((off, min(n, left)))
|
||||
left -= min(n, left)
|
||||
return out
|
||||
|
||||
def read_dir(self, first):
|
||||
if first == 0 and self.root_region:
|
||||
self.f.seek(self.root_region[0])
|
||||
return self.f.read(self.root_region[1])
|
||||
data = bytearray()
|
||||
for off, n in self.runs(first, len(self.chain(first, 1 << 16)) * self.cluster):
|
||||
self.f.seek(off)
|
||||
data += self.f.read(n)
|
||||
return bytes(data)
|
||||
|
||||
def entries(self, first):
|
||||
"""(name, is_dir, first cluster, size) of a directory's entries (long names preferred; volume labels,
|
||||
deleted entries, '.' and '..' skipped)."""
|
||||
data, lfn = self.read_dir(first), []
|
||||
for i in range(0, len(data) - 31, 32):
|
||||
e = data[i:i + 32]
|
||||
if e[0] == 0:
|
||||
break
|
||||
if e[0] == 0xE5:
|
||||
lfn = []
|
||||
continue
|
||||
if e[11] == 0x0F:
|
||||
lfn.insert(0, e[1:11] + e[14:26] + e[28:32])
|
||||
continue
|
||||
if lfn:
|
||||
name = b"".join(lfn).decode("utf-16-le", "replace").split("\0")[0]
|
||||
else:
|
||||
base = e[0:8].replace(b"\x05", b"\xe5", 1).decode("latin-1").rstrip()
|
||||
ext = e[8:11].decode("latin-1").rstrip()
|
||||
if e[12] & 0x08:
|
||||
base = base.lower()
|
||||
if e[12] & 0x10:
|
||||
ext = ext.lower()
|
||||
name = base + ("." + ext if ext else "")
|
||||
lfn = []
|
||||
if e[11] & 0x08 or name in (".", ".."):
|
||||
continue
|
||||
cl = struct.unpack_from("<H", e, 20)[0] << 16 | struct.unpack_from("<H", e, 26)[0]
|
||||
if self.bits != 32:
|
||||
cl &= 0xFFFF
|
||||
yield name, bool(e[11] & 0x10), cl, struct.unpack_from("<I", e, 28)[0]
|
||||
|
||||
def walk(self, first=None, prefix="", depth=0):
|
||||
"""(relative path, is_dir, first cluster, size) of every entry, parents before children."""
|
||||
if depth > 64:
|
||||
raise ValueError("directories nested too deep")
|
||||
for name, is_dir, cl, size in self.entries(self.root if first is None else first):
|
||||
if not name or any(c in name for c in "/\\\0") or name.strip(". ") == "":
|
||||
raise ValueError(f"unsafe file name in the image: {name!r}")
|
||||
rel = prefix + name
|
||||
yield rel, is_dir, cl, size
|
||||
if is_dir:
|
||||
if cl < 2 or cl == self.root:
|
||||
raise ValueError(f"directory {rel} points back at the root")
|
||||
yield from self.walk(cl, rel + "/", depth + 1)
|
||||
|
||||
|
||||
def obb_footer(path):
|
||||
"""Android's OBB footer (jobb): {package, version, flags}, or None (an image without one is unpacked too)."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
f.seek(0, 2)
|
||||
size = f.tell()
|
||||
if size < 41:
|
||||
return None
|
||||
f.seek(size - 8)
|
||||
n, sig = struct.unpack("<II", f.read(8))
|
||||
if sig != OBB_FOOTER_SIG or not 33 <= n <= min(size - 8, 4096):
|
||||
return None
|
||||
f.seek(size - 8 - n)
|
||||
ft = f.read(n)
|
||||
_ver, pkg_ver, flags = struct.unpack_from("<III", ft, 0)
|
||||
name_len = struct.unpack_from("<I", ft, 20)[0]
|
||||
return {"version": pkg_ver, "flags": flags, "package": ft[24:24 + name_len].decode("utf-8", "replace")}
|
||||
except (OSError, struct.error):
|
||||
return None
|
||||
|
||||
|
||||
def is_obb_image(path):
|
||||
"""A FAT disk image (what StorageManager.mountObb mounts), not an archive or asset file the game reads itself."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
bs = f.read(512)
|
||||
except OSError:
|
||||
return False
|
||||
return (len(bs) == 512 and bs[510:512] == b"\x55\xaa" and bs[0] in (0xEB, 0xE9)
|
||||
and (bs[54:59] in (b"FAT12", b"FAT16") or bs[82:87] == b"FAT32"))
|
||||
|
||||
|
||||
def unpack_obb_image(path, dest):
|
||||
"""Unpack the FAT image `path` into the new folder `dest`; returns (files, bytes)."""
|
||||
footer = obb_footer(path)
|
||||
if footer and footer["flags"] & OBB_SALTED:
|
||||
raise AgentError(f"{os.path.basename(path)} is an encrypted OBB image; FramePort can't unpack it")
|
||||
files = total = 0
|
||||
try:
|
||||
fs = FatImage(path)
|
||||
except ValueError as exc:
|
||||
raise AgentError(f"{os.path.basename(path)}: {exc}") from None
|
||||
try:
|
||||
os.makedirs(dest)
|
||||
real_dest = os.path.realpath(dest)
|
||||
for rel, is_dir, cl, size in fs.walk():
|
||||
target = os.path.join(dest, *rel.split("/"))
|
||||
if not os.path.realpath(target).startswith(real_dest + os.sep):
|
||||
raise AgentError(f"refusing a path outside the folder: {rel}")
|
||||
if is_dir:
|
||||
os.makedirs(target, exist_ok=True)
|
||||
continue
|
||||
with open(target, "xb") as out:
|
||||
for off, n in fs.runs(cl, size):
|
||||
fs.f.seek(off)
|
||||
while n:
|
||||
chunk = fs.f.read(min(n, 8 << 20))
|
||||
if not chunk:
|
||||
raise AgentError(f"{os.path.basename(path)} ends inside {rel}")
|
||||
out.write(chunk)
|
||||
n -= len(chunk)
|
||||
files += 1
|
||||
total += size
|
||||
except ValueError as exc:
|
||||
raise AgentError(f"{os.path.basename(path)}: {exc}") from None
|
||||
finally:
|
||||
fs.close()
|
||||
return files, total
|
||||
|
||||
|
||||
def obb_unpacked_size(folder):
|
||||
"""The image size recorded in an unpacked image's folder, else None."""
|
||||
try:
|
||||
with open(os.path.join(folder, OBB_MARKER)) as f:
|
||||
size = json.load(f).get("size")
|
||||
return size if isinstance(size, int) else None
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def obb_manifest(top):
|
||||
"""rel path -> size of the game data in an obb folder; an unpacked image's folder counts as the image file."""
|
||||
out = {}
|
||||
for root, dirs, files in os.walk(top):
|
||||
keep = []
|
||||
for d in dirs:
|
||||
p = os.path.join(root, d)
|
||||
size = obb_unpacked_size(p)
|
||||
if size is not None and not d.endswith(OBB_UNPACKING):
|
||||
out[os.path.relpath(p, top)] = size
|
||||
elif size is None and not d.endswith(OBB_UNPACKING):
|
||||
keep.append(d)
|
||||
dirs[:] = keep
|
||||
for name in files:
|
||||
if not name.endswith(".part"):
|
||||
p = os.path.join(root, name)
|
||||
out[os.path.relpath(p, top)] = os.path.getsize(p)
|
||||
return out
|
||||
|
||||
|
||||
def finish_obb_unpacks(obb):
|
||||
"""An unpack interrupted after its image was removed only needs its folder's final name; others are dropped."""
|
||||
for root, dirs, _files in os.walk(obb):
|
||||
for d in list(dirs):
|
||||
tmp = os.path.join(root, d)
|
||||
if not d.endswith(OBB_UNPACKING):
|
||||
if obb_unpacked_size(tmp) is not None:
|
||||
dirs.remove(d)
|
||||
continue
|
||||
dirs.remove(d)
|
||||
final = tmp[:-len(OBB_UNPACKING)]
|
||||
if obb_unpacked_size(tmp) is not None and not os.path.exists(final):
|
||||
os.rename(tmp, final)
|
||||
else:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def cmd_unpack_obb(args):
|
||||
"""frame.obb_mount_emul: without `image`, the game's OBB disk images still to unpack ({pending: [rel, ...]});
|
||||
with `image` (one of them), unpack it into a folder of the same name and remove the image."""
|
||||
pkg = check_pkg(args["package"])
|
||||
dep = deployment(pkg)
|
||||
if not dep:
|
||||
raise AgentError(f"{pkg} is not installed")
|
||||
obb = os.path.join(dep["base"], "lepton-app", "obb")
|
||||
finish_obb_unpacks(obb)
|
||||
pending = []
|
||||
for root, dirs, files in os.walk(obb):
|
||||
dirs[:] = [d for d in dirs if obb_unpacked_size(os.path.join(root, d)) is None]
|
||||
for name in files:
|
||||
p = os.path.join(root, name)
|
||||
if name.lower().endswith(".obb") and is_obb_image(p):
|
||||
pending.append(os.path.relpath(p, obb))
|
||||
image = args.get("image")
|
||||
if image is None:
|
||||
return {"pending": sorted(pending)}
|
||||
if image not in pending:
|
||||
raise AgentError(f"{image} is not an OBB disk image waiting to be unpacked")
|
||||
if container_running(dep["appid"]):
|
||||
raise AgentError(f"{dep.get('title', pkg)} is running on the Frame. Close it first.")
|
||||
src = os.path.join(obb, image)
|
||||
tmp = src + OBB_UNPACKING
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
size = os.path.getsize(src)
|
||||
if shutil.disk_usage(obb).free < size + (512 << 20):
|
||||
raise AgentError(f"not enough space on the Frame to unpack {image} ({size / 2**30:.1f} GiB needed)")
|
||||
started = time.time()
|
||||
try:
|
||||
files, total = unpack_obb_image(src, tmp)
|
||||
with open(os.path.join(tmp, OBB_MARKER), "w") as f:
|
||||
json.dump({"size": size, "footer": obb_footer(src), "files": files, "bytes": total,
|
||||
"time": time.time()}, f)
|
||||
except BaseException:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
raise
|
||||
os.remove(src)
|
||||
os.rename(tmp, src)
|
||||
return {"image": image, "files": files, "bytes": total, "seconds": round(time.time() - started, 1)}
|
||||
|
||||
|
||||
def pcvr_pids(base):
|
||||
"""Processes of a PC VR game (the Proton/Wine command lines contain its install folder)."""
|
||||
p = run(["pgrep", "-f", re.escape(base.rstrip("/") + "/")])
|
||||
|
||||
@@ -7,8 +7,14 @@ xr: OpenXR
|
||||
status: works
|
||||
notes: Uses Meta XR Audio via FMOD (not covered by patch_meta_xr_audio); if audio is missing, that is the likely
|
||||
cause.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 63fff470777f2753d55ad50f8355573ced8fa48bcaee0f5e6a0c10369f951a38
|
||||
updated: '2026-10-15'
|
||||
@@ -6,9 +6,15 @@ engine: Unity
|
||||
xr: VrApi
|
||||
status: issues
|
||||
notes: Right eye distorts during movement (unresolved; swap, tracking, Valve layers, depth and pacing ruled out).
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Legacy VrApi via OVRPlugin; overport swaps in an OpenXR OVRPlugin. Do not remove VrApi.
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 192ff250f5fbec38b9c4e898f0b8408edc25901f46bb4b227f7ca60bbd9f0bba
|
||||
updated: '2026-10-15'
|
||||
@@ -6,9 +6,15 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Needed the GLES/sRGB swapchain fallback.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Also available as a native PC SteamVR title.
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 158f7c4f5e8558029dc8cc5e8dcb4e67e846b261bf34e3e448fee7f657c74597
|
||||
updated: '2026-10-15'
|
||||
@@ -0,0 +1,10 @@
|
||||
package: com.FlatHeadStudio.DeadlyDelivery
|
||||
title: Deadly Delivery
|
||||
status: unsupported
|
||||
notes: "Quits after Meta's entitlement check fails: it needs Meta's online services."
|
||||
tested_version: 1.0.30
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
verified:
|
||||
date: '2026-10-15'
|
||||
issue: 322
|
||||
@@ -6,8 +6,14 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Recommended pilot title.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 2300f628ce04c1da60b52fed18335d6a31b88cc88313fd6d2c0e1a8e7785126f
|
||||
updated: '2026-10-15'
|
||||
@@ -5,9 +5,14 @@ source_hint: 'Sniper Elite VR- Winter Warrior'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: ''
|
||||
notes: frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 6b8170da5826b9a35326a20967a0e5a242d46fb569a0a3c669e1cbd368102dd3
|
||||
updated: '2026-10-15'
|
||||
@@ -6,11 +6,14 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Passthrough + Meta scene/room emulated from the Steam guardian (scene_emul=1).
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Mixed-reality-only; builds its level from the room model, which the adapter emulates from the Steam guardian.
|
||||
overport_extra:
|
||||
- patch_force_passthrough
|
||||
adapter:
|
||||
scene_emul: 1
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
@@ -18,3 +21,5 @@ verified:
|
||||
known_good_sha256: 9f900fc86fbb2663abe83c82ab79cc72bbf1f93fea54f7583fd274dd07e3d09c
|
||||
frame:
|
||||
- frame.meta_permissions
|
||||
- frame.unity_text_input
|
||||
updated: '2026-10-15'
|
||||
@@ -6,12 +6,18 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: issues
|
||||
notes: Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Hand-tracking-only game; Steam Frame has no optical hand tracking. Built with controller_fix=0 so hand
|
||||
data passes through if the runtime ever provides it.
|
||||
adapter:
|
||||
controller_fix: 0
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: a19b80db0c300d422cbba35a4924ab59211b39bcb6485e200d310e3e1372ddb7
|
||||
updated: '2026-10-15'
|
||||
@@ -7,10 +7,15 @@ xr: OpenXR
|
||||
status: works
|
||||
notes: FrameBridge turns its upside-down UI panels upright (flip emulation, in place since FramePort 1.2.0). Ubisoft
|
||||
login may not work.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Ubisoft online login may not work. Data folder holds raw assets (start0..2, .climb, bundles), not .obb
|
||||
files; install to obb as-is.
|
||||
overport_extra:
|
||||
- patch_ac_nexus_no_appsw_90
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
@@ -18,5 +23,5 @@ verified:
|
||||
known_good_sha256: 89644a12bf8df956fa3d3a9a40540ba9e1fb62f63494714a05c9f05984383ac8
|
||||
no_appsw_90: '2026-10-02, OVRPort 1.2.5 (owner: 90 Hz felt better than 72 Hz; same known issues)'
|
||||
flip_in_place: '2026-10-14, owner in the headset: launch warnings and tutorial pop-ups upright (GitHub #138)'
|
||||
updated: '2026-10-14'
|
||||
updated: '2026-10-15'
|
||||
min_app: 1.2.0
|
||||
@@ -1,5 +1,5 @@
|
||||
package: com.asg.clockworkdev
|
||||
title: Clockwork
|
||||
title: The Last Clockwinder
|
||||
status: works
|
||||
tested_version: '1.29'
|
||||
engine: Unity
|
||||
|
||||
@@ -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'
|
||||
@@ -0,0 +1,18 @@
|
||||
package: org.crisiszone.vr
|
||||
title: Crisis Zone VR
|
||||
status: works
|
||||
notes: Played the beginning of the first mission without issues.
|
||||
tested_version: 0.1.4
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.sdl_clipboard
|
||||
verified:
|
||||
date: '2026-10-15'
|
||||
known_good_sha256: 747d1e6613ae443ef03e509b509e4ec89039eec1aecf3689643db4057e53266a
|
||||
app: 1.2.1.dev433
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 91
|
||||
issue: 331
|
||||
source_hint: CrisisZoneVR-v0.1.4-quest3
|
||||
@@ -5,9 +5,14 @@ source_hint: Toy Master
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: ''
|
||||
notes: frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: e951595c67b6e448f3fb4afd07e6b83e98e589094bb3b73374cfb28aa01e96ee
|
||||
updated: '2026-10-15'
|
||||
@@ -7,6 +7,9 @@ xr: OpenXR
|
||||
status: works
|
||||
notes: Needs Oculus OS telemetry class stubs and the no-ForceQuit build (the normal build quits right after
|
||||
starting).
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-02' # owner-confirmed with the no-ForceQuit build
|
||||
overport_cli: 1.2.3
|
||||
@@ -14,6 +17,8 @@ verified:
|
||||
known_good_sha256: 31469d50b8ee292597291db7cc3bc3eca8e851f533cd92bba0d64214893580cc
|
||||
frame:
|
||||
- frame.oculusos
|
||||
- frame.unity_text_input
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
use_alt: true
|
||||
updated: '2026-10-15'
|
||||
@@ -1,9 +1,9 @@
|
||||
package: xyz.TheAviary.Vermillion
|
||||
title: Vermillion
|
||||
status: issues
|
||||
notes: Runs. The canvas used to hide the hand and brush while painting (the Frame draws the game's canvas layer in
|
||||
front of everything); the recipe makes the game draw canvases in the scene instead (fix found by Klownicle, GitHub
|
||||
#200). Not yet confirmed in the headset with this patch.
|
||||
status: works
|
||||
notes: "The canvas used to hide the hand and brush while painting (the Frame draws the game's canvas layer in front
|
||||
of everything); the recipe makes the game draw canvases in the scene instead (fix found by Klownicle, GitHub #200).
|
||||
Confirmed in the headset by Klownicle."
|
||||
tested_version: 1.7.2
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
@@ -16,10 +16,10 @@ adapter:
|
||||
haptic_fix: 1
|
||||
scene_emul: 1
|
||||
verified:
|
||||
date: '2026-10-11'
|
||||
date: '2026-10-15'
|
||||
app: 1.0.1
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 79
|
||||
issue: 200
|
||||
updated: '2026-10-12'
|
||||
updated: '2026-10-15'
|
||||
source_hint: Vermillion - VR Painting
|
||||
@@ -5,9 +5,14 @@ source_hint: H.U.N.T
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: ''
|
||||
notes: frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: c35373d0f7c870ade148e3259abcc74098b6bc89fe2c2f3a2e55168df642e391
|
||||
updated: '2026-10-15'
|
||||
@@ -11,6 +11,16 @@ signatures:
|
||||
severity: error
|
||||
diagnosis: A DLC manager reports a Meta asset-file status error after VR starts (e.g. PixelRipped 1995). The asset-file check failed; the log does not identify the native call or asset ID. Try the experimental asset-file bypass (empty list, installed status with no file path); this does not supply DLC and has not been verified in the game.
|
||||
suggest: [frame.asset_files_stub]
|
||||
- id: meta-entitlement
|
||||
# Quest games (the pcvr oculus-entitlement signature covers Rift games). Only explicit failure results: a bare
|
||||
# ovr_PlatformInitialize error isn't matched. `error`, not fatal: offline games may log it and play on; an online
|
||||
# game that then quits (e.g. Deadly Delivery, GitHub #322) fails the test through its exit.
|
||||
pattern: '(?i)entitlement check failed|\bnot entitled to (use|play)|ovrPlatformInitialize_(NotEntitled|UnableToVerify|FileInvalid|SignatureInvalid)'
|
||||
severity: error
|
||||
diagnosis: The game's Meta licence check (entitlement) failed. It needs Meta's online login and licence check,
|
||||
which the Frame doesn't have; games that depend on it (often online games) quit or stay offline. FramePort
|
||||
doesn't get around such checks.
|
||||
suggest: []
|
||||
- id: lua-lightuserdata-pointer
|
||||
pattern: 'bad light userdata pointer'
|
||||
severity: fatal
|
||||
@@ -355,6 +365,12 @@ signatures:
|
||||
diagnosis: The game's Vivox voice-chat SDK calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes (for example Green Hell VR). The patch makes Vivox's audio-route check return early (voice chat keeps the default route).
|
||||
suggest: [frame.vivox_audio_route]
|
||||
supersedes: [java-crash]
|
||||
- id: obb-mount-failed
|
||||
pattern: "Can't mount OBB \\S+ \\(state=2[015]\\)|StorageManagerService: Couldn't mount OBB"
|
||||
severity: fatal
|
||||
diagnosis: The game mounts its OBB files as disk images (Android's StorageManager.mountObb, for example GRID Legends). Lepton has no loop devices, so the mount fails and the game quits. The patch unpacks the images on the Frame after the install and makes the game read those folders instead of mounting (known OBB helpers only; others need support in FramePort).
|
||||
suggest: [frame.obb_mount_emul]
|
||||
supersedes: [java-crash]
|
||||
- id: lepton-data-link-failed
|
||||
pattern: "ln: cannot create symbolic link from '[^']*' to '/data/data/|Couldn't create directory for SharedPreferences file /data/user/0/"
|
||||
severity: fatal
|
||||
|
||||
+22
-4
@@ -14,10 +14,12 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Asgard's Wrath 2 | Quest | ✅ Works | |
|
||||
| Assassin's Creed Nexus | Quest | ✅ Works | |
|
||||
| Audica | Quest | ✅ Works | |
|
||||
| AugmentedEmpire | Quest | ✅ Works | |
|
||||
| BAM | Quest | ✅ Works | |
|
||||
| BARTENDER VR SIMULATOR | Quest | ✅ Works | |
|
||||
| Batman: Arkham Shadow | Quest | ✅ Works | |
|
||||
| BattleGlide | Quest | ✅ Works | |
|
||||
| Battlemarked | Quest | ✅ Works | |
|
||||
| BattleSisters | Quest | ✅ Works | |
|
||||
| Beat Saber | Quest | ✅ Works | |
|
||||
| Beat Saber | Quest | ✅ Works | |
|
||||
@@ -29,14 +31,15 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| BONELAB | Quest | ✅ Works | |
|
||||
| Carve Snowboarding | Quest | ✅ Works | |
|
||||
| Caves | Quest | ✅ Works | |
|
||||
| Clockwork | Quest | ✅ Works | |
|
||||
| COMPOUND | Quest | ✅ Works | |
|
||||
| Cook-Out | Quest | ✅ Works | |
|
||||
| Cosmodread | Quest | ✅ Works | |
|
||||
| Creed | Quest | ✅ Works | |
|
||||
| Crisis Zone VR | Quest | ✅ Works | |
|
||||
| cubism | Quest | ✅ Works | |
|
||||
| Dance Central | Quest | ✅ Works | |
|
||||
| Deep Cuts | Quest | ✅ Works | |
|
||||
| Demeo | Quest | ✅ Works | |
|
||||
| Demeter | Quest | ✅ Works | |
|
||||
| Dinosaur Island | Quest | ✅ Works | |
|
||||
| Doom3Quest | Quest | ✅ Works | |
|
||||
@@ -46,10 +49,12 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Eleven Table Tennis | Quest | ✅ Works | |
|
||||
| Eleven: Table Tennis VR | PC VR | ✅ Works | |
|
||||
| Espire 2 | Quest | ✅ Works | |
|
||||
| Eye of the Temple | Quest | ✅ Works | |
|
||||
| FrenzyVR | Quest | ✅ Works | |
|
||||
| Fruit Ninja | Quest | ✅ Works | |
|
||||
| Fruit Salon | Quest | ✅ Works | |
|
||||
| Genotype | Quest | ✅ Works | |
|
||||
| Ghost Town | Quest | ✅ Works | |
|
||||
| Ghostbusters: Rise of the Ghost Lord | Quest | ✅ Works | |
|
||||
| GORN2 | Quest | ✅ Works | |
|
||||
| Gravity Lab | Quest | ✅ Works | |
|
||||
@@ -61,6 +66,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| In Death: Unchained | Quest | ✅ Works | |
|
||||
| Into The Radius 2 | Quest | ✅ Works | |
|
||||
| Job Simulator | Quest | ✅ Works | |
|
||||
| Journey of the Gods | Quest | ✅ Works | |
|
||||
| Jurassic World Aftermath Collection | Quest | ✅ Works | |
|
||||
| Keep Talking and Nobody Explodes | Quest | ✅ Works | |
|
||||
| Kill It With Fire VR | Quest | ✅ Works | |
|
||||
@@ -68,18 +74,23 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Lambda1VR | Quest | ✅ Works | |
|
||||
| LEGO® Bricktales | Quest | ✅ Works | |
|
||||
| Lucky's Tale | Quest | ✅ Works | |
|
||||
| Maestro | Quest | ✅ Works | |
|
||||
| Marvel's Deadpool VR | Quest | ✅ Works | |
|
||||
| Marvel's Iron Man VR | Quest | ✅ Works | |
|
||||
| Max Mustard | Quest | ✅ Works | |
|
||||
| Medieval Dynasty New Settlement | Quest | ✅ Works | |
|
||||
| Metro Awakening | Quest | ✅ Works | |
|
||||
| Mini Motor Racing X | Quest | ✅ Works | |
|
||||
| Mobile Suit Gundam: Silver Phantom | Quest | ✅ Works | |
|
||||
| Moss | Quest | ✅ Works | |
|
||||
| Moss: Book II | Quest | ✅ Works | |
|
||||
| Munkr Funkr (The Legacy Days) | Quest | ✅ Works | |
|
||||
| Nano | Quest | ✅ Works | |
|
||||
| NEX Player | Quest | ✅ Works | |
|
||||
| NFL PRO ERA II | Quest | ✅ Works | |
|
||||
| NOPE CHALLENGE | Quest | ✅ Works | |
|
||||
| Ocean Rift | Quest | ✅ Works | |
|
||||
| Operation Wolf Returns: First Mission VR | Quest | ✅ Works | |
|
||||
| palazzo_santacruz | Quest | ✅ Works | |
|
||||
| Path of the Warrior | Quest | ✅ Works | |
|
||||
| Pistol Whip | Quest | ✅ Works | |
|
||||
@@ -97,9 +108,11 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Riven | Quest | ✅ Works | |
|
||||
| Robo Recall | Quest | ✅ Works | |
|
||||
| RUINSMAGUS | Quest | ✅ Works | |
|
||||
| Shadow Point | Quest | ✅ Works | |
|
||||
| Shoot The Zombirds VR | Quest | ✅ Works | |
|
||||
| Shores of Loci | Quest | ✅ Works | |
|
||||
| SilentSlayer | Quest | ✅ Works | |
|
||||
| Smash Drums | Quest | ✅ Works | |
|
||||
| Sniper Elite VR | Quest | ✅ Works | |
|
||||
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
|
||||
| Space Pirate Trainer Quest | Quest | ✅ Works | |
|
||||
@@ -112,9 +125,11 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| SUPERHOT VR | Quest | ✅ Works | |
|
||||
| SynthRiders | Quest | ✅ Works | |
|
||||
| Taskmaster | Quest | ✅ Works | |
|
||||
| Tea For God | Quest | ✅ Works | |
|
||||
| TetrisEffect | Quest | ✅ Works | |
|
||||
| The Boys VR | Quest | ✅ Works | |
|
||||
| The Climb 2 | Quest | ✅ Works | |
|
||||
| The Last Clockwinder | Quest | ✅ Works | |
|
||||
| The Room VR | Quest | ✅ Works | |
|
||||
| The Tale of Onogoro | Quest | ✅ Works | |
|
||||
| Time Crisis II VR (Test) | Quest | ✅ Works | |
|
||||
@@ -128,6 +143,8 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Vader Immortal: Episode II | Quest | ✅ Works | |
|
||||
| Vader Immortal: Episode III | Quest | ✅ Works | |
|
||||
| Vampire Survivors | Quest | ✅ Works | |
|
||||
| Vendetta Forever | Quest | ✅ Works | |
|
||||
| Vermillion | Quest | ✅ Works | |
|
||||
| VR HOT Quest | Quest | ✅ Works | |
|
||||
| VR4 | Quest | ✅ Works | |
|
||||
| Walkabout Mini Golf | Quest | ✅ Works | |
|
||||
@@ -144,23 +161,24 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| ExploreVR | Quest | ⚠️ Works with issues | The 3D world warps and shifts with head movement (wrong perspective). |
|
||||
| Fisherman | Quest | ⚠️ Works with issues | Loading screens have corruption, but everything works otherwise. |
|
||||
| Freedom | Quest | ⚠️ Works with issues | Volume can't be changed in the game. |
|
||||
| Homeworld: Vast Reaches | Quest | ⚠️ Works with issues | The game starts but after selecting to start a new game it seems to crash. |
|
||||
| MacacoVR | Quest | ⚠️ Works with issues | 2d loading screens are glitchy, otherwise main gameplay is fine |
|
||||
| Majora's Mask VR | Quest | ⚠️ Works with issues | Audio on the file select screen sounds crunchy. |
|
||||
| Myst | Quest | ⚠️ Works with issues | Minor graphical glitches on some objects. |
|
||||
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
|
||||
| Pinball FX VR | Quest | ⚠️ Works with issues | Plays; mixed reality mode not working yet. |
|
||||
| Plezy | Quest | ⚠️ Works with issues | Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding. |
|
||||
| Quantum Void | Quest | ⚠️ Works with issues | no hands |
|
||||
| Ragnarock | Quest | ⚠️ Works with issues | |
|
||||
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
|
||||
| Spunky Dungeon | Quest | ⚠️ Works with issues | Gear VR / Oculus Go game (32-bit only), runs through FramePort's 32-bit bridge (experimental). |
|
||||
| Swarm 2 | Quest | ⚠️ Works with issues | MSAA creates visible seams around edge geometry and also the skybox edge |
|
||||
| The Light Brigade | Quest | ⚠️ Works with issues | Judder in weapons |
|
||||
| The Thrill Of The Fight | Quest | ⚠️ Works with issues | Right eye doesn't draw the opponent or the referee. |
|
||||
| Time Stall | Quest | ⚠️ Works with issues | Both eyes distort during movement (unresolved). |
|
||||
| Vermillion | Quest | ⚠️ Works with issues | Runs. |
|
||||
| WiiCompiled VR | Quest | ⚠️ Works with issues | To add a game: in FramePort's Files tab, upload your .wcgame file to this game's storage, folder Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR… |
|
||||
| Deadly Delivery | Quest | ❌ Doesn't run | Quits after Meta's entitlement check fails: it needs Meta's online services. |
|
||||
| Espire 1: VR Operative (Quest Edition) | Quest | ❌ Doesn't run | Mesa GL driver crash during texture upload. |
|
||||
| GOLF+ | Quest | ❌ Doesn't run | Black screen. |
|
||||
| HITMAN 3 VR: Reloaded | Quest | ❌ Doesn't run | Vulkan driver crash (freedreno), even without Valve layers. |
|
||||
| Journey of the Gods | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
|
||||
| Roblox | Quest | ❌ Doesn't run | Crashes on its first VR frame on the Frame. |
|
||||
| Shadow Point | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
|
||||
@@ -0,0 +1,83 @@
|
||||
# Evaluating patch heuristics
|
||||
|
||||
`scripts/eval_heuristics.py` compares `engine.suggest(analysis, use_catalog=False)` with the bundled
|
||||
`catalog/games/*.yaml` entries. It does not merge personal or remote catalog recipes into the benchmark.
|
||||
It supports the original dumps command, or a precomputed `scripts/heuristics_snapshot.py` snapshot:
|
||||
|
||||
```sh
|
||||
.venv/bin/python scripts/eval_heuristics.py '<dumps folder>' --verbose
|
||||
.venv/bin/python scripts/eval_heuristics.py --snapshot analyses.json --split all --json baseline.json
|
||||
.venv/bin/python scripts/eval_heuristics.py --snapshot analyses.json --fit-report --out fit-report.md
|
||||
```
|
||||
|
||||
Use a scratch `FRAMEPORT_HOME`, `FRAMEPORT_NO_CATALOG_UPDATE=1`, and an empty `FRAMEPORT_WINDOWS_DATA` for offline
|
||||
measurement. Snapshot mode reads no APKs or game data. Each snapshot read copies the source to a temporary file,
|
||||
then parses the copy, retrying incomplete JSON. To compare multiple splits against an actively growing snapshot,
|
||||
freeze one valid copy first and use that file for every run.
|
||||
|
||||
The default split is `dev`. `split_packages({package: analysis_engine})` sorts packages by SHA-256 within Unity,
|
||||
Unreal, and Other strata, then holds out the nearest 20% (half rounded up). Pass only packages with both an analysis
|
||||
and a catalog entry. The same input set always yields the same split regardless of iteration order; adding packages
|
||||
can move the cutoff. Very small strata can have no holdout. Unsupported entries participate in splitting.
|
||||
|
||||
## What is scored
|
||||
|
||||
Ground truth contains catalog choices and patch registry defaults, with no inherited heuristic adapter settings.
|
||||
Default expectations are determined per analysis, using each default patch's own detection in the same way as the
|
||||
engine's initial heuristic pass. Thus a conditional default that is not detected (e.g. LTW on a game without LTW)
|
||||
is not expected. Explicit catalog `overport_remove` and `frame_remove` entries remove expected defaults. An absent
|
||||
expected default is represented as `remove:<patch id>`; enabled defaults do not inflate patch scores.
|
||||
|
||||
Opt-in overport/frame/device/PC VR IDs, default removals, adapter values, device-file names, foveation parameters,
|
||||
and alternate-build patch IDs are compared. Alternate-build identities use `alt:<patch id>` so choosing one in a
|
||||
fallback is distinct from choosing it in the primary build. Device-file content is not compared. Adapter values
|
||||
must match: a wrong value contributes both an FP and an FN for `adapter.<key>`. Patch precision, recall and F1 are
|
||||
micro-averaged across games; an empty denominator is reported as zero. Exact-game accuracy also compares the
|
||||
unsupported status flag. Fields report matching games, including empty-field matches.
|
||||
|
||||
`RUNTIME_ONLY` lists choices excluded from the headline: `use_alt`, selected runtime adapter settings, Lepton
|
||||
environment variables, and config-sync settings. They have their own exact, field and patch metrics. Catalog
|
||||
entries with status `unsupported` are excluded from both headline and runtime supported-game metrics, and have
|
||||
separate metrics and per-game diffs. Converted analyses are included and marked. JSON contains all metrics per
|
||||
engine, all game signatures, split assignments, and missing/extra decisions.
|
||||
|
||||
## Dated score: catalog gaps vs. detection errors
|
||||
|
||||
The strict score treats the catalog as complete, but older entries were verified before many patches existed, and
|
||||
a catalog recipe inherits every heuristic adapter setting and device patch (except `device.text_input_window`)
|
||||
without listing them (`engine.suggest`), so the verified build had those. The dated score (`headline_dated`, printed
|
||||
under "Dated") sets aside, on both sides:
|
||||
|
||||
- patches first committed after the entry's `verified.date` (the verifier couldn't have chosen them), and
|
||||
- inherited adapter/device picks (`inherited_decisions`).
|
||||
|
||||
What remains are real disagreements: an extra on an entry verified after the patch existed, or a catalog choice
|
||||
the heuristics don't make. Gaps are counted per patch ("Catalog gaps by patch"). The dated score needs the intro
|
||||
date cache that `--fit-report` writes; without it only inherited picks are set aside.
|
||||
|
||||
Before tuning a rule to remove an "extra", check which kind it is: a gap on an old entry is a candidate for the
|
||||
catalog (fit report), not a detection error. Before adding a patch to a working entry, weigh its benefit (does it
|
||||
fix something the game hits?) against a rebuild prompt for everyone who installed it; adapter settings never need
|
||||
adding (inherited). Results 2026-10-15: strict 5/65 exact, F1 73.8%; dated 39/65, F1 88.6% (dev 90.5%, holdout
|
||||
79.5%). Remaining misses are one-game fixes found at runtime (e.g. Vader's gl_error_clear, BlazeRush's GL settings).
|
||||
|
||||
An evaluation writes JSON even when its headline has differences, then exits 1 for compatibility with the original
|
||||
script. It exits 0 when exact, or after producing a fit report.
|
||||
|
||||
## New patch fit
|
||||
|
||||
The fit report covers all catalog entries, independently of `--split`. Candidates come from the final heuristic
|
||||
recipe, preserving platform, flat-app, dependency, and 32-bit normalization rules. A candidate must also be opt-in,
|
||||
applicable and recommended by its own `detect()`. Entries already listing or explicitly removing the patch are
|
||||
excluded; a listed adapter key is excluded regardless of its value. This report proposes additions, not value edits.
|
||||
|
||||
Each candidate includes its reason, experimental/strict flags, first local git introduction date, catalog entry
|
||||
date (`verified.date`; `updated` only without one, since it also moves for unrelated edits), and whether the
|
||||
patch is newer. Unknown dates are marked unknown. Warnings
|
||||
come from the full catalog recipe plus that one candidate; they may include pre-existing conflicts. Missing-analysis
|
||||
entries appear as metadata-only rows. Intro dates, including unknown results, are cached in
|
||||
`patch-intro-dates.json` beside the snapshot (or dumps folder); subsequent runs only query uncached patch IDs.
|
||||
|
||||
`tests/test_eval_heuristics.py` uses synthetic data. The opt-in real-dump test in `tests/test_games.py` ratchets the
|
||||
supported all-split exact ratio and patch F1 against measured, rounded-down baseline constants. Machine-local
|
||||
`.eval-home/` and `.eval-results/` scratch outputs are ignored by git.
|
||||
@@ -167,6 +167,10 @@ FramePort downloads it, checks it, restarts and keeps your games and settings. *
|
||||
- Settings → **Updates** turns the check off or turns on **Install updates automatically**.
|
||||
- **Recipes** (the tested patches and settings for each game) update by themselves. A game whose recipe changed shows
|
||||
**Update on Frame**.
|
||||
- **A newer version of a game:** right-click it (or use **…** on its page) → **Update with a newer APK…** (Quest and
|
||||
Android games) or **Update with a newer version…** (Linux apps: the new AppImage, archive or folder; PC VR games:
|
||||
the new game folder). Its name, artwork, tags and your settings stay. Then click **Update on Frame** (or **on this
|
||||
PC**).
|
||||
- **Dev builds:** if you're asked to test a change before its release, use Settings → Updates → **Install the latest
|
||||
dev build…**. The next release then arrives as a normal update.
|
||||
|
||||
@@ -255,6 +259,7 @@ option. The main ones:
|
||||
|---|---|
|
||||
| `scan <folder>` / `list` / `show <game>` | add games, list the library, show a game's patches |
|
||||
| `add-linux <path> [--exe <program>]` | add a Linux app (AppImage, folder or archive) |
|
||||
| `new-version <game> <path>` | point a game at its newer version (APK, Linux app file/folder, PC VR game folder) |
|
||||
| `recipe <game> --enable/--disable <patch>` | change a game's patches (`patches` lists them all) |
|
||||
| `build <game>` / `install <game>` / `test <game>` | build, install on the Frame (`--to pc` for this PC), launch test |
|
||||
| `frame discover` / `frame connect` / `frame info` | find, connect to and describe the Frame |
|
||||
|
||||
+25
-10
@@ -2,7 +2,7 @@
|
||||
|
||||
# Patches
|
||||
|
||||
Every patch and game setting FramePort knows: 169 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
Every patch and game setting FramePort knows: 170 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
|
||||
Each entry says what the patch does, whether it changes the APK or only the install, when FramePort suggests it, its options, the catalog games whose recipe names it and the launch-test signatures that suggest it. Patches that are on by default are on for every game, so the catalog rarely names them.
|
||||
|
||||
@@ -11,7 +11,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
## Groups
|
||||
|
||||
- [Patches from OVRPort](#group-overport) (25)
|
||||
- [Steam Frame patches](#group-frame) (55)
|
||||
- [Steam Frame patches](#group-frame) (56)
|
||||
- [Game settings](#group-adapter) (68)
|
||||
- [Files and settings on the Frame](#group-device) (8)
|
||||
- [PC VR (Proton and Revive)](#group-pcvr) (13)
|
||||
@@ -64,6 +64,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 2 |
|
||||
| [Meta XR Audio: skip Quest telemetry class](#frame-metaxr_telemetry) | `frame.metaxr_telemetry` | Steam Frame patches | default |
|
||||
| [Clear android:debuggable](#frame-nodebug) | `frame.nodebug` | Steam Frame patches | 3 |
|
||||
| [OBB disk images: unpack instead of mounting](#frame-obb_mount_emul) | `frame.obb_mount_emul` | Steam Frame patches | |
|
||||
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 15 |
|
||||
| [Fix crash when the game prepares the microphone](#frame-ovr_microphone) | `frame.ovr_microphone` | Steam Frame patches | |
|
||||
| [Keep the same Meta user between starts](#frame-ovr_stable_user) | `frame.ovr_stable_user` | Steam Frame patches | default |
|
||||
@@ -71,7 +72,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Platform compat (ovrMessageType_ToString)](#frame-ovrplatformcompat) | `frame.ovrplatformcompat` | Steam Frame patches | default |
|
||||
| [Stub missing Meta platform functions](#frame-ovrstubs) | `frame.ovrstubs` | Steam Frame patches | default |
|
||||
| [Unpaired pointer-authentication checks](#frame-pac_hints) | `frame.pac_hints` | Steam Frame patches | 1 |
|
||||
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 4 |
|
||||
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 5 |
|
||||
| [Stress Level Zero: let Unity start Vulkan itself](#frame-slz_vulkan_hooks) | `frame.slz_vulkan_hooks` | Steam Frame patches | 1 |
|
||||
| [Start straight in VR (skip the 2D launcher)](#frame-start_activity) | `frame.start_activity` | Steam Frame patches | |
|
||||
| [Allow swapchains larger than 4096 px](#frame-swapchain_limit) | `frame.swapchain_limit` | Steam Frame patches | default |
|
||||
@@ -82,7 +83,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 +176,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) | |
|
||||
@@ -740,6 +741,19 @@ Stops strict debugging checks that make some games quit.
|
||||
|
||||
> OVRPort marks apps debuggable; that turns on CheckJNI, which aborts some Unreal games on sloppy JNI calls ('JNI DETECTED ERROR', for example 'GetStringUTFChars ... NULL' in Time Stall). Clear it for those.
|
||||
|
||||
### <a id="frame-obb_mount_emul"></a>OBB disk images: unpack instead of mounting
|
||||
|
||||
Lets games whose data files are disk images start on the Frame (they'd quit with "Can't mount OBB"), for example GRID Legends. The images are unpacked on the Frame.
|
||||
|
||||
- `frame.obb_mount_emul` · experimental · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** The game mounts its OBB files as disk images, which Lepton can't do: unpack them on the Frame and let the game read the folders.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `obb-mount-failed` (fatal): The game mounts its OBB files as disk images (Android's StorageManager.mountObb, for example GRID Legends).
|
||||
- **Source:** [patches/frame/obb_mount.py](../src/frameport/patches/frame/obb_mount.py)
|
||||
|
||||
> Some games keep their data in OBB files that Android mounts as disk images (for example GRID Legends). Lepton can't mount disk images, so these games quit at start ("Can't mount OBB"). FramePort unpacks the images on the Frame after the install (into folders with the same names, so the data takes its space only once) and changes the game's OBB code so it uses those folders instead of mounting.
|
||||
|
||||
### <a id="frame-oculusos"></a>Oculus OS telemetry class stubs
|
||||
|
||||
Provides stand-ins for Quest system calls some games make at start.
|
||||
@@ -838,7 +852,7 @@ Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboar
|
||||
- `frame.sdl_clipboard` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** SDL app: it crashes at start in Lepton, which has no clipboard service.
|
||||
- **Catalog games (4):** Majora's Mask VR, Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
|
||||
- **Catalog games (5):** Crisis Zone VR, Majora's Mask VR, Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `sdl-no-clipboard` (fatal): The app crashed at start because the Frame's Android has no clipboard.
|
||||
- **Source:** [patches/frame/sdl_clipboard.py](../src/frameport/patches/frame/sdl_clipboard.py)
|
||||
@@ -904,7 +918,7 @@ Stops a gray or frozen screen in Unity games that turn on anti-aliasing while ru
|
||||
- `frame.unity_gl_shim`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity GLES game that may switch to MSAA at runtime: hide multisampled render-to-texture, which crashes the Frame's GL driver (for example The Room VR).
|
||||
- **Catalog games (23):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 15 more.
|
||||
- **Catalog games (23):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 15 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unreal-msrtt-crash` (fatal): The game's graphics crashed a few seconds after start.
|
||||
- **Source:** [patches/frame/vrapi_bridge.py](../src/frameport/patches/frame/vrapi_bridge.py)
|
||||
@@ -973,7 +987,7 @@ Keeps a smoothing setting off that the game turns on itself and that can freeze
|
||||
- `frame.unity_runtime_msaa_off` · revision 2
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled render-to-texture can hang the Frame's GPU.
|
||||
- **Catalog games (28):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 20 more.
|
||||
- **Catalog games (28):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 20 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unity-render-crash` (fatal): The game's graphics crashed, so the picture freezes or stays gray.
|
||||
- `unity-runtime-msaa` (info): The game turns MSAA on at runtime, which can hang the Frame's GPU.
|
||||
@@ -989,7 +1003,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 +1014,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 +2349,7 @@ Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard
|
||||
- `device.text_input_window`
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** App with text fields: key presses only reach a shown Android window.
|
||||
- **Catalog games (88):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, AugmentedEmpire, Battlemarked and 80 more.
|
||||
- **Catalog games (105):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Audica and 97 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as "Gamescope" (the Frame's compositor). Pairs with "Make Unity text fields work without a system keyboard".
|
||||
|
||||
@@ -24,6 +24,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| A Unity OpenXR game closes right after the OpenXR instance is created: `F VrApiLoader: vrapi_SetPropertyInt was called before vrapi_Initialize()!`, SIGABRT on UnityMain (GitHub #62, Jurassic World Aftermath) | the APK still ships Meta's VrApi loader; OVRPlugin (1.89, on OpenXR) calls it without starting VrApi and Meta's loader aborts. The VrApi bridge can't replace it (39 of the loader's 114 functions: OVRPlugin wouldn't link) | `frame.vrapi_stub` (same exports, each returns 0); triage `vrapi-before-init` |
|
||||
| A VrApi game exits at once: `UnsatisfiedLinkError … dlopen failed: cannot locate symbol "vrapi_PollEvent"` (GitHub #57, BlazeRush) | the engine imports a VrApi function the VrApi bridge didn't implement | the bridge has `vrapi_PollEvent`, `RecenterPose`, `SetDisplayRefreshRate`, `GetSystemPropertyFloatArray` (rebuild the game); the static check "VrApi functions resolvable" names any other missing one; triage `vrapi-symbol-missing` |
|
||||
| A Team Beef port (Lambda1VR, RTCWQuest, …) exits at once: `UnsatisfiedLinkError: dlopen failed: library "libopenxr_loader_valve.so" not found` (GitHub #59) | TBXR loads `openxr_loader_<Build.MANUFACTURER>` (Lepton: valve) and takes the Meta path only when `strstr(OPENXR_HMD, "meta")` matches, else Pico's | `frame.tbxr_vendor` (empty `libopenxr_loader_valve.so` + the "meta" literal → "alve"). Game data (`xash/`, …) goes to /sdcard: upload it with the Files tab to the game's storage. Black eyes with `TBXR: Incomplete frame buffer object: GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE`: TBXR always uses multisampled render-to-texture (even `--msaa 1`); `frame.tbxr_vendor` links the GL shim, which hands it single-sampled versions (log `GL shim: multisampled render-to-texture … drawn single-sampled`) |
|
||||
| A Quest game renders, then logs `Entitlement check failed` / `ovrPlatformInitialize_NotEntitled` (often with retries) and quits, e.g. `SIG: 9` after `[Photon] Disconnected` (GitHub #322, Deadly Delivery) | The game's Meta licence check (entitlement) needs Meta's online login, which the Frame doesn't have; online-only games quit (triage `meta-entitlement`) | None: FramePort doesn't get around entitlement checks. Offline games that log it and play on are fine |
|
||||
| Black screen after VR starts; `Asset status error (code 100)` in `DLC_Manager:AssetStatusCallback` (GitHub #203, e.g. PixelRipped 1995) | Meta asset-file status fails after entitlement succeeds; the log does not identify the native call or asset ID | Try `frame.asset_files_stub` (opt-in, triage-only): native/langpack answers GetList with an empty successful list and StatusById with installed status, an empty path and the requested ID. No DLC files are supplied; download requests retain their existing behavior. Disable with `FRAMEPORT_ASSET_FILES_STUB=0`. Host-tested only; whether this fixes startup needs a game/device test |
|
||||
| Missing sound or extra content in a game whose OBB folder has separate `*.pak` files (GitHub #61, Star Wars: Tales from the Galaxy's Edge: seasons + Wwise banks) | the game asks Meta's platform for these asset files (`ovr_AssetFile_GetList` / `DownloadById`); OVRPort's loader doesn't list them | copy every content/sound-bank PAK and use `frame.asset_files` (native/langpack lists the data's `*.pak` as installed `default` assets, IAP `free`, and answers DownloadById at once with the path; off: env FRAMEPORT_ASSET_FILES=0). Tales also needs the splash and depth-size fixes below |
|
||||
| A SideQuest backup has `apk/` and a sibling `obb/` with content/sound-bank PAKs, but the Frame only has the main OBB (GitHub #61, Tales from the Galaxy's Edge) | older scans treated that layout as loose expansion files and selected only the named OBBs; the asset-file patch then found no PAKs | the scanner chooses the OBB folder by version first, then keeps the whole sibling data folder when all its expansion files are selected for this APK; app updates repair affected library entries and refresh asset/language-pack detection. Reinstall to copy the missing files, or scan the backup again first. Folders with other packages or unselected OBB versions still send only the selected OBBs |
|
||||
@@ -62,6 +63,8 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Un-cracked Oculus PC VR game: "Initializing OVR session" then exits / signature check on the runtime | Revive's hooks don't install on Proton-arm64; the Oculus shim rejects the unsigned Revive runtime | Not runnable on the Frame without the repack's crack extracted (not done by FramePort); use the PC version |
|
||||
| PC VR game: `Failed to initialize Oculus API (-3001)` / `Unable to load LibOVRRT DLL` | Revive's LoadLibrary hook doesn't work under Proton arm64 (ARM64EC kernelbase); the game's LibOVR shim finds no runtime DLL | Symlink `LibOVRRT64_1.dll` → Revive's DLL in the prefix's system32 (manual so far) → then -3021 (runtime signature check; open) |
|
||||
| Uploads to the Frame are slow | The link, storage or transfer backend can limit throughput | Check the job's "Transfer link"; try the Frame's hotspot or USB cable. Large install files prefer system OpenSSH SFTP, retaining paired-host checks and `.part` resume; unavailable or failed native uploads fall back to Paramiko. The log names the backend. Settings → Installing → Parallel uploads accepts 1–8 (default 3; saved preferences are preserved). More is not always faster. Measure the production path with `scripts/upload_bench.py --methods app-files-3 --check-resume` (three repetitions, checksum verification) |
|
||||
| Log: `OpenSSH SFTP failed (255): … Permission denied (publickey,password)`, then Paramiko | OpenSSH offered keys the Frame doesn't authorize (GitHub #242) | The log lists the keys OpenSSH offered (`Offering public key`) and its warnings. A connection that logged in with the PC's own keys/agent makes OpenSSH use them too (copies of `~/.ssh/id_*` + the agent); a refused FramePort key is retried that way once. A Frame that accepts only another key file: Settings → Installing → SSH keys (CLI `frameport frame ssh-keys --add <file>`); passphrase keys go through the SSH agent |
|
||||
| Log: `OpenSSH SFTP failed (1): stat C://…/[…: No such file or directory` | sftp got glob escapes inside its quotes (GitHub #324, fixed): `[` in a local folder name | Update FramePort; the native_upload `quote()` passes only `\\` and `\"` escapes, Windows paths with `/` |
|
||||
| PC VR game exits right away: `Unhandled Exception: 0xc06d007e` (after `FOnlineSubsystemOculus::InitWithWindowsPlatform`) | Delay-loaded `LibOVRPlatform64_1.dll` (Oculus Platform SDK, installed with the Oculus app) is missing | None on the Frame (entitlement check; FramePort doesn't replace it). PC mode with the Oculus app |
|
||||
| Unity PC VR game (Oculus + SteamVR build: `OVRPlugin.dll` and `openvr_api.dll` in `<Name>_Data/Plugins`) opens a window and quits after ~20 s; no VR Vulkan instance after the game's DXVK device in launch.log; Unity log (`unity log … Player.log`, agent v67): `OpenVR failed initialization` / `Initialization of device … failed` | Unity's built-in VR tries its SDKs in list order (Oculus first) unless `-vrmode` picks one; a catalog recipe verified with another build of the game (for example SUPERHOT VR's OpenXR build, GitHub #105) used to drop the build's own arguments | `pcvr.launch_args` = `-vrmode OpenVR` (detected for such builds; kept when the catalog's `xr` differs from the build's). A game's Electron launcher next to it (SHVR.exe) is ranked below the game |
|
||||
| Unreal PC VR game opens the crash reporter instead of closing | UE starts CrashReportClient.exe on a crash | `pcvr.no_crash_reporter` (default for Unreal Rift games): `-nocrashreports` + CrashReportClient.exe renamed `.disabled` in the Frame copy |
|
||||
@@ -133,6 +136,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| SDL / LÖVE app crashes at start: `NullPointerException … ClipboardManager.addPrimaryClipChangedListener` in `SDLClipboardHandler.<init>` (for example Dramatic Shape) | Lepton's Android has no clipboard service (`service check clipboard`: not found) | `frame.sdl_clipboard` (that call → nops in classes.dex, in place, `apk/dex.py`); reproduced and verified with LÖVE for Android 11.5 |
|
||||
| Godot 4 app crashes at start: `java.lang.NullPointerException: null cannot be cast to non-null type android.content.ClipboardManager` at `org.godotengine.godot.Godot.<init>` (Godot 4.3/4.4; 4.2 when the clipboard is first used, in `Godot$mClipboard$2.invoke`) (for example EndoparasiticVR, GitHub #134); triage `godot-no-clipboard` | Lepton's Android has no clipboard service and Godot's Kotlin code casts it with a non-null `as ClipboardManager` (`Intrinsics.checkNotNull` before the check-cast). Godot 4.1 (Java) and 4.5+ (`as?`) don't throw | `frame.godot_clipboard` (suggested by analysis `godot_clipboard`): that checkNotNull call → nops in classes.dex (check-cast accepts null), and Godot's getClipboard/hasClipboard/setClipboard return at once ("" / false / nothing; `Dex.return_early`), in place; matched exactly, other dex files untouched. Checked against the Maven godot-lib 4.2.2/4.3.0/4.4.1 and the 4.3/4.4.1 release export templates (D8 re-reads the result); not yet run on the device |
|
||||
| Game crashes at start: `java.lang.NoSuchMethodError: No virtual method getAvailableCommunicationDevices()… in class Landroid/media/AudioManager` at `com.vivox.sdk.AudioChangeListener.checkAudioRouteAndApplyChanges` (for example Green Hell VR, GitHub #101); triage `vivox-api31` | Newer Vivox (voice chat) builds call Android 12 audio-routing methods without a version check; Lepton is Android 11. Older Vivox builds (Eleven Table Tennis, BattleSisters) don't have that code | `frame.vivox_audio_route` (suggested by analysis `vivox_api31`): every AudioChangeListener method that calls one of them returns at once (`Dex.return_early`: return-void / `const/4 v0, 0; return v0`, in place); voice chat keeps the default audio route |
|
||||
| Game quits at start: `java.lang.RuntimeException: Can't mount OBB DataSet0.obb (state=21)` from `androidutils.OBBHandler`, with `vold: Failed to open loop-control` / `Failed to open device-mapper` (GitHub #308, GRID Legends); triage `obb-mount-failed` | The game's OBBs are FAT disk images (Google's jobb tool, footer signature 0x01059983) that it mounts with `StorageManager.mountObb` and reads below `getMountedObbPath`. vold needs loop devices + device-mapper, which Lepton's container doesn't have | `frame.obb_mount_emul` (experimental; analysis `obb_mount`): the agent (`unpack_obb`, called by the installer after finalize/configure, one image per call) unpacks each image into a folder with the image's own name (`obb/DataSet0.obb/`, marker `.frameport-obb-image.json` keeps the image size, so uploads/data checks still see the image) and removes the image; the dex rewrite (`Dex.rewrite_start`) makes Codemasters' `OBBHandler.mountOBB` report MOUNTED at once and `getMountedOBBFullPath` return the OBB's own path (= that folder). Other mountObb callers are only detected (`obb_mount: ["other"]`) and need a handler entry. Encrypted (jobb -k) images are refused. GRID 1.0.0.41 then crashed in `getOBBMountPoints()+420` (SIGSEGV, fault address = path text): libgrid frees the path string from libandroid_utils with its own operator delete, which only goes unnoticed for short (in-object) strings like Quest's `/mnt/obb/…`; the patch also takes out that free in that build (`NATIVE_FIXES`, window SHA-256 checked). Headless 2026-10-16 with DataSet0 only: "OBB DataSet0.obb mounted", OpenXR instance + swapchains, then UI shutdown/crash 3 s later (the other 16 OBBs were missing) |
|
||||
| A game on a microSD card or another drive doesn't start, loses its saves or loops at start; launch.log has `ln: cannot create symbolic link from 'Frame' to '/data/data/<pkg>'` or `Couldn't create directory for SharedPreferences file /data/user/0/<pkg>/…` (GitHub #213/#214/#215/#221: a card named "Steam Frame 512g"); triage `lepton-data-link-failed` | Valve's Lepton pastes `STEAM_COMPAT_DATA_PATH` unquoted into `sh -c "ln -s …/internal/<pkg> /data/data/<pkg>"` (liblepton mounting.sh) and APK paths into unquoted substitutions, so a path with a space never gets the app's internal storage | agent v83: the Quest launcher links the game folder to `~/.local/share/frameport/lepton-paths/<pkg>` at every start and gives Lepton that path (`app_dir_lines`); `upgrade_launchers` converts existing launchers at the next connection; deployment.json keeps the real path |
|
||||
| Start never finishes, 100 % CPU, launch.log of gigabytes repeating `EOSSDK.createKeychain` / `resetKeychain` (Beyond Sandbox, GitHub #214); triage `eos-keychain-loop` | Epic Online Services' Java keychain can't write its SharedPreferences and resets/creates without end (seen when the app had no internal storage, row above) | fix the storage first; `frame.eos_keychain_loop` (opt-in, offered by triage; analysis `eos_keychain`) makes `EOSSDK.resetKeychain` return at once (`Dex.return_early`) |
|
||||
| Unity game freezes on its loading screen ~10-15 s in (Yupitergrad, GitHub #235); launch.log has `GrantPermissionsActivity: Permission grant result … permission=android.permission.BLUETOOTH_CONNECT`, then OVRPlugin `SetupDisplayObjects(): Unable to find EGLConfig for configId 0` and no more frames; triage `bhaptics-permission-prompt` | bHaptics' vest SDK (`com.bhaptics.commons.PermissionUtils.requestBluetoothPermission`) asks for Android 12's BLUETOOTH_CONNECT; on Lepton's Android 11 it can't be granted at install, so Android's permission screen takes the foreground for a moment, the game pauses/resumes and OVRPlugin's GLES compositor stops drawing | `frame.bhaptics_permission` (suggested by analysis `bhaptics_permission`): the request returns at once (`Dex.return_early`) |
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 421 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 668 KiB After Width: | Height: | Size: 663 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 99 KiB |
+477
-38
@@ -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())
|
||||
+5
-2
@@ -137,7 +137,8 @@ def main() -> int:
|
||||
("monitor", lambda a: a.go("monitor")), ("monitor-details", open_monitor_details),
|
||||
("tools", lambda a: a.go("settings")),
|
||||
# the index on the left: "This PC" scrolls the page there
|
||||
("settings-index", lambda a: a.page.run_task(a.settings_view.show_section, "pc"))]
|
||||
("settings-index", lambda a: a.page.run_task(a.settings_view.show_section, "pc")),
|
||||
("settings-installing", lambda a: a.page.run_task(a.settings_view.show_section, "installing"))]
|
||||
if game:
|
||||
steps.insert(1, ("game", lambda a: a.open_game(game)))
|
||||
steps.insert(2, ("game-customize", lambda a: a.open_game(game, advanced=True)))
|
||||
@@ -150,7 +151,8 @@ def main() -> int:
|
||||
if args.fake_frame: # GitHub #90: the Move to… dialog (drives load in the background)
|
||||
steps.append(("move-dialog", lambda a: (a.page.pop_dialog(), a.move_game(game))))
|
||||
steps.append(("uninstall-dialog", lambda a: (a.page.pop_dialog(), a.uninstall(game))))
|
||||
# last: the right-click menu stays open over whatever comes next
|
||||
# last: the menus stay open over whatever comes next (the page's "…" menu, then the right-click menu)
|
||||
steps.append(("game-menu", lambda a: (a.show_activity(False), a.open_game_menu(game))))
|
||||
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
|
||||
a.library_view.open_menu(game))))
|
||||
if args.docs:
|
||||
@@ -278,6 +280,7 @@ def main() -> int:
|
||||
("linux-change-program", lambda a: a.choose_exe(LINUX_FOLDER)),
|
||||
("frame", lambda a: (a.page.pop_dialog(), a.navigate(1))),
|
||||
("linux-move", lambda a: a.move_game(LINUX_APPIMAGE) if args.fake_frame else None),
|
||||
("linux-game-menu", lambda a: (a.page.pop_dialog(), a.open_game_menu(LINUX_FOLDER))),
|
||||
("linux-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
|
||||
a.library_view.open_menu(LINUX_APPIMAGE)))]
|
||||
if args.links:
|
||||
|
||||
@@ -474,6 +474,11 @@
|
||||
"title": "Clear android:debuggable",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.obb_mount_emul": {
|
||||
"anchor": "frame-obb_mount_emul",
|
||||
"title": "OBB disk images: unpack instead of mounting",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.oculusos": {
|
||||
"anchor": "frame-oculusos",
|
||||
"title": "Oculus OS telemetry class stubs",
|
||||
|
||||
@@ -43,7 +43,9 @@ IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
|
||||
# 17: headset (the Meta headset a VR app was made for: Gear VR / Go games run through frame.a32_bridge) (2026-10)
|
||||
# 18: headset "quest" also for 32-bit apps that require 6DoF head tracking (Santa Cruz builds, e.g. Sports
|
||||
# Scramble: Touch controllers on the a32 bridge) (2026-10)
|
||||
ANALYSIS_VERSION = 19
|
||||
# 20: obb_mount (the game mounts its OBBs as disk images through StorageManager.mountObb, which Lepton can't:
|
||||
# frame.obb_mount_emul, GitHub #308) (2026-10)
|
||||
ANALYSIS_VERSION = 20
|
||||
|
||||
|
||||
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
|
||||
@@ -249,6 +251,12 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
for d in dexes)
|
||||
# video through Android's decoders (frame.hw_video_decode): Java MediaCodec, ExoPlayer/Media3, or libVLC
|
||||
media_codec = any(m in d for d in dexes for m in MEDIA_CODEC_MARKERS) or "libvlc.so" in libs
|
||||
# OBBs mounted as disk images (StorageManager.mountObb; Lepton has no loop devices): frame.obb_mount_emul
|
||||
from ..patches.frame.obb_mount import handler_classes
|
||||
|
||||
obb_mount = sorted({c for d in dexes for c in handler_classes(d)}) or (
|
||||
["other"] if any(b"Landroid/os/storage/StorageManager;" in d and b"\x08mountObb\0" in d for d in dexes)
|
||||
else [])
|
||||
from ..patches.frame.unreal_obb_check import has_delivery_check
|
||||
|
||||
# Includes obfuscated DownloaderActivity predicates; CRC verification is a separate manifest switch.
|
||||
@@ -381,6 +389,7 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
"sdl_java": sdl_java,
|
||||
"godot_clipboard": godot_clipboard,
|
||||
"vivox_api31": vivox_api31,
|
||||
"obb_mount": obb_mount,
|
||||
"eos_keychain": eos_keychain,
|
||||
"bhaptics_permission": bhaptics_permission,
|
||||
"media_codec": media_codec,
|
||||
|
||||
@@ -67,6 +67,17 @@ class Dex:
|
||||
cls, _proto, name = struct.unpack_from("<HHI", self.data, self.methods_off + 8 * idx)
|
||||
return self.type_name(cls), self.string(name)
|
||||
|
||||
def shorty(self, idx: int) -> str:
|
||||
"""The method's shorty descriptor (return type + parameter types, one letter each: "VIL" = void f(int, Obj))."""
|
||||
proto = struct.unpack_from("<HHI", self.data, self.methods_off + 8 * idx)[1]
|
||||
protos_off = struct.unpack_from("<I", self.data, 76)[0]
|
||||
return self.string(struct.unpack_from("<I", self.data, protos_off + 12 * proto)[0])
|
||||
|
||||
def code_header(self, code_off: int) -> tuple[int, int, int]:
|
||||
"""(registers_size, ins_size, insns_size) of a code item; the ins are the last registers."""
|
||||
regs, ins = struct.unpack_from("<2H", self.data, code_off)
|
||||
return regs, ins, struct.unpack_from("<I", self.data, code_off + 12)[0]
|
||||
|
||||
def return_type(self, idx: int) -> str:
|
||||
proto = struct.unpack_from("<HHI", self.data, self.methods_off + 8 * idx)[1]
|
||||
protos_off = struct.unpack_from("<I", self.data, 76)[0]
|
||||
@@ -168,6 +179,12 @@ class Dex:
|
||||
new = [CONST_STRING_V0, 0, RETURN_OBJECT_V0] # string ids are sorted: "" is always the first
|
||||
else:
|
||||
return False
|
||||
return self.rewrite_start(code_off, new)
|
||||
|
||||
def rewrite_start(self, code_off: int, new: list[int]) -> bool:
|
||||
"""Replace a method's first instruction(s) with `new` (code units ending in a return, so the rest of the
|
||||
method becomes unreachable), padded with nops to the next instruction boundary. Nothing moves. False if the
|
||||
method already starts with `new` or its first instructions can't be decoded / are shorter than `new`."""
|
||||
start = code_off + 16
|
||||
insns_n = struct.unpack_from("<I", self.data, code_off + 12)[0]
|
||||
if list(struct.unpack_from(f"<{min(len(new), insns_n)}H", self.data, start)) == new:
|
||||
@@ -182,6 +199,14 @@ class Dex:
|
||||
struct.pack_into(f"<{width}H", self.data, start, *new, *([0] * (width - len(new))))
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def invoke_static(method: int, regs: list[int]) -> list[int]:
|
||||
"""invoke-static {regs}, method@ (format 35c: up to 5 registers, each v0-v15)."""
|
||||
if len(regs) > 5 or any(not 0 <= r < 16 for r in regs):
|
||||
raise ValueError("invoke-static needs at most 5 registers below v16")
|
||||
r = list(regs) + [0] * (5 - len(regs))
|
||||
return [(len(regs) << 12) | (r[4] << 8) | 0x71, method, r[0] | (r[1] << 4) | (r[2] << 8) | (r[3] << 12)]
|
||||
|
||||
def finish(self) -> bytes:
|
||||
self.data[12:32] = hashlib.sha1(bytes(self.data[32:])).digest()
|
||||
struct.pack_into("<I", self.data, 8, zlib.adler32(bytes(self.data[12:])) & 0xFFFFFFFF)
|
||||
|
||||
@@ -398,6 +398,30 @@ def rename(package: str = typer.Argument(..., help="the game (package or part of
|
||||
pipeline.sync_title(pkg, _target(None, to="pc"), rep)
|
||||
|
||||
|
||||
@app.command("new-version")
|
||||
def new_version(package: str = typer.Argument(..., help="the game (package or part of its title)"),
|
||||
path: Path = typer.Argument(..., exists=True, help="the newer version: an APK (Quest/Android games), "
|
||||
"an AppImage/archive/folder (Linux apps) or the game folder (PC VR)")):
|
||||
"""Point a game at a newer version of it (GitHub #161). Its name, artwork, tags and your own recipe stay; install
|
||||
it again (frameport install) to update it on the Frame."""
|
||||
[pkg] = _pkgs(package, False)
|
||||
try:
|
||||
res = pipeline.replace_source(pkg, path, printing_reporter(verbose=False))
|
||||
except pipeline.WrongPackage as exc:
|
||||
raise typer.BadParameter(f"that APK is a different app: {exc}") from None
|
||||
except ValueError as exc:
|
||||
raise typer.BadParameter(str(exc)) from None
|
||||
g = res["entry"]
|
||||
typer.echo(f"{pkg}: now {path}" + (f" (program {g.get('exe')})" if g.get("kind") in ("rift", "linux") else ""))
|
||||
if res.get("older"):
|
||||
typer.echo("Heads-up: that APK is an older version than the one before.", err=True)
|
||||
if res.get("different"):
|
||||
typer.echo(f"Heads-up: {res.get('name')} looks like a different app than {g.get('title')}.", err=True)
|
||||
if g.get("kind") == "rift" and g.get("exe_confirmed") is False:
|
||||
typer.echo(f"More than one program could start it; FramePort picked {g.get('exe')}. Another one: "
|
||||
f"frameport recipe {pkg} --exe <program>", err=True)
|
||||
|
||||
|
||||
@app.command()
|
||||
def patches():
|
||||
"""List every available patch."""
|
||||
@@ -706,6 +730,31 @@ def frame_trust_key(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
typer.echo(f"connected: {info.get('hostname') or t.host} (new key saved)")
|
||||
|
||||
|
||||
@frame_app.command("ssh-keys")
|
||||
def frame_ssh_keys(add: list[str] = typer.Option([], "--add", help="a private key file to log in with too"),
|
||||
remove: list[str] = typer.Option([], "--remove", help="stop using this key file (it stays)")):
|
||||
"""Private SSH key files FramePort logs in with besides its own key (Settings → Installing → SSH keys): for a
|
||||
Frame that accepts only your own key. The Frame connection and OpenSSH uploads both use them. Lists them."""
|
||||
from .frame.connection import KeyFileError, add_extra_key, extra_key_status, remove_extra_key
|
||||
|
||||
failed = False
|
||||
for path in remove:
|
||||
typer.echo(f"removed {path}" if remove_extra_key(path) else f"not in the list: {path}")
|
||||
for path in add:
|
||||
try:
|
||||
typer.echo(f"added {add_extra_key(path)}")
|
||||
except KeyFileError as exc:
|
||||
typer.echo(f"can't use {path}: {exc}", err=True)
|
||||
failed = True
|
||||
status = extra_key_status()
|
||||
for path, problem in status:
|
||||
typer.echo(f"{path}" + (f" (not usable: {problem})" if problem else ""))
|
||||
if not status:
|
||||
typer.echo("no extra keys: FramePort's own key and your SSH agent's keys only")
|
||||
if failed:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@frame_app.command("usb-check")
|
||||
def frame_usb_check(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
no_speed: bool = typer.Option(False, help="skip the upload speed test (2 × 128 MB)")):
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""SSH connection to a Steam Frame and the remote agent protocol.
|
||||
|
||||
Authentication: FramePort's own keys first (<user data>/ssh/id_ed25519, installed by the bootstrap script, and
|
||||
id_rsa, registered through Valve's devkit pairing, which only takes RSA keys), then the user's SSH agent/keys, then a
|
||||
password if given.
|
||||
id_rsa, registered through Valve's devkit pairing, which only takes RSA keys), then key files the user added in
|
||||
Settings (EXTRA_KEYS_SETTING), then the user's SSH agent/keys, then a password if given.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -163,6 +163,96 @@ def app_keys() -> list[paramiko.PKey]:
|
||||
return keys
|
||||
|
||||
|
||||
EXTRA_KEYS_SETTING = "ssh.extra_keys" # library setting: private key files the user added (Settings → Installing)
|
||||
|
||||
|
||||
class KeyFileError(ValueError):
|
||||
"""A key file the user named can't be used (missing, not a private key, protected with a passphrase)."""
|
||||
|
||||
|
||||
def load_key_file(path) -> paramiko.PKey:
|
||||
"""A private key FramePort can log in with, or KeyFileError with a sentence saying why not."""
|
||||
p = Path(path).expanduser()
|
||||
if not p.is_file():
|
||||
raise KeyFileError(f"{p}: file not found")
|
||||
try:
|
||||
return paramiko.PKey.from_path(p)
|
||||
except (paramiko.SSHException, ValueError, OSError, UnicodeDecodeError, TypeError) as exc:
|
||||
# paramiko says so with PasswordRequiredException (PEM) or a plain SSHException (OpenSSH format)
|
||||
if isinstance(exc, paramiko.PasswordRequiredException) or "password" in str(exc).lower():
|
||||
raise KeyFileError(f"{p.name} is protected with a passphrase, which FramePort can't ask for: add the "
|
||||
"key to your SSH agent instead (FramePort also logs in with the agent's keys)") from None
|
||||
raise KeyFileError(f"{p.name} isn't a private SSH key FramePort can read "
|
||||
f"(OpenSSH or PEM format; a .pub file is the public half): {exc}") from None
|
||||
|
||||
|
||||
def extra_key_paths() -> list[Path]:
|
||||
"""The key files from Settings, as stored (each may be missing or unreadable by now: see extra_keys)."""
|
||||
try:
|
||||
from ..core import library
|
||||
|
||||
stored = library.peek_setting(EXTRA_KEYS_SETTING, [])
|
||||
except Exception: # noqa: BLE001 - no library yet
|
||||
return []
|
||||
if isinstance(stored, str):
|
||||
stored = [stored]
|
||||
paths: list[Path] = []
|
||||
for value in stored if isinstance(stored, list) else []:
|
||||
if isinstance(value, str) and value.strip():
|
||||
p = Path(value.strip()).expanduser()
|
||||
if p not in paths:
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
|
||||
def add_extra_key(path) -> Path:
|
||||
"""Check a key file (KeyFileError if FramePort can't use it) and add it to Settings. Returns the stored path."""
|
||||
p = Path(path).expanduser()
|
||||
if p.name.endswith(".pub") and p.with_name(p.name[:-4]).is_file():
|
||||
raise KeyFileError(f"{p.name} is the public half: pick {p.name[:-4]} (the file without .pub) instead")
|
||||
load_key_file(p)
|
||||
p = p.resolve()
|
||||
from ..core import library
|
||||
|
||||
library.update_setting(EXTRA_KEYS_SETTING, lambda old: [v for v in (old if isinstance(old, list) else [])
|
||||
if Path(v).expanduser() != p] + [str(p)], [])
|
||||
return p
|
||||
|
||||
|
||||
def remove_extra_key(path) -> bool:
|
||||
"""Take a key file out of Settings (the file itself stays). False if it wasn't there."""
|
||||
from ..core import library
|
||||
|
||||
p = Path(path).expanduser()
|
||||
before = extra_key_paths()
|
||||
library.update_setting(EXTRA_KEYS_SETTING, lambda old: [v for v in (old if isinstance(old, list) else [])
|
||||
if Path(v).expanduser() != p], [])
|
||||
return p in before
|
||||
|
||||
|
||||
def extra_key_status() -> list[tuple[Path, str]]:
|
||||
"""Each key file from Settings with "" when usable, else why not (for Settings and the CLI)."""
|
||||
status = []
|
||||
for path in extra_key_paths():
|
||||
try:
|
||||
load_key_file(path)
|
||||
status.append((path, ""))
|
||||
except KeyFileError as exc:
|
||||
status.append((path, str(exc)))
|
||||
return status
|
||||
|
||||
|
||||
def extra_keys() -> list[tuple[Path, paramiko.PKey]]:
|
||||
"""The usable keys from Settings; unusable ones are logged and skipped (a login still tries the others)."""
|
||||
keys = []
|
||||
for path in extra_key_paths():
|
||||
try:
|
||||
keys.append((path, load_key_file(path)))
|
||||
except KeyFileError as exc:
|
||||
log.warning("SSH key from Settings skipped: %s", exc)
|
||||
return keys
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameTarget:
|
||||
host: str
|
||||
@@ -409,6 +499,10 @@ class Frame:
|
||||
_video_codec_digest: str = "" # "<manifest digest>:<on|off>" handled on this connection (incl. a failed install)
|
||||
_video_codec_lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
home: str = ""
|
||||
# how the last login worked: "app_key" (FramePort's own key), "user_key" (a key file from Settings),
|
||||
# "agent" (this PC's SSH agent / ~/.ssh keys) or "password"; OpenSSH uploads follow it (native_upload)
|
||||
auth: str = ""
|
||||
openssh_relaxed: bool | None = None # native_upload: True once OpenSSH needed this PC's own keys/agent
|
||||
|
||||
# ------------------------------------------------------------------ connect
|
||||
def connect(self, timeout: float = 10) -> Frame:
|
||||
@@ -459,7 +553,8 @@ class Frame:
|
||||
kwargs = dict(hostname=self.target.host, port=self.target.port, username=self.target.user, timeout=timeout,
|
||||
banner_timeout=timeout, auth_timeout=timeout)
|
||||
errors = []
|
||||
attempts = [("app_key", k) for k in app_keys()] + [("agent", None), ("password", None)]
|
||||
attempts = ([("app_key", k) for k in app_keys()] + [("user_key", k) for _, k in extra_keys()]
|
||||
+ [("agent", None), ("password", None)])
|
||||
for attempt, pkey in attempts:
|
||||
if attempt == "password" and not self.password:
|
||||
continue
|
||||
@@ -467,7 +562,7 @@ class Frame:
|
||||
client.load_host_keys(str(known))
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # trust on first use (pairing)
|
||||
try:
|
||||
if attempt == "app_key":
|
||||
if attempt in ("app_key", "user_key"):
|
||||
client.connect(pkey=pkey, allow_agent=False, look_for_keys=False, **kwargs)
|
||||
elif attempt == "agent":
|
||||
client.connect(allow_agent=True, look_for_keys=True, **kwargs)
|
||||
@@ -492,6 +587,7 @@ class Frame:
|
||||
raise
|
||||
else:
|
||||
raise FrameNotPaired(bool(self.password), errors)
|
||||
self.auth = attempt
|
||||
client.save_host_keys(str(known))
|
||||
transport = client.get_transport()
|
||||
transport.set_keepalive(15)
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
@@ -13,19 +12,30 @@ import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PureWindowsPath
|
||||
|
||||
from .connection import sh_quote, ssh_dir
|
||||
from .connection import extra_keys, sh_quote, ssh_dir
|
||||
|
||||
|
||||
def quote(value) -> str:
|
||||
def quote(value, windows=False) -> str:
|
||||
"""One sftp batch argument, taken literally.
|
||||
|
||||
Inside double quotes sftp escapes glob characters (* ? [) itself and undoes that for put's destination, so
|
||||
they must NOT be escaped again: an extra backslash became part of the name (GitHub #324: "[ENG]" in a folder
|
||||
name made every OpenSSH upload fail, on Linux too). Only backslash and the quote are escaped. ``windows``: a
|
||||
local Windows path goes with forward slashes; Win32-OpenSSH's sftp turns every backslash of a local path into
|
||||
'/', escapes included ("C:\\\\Users" → "C://Users").
|
||||
"""
|
||||
value = str(value)
|
||||
if any(c in value for c in "\r\n\x00"):
|
||||
raise ValueError("SFTP paths cannot contain newlines or NUL")
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
for char in "*?[":
|
||||
escaped = escaped.replace(char, "\\" + char)
|
||||
return '"' + escaped + '"'
|
||||
if windows:
|
||||
value = PureWindowsPath(value).as_posix()
|
||||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def quote_local(path) -> str:
|
||||
return quote(path, windows=os.name == "nt")
|
||||
|
||||
|
||||
def size_of(sftp, path):
|
||||
@@ -35,27 +45,74 @@ def size_of(sftp, path):
|
||||
return 0
|
||||
|
||||
|
||||
def key_files(relaxed=False) -> list[Path]:
|
||||
"""The private keys OpenSSH offers: FramePort's own (<data>/ssh) and the usable key files from Settings;
|
||||
``relaxed`` also this PC's default keys (default_key_files)."""
|
||||
keys = [ssh_dir() / name for name in ("id_ed25519", "id_rsa") if (ssh_dir() / name).exists()]
|
||||
for path in [path for path, _ in extra_keys()] + (default_key_files() if relaxed else []):
|
||||
if path not in keys:
|
||||
keys.append(path)
|
||||
return keys
|
||||
|
||||
|
||||
def default_key_files() -> list[Path]:
|
||||
"""This PC's own keys that Paramiko's "agent" login tries (look_for_keys: ~/.ssh/id_rsa, id_dsa, id_ecdsa,
|
||||
id_ed25519; on Windows ~/ssh too), usable without a passphrase. OpenSSH needs them named: it skips its default
|
||||
keys as soon as one -i is given, and Windows OpenSSH refuses a key file others can read (a private copy can't
|
||||
be refused)."""
|
||||
from .connection import KeyFileError, load_key_file
|
||||
|
||||
folders = [Path.home() / ".ssh"] + ([Path.home() / "ssh"] if os.name == "nt" else [])
|
||||
found = []
|
||||
for folder in folders:
|
||||
for name in ("id_rsa", "id_dsa", "id_ecdsa", "id_ed25519"):
|
||||
path = folder / name
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
load_key_file(path)
|
||||
except KeyFileError:
|
||||
continue # passphrase-protected or not a key: only the agent can use it
|
||||
found.append(path)
|
||||
return found
|
||||
|
||||
|
||||
def current_sid() -> str:
|
||||
"""This Windows account's SID. whoami prints in the console code page: read bytes, so a user name with
|
||||
accents can't break decoding (only the ASCII SID is used)."""
|
||||
out = subprocess.check_output(["whoami", "/user", "/fo", "csv", "/nh"], **hidden())
|
||||
match = re.search(rb"S-1-[0-9]+(?:-[0-9]+)+", out)
|
||||
if not match:
|
||||
raise OSError("cannot find this Windows account's SID for the key copies' permissions")
|
||||
return match.group().decode()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def credentials(frame, port_flag="-P"):
|
||||
"""Private temporary copies avoid modifying pairing keys, including their Windows ACLs."""
|
||||
key_paths = [ssh_dir() / name for name in ("id_ed25519", "id_rsa") if (ssh_dir() / name).exists()]
|
||||
if not key_paths:
|
||||
def credentials(frame, port_flag="-P", relaxed=False):
|
||||
"""OpenSSH arguments that log in like the Frame connection and trust only the host key it pinned.
|
||||
|
||||
Keys are offered as private temporary copies (that avoids modifying pairing keys, including their Windows
|
||||
ACLs; Windows OpenSSH refuses key files others can read). Strict (default): FramePort's keys and the key
|
||||
files from Settings only (IdentitiesOnly). ``relaxed``: also this PC's default ~/.ssh keys and SSH agent, the
|
||||
way Paramiko's "agent" login does (GitHub #242: a Frame that only authorizes the user's own key). Verbose
|
||||
(-v), so a refused login can say which keys were offered (failure()).
|
||||
"""
|
||||
key_paths = key_files(relaxed)
|
||||
if not key_paths and not relaxed:
|
||||
raise OSError("no FramePort key available for OpenSSH")
|
||||
key = frame.client.get_transport().get_remote_server_key()
|
||||
with tempfile.TemporaryDirectory(prefix="frameport-upload-") as directory:
|
||||
directory = Path(directory)
|
||||
sid = None
|
||||
if os.name == "nt":
|
||||
row = next(csv.reader(io.StringIO(subprocess.check_output(
|
||||
["whoami", "/user", "/fo", "csv", "/nh"], text=True, **hidden()))))
|
||||
sid = row[-1]
|
||||
sid = current_sid()
|
||||
subprocess.run(["icacls", str(directory), "/inheritance:r", "/grant:r", f"*{sid}:(OI)(CI)F"],
|
||||
check=True, stdout=subprocess.DEVNULL, **hidden())
|
||||
else:
|
||||
directory.chmod(0o700)
|
||||
copies = []
|
||||
for path in key_paths:
|
||||
copy = directory / path.name
|
||||
for n, path in enumerate(key_paths):
|
||||
copy = directory / f"key{n}" # own names: a key from Settings may be called id_ed25519 too
|
||||
shutil.copyfile(path, copy)
|
||||
if sid:
|
||||
subprocess.run(["icacls", str(copy), "/inheritance:r", "/remove:g", "*S-1-3-4",
|
||||
@@ -66,13 +123,72 @@ def credentials(frame, port_flag="-P"):
|
||||
known = directory / "known_hosts"
|
||||
host = frame.target.host if frame.target.port == 22 else f"[{frame.target.host}]:{frame.target.port}"
|
||||
known.write_text(f"{host} {key.get_name()} {key.get_base64()}\n")
|
||||
yield ["-F", os.devnull, *copies, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
|
||||
yield ["-F", os.devnull, "-v", *copies, "-o", "BatchMode=yes",
|
||||
*([] if relaxed else ["-o", "IdentitiesOnly=yes"]),
|
||||
"-o", "StrictHostKeyChecking=yes", "-o", f'UserKnownHostsFile="{known.as_posix()}"',
|
||||
"-o", f"GlobalKnownHostsFile={os.devnull}", "-o", "ConnectTimeout=10",
|
||||
"-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3",
|
||||
port_flag, str(frame.target.port)]
|
||||
|
||||
|
||||
class OpenSSHAuthFailed(OSError):
|
||||
"""OpenSSH reached the Frame but the login was refused (the keys it offered aren't authorized there)."""
|
||||
|
||||
|
||||
AUTH_REFUSED = ("Permission denied (", "Too many authentication failures", "No more authentication methods")
|
||||
# debug lines (-v) worth keeping: which keys were found, offered, refused or unreadable
|
||||
AUTH_DEBUG = ("identity file", "Offering public key", "Server accepts key", "Will attempt key",
|
||||
"Authentications that can continue", "Authenticated to", "Load key", "no such identity",
|
||||
"Skipping", "agent", "Next authentication method")
|
||||
|
||||
|
||||
def failure(what: str, code, stderr: bytes) -> OSError:
|
||||
"""The error for a failed OpenSSH run: OpenSSH's own messages (all non-debug lines: warnings such as
|
||||
"UNPROTECTED PRIVATE KEY FILE" included) plus the -v lines about keys; OpenSSHAuthFailed for a refused login."""
|
||||
text = stderr.decode(errors="replace").replace("\r\n", "\n")
|
||||
lines = [line for line in text.splitlines() if line.strip() and (
|
||||
not line.startswith("debug") or any(k in line for k in AUTH_DEBUG))]
|
||||
detail = "\n".join(lines)
|
||||
if len(detail) > 4000:
|
||||
detail = "…" + detail[-4000:]
|
||||
refused = code == 255 and any(marker in text for marker in AUTH_REFUSED)
|
||||
return (OpenSSHAuthFailed if refused else OSError)(f"{what} failed ({code}): {detail}")
|
||||
|
||||
|
||||
def login_modes(frame) -> list[bool]:
|
||||
"""How OpenSSH logs in, in order (False = FramePort's keys only, True = also this PC's keys and agent).
|
||||
|
||||
A Frame connection that logged in with this PC's own keys/agent (Paramiko's "agent" attempt) goes straight to
|
||||
relaxed: FramePort's key isn't authorized there. Else strict first, relaxed once more after a refused login.
|
||||
A mode that worked is kept for the rest of the connection. FramePort doesn't add its key to the Frame for
|
||||
this: it only does that after a password login, where the user typed the Frame's password for it."""
|
||||
known = getattr(frame, "openssh_relaxed", None)
|
||||
if known is not None:
|
||||
return [bool(known)]
|
||||
if getattr(frame, "auth", "") == "agent":
|
||||
return [True]
|
||||
return [False, True]
|
||||
|
||||
|
||||
def with_login_modes(frame, reporter, run):
|
||||
"""run(relaxed) in each login mode until one isn't refused (see login_modes)."""
|
||||
modes = login_modes(frame)
|
||||
if modes == [True] and getattr(frame, "openssh_relaxed", None) is None:
|
||||
reporter.log("the Frame connection logged in with this PC's own SSH keys or agent: OpenSSH uses them too")
|
||||
for n, relaxed in enumerate(modes):
|
||||
try:
|
||||
result = run(relaxed)
|
||||
except OpenSSHAuthFailed as exc:
|
||||
if n == len(modes) - 1:
|
||||
raise
|
||||
reporter.log(f"OpenSSH login with FramePort's SSH keys was refused; trying this PC's own SSH keys and "
|
||||
f"agent too. {exc}")
|
||||
continue
|
||||
with contextlib.suppress(AttributeError):
|
||||
frame.openssh_relaxed = relaxed
|
||||
return result
|
||||
|
||||
|
||||
def hidden():
|
||||
return {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {}
|
||||
|
||||
@@ -99,6 +215,11 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
|
||||
Only successful batches are promoted, after every process has exited successfully. All control operations
|
||||
happen in the supervising thread, so Paramiko's SFTP client is never shared across threads.
|
||||
"""
|
||||
return with_login_modes(frame, reporter, lambda relaxed: _upload(
|
||||
frame, items, remote_root, reporter, total, sent, workers, relaxed))
|
||||
|
||||
|
||||
def _upload(frame, items, remote_root, reporter, total, sent, workers, relaxed):
|
||||
executable = shutil.which("sftp")
|
||||
if not executable:
|
||||
raise OSError("system OpenSSH SFTP is unavailable")
|
||||
@@ -111,7 +232,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
|
||||
sizes, offsets, progress = {}, {}, {}
|
||||
for path, rel, size in sorted(items, key=lambda item: item[2], reverse=True):
|
||||
remote = posixpath.join(remote_root, rel)
|
||||
quote(path)
|
||||
quote_local(path)
|
||||
quote(remote)
|
||||
offset = size_of(frame.sftp, remote + ".part")
|
||||
if offset > size:
|
||||
@@ -127,7 +248,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
|
||||
speed = Speed(total - sent - sum(offsets.values()))
|
||||
reporter.log(f"uploading large files with OpenSSH SFTP ({len(groups)} workers)")
|
||||
setup_started = time.monotonic()
|
||||
with credentials(frame) as args, contextlib.ExitStack() as stack:
|
||||
with credentials(frame, relaxed=relaxed) as args, contextlib.ExitStack() as stack:
|
||||
monitor = stack.enter_context(frame.client.open_sftp())
|
||||
monitor.get_channel().settimeout(2)
|
||||
reporter.log(f"OpenSSH credentials and progress connection ready in {time.monotonic() - setup_started:.2f}s")
|
||||
@@ -139,7 +260,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
|
||||
for path, rel, _ in group:
|
||||
remote = posixpath.join(remote_root, rel) + ".part"
|
||||
command = "reput" if offsets[rel] else "put"
|
||||
batch.write(f"{command} {quote(path)} {quote(remote)}\n".encode())
|
||||
batch.write(f"{command} {quote_local(path)} {quote(remote)}\n".encode())
|
||||
batch.seek(0)
|
||||
errors = stack.enter_context(tempfile.TemporaryFile())
|
||||
process = subprocess.Popen([executable, *args, "-b", "-",
|
||||
@@ -152,8 +273,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
|
||||
for process, errors in processes:
|
||||
if process.poll() not in (None, 0):
|
||||
errors.seek(0)
|
||||
detail = errors.read().decode(errors="replace")[-1500:]
|
||||
raise OSError(f"OpenSSH SFTP failed ({process.returncode}): {detail}")
|
||||
raise failure("OpenSSH SFTP", process.returncode, errors.read())
|
||||
for rel, size in sizes.items():
|
||||
reporter.check_cancel()
|
||||
progress[rel] = max(progress[rel], min(size, size_of(
|
||||
@@ -187,11 +307,16 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
|
||||
|
||||
def put_tar(frame, files, remote_root, reporter, progress):
|
||||
"""Stream a tar batch over paired OpenSSH; supervise writes so Cancel also stops a blocked pipe."""
|
||||
return with_login_modes(frame, reporter,
|
||||
lambda relaxed: _put_tar(frame, files, remote_root, reporter, progress, relaxed))
|
||||
|
||||
|
||||
def _put_tar(frame, files, remote_root, reporter, progress, relaxed):
|
||||
executable = shutil.which("ssh")
|
||||
if not executable:
|
||||
raise OSError("system OpenSSH SSH is unavailable")
|
||||
reporter.check_cancel()
|
||||
with credentials(frame, port_flag="-p") as args, tempfile.TemporaryFile() as errors:
|
||||
with credentials(frame, port_flag="-p", relaxed=relaxed) as args, tempfile.TemporaryFile() as errors:
|
||||
command = f"mkdir -p {sh_quote(remote_root)} && tar -xf - -C {sh_quote(remote_root)}"
|
||||
process = subprocess.Popen([executable, *args, f"{frame.target.user}@{frame.target.host}", command],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=errors,
|
||||
@@ -222,12 +347,14 @@ def put_tar(frame, files, remote_root, reporter, progress):
|
||||
if not writer.is_alive() and process.poll() is None:
|
||||
time.sleep(0.1)
|
||||
reporter.check_cancel()
|
||||
if failures and process.poll() is None: # a refused login shows as a broken pipe first
|
||||
with contextlib.suppress(subprocess.TimeoutExpired):
|
||||
process.wait(timeout=2)
|
||||
if process.poll() not in (None, 0):
|
||||
errors.seek(0)
|
||||
raise failure("OpenSSH tar", process.returncode, errors.read())
|
||||
if failures:
|
||||
raise failures[0]
|
||||
if process.poll() != 0:
|
||||
errors.seek(0)
|
||||
raise OSError(f"OpenSSH tar failed ({process.returncode}): "
|
||||
+ errors.read().decode(errors="replace")[-1500:])
|
||||
finally:
|
||||
stop_process(process)
|
||||
writer.join()
|
||||
|
||||
@@ -82,15 +82,37 @@ def update_existing(frame: Frame, entry: dict, deployment: dict, plan, recipe: R
|
||||
args.update(obb_manifest=manifest, existing_only=True, consume_artwork=False,
|
||||
apk_name=deployment.get("apk", "game.apk"))
|
||||
reporter.stage("Finalize game data")
|
||||
return frame.agent("finalize", **args)
|
||||
result = frame.agent("finalize", **args)
|
||||
unpack_obb_images(frame, entry["package"], recipe, reporter)
|
||||
return result
|
||||
reporter.stage("Apply game configuration")
|
||||
result = frame.agent("configure", **args)
|
||||
if result.get("deployment", {}).get("configuration_fp") != args["configuration_fp"]:
|
||||
raise RuntimeError("the Frame did not confirm the applied configuration")
|
||||
reporter.check("Game configuration", True, "applied; used from the game's next start")
|
||||
unpack_obb_images(frame, entry["package"], recipe, reporter)
|
||||
return result
|
||||
|
||||
|
||||
OBB_IMAGE_PATCH = "frame.obb_mount_emul"
|
||||
|
||||
|
||||
def unpack_obb_images(frame: Frame, package: str, recipe: Recipe, reporter: Reporter) -> int:
|
||||
"""frame.obb_mount_emul: the game's OBB disk images are unpacked on the Frame (agent unpack_obb), one call per
|
||||
image (each takes a while: ~2 GB of reading and writing). Returns how many were unpacked."""
|
||||
if OBB_IMAGE_PATCH not in recipe.patches:
|
||||
return 0
|
||||
pending = frame.agent("unpack_obb", package=package).get("pending") or []
|
||||
for i, image in enumerate(pending, 1):
|
||||
reporter.check_cancel()
|
||||
reporter.stage(f"Unpack game data {i}/{len(pending)}")
|
||||
reporter.progress((i - 1) / len(pending), f"Unpacking {image}")
|
||||
done = frame.agent("unpack_obb", package=package, image=image, timeout=None)
|
||||
reporter.log(f"unpacked {image}: {done['files']} files, {done['bytes'] / 2**30:.2f} GiB in "
|
||||
f"{done['seconds']} s")
|
||||
return len(pending)
|
||||
|
||||
|
||||
def local_data_manifest(data_dir: Path | None, files: list[str] | None = None) -> dict[str, int]:
|
||||
return data_manifest(data_dir, files)
|
||||
|
||||
@@ -132,8 +154,10 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
|
||||
existing = prep["existing_obb"]
|
||||
to_send = [rel for rel, size in manifest.items() if existing.get(rel) != size]
|
||||
need = sum(manifest[r] for r in to_send) + (0 if prep["same_apk"] else plan.apk.stat().st_size)
|
||||
if need > prep["free_bytes"] - (1 << 30):
|
||||
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
|
||||
# unpacking OBB disk images (frame.obb_mount_emul) holds one image twice for a while
|
||||
space = need + (max(manifest[r] for r in to_send) if OBB_IMAGE_PATCH in plan.recipe.patches and to_send else 0)
|
||||
if space > prep["free_bytes"] - (1 << 30):
|
||||
raise RuntimeError(f"not enough space on the Frame: need {space / 2**30:.1f} GiB + 1 GiB headroom, "
|
||||
f"have {prep['free_bytes'] / 2**30:.1f} GiB")
|
||||
total = max(need, 1)
|
||||
sent = 0
|
||||
@@ -182,6 +206,7 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
|
||||
preserve_data=plan.apk_only, consume_artwork=artwork_manifest is not None, **config,
|
||||
)
|
||||
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
|
||||
unpack_obb_images(frame, plan.package, plan.recipe, reporter)
|
||||
log_controller_models(result.get("controller_models"), reporter)
|
||||
log_config_sync(result.get("config_sync"), reporter)
|
||||
return result
|
||||
@@ -220,6 +245,28 @@ class PcvrPlan:
|
||||
art_lookup: str | None = None # package whose store art to use
|
||||
dest: str | None = None
|
||||
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
|
||||
source_id: str | None = None # which version of the game folder (GitHub #161: a newer one was added)
|
||||
changed_since: float | None = None # a newer version: game files changed after this are sent again (see below)
|
||||
|
||||
|
||||
def changed_since(path: Path, since: float | None) -> bool:
|
||||
"""A newer version of a Linux app / PC VR game (GitHub #161): uploads skip files the Frame has with the same
|
||||
size, so a changed file of the same size would stay old. Files written (or unpacked: ctime) after the last
|
||||
install are sent again; since=None (no newer version) never, 0 (last install unknown) always."""
|
||||
if since is None:
|
||||
return False
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return True
|
||||
return max(st.st_mtime, st.st_ctime) > since
|
||||
|
||||
|
||||
def _recipe_record(recipe: dict | None, source_id: str | None) -> dict | None:
|
||||
"""The deployment's recipe record, with the source version it was installed from (ui.components.source_replaced)."""
|
||||
if not source_id:
|
||||
return recipe
|
||||
return {**(recipe or {}), "source_id": source_id}
|
||||
|
||||
|
||||
def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
@@ -259,7 +306,8 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
manifests["helpers"] = {p.name: p.stat().st_size for p in sorted(helpers.iterdir()) if p.is_file()}
|
||||
sources["helpers"] = helpers
|
||||
to_send = [(t, rel) for t, m in manifests.items() for rel, size in m.items()
|
||||
if prep["existing"].get(t, {}).get(rel) != size]
|
||||
if prep["existing"].get(t, {}).get(rel) != size
|
||||
or t == "game" and changed_since(plan.game_dir / rel, plan.changed_since)]
|
||||
need = sum(manifests[t][rel] for t, rel in to_send)
|
||||
if need > prep["free_bytes"] - (1 << 30):
|
||||
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
|
||||
@@ -290,7 +338,8 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
game_args=game_args(plan.recipe), no_crash_reporter="pcvr.no_crash_reporter" in plan.recipe.patches,
|
||||
libovr_redirect="pcvr.libovr_redirect" in plan.recipe.patches,
|
||||
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version, vr=not is_flat_windows(plan.package),
|
||||
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, overlay=plan.overlay,
|
||||
recipe=_recipe_record({"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, plan.source_id),
|
||||
overlay=plan.overlay,
|
||||
)
|
||||
reporter.log(f"installed at {result['base']} (Proton {result['proton']}, Steam shortcut id {result['appid']})")
|
||||
log_overlay(result.get("overlay"), reporter)
|
||||
@@ -325,6 +374,8 @@ class LinuxPlan:
|
||||
desktop_entry: bool = True # an entry in Desktop Mode's menu and on its desktop (GitHub #84)
|
||||
desktop_icon: bool = True # with it, a copy on Desktop Mode's desktop (GitHub #212)
|
||||
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
|
||||
source_id: str | None = None # which version of the app (GitHub #161: a newer one was added)
|
||||
changed_since: float | None = None # a newer version: files changed after this are sent again
|
||||
|
||||
|
||||
def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
@@ -339,7 +390,8 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
else:
|
||||
manifest = local_data_manifest(plan.root)
|
||||
manifest = {k: v for k, v in manifest.items() if not k.startswith(".unpacked")}
|
||||
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size]
|
||||
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size
|
||||
or changed_since(plan.root / rel, plan.changed_since)]
|
||||
need = sum(manifest[rel] for rel in to_send)
|
||||
if need > prep["free_bytes"] - (1 << 30):
|
||||
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
|
||||
@@ -358,7 +410,7 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
appimage=plan.appimage, openxr=plan.openxr, x86_64=plan.x86_64, manifests={"app": manifest},
|
||||
executables=executables, tags=_tags(plan.package), dest=plan.dest,
|
||||
desktop_entry=plan.desktop_entry, desktop_icon=plan.desktop_icon, overlay=plan.overlay,
|
||||
timeout=900)
|
||||
recipe=_recipe_record(None, plan.source_id), timeout=900)
|
||||
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
|
||||
log_overlay(result.get("overlay"), reporter)
|
||||
apply_app_icon(frame, plan.package, prep.get("anchor"), result, reporter)
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"AC Nexus: no AppSW at 72 Hz": "",
|
||||
"AC Nexus: no AppSW at 90 Hz": "",
|
||||
"Activity": "",
|
||||
"Add a key file…": "",
|
||||
"Add a Linux app folder…": "",
|
||||
"Add a Linux app…": "",
|
||||
"Add a PC game folder…": "",
|
||||
@@ -557,6 +558,7 @@
|
||||
"Frame OpenXR compatibility layer": "",
|
||||
"FrameBridge OpenXR adapter": "",
|
||||
"FramePort": "",
|
||||
"FramePort also logs in with {name} from the next connection.": "",
|
||||
"FramePort can't update itself in {value} (no permission) — opening the release page to download it": "",
|
||||
"FramePort changelog": "",
|
||||
"FramePort checks GitHub for a new version every few hours. Update now downloads and checks it, then restarts FramePort; your games and settings stay.": "",
|
||||
@@ -568,6 +570,7 @@
|
||||
"FramePort isn't sure which program starts this game. Open the game and use … → Change program… to pick it.": "",
|
||||
"FramePort keeps its own copies; nothing is installed system-wide": "",
|
||||
"FramePort live view": "",
|
||||
"FramePort logs in to the Frame with its own key. If your Frame accepts only one of your own keys, add that private key file here; fast uploads use it too. A key with a passphrase: add it to your SSH agent instead.": "",
|
||||
"FramePort looked at your last session of {n} game and has something to try.": [
|
||||
"",
|
||||
""
|
||||
@@ -664,6 +667,7 @@
|
||||
"Hardware video decoding (H.264, HEVC, VP9)": "",
|
||||
"Hardware video decoding on the Frame": "",
|
||||
"Heads-up: that APK is an older version than the one before.": "",
|
||||
"Heads-up: {name} looks like a different app than {title}.": "",
|
||||
"headset found": "",
|
||||
"Hi there!": "",
|
||||
"Hide Android's navigation bar": "",
|
||||
@@ -822,6 +826,7 @@
|
||||
"Lets games built on Meta's oldest VR interface run on the Frame.": "",
|
||||
"Lets games made for Oculus PCs run on SteamVR.": "",
|
||||
"Lets games start that would stop at Meta's avatars (for example BlazeRush). No Meta avatars.": "",
|
||||
"Lets games whose data files are disk images start on the Frame (they'd quit with \"Can't mount OBB\"), for example GRID Legends. The images are unpacked on the Frame.": "",
|
||||
"Lets games with a verified older Lua engine get past a startup crash on the Frame.": "",
|
||||
"Lets games with Vivox voice chat start on the Frame (they'd crash on an Android 12 audio call).": "",
|
||||
"Lets games written for newer OpenXR versions run on the Frame.": "",
|
||||
@@ -941,6 +946,8 @@
|
||||
"New recipe applied. Click Update on Frame to install it.": "",
|
||||
"Newer APK for {title}": "",
|
||||
"Newer APK of {title}": "",
|
||||
"Newer version for {title}": "",
|
||||
"Newer version of {title}": "",
|
||||
"Next": "",
|
||||
"Nice, it works! FramePort receives install links. (Demo: nothing was installed.)": "",
|
||||
"No": "",
|
||||
@@ -999,6 +1006,7 @@
|
||||
"Now {scale:.0%}; {new:.0%} after restarting FramePort.": "",
|
||||
"nowhere": "",
|
||||
"NPU": "",
|
||||
"OBB disk images: unpack instead of mounting": "",
|
||||
"Oculus game: it needs Revive, which doesn't run on the Frame. Play it on this PC.": "",
|
||||
"Oculus Go": "",
|
||||
"Oculus Go (32-bit)": "",
|
||||
@@ -1026,6 +1034,7 @@
|
||||
"On your Frame": "",
|
||||
"on: {names}": "",
|
||||
"One PC game folder": "",
|
||||
"Only FramePort's own key, and your SSH agent's keys.": "",
|
||||
"Only if 360° pictures show wrongly.": "",
|
||||
"Only if 360° pictures show wrongly: 1 = upside down, 2 = mirrored, 4 = turned around (add values to combine).": "",
|
||||
"Only install software from sites you trust. FramePort downloads it from {host} and installs it on your Frame.": "",
|
||||
@@ -1138,6 +1147,7 @@
|
||||
"Pretty smooth": "",
|
||||
"Prevents a start-up crash in some Unreal games.": "",
|
||||
"Previous": "",
|
||||
"Private SSH key file (not the .pub file)": "",
|
||||
"Problems and feedback": "",
|
||||
"Processes": "",
|
||||
"Processor": "",
|
||||
@@ -1173,6 +1183,7 @@
|
||||
"Reading the file list from the Frame…": "",
|
||||
"Reading the install link": "",
|
||||
"Reading the new APK": "",
|
||||
"Reading the new version": "",
|
||||
"ready": "",
|
||||
"Ready": "",
|
||||
"Ready to play": "",
|
||||
@@ -1399,6 +1410,7 @@
|
||||
"Some engines leave the pNext of unused Vulkan attachment references uninitialized. The Frame's driver never reads it, but Lepton always loads Steam's Fossilize shader-cache layer, which follows it and crashes on the first frame (SIGSEGV in libVkLayer_fossilize.so from FVulkanRenderPass, for example Deadpool VR). Loads Vulkan through a small shim that keeps valid pointers and drops only unreadable ones or ones pointing at the wrong structure type. It also drops a depth resolve named in a subpass without a depth attachment, which crashes the Frame's driver at the first render pass (for example Metro Awakening).": "",
|
||||
"Some game folders are already set up for SteamVR: a Revive copy (LibRevive64.dll) next to the game, started by a small loader DLL (for example xinput1_3.dll) that Windows loads from the game's folder. Then FramePort starts the game program directly — like double-clicking it — instead of through its own Revive (two Revives conflict; for example the Oculus Platform check then fails). On the Frame, Wine is told to use those loader DLLs from the game folder instead of its own.": "",
|
||||
"Some games keep content in separate files next to their OBB (for example Star Wars: Tales from the Galaxy's Edge: its seasons and sound banks) and ask Meta's platform for them as asset files. OVRPort's platform loader doesn't know them, so the game waits for content that never arrives (black screen after loading). Lists the content files shipped with the game's data (*.pak) as installed and answers their download request at once with the file's path; nothing is copied or downloaded. Shares the language-pack library.": "",
|
||||
"Some games keep their data in OBB files that Android mounts as disk images (for example GRID Legends). Lepton can't mount disk images, so these games quit at start (\"Can't mount OBB\"). FramePort unpacks the images on the Frame after the install (into folders with the same names, so the data takes its space only once) and changes the game's OBB code so it uses those folders instead of mounting.": "",
|
||||
"Some games save their render resolution in a settings file and load it after creating the headset's eye buffers; after a resolution scale change the saved size no longer matches and the picture is displaced or stretched, with black borders (for example BlazeRush's user_config.xml). FramePort writes the values its recipe names into the game's file while the game isn't running (after an install and after a Game settings change), from the Frame's own eye-buffer size times the resolution scale: w and h = the eye buffer's width and height, for example h*16/9. Only existing entries change; the previous file is kept as <file>.frameport-backup. The size is learned from the game's last start, so a file the game wrote before is corrected from the next install or settings change on.": "",
|
||||
"Some OpenGL ES engines compile every shader for multiview (both eyes in one draw, `num_views=2`) and use the same shaders to draw into ordinary single-layer framebuffers, for example a HUD or PDA screen rendered to a texture. Quest's driver draws them anyway; the Frame's driver (Mesa) follows the OVR_multiview rule and silently drops every such draw, so those panels stay black (for example Doom3Quest's HUD and PDA). Loads OpenGL ES through a small interposer (libfpglmv.so) that draws those cases with a single-view copy of the shader program (left-eye view; uniforms copied before each such draw). Eye-buffer draws are unchanged. Logs under the tag GLMV; the setting gl_mv_debug adds error checks and counters.": "",
|
||||
"Some OpenXR games still ship Meta's VrApi loader and call it a few times without starting VrApi (for example Jurassic World Aftermath); Meta's loader then stops the game (\"vrapi_SetPropertyInt was called before vrapi_Initialize()\"). Replaces it with a library that has the same functions, each doing nothing. Only for games whose VR runs through OVRPlugin on OpenXR.": "",
|
||||
@@ -1412,6 +1424,7 @@
|
||||
"Sound on": "",
|
||||
"Source": "",
|
||||
"square, 256 × 256": "",
|
||||
"SSH keys": "",
|
||||
"Stable (default)": "",
|
||||
"Stand in for cube layers": "",
|
||||
"Standard for every game": "",
|
||||
@@ -1462,6 +1475,7 @@
|
||||
"Stereo (3D) 360° layers: 0 = as the game sends them, 2 = flat (the left image to both eyes).": "",
|
||||
"Stop": "",
|
||||
"Stop objects popping in and out": "",
|
||||
"Stop using this key (the file stays)": "",
|
||||
"Stopped: {why}": "",
|
||||
"Stops a black picture in Minecraft launchers (for example QuestCraft).": "",
|
||||
"Stops a crash a few seconds after start in some Unreal games with anti-aliasing (for example Star Wars Pinball VR).": "",
|
||||
@@ -1513,6 +1527,7 @@
|
||||
"Test Proton on the Frame": "",
|
||||
"Text and layout size": "",
|
||||
"That APK is a different app, not {title}.": "",
|
||||
"That key can't be used: {error}": "",
|
||||
"That theme file can't be used: {error}": "",
|
||||
"That's me!": "",
|
||||
"The APK file is damaged or incomplete. Get the game again and rescan the folder.": "",
|
||||
@@ -1711,6 +1726,7 @@
|
||||
"Update the app only?": "",
|
||||
"Update tools": "",
|
||||
"Update with a newer APK…": "",
|
||||
"Update with a newer version…": "",
|
||||
"Updates": "",
|
||||
"Updates every game marked \"Update ready\", one after another.": "",
|
||||
"Updates the game, frame rate and battery every 5 seconds. Turn off to save the Frame's power.": "",
|
||||
@@ -1971,6 +1987,8 @@
|
||||
"{title}: name updated in Steam": "",
|
||||
"{title}: newer APK added": "",
|
||||
"{title}: newer APK added. Press Update on Frame to install it.": "",
|
||||
"{title}: newer version added": "",
|
||||
"{title}: newer version added. Press Update to install it.": "",
|
||||
"{title}: ready": "",
|
||||
"{title}: Steam artwork updated on the Frame": "",
|
||||
"{title}: {line}": "",
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Games that mount their OBB as a disk image (GitHub #308, GRID Legends): Android's StorageManager.mountObb() asks
|
||||
vold to attach the .obb (a FAT image made with Google's jobb tool) to a loop device and mount it; the game then reads
|
||||
its data below StorageManager.getMountedObbPath(). Lepton's Android 11 has neither loop devices nor device-mapper
|
||||
(`vold: Failed to open loop-control`), so every mount fails with state 21 (ERROR_COULD_NOT_MOUNT) and the game throws
|
||||
`RuntimeException: Can't mount OBB …`.
|
||||
|
||||
FramePort mounts nothing: the agent unpacks each FAT image on the Frame into a folder *with the image's name*
|
||||
(`obb/DataSet0.obb/` instead of the file `obb/DataSet0.obb`; agent `unpack_obb`, after the install), and this patch
|
||||
rewrites the game's OBB helper so that "mounting" reports success at once and the "mount point" is that folder, i.e.
|
||||
the OBB's own path. The folder keeps the checks that look at the file working (File.exists(), the `.obb` count in the
|
||||
OBB folder), the storage cost is the data once (the image is removed after unpacking), and Lepton's read-only bind
|
||||
mount of the OBB folder shows it to the game unchanged.
|
||||
|
||||
Handlers: only Codemasters' `androidutils.OBBHandler` (GRID Legends; its native side, libandroid_utils.so, already
|
||||
accepts a synchronous MOUNTED callback: that is its "already mounted" path) is rewritten so far. Other mountObb users
|
||||
are detected (analysis `obb_mount`) and reported, but need their own handler entry (or a dex writer that can add
|
||||
methods). The edit is in place (Dex.rewrite_start): `mountOBB(name)` → `onMountOBBStateChanged(1 /* MOUNTED */,
|
||||
name); return`, `getMountedOBBFullPath(name)` → `return getOBBFullPath(name)`. unmountOBB stays: in Lepton
|
||||
isObbMounted() is false, so it already reports "not mounted" (23), which the game accepts.
|
||||
|
||||
Encrypted OBBs (jobb -k: PBKDF2 + Twofish, footer flag OBB_SALTED) are not supported; GRID's are plain FAT32 and the
|
||||
game passes no key. FAT is case-insensitive, the Frame's file systems are not: unpacked names keep the image's case
|
||||
(GRID's own index.txt uses the same spelling as its files).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ...analysis import elf
|
||||
from ...apk.dex import Dex
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
|
||||
MOUNTED = 1 # android.os.storage.OnObbStateChangeListener.MOUNTED
|
||||
|
||||
# class -> (mount method, mounted-path method, state callback (I,String)V, image path helper (String)String)
|
||||
HANDLERS = {
|
||||
"Landroidutils/OBBHandler;": ("mountOBB", "getMountedOBBFullPath", "onMountOBBStateChanged", "getOBBFullPath"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NativeFix:
|
||||
"""Instruction words to replace in one known build of a library: the window [start, start + len) must hash to
|
||||
`sha256` (as shipped), else nothing is changed."""
|
||||
lib: str
|
||||
start: int # virtual address of the checked window
|
||||
length: int
|
||||
sha256: str
|
||||
edits: tuple[tuple[int, bytes, bytes], ...] # (vaddr, old word, new word)
|
||||
|
||||
|
||||
# GRID Legends 1.0.0.41 (libgrid.so BuildId 88834f67…): getOBBMountPoints() frees the std::string that
|
||||
# libandroid_utils' AndroidUtils::getMountedOBBFullPath() returns with libgrid's own operator delete, although
|
||||
# libandroid_utils (loaded first, by Java) allocated it with libc++'s operator new. On a Quest the mounted path is
|
||||
# short ("/mnt/obb/…", kept inside the string object: nothing is freed); the OBB's own path is longer, so the game
|
||||
# crashed in that delete (SIGSEGV at getOBBMountPoints+420, fault address = the path's text). The two branches into
|
||||
# the delete are taken out: 17 short strings are leaked once at start.
|
||||
NATIVE_FIXES = (
|
||||
NativeFix("libgrid.so", 0x14B7638, 0x100, "8ec37aeaa7fdd5b22200d183965dc4917a7875fd8aff0afe0ad75907d419e3f5",
|
||||
((0x14B769C, bytes.fromhex("d5040034"), bytes.fromhex("26000014")), # cbz w21, +0x98 -> b +0x98
|
||||
(0x14B7730, bytes.fromhex("95fbff35"), bytes.fromhex("1f2003d5")))), # cbnz w21, free -> nop
|
||||
)
|
||||
|
||||
|
||||
def native_fix(data: bytes, fix: NativeFix) -> bytes | None:
|
||||
"""The library with `fix` applied, or None when it isn't that build (or is already fixed)."""
|
||||
try:
|
||||
segments = elf.load_segments(data)
|
||||
except Exception: # noqa: BLE001 (not an ELF file)
|
||||
return None
|
||||
off = elf.vaddr_to_offset(segments, fix.start)
|
||||
if off is None or hashlib.sha256(data[off:off + fix.length]).hexdigest() != fix.sha256:
|
||||
return None
|
||||
out = bytearray(data)
|
||||
for va, old, new in fix.edits:
|
||||
pos = elf.vaddr_to_offset(segments, va)
|
||||
if pos is None or out[pos:pos + 4] != old:
|
||||
return None
|
||||
out[pos:pos + 4] = new
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def handler_classes(data: bytes) -> list[str]:
|
||||
"""Known OBB-mounting handler classes in one dex's bytes (cheap byte check, for analysis)."""
|
||||
return [c for c in HANDLERS if c.encode() in data and b"mountObb" in data]
|
||||
|
||||
|
||||
def patch_dex(dex: Dex) -> list[str]:
|
||||
"""Rewrite every known handler in this dex; returns 'Class.method' for each method changed ([] = nothing)."""
|
||||
done = []
|
||||
for cls, (mount, mounted_path, callback, image_path) in HANDLERS.items():
|
||||
cb = [i for i in dex.method_index(cls, callback) if dex.shorty(i) == "VIL"]
|
||||
path = [i for i in dex.method_index(cls, image_path) if dex.shorty(i) == "LL"]
|
||||
if not cb or not path:
|
||||
continue
|
||||
for idx, code in dex.class_methods(cls):
|
||||
name = dex.method(idx)[1]
|
||||
if name not in (mount, mounted_path) or dex.shorty(idx) != ("VL" if name == mount else "LL"):
|
||||
continue
|
||||
regs, ins, _n = dex.code_header(code)
|
||||
arg = regs - ins # static (String): the name is the last register
|
||||
if ins != 1 or regs < 2 or arg >= 16:
|
||||
continue
|
||||
if name == mount: # const/4 v0, #1; invoke-static {v0, vArg}, callback; return-void
|
||||
new = [0x0012 | (MOUNTED << 12)] + Dex.invoke_static(cb[0], [0, arg]) + [0x000E]
|
||||
else: # invoke-static {vArg}, image path; move-result-object v0; return-object v0
|
||||
new = Dex.invoke_static(path[0], [arg]) + [0x000C, 0x0011]
|
||||
if dex.rewrite_start(code, new):
|
||||
done.append(f"{cls[1:-1].replace('/', '.')}.{name}")
|
||||
return done
|
||||
|
||||
|
||||
class ObbMountEmulation(Patch):
|
||||
id = "frame.obb_mount_emul"
|
||||
title = "OBB disk images: unpack instead of mounting"
|
||||
description = ("Some games keep their data in OBB files that Android mounts as disk images (for example GRID "
|
||||
"Legends). Lepton can't mount disk images, so these games quit at start (\"Can't mount OBB\"). "
|
||||
"FramePort unpacks the images on the Frame after the install (into folders with the same names, "
|
||||
"so the data takes its space only once) and changes the game's OBB code so it uses those "
|
||||
"folders instead of mounting.")
|
||||
order = 44
|
||||
needs_vr = False
|
||||
experimental = True
|
||||
|
||||
def applies(self, a):
|
||||
return bool((a.extra or {}).get("obb_mount"))
|
||||
|
||||
def detect(self, a):
|
||||
found = (a.extra or {}).get("obb_mount") or []
|
||||
if any(c in HANDLERS for c in found):
|
||||
return Suggestion(True, "The game mounts its OBB files as disk images, which Lepton can't do: unpack "
|
||||
"them on the Frame and let the game read the folders.")
|
||||
return None
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
changed = False
|
||||
for name in sorted(n for n in ctx.ws.names() if n.startswith("classes") and n.endswith(".dex")):
|
||||
data = ctx.ws.read(name)
|
||||
if not handler_classes(data):
|
||||
continue
|
||||
dex = Dex(data)
|
||||
done = patch_dex(dex)
|
||||
if done:
|
||||
ctx.ws.put(name, dex.finish())
|
||||
ctx.notes.append(f"{name}: OBB mounting replaced by unpacked folders in {', '.join(sorted(done))}")
|
||||
changed = True
|
||||
if changed and ctx.ws.abi == "arm64-v8a":
|
||||
for fix in NATIVE_FIXES:
|
||||
if ctx.ws.has(ctx.ws.lib(fix.lib)):
|
||||
new = native_fix(ctx.ws.read_lib(fix.lib), fix)
|
||||
if new is not None:
|
||||
ctx.ws.put(ctx.ws.lib(fix.lib), new)
|
||||
ctx.notes.append(f"{fix.lib}: the mounted OBB path is no longer freed with the game's allocator")
|
||||
return changed
|
||||
|
||||
|
||||
register(ObbMountEmulation)
|
||||
@@ -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 "
|
||||
|
||||
@@ -82,6 +82,8 @@ SUMMARIES = {
|
||||
"would never finish starting).",
|
||||
"frame.unity_tls_key_pool": "Lets Unity 6 games with many native libraries start when they run out of thread keys "
|
||||
"(they'd abort with \"cannot create thread specific key\"), for example Wander.",
|
||||
"frame.obb_mount_emul": "Lets games whose data files are disk images start on the Frame (they'd quit with "
|
||||
"\"Can't mount OBB\"), for example GRID Legends. The images are unpacked on the Frame.",
|
||||
"frame.vivox_audio_route": "Lets games with Vivox voice chat start on the Frame (they'd crash on an Android 12 "
|
||||
"audio call).",
|
||||
"frame.unity_oculus_check": "Lets older Unity games start VR without Meta's system apps (they'd stay on a 2D "
|
||||
|
||||
+123
-13
@@ -137,18 +137,25 @@ def _existing_rift(folder: Path) -> dict | None:
|
||||
|
||||
|
||||
def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe: str | None = None,
|
||||
force: bool = False, art: bool = False) -> dict | None:
|
||||
force: bool = False, art: bool = False, replace: dict | None = None) -> dict | None:
|
||||
"""Analyze one Rift game folder and store it. Unchanged folders (same program, size, date) are not analyzed again;
|
||||
the user's exe choice, recipe and tags survive rescans. Returns None for folders without a VR runtime (unless
|
||||
force)."""
|
||||
force). replace: the library entry this folder is a newer version of (replace_rift_game): it keeps its package and
|
||||
name."""
|
||||
from .analysis import rift
|
||||
from .recommend import catalog
|
||||
|
||||
folder = Path(folder)
|
||||
old = _existing_rift(folder)
|
||||
if replace is None and not force and any(str(folder) in (g.get("replaced_dirs") or []) for g in library.games()
|
||||
if g.get("kind") == "rift"):
|
||||
if reporter: # the older copy of a game updated from another folder: a rescan must not bring it back
|
||||
reporter.log(f"skipped {folder.name}: an older version of a game that was updated from another folder")
|
||||
return None
|
||||
old = replace or _existing_rift(folder)
|
||||
old_extra = ((old or {}).get("analysis") or {}).get("extra") or {}
|
||||
forced = exe is not None # set_exe: always analyze with the user's choice
|
||||
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
|
||||
forced = exe is not None or replace is not None # set_exe / a newer version: always analyze
|
||||
if replace is None:
|
||||
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
|
||||
if old and not forced and old_extra.get("fingerprint") and \
|
||||
old_extra["fingerprint"] == rift.fingerprint(folder, old.get("exe") or ""):
|
||||
if reporter:
|
||||
@@ -177,7 +184,9 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
|
||||
suggested=library.recipe_to_dict(recipe))
|
||||
if not old or (old.get("recipe") or {}).get("source") != "user":
|
||||
fields.update(recipe=library.recipe_to_dict(recipe), status=recipe.status)
|
||||
if not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
|
||||
if replace is not None:
|
||||
pass # a newer version of the game: its name stays
|
||||
elif not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
|
||||
fields["title"] = recipe.title or a.label # (store-matched titles are kept)
|
||||
elif old.get("title_locked") and old.get("art_source") not in ("oculusdb", "meta", "steam"):
|
||||
fields["auto_title"] = recipe.title or a.label # the user's name stays; a reset brings this one
|
||||
@@ -400,14 +409,20 @@ def missing_obb(entry: dict) -> bool:
|
||||
return bool(extra.get("expects_obb")) and not (entry.get("data_dir") and entry.get("data_bytes") != 0)
|
||||
|
||||
|
||||
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None) -> dict:
|
||||
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None,
|
||||
replace: str | None = None) -> dict:
|
||||
"""Add a Linux app to the library (no conversion: it's installed as it is): arm64, or x86_64 (run through FEX on
|
||||
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder)."""
|
||||
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder). replace: the package of
|
||||
the library entry this is a newer version of (replace_linux_app): it keeps that package, its name and its art."""
|
||||
from .analysis import linux
|
||||
from .core.models import Recipe
|
||||
|
||||
path = Path(path)
|
||||
info = linux.inspect(path)
|
||||
if replace and not exe and not info["files"]: # a folder: the program the app started before, if it's still there
|
||||
before = (library.game(replace) or {}).get("exe")
|
||||
if before and before != info["exe"] and (Path(info["root"]) / before).is_file():
|
||||
exe = before
|
||||
if exe:
|
||||
info["exe"] = exe
|
||||
x86 = info["machine"] == linux.EM_X86_64
|
||||
@@ -417,7 +432,7 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
|
||||
if x86 and reporter:
|
||||
reporter.log(f"{path.name} is an x86_64 build: the Frame runs it through FEX (x86 translation; an arm64 "
|
||||
"build runs faster if there is one)")
|
||||
package = f"linux.{linux.slug(info['title'])}"
|
||||
package = replace or f"linux.{linux.slug(info['title'])}"
|
||||
if reporter:
|
||||
reporter.log(f"{info['title']}: program {info['exe']}{' (AppImage)' if info['appimage'] else ''}"
|
||||
f"{', OpenXR (VR)' if info['openxr'] else ''}")
|
||||
@@ -439,6 +454,8 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
|
||||
if not old:
|
||||
fields.update(title=info["title"], recipe=library.recipe_to_dict(recipe), status="unknown")
|
||||
library.upsert_game(package, **fields)
|
||||
if replace: # a newer version: the artwork stays as it is
|
||||
return library.game(package)
|
||||
try:
|
||||
from .artwork import fetch
|
||||
|
||||
@@ -580,7 +597,7 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
|
||||
reporter, x86_64=bool(extra.get("x86_64")),
|
||||
desktop_entry=entry.get("desktop_entry", True) is not False,
|
||||
desktop_icon=entry.get("desktop_icon", True) is not False,
|
||||
overlay=linux_overlay(entry))
|
||||
overlay=linux_overlay(entry), **_source_args(entry, target.label))
|
||||
if extra.get("x86_64"):
|
||||
reporter.check("x86 translation", True, "runs through FEX on SteamOS's x86 system (its libraries come from "
|
||||
"there; not checked ahead)")
|
||||
@@ -591,10 +608,23 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
|
||||
reporter.check("Libraries on the Frame", True, "everything the app needs is there")
|
||||
if add_to_library:
|
||||
target.add_to_library([package], reporter)
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
|
||||
"source_id": entry.get("source_id")})
|
||||
return result
|
||||
|
||||
|
||||
def _source_args(entry: dict, label: str) -> dict:
|
||||
"""source_id (recorded with the install) and changed_since for a Linux app / PC VR game install: after a newer
|
||||
version was added (replace_linux_app/replace_rift_game), files changed since the last install there are sent
|
||||
again even when the Frame has a file of the same size (0 = the last install is unknown: every file)."""
|
||||
sid = entry.get("source_id")
|
||||
if not sid:
|
||||
return {}
|
||||
rec = (entry.get("installs") or {}).get(label) or {}
|
||||
return {"source_id": sid,
|
||||
"changed_since": None if rec.get("source_id") == sid else float(rec.get("time") or 0)}
|
||||
|
||||
|
||||
def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
|
||||
if reporter:
|
||||
reporter.log(f"analyzing {src.apk.name}")
|
||||
@@ -662,6 +692,85 @@ def replace_apk(package: str, apk: Path, reporter: Reporter | None = None) -> di
|
||||
"new_version": new_vc}
|
||||
|
||||
|
||||
def _looks_different(before: str | None, now: str | None) -> bool:
|
||||
"""A newer version whose name has little in common with the old one's (maybe another app): the caller warns."""
|
||||
import difflib
|
||||
|
||||
a, b = _norm_title(before or ""), _norm_title(now or "")
|
||||
if not a or not b or a in b or b in a:
|
||||
return False
|
||||
return difflib.SequenceMatcher(None, a, b).ratio() < 0.5
|
||||
|
||||
|
||||
def _mark_source_replaced(package: str, **fields) -> dict:
|
||||
"""A Linux app or PC VR game got a newer version: a new source id, which installs record (in the deployment's
|
||||
recipe); an install without it shows the game as outdated (ui.components.source_replaced)."""
|
||||
import uuid
|
||||
|
||||
return library.upsert_game(package, source_id=uuid.uuid4().hex[:12], source_updated=time.time(), **fields)
|
||||
|
||||
|
||||
def replace_linux_app(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
|
||||
"""A newer version of a Linux app already in the library (GitHub #161): an AppImage/archive or a folder, analysed
|
||||
like "Add a Linux app…" (program, arm64/x86_64, AppImage, OpenXR). The app keeps its package (so its Steam
|
||||
shortcut on the Frame), name, artwork, tags and switches (Desktop Mode entry, overlay app); a folder keeps the
|
||||
program chosen before if it's still there. Returns {"entry", "different": its name looks like another app's,
|
||||
"name": the new version's own name}."""
|
||||
entry = library.game(package)
|
||||
if entry is None or not is_linux(entry):
|
||||
raise ValueError("only Linux apps can get a newer version this way")
|
||||
before = ((entry.get("analysis") or {}).get("label")) or entry.get("title")
|
||||
old_source = ((entry.get("analysis") or {}).get("extra") or {}).get("source")
|
||||
if reporter:
|
||||
reporter.log(f"reading {Path(path).name}")
|
||||
stored = add_linux_app(path, reporter, replace=package)
|
||||
name = (stored.get("analysis") or {}).get("label")
|
||||
stored = _mark_source_replaced(package, replaced_source=old_source)
|
||||
return {"entry": stored, "different": _looks_different(before, name), "name": name}
|
||||
|
||||
|
||||
def replace_rift_game(package: str, folder: Path | str, reporter: Reporter | None = None) -> dict:
|
||||
"""A newer version of a PC VR (Rift) game already in the library (GitHub #161): its new game folder, analysed like
|
||||
a scanned one (programs, VR runtimes, fingerprint). The game keeps its package, name, artwork, tags and the user's
|
||||
own recipe; a program the user confirmed stays if the new folder has it at the same place, else FramePort picks
|
||||
one again (exe_confirmed False when that's unclear: the caller asks). The old folder is left out of later scans.
|
||||
Returns {"entry", "different", "name", "exe_changed"}."""
|
||||
entry = library.game(package)
|
||||
if entry is None or not is_rift(entry):
|
||||
raise ValueError("only PC VR games can get a newer version folder")
|
||||
folder = Path(folder)
|
||||
if not folder.is_dir():
|
||||
raise ValueError(f"{folder} isn't a folder")
|
||||
other = _existing_rift(folder)
|
||||
if other and other["package"] != package:
|
||||
raise ValueError(f"{folder.name} is already in the library as {other.get('title') or other['package']}")
|
||||
before = (entry.get("analysis") or {}).get("label") or entry.get("title")
|
||||
old_dir, old_exe = entry.get("game_dir"), entry.get("exe")
|
||||
keep = old_exe if entry.get("exe_confirmed") and old_exe and (folder / old_exe).is_file() else None
|
||||
stored = add_rift_game(folder, reporter, exe=keep, force=True, replace=entry)
|
||||
if stored is None:
|
||||
raise ValueError(f"no game program found in {folder.name}")
|
||||
replaced = [d for d in (entry.get("replaced_dirs") or []) + [old_dir] if d and d != str(folder)]
|
||||
# build=None: the next install checks the new folder first (prepare_rift); the exe's checksum comes from there
|
||||
stored = _mark_source_replaced(package, build=None, replaced_dirs=list(dict.fromkeys(replaced)))
|
||||
name = (stored.get("analysis") or {}).get("label")
|
||||
return {"entry": stored, "different": _looks_different(before, name), "name": name,
|
||||
"exe_changed": stored.get("exe") != old_exe}
|
||||
|
||||
|
||||
def replace_source(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
|
||||
"""A newer version of any game in the library: an APK (Quest/Android), an app file or folder (Linux) or a game
|
||||
folder (PC VR). See replace_apk, replace_linux_app, replace_rift_game."""
|
||||
entry = library.game(package)
|
||||
if entry is None:
|
||||
raise ValueError(f"{package} isn't in the library")
|
||||
if is_linux(entry):
|
||||
return replace_linux_app(package, path, reporter)
|
||||
if is_rift(entry):
|
||||
return replace_rift_game(package, path, reporter)
|
||||
return replace_apk(package, Path(path), reporter)
|
||||
|
||||
|
||||
def _analyze_entry(entry: dict, reporter: Reporter | None = None):
|
||||
src = source_of(entry)
|
||||
if reporter:
|
||||
@@ -1275,10 +1384,11 @@ def install_rift(package: str, target: Target, reporter: Reporter, add_to_librar
|
||||
result = target.install_pcvr(package, title, Path(entry["game_dir"]), entry["exe"], recipe, reporter,
|
||||
revive_dir=rdir, revive_version=revive.installed_version() if rdir else None,
|
||||
exe_sha256=entry["build"].get("sha256"), art_lookup=entry.get("quest_package"),
|
||||
overlay=rift_overlay(entry, recipe))
|
||||
overlay=rift_overlay(entry, recipe), **_source_args(entry, target.label))
|
||||
if add_to_library:
|
||||
target.add_to_library([package], reporter)
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
|
||||
"source_id": entry.get("source_id")})
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ class Target(ABC):
|
||||
raise NotImplementedError(f"{self.label} can't launch games")
|
||||
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
|
||||
desktop_entry=True, overlay=None, desktop_icon=True) -> dict:
|
||||
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None,
|
||||
changed_since=None) -> dict:
|
||||
"""Install a Linux app (only the Frame runs them; x86_64 ones through FEX)."""
|
||||
raise NotImplementedError(f"{self.label} can't install Linux apps")
|
||||
|
||||
|
||||
@@ -86,14 +86,15 @@ class FrameLeptonTarget(Target):
|
||||
def install_pcvr(self, package, title, game_dir, exe, recipe, reporter, **extra):
|
||||
plan = installer.PcvrPlan(package, title, Path(game_dir), exe, recipe, extra.get("revive_dir"),
|
||||
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"),
|
||||
dest=self.install_dest(), overlay=extra.get("overlay"))
|
||||
dest=self.install_dest(), overlay=extra.get("overlay"),
|
||||
source_id=extra.get("source_id"), changed_since=extra.get("changed_since"))
|
||||
return installer.install_pcvr(self.connect().frame, plan, reporter)
|
||||
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
|
||||
desktop_entry=True, overlay=None, desktop_icon=True):
|
||||
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None, changed_since=None):
|
||||
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64,
|
||||
dest=self.install_dest(), desktop_entry=desktop_entry, overlay=overlay,
|
||||
desktop_icon=desktop_icon)
|
||||
desktop_icon=desktop_icon, source_id=source_id, changed_since=changed_since)
|
||||
return installer.install_linux(self.connect().frame, plan, reporter)
|
||||
|
||||
def drives(self) -> list[dict]:
|
||||
|
||||
@@ -191,7 +191,9 @@ class PcReviveTarget(Target):
|
||||
"tuning": dict(recipe.params("pcvr.steamvr_tuning"), enabled=True)
|
||||
if "pcvr.steamvr_tuning" in recipe.patches else None,
|
||||
"sha256": extra.get("exe_sha256"), "art_lookup": extra.get("art_lookup"),
|
||||
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source}, "time": time.time()}
|
||||
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source,
|
||||
**({"source_id": extra["source_id"]} if extra.get("source_id") else {})},
|
||||
"time": time.time()}
|
||||
exe_field, _, _ = shortcut_fields(dep)
|
||||
d = pc_dir() / package
|
||||
try: # a reinstall keeps the shortcut's appid: Steam's entry is matched by its Exe, whatever its name now
|
||||
|
||||
+58
-2
@@ -880,7 +880,7 @@ class FramePortApp:
|
||||
self.toast(msg, error=True, action=tr("Details"),
|
||||
on_action=lambda e: self.show_activity(True))
|
||||
self.refresh_view()
|
||||
if job.kind == "scan" and self.exe_queue:
|
||||
if job.kind in ("scan", "task") and self.exe_queue: # (a task: a newer PC VR game version)
|
||||
self.page.run_thread(self.next_exe_choice)
|
||||
elif job and job.active and job.package and self.route[:2] == ("game", job.package):
|
||||
pkg, transit = self.hero_transit or (None, None)
|
||||
@@ -1152,6 +1152,7 @@ class FramePortApp:
|
||||
"move": lambda e: self.move_game(pkg),
|
||||
"update_app": lambda e: self.update_app_only(pkg),
|
||||
"new_apk": lambda e: self.page.run_task(self.update_with_apk, pkg),
|
||||
"new_version": lambda e: self.page.run_task(self.update_with_version, pkg),
|
||||
"uninstall_frame": lambda e: self.uninstall(pkg, "frame"),
|
||||
"uninstall_pc": lambda e: self.uninstall(pkg, "pc"),
|
||||
"remove": lambda e: self.remove_from_library(pkg),
|
||||
@@ -1168,7 +1169,8 @@ class FramePortApp:
|
||||
settings=self.has_game_settings(g), programs=rift or (linux and bool(self.linux_programs(g))),
|
||||
selectable=lv is not None,
|
||||
media_button=C.is_media_player(g) and not rift,
|
||||
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux)
|
||||
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux,
|
||||
new_version=rift or linux)
|
||||
return menu_sections(g, st, handlers.__getitem__)
|
||||
|
||||
def open_game_menu(self, pkg: str) -> None:
|
||||
@@ -1621,6 +1623,60 @@ class FramePortApp:
|
||||
return tr("{title}: newer APK added").format(title=self._title(pkg))
|
||||
self.submit(tr("Newer APK for {title}").format(title=self._title(pkg)), run, pkg, "task")
|
||||
|
||||
async def update_with_version(self, pkg: str) -> None:
|
||||
"""Game menu "Update with a newer version…" for Linux apps and PC VR games (GitHub #161): pick the new app
|
||||
file or folder (how the app was added) / the new game folder; the game keeps its name, art, tags and switches,
|
||||
and its page then offers Update on Frame (or on this PC)."""
|
||||
from ..analysis import linux as linux_analysis
|
||||
|
||||
if self.jobs.busy_with(pkg):
|
||||
return
|
||||
g = library.game(pkg) or {}
|
||||
rift = g.get("kind") == "rift"
|
||||
title = tr("Newer version of {title}").format(title=self._title(pkg))
|
||||
if rift or self._linux_folder_app(g):
|
||||
path = await C.pick_dir(self.page, dialog_title=title)
|
||||
if not path:
|
||||
return
|
||||
else:
|
||||
paths = await C.pick_files(self.page, dialog_title=title)
|
||||
if not paths:
|
||||
return
|
||||
path = paths[0]
|
||||
if not linux_analysis.looks_like_linux_app(Path(path)):
|
||||
self.toast(tr("{name} isn't a Linux app (AppImage, Linux program or .zip/.tar archive)")
|
||||
.format(name=Path(path).name), error=True)
|
||||
return
|
||||
on_frame = self.frame_state == "connected" and \
|
||||
C.install_state(g, self.frame_info) in ("installed", "outdated")
|
||||
on_pc = rift and pkg in self.pc_installs()
|
||||
|
||||
def run(job: Job):
|
||||
job.reporter.stage(tr("Reading the new version"))
|
||||
res = pipeline.replace_source(pkg, Path(path), job.reporter)
|
||||
if res.get("different"):
|
||||
self.toast(tr("Heads-up: {name} looks like a different app than {title}.").format(
|
||||
name=res.get("name") or Path(path).name, title=self._title(pkg)))
|
||||
if rift and res["entry"].get("exe_confirmed") is False: # which program starts it? (asked after the job)
|
||||
self.exe_queue.append(pkg)
|
||||
self.open_game(pkg)
|
||||
if on_frame or on_pc:
|
||||
return tr("{title}: newer version added. Press Update to install it.").format(
|
||||
title=self._title(pkg))
|
||||
return tr("{title}: newer version added").format(title=self._title(pkg))
|
||||
self.submit(tr("Newer version for {title}").format(title=self._title(pkg)), run, pkg, "task")
|
||||
|
||||
@staticmethod
|
||||
def _linux_folder_app(g: dict) -> bool:
|
||||
"""A Linux app added from a folder (its newer version is a folder too), not from an AppImage/archive."""
|
||||
from ..analysis import linux as linux_analysis
|
||||
|
||||
extra = (g.get("analysis") or {}).get("extra") or {}
|
||||
source = Path(extra.get("source") or g.get("game_dir") or "")
|
||||
if source.exists():
|
||||
return source.is_dir()
|
||||
return not extra.get("files") and not source.name.lower().endswith(linux_analysis.ARCHIVES)
|
||||
|
||||
def updatable(self) -> list[tuple[str, str]]:
|
||||
"""[(package, "frame" | "pc")] installs with an update ready (a newer build or changed patch settings)."""
|
||||
out = []
|
||||
|
||||
@@ -178,6 +178,8 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
|
||||
# still installed (GitHub #90)
|
||||
if not dep or (dep.get("apk_present") is False and not dep.get("drive_missing")):
|
||||
return "missing"
|
||||
if source_replaced(game, dep):
|
||||
return "outdated" # a newer version of the Linux app / PC VR game was added since this install (GitHub #161)
|
||||
b = game.get("build") or {}
|
||||
if game.get("kind") not in ("linux", "rift") and b.get("apk_recipe_fp") and dep.get("configuration_fp"):
|
||||
from ..game_update import plan_update
|
||||
@@ -194,6 +196,16 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
|
||||
return "outdated" if settings_diff(game, frame_info) else "installed"
|
||||
|
||||
|
||||
def source_replaced(game: dict, dep: dict | None) -> bool:
|
||||
"""A Linux app or PC VR game got a newer version ("Update with a newer version…", GitHub #161) that this install
|
||||
(the Frame's or the PC's deployment record) doesn't have yet."""
|
||||
sid = game.get("source_id")
|
||||
if not sid or game.get("kind") not in ("linux", "rift"):
|
||||
return False
|
||||
rec = (dep or {}).get("recipe")
|
||||
return (rec.get("source_id") if isinstance(rec, dict) else None) != sid
|
||||
|
||||
|
||||
def recipe_changed(game: dict) -> bool:
|
||||
from ..patches.base import recipe_fingerprint, revised_since_unrecorded
|
||||
|
||||
@@ -216,7 +228,7 @@ def pc_outdated(g: dict, dep: dict) -> bool:
|
||||
args = game_args(recipe_from_dict(g["recipe"]))
|
||||
except Exception: # noqa: BLE001
|
||||
args = dep.get("game_args") or []
|
||||
return installed != want or list(dep.get("game_args") or []) != args
|
||||
return installed != want or list(dep.get("game_args") or []) != args or source_replaced(g, dep)
|
||||
|
||||
|
||||
def one_choice():
|
||||
|
||||
@@ -62,6 +62,9 @@ HELP: dict[str, str] = _Translated({
|
||||
"hw_video_decode": "Games with the Hardware video decoding patch play their videos on the Frame's video hardware. "
|
||||
"Turn it off if videos misbehave; games pick it up at their next start. For one game only, put "
|
||||
"FRAMEPORT_NO_HW_VIDEO=1 before %command% in its Steam launch options.",
|
||||
"ssh_keys": "FramePort logs in to the Frame with its own key. If your Frame accepts only one of your own keys, "
|
||||
"add that private key file here; fast uploads use it too. A key with a passphrase: add it to your SSH "
|
||||
"agent instead.",
|
||||
"launch_test": "Starts the game on the Frame while nobody wears the headset and reads the log: did it start and "
|
||||
"draw frames. It can't check the picture.",
|
||||
"last_session": "After you play a game on the Frame, FramePort reads that session's log: crashes, frame rate and "
|
||||
|
||||
@@ -35,6 +35,7 @@ class MenuState:
|
||||
media_button: bool = False # the game page shows "Add videos" already
|
||||
movable: bool = False # the Frame knows which drive it's on (agent v63+, microSD): Move to…
|
||||
android: bool = False # a Quest/Android game (not PC VR, not a Linux app): it can get a newer APK
|
||||
new_version: bool = False # a Linux app or PC VR game: it can get a newer version (file or folder)
|
||||
|
||||
|
||||
def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> list:
|
||||
@@ -55,6 +56,8 @@ def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> lis
|
||||
top.append((tr("Move to…"), ft.Icons.DRIVE_FILE_MOVE_ROUNDED, act("move")))
|
||||
if st.android and not job: # GitHub #161: the game keeps its name, art and recipe
|
||||
top.append((tr("Update with a newer APK…"), ft.Icons.UPGRADE_ROUNDED, act("new_apk")))
|
||||
if st.new_version and not job: # the same for Linux apps and PC VR games
|
||||
top.append((tr("Update with a newer version…"), ft.Icons.UPGRADE_ROUNDED, act("new_version")))
|
||||
if st.on_frame and not rift and not linux and not job:
|
||||
# the new build only; the Frame's game data stays (e.g. changed OBBs a normal update would replace)
|
||||
top.append((tr("Update app only…"), ft.Icons.SYSTEM_UPDATE_ALT_ROUNDED, act("update_app")))
|
||||
@@ -103,6 +106,8 @@ def quick_menu(st: MenuState, act: Callable[[str], Callable]) -> list:
|
||||
out += st.installs[:1]
|
||||
if st.android:
|
||||
out.append((tr("Update with a newer APK…"), ft.Icons.UPGRADE_ROUNDED, act("new_apk")))
|
||||
if st.new_version:
|
||||
out.append((tr("Update with a newer version…"), ft.Icons.UPGRADE_ROUNDED, act("new_version")))
|
||||
if st.settings:
|
||||
out.append((tr("Game settings…"), ft.Icons.TUNE_ROUNDED, act("settings")))
|
||||
out.append((tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, act("rename")))
|
||||
|
||||
@@ -131,9 +131,55 @@ class SettingsView:
|
||||
C.meta(tr("Large files upload together using OpenSSH when available. "
|
||||
"Default: 3. Lower this on a busy network.")),
|
||||
C.ghost(tr("Remove converted copies now"), ft.Icons.CLEANING_SERVICES_OUTLINED, clean,
|
||||
tooltip=tr("Your own game files aren't touched."))],
|
||||
tooltip=tr("Your own game files aren't touched.")),
|
||||
ft.Container(height=T.S2), self.ssh_keys()],
|
||||
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
def ssh_keys(self) -> ft.Control:
|
||||
"""Settings → Installing: private key files FramePort logs in with besides its own (frame.connection
|
||||
EXTRA_KEYS_SETTING; the Frame connection and OpenSSH uploads both use them, GitHub #242)."""
|
||||
from ...frame import connection
|
||||
|
||||
app = self.app
|
||||
rows = ft.Column(spacing=T.S1)
|
||||
|
||||
def fill():
|
||||
items = []
|
||||
for path, problem in connection.extra_key_status():
|
||||
items.append(ft.Row([
|
||||
C.as_icon(ft.Icons.KEY_ROUNDED if not problem else ft.Icons.WARNING_AMBER_ROUNDED, T.px(18),
|
||||
T.TEXT_2 if not problem else T.WARN),
|
||||
ft.Column([C.body(path.name, T.TEXT), C.meta(problem or str(path), selectable=True)],
|
||||
spacing=0, expand=True),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Stop using this key (the file stays)"),
|
||||
on_click=lambda e, p=path: remove(p)),
|
||||
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER))
|
||||
rows.controls = items or [C.meta(tr("Only FramePort's own key, and your SSH agent's keys."))]
|
||||
C.update(rows)
|
||||
|
||||
def remove(path):
|
||||
connection.remove_extra_key(path)
|
||||
fill()
|
||||
|
||||
async def add(e):
|
||||
for path in await C.pick_files(app.page, dialog_title=tr("Private SSH key file (not the .pub file)"),
|
||||
multiple=True):
|
||||
try:
|
||||
stored = connection.add_extra_key(path)
|
||||
except connection.KeyFileError as exc:
|
||||
app.toast(tr("That key can't be used: {error}").format(error=exc), error=True)
|
||||
continue
|
||||
app.toast(tr("FramePort also logs in with {name} from the next connection.").format(name=stored.name))
|
||||
fill()
|
||||
|
||||
fill()
|
||||
return ft.Column([
|
||||
ft.Row([C.body(tr("SSH keys"), T.TEXT, weight=ft.FontWeight.W_500), C.help_icon("ssh_keys")],
|
||||
spacing=T.px(4), vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
rows,
|
||||
C.secondary(tr("Add a key file…"), ft.Icons.VPN_KEY_ROUNDED, add),
|
||||
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
def updates_card(self) -> ft.Control:
|
||||
"""Settings → Updates: FramePort's own updates (ui/updater.py, frameport/updates.py)."""
|
||||
import time
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Synthetic evaluator tests: no APKs, snapshots, git history, or network needed."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core.library import analysis_from_dict
|
||||
from frameport.patches import base
|
||||
from frameport.recommend import catalog, engine
|
||||
|
||||
script = Path(__file__).parents[1] / "scripts/eval_heuristics.py"
|
||||
spec = importlib.util.spec_from_file_location("eval_heuristics", script)
|
||||
ev = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(ev)
|
||||
|
||||
|
||||
def _analysis(**kwargs):
|
||||
fields = dict(package="com.example.game", engine="Unity", xr="OpenXR", abis=["arm64-v8a"],
|
||||
libs=[], extra={}, graphics="Vulkan")
|
||||
fields.update(kwargs)
|
||||
return analysis_from_dict(fields)
|
||||
|
||||
|
||||
def _entry(**kwargs):
|
||||
return catalog.CatalogEntry(**(dict(package="com.example.game", title="Example", status="works") | kwargs))
|
||||
|
||||
|
||||
def _patch(monkeypatch, pid, *, category="frame", default=False, applies=True, recommended=True,
|
||||
a32=None, requires=(), conflicts=()):
|
||||
base.load_all()
|
||||
p = base.Patch()
|
||||
p.id, p.title, p.category, p.default_on = pid, pid, category, default
|
||||
p.a32, p.requires, p.conflicts = a32, requires, conflicts
|
||||
p.experimental, p.strict = True, True
|
||||
p.applies = lambda _: applies
|
||||
params = {"value": 3} if category == "adapter" else {}
|
||||
p.detect = lambda _: base.Suggestion(recommended, f"reason for {pid}", params)
|
||||
monkeypatch.setitem(base.REGISTRY, pid, p)
|
||||
return p
|
||||
|
||||
|
||||
def test_catalog_signature_is_catalog_only(monkeypatch):
|
||||
def forbidden(*args, **kwargs):
|
||||
pytest.fail("catalog signature must not call suggest")
|
||||
|
||||
monkeypatch.setattr(engine, "suggest", forbidden)
|
||||
a = _analysis()
|
||||
defaults = ev.expected_defaults(a)
|
||||
entry = _entry(overport_extra=["patch_remove_unreal_force_quit"], overport_remove=["patch_copy_libraries"],
|
||||
frame=["frame.unity_text_input"], frame_remove=["frame.adapter"],
|
||||
adapter={"scene_emul": 0, "scale": 1.5}, device=["device.text_input_window"],
|
||||
device_files={"user.cfg": "r_vsync=0"}, alt_overport=["patch_fix_ue4_crash"], use_alt=True,
|
||||
foveation="fixed", foveation_radius=0.5, foveation_test=True,
|
||||
lepton_env={"X": "1"}, config_sync={"user.cfg": {"width": "w"}})
|
||||
want = ev.catalog_signature(entry, defaults)
|
||||
assert want["overport"] == ["patch_remove_unreal_force_quit"]
|
||||
assert want["overport_removed"] == ["patch_copy_libraries"]
|
||||
assert want["frame"] == ["frame.unity_text_input"]
|
||||
assert want["frame_removed"] == ["frame.adapter"]
|
||||
assert want["adapter"] == {"scene_emul": 0, "scale": 1.5}
|
||||
assert want["device"] == ["device.text_input_window"]
|
||||
assert want["device_files"] == ["user.cfg"]
|
||||
assert want["alt"] == ["patch_fix_ue4_crash"]
|
||||
assert want["use_alt"] is True
|
||||
assert want["lepton_env"] == {"X": "1"}
|
||||
assert want["config_sync"] == {"user.cfg": {"width": "w"}}
|
||||
assert want["foveation"] == {"mode": "fixed", "radius": 0.5, "test": True}
|
||||
assert not want["status_unsupported"]
|
||||
|
||||
|
||||
def test_mask_conditional_defaults_and_score_explicit_removals():
|
||||
a = _analysis()
|
||||
defaults = ev.expected_defaults(a)
|
||||
assert "frame.ltw_depth" not in defaults
|
||||
assert "frame.vk_sanitize" not in defaults
|
||||
assert "frame.adapter" in defaults
|
||||
r = ev.catalog_recipe(_entry(), defaults)
|
||||
assert ev.signature(r, defaults)["frame_removed"] == []
|
||||
removed = ev.catalog_signature(_entry(frame_remove=["frame.adapter", "frame.ltw_depth"]), defaults)
|
||||
assert removed["frame_removed"] == ["frame.adapter"]
|
||||
scored = ev.score_signatures(removed, ev.signature(r, defaults))["headline"]
|
||||
assert scored["missing"] == {"remove:frame.adapter": True}
|
||||
assert not scored["exact"]
|
||||
r.patches.pop("frame.adapter")
|
||||
dropped = ev.score_signatures(ev.catalog_signature(_entry(), defaults), ev.signature(r, defaults))["headline"]
|
||||
assert dropped["extra"] == {"remove:frame.adapter": True}
|
||||
|
||||
|
||||
def test_catalog_adapter_does_not_inherit_heuristics():
|
||||
a = _analysis(libs=["libOVRPlugin.so"])
|
||||
got = engine.suggest(a, use_catalog=False)
|
||||
assert "adapter.haptic_fix" in got.patches
|
||||
assert ev.catalog_signature(_entry(), ev.expected_defaults(a))["adapter"] == {}
|
||||
|
||||
|
||||
def test_scorer_values_counts_and_exact():
|
||||
want = ev.catalog_signature(_entry(frame=["frame.unity_text_input"], adapter={"scene_emul": 1}))
|
||||
got = ev.catalog_signature(_entry(frame=["frame.unity_text_input", "frame.hw_video_decode"],
|
||||
adapter={"scene_emul": 2}))
|
||||
score = ev.score_signatures(want, got)["headline"]
|
||||
assert not score["exact"]
|
||||
assert score["per_patch"]["frame.unity_text_input"] == ev._rates(1, 0, 0)
|
||||
assert score["per_patch"]["frame.hw_video_decode"] == ev._rates(0, 1, 0)
|
||||
assert score["per_patch"]["adapter.scene_emul"] == ev._rates(0, 1, 1)
|
||||
assert score["missing"] == {"adapter.scene_emul": 1}
|
||||
assert score["extra"] == {"adapter.scene_emul": 2, "frame.hw_video_decode": True}
|
||||
same = ev.score_signatures(want, want)
|
||||
assert same["headline"]["exact"] and same["runtime"]["exact"]
|
||||
assert same["headline"]["per_patch"]["adapter.scene_emul"] == ev._rates(1, 0, 0)
|
||||
|
||||
|
||||
def test_runtime_separate_from_headline():
|
||||
want = ev.catalog_signature(_entry(adapter={"scale": 1.5, "sync_guard": 1}, use_alt=True,
|
||||
lepton_env={"X": "Y"}, config_sync={"foo": {"w": "w"}}))
|
||||
got = ev.catalog_signature(_entry())
|
||||
score = ev.score_signatures(want, got)
|
||||
assert score["headline"]["exact"]
|
||||
assert not score["headline"]["missing"]
|
||||
assert not score["runtime"]["exact"]
|
||||
assert score["runtime"]["per_patch"]["adapter.scale"] == ev._rates(0, 0, 1)
|
||||
assert score["runtime"]["missing"]["use_alt"]
|
||||
|
||||
|
||||
def test_aggregate_excludes_unsupported_and_breaks_down_engines():
|
||||
want = ev.catalog_signature(_entry(frame=["frame.unity_text_input"]))
|
||||
empty = ev.catalog_signature(_entry())
|
||||
rows = [dict(package="com.a", status="works", engine="Unity", converted=True, want=want, got=want),
|
||||
dict(package="com.b", status="issues", engine="Unreal", want=want, got=empty),
|
||||
dict(package="com.c", status="unsupported", engine="Other", want=want, got=empty)]
|
||||
result = ev.score_records(rows)
|
||||
assert result["headline"]["games"] == 2
|
||||
assert result["headline"]["exact"] == 1
|
||||
assert result["headline"]["exact_ratio"] == 0.5
|
||||
assert result["headline"]["patches"] == ev._rates(1, 0, 1)
|
||||
assert result["headline"]["fields"]["frame"] == {"matched": 1, "total": 2}
|
||||
assert result["runtime"]["exact"] == 2
|
||||
assert result["excluded_unsupported"] == 1
|
||||
assert result["unsupported"]["headline"]["patches"] == ev._rates(0, 0, 1)
|
||||
assert result["by_engine"]["Unreal"]["headline"]["patches"] == ev._rates(0, 0, 1)
|
||||
assert result["converted_games"] == 1
|
||||
assert len(result["games"]) == 3
|
||||
|
||||
|
||||
def test_split_deterministic_stratified_and_disjoint():
|
||||
engines = {f"com.example.{name}{i}": name for name in ("Unity", "Unreal", "Other") for i in range(10)}
|
||||
assignments = ev.split_packages(engines)
|
||||
assert assignments == ev.split_packages(dict(reversed(list(engines.items()))))
|
||||
for name in ("Unity", "Unreal", "Other"):
|
||||
mine = [pkg for pkg in engines if engines[pkg] == name]
|
||||
ranked = sorted(mine, key=lambda p: ev.hashlib.sha256(p.encode()).digest())
|
||||
assert {p for p in mine if assignments[p] == "holdout"} == set(ranked[:2])
|
||||
dev = {p for p, s in assignments.items() if s == "dev"}
|
||||
holdout = {p for p, s in assignments.items() if s == "holdout"}
|
||||
assert not dev & holdout
|
||||
assert dev | holdout == engines.keys()
|
||||
assert ev.split_packages({}) == {}
|
||||
assert ev.split_packages({"com.one": "CryEngine"}) == {"com.one": "dev"}
|
||||
|
||||
|
||||
def test_fit_selects_only_recommended_opt_ins_not_listed_or_removed(monkeypatch):
|
||||
_patch(monkeypatch, "frame.eval_candidate", conflicts=("frame.adapter",), requires=("frame.eval_unrecommended",))
|
||||
_patch(monkeypatch, "frame.eval_default", default=True)
|
||||
_patch(monkeypatch, "frame.eval_unrecommended", recommended=False)
|
||||
_patch(monkeypatch, "frame.eval_inapplicable", applies=False)
|
||||
_patch(monkeypatch, "frame.eval_listed")
|
||||
_patch(monkeypatch, "frame.eval_removed")
|
||||
_patch(monkeypatch, "frame.eval_alternate")
|
||||
_patch(monkeypatch, "adapter.eval_setting", category="adapter")
|
||||
_patch(monkeypatch, "pcvr.eval_wrong_platform", category="pcvr")
|
||||
entry = _entry(frame=["frame.eval_listed"], frame_remove=["frame.eval_removed"],
|
||||
alt_overport=["frame.eval_alternate"], adapter={"eval_setting": 9})
|
||||
candidates = {r["patch"]: r for r in ev.fit_candidates(_analysis(), entry)}
|
||||
assert "frame.eval_candidate" in candidates
|
||||
assert not set(candidates) & {"frame.eval_default", "frame.eval_unrecommended", "frame.eval_inapplicable",
|
||||
"frame.eval_listed", "frame.eval_removed", "frame.eval_alternate",
|
||||
"adapter.eval_setting", "pcvr.eval_wrong_platform"}
|
||||
c = candidates["frame.eval_candidate"]
|
||||
assert c["reason"] == "reason for frame.eval_candidate"
|
||||
assert c["category"] == "frame" and c["experimental"] and c["strict"]
|
||||
assert any("conflicts" in w for w in c["warnings"])
|
||||
|
||||
|
||||
def test_fit_uses_a32_and_flat_rules(monkeypatch):
|
||||
_patch(monkeypatch, "frame.eval_64_only", a32=False)
|
||||
_patch(monkeypatch, "frame.eval_32_ok", a32=True)
|
||||
candidates = {r["patch"] for r in ev.fit_candidates(_analysis(abis=["armeabi-v7a"]), _entry())}
|
||||
assert "frame.eval_32_ok" in candidates
|
||||
assert "frame.eval_64_only" not in candidates
|
||||
assert "frame.a32_bridge" not in candidates # implied for 32-bit entries, as engine._a32_predates_bridge does
|
||||
flat = {r["patch"] for r in ev.fit_candidates(_analysis(extra={"vr_kind": "none"}), _entry())}
|
||||
assert "frame.eval_32_ok" not in flat # needs_vr defaults True, removed by engine._non_quest
|
||||
|
||||
|
||||
def test_fit_metadata_dates_and_converted(monkeypatch):
|
||||
monkeypatch.setattr(ev, "fit_candidates", lambda a, e: [dict(patch="frame.new", category="frame",
|
||||
reason="x", experimental=False, strict=False,
|
||||
warnings=[])])
|
||||
entries = {"com.a": _entry(package="com.a", verified={"date": "2026-01-01"}, updated="2026-02-01"),
|
||||
"com.b": _entry(package="com.b", status="unsupported", engine="Other", xr="VrApi")}
|
||||
report = ev.fit_report({"com.a": {"analysis": _analysis(package="com.a").to_dict(), "converted": True}}, entries,
|
||||
{"frame.new": "2026-03-01"})
|
||||
assert report["games"][0]["candidates"][0]["newer_patch"] is True
|
||||
assert report["games"][0]["entry_date"] == "2026-01-01" # verified, not updated
|
||||
assert report["games"][0]["converted"]
|
||||
assert report["metadata_only"][0]["package"] == "com.b"
|
||||
assert report["metadata_only"][0]["xr"] == "VrApi"
|
||||
assert report["totals_by_patch"] == {"frame.new": 1}
|
||||
md = ev.render_fit_report(report)
|
||||
assert "metadata only" in md and "com.b" in md and "frame.new" in md
|
||||
|
||||
|
||||
def test_snapshot_copied_before_read_and_retried(tmp_path, monkeypatch):
|
||||
source = tmp_path / "snapshot.json"
|
||||
source.write_text('{"complete": true}')
|
||||
calls = []
|
||||
real_copy = ev.shutil.copyfile
|
||||
|
||||
def copying(src, dest):
|
||||
calls.append((src, dest))
|
||||
real_copy(src, dest)
|
||||
if len(calls) == 1:
|
||||
dest.write_text('{"partial":')
|
||||
|
||||
monkeypatch.setattr(ev.shutil, "copyfile", copying)
|
||||
monkeypatch.setattr(ev.time, "sleep", lambda _: None)
|
||||
assert ev.read_snapshot(source) == {"complete": True}
|
||||
assert len(calls) == 2
|
||||
assert all(src == source and dest != source for src, dest in calls)
|
||||
assert json.loads(source.read_text()) == {"complete": True}
|
||||
|
||||
|
||||
def test_intro_dates_cached_and_adapter_searches_bare_key(tmp_path, monkeypatch):
|
||||
calls = []
|
||||
patch = base.get("adapter.scene_emul")
|
||||
monkeypatch.setattr(base, "all_patches", lambda: [patch])
|
||||
|
||||
def log(args, **kwargs):
|
||||
calls.append(args)
|
||||
return type("Result", (), {"stdout": "2026-01-02\n2026-02-03\n"})()
|
||||
|
||||
monkeypatch.setattr(ev.subprocess, "run", log)
|
||||
cache = tmp_path / "patch-intro-dates.json"
|
||||
assert ev.patch_intro_dates(cache) == {"adapter.scene_emul": "2026-01-02"}
|
||||
assert '-S"scene_emul"' in calls[0]
|
||||
assert calls[0][-1] == "src/frameport/patches/settings.py"
|
||||
assert ev.patch_intro_dates(cache) == {"adapter.scene_emul": "2026-01-02"}
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_legacy_dumps_cli_remains_available(tmp_path, monkeypatch, capsys):
|
||||
a = _analysis()
|
||||
snapshot = {a.package: {"analysis": a.to_dict()}}
|
||||
entry = _entry()
|
||||
calls = []
|
||||
monkeypatch.setattr(ev, "read_dumps", lambda p: calls.append(p) or snapshot)
|
||||
monkeypatch.setattr(catalog, "_load_dir", lambda *args: {a.package: entry})
|
||||
monkeypatch.setattr(ev.sys, "argv", ["eval_heuristics.py", str(tmp_path), "--verbose"])
|
||||
assert ev.main() in (0, 1) # a difference exit is compatible with the original CLI
|
||||
assert calls == [tmp_path]
|
||||
output = capsys.readouterr().out
|
||||
assert "Split: dev" in output and a.package in output
|
||||
|
||||
|
||||
def test_snapshot_fit_cli_writes_markdown_and_json(tmp_path, monkeypatch):
|
||||
a = _analysis()
|
||||
snapshot = tmp_path / "synthetic.json"
|
||||
snapshot.write_text(json.dumps({a.package: {"analysis": a.to_dict(), "converted": True}}))
|
||||
md, js = tmp_path / "fit.md", tmp_path / "fit.json"
|
||||
monkeypatch.setattr(catalog, "_load_dir", lambda *args: {a.package: _entry()})
|
||||
monkeypatch.setattr(ev, "patch_intro_dates", lambda p: {})
|
||||
monkeypatch.setattr(ev.quest_dump, "scan", lambda *a: pytest.fail("must not scan with --snapshot"))
|
||||
monkeypatch.setattr(ev.sys, "argv", ["eval_heuristics.py", "--snapshot", str(snapshot), "--fit-report",
|
||||
"--out", str(md), "--json", str(js)])
|
||||
assert ev.main() == 0
|
||||
assert "# New patch fit" in md.read_text()
|
||||
assert json.loads(js.read_text())["games"][0]["converted"]
|
||||
|
||||
|
||||
def test_evaluate_split_coverage_and_no_snapshot_analysis_mutation():
|
||||
snapshot = {f"com.game.n{i}": {"analysis": _analysis(package=f"com.game.n{i}").to_dict(), "converted": i == 0}
|
||||
for i in range(10)}
|
||||
entries = {p: _entry(package=p) for p in snapshot}
|
||||
before = json.dumps(snapshot, sort_keys=True)
|
||||
reports = {s: ev.evaluate(snapshot, entries, s) for s in ("all", "dev", "holdout")}
|
||||
assert reports["dev"]["total_games"] == 8
|
||||
assert reports["holdout"]["total_games"] == 2
|
||||
assert reports["all"]["total_games"] == 10
|
||||
assert json.dumps(snapshot, sort_keys=True) == before
|
||||
assert reports["all"]["converted_games"] == 1
|
||||
|
||||
|
||||
def test_catalog_truth_applies_the_a32_bridge_transform():
|
||||
"""A 32-bit game's entry relies on the bridge switching OVRPort and the 64-bit fixes off; the heuristics do the
|
||||
same, so neither side may count those as removals."""
|
||||
a = _analysis(abis=["armeabi-v7a"], engine="Unreal", graphics="GLES")
|
||||
defaults = ev.expected_defaults(a)
|
||||
want = ev.catalog_signature(_entry(), defaults, a)
|
||||
got = ev.signature(engine.suggest(a, use_catalog=False), defaults)
|
||||
assert want["overport_removed"] == got["overport_removed"]
|
||||
assert want["frame_removed"] == got["frame_removed"]
|
||||
|
||||
|
||||
def test_dated_score_sets_aside_patches_newer_than_the_entry():
|
||||
want = ev.catalog_signature(_entry(frame=["frame.unity_text_input"]))
|
||||
got = ev.catalog_signature(_entry(frame=["frame.unity_text_input", "frame.hw_video_decode"]))
|
||||
strict = ev.score_signatures(want, got)["headline"]
|
||||
dated = ev.score_signatures(want, got, {"frame.hw_video_decode"})["headline"]
|
||||
assert strict["extra"] and not strict["exact"]
|
||||
assert dated["exact"] and dated["gap"] == {"frame.hw_video_decode": True}
|
||||
assert "frame.hw_video_decode" not in dated["per_patch"]
|
||||
|
||||
|
||||
def test_inherited_picks_are_adapter_and_untoggled_device_patches():
|
||||
inherited = ev.inherited_decisions()
|
||||
assert "adapter.haptic_fix" in inherited
|
||||
assert "device.text_input_window" not in inherited # catalog.TOGGLED_DEVICE: dropped unless the entry names it
|
||||
assert not any(pid.startswith("frame.") for pid in inherited)
|
||||
|
||||
|
||||
def test_gap_decisions_use_the_verified_date_not_updated():
|
||||
e = _entry(verified={"date": "2026-09-28"}, updated="2026-10-14")
|
||||
gaps = ev.gap_decisions(e, {"frame.unity_text_input": "2026-10-03", "frame.adapter": "2026-09-28",
|
||||
"patch_remove_unreal_force_quit": "2026-10-01"})
|
||||
assert gaps == {"frame.unity_text_input", "alt:patch_remove_unreal_force_quit"}
|
||||
assert ev.entry_date(e) == "2026-09-28"
|
||||
+17
-13
@@ -8,6 +8,12 @@ from frameport.sources import quest_dump
|
||||
|
||||
pytestmark = pytest.mark.games
|
||||
|
||||
# 2026-10-15 full snapshot (80 dumps, 65 supported catalog games), strict score: 5/65 exact, patch F1 73.8%;
|
||||
# rounded down. The dated score (--fit-report's date cache) was 39/65, F1 88.6%. See docs/HEURISTICS_EVAL.md;
|
||||
# runtime-only choices and unsupported catalog entries are excluded.
|
||||
MIN_HEURISTICS_EXACT_RATIO = 0.07
|
||||
MIN_HEURISTICS_PATCH_F1 = 0.73
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def dumps():
|
||||
@@ -36,8 +42,7 @@ def test_every_catalog_game_found_and_detected(dumps):
|
||||
|
||||
|
||||
def test_heuristics_reproduce_catalog(dumps):
|
||||
"""Without the catalog, heuristics must reproduce the verified recipes (Phantom's use-the-no-ForceQuit-build is a
|
||||
runtime finding the triage makes, not a static one)."""
|
||||
"""Ratchet catalog-only static accuracy without requiring runtime findings to be detectable from APKs."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
@@ -45,16 +50,15 @@ def test_heuristics_reproduce_catalog(dumps):
|
||||
spec = importlib.util.spec_from_file_location("ev", script)
|
||||
ev = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(ev)
|
||||
exact = total = 0
|
||||
snapshot = {}
|
||||
for g in dumps:
|
||||
a = analyze(g.apk, data_bytes=g.data_bytes())
|
||||
entry = catalog.lookup(a.package)
|
||||
if not entry:
|
||||
continue
|
||||
from frameport.recommend import engine
|
||||
|
||||
want, got = ev.signature(engine.suggest(a)), ev.signature(engine.suggest(a, use_catalog=False))
|
||||
want["status_unsupported"] = got["status_unsupported"] = False
|
||||
total += 1
|
||||
exact += want == got
|
||||
assert exact >= total - 1, f"only {exact}/{total} catalog recipes reproduced by heuristics"
|
||||
row = {"analysis": a.to_dict(), "converted": a.is_overport_output}
|
||||
if a.package not in snapshot or snapshot[a.package]["converted"] and not row["converted"]:
|
||||
snapshot[a.package] = row
|
||||
entries = catalog._load_dir(script.parents[1] / "catalog/games", "bundled")
|
||||
result = ev.evaluate(snapshot, entries, split="all")["headline"]
|
||||
assert result["games"], "no supported catalog games found in the dumps"
|
||||
assert result["exact_ratio"] >= MIN_HEURISTICS_EXACT_RATIO, (
|
||||
f"only {result['exact']}/{result['games']} static catalog recipes reproduced exactly")
|
||||
assert result["patches"]["f1"] >= MIN_HEURISTICS_PATCH_F1, result["patches"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Adapter recommendations use the detected runtime path, without a catalog."""
|
||||
import pytest
|
||||
from test_patches import _analysis
|
||||
|
||||
from frameport.patches import base
|
||||
from frameport.recommend.engine import suggest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("extra", "other_libs", "converted", "expected"), [
|
||||
# Built-in Oculus has the system-app check; modern Unity OpenXR can still ship OVRPlugin.
|
||||
({"unity_oculus_check": True}, [], False, True),
|
||||
({"unity_oculus_check": False}, ["libUnityOpenXR.so"], False, False),
|
||||
({"unity_oculus_check": False}, [], False, False),
|
||||
# Neither the version nor the graphics API is the reason to select this fix.
|
||||
({"unity_oculus_check": False, "unity_version": "2019.4.20f1"}, [], False, False),
|
||||
({"unity_oculus_check": True, "unity_version": "2019.4.20f1"}, [], False, True),
|
||||
# The built-in check can coexist with XR Plugin Management: retain both exclusions.
|
||||
({"unity_oculus_check": True, "oculus_xr_plugin": True}, [], False, False),
|
||||
({"unity_oculus_check": True}, ["libOculusXRPlugin.so"], False, False),
|
||||
# Old analyses have no check signal. Converted builds may have the check patched out.
|
||||
({}, [], False, True),
|
||||
({"unity_oculus_check": False}, [], True, True),
|
||||
({"unity_oculus_check": False}, ["libOculusXRPlugin.so"], True, False),
|
||||
])
|
||||
def test_pose_time_fix_uses_builtin_oculus_evidence(extra, other_libs, converted, expected):
|
||||
a = _analysis(libs=["libunity.so", "libOVRPlugin.so", *other_libs],
|
||||
extra=extra, is_overport_output=converted)
|
||||
patch = base.get("adapter.pose_time_fix")
|
||||
detected = patch.detect(a)
|
||||
assert patch.applies(a) # still available for manual investigation in any OVRPlugin game
|
||||
assert bool(detected and detected.recommended) is expected
|
||||
recipe = suggest(a, use_catalog=False)
|
||||
assert (patch.id in recipe.patches) is expected
|
||||
if expected:
|
||||
assert recipe.patches[patch.id] == {"value": 1}
|
||||
# The new evidence is specific to pose timing: OVRPlugin's haptic bug affects both paths.
|
||||
assert recipe.patches["adapter.haptic_fix"] == {"value": 1}
|
||||
|
||||
|
||||
def test_pose_time_fix_still_requires_ovrplugin():
|
||||
a = _analysis(libs=["libunity.so"], extra={"unity_oculus_check": True}, is_overport_output=False)
|
||||
patch = base.get("adapter.pose_time_fix")
|
||||
assert not patch.applies(a)
|
||||
assert patch.detect(a) is None
|
||||
assert patch.id not in suggest(a, use_catalog=False).patches
|
||||
|
||||
|
||||
def test_unreal_pose_evidence_is_independent_of_unity():
|
||||
a = _analysis(engine="Unreal", libs=["libUE4.so", "libOVRPlugin.so"], is_overport_output=False,
|
||||
extra={"unity_oculus_check": False, "unreal_thumb_touch": True})
|
||||
assert suggest(a, use_catalog=False).patches["adapter.pose_time_fix"] == {"value": 1}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Unity heuristics from executable paths and tool support, without catalog/package matching."""
|
||||
import pytest
|
||||
from test_patches import _analysis
|
||||
|
||||
from frameport.patches import base
|
||||
from frameport.recommend.engine import suggest
|
||||
|
||||
IL2CPP_FIXES = ("frame.unity_text_input", "frame.unity_runtime_msaa_off")
|
||||
|
||||
|
||||
def _il2cpp(**changes):
|
||||
values = dict(libs=["libunity.so", "libil2cpp.so", "libOVRPlugin.so"], graphics="GLES",
|
||||
extra={"unity_version": "2019.4.10f1", "text_fields": ["TMP_InputField"],
|
||||
"ovr_runtime_msaa": True})
|
||||
values.update(changes)
|
||||
return _analysis(**values)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pid", IL2CPP_FIXES)
|
||||
@pytest.mark.parametrize("version", ["2017.4.23f1", "2017.1.0f1", "5.6.7f1"])
|
||||
def test_unreadable_old_metadata_does_not_recommend_il2cpp_rewrites(pid, version):
|
||||
a = _il2cpp(extra={"unity_version": version, "text_fields": ["TMP_InputField"], "ovr_runtime_msaa": True})
|
||||
p = base.get(pid)
|
||||
assert p.detect(a) is None
|
||||
assert pid not in suggest(a, use_catalog=False).patches
|
||||
assert p.applies(a) # still available for an explicit recipe; only automatic detection changes
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pid", IL2CPP_FIXES)
|
||||
@pytest.mark.parametrize("version", ["2018.4.0f1", "2019.4.1f1", "2022.3.1f1", "6000.5.6f1", None, "unknown"])
|
||||
def test_supported_or_unknown_metadata_keeps_il2cpp_recommendations(pid, version):
|
||||
a = _il2cpp(extra={"unity_version": version, "text_fields": ["InputField"], "ovr_runtime_msaa": True})
|
||||
assert base.get(pid).detect(a).recommended
|
||||
assert pid in suggest(a, use_catalog=False).patches
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pid", IL2CPP_FIXES)
|
||||
@pytest.mark.parametrize("abis", [["armeabi-v7a"], ["x86_64"], []])
|
||||
def test_aarch64_return_instructions_are_not_recommended_for_other_abis(pid, abis):
|
||||
assert base.get(pid).detect(_il2cpp(abis=abis)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pid", IL2CPP_FIXES)
|
||||
def test_il2cpp_rewrites_still_need_unity_and_il2cpp(pid):
|
||||
assert base.get(pid).detect(_il2cpp(engine="Unreal")) is None
|
||||
assert base.get(pid).detect(_il2cpp(libs=["libunity.so", "libmonobdwgc-2.0.so"])) is None
|
||||
|
||||
|
||||
def test_text_classes_remain_a_signal_for_both_unity_field_implementations():
|
||||
p = base.get("frame.unity_text_input")
|
||||
for fields in (["TMP_InputField"], ["InputField"], ["TMP_InputField", "InputField"]):
|
||||
a = _il2cpp(extra={"text_fields": fields})
|
||||
assert p.detect(a).recommended
|
||||
assert base.get("device.text_input_window").detect(a).recommended
|
||||
assert p.detect(_il2cpp(extra={"text_fields": []})) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("graphics", ["Vulkan (declared in manifest)", "Vulkan (Unity boot.config)"])
|
||||
def test_runtime_msaa_does_not_guess_gles_from_a_vulkan_label(graphics):
|
||||
assert base.get("frame.unity_runtime_msaa_off").detect(_il2cpp(graphics=graphics)) is None
|
||||
|
||||
|
||||
def test_runtime_msaa_needs_the_runtime_msaa_signal():
|
||||
assert base.get("frame.unity_runtime_msaa_off").detect(_il2cpp(extra={}, unity_msaa_levels=4)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["2017.4.23f1", "2019.4.20f1", None])
|
||||
def test_built_in_oculus_path_still_needs_system_app_bypass(version):
|
||||
a = _il2cpp(extra={"unity_version": version, "unity_oculus_check": True})
|
||||
p = base.get("frame.unity_oculus_check")
|
||||
assert p.detect(a).recommended
|
||||
assert p.id in suggest(a, use_catalog=False).patches
|
||||
|
||||
|
||||
def test_oculus_xr_plugin_still_gets_the_builtin_startup_check():
|
||||
"""A verified game (com.coatsink.alone) ships libOculusXRPlugin.so and still needs the bypass."""
|
||||
a = _il2cpp(libs=["libunity.so", "libil2cpp.so", "libOVRPlugin.so", "libOculusXRPlugin.so"],
|
||||
extra={"unity_version": "2019.4.35f1", "unity_oculus_check": True})
|
||||
assert base.get("frame.unity_oculus_check").detect(a).recommended
|
||||
|
||||
|
||||
def test_oculus_metadata_field_alone_does_not_prove_a_native_plugin_path():
|
||||
a = _il2cpp(extra={"unity_oculus_check": True, "oculus_xr_plugin": True})
|
||||
assert base.get("frame.unity_oculus_check").detect(a).recommended
|
||||
|
||||
|
||||
def test_oculus_bypass_needs_both_the_check_and_ovrplugin():
|
||||
p = base.get("frame.unity_oculus_check")
|
||||
assert p.detect(_il2cpp(extra={})) is None
|
||||
assert p.detect(_il2cpp(libs=["libunity.so"], extra={"unity_oculus_check": True})) is None
|
||||
assert p.detect(_il2cpp(engine="Other", extra={"unity_oculus_check": True})) is None
|
||||
|
||||
|
||||
FIRESTORE = "libFirebaseCppFirestore.so"
|
||||
GECKO = "libxul.so"
|
||||
|
||||
|
||||
def _tls(version="6000.5.6f1", heavy=(FIRESTORE, GECKO), **changes):
|
||||
return _analysis(libs=["libunity.so", *heavy], extra={"unity_version": version}, **changes)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["6000.0.1f1", "6000.5.6f1"])
|
||||
def test_unity6_firestore_and_gecko_recommend_thread_key_pool(version):
|
||||
a = _tls(version)
|
||||
p = base.get("frame.unity_tls_key_pool")
|
||||
assert p.detect(a).recommended
|
||||
assert p.id in suggest(a, use_catalog=False).patches
|
||||
|
||||
|
||||
@pytest.mark.parametrize("heavy", [(FIRESTORE,), (GECKO,), ("libFirebaseCppApp-9_6_0.so", GECKO)])
|
||||
def test_less_thread_key_pressure_remains_an_optional_diagnostic(heavy):
|
||||
a = _tls(heavy=heavy)
|
||||
p = base.get("frame.unity_tls_key_pool")
|
||||
assert not p.detect(a).recommended
|
||||
assert p.id not in suggest(a, use_catalog=False).patches
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["2019.4.1f1", "2022.3.1f1", None, "unknown"])
|
||||
def test_thread_key_pressure_rule_requires_unity6(version):
|
||||
assert base.get("frame.unity_tls_key_pool").detect(_tls(version)) is None
|
||||
|
||||
|
||||
def test_thread_key_pool_requires_native_unity_for_arm64():
|
||||
p = base.get("frame.unity_tls_key_pool")
|
||||
assert p.detect(_tls(engine="Unreal")) is None
|
||||
assert p.detect(_tls(abis=["armeabi-v7a"])) is None
|
||||
assert p.detect(_analysis(libs=[FIRESTORE, GECKO], extra={"unity_version": "6000.5.6f1"})) is None
|
||||
|
||||
|
||||
def test_rendering_tradeoffs_stay_opt_in_without_the_runtime_symptom():
|
||||
a = _il2cpp(libs=["libunity.so", "libil2cpp.so", "libOculusXRPlugin.so"], unity_msaa_levels=4,
|
||||
extra={"oculus_xr_plugin": True, "ovr_runtime_msaa": True})
|
||||
for pid in ("frame.unity_multipass", "frame.unity_no_overlay_copy"):
|
||||
p = base.get(pid)
|
||||
assert p.applies(a) and p.detect(a) is None
|
||||
assert pid not in suggest(a, use_catalog=False).patches
|
||||
+18
-2
@@ -31,9 +31,11 @@ def _states():
|
||||
("quest not installed", QUEST, MenuState(connected=True, installs=[_install("Install on Frame")],
|
||||
settings=True, selectable=True, android=True)),
|
||||
("rift on PC", RIFT, MenuState(on_pc=True, plays=[_play("this PC")], programs=True, selectable=True,
|
||||
installs=[_install("Reinstall on this PC")])),
|
||||
installs=[_install("Reinstall on this PC")], new_version=True)),
|
||||
("linux on Frame", LINUX, MenuState(connected=True, on_frame=True, plays=[_play()], installs=[_install()],
|
||||
selectable=True)),
|
||||
selectable=True, new_version=True)),
|
||||
("linux job running", LINUX, MenuState(connected=True, on_frame=True, job=True, selectable=True,
|
||||
new_version=True)),
|
||||
("job running", QUEST, MenuState(connected=True, on_frame=True, job=True, settings=True, selectable=True,
|
||||
android=True)),
|
||||
]
|
||||
@@ -172,3 +174,17 @@ def test_newer_apk_for_quest_games_in_both_menus():
|
||||
want = g is QUEST and not st.job
|
||||
assert ("Update with a newer APK…" in quick) == want, name
|
||||
assert ("Update with a newer APK…" in full) == want, name
|
||||
|
||||
|
||||
def test_newer_version_for_linux_apps_and_pc_vr_games():
|
||||
"""GitHub #161: Linux apps and PC VR games get "Update with a newer version…" (a file or folder), not the APK
|
||||
entry, in both menus; never while a job runs."""
|
||||
for name, g, st in _states():
|
||||
quick = [e[0] for e in _items(menu_sections(g, st, _key))]
|
||||
st.quick = False
|
||||
full = [e[0] for e in _items(menu_sections(g, st, _key))]
|
||||
want = g is not QUEST and not st.job
|
||||
assert ("Update with a newer version…" in quick) == want, name
|
||||
assert ("Update with a newer version…" in full) == want, name
|
||||
if g is not QUEST:
|
||||
assert "Update with a newer APK…" not in quick + full, name
|
||||
@@ -38,8 +38,36 @@ def test_quote_refuses_batch_injection(value):
|
||||
native.quote(value)
|
||||
|
||||
|
||||
def test_quote_protects_spaces_quotes_globs():
|
||||
assert native.quote('a *?["b') == '"a \\*\\?\\[\\"b"'
|
||||
def test_quote_leaves_globs_to_sftp():
|
||||
"""Inside double quotes sftp escapes * ? [ itself: an extra backslash became part of the name (GitHub #324)."""
|
||||
assert native.quote('a *?["b') == '"a *?[\\"b"'
|
||||
assert native.quote("/x/te*s?t [ENG] \\b+(1).apk") == '"/x/te*s?t [ENG] \\\\b+(1).apk"'
|
||||
|
||||
|
||||
def test_quote_windows_local_path_uses_forward_slashes():
|
||||
"""Win32-OpenSSH turns a local path's backslashes into '/', escapes included ("C://Users", "/[ENG]")."""
|
||||
path = r"C:\Users\user_name\AppData\Local\FramePort\output\All-In-One - Sports VR [ENG] v100+1.9.0 (x)\a.apk"
|
||||
assert native.quote(path, windows=True) == (
|
||||
'"C:/Users/user_name/AppData/Local/FramePort/output/All-In-One - Sports VR [ENG] v100+1.9.0 (x)/a.apk"')
|
||||
assert native.quote(r"\\server\share\*?[a].apk", windows=True) == '"//server/share/*?[a].apk"'
|
||||
assert native.quote('C:\\odd "name"\\a.apk', windows=True) == '"C:/odd \\"name\\"/a.apk"'
|
||||
|
||||
|
||||
@pytest.mark.parametrize("os_name,expected", [("nt", '"C:/games/[ENG] a/x.apk"'),
|
||||
("posix", '"C:\\\\games\\\\[ENG] a\\\\x.apk"')])
|
||||
def test_quote_local_follows_the_platform(monkeypatch, os_name, expected):
|
||||
monkeypatch.setattr(native.os, "name", os_name)
|
||||
assert native.quote_local(r"C:\games\[ENG] a\x.apk") == expected
|
||||
|
||||
|
||||
def test_batch_line_for_bracketed_folder(tmp_path, monkeypatch):
|
||||
folder = tmp_path / "All-In-One - Sports VR [ENG] v100+1.9.0"
|
||||
folder.mkdir()
|
||||
frame, items, files, commands, processes = setup_batch(folder, monkeypatch)
|
||||
monkeypatch.setattr(native.os, "name", "posix")
|
||||
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1)
|
||||
assert sorted(commands[0].splitlines()) == [f'put "{folder}/patch.obb" "/incoming/patch.obb.part"',
|
||||
f'reput "{folder}/main.obb" "/incoming/main.obb.part"']
|
||||
|
||||
|
||||
def test_size_lookup_does_not_hide_connection_errors():
|
||||
@@ -65,10 +93,11 @@ def setup_batch(tmp_path, monkeypatch, code=0):
|
||||
monitor.stat.side_effect = stat
|
||||
monitor.remove.side_effect = lambda path: files.pop(path)
|
||||
frame = SimpleNamespace(sftp=monitor, client=SimpleNamespace(open_sftp=lambda: monitor),
|
||||
target=FrameTarget("paired"), run=Mock(return_value=(0, "", "")))
|
||||
target=FrameTarget("paired"), run=Mock(return_value=(0, "", "")), modes_seen=[])
|
||||
monkeypatch.setattr(native.shutil, "which", lambda _: "sftp")
|
||||
@contextlib.contextmanager
|
||||
def creds(frame):
|
||||
def creds(frame, relaxed=False):
|
||||
frame.modes_seen.append(relaxed)
|
||||
yield ["-o", "StrictHostKeyChecking=yes"]
|
||||
monkeypatch.setattr(native, "credentials", creds)
|
||||
commands, processes = [], []
|
||||
@@ -210,7 +239,7 @@ def setup_tar(tmp_path, monkeypatch, code=0):
|
||||
|
||||
path = tmp_path / "small.bin"
|
||||
path.write_bytes(b"payload")
|
||||
frame = SimpleNamespace(target=FrameTarget("paired.example", port=2222))
|
||||
frame = SimpleNamespace(target=FrameTarget("paired.example", port=2222), modes_seen=[])
|
||||
pipe = io.BytesIO()
|
||||
pipe.close = lambda: None
|
||||
process = Mock(stdin=pipe, returncode=code)
|
||||
@@ -219,8 +248,9 @@ def setup_tar(tmp_path, monkeypatch, code=0):
|
||||
monkeypatch.setattr(native.subprocess, "Popen", popen)
|
||||
monkeypatch.setattr(native.shutil, "which", lambda _: "ssh")
|
||||
@contextlib.contextmanager
|
||||
def creds(frame, port_flag):
|
||||
def creds(frame, port_flag, relaxed=False):
|
||||
assert port_flag == "-p"
|
||||
frame.modes_seen.append(relaxed)
|
||||
yield ["-o", "StrictHostKeyChecking=yes", port_flag, "2222"]
|
||||
monkeypatch.setattr(native, "credentials", creds)
|
||||
return frame, [(path, "nested/small.bin")], process, popen, pipe
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""GitHub #161: a newer version of a Linux app or a PC VR game already in the library (game menu "Update with a newer
|
||||
version…", CLI `frameport new-version`)."""
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from test_install_drives import FakeFrame
|
||||
from test_linux_apps import elf
|
||||
from test_rift import unity_game
|
||||
|
||||
from frameport import pipeline
|
||||
from frameport.analysis import linux
|
||||
from frameport.core import library
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.install import installer
|
||||
from frameport.ui import components as C
|
||||
|
||||
|
||||
def _appimage(path, extra=b""):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(elf(linux.EM_AARCH64, appimage=True, extra=extra))
|
||||
return path
|
||||
|
||||
|
||||
def _frame(package, recipe=None):
|
||||
return {"installed": [{"package": package, "sha256": None, "recipe": recipe}]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Linux apps
|
||||
def test_newer_appimage_keeps_name_art_and_switches(tmp_path):
|
||||
old = _appimage(tmp_path / "v1" / "Venera-Prime-2.4.2-aarch64.AppImage")
|
||||
pkg = pipeline.add_linux_app(old)["package"]
|
||||
library.upsert_game(pkg, title="My Venera", title_locked=True, tags=["mine"], desktop_entry=False,
|
||||
desktop_icon=False, vr_overlay=True)
|
||||
assert C.install_state(library.game(pkg), _frame(pkg)) == "installed" # never updated: no source id needed
|
||||
new = _appimage(tmp_path / "v2" / "Venera-Prime-2.5.0-aarch64.AppImage", extra=b"newer")
|
||||
res = pipeline.replace_linux_app(pkg, new)
|
||||
g = library.game(pkg)
|
||||
assert g["package"] == pkg and g["exe"] == new.name and g["game_dir"] == str(new.parent)
|
||||
assert g["analysis"]["extra"]["source"] == str(new) and g["analysis"]["extra"]["files"] == [new.name]
|
||||
assert g["data_bytes"] == new.stat().st_size
|
||||
assert (g["title"], g["title_locked"], g["tags"]) == ("My Venera", True, ["mine"])
|
||||
assert (g["desktop_entry"], g["desktop_icon"], g["vr_overlay"]) == (False, False, True)
|
||||
assert g["replaced_source"] == str(old) and g["source_id"] and res["different"] is False
|
||||
# the Frame's copy (installed before) is outdated until an install records this version
|
||||
assert C.install_state(g, _frame(pkg)) == "outdated"
|
||||
assert C.install_state(g, _frame(pkg, {"source_id": "older"})) == "outdated"
|
||||
assert C.install_state(g, _frame(pkg, {"source_id": g["source_id"]})) == "installed"
|
||||
|
||||
|
||||
def test_newer_linux_folder_keeps_the_chosen_program(tmp_path):
|
||||
def app(root, size):
|
||||
(root / "bin").mkdir(parents=True)
|
||||
(root / "Tool").write_bytes(elf(linux.EM_AARCH64, extra=b"\0" * size)) # bigger: FramePort's pick
|
||||
(root / "bin" / "tool-vr").write_bytes(elf(linux.EM_AARCH64))
|
||||
return root
|
||||
pkg = pipeline.add_linux_app(app(tmp_path / "v1" / "Tool", 4096), exe="bin/tool-vr")["package"]
|
||||
assert library.game(pkg)["exe"] == "bin/tool-vr"
|
||||
pipeline.replace_linux_app(pkg, app(tmp_path / "v2" / "Tool", 8192))
|
||||
g = library.game(pkg)
|
||||
assert g["exe"] == "bin/tool-vr" and g["game_dir"] == str(tmp_path / "v2" / "Tool")
|
||||
# the program isn't there any more: FramePort's pick
|
||||
(tmp_path / "v3" / "Tool").mkdir(parents=True)
|
||||
(tmp_path / "v3" / "Tool" / "Tool").write_bytes(elf(linux.EM_AARCH64))
|
||||
pipeline.replace_linux_app(pkg, tmp_path / "v3" / "Tool")
|
||||
assert library.game(pkg)["exe"] == "Tool"
|
||||
|
||||
|
||||
def test_another_linux_app_is_added_with_a_warning(tmp_path):
|
||||
pkg = pipeline.add_linux_app(_appimage(tmp_path / "Venera-Prime-1.0-aarch64.AppImage"))["package"]
|
||||
res = pipeline.replace_linux_app(pkg, _appimage(tmp_path / "Blender-4.2-aarch64.AppImage"))
|
||||
assert res["different"] and res["name"] == "Blender" and library.game(pkg)["exe"].startswith("Blender")
|
||||
assert not library.game("linux.blender") # no second entry: the user's pick is the newer version
|
||||
with pytest.raises(ValueError):
|
||||
pipeline.replace_linux_app(pkg, tmp_path / "missing.AppImage")
|
||||
|
||||
|
||||
def test_only_the_right_kind(tmp_path):
|
||||
pkg = pipeline.add_linux_app(_appimage(tmp_path / "Tool-aarch64.AppImage"))["package"]
|
||||
with pytest.raises(ValueError):
|
||||
pipeline.replace_rift_game(pkg, tmp_path)
|
||||
library.upsert_game("com.x.quest", title="Quest", apk="/x.apk")
|
||||
with pytest.raises(ValueError):
|
||||
pipeline.replace_linux_app("com.x.quest", tmp_path / "Tool-aarch64.AppImage")
|
||||
|
||||
|
||||
def test_linux_update_sends_changed_files_of_the_same_size(tmp_path, monkeypatch):
|
||||
(tmp_path / "app").mkdir()
|
||||
for name in ("tool", "data.txt"):
|
||||
(tmp_path / "app" / name).write_bytes(b"x" * 10)
|
||||
old = time.time() - 3600
|
||||
os.utime(tmp_path / "app" / "data.txt", (old, old))
|
||||
monkeypatch.setattr(installer, "upload_steam_art", lambda *a, **k: None)
|
||||
monkeypatch.setattr(installer, "apply_app_icon", lambda *a, **k: None)
|
||||
monkeypatch.setattr(installer, "_tags", lambda pkg: [])
|
||||
sent = []
|
||||
monkeypatch.setattr(installer, "upload_files", lambda xfer, files, *a: sent.extend(f[1] for f in files))
|
||||
monkeypatch.setattr(installer, "transfer_link", lambda *a: _Null())
|
||||
frame = FakeFrame({"prepare_linux": {"existing": {"app": {"tool": 10, "data.txt": 10}}, "free_bytes": 1 << 40,
|
||||
"incoming": "/x/incoming", "base": "/b"},
|
||||
"finalize_linux": {"base": "/b", "appid": 1}})
|
||||
plan = installer.LinuxPlan("linux.tool", "Tool", tmp_path / "app", "tool")
|
||||
installer.install_linux(frame, plan, Reporter())
|
||||
assert sent == [] and dict(frame.calls)["finalize_linux"]["recipe"] is None # same sizes: nothing sent
|
||||
# (st_ctime can't be set: the file's own ctime is now) a newer version: files changed since the last install
|
||||
plan = installer.LinuxPlan("linux.tool", "Tool", tmp_path / "app", "tool", source_id="v2",
|
||||
changed_since=time.time() + 60)
|
||||
installer.install_linux(frame, plan, Reporter())
|
||||
assert sent == []
|
||||
plan.changed_since = time.time() - 60
|
||||
installer.install_linux(frame, plan, Reporter())
|
||||
assert sorted(sent) == ["app/data.txt", "app/tool"]
|
||||
assert frame.calls[-1][1]["recipe"] == {"source_id": "v2"}
|
||||
|
||||
|
||||
class _Null:
|
||||
def __enter__(self):
|
||||
return None
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
|
||||
def test_install_records_the_version(tmp_path, monkeypatch):
|
||||
pkg = pipeline.add_linux_app(_appimage(tmp_path / "v1" / "Tool-aarch64.AppImage"))["package"]
|
||||
got = {}
|
||||
|
||||
class T:
|
||||
label = "Frame"
|
||||
|
||||
def install_linux(self, *a, **k):
|
||||
got.clear()
|
||||
got.update(k)
|
||||
return {}
|
||||
monkeypatch.setattr(pipeline, "steam_title", lambda e: "Tool")
|
||||
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
|
||||
assert "source_id" not in got # never updated: installs as before
|
||||
pipeline.replace_linux_app(pkg, _appimage(tmp_path / "v2" / "Tool-aarch64.AppImage"))
|
||||
sid = library.game(pkg)["source_id"]
|
||||
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
|
||||
assert got["source_id"] == sid and 0 < got["changed_since"] <= time.time() # since the last install there
|
||||
assert library.game(pkg)["installs"]["Frame"]["source_id"] == sid
|
||||
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
|
||||
assert got["changed_since"] is None # this version is there already: resumes as usual
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ PC VR (Rift) games
|
||||
def _rift(tmp_path):
|
||||
first = unity_game(tmp_path / "v1")
|
||||
[g] = pipeline.add_path(tmp_path / "v1")
|
||||
pkg = g["package"]
|
||||
r = library.recipe_from_dict(g["recipe"])
|
||||
r.source = "user"
|
||||
r.patches["pcvr.no_crash_reporter"] = {}
|
||||
pipeline.set_recipe(pkg, r)
|
||||
library.upsert_game(pkg, title="My Space", tags=["mine"], exe_confirmed=True,
|
||||
build={"ok": True, "sha256": "old", "kind": "rift"})
|
||||
return pkg, first
|
||||
|
||||
|
||||
def test_newer_rift_folder_keeps_name_recipe_and_program(tmp_path):
|
||||
pkg, first = _rift(tmp_path)
|
||||
new = unity_game(tmp_path / "v2")
|
||||
res = pipeline.replace_rift_game(pkg, new)
|
||||
g = library.game(pkg)
|
||||
assert g["game_dir"] == str(new) and g["exe"] == "Space Game.exe" and g["exe_confirmed"] is True
|
||||
assert (g["title"], g["tags"]) == ("My Space", ["mine"])
|
||||
assert "pcvr.no_crash_reporter" in g["recipe"]["patches"] and g["recipe"]["source"] == "user"
|
||||
assert g["build"] is None and g["replaced_dirs"] == [str(first)] and not res["different"]
|
||||
from frameport.patches.pcvr import game_args
|
||||
|
||||
dep = {"package": pkg, "sha256": "old", "recipe": {"patches": sorted(g["recipe"]["patches"])},
|
||||
"game_args": game_args(library.recipe_from_dict(g["recipe"]))}
|
||||
assert C.install_state(g, {"installed": [dep]}) == "outdated"
|
||||
assert C.pc_outdated(g, dep)
|
||||
dep["recipe"]["source_id"] = g["source_id"]
|
||||
assert C.install_state(g, {"installed": [dep]}) == "installed" and not C.pc_outdated(g, dep)
|
||||
# a rescan of the folders doesn't bring the old copy back
|
||||
assert pipeline.add_path(tmp_path / "v1") == [] and library.game(pkg)["game_dir"] == str(new)
|
||||
|
||||
|
||||
def test_newer_rift_folder_without_the_program_picks_again(tmp_path):
|
||||
pkg, _ = _rift(tmp_path)
|
||||
new = unity_game(tmp_path / "v2", name="Space Game VR")
|
||||
res = pipeline.replace_rift_game(pkg, new)
|
||||
assert res["exe_changed"] and library.game(pkg)["exe"] == "Space Game VR.exe"
|
||||
with pytest.raises(ValueError):
|
||||
pipeline.replace_rift_game(pkg, tmp_path / "nothing")
|
||||
other = unity_game(tmp_path / "other", name="Moon Game")
|
||||
pipeline.add_path(tmp_path / "other")
|
||||
with pytest.raises(ValueError, match="already in the library"):
|
||||
pipeline.replace_rift_game(pkg, other)
|
||||
|
||||
|
||||
def test_rift_update_resends_changed_game_files(tmp_path, monkeypatch):
|
||||
game = unity_game(tmp_path)
|
||||
monkeypatch.setattr(installer, "upload_steam_art", lambda *a, **k: None)
|
||||
monkeypatch.setattr(installer, "_tags", lambda pkg: [])
|
||||
monkeypatch.setattr(installer, "transfer_link", lambda *a: _Null())
|
||||
sent = []
|
||||
monkeypatch.setattr(installer, "upload_files", lambda xfer, files, *a: sent.extend(f[1] for f in files))
|
||||
monkeypatch.setattr("frameport.artwork.sources.has_art", lambda pkg: True)
|
||||
manifest = installer.local_data_manifest(game)
|
||||
frame = FakeFrame({"prepare_pcvr": {"proton": {"display_name": "Proton"}, "openxr": None,
|
||||
"existing": {"game": dict(manifest)}, "free_bytes": 1 << 40,
|
||||
"incoming": "/i", "base": "/b"},
|
||||
"finalize_pcvr": {"base": "/b", "proton": "p", "appid": 1}})
|
||||
from frameport.core.models import Recipe
|
||||
|
||||
plan = installer.PcvrPlan("rift.space_game", "Space", game, "Space Game.exe", Recipe(package="rift.space_game"),
|
||||
None, source_id="v2", changed_since=time.time() - 60)
|
||||
installer.install_pcvr(frame, plan, Reporter())
|
||||
assert sorted(sent) == sorted(f"game/{rel}" for rel in manifest)
|
||||
assert frame.calls[-1][1]["recipe"]["source_id"] == "v2"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ CLI
|
||||
def test_cli_new_version(tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport import cli
|
||||
|
||||
pkg = pipeline.add_linux_app(_appimage(tmp_path / "v1" / "Tool-1.0-aarch64.AppImage"))["package"]
|
||||
new = _appimage(tmp_path / "v2" / "Tool-1.1-aarch64.AppImage")
|
||||
out = CliRunner().invoke(cli.app, ["new-version", pkg, str(new)])
|
||||
assert out.exit_code == 0, out.output
|
||||
assert library.game(pkg)["exe"] == new.name and "program Tool-1.1-aarch64.AppImage" in out.output
|
||||
(tmp_path / "empty").mkdir()
|
||||
bad = CliRunner().invoke(cli.app, ["new-version", pkg, str(tmp_path / "empty")]) # no program in it
|
||||
assert bad.exit_code == 2
|
||||
@@ -0,0 +1,362 @@
|
||||
"""frame.obb_mount_emul (GitHub #308, GRID Legends): games that mount their OBBs as FAT disk images. The agent unpacks
|
||||
each image into a folder with the image's name (agent unpack_obb), the dex rewrite makes the game's OBB helper report
|
||||
MOUNTED at once and use that folder as the mount point."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.apk.dex import Dex
|
||||
from frameport.patches.frame.obb_mount import HANDLERS, ObbMountEmulation, handler_classes, patch_dex
|
||||
from frameport.validate.triage import triage
|
||||
|
||||
AGENT = Path(__file__).resolve().parents[1] / "agent" / "frameport_agent.py"
|
||||
H = "Landroidutils/OBBHandler;"
|
||||
|
||||
|
||||
def _uleb(n: int) -> bytes:
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = n & 0x7F
|
||||
n >>= 7
|
||||
out.append(b | (0x80 if n else 0))
|
||||
if not n:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_dex(methods):
|
||||
"""A minimal dex: methods = (class, name, shorty, registers, ins, code units or None). Types in a shorty: V void,
|
||||
I int, L java/lang/String (enough for these methods)."""
|
||||
strings: list[str] = []
|
||||
|
||||
def s(x):
|
||||
if x not in strings:
|
||||
strings.append(x)
|
||||
return strings.index(x)
|
||||
|
||||
types: list[int] = []
|
||||
|
||||
def t(x):
|
||||
i = s(x)
|
||||
if i not in types:
|
||||
types.append(i)
|
||||
return types.index(i)
|
||||
|
||||
names = {"V": "V", "I": "I", "L": "Ljava/lang/String;"}
|
||||
protos: list[tuple[int, int, tuple[int, ...]]] = []
|
||||
|
||||
def p(shorty):
|
||||
key = (s(shorty), t(names[shorty[0]]), tuple(t(names[c]) for c in shorty[1:]))
|
||||
if key not in protos:
|
||||
protos.append(key)
|
||||
return protos.index(key)
|
||||
|
||||
mids = [(t(c), p(sh), s(n)) for c, n, sh, *_ in methods]
|
||||
header = 0x70
|
||||
off = header + 4 * len(strings) + 4 * len(types) + 12 * len(protos) + 8 * len(mids)
|
||||
classes = sorted({m[0] for m in methods if m[5] is not None})
|
||||
sid_off = header
|
||||
tid_off = sid_off + 4 * len(strings)
|
||||
pid_off = tid_off + 4 * len(types)
|
||||
mid_off = pid_off + 12 * len(protos)
|
||||
cdef_off = off
|
||||
off += 32 * len(classes)
|
||||
data = bytearray()
|
||||
|
||||
def place(blob, align=1):
|
||||
while (off + len(data)) % align:
|
||||
data.append(0)
|
||||
pos = off + len(data)
|
||||
data.extend(blob)
|
||||
return pos
|
||||
|
||||
str_offs = [place(_uleb(len(x)) + x.encode() + b"\0") for x in strings]
|
||||
plists = [place(struct.pack("<I", len(ps)) + b"".join(struct.pack("<H", x) for x in ps), 4) if ps else 0
|
||||
for _s, _r, ps in protos]
|
||||
code_offs = {}
|
||||
for i, (_c, _n, _sh, regs, ins, code) in enumerate(methods):
|
||||
if code is not None:
|
||||
item = struct.pack("<4HII", regs, ins, 2, 0, 0, len(code)) + struct.pack(f"<{len(code)}H", *code)
|
||||
code_offs[i] = place(item, 4)
|
||||
class_data = {}
|
||||
for c in classes:
|
||||
idxs = [i for i, m in enumerate(methods) if m[0] == c and m[5] is not None]
|
||||
blob = _uleb(0) + _uleb(0) + _uleb(len(idxs)) + _uleb(0) # direct (static) methods
|
||||
prev = 0
|
||||
for i in idxs:
|
||||
blob += _uleb(i - prev) + _uleb(9) + _uleb(code_offs[i]) # public static
|
||||
prev = i
|
||||
class_data[c] = place(blob)
|
||||
out = bytearray(header)
|
||||
out[:8] = b"dex\n035\0"
|
||||
struct.pack_into("<10I", out, 56, len(strings), sid_off, len(types), tid_off, len(protos), pid_off, 0, 0,
|
||||
len(mids), mid_off)
|
||||
struct.pack_into("<2I", out, 96, len(classes), cdef_off)
|
||||
out += b"".join(struct.pack("<I", o) for o in str_offs)
|
||||
out += b"".join(struct.pack("<I", i) for i in types)
|
||||
out += b"".join(struct.pack("<3I", a, b, pl) for (a, b, _ps), pl in zip(protos, plists))
|
||||
out += b"".join(struct.pack("<HHI", *m) for m in mids)
|
||||
out += b"".join(struct.pack("<8I", t(c), 1, 0, 0, 0, 0, class_data[c], 0) for c in classes)
|
||||
out += data
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# GRID Legends' OBBHandler, first instructions as in 1.0.0.41 (sget-object; if-nez; sget-object; const-string ...)
|
||||
MOUNT = [0x0062, 0x1C, 0x0039, 0x0E, 0x0062, 0x19, 0x011A, 0x47CD, 0x000E]
|
||||
MOUNTED_PATH = [0x0062, 0x1C, 0x0039, 0x09, 0x011A, 0x2D1A, 0x0011]
|
||||
|
||||
|
||||
def _dex() -> bytes:
|
||||
return build_dex([(H, "onMountOBBStateChanged", "VIL", 0, 0, None), # 0: native
|
||||
(H, "getOBBFullPath", "LL", 2, 1, [0x0000, 0x0011]), # 1
|
||||
(H, "mountOBB", "VL", 5, 1, MOUNT), # 2: name in v4
|
||||
(H, "getMountedOBBFullPath", "LL", 2, 1, MOUNTED_PATH), # 3: name in v1
|
||||
(H, "unmountOBB", "VL", 5, 1, [0x000E])])
|
||||
|
||||
|
||||
def _code(dex: Dex, name: str) -> list[int]:
|
||||
off = dex.code_items(H, name)[0]
|
||||
n = struct.unpack_from("<I", dex.data, off + 12)[0]
|
||||
return list(struct.unpack_from(f"<{n}H", dex.data, off + 16))
|
||||
|
||||
|
||||
def test_handler_rewrite_in_place():
|
||||
raw = _dex()
|
||||
assert handler_classes(raw + b"mountObb") == [H]
|
||||
dex = Dex(raw)
|
||||
assert dex.shorty(0) == "VIL" and dex.shorty(2) == "VL"
|
||||
assert sorted(patch_dex(dex)) == ["androidutils.OBBHandler.getMountedOBBFullPath",
|
||||
"androidutils.OBBHandler.mountOBB"]
|
||||
# const/4 v0, #1; invoke-static {v0, v4}, onMountOBBStateChanged; return-void; nop (to the boundary)
|
||||
assert _code(dex, "mountOBB") == [0x1012, 0x2071, 0, 0x0040, 0x000E, 0x0000] + MOUNT[6:]
|
||||
# invoke-static {v1}, getOBBFullPath; move-result-object v0; return-object v0; nop
|
||||
assert _code(dex, "getMountedOBBFullPath") == [0x1071, 1, 0x0001, 0x000C, 0x0011, 0x0000, 0x0011]
|
||||
assert _code(dex, "unmountOBB") == [0x000E]
|
||||
out = dex.finish()
|
||||
assert len(out) == len(raw) and patch_dex(Dex(out)) == []
|
||||
|
||||
|
||||
def test_invoke_static_encoding():
|
||||
assert Dex.invoke_static(0xCE, [0, 1]) == [0x2071, 0xCE, 0x0010] # GRID's own access$000
|
||||
with pytest.raises(ValueError):
|
||||
Dex.invoke_static(1, [16])
|
||||
|
||||
|
||||
def test_detect_triage_and_handlers():
|
||||
class A:
|
||||
extra = {"obb_mount": [H]}
|
||||
assert set(HANDLERS) == {H}
|
||||
assert ObbMountEmulation().detect(A()).recommended
|
||||
A.extra = {"obb_mount": ["other"]}
|
||||
assert ObbMountEmulation().applies(A()) and ObbMountEmulation().detect(A()) is None
|
||||
log = ("10-15 20:00:01.000 900 950 E AndroidRuntime: FATAL EXCEPTION: main\n"
|
||||
"10-15 20:00:01.000 900 950 E AndroidRuntime: java.lang.RuntimeException: Can't mount OBB "
|
||||
"DataSet0.obb (state=21)\n")
|
||||
r = triage(log, "EXITED", "com.codemasters.gridlegends")
|
||||
assert [f.id for f in r.findings] == ["obb-mount-failed"]
|
||||
assert r.findings[0].suggest == ["frame.obb_mount_emul"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------- agent side
|
||||
def load_agent(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
spec = importlib.util.spec_from_file_location("frameport_agent", AGENT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _short(name: str) -> bytes:
|
||||
base, _, ext = name.upper().partition(".")
|
||||
return base[:8].ljust(8).encode() + ext[:3].ljust(3).encode()
|
||||
|
||||
|
||||
def _lfn_entries(name: str, short: bytes) -> list[bytes]:
|
||||
csum = 0
|
||||
for b in short:
|
||||
csum = (((csum & 1) << 7) + (csum >> 1) + b) & 0xFF
|
||||
chars = [name[i] for i in range(len(name))]
|
||||
pieces = [chars[i:i + 13] for i in range(0, len(chars), 13)]
|
||||
out = []
|
||||
for n, piece in enumerate(pieces, 1):
|
||||
u = "".join(piece).encode("utf-16-le")
|
||||
if len(piece) < 13:
|
||||
u += b"\0\0" + b"\xff\xff" * (12 - len(piece))
|
||||
e = bytearray(32)
|
||||
e[0] = n | (0x40 if n == len(pieces) else 0)
|
||||
e[1:11], e[11], e[13], e[14:26], e[28:32] = u[0:10], 0x0F, csum, u[10:22], u[22:26]
|
||||
out.append(bytes(e))
|
||||
return list(reversed(out))
|
||||
|
||||
|
||||
def make_fat32(path: Path, tree: dict, cluster=1024, footer_flags=None, fragment=False):
|
||||
"""A small FAT32 image (fat32lib layout: 32 reserved sectors, 2 FATs) with long names; tree = {name: bytes |
|
||||
dict}. fragment=True interleaves files' clusters (non-contiguous chains)."""
|
||||
bps, spc = 512, cluster // 512
|
||||
nclusters = 70000 # FAT32 needs >= 65525 clusters
|
||||
fat_sectors = (nclusters + 2) * 4 // bps + 1
|
||||
res, nfat = 32, 2
|
||||
data_off = (res + nfat * fat_sectors) * bps
|
||||
fat = [0x0FFFFFF8, 0x0FFFFFFF] + [0] * nclusters
|
||||
clusters: dict[int, bytes] = {}
|
||||
nxt = [3]
|
||||
|
||||
def alloc(blob: bytes) -> int:
|
||||
n = max(1, (len(blob) + cluster - 1) // cluster)
|
||||
step = 2 if fragment else 1
|
||||
cs = [nxt[0] + i * step for i in range(n)]
|
||||
nxt[0] = cs[-1] + 1
|
||||
for i, c in enumerate(cs):
|
||||
fat[c] = cs[i + 1] if i + 1 < n else 0x0FFFFFFF
|
||||
clusters[c] = blob[i * cluster:(i + 1) * cluster].ljust(cluster, b"\0")
|
||||
return cs[0]
|
||||
|
||||
def directory(entries: dict, first: int | None = None) -> int:
|
||||
raw = bytearray()
|
||||
for i, (name, content) in enumerate(entries.items()):
|
||||
short = _short(name)[:6] + b"~" + str(i % 10).encode() + _short(name)[8:]
|
||||
if isinstance(content, dict):
|
||||
cl = directory(content)
|
||||
attr, size = 0x10, 0
|
||||
else:
|
||||
cl = alloc(content) if content else 0
|
||||
attr, size = 0x20, len(content)
|
||||
raw += b"".join(_lfn_entries(name, short))
|
||||
e = bytearray(32)
|
||||
e[0:11], e[11] = short, attr
|
||||
struct.pack_into("<H", e, 26, cl & 0xFFFF)
|
||||
struct.pack_into("<H", e, 20, cl >> 16)
|
||||
struct.pack_into("<I", e, 28, size)
|
||||
raw += e
|
||||
raw += b"\xe5" + b"\0" * 31 # a deleted entry
|
||||
if first is not None:
|
||||
fat[first] = 0x0FFFFFFF
|
||||
clusters[first] = bytes(raw).ljust(cluster, b"\0")
|
||||
return first
|
||||
return alloc(bytes(raw) + b"\0" * 32)
|
||||
|
||||
directory(tree, first=2)
|
||||
total = data_off // bps + nclusters * spc
|
||||
bs = bytearray(512)
|
||||
bs[0:3], bs[3:11] = b"\xeb\x3c\x90", b"fat32lib"
|
||||
struct.pack_into("<HBHBHHBH", bs, 11, bps, spc, res, nfat, 0, 0, 0xF8, 0)
|
||||
struct.pack_into("<II", bs, 32, total, fat_sectors)
|
||||
struct.pack_into("<I", bs, 44, 2)
|
||||
bs[82:90], bs[510:512] = b"FAT32 ", b"\x55\xaa"
|
||||
fat_bytes = b"".join(struct.pack("<I", v) for v in fat).ljust(fat_sectors * bps, b"\0")
|
||||
with open(path, "wb") as f:
|
||||
f.write(bs)
|
||||
f.write(b"\0" * (res * bps - 512))
|
||||
f.write(fat_bytes * nfat)
|
||||
for c in sorted(clusters):
|
||||
f.seek(data_off + (c - 2) * cluster)
|
||||
f.write(clusters[c])
|
||||
f.truncate(total * bps)
|
||||
f.seek(0, 2)
|
||||
if footer_flags is not None:
|
||||
pkg = b"com.example.game"
|
||||
ft = struct.pack("<III", 1, 51, footer_flags) + b"\0" * 8 + struct.pack("<I", len(pkg)) + pkg
|
||||
f.write(ft + struct.pack("<II", len(ft), 0x01059983))
|
||||
|
||||
|
||||
TREE = {"index.txt": b"data/x\n", "data": {"Mixed_Case.bk2": b"B" * 3000, "empty.bin": b"",
|
||||
"deep": {"a.erp": bytes(range(256)) * 9}}}
|
||||
|
||||
|
||||
def _check(root: Path):
|
||||
assert (root / "index.txt").read_bytes() == b"data/x\n"
|
||||
assert (root / "data/Mixed_Case.bk2").read_bytes() == b"B" * 3000
|
||||
assert (root / "data/empty.bin").read_bytes() == b""
|
||||
assert (root / "data/deep/a.erp").read_bytes() == bytes(range(256)) * 9
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fragment", [False, True])
|
||||
def test_fat_reader_unpacks_long_names_and_chains(monkeypatch, tmp_path, fragment):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
img = tmp_path / "DataSet0.obb"
|
||||
make_fat32(img, TREE, footer_flags=0, fragment=fragment)
|
||||
assert a.is_obb_image(str(img)) and a.obb_footer(str(img))["package"] == "com.example.game"
|
||||
assert a.unpack_obb_image(str(img), str(tmp_path / "out")) == (4, 7 + 3000 + 2304)
|
||||
_check(tmp_path / "out")
|
||||
|
||||
|
||||
def test_encrypted_and_non_fat_images_are_refused(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
img = tmp_path / "x.obb"
|
||||
make_fat32(img, TREE, footer_flags=1)
|
||||
with pytest.raises(a.AgentError, match="encrypted"):
|
||||
a.unpack_obb_image(str(img), str(tmp_path / "out"))
|
||||
zipped = tmp_path / "main.1.com.x.obb"
|
||||
zipped.write_bytes(b"PK\3\4" + b"\0" * 600)
|
||||
assert not a.is_obb_image(str(zipped))
|
||||
|
||||
|
||||
def test_unsafe_names_are_refused(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
img = tmp_path / "x.obb"
|
||||
make_fat32(img, {"..\\evil": b"x"})
|
||||
with pytest.raises(a.AgentError):
|
||||
a.unpack_obb_image(str(img), str(tmp_path / "out"))
|
||||
assert not (tmp_path / "evil").exists()
|
||||
|
||||
|
||||
def test_unpack_command_and_manifest(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
base = tmp_path / "game"
|
||||
obb = base / "lepton-app" / "obb"
|
||||
obb.mkdir(parents=True)
|
||||
make_fat32(obb / "DataSet0.obb", TREE, footer_flags=0)
|
||||
(obb / "plain.obb").write_bytes(b"PK\3\4 not an image")
|
||||
size = (obb / "DataSet0.obb").stat().st_size
|
||||
monkeypatch.setattr(a, "deployment", lambda pkg: {"base": str(base), "appid": 1, "title": "G"})
|
||||
monkeypatch.setattr(a, "container_running", lambda appid: False)
|
||||
assert a.cmd_unpack_obb({"package": "com.example.game"}) == {"pending": ["DataSet0.obb"]}
|
||||
done = a.cmd_unpack_obb({"package": "com.example.game", "image": "DataSet0.obb"})
|
||||
assert done["files"] == 4
|
||||
assert (obb / "DataSet0.obb").is_dir()
|
||||
_check(obb / "DataSet0.obb")
|
||||
assert json.loads((obb / "DataSet0.obb" / a.OBB_MARKER).read_text())["size"] == size
|
||||
# the PC's "already on the Frame" check and finalize's data check see the image, not its files
|
||||
assert a.obb_manifest(str(obb)) == {"DataSet0.obb": size, "plain.obb": 17}
|
||||
assert a.cmd_unpack_obb({"package": "com.example.game"}) == {"pending": []}
|
||||
with pytest.raises(a.AgentError):
|
||||
a.cmd_unpack_obb({"package": "com.example.game", "image": "plain.obb"})
|
||||
|
||||
|
||||
def test_interrupted_unpack_is_finished_or_dropped(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
obb = tmp_path / "obb"
|
||||
done = obb / ("DataSet1.obb" + a.OBB_UNPACKING) # complete, image already removed: only the rename was missing
|
||||
done.mkdir(parents=True)
|
||||
(done / a.OBB_MARKER).write_text(json.dumps({"size": 5}))
|
||||
half = obb / ("DataSet2.obb" + a.OBB_UNPACKING) # no marker: dropped (the image is still there)
|
||||
half.mkdir()
|
||||
(half / "f").write_bytes(b"x")
|
||||
a.finish_obb_unpacks(str(obb))
|
||||
assert sorted(os.listdir(obb)) == ["DataSet1.obb"]
|
||||
assert a.obb_manifest(str(obb)) == {"DataSet1.obb": 5}
|
||||
|
||||
|
||||
def test_native_fix_only_touches_the_known_build():
|
||||
import hashlib
|
||||
|
||||
from frameport.analysis import elf
|
||||
from frameport.patches.frame.obb_mount import NATIVE_FIXES, NativeFix, native_fix
|
||||
|
||||
assert NATIVE_FIXES[0].lib == "libgrid.so"
|
||||
data = (Path(__file__).resolve().parents[1] / "artifacts" / "arm64-v8a" / "libframe_xrshim.so").read_bytes()
|
||||
va, off, _size = elf.load_segments(data)[0]
|
||||
start = va + 0x40
|
||||
word = data[off + 0x48:off + 0x4C]
|
||||
fix = NativeFix("x.so", start, 0x20, hashlib.sha256(data[off + 0x40:off + 0x60]).hexdigest(),
|
||||
((start + 8, word, bytes.fromhex("1f2003d5")),))
|
||||
out = native_fix(data, fix)
|
||||
assert out is not None and len(out) == len(data) and out[off + 0x48:off + 0x4C] == bytes.fromhex("1f2003d5")
|
||||
assert out[:off + 0x48] == data[:off + 0x48] and out[off + 0x4C:] == data[off + 0x4C:]
|
||||
assert native_fix(out, fix) is None # already fixed / another build: left alone
|
||||
assert native_fix(b"not an elf", fix) is None
|
||||
@@ -0,0 +1,340 @@
|
||||
"""SSH keys beyond FramePort's own (GitHub #242): key files from Settings, the login a connection used, and OpenSSH
|
||||
uploads that fall back to this PC's own keys/agent when the Frame refuses FramePort's key."""
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import paramiko
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
from frameport.core import library
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.frame import connection as C
|
||||
from frameport.frame import native_upload as native
|
||||
|
||||
REFUSED = (b"debug1: Reading configuration data nul\r\n"
|
||||
b"debug1: identity file C:/Temp/frameport-upload-x/key0 type 3\r\n"
|
||||
b"debug1: Connecting to 10.0.0.2 [10.0.0.2] port 22.\r\n"
|
||||
b"@ WARNING: UNPROTECTED PRIVATE KEY FILE! @\r\n"
|
||||
b"debug1: Offering public key: C:/Temp/frameport-upload-x/key0 ED25519 SHA256:abc explicit\r\n"
|
||||
b"debug1: Authentications that can continue: publickey,password\r\n"
|
||||
b"debug1: kex: algorithm: curve25519-sha256\r\n"
|
||||
b"steamos@10.0.0.2: Permission denied (publickey,password).\r\n")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_real_home_keys(tmp_path, monkeypatch):
|
||||
"""Never read the real ~/.ssh (relaxed mode looks there)."""
|
||||
empty = tmp_path / "empty-home"
|
||||
empty.mkdir()
|
||||
monkeypatch.setattr(native.Path, "home", lambda: empty)
|
||||
|
||||
|
||||
def write_key(path: Path, password: bytes | None = None) -> Path:
|
||||
enc = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption()
|
||||
key = ed25519.Ed25519PrivateKey.generate()
|
||||
path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, enc))
|
||||
path.with_name(path.name + ".pub").write_bytes(key.public_key().public_bytes(
|
||||
serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH))
|
||||
return path
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- key files
|
||||
def test_key_file_validation(tmp_path):
|
||||
good = write_key(tmp_path / "id_ed25519")
|
||||
assert isinstance(C.load_key_file(good), paramiko.PKey)
|
||||
with pytest.raises(C.KeyFileError, match="passphrase"):
|
||||
C.load_key_file(write_key(tmp_path / "locked", b"secret"))
|
||||
with pytest.raises(C.KeyFileError, match="public half"):
|
||||
C.load_key_file(tmp_path / "id_ed25519.pub")
|
||||
with pytest.raises(C.KeyFileError, match="not found"):
|
||||
C.load_key_file(tmp_path / "missing")
|
||||
(tmp_path / "notes.txt").write_text("hello")
|
||||
with pytest.raises(C.KeyFileError, match="isn't a private SSH key"):
|
||||
C.load_key_file(tmp_path / "notes.txt")
|
||||
|
||||
|
||||
def test_extra_keys_come_from_settings_and_skip_unusable_ones(tmp_path):
|
||||
good = write_key(tmp_path / "mine")
|
||||
locked = write_key(tmp_path / "locked", b"secret")
|
||||
library.set_setting(C.EXTRA_KEYS_SETTING, [str(good), str(good), str(locked), str(tmp_path / "gone"), "", 3])
|
||||
assert C.extra_key_paths() == [good, locked, tmp_path / "gone"]
|
||||
assert [path for path, _ in C.extra_keys()] == [good]
|
||||
library.set_setting(C.EXTRA_KEYS_SETTING, str(good)) # a single path, e.g. edited by hand
|
||||
assert C.extra_key_paths() == [good]
|
||||
|
||||
|
||||
class _Client:
|
||||
"""paramiko.SSHClient stand-in: accepts the key `accepted` (or the agent when accepted == "agent")."""
|
||||
accepted = None
|
||||
tried: list = []
|
||||
|
||||
def load_host_keys(self, path):
|
||||
pass
|
||||
|
||||
def set_missing_host_key_policy(self, policy):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def save_host_keys(self, path):
|
||||
pass
|
||||
|
||||
def get_transport(self):
|
||||
return Mock()
|
||||
|
||||
def connect(self, pkey=None, password=None, allow_agent=False, look_for_keys=False, **kw):
|
||||
kind = "password" if password else "agent" if allow_agent else pkey
|
||||
_Client.tried.append(kind)
|
||||
if kind != _Client.accepted:
|
||||
raise paramiko.AuthenticationException("Authentication failed.")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("accepted,auth", [("app", "app_key"), ("user", "user_key"), ("agent", "agent")])
|
||||
def test_connection_records_the_login_that_worked(monkeypatch, accepted, auth):
|
||||
monkeypatch.setattr(C.paramiko, "SSHClient", _Client)
|
||||
monkeypatch.setattr(C, "app_keys", lambda: ["app"])
|
||||
monkeypatch.setattr(C, "extra_keys", lambda: [(Path("/k"), "user")])
|
||||
monkeypatch.setattr(C.Frame, "run", lambda self, cmd: (0, "/home/steamos", ""))
|
||||
_Client.accepted, _Client.tried = accepted, []
|
||||
frame = C.Frame(C.FrameTarget("10.0.0.2")).connect()
|
||||
assert frame.auth == auth
|
||||
assert _Client.tried == ["app", "user", "agent"][:["app", "user", "agent"].index(accepted) + 1]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- OpenSSH arguments
|
||||
def frame_with_key():
|
||||
key = SimpleNamespace(get_name=lambda: "ssh-ed25519", get_base64=lambda: "PAIRED_KEY")
|
||||
client = SimpleNamespace(get_transport=lambda: SimpleNamespace(get_remote_server_key=lambda: key))
|
||||
return C.Frame(C.FrameTarget("paired.example"), client=client)
|
||||
|
||||
|
||||
def test_credentials_offer_own_and_settings_keys_strict_or_relaxed(tmp_path, monkeypatch):
|
||||
own = tmp_path / "ssh"
|
||||
own.mkdir()
|
||||
write_key(own / "id_ed25519")
|
||||
mine = write_key(tmp_path / "id_ed25519") # same file name as FramePort's: copies get their own names
|
||||
monkeypatch.setattr(native, "ssh_dir", lambda: own)
|
||||
library.set_setting(C.EXTRA_KEYS_SETTING, [str(mine)])
|
||||
with native.credentials(frame_with_key()) as args:
|
||||
copies = [Path(args[i + 1]) for i, a in enumerate(args) if a == "-i"]
|
||||
assert [c.read_bytes() for c in copies] == [(own / "id_ed25519").read_bytes(), mine.read_bytes()]
|
||||
assert "IdentitiesOnly=yes" in args and "BatchMode=yes" in args and "-v" in args
|
||||
assert all(oct(c.stat().st_mode & 0o777) == "0o600" for c in copies)
|
||||
assert not any(c.exists() for c in copies)
|
||||
with native.credentials(frame_with_key(), relaxed=True) as args:
|
||||
assert "IdentitiesOnly=yes" not in args and args.count("-i") == 2
|
||||
assert "StrictHostKeyChecking=yes" in args # host key still pinned
|
||||
|
||||
|
||||
def test_relaxed_credentials_work_without_any_key_file(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(native, "ssh_dir", lambda: tmp_path)
|
||||
with pytest.raises(OSError, match="no FramePort key"):
|
||||
with native.credentials(frame_with_key()):
|
||||
pass
|
||||
with native.credentials(frame_with_key(), relaxed=True) as args:
|
||||
assert "-i" not in args
|
||||
|
||||
|
||||
def test_windows_sid_survives_a_non_ascii_user_name(monkeypatch):
|
||||
out = '"desktop-1\\josé müller","S-1-5-21-1004336348-1177238915-682003330-1001"\r\n'.encode("cp850")
|
||||
monkeypatch.setattr(native.subprocess, "check_output", lambda *a, **k: out)
|
||||
assert native.current_sid() == "S-1-5-21-1004336348-1177238915-682003330-1001"
|
||||
monkeypatch.setattr(native.subprocess, "check_output", lambda *a, **k: b"nothing")
|
||||
with pytest.raises(OSError):
|
||||
native.current_sid()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- failures and modes
|
||||
def test_failure_keeps_openssh_messages_and_key_lines():
|
||||
exc = native.failure("OpenSSH SFTP", 255, REFUSED)
|
||||
assert isinstance(exc, native.OpenSSHAuthFailed)
|
||||
text = str(exc)
|
||||
assert "UNPROTECTED PRIVATE KEY FILE" in text and "Offering public key" in text
|
||||
assert "identity file" in text and "Permission denied (publickey,password)" in text
|
||||
assert "kex: algorithm" not in text and "Reading configuration" not in text
|
||||
other = native.failure("OpenSSH SFTP", 1, b"stat x: No such file or directory\n")
|
||||
assert type(other) is OSError and "No such file" in str(other)
|
||||
assert type(native.failure("OpenSSH tar", 255, b"ssh: connect to host x port 22: Connection timed out")) is OSError
|
||||
|
||||
|
||||
@pytest.mark.parametrize("auth,known,modes", [("app_key", None, [False, True]), ("user_key", None, [False, True]),
|
||||
("password", None, [False, True]), ("agent", None, [True]),
|
||||
("app_key", True, [True]), ("agent", False, [False])])
|
||||
def test_login_modes(auth, known, modes):
|
||||
assert native.login_modes(SimpleNamespace(auth=auth, openssh_relaxed=known)) == modes
|
||||
assert native.login_modes(SimpleNamespace()) == [False, True] # stand-ins without the fields
|
||||
|
||||
|
||||
def sftp_setup(tmp_path, monkeypatch, refuse_strict=True, refuse_relaxed=False, auth="app_key"):
|
||||
"""Two .part-less files; strict/relaxed logins refused as asked; returns the frame and the Popen calls."""
|
||||
paths = [tmp_path / "main.obb", tmp_path / "patch.obb"]
|
||||
for path in paths:
|
||||
path.write_bytes(b"0123456789")
|
||||
items = [(path, path.name, 10) for path in paths]
|
||||
files = {}
|
||||
monitor = Mock()
|
||||
monitor.__enter__ = Mock(return_value=monitor)
|
||||
monitor.__exit__ = Mock(return_value=False)
|
||||
|
||||
def stat(path):
|
||||
if path not in files:
|
||||
raise FileNotFoundError(path)
|
||||
return SimpleNamespace(st_size=files[path])
|
||||
monitor.stat.side_effect = stat
|
||||
frame = SimpleNamespace(sftp=monitor, client=SimpleNamespace(open_sftp=lambda: monitor), auth=auth,
|
||||
openssh_relaxed=None, target=C.FrameTarget("paired"), run=Mock(return_value=(0, "", "")))
|
||||
monkeypatch.setattr(native.shutil, "which", lambda _: "sftp")
|
||||
mode = {}
|
||||
|
||||
@contextlib.contextmanager
|
||||
def creds(frame, port_flag="-P", relaxed=False):
|
||||
mode["relaxed"] = relaxed
|
||||
yield ["relaxed" if relaxed else "strict"]
|
||||
monkeypatch.setattr(native, "credentials", creds)
|
||||
calls = []
|
||||
|
||||
def popen(args, **kwargs):
|
||||
relaxed = mode["relaxed"]
|
||||
calls.append(relaxed)
|
||||
refused = refuse_relaxed if relaxed else refuse_strict
|
||||
if refused:
|
||||
kwargs["stderr"].write(REFUSED)
|
||||
code = 255
|
||||
else:
|
||||
for _, rel, size in items:
|
||||
files["/incoming/" + rel + ".part"] = size
|
||||
code = 0
|
||||
process = Mock(returncode=code)
|
||||
process.poll.return_value = code
|
||||
return process
|
||||
monkeypatch.setattr(native.subprocess, "Popen", popen)
|
||||
return frame, items, calls
|
||||
|
||||
|
||||
def test_refused_strict_login_retries_with_this_pcs_keys(tmp_path, monkeypatch):
|
||||
frame, items, calls = sftp_setup(tmp_path, monkeypatch)
|
||||
logs = []
|
||||
reporter = Reporter()
|
||||
reporter.log = lambda message, **k: logs.append(message)
|
||||
assert native.upload(frame, items, "/incoming", reporter, 20, 0, 2) == 20
|
||||
assert calls == [False, False, True, True] # both strict workers are refused, then both run relaxed
|
||||
assert frame.openssh_relaxed is True
|
||||
assert any("trying this PC's own SSH keys" in m and "UNPROTECTED" in m for m in logs)
|
||||
assert native.login_modes(frame) == [True] # the next upload starts in the mode that worked
|
||||
|
||||
|
||||
def test_agent_login_goes_straight_to_relaxed(tmp_path, monkeypatch):
|
||||
frame, items, calls = sftp_setup(tmp_path, monkeypatch, auth="agent")
|
||||
assert native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1) == 20
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
def test_refused_in_both_modes_raises_the_auth_failure(tmp_path, monkeypatch):
|
||||
frame, items, calls = sftp_setup(tmp_path, monkeypatch, refuse_relaxed=True)
|
||||
with pytest.raises(native.OpenSSHAuthFailed, match="Permission denied"):
|
||||
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1)
|
||||
assert calls == [False, True] and frame.openssh_relaxed is None
|
||||
assert not frame.run.called # nothing promoted
|
||||
|
||||
|
||||
def test_tar_retries_a_refused_login(tmp_path, monkeypatch):
|
||||
path = tmp_path / "small.bin"
|
||||
path.write_bytes(b"abc")
|
||||
frame = SimpleNamespace(target=C.FrameTarget("paired"), auth="app_key", openssh_relaxed=None)
|
||||
monkeypatch.setattr(native.shutil, "which", lambda _: "ssh")
|
||||
mode = {}
|
||||
|
||||
@contextlib.contextmanager
|
||||
def creds(frame, port_flag="-P", relaxed=False):
|
||||
mode["relaxed"] = relaxed
|
||||
yield []
|
||||
monkeypatch.setattr(native, "credentials", creds)
|
||||
|
||||
class Pipe:
|
||||
def write(self, data):
|
||||
if not mode["relaxed"]:
|
||||
raise BrokenPipeError(32, "Broken pipe") # ssh already exited
|
||||
return len(data)
|
||||
|
||||
def flush(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
calls = []
|
||||
|
||||
def popen(args, **kwargs):
|
||||
calls.append(mode["relaxed"])
|
||||
code = 0 if mode["relaxed"] else 255
|
||||
if code:
|
||||
kwargs["stderr"].write(REFUSED)
|
||||
process = Mock(stdin=Pipe(), returncode=code)
|
||||
process.poll.return_value = code
|
||||
return process
|
||||
monkeypatch.setattr(native.subprocess, "Popen", popen)
|
||||
native.put_tar(frame, [(path, "small.bin")], "/incoming", Reporter(), Mock())
|
||||
assert calls == [False, True] and frame.openssh_relaxed is True
|
||||
|
||||
|
||||
def test_azure_ad_sid(monkeypatch):
|
||||
out = b'"azuread\\jmueller","S-1-12-1-1234567890-1234567890-1234567890-1234567890"\r\n'
|
||||
monkeypatch.setattr(native.subprocess, "check_output", lambda *a, **k: out)
|
||||
assert native.current_sid() == "S-1-12-1-1234567890-1234567890-1234567890-1234567890"
|
||||
|
||||
|
||||
def test_relaxed_mode_names_this_pcs_default_keys(tmp_path, monkeypatch):
|
||||
"""OpenSSH skips its default keys once any -i is given, so relaxed mode names the ones Paramiko tries."""
|
||||
home = tmp_path / "home"
|
||||
(home / ".ssh").mkdir(parents=True)
|
||||
rsa = home / ".ssh" / "id_rsa"
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa as rsa_keys
|
||||
|
||||
rsa.write_bytes(rsa_keys.generate_private_key(65537, 2048).private_bytes(
|
||||
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption()))
|
||||
write_key(home / ".ssh" / "id_ecdsa", b"secret") # passphrase: only usable through the agent
|
||||
(home / ".ssh" / "id_ed25519").write_text("garbage")
|
||||
monkeypatch.setattr(native.Path, "home", lambda: home)
|
||||
own = tmp_path / "own"
|
||||
own.mkdir()
|
||||
write_key(own / "id_ed25519")
|
||||
monkeypatch.setattr(native, "ssh_dir", lambda: own)
|
||||
assert native.default_key_files() == [rsa]
|
||||
assert native.key_files() == [own / "id_ed25519"]
|
||||
assert native.key_files(relaxed=True) == [own / "id_ed25519", rsa]
|
||||
library.set_setting(C.EXTRA_KEYS_SETTING, [str(rsa)]) # a default key also named in Settings: offered once
|
||||
assert native.key_files(relaxed=True) == [own / "id_ed25519", rsa]
|
||||
|
||||
|
||||
def test_add_remove_and_status_of_settings_keys(tmp_path):
|
||||
good = write_key(tmp_path / "frame key")
|
||||
with pytest.raises(C.KeyFileError, match="public half"):
|
||||
C.add_extra_key(tmp_path / "frame key.pub")
|
||||
with pytest.raises(C.KeyFileError, match="passphrase"):
|
||||
C.add_extra_key(write_key(tmp_path / "locked", b"pw"))
|
||||
assert C.add_extra_key(good) == good.resolve()
|
||||
C.add_extra_key(good) # once only
|
||||
assert C.extra_key_paths() == [good.resolve()]
|
||||
good.unlink()
|
||||
[(path, problem)] = C.extra_key_status()
|
||||
assert path == good.resolve() and "not found" in problem
|
||||
assert C.remove_extra_key(good) is True and C.remove_extra_key(good) is False
|
||||
assert C.extra_key_paths() == []
|
||||
|
||||
|
||||
def test_cli_ssh_keys(tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport.cli import app
|
||||
|
||||
good = write_key(tmp_path / "mine")
|
||||
run = CliRunner().invoke(app, ["frame", "ssh-keys", "--add", str(good)])
|
||||
assert run.exit_code == 0 and f"added {good.resolve()}" in run.output
|
||||
run = CliRunner().invoke(app, ["frame", "ssh-keys", "--add", str(tmp_path / "nope")])
|
||||
assert run.exit_code == 1 and str(good.resolve()) in run.output
|
||||
run = CliRunner().invoke(app, ["frame", "ssh-keys", "--remove", str(good.resolve())])
|
||||
assert run.exit_code == 0 and "no extra keys" in run.output
|
||||
@@ -510,3 +510,32 @@ def test_obb_and_cube_advice_describes_limits():
|
||||
advice = next(f for f in cube.findings if f.id == "cube-swapchain-refused")
|
||||
assert "GLES" in advice.diagnosis
|
||||
assert "For a Vulkan game, the current build cannot stand in" in advice.diagnosis
|
||||
|
||||
|
||||
# Deadly Delivery 1.0.30 (GitHub #322): renders, then its lobby's Meta entitlement check fails four times and it quits
|
||||
LOG_ENTITLEMENT = """
|
||||
10-15 12:45:23.964 212 240 I ActivityManager: Start proc 1214:com.FlatHeadStudio.DeadlyDelivery/u0a14 for \
|
||||
pre-top-activity {com.FlatHeadStudio.DeadlyDelivery/com.unity3d.player.UnityPlayerActivity}
|
||||
10-15 12:45:38.349 1214 1238 E Unity : [LobbyManager]: Entitlement check failed: .
|
||||
10-15 12:45:38.349 1214 1238 E Unity : FlatHeads.<>c__DisplayClass105_0:<CheckEntitlementAndConfigureAsync>b__0(Message)
|
||||
10-15 12:45:38.352 1214 1238 I Unity : [LobbyManager]: Retrying oculus authentication in 2 seconds.
|
||||
10-15 12:45:43.372 1214 1286 I FrameBridge: pacing: 71.6 fps, displayTime vs predicted: avg 13.97 ms, max 83.33 ms
|
||||
10-15 12:45:46.349 1214 1238 I Unity : stopping xr plugin
|
||||
10-15 12:45:47.424 1214 1238 I Unity : [Photon] Disconnected from Photon (None)
|
||||
10-15 12:45:48.857 1214 1214 I Process : Sending signal. PID: 1214 SIG: 9
|
||||
"""
|
||||
|
||||
|
||||
def test_meta_entitlement_quest():
|
||||
r = triage(LOG_ENTITLEMENT, "EXITED", "com.FlatHeadStudio.DeadlyDelivery")
|
||||
f = next(f for f in r.findings if f.id == "meta-entitlement")
|
||||
assert f.severity == "error" and f.suggest == []
|
||||
assert "Entitlement check failed" in f.evidence
|
||||
assert "oculus-entitlement" not in {x.id for x in r.findings} # the Rift signature stays PC VR only
|
||||
assert r.verdict == "fail"
|
||||
assert "meta-entitlement" in {x.id for x in triage("E Unity: ovrPlatformInitialize_NotEntitled", "EXITED").findings}
|
||||
# routine Platform SDK lines of games that work don't match; a game that logs it and keeps running still passes
|
||||
for ok in ("I Unity : CheckEntitlementAndConfigureAsync", "I Unity : Entitlement check passed",
|
||||
"I Unity : ovr_Entitlement_GetIsViewerEntitled", "I Unity : User is entitled"):
|
||||
assert "meta-entitlement" not in {x.id for x in triage(ok + "\n" + LOG_OK, "RUNNING").findings}
|
||||
assert triage("E Unity : Entitlement check failed\n" + LOG_OK, "RUNNING").verdict == "pass"
|
||||
Reference in new issue
Block a user