Compare commits

..
2 Commits
Author SHA1 Message Date
spoopyghosty0 1512955fb1 PC_ANDROID: headset results (Lucky's Tale black: AXRB's raw image path at SteamVR's resolution, 6-10 s per frame; Pinball FX VR crashes in AXRB's Vulkan layer with the headset too) 2026-10-10 09:51:10 -04:00
spoopyghosty0 9f49414bd6 Quest games on this PC through AXRB (experimental): the "This PC" target installs Quest/OpenXR APKs into AXRB's Android emulator
Spike (docs/PC_ANDROID.md, headless on Windows 11 + RTX 4080): unconverted Quest APKs abort in AXRB (Meta platform
loader), so the PC gets its own build: OVRPort's patches plus the APK fixes marked on_pc (platform stand-ins, swapchain
limit), no Steam Frame fixes (build(pc=True), library build_pc). Lucky's Tale runs a full OpenXR session in AXRB's
runtime; frames to SteamVR need a headset.

tools/axrb.py: AXRB is downloaded only on first use (official Setup exe, SHA256SUMS, /S, per user), never bundled or
modified; its Android runtime is set up like AXRB's launcher does (pins from the installed app.asar, AVD
axrb-managed-api36, receipts shared with AXRB). Scripts start through WMI (QEMU/adb inherit any handle; WSL interop
kept calls hanging). targets/pc_android.py: install (emulator, runtime APK, adb install, OBB push with resume),
launch test, uninstall, diagnostics; Play = Steam shortcut -> FramePort's launcher -> AXRB's run_windows_game.ps1.
Agent v71: shortcut options_key (all these shortcuts share powershell.exe). GUI: Install/Play on this PC for Quest
games (secondary, first-use confirmation), PC card, filters. Triage kind axrb. End to end on the PC: install + launch
test (axrb-no-headset without a headset).
2026-10-10 01:55:27 -04:00
64 changed files with 1880 additions and 2579 deletions

No files matched your search

+17 -20
View File
@@ -79,6 +79,23 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
`launcher` field, else text swap), list_installed `drive`/`drive_missing` (install_state keeps such games
"installed"). PC: `install/drives.py`, library setting `install.drive` (Frame page → Storage), game menu
"Move to…" (job kind tool-frame), CLI `frame drives`/`frame move`/`install --dest`. Untested on the device.
- **Quest games on this PC via AXRB** (experimental, branch `worktree-pc-android-axrb`, docs/PC_ANDROID.md): the
"This PC" target (`PcReviveTarget`) installs APKs through `targets/pc_android.py` + `tools/axrb.py`. AXRB
(Android-XR-Bridge/AXRB, Windows: x86_64 Android 16 emulator + libndk_translation + its own OpenXR runtime →
`axrb-host-bridge.exe` → SteamVR) is downloaded only on first use (`AXRB-Setup-<v>.exe /S`, SHA256SUMS, per user
→ `%LOCALAPPDATA%\Programs\axrb-launcher`), never bundled/modified (host code: AXRB Source-Available License).
Its runtime setup lives only in AXRB's Electron JS, so FramePort repeats it (pins read from the installed
`app.asar` `core/components.json`, AVD `axrb-managed-api36` port 5584, adb `-P 5038`, receipts `ready.json`/
`license-acceptance.json`, so AXRB's own launcher shares it). Spike 2026-10-10 (headless): **unconverted APKs
abort** (Meta platform loader) → PC build = OVRPort patches + `Patch.on_pc` APK fixes (`base.pc_selection`,
`build(pc=True)`, library `build_pc`, `pc_recipe_fingerprint`); Lucky's Tale ran a full OpenXR session in AXRB's
runtime; Pinball FX VR crashed in gfxstream vkAllocateMemory (AXRB's layer). SteamVR can't stand in for a headset
(null driver: no D3D11 adapter; vrlink without a Frame: no instance) → frames need the owner. Play = Steam
shortcut `powershell.exe -File %LOCALAPPDATA%\FramePort\axrb\fp-axrb-run.ps1 -Package …` → AXRB's
`run_windows_game.ps1`; all such shortcuts share Exe, so agent v71 `upsert/remove_shortcut(options_key=
"-Package <pkg> ")`. AXRB scripts start through WMI (`axrb.spawn_detached`): QEMU/adb inherit any handle, and
even /dev/null through WSL interop kept the call hanging; PowerShell 5.1 `*>>` logs are UTF-16 (`read_log`). Triage
`kind: axrb`. Full uninstall in AXRB deletes OBB + saves; a signature change needs one.
- **PC VR repacks are pre-patched to run directly** (proven: Rick and Morty, Vader Immortal run when the exe is
launched directly; Revive breaks them). So Rift games default to `as_is` = install the copy unchanged and launch
the exe directly (`pcvr.xr_timefix` for the Frame OpenXR-1.1→1.0 fix, `pcvr.no_crash_reporter` for Unreal).
@@ -651,26 +668,6 @@ XR_ERROR_RUNTIME_FAILURE); OVRPlugin ignores that ("CreateSwapchain for eye 0: 0
ovrp_EndFrame4 (memset). FrameBridge `cube_standin` (default on, `native/adapter/cube_standin.c`) serves a refused cube
swapchain as one GL cube-map texture in the app's context (GLES only) and drops its layers. Verified headless with
Budget Cuts Ultimate (2048² sRGB, 12 mips; runs on at ~70 fps); what the cube layer showed is simply missing.
**Hardware video decoding (PR #96, PR #128 by Lucas-Mathieu, adapted 2026-10-10, agent v71):** `native/hevc` = one
OMX plugin `OMX.frameport.{avc,hevc,vp9}.decoder` (FFmpeg v4l2m2m on Iris /dev/video-dec0, FFmpeg software decoders as
fallback inside the component, Vulkan copy for big native surfaces). The connection installs it once per Frame
(`ensure_video_codec` → agent `video_codec_status`/`install_video_codec`: `~/.local/share/frameport/video-codec/versions/
<manifest sha>` + `current` symlink). A Frame keeps the same or a newer `revision` (no flip-flop between PCs) → **bump
`revision` in native/hevc/build.py with every artifact change**; a failed install isn't retried during the connection.
Per game: only launchers of games whose recipe has `frame.hw_video_decode` (install stage, no APK change; suggested
from analysis `media_codec`, ANALYSIS_VERSION 8; deployment.json `hw_video_decode`) get the codec line → the Podman
wrapper (native/hevc/podman.py) mounts the plugin into that container (merged media_codecs.xml in
$XDG_RUNTIME_DIR/frameport-video). Off for every game: Settings → Installing (library `video.hw_decode` → agent
`video_codec_switch` = video-codec/disabled); one game: `FRAMEPORT_NO_HW_VIDEO=1 %command%`. `upgrade_launchers`
converts agent ≤70 launchers (Batman's `<base>/frameport-codec` → keeps the line, folder then removed). Batman: catalog
+ migration `batman_video_patches` (`frame.hw_video_decode` + hidden adapter setting `surface_native`). Iris: 8K
refused (ENOMEM) while any other decoder session is open; SteamVR's vrlinkrunthread holds one ~12 s at game start →
refused opens retry until 20 s after the plugin loaded (else 2 s), then software; VP9 7680x3840 never returned a
picture → VP9 ≤4096x2304; hidden VP9 frames come back as empty capture buffers (bytesused 0, no LAST):
FFmpeg's wrapper ended the EOS drain at the first one (two-pass VP9 lost its last ~25 frames) → build.py requeues
them (codec revision 8; drain ends at LAST, or 1 s after a skipped empty buffer; dev Frame 2026-10-10: 600/600 =
OMX.google.vp9 Y hashes). Rebuilds are deterministic (ext4 + NTFS path
with spaces, two NDK copies → same; rev 8 141de01f…; PR #128's own sources → its 27a2d749…). Not yet run on the device.
**Vivox API 31 (GitHub #101, 2026-10-09):** newer Vivox builds (Green Hell VR) call Android 12 AudioManager
communication-device methods from `com.vivox.sdk.AudioChangeListener` with no SDK check → NoSuchMethodError on Lepton's
Android 11. `frame.vivox_audio_route` (analysis `vivox_api31`, ANALYSIS_VERSION 4) makes every such method return at
+2 -1
View File
@@ -117,7 +117,8 @@ recipe for you) or **Report a problem…** (attaches a diagnostics zip with pers
[OVRPort](https://github.com/Android-XR-Bridge/OVRPort) (Quest → OpenXR, originally
[ovrport/app](https://github.com/ovrport/app)) · Valve Lepton, Proton and SteamVR ·
[Revive](https://github.com/LibreVR/Revive) · Mesa (Zink) · [Khronos OpenXR SDK](https://github.com/KhronosGroup/OpenXR-SDK)
[Revive](https://github.com/LibreVR/Revive) · [AXRB](https://github.com/Android-XR-Bridge/AXRB) (Quest games on a PC,
experimental) · Mesa (Zink) · [Khronos OpenXR SDK](https://github.com/KhronosGroup/OpenXR-SDK)
· Eclipse Temurin, Android apksigner and NDK · [Flet](https://flet.dev) · OculusDB and Steam store data.
What FramePort adds itself: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
+2
View File
@@ -24,6 +24,8 @@ FramePort's own native code (the FrameBridge adapter, GL/Vulkan/OpenXR shims, th
| Eclipse Temurin JRE | api.adoptium.net | GPL-2.0 with Classpath Exception |
| apksigner (Android build-tools) | dl.google.com Android repository | Apache-2.0 |
| Revive (portable copy, only if Revive isn't installed) | github.com/LibreVR/Revive releases | GPL-3.0 |
| AXRB / Android XR Bridge (official installer, only when a Quest game is installed on this PC; run unmodified) | github.com/Android-XR-Bridge/AXRB releases | mixed: launcher GPL-3.0-or-later, host/clock code AXRB Source-Available License 1.0, other parts MIT (AXRB's `LICENSE`) |
| Android Emulator, platform-tools, build-tools, Android 16 system image (pinned by AXRB) | dl.google.com Android repository | Android SDK License |
Valve's Lepton, Proton and SteamVR, and Meta's software, are used on your devices as installed by Steam / Meta;
FramePort doesn't distribute them.
+55 -236
View File
@@ -23,6 +23,7 @@ PC VR (Oculus Rift) games packed for the Frame (id "rift.<slug>"), run by Proton
<dest>/<id>/compatdata/ Proton prefix = saves (kept across reinstalls), launch.log
"""
import base64
import fcntl
import glob
import hashlib
import json
@@ -33,38 +34,17 @@ import shutil
import struct
import subprocess
import sys
import tempfile
import time
import zipfile
import zlib
from types import SimpleNamespace
try:
import fcntl
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
fcntl = None
AGENT_VERSION = 71
AGENT_VERSION = 71 # v71: upsert/remove_shortcut options_key (the PC's Quest games share powershell.exe as Exe)
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
LEPTON_APPID = "3029110" # fallback when no appmanifest names Lepton
PKG_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$")
VIDEO_CODEC_DIR = os.path.join(HOME, ".local/share/frameport/video-codec")
VIDEO_CODEC_FILES = ("libstagefrighthw.so", "media_codecs_frameport.xml", "podman.py", "COPYING.FFmpeg")
# Hardware video decoding (patch frame.hw_video_decode, per game): only launchers of games whose recipe has it put the
# shared codec's Podman wrapper first on Lepton's PATH (it adds the codec plugin to that game's container). Off for
# every game: FramePort's setting (VIDEO_CODEC_DIR/disabled); one game: FRAMEPORT_NO_HW_VIDEO=1 in its launch options.
VIDEO_CODEC_LINE = ('codec_dir="$HOME/.local/share/frameport/video-codec"\n'
'[[ "${FRAMEPORT_NO_HW_VIDEO:-0}" != 0 || -e "$codec_dir/disabled" || '
'! -x "$codec_dir/current/bin/podman" ]] || export PATH="$codec_dir/current/bin:$PATH"')
HW_VIDEO_PATCH = "frame.hw_video_decode"
# earlier codec lines: agent <= 70 (per-game codec extracted from the APK, line in every launcher) and PR #128's
# shared line (in every Lepton launcher)
OLD_CODEC_LINES = ('[[ ! -x "$app_dir/frameport-codec/bin/podman" ]] || '
'export PATH="$app_dir/frameport-codec/bin:$PATH"',
'codec_bin="$HOME/.local/share/frameport/video-codec/current/bin"\n'
'[[ ! -x "$codec_bin/podman" ]] || export PATH="$codec_bin:$PATH"')
class AgentError(Exception):
@@ -702,12 +682,6 @@ def ensure_host_fixes():
upgraded = []
if upgraded:
changed.append(f"launchers: exit watchdog, dashboard, play log ({len(upgraded)})")
try:
old = remove_old_codec_dirs()
except Exception: # noqa: BLE001
old = []
if old:
changed.append(f"per-game video codec folders removed ({len(old)})")
try:
entries = refresh_desktop_entries()
except Exception: # noqa: BLE001
@@ -913,15 +887,21 @@ def vdf_encode(obj):
return bytes(out) + bytes([TYPE_END])
def _shortcut_matches(v, exe, options_key=None):
"""A shortcut is the game's when its Exe matches; with options_key (games sharing one Exe, e.g. Quest games on a
PC all start powershell.exe) its LaunchOptions must also contain that key."""
return isinstance(v, dict) and v.get("Exe") == exe and (not options_key or options_key in v.get("LaunchOptions", ""))
def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Frame", launch_options="", tags=None,
openvr=True, write=True):
openvr=True, write=True, options_key=None):
"""Add/update a non-Steam shortcut (matched by Exe, so its appid never changes). `tags` (genres, the user's tags)
are merged with tags already on the shortcut, so ones set in Steam are kept. write=False: change nothing, return
(appid, whether shortcuts.vdf would change)."""
data = open(vdf_path, "rb").read() if os.path.exists(vdf_path) else b""
root = vdf_decode(data) if data else {"shortcuts": {}}
shortcuts = root.setdefault("shortcuts", {})
entry = next((v for v in shortcuts.values() if isinstance(v, dict) and v.get("Exe") == exe), None)
entry = next((v for v in shortcuts.values() if _shortcut_matches(v, exe, options_key)), None)
ident = entry["appid"] if entry else shortcut_appid(exe, title)
if entry is None:
entry = {"appid": ident, "LastPlayTime": 0, "tags": {"0": tag}}
@@ -1015,12 +995,12 @@ def grid_files(grid, appid):
return []
def remove_shortcut(vdf_path, exe):
def remove_shortcut(vdf_path, exe, options_key=None):
if not os.path.exists(vdf_path):
return False
root = vdf_decode(open(vdf_path, "rb").read())
sc = root.get("shortcuts", {})
keep = [v for v in sc.values() if not (isinstance(v, dict) and v.get("Exe") == exe)]
keep = [v for v in sc.values() if not _shortcut_matches(v, exe, options_key)]
if len(keep) == len(sc):
return False
root["shortcuts"] = {str(i): v for i, v in enumerate(keep)}
@@ -2215,7 +2195,7 @@ export XDG_RUNTIME_DIR="/run/user/$(id -u)"
export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus"
export IS_PARENT=true
{extra_env}
{video_codec}
[[ ! -x "$app_dir/frameport-codec/bin/podman" ]] || export PATH="$app_dir/frameport-codec/bin:$PATH"
child=''
stop() {{
trap - EXIT INT TERM
@@ -2293,10 +2273,6 @@ def upgrade_launchers():
except OSError:
continue
new = text
# the codec line follows the game's deployment (only Lepton launchers have this variable; Proton/Linux
# launchers are never touched). Running launchers keep reading their old file.
if "export LEPTON_ENV_FRAMEBRIDGE_CONFIG=" in new:
new = set_codec_line(new, wants_hw_video(os.path.dirname(path)))
if "a Linux app. Generated by FramePort" in new and "FRAMEPORT_DESKTOP" not in new:
new = upgrade_linux_launcher(new)
if OLD_WATCHDOG in new and "parent=$PPID" not in new:
@@ -2330,94 +2306,6 @@ def upgrade_launchers():
return changed
def wants_hw_video(anchor):
"""Whether this game's launcher gets the shared codec (frame.hw_video_decode): deployment.json's choice (written
at finalize), else its recipe's patches. A game that had agent <= 70's per-game codec (extracted from its APK:
Batman) keeps it; that choice is saved, since ensure_host_fixes then removes the old codec folder."""
path = os.path.join(anchor, "deployment.json")
try:
with open(path) as f:
dep = json.load(f)
except (OSError, ValueError):
return False
if not isinstance(dep, dict):
return False
if "hw_video_decode" in dep:
return bool(dep["hw_video_decode"])
recipe = dep.get("recipe") if isinstance(dep.get("recipe"), dict) else {}
if HW_VIDEO_PATCH in (recipe.get("patches") or []):
return True
base = dep.get("base")
if isinstance(base, str) and os.path.exists(os.path.join(base, "frameport-codec/bin/podman")):
dep["hw_video_decode"] = True
with open(path + ".tmp", "w") as f:
json.dump(dep, f, indent=2)
os.replace(path + ".tmp", path)
return True
return False
def set_codec_line(text, want):
"""A Lepton launcher with the current codec line where it belongs (want) or none; earlier lines are dropped."""
at = -1
for line in (VIDEO_CODEC_LINE, *OLD_CODEC_LINES):
i = text.find(line + "\n")
while i >= 0:
text = text[:i] + text[i + len(line) + 1:]
at = i if at < 0 else min(at, i) # the earliest one's place (text before it is unchanged)
i = text.find(line + "\n")
if want:
if at < 0:
for anchor in ("\nchild=''\n", "\nsetsid "):
if anchor in text:
at = text.index(anchor) + 1
break
if at >= 0:
text = text[:at] + VIDEO_CODEC_LINE + "\n" + text[at:]
return text
def remove_old_codec_dirs():
"""Agent <= 70 extracted a per-game codec into <base>/frameport-codec; the shared codec replaced it. Removed
once the game's launcher no longer uses it (upgrade_launchers converted it; the choice is in deployment.json or
the recipe's patches)."""
removed = []
for dep_path in glob.glob(os.path.join(ANCHORS, "*/deployment.json")):
try:
with open(dep_path) as f:
dep = json.load(f)
base = dep.get("base")
except (OSError, ValueError, AttributeError):
continue
old = os.path.join(base, "frameport-codec") if isinstance(base, str) else ""
if not old or not os.path.isdir(old) or os.path.islink(old):
continue
try:
with open(os.path.join(os.path.dirname(dep_path), "launch.sh")) as f:
launcher = f.read()
except OSError:
launcher = ""
if "frameport-codec" in launcher:
continue # its launcher hasn't been converted yet
shutil.rmtree(old, ignore_errors=True)
removed.append(dep.get("package") or os.path.basename(os.path.dirname(dep_path)))
return removed
def cmd_video_codec_switch(args):
"""FramePort's setting "Hardware video decoding" for every game on this Frame: off writes VIDEO_CODEC_DIR/disabled,
which launchers and the wrapper check at every start (no launcher rewrite, running games keep what they have)."""
flag = os.path.join(VIDEO_CODEC_DIR, "disabled")
if args.get("enabled", True):
if os.path.exists(flag):
os.remove(flag)
else:
os.makedirs(VIDEO_CODEC_DIR, exist_ok=True)
with open(flag, "w") as f:
f.write("switched off in FramePort\n")
return cmd_video_codec_status({})
def upgrade_linux_launcher(text):
"""A Linux app's launcher from before agent v63, made fit for Desktop Mode's menu entry (GitHub #84): no Steam
parent watchdog and no display taken from Steam when FRAMEPORT_DESKTOP is set."""
@@ -2430,13 +2318,12 @@ def upgrade_linux_launcher(text):
return text
def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False):
def write_launcher(anchor, base, pkg, title, appid, lepton, env):
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (env or {}).items()
if re.fullmatch(r"[A-Z_][A-Z0-9_]*", k))
text = LAUNCH_SH.format(title=title.replace("\n", " "), pkg=pkg, base_q=shlex.quote(base), appid=appid,
lepton_q=shlex.quote(lepton), extra_env=extra, watchdog=WATCHDOG,
dashboard=dashboard_line(), logcat=logcat_line(), single=SINGLE_LINE,
video_codec=VIDEO_CODEC_LINE if hw_video else "",
plays_start=plays_lines(anchor)[0],
plays_end=plays_lines(anchor)[1])
path = os.path.join(anchor, "launch.sh")
@@ -2450,112 +2337,45 @@ def data_files_dir(base, pkg):
return os.path.join(base, "lepton-data/external/Android/data", pkg, "files")
def cmd_video_codec_status(args):
"""The shared codec installed on this Frame ({digest, revision}, {} if none or damaged) and whether FramePort's
setting switched it off for every game ("disabled")."""
status = video_codec_installed()
status["disabled"] = os.path.exists(os.path.join(VIDEO_CODEC_DIR, "disabled"))
return status
def video_codec_installed():
path = os.path.join(VIDEO_CODEC_DIR, "current", "manifest.json")
try:
with open(path, "rb") as f:
raw = f.read()
manifest = json.loads(raw)
with open(os.path.join(os.path.dirname(path), "deployment.json")) as f:
config = json.load(f)
if not isinstance(config, dict) or config.get("scope") != "shared" or \
config.get("runtime_sha256") != manifest["runtime_sha256"]:
return {}
for name in VIDEO_CODEC_FILES:
local = "bin/podman" if name == "podman.py" else name
if sha256_file(os.path.join(os.path.dirname(path), local)) != manifest["files"][name]:
return {}
return {"digest": hashlib.sha256(raw).hexdigest(), "revision": manifest.get("revision", 1)}
except (OSError, ValueError, KeyError, TypeError):
return {}
def prune_video_codec_versions(versions, keep):
"""Each revision is ~15 MB. Keep the active one and the one it replaced (a launch that resolved the old
'current' just before the switch still mounts its files); running containers hold their mounts anyway."""
for name in os.listdir(versions):
if name in keep or not re.fullmatch(r"[0-9a-f]{64}(\.previous-[0-9]+)?|\.install-.*", name):
continue
path = os.path.join(versions, name)
if os.path.isdir(path) and not os.path.islink(path):
shutil.rmtree(path, ignore_errors=True)
def cmd_install_video_codec(args):
"""Verify a shared payload, then publish its complete version in one step."""
encoded = args["bundle"]
if not isinstance(encoded, str) or len(encoded) > 32 * 1024 * 1024:
raise AgentError("oversized video codec bundle")
import io
raw = base64.b64decode(encoded, validate=True)
data = {}
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
if set(archive.namelist()) != {"manifest.json", *VIDEO_CODEC_FILES} or len(archive.infolist()) != 5:
raise AgentError("unexpected video codec bundle files")
for name in ("manifest.json", *VIDEO_CODEC_FILES):
if archive.getinfo(name).file_size > 16 * 1024 * 1024:
def install_video_codec(base, lepton, appid):
"""Extract the APK's codec assets for this game's container. Never alter shared Lepton or MP4 files."""
directory = os.path.join(base, "frameport-codec")
prefix = "assets/frameport/hevc/"
with zipfile.ZipFile(os.path.join(base, "lepton-app/game.apk")) as apk:
if prefix + "manifest.json" not in apk.namelist():
# An older/unpatched APK must not keep using an obsolete codec.
wrapper = os.path.join(directory, "bin/podman")
if os.path.exists(wrapper):
os.remove(wrapper)
return False
manifest = json.loads(apk.read(prefix + "manifest.json"))
data = {}
for name in ("libstagefrighthw.so", "media_codecs_frameport.xml", "podman.py", "COPYING.FFmpeg"):
info = apk.getinfo(prefix + name)
if info.file_size > 16 * 1024 * 1024:
raise AgentError(f"oversized video codec asset: {name}")
data[name] = archive.read(name)
manifest = json.loads(data["manifest.json"])
if not isinstance(manifest, dict) or not isinstance(manifest.get("files"), dict) or \
not isinstance(manifest.get("revision", 1), int) or manifest.get("revision", 1) < 1:
raise AgentError("invalid video codec manifest")
digest = hashlib.sha256(data["manifest.json"]).hexdigest()
if digest != args["digest"]:
raise AgentError("video codec manifest checksum mismatch")
for name in VIDEO_CODEC_FILES:
if hashlib.sha256(data[name]).hexdigest() != manifest["files"][name]:
raise AgentError(f"video codec asset checksum mismatch: {name}")
# A second PC with older FramePort must not downgrade the shared codec.
os.makedirs(VIDEO_CODEC_DIR, exist_ok=True)
with open(os.path.join(VIDEO_CODEC_DIR, "install.lock"), "a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
current = video_codec_installed()
if current.get("digest") not in (None, digest) and current.get("revision", 0) >= manifest.get("revision", 1):
# another PC's FramePort installed this revision (or a newer one) built differently: keep it, two
# PCs mustn't replace each other's codec at every connection
return dict(current, kept=True)
versions = os.path.join(VIDEO_CODEC_DIR, "versions")
os.makedirs(versions, exist_ok=True)
version = os.path.join(versions, digest)
previous = os.path.basename(os.path.realpath(os.path.join(VIDEO_CODEC_DIR, "current")))
if current.get("digest") != digest:
stage = tempfile.mkdtemp(prefix=".install-", dir=versions)
try:
os.mkdir(os.path.join(stage, "bin"))
# Config first, executable last, then expose the entire version.
config = {"scope": "shared", "runtime_sha256": manifest["runtime_sha256"]}
with open(os.path.join(stage, "deployment.json"), "w") as f:
json.dump(config, f)
for name in ("manifest.json", *[n for n in VIDEO_CODEC_FILES if n != "podman.py"], "podman.py"):
target = os.path.join(stage, "bin/podman" if name == "podman.py" else name)
with open(target, "wb") as f:
f.write(data[name])
os.chmod(target, 0o755 if name == "podman.py" else 0o644)
if os.path.lexists(version):
# Preserve an interrupted/corrupt prior version for diagnosis.
os.rename(version, version + f".previous-{time.time_ns()}")
os.rename(stage, version)
link = os.path.join(VIDEO_CODEC_DIR, f".current-{os.getpid()}")
if os.path.lexists(link):
os.unlink(link)
os.symlink(os.path.join("versions", digest), link)
os.replace(link, os.path.join(VIDEO_CODEC_DIR, "current"))
finally:
if os.path.isdir(stage):
shutil.rmtree(stage)
prune_video_codec_versions(versions, {digest, previous})
upgraded = upgrade_launchers()
return {"digest": digest, "revision": manifest.get("revision", 1), "launchers": upgraded}
data[name] = apk.read(info)
if hashlib.sha256(data[name]).hexdigest() != manifest["files"][name]:
raise AgentError(f"video codec asset checksum mismatch: {name}")
real_podman = shutil.which("podman")
if not real_podman:
raise AgentError("podman is unavailable")
os.makedirs(os.path.join(directory, "bin"), exist_ok=True)
config = {"lepton": lepton, "appid": str(appid), "podman": real_podman,
"runtime_sha256": manifest["runtime_sha256"]}
path = os.path.join(directory, "deployment.json")
with open(path + ".tmp", "w") as f:
json.dump(config, f)
os.replace(path + ".tmp", path)
# Publish the executable last: a first install cannot expose a wrapper
# whose configuration or codec files have not been written yet.
for name in (*[n for n in data if n != "podman.py"], "podman.py"):
path = os.path.join(directory, "bin/podman" if name == "podman.py" else name)
with open(path + ".tmp", "wb") as f:
f.write(data[name])
os.chmod(path + ".tmp", 0o755 if name == "podman.py" else 0o644)
os.replace(path + ".tmp", path)
return True
def set_flatscreen(app_dir, on):
@@ -2638,9 +2458,8 @@ def cmd_finalize(args):
with open(target, "w") as f:
f.write(content)
models = install_controller_models(files_dir, str(settings.get("controller_models", 0)) not in ("0", "0.0"))
recipe = args.get("recipe") if isinstance(args.get("recipe"), dict) else {}
hw_video = HW_VIDEO_PATCH in (recipe.get("patches") or []) # the shared codec (install_video_codec)
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"), hw_video)
install_video_codec(base, lepton, appid)
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"))
art_in = os.path.join(base, "incoming-artwork")
if os.path.isdir(art_in):
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
@@ -2648,7 +2467,7 @@ def cmd_finalize(args):
dep = {"package": pkg, "appid": int(appid), "base": base, "title": title, "tags": args.get("tags") or [],
"apk": args.get("apk_name", "game.apk"),
"sha256": args.get("apk_sha256"), "recipe": args.get("recipe"), "installed_by": "frameport",
"hw_video_decode": hw_video, "agent_version": AGENT_VERSION, "time": time.time()}
"agent_version": AGENT_VERSION, "time": time.time()}
with open(os.path.join(anchor, "deployment.json"), "w") as f:
json.dump(dep, f, indent=2)
return {"ok": True, "base": base, "appid": appid, "moved_data_files": moved, "controller_models": models}
+4 -4
View File
@@ -13,10 +13,10 @@ aa4dc0020c77e12d41ef6ce80b23ad90c9cb7e882338eddf2261efe8645cf38d ./arm64-v8a/li
7943c6825e1c2841e54627f96acbd0754a3c06bb608c47f7aa15007aec2191a5 ./armeabi-v7a/libopenxr_loader_generic.so
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
4516971bbb2b635e19b14d2d37b9353962876f7de02043e7457327052c1fcbc0 ./hevc/manifest.json
46c3dcad2c43cc30bea3b3680b362ed84a99c15d60714bbbbb422c80ecf90405 ./hevc/media_codecs_frameport.xml
529ea35d5d82474afe9e09ad1216d269317f56dd24a05fb100fced52b7b4dbca ./hevc/podman.py.txt
c1c2a73e269ff765ce6ed74cd19d37d2258a7499873d799c8ee4799575cd56d3 ./hevc/libstagefrighthw.so
7d0e0677d1dc414766a966a4ad60642364b60829fa8873b51534b17c8e571870 ./hevc/manifest.json
e4028fccc30132918160c230958aeac616ac6dbf851e6af0c1fe15517b30f2e6 ./hevc/media_codecs_frameport.xml
66caeda1938934f5bc76d35261641ccee2020015d9932559943749fabd274088 ./hevc/podman.py.txt
1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json
28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.so
b10b3a5c10c3339fc63fd9f6cb4d01ea29be11a863d0ebad1389ea69fd471940 ./linux-arm64-bin/fp_venc
Binary file not shown.
+3 -9
View File
@@ -1,17 +1,11 @@
{
"revision": 8,
"runtime_sha256": "456e912c75cd389abcf6a63bc80e2a53bdc334371d00b200c93680388ae955e2",
"files": {
"libstagefrighthw.so": "141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c",
"podman.py": "529ea35d5d82474afe9e09ad1216d269317f56dd24a05fb100fced52b7b4dbca",
"media_codecs_frameport.xml": "46c3dcad2c43cc30bea3b3680b362ed84a99c15d60714bbbbb422c80ecf90405",
"libstagefrighthw.so": "c1c2a73e269ff765ce6ed74cd19d37d2258a7499873d799c8ee4799575cd56d3",
"podman.py": "a55a7d18a5725d5b1f461999dab35c988b283dc885fc0f7a41d2d762911729de",
"media_codecs_frameport.xml": "e4028fccc30132918160c230958aeac616ac6dbf851e6af0c1fe15517b30f2e6",
"COPYING.FFmpeg": "b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe"
},
"codecs": [
"video/hevc",
"video/avc",
"video/x-vnd.on2.vp9"
],
"build": {
"ndk_revision": "27.2.12479018",
"ffmpeg_source_sha256": "733984395e0dbbe5c046abda2dc49a5544e7e0e1e2366bba849222ae9e3a03b1"
-18
View File
@@ -10,23 +10,5 @@
<Limit name="bitrate" range="1-245000000" />
<Limit name="concurrent-instances" max="1" />
</MediaCodec>
<MediaCodec name="OMX.frameport.avc.decoder" type="video/avc" rank="64">
<Limit name="size" min="128x128" max="8192x8192" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Limit name="block-count" range="1-138240" />
<Limit name="blocks-per-second" range="1-7864320" />
<Limit name="bitrate" range="1-245000000" />
<Limit name="concurrent-instances" max="1" />
</MediaCodec>
<MediaCodec name="OMX.frameport.vp9.decoder" type="video/x-vnd.on2.vp9" rank="64">
<Limit name="size" min="128x128" max="4096x2304" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Limit name="block-count" range="1-36864" />
<Limit name="blocks-per-second" range="1-2211840" />
<Limit name="bitrate" range="1-245000000" />
<Limit name="concurrent-instances" max="1" />
</MediaCodec>
</Decoders>
</MediaCodecs>
+18 -63
View File
@@ -1,9 +1,8 @@
#!/usr/bin/python3
"""Add shared codecs to Lepton containers without editing their shared rootfs."""
"""Add a per-game codec to Lepton's container, without editing its shared rootfs."""
import hashlib
import json
import os
import re
import shutil
import sys
from pathlib import Path
@@ -19,30 +18,11 @@ def mounts(directory, config, args):
name = args[i + 1]
elif arg.startswith("--name="):
name = arg.partition("=")[2]
if config.get("scope") == "shared":
# The common launcher supplies these values. Do not intercept arbitrary
# Podman containers or depend on a game's package/modified APK.
appid = os.environ.get("SteamAppId", "")
if not re.fullmatch(r"[0-9]+", appid) or name != f"lepton-steamlaunch-{appid}":
return []
app = Path(os.environ.get("STEAM_COMPAT_INSTALL_PATH", ""))
if app.name != "lepton-app" or not app.is_dir():
return []
root_arg = None
for i, arg in enumerate(args):
if arg == "--rootfs" and i + 1 < len(args):
root_arg = args[i + 1]
elif arg.startswith("--rootfs="):
root_arg = arg.partition("=")[2]
if not root_arg or not root_arg.endswith(":O"):
return []
root = Path(root_arg[:-2]).resolve()
else: # old per-game deployments remain compatible during migration
if name != f"lepton-steamlaunch-{config['appid']}":
return []
root = Path(config["lepton"]).resolve().parent / "images" / "rootfs"
if name != f"lepton-steamlaunch-{config['appid']}":
return []
root = Path(config["lepton"]).resolve().parent / "images" / "rootfs"
expected_root = str(root) + ":O"
if not any(arg in (expected_root, "--rootfs=" + expected_root) for arg in args):
if not any(arg == expected_root for arg in args):
return []
device = Path("/dev/video-dec0")
runtime = root / "vendor/lib64/libstagefright_softomx.so"
@@ -56,53 +36,30 @@ def mounts(directory, config, args):
if target == "/vendor/lib64/libstagefright_softomx.so" and fields.get("source"):
runtime = Path(fields["source"])
if upstream_plugin:
print("FramePort video: using the runtime's hardware codec plugin", file=sys.stderr)
print("FramePort HEVC: using the runtime's hardware codec plugin", file=sys.stderr)
return []
if not device.exists() or hashlib.sha256(runtime.read_bytes()).hexdigest() != config["runtime_sha256"]:
print("FramePort video: device or runtime ABI differs; retaining the stock codecs", file=sys.stderr)
print("FramePort HEVC: device or runtime ABI differs; retaining the stock codecs", file=sys.stderr)
return []
xml = ET.parse(root / "vendor/etc/media_codecs.xml")
if not any(node.get("href") == "media_codecs_frameport.xml" for node in xml.getroot().findall("Include")):
ET.SubElement(xml.getroot(), "Include", href="media_codecs_frameport.xml")
# Immutable plugin versions can serve simultaneous app launches and
# different Lepton installations. Never share a temporary XML filename.
# The version directory stays as the agent verified it: the merged list goes to the user's runtime dir.
root_key = hashlib.sha256(str(root).encode()).hexdigest()[:16]
merged = merged_dir() / f"media_codecs.{root_key}.xml"
temporary = merged.with_suffix(f".{os.getpid()}.tmp")
ET.SubElement(xml.getroot(), "Include", href="media_codecs_frameport.xml")
merged = directory / "media_codecs.xml"
temporary = merged.with_suffix(".tmp")
xml.write(temporary, encoding="utf-8", xml_declaration=True)
temporary.replace(merged)
# Lepton supplies its own /dev tmpfs. A Podman --device node disappears
# beneath it; a bind mount matches Lepton's existing GPU/sound device setup.
result = ["--mount", f"type=bind,source={device},destination=/dev/video-dec0,rw"]
for path, target in (
(directory / "libstagefrighthw.so", "/vendor/lib64/libstagefrighthw.so"),
(merged, "/vendor/etc/media_codecs.xml"),
(directory / "media_codecs_frameport.xml", "/vendor/etc/media_codecs_frameport.xml"),
for source, target in (
("libstagefrighthw.so", "/vendor/lib64/libstagefrighthw.so"),
("media_codecs.xml", "/vendor/etc/media_codecs.xml"),
("media_codecs_frameport.xml", "/vendor/etc/media_codecs_frameport.xml"),
):
if not path.is_file():
raise FileNotFoundError(f"missing video codec mount: {path}")
result += ["--mount", f"type=bind,source={path},destination={target},ro"]
print("FramePort video: loading the Iris hardware codec plugin for this container", file=sys.stderr)
result += ["--mount", f"type=bind,source={directory / source},destination={target},ro"]
print("FramePort HEVC: loading the Iris hardware codec plugin for this container", file=sys.stderr)
return result
def merged_dir():
runtime = os.environ.get("XDG_RUNTIME_DIR", "")
base = Path(runtime) if runtime and Path(runtime).is_dir() else Path.home() / ".cache"
path = base / "frameport-video"
path.mkdir(mode=0o700, parents=True, exist_ok=True)
return path
def switched_off(directory):
"""FRAMEPORT_NO_HW_VIDEO=1 (e.g. in a game's Steam launch options) or the Frame-wide switch (FramePort's
setting; the agent writes video-codec/disabled) leave every container with Android's stock codecs."""
if os.environ.get("FRAMEPORT_NO_HW_VIDEO", "") not in ("", "0"):
return True
return directory.parent.name == "versions" and (directory.parent.parent / "disabled").exists()
def real_podman(directory):
"""Find Podman independently of deployment.json, without recursing into this wrapper."""
own_bin = (directory / "bin").resolve()
@@ -127,18 +84,16 @@ def main():
directory = Path(__file__).resolve().parent.parent
args = sys.argv[1:]
fallback = real_podman(directory)
if switched_off(directory):
os.execv(fallback, [fallback, *args])
try:
config = json.loads((directory / "deployment.json").read_text())
podman = Path(config.get("podman", fallback)).resolve()
podman = Path(config["podman"]).resolve()
if podman.parent == (directory / "bin").resolve() or podman == Path(__file__).resolve():
raise ValueError("configured Podman points to the codec wrapper")
extra = mounts(directory, config, args)
launch_args = [args[0], *extra, *args[1:]] if extra else args
os.execv(str(podman), [str(podman), *launch_args])
except Exception as exc: # a codec/configuration failure must never prevent the stock container from starting
print(f"FramePort video: retaining stock codecs: {exc}", file=sys.stderr)
print(f"FramePort HEVC: retaining stock codecs: {exc}", file=sys.stderr)
os.execv(fallback, [fallback, *args])
@@ -6,14 +6,10 @@ notes: 'Native OpenXR GLES video player. 360 theatres/videos (equirect2 layers,
at 72 fps (the runtime asks for 4536 px per eye). Confirmed in the headset by the owner (2026-10-01). Its "Internal
Storage" list is /sdcard/4XPlayer: send videos with the game page''s "Add videos".'
details: 'Settings: equirect_emul (GLES worker draws 360 layers into a projection layer), stable_local, focus_hold,
scale 0.75; frame.swapchain_limit for its 7680x3840 theatre swapchains. Its hardware decoding mode (Java MediaCodec,
Vr4pMediaPlayer; its own FFmpeg is the software path) gets the Frame''s video hardware with frame.hw_video_decode
(added 2026-10-09, not yet checked in the headset).'
scale 0.75; frame.swapchain_limit for its 7680x3840 theatre swapchains.'
tested_version: 2.0.22
engine: Other
xr: OpenXR
frame:
- frame.hw_video_decode
adapter:
equirect_emul: 1
stable_local: 1
@@ -21,6 +17,4 @@ adapter:
scale: 0.75
verified:
date: '2026-10-01'
updated: '2026-10-10'
min_app: 0.12.1
source_hint: 4XVR Video Player
+4 -9
View File
@@ -2,25 +2,20 @@ package: com.camouflaj.manta
title: 'Batman: Arkham Shadow'
status: works
notes: Large data set (~27 GiB) incl. all language packs.
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
after the Frame runtime crashed in xrSyncActions (CVRInputLatest::UpdateActionState), the input race Myst had.'
details: 'Uses Meta XR Audio (Wwise), handled by patch_meta_xr_audio. Includes all language packs (~28 GB). sync_guard
(to test, GitHub #102) - a reporter''s game quit to Steam''s waiting screen after the Frame runtime crashed in
xrSyncActions (CVRInputLatest::UpdateActionState), the input race Myst had.'
tested_version: 1.4.1-350961
engine: Unity
xr: OpenXR
overport_extra:
- patch_disable_space_warp
frame:
- frame.hw_video_decode
adapter:
sync_guard: 1
surface_native: 1
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: 796d1cab08f614d72c50d9702974c719c0908f5de91183cfe02d1fd5eb09551a
updated: '2026-10-10'
min_app: 0.12.1
updated: '2026-10-09'
source_hint: Batman- Arkham Shadow
-24
View File
@@ -1,24 +0,0 @@
package: com.harmonixmusic.kata
title: Audica
status: works
tested_version: 1.0.3.4
engine: Unity
xr: VrApi
frame:
- frame.unity_no_msaa
- frame.unity_oculus_check
- frame.unity_text_input
- frame.unity_runtime_msaa_off
- frame.unity_gl_shim
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-09'
known_good_sha256: bc2c00fe049fd97643a296e622e3a90e08f0bd796ee454b0278525e9d72f8139
app: 0.12.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 59
issue: 126
+30 -7
View File
@@ -456,15 +456,38 @@ signatures:
diagnosis: FrameBridge couldn't emulate an Android video panel (XR_KHR_android_surface_swapchain); the panel stays
empty, and a game waiting for its intro video may stay black (e.g. I Am Monkey's intro).
suggest: []
- id: hw-video-decoder-busy
pattern: 'Iris \S+ unavailable; keeping Android|Iris \S+ initialization failed \(-1[26]\); using software'
severity: info
diagnosis: FramePort's hardware video decoder (frame.hw_video_decode) couldn't open a session, because other
decoder sessions held the hardware (Steam's own hardware video decoding, SteamVR's link while a game starts);
the video was decoded in software instead, which is slow for 4K/8K. Turn off hardware video decoding in Steam's
settings, restart Steam, then start the game again.
# ---- Quest games on this PC (AXRB, docs/PC_ANDROID.md): AXRB's host.err/session.json + guest log
- id: axrb-no-headset
kind: axrb
pattern: 'failed to find D3D11 adapter requested by OpenXR runtime|xrCreateInstance failed: XR_ERROR_RUNTIME_FAILURE'
severity: fatal
diagnosis: The PC's OpenXR runtime (SteamVR) has no headset to show the game on, so AXRB's bridge couldn't open its VR session. Connect the headset (Steam Link / cable) and let SteamVR show it before starting the game.
suggest: []
- id: axrb-host-not-ready
kind: axrb
pattern: 'Host pose server did not become ready|Host failed to start'
severity: fatal
diagnosis: AXRB's Windows bridge (axrb-host-bridge.exe) didn't start, so the game was never launched. Its reason is in host.err (diagnostics); a missing headset is the usual one.
suggest: []
report: true
- id: axrb-gfxstream-crash
kind: axrb
pattern: 'vulkan\.ranchu\.so \(gfxstream::vk::'
severity: fatal
diagnosis: The game crashed inside the Android emulator's Vulkan driver (gfxstream), reached through AXRB's runtime layer (e.g. Pinball FX VR at its first Vulkan allocations). That's AXRB's GPU path, not FramePort's build - report it to AXRB with the diagnostics.
suggest: []
report: true
- id: axrb-translation-crash
kind: axrb
pattern: 'libndk_translation\.so \(berberis::|DEBUG\s*:.*#00 pc \S+\s+/system/lib64/libndk_translation\.so'
severity: fatal
diagnosis: The game crashed in Android's ARM translation (libndk_translation), which runs Quest games' arm64 code on the PC's x86 CPU (known for some Unity games, AXRB issue #9). Nothing to patch in the game; AXRB plans another translator.
suggest: []
milestones: # progress markers, in order; the furthest one reached is reported
- {id: axrb-host, kind: axrb, pattern: 'AXRB TCP: listening', label: AXRB bridge started}
- {id: axrb-runtime, kind: axrb, pattern: 'AXRB\.PoseClient|Got runtime: package: com\.axrb\.openxrruntime', label: AXRB's OpenXR runtime loaded}
- {id: axrb-instance, kind: axrb, pattern: 'xrCreateInstance\(\) succeeded|LoaderInstance::CreateInstance succeeded', label: OpenXR instance created}
- {id: axrb-frames, kind: axrb, pattern: 'AXRB\.Perf end-frame', label: Submitting frames}
- {id: pcvr-launch, kind: pcvr, pattern: 'FramePort: launching|Launched injector with', label: Launcher started}
- {id: pcvr-oculus-hmd, kind: pcvr, pattern: 'FramePort oculushmd: OculusHMDConnected event ready', label: Oculus headset event provided}
- {id: pcvr-inject, kind: pcvr, pattern: 'Succesfully injected|Successfully injected', label: Revive injected into the game}
+80
View File
@@ -0,0 +1,80 @@
# Quest games on this PC (AXRB)
FramePort's PC target ("This PC") already runs Oculus Rift games through Revive. For Quest/Android games it uses
**AXRB** (Android XR Bridge, github.com/Android-XR-Bridge/AXRB): an Android 16 emulator (x86_64 Google APIs image,
WHPX) that runs arm64 games through Android's ARM translation (`libndk_translation`), with AXRB's own OpenXR runtime
inside Android and a Windows host process (`axrb-host-bridge.exe`) that opens a real OpenXR session on the PC's
runtime (SteamVR, Virtual Desktop, ...). FramePort only drives it: it downloads AXRB's official release when a user
first installs a Quest game on the PC, never bundles or modifies it (AXRB's host/clock code is under the AXRB
Source-Available License 1.0, which allows running it and interoperating through its public interfaces).
Status: **experimental**. AXRB is young (v1.0.4, 2026-10-06) and many games still fail in it; Steam Frame
controllers don't work in AXRB yet (AXRB issue #20).
## Spike results (2026-10-10, Windows 11 26200, i7-14700K, RTX 4080 SUPER, 64 GB; headless)
| Step | Result |
|---|---|
| AXRB install | `AXRB-Setup-<v>.exe /S` (electron-builder one-click NSIS, per user, no UAC) → `%LOCALAPPDATA%\Programs\axrb-launcher`, 462 MB. Release assets: Setup exe + `SHA256SUMS-<v>-setup.txt` + source zip (no portable zip since 1.0.4). |
| Runtime setup | Not scriptable through AXRB (it lives in the Electron launcher's JS), so FramePort reproduces it: 4 pinned `dl.google.com` zips from AXRB's `core/components.json` (emulator 36.5.11, platform-tools r37.0.1, build-tools 36.1, `sys-img/google_apis/x86_64-36_r07`), hash-checked, into `%LOCALAPPDATA%\AXRB Runtime\sdk`, AVD `axrb-managed-api36` written as AXRB writes it, `ready.json` / `license-acceptance.json` receipts so AXRB's own launcher sees the same runtime as set up. 2.4 GB download, ~3 min on a fast line; 26 GB on disk after one 7 GB game. |
| Emulator boot | AXRB's `scripts\emulator\windows_android_emulator.ps1 -Action Start` (headless QEMU, AXRB clock adapter + GPU layer). First cold boot ~6 min (TSC-corrected clock cold-boots every time). ABIs `x86_64,arm64-v8a`. QEMU (`Start-Process`) and the adb server inherit the caller's handles: a pipe, a file through WSL interop, even `/dev/null` through interop (its relay stays open) all left the FramePort call hanging after the script had ended → FramePort starts AXRB scripts through WMI (`Win32_Process.Create`, hidden, nothing inherited), polls the PID and reads an `FP_EXIT <code>` line from the log. Windows PowerShell 5.1's `*>>` writes UTF-16. |
| AXRB runtime | `adb install --no-incremental --force-queryable -r axrb-openxr-runtime-debug.apk` (0.3 s); found by the OpenXR loader through the system runtime broker. |
| Data | `adb push` of OBB/pak files: 25-35 MB/s (7.2 GB Pinball FX VR in 5.5 min). A full uninstall (or a signature change: `INSTALL_FAILED_UPDATE_INCOMPATIBLE`) deletes the OBB folder and the saves → FramePort's stable per-game keys matter here too. |
| **Original APK** (Pinball FX VR) | No launcher activity (Meta's VR category only), started explicitly: **SIGABRT 0.3 s after loading Meta's `libovrplatformloader.so`**. Unconverted Quest APKs don't run. |
| **OVRPort-only build** (Lucky's Tale, Unity 2019 GLES) | OVRPort's loader → AXRB runtime → instance + session, frames submitted (2 s each without a host: the image send times out), no crash in 40 s. |
| **OVRPort-only build** (Pinball FX VR, UE5 Vulkan) | OVRPlugin pre-init OK (AXRB reports "Oculus 67.522.0"), then exits (ForceQuit) / with the no-ForceQuit build SIGSEGV in `gfxstream vkAllocateMemory` called from AXRB's runtime Vulkan layer. Not decided headless (no host attached, see below). |
| Host bridge | Needs an OpenXR runtime with a D3D11 adapter. Headless options all failed: SteamVR's null driver (`failed to find D3D11 adapter requested by OpenXR runtime`), `vrlink` without a headset (`xrCreateInstance` -2), Meta XR Simulator (binary download needs a Meta developer login). → frames-to-SteamVR need a real headset. |
**Build decision: FramePort hands AXRB an OVRPort-only build** (the recipe's OVRPort patches, no Steam Frame fixes,
signed with the game's FramePort key). The original APK crashes; FramePort's Frame fixes (FrameBridge adapter, Lepton
and Frame-runtime workarounds) target the Frame. The recipe's alternate (no-ForceQuit) build is used when the recipe
says so.
### FramePort end to end (same day, headless, this branch)
`frameport install com.playful.LuckysTale --to pc --no-library` (isolated `FRAMEPORT_HOME`): PC build (19 patches:
OVRPort's + `frame.ovrplatformcompat`/`frame.ovrstubs`/`frame.swapchain_limit`; static checks pass without the
FrameBridge check) → AXRB found, requirements met → emulator → AXRB runtime → `adb install` → 3.7 GB OBB in ~95 s
(39 MB/s) → `deployment.json` + launcher script. `frameport test com.playful.LuckysTale --to pc`: launcher → AXRB run
script → host bridge → triage `axrb-no-headset` (expected without a headset). The Steam shortcut write (needs a Steam
restart) is unit-tested only.
## How FramePort drives AXRB
- `tools/axrb.py`: find (env `FRAMEPORT_AXRB_DIR` > installed AXRB) / install (Setup exe, SHA256SUMS checked, `/S`) /
runtime setup (above) / requirements (WHPX via AXRB's `check_windows.ps1`, ≥ 12 GB RAM, x64).
- Environment for every AXRB script (the installed build sets none of it itself): `AXRB_DATA_HOME=<Runtime>\output`,
`ANDROID_AVD_HOME=<Runtime>\avd`, `ANDROID_USER_HOME=<Runtime>\android`, `ANDROID_HOME`/`ANDROID_SDK_ROOT=<Runtime>\sdk`,
`ANDROID_ADB_SERVER_PORT=5038`, `ADB_USB_LEGACY=1`, `ADB_LOCAL_TRANSPORT_MAX_PORT=5683`, AXRB's embedded Python
(`resources\runtime\tools\python`) first on PATH; arguments `-Sdk <Runtime>\sdk -Avd axrb-managed-api36 -Port 5584`.
- Install: start the emulator if needed (and stop it again if FramePort started it), install the AXRB runtime APK,
`adb install -r` the build, push OBB/data to `/sdcard/Android/obb|data/<pkg>/`, record `<data>/pc/<pkg>/deployment.json`
(`kind: "android"`).
- Play: a Steam shortcut runs FramePort's small launcher script (`%LOCALAPPDATA%\FramePort\axrb\fp-axrb-run.ps1`),
which sets the environment above and calls AXRB's `scripts\run\run_windows_game.ps1` (boots the emulator when
needed and then owns it, registers a SteamVR app identity, starts the host bridge, `am start`, waits; exit codes
0 ok, 1 error, 3 game lost, 4 host lost; `logs\game\session.json`, `host.err`).
- Logs for diagnostics: `<Runtime>\output\logs\game\{host.log,host.err,session.json,guest.log}`,
`logs\emulator\emulator.std{out,err}.log`.
## Headset results (2026-10-10, Steam Frame streaming to the PC through Steam Link / vrlink, 96 Hz)
AXRB's bridge opened a SteamVR session on the Frame each time (session state up to FOCUSED, both hands active), started
through FramePort's launcher with the emulator booted by AXRB's run script (~1 min), and shut down cleanly.
- **Lucky's Tale (Unity, GLES): black.** GLES games go through AXRB's raw image path (eye readback → TCP), sized
by SteamVR's recommended resolution: 3412×3412 per eye at the owner's 250 % supersampling = 93 MB per frame,
~10 s per frame; at a lowered resolution 2644×2644 = 56 MB, 6.6 s per frame (~8.5 MB/s). Not fixable from
FramePort (AXRB has no maximum-extent setting, only AXRB_MIN_EYE_EXTENT); even ~1000² would be ~1 fps.
AXRB's zero-copy GPU path covers Vulkan games only.
- **Pinball FX VR (UE5, Vulkan): crash 4 s after start**, the same gfxstream `vkAllocateMemory` SIGSEGV from AXRB's
runtime layer as headless (so not a headless artefact). AXRB's side.
Not reached: sound, controllers. Next candidates: a Vulkan game AXRB reports playable (Deadpool VR, Batman: Arkham
Shadow); an AXRB issue for the Pinball crash.
## Open (needs the owner + a headset)
- A game in the headset through SteamVR (picture, audio, controllers; Steam Frame controllers: AXRB #20).
- Pinball FX VR's Vulkan crash with the host attached (AXRB's demo game: likely an AXRB/emulator issue if it repeats).
- Play from the PC's Steam library (shortcut → launcher script → AXRB).
+11 -1
View File
@@ -104,7 +104,6 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Setup/intro loops every launch (Espire 2) | save folders created without write permission | launcher repairs permissions every 2 s (built in) |
| VrApi bridge: never enters VR | Frame reaches FOCUSED later than Quest; tracking only when worn | bridge waits 30 s; test in the headset |
| Unreal game (ILMxLAB, e.g. Vader Immortal) plays its intro, then stays on its loading card (portrait + progress bar) at 72 fps and ignores input (GitHub #49) | the menu waits for `UVRUtils::GetQuestShaderPrecompilePercent()` to reach 100 %; the precompile only starts when `IsRunningOnSantaCruz()` (a Quest), the other branch returns 0.0 | `frame.unreal_quest_precompile` (non-Quest branch returns 1.0; found by Klownicle); suggested when the engine exports that function (analysis `unreal_quest_gates`) |
| Video in a game or player stutters, falls behind or drops to a few fps (4K/8K, e.g. a video player's 360° video, Batman's cutscenes); `OMX.google.*` / `c2.android.*` decoders in the log | Lepton's Android has only software decoders | `frame.hw_video_decode` (suggested when the APK uses MediaCodec/ExoPlayer/VLC; no APK change, reinstall): `OMX.frameport.{avc,hevc,vp9}.decoder` on the Frame's Iris hardware (H.264 High, HEVC Main, VP9 profile 0 up to 4096x2304; 8-bit; up to 8192 px), mounted into this game's container only (log `Iris hardware video/… decoder active`; launch.log `FramePort video: loading the Iris hardware codec plugin`). `Iris … unavailable` / `initialization failed (-12)` (triage `hw-video-decoder-busy`): another decoder holds the hardware, usually Steam's own hardware video decoding: turn it off in Steam, restart Steam. Video broken with it: Settings → Installing → Hardware video decoding off (all games) or `FRAMEPORT_NO_HW_VIDEO=1 %command%` (one game) |
## Picture problems (headset on)
| Symptom | Cause | Fix |
@@ -221,3 +220,14 @@ Scan a folder with Rift game dumps (Windows game folders) like Quest dumps; they
| Proton game hangs at start in a headless/SSH launch; log `no driver could be loaded` / `explorer process failed to start` | no display session (DISPLAY/GAMESCOPE_WAYLAND_DISPLAY) | the Proton launch.sh takes them from the running Steam (built in) |
| Unreal game starts (window created) then `CrashReportClient` runs | the game crashed under Proton; often no VR runtime reached it (Revive off) | keep `pcvr.revive` on (repacks' bundled LibRevive64.dll isn't loaded by itself); read the game log + crash summary in the launch log |
| `VK_ERROR_DEVICE_LOST` under DXVK | freedreno GPU hang | `pcvr.proton_log` to capture; PC mode |
## Quest games on this PC (AXRB, `docs/PC_ANDROID.md`)
| Symptom | Cause | Fix |
|---|---|---|
| Unconverted Quest APK in AXRB: no launcher activity, SIGABRT ~0.3 s after `libovrplatformloader.so` loads | Meta's platform loader aborts outside a Quest | FramePort always gives AXRB its OVRPort build (the PC build: OVRPort + `on_pc` APK fixes, no Steam Frame fixes) |
| `axrb-no-headset`: host.err `failed to find D3D11 adapter requested by OpenXR runtime` / `xrCreateInstance failed: XR_ERROR_RUNTIME_FAILURE` | SteamVR has no headset (null driver: no D3D11 adapter; vrlink with no Frame connected: no instance) | connect the headset and let SteamVR show it first; headless checks can only go as far as the guest log (`am start`, logcat) |
| `axrb-gfxstream-crash`: SIGSEGV in `vulkan.ranchu.so (gfxstream::vk::ResourceTracker::on_vkAllocateMemory)` from `libVkLayer_AXRB_runtime.so` (Pinball FX VR, UE5 Vulkan, headless) | the emulator's Vulkan path through AXRB's runtime layer | report to AXRB with diagnostics (`axrb/*` logs); recheck with the host attached |
| `axrb-translation-crash`: crash in `libndk_translation.so (berberis::…)` | Android's ARM translation (AXRB issue #9, some Unity games) | nothing to patch; wait for AXRB's other translator |
| Reinstall fails `INSTALL_FAILED_UPDATE_INCOMPATIBLE`, or the game data must be copied again | a copy signed with another key was installed in AXRB (e.g. by AXRB's own launcher); a full uninstall deletes the OBB folder and saves | uninstall it in AXRB first; FramePort keeps one key per game, so its own updates keep data |
| A FramePort call that runs an AXRB script never returns although the script ended (emulator already up) | QEMU (`Start-Process`) and the adb server inherit the caller's handles; through WSL interop even a file or /dev/null keeps the relay open | `axrb.spawn_detached` starts the script through WMI (`Win32_Process.Create`, nothing inherited) and polls its PID; exit code from the log's `FP_EXIT` line |
+16 -20
View File
@@ -9,15 +9,12 @@ assets.
## Scope and installation
FrameBridge enables the native-video path through the recipe-only adapter
setting `surface_native` (Batman's catalog recipe; a one-time library migration,
`batman_video_patches`, adds it and `frame.hw_video_decode` to existing Batman
recipes). Hardware decoding is deployed independently into FramePort's shared,
versioned codec store, without embedding decoder assets; games that have
`frame.hw_video_decode` in their recipe get the launcher line that places the
shared wrapper on the launcher's child PATH. Matching containers
receive read-only plugin/XML mounts and the Iris decoder device. Shared Lepton
files and original MP4/OBB assets are
FrameBridge enables the internal native-video path only for this package's
arm64 APK. It bundles a private hardware codec, its checksum manifest, and a
Podman wrapper in that APK. The agent verifies and extracts those files into
the game's own directory. The launcher places this wrapper on its child PATH.
Only the matching game's container receives the read-only plugin/XML mounts
and Iris decoder device. Shared Lepton files and original MP4/OBB assets are
never replaced, transcoded, resized, or rewritten.
The native projection, view recording and Vulkan-enable hooks are gated by
@@ -37,24 +34,23 @@ If the driver's session limit is exhausted, Android can fall back to software
decoding; that preserves functionality but not full-resolution performance.
FramePort does not change Steam's global hardware-decoding settings.
Other games don't get `surface_native`. The decoder is separate from that
setting, and runs in Lepton's arm64 media service. Rebuilding an older APK
removes the previously bundled codec files; launcher migration replaces
Batman's old per-game wrapper with the shared one. MP4 presence alone is not
evidence of compatible surface/overlay semantics.
Other packages and arm32 APKs do not receive the native-video setting or these
codec assets. Rebuilding an older unrelated APK removes the previously bundled
codec files, and installing an APK without its manifest disables its old wrapper.
MP4 presence alone is not evidence of compatible surface/overlay semantics.
The recipe change marks Batman's installed build "Update on Frame"; the shared
adapter revision is unchanged, so other games stay installed as they are.
Package-specific patch revisions use the existing Update on Frame state without
adding UI controls or wording. The shared adapter revision is unchanged; only
Batman is marked outdated for this repair. Older builds without recorded recipe
fingerprints are handled with the same package scope.
## Decode and render path
`native/hevc/frameport_hevc.cpp` exposes `OMX.frameport.hevc.decoder` through the
tested SoftOMX ABI, using FFmpeg's LGPL Iris V4L2 hardware decoder wrapper.
Native surface clients receive full-resolution YUV hardware buffers with fences.
Compatible large native surfaces use fenced Vulkan transfers between imported
decoder and Android DMA buffers, retaining the decoded frame until GPU completion.
Other surfaces retain parallel NV12 row copies without CPU color conversion.
Input timestamps, EOS, dynamic dimensions, seek and flush
Persistent workers copy disjoint NV12 row bands into Android buffers without
CPU color conversion. Input timestamps, EOS, dynamic dimensions, seek and flush
are preserved.
An AImageReader retains the decoder's actual image. The surface worker samples
+31 -158
View File
@@ -1,148 +1,50 @@
# Shared hardware video decoding for Lepton
# Hardware HEVC for Lepton
`frameport_hevc.cpp` implements Android OMX decoders for HEVC Main, H.264
(Constrained) Baseline/Main/(Constrained) High and VP9 Profile 0 using Iris
stateful V4L2 through FFmpeg. Android reports constrained H.264 streams under
separate profile values, so they are listed as stock decoders list them.
These are 8-bit 4:2:0 decoders. Format-aware clients keep Android's existing
codecs for Main10/HDR and protected content; clients selecting by MIME alone
must still respect the component's advertised profiles. Other codec types
retain Android's existing codecs. Applications that explicitly select their
own software decoder bypass this integration. Byte-buffer clients receive full-size
`frameport_hevc.cpp` implements `OMX.frameport.hevc.decoder` using the Iris
stateful V4L2 decoder through FFmpeg's LGPL hardware wrapper. The FFmpeg build
does not contain a software HEVC decoder. Byte-buffer clients receive full-size
planar frames. Native surface clients receive YUV in Android hardware buffers,
with acquire/release fences. For large hardware-decoded NV12 surfaces (at least
4096×2048 pixels), compatible linear NV12 buffers are imported into Vulkan
and copied on the GPU without CPU pixel access or colour conversion. The decoded
AVFrame remains held until the transfer fence completes, preventing its Iris
capture buffer from being requeued. Explicit FOREIGN queue-family transfers
return both buffers to their Android/decoder owners before delivery. Imports are
cached within a bounded pool and cleared before decoder reconfiguration or flush.
Unsupported layouts, unavailable Vulkan capabilities and import failures retain
the parallel CPU copy; a submitted transfer that fails to complete reports an
OMX error instead of exposing incomplete pixels. This removes the CPU frame copy
on compatible surfaces but still performs one GPU copy; it is not direct decoding
into Android's consumer buffers. Small surfaces use disjoint parallel CPU row bands.
The same persistent worker pool handles native YUV copies, byte-buffer planar
copies and legacy RGBA conversion (helpers start at panorama sizes); all workers finish before the buffer is unlocked or the decoded
with acquire/release fences. The hardware decoder's NV12 planes are copied to
the Android buffer in disjoint parallel row bands without CPU colour conversion.
The same persistent worker pool handles native YUV copies and legacy RGBA
conversion; all workers finish before the buffer is unlocked or the decoded
frame is released. Legacy software surfaces
retain parallel NEON RGBA conversion. Input timestamps, dynamic dimensions, EOS and
seek/flush are preserved.
This uses the tested Lepton Android 11 SoftOMX ABI. On connection, FramePort
deploys the plugin, codec XML and Podman wrapper once into the agent's shared
`~/.local/share/frameport/video-codec/versions/<manifest-sha256>` store.
Payload checksums are verified before an atomic `current` symlink exposes the
complete version. Older clients cannot downgrade a newer codec revision.
A Frame keeps an installed codec of the same or a newer manifest `revision`
(two PCs with different builds of one revision don't replace each other's), so
`build.py` bumps the revision with every change. A failed install is not
retried during the same connection.
Superseded versions are pruned, keeping the active version and the one it
replaced (a launch that resolved the previous `current` can still mount it).
Games opt in through their recipe: the patch `frame.hw_video_decode` (no APK
change; suggested when the APK decodes video through MediaCodec, ExoPlayer or
VLC) makes the agent give that game's launcher the line that puts the shared
wrapper first on Lepton's PATH (`deployment.json` `hw_video_decode`;
`upgrade_launchers` adds or removes it on existing launchers). Only that game's
matching `podman run` gains a read-only plugin/XML mount and `/dev/video-dec0`.
All other Podman operations pass through. FramePort's setting "Hardware video
decoding" turns it off for every game (`video-codec/disabled`, checked by the
launcher line and the wrapper at every start); `FRAMEPORT_NO_HW_VIDEO=1` in a
game's Steam launch options turns it off for that game. Shared Lepton, drivers and original MP4 assets are unchanged.
This uses the tested Lepton Android 11 SoftOMX ABI. FramePort stores the plugin,
codec XML and a narrow Podman wrapper per game. The launcher adds this wrapper
to its child process's PATH. Only the matching game's `podman run` gains a
read-only plugin/XML mount and `/dev/video-dec0`. All other Podman operations
pass through. Shared Lepton, drivers and original MP4 assets are unchanged.
Unknown runtime ABIs retain the stock codecs and log why. A future native
Lepton hardware plugin takes precedence.
The wrapper resolves a fallback Podman from PATH outside its own directory
before reading deployment configuration. Lepton's Android-only `env -i PATH`
attach commands use host default paths if their PATH contains no host Podman.
Missing/malformed configuration,
before reading deployment configuration. Missing/malformed configuration,
recursive executable paths, mount-preparation errors and failed exec calls
retain the original arguments and launch stock Podman. The agent publishes
`deployment.json` and codec payloads before exposing the complete version.
The merged runtime XML is written to `$XDG_RUNTIME_DIR/frameport-video/`
(never into the verified version directory), named per runtime path, with
process-local temporary filenames, so concurrent app launches do not overwrite
each other's staging.
`deployment.json` and codec payloads before atomically exposing `bin/podman`.
No package check or MP4 scan selects decoding: the recipe does. Older embedded
codec assets are removed when an APK is rebuilt, and agent 70's per-game
`<base>/frameport-codec` folders are removed once the launcher is converted (a
game that had one, Batman, keeps hardware decoding). The
codec has no OpenXR calls, camera poses or composition changes. Batman's
`surface_native` renderer remains package-scoped and separate. Other apps keep
their existing rendering path; hardware decoding alone does not eliminate
surface upload/conversion or GPU rendering bottlenecks.
Packaging is limited to the validated arm64 Batman package (`com.camouflaj.manta`).
Unrelated games and arm32 APKs do not receive these assets or the native video
setting. Rebuilding an older unrelated test APK removes its previous codec
assets, and installation disables its old per-game wrapper. The presence of
an MP4 alone does not establish compatible video-surface or overlay semantics.
The plugin enumerates coded formats and probes each at 1920x1080, not Batman's
8K geometry. Unavailable components are omitted so Android retains its stock
decoders. Actual-session admission can still fail (including Iris's concurrent
session-accounting issue: while any other decoder session is open, 8K is
refused with ENOMEM). SteamVR's link (`vrlinkrunthread`) holds a session for
about 12 s around every game start, when the plugin loads too (measured on the
dev Frame, 2026-10-09), so a refused session is retried every 250 ms until 20 s
after the plugin loaded (later: for 2 s); initialization then falls back to
FFmpeg software decoding inside the already-selected component. VP9 is limited
to 4096x2304 (codec list and component; larger VP9 decodes in software): at
7680x3840 Iris accepted the session but never returned a picture. For every
VP9 frame that isn't shown (alt-ref/hidden frames split from superframes) Iris
returns an empty capture buffer (bytesused 0, no LAST flag, time 0). FFmpeg's
wrapper returned those as pictures and, during the EOS drain, took the first one
for the end of the stream, losing the pictures still in the driver (two-pass
VP9 at 4K: 573 of 600 frames, measured 2026-10-10). `build.py` makes the wrapper
requeue empty non-LAST buffers instead; the drain ends at the LAST buffer, or
after a skipped empty buffer at one silent second, so a driver that ends a drain
with an empty unflagged buffer cannot block. This preserves the client's
buffer/surface contract without requiring the app to retry another codec.
During hardware playback, bounded references to two GOPs of compressed packets
permit software recovery after a driver-reported damaged picture. Replay drops
already-presented timestamps, retaining ordered output and EOS. The cache is
capped at 64 MiB/512 packets; if no usable keyframe remains, ordinary OMX error
handling applies. After recovery, later seeks flush the software decoder for
that component until reset. This is a fallback, not a repair to the Iris driver. VP9 seek tests
on this Frame exercised it. Capture buffers use FFmpeg's general-purpose budget
of 20 at ordinary sizes, which avoids the stall observed with the earlier
four-buffer H.264 configuration. At panorama sizes the budget follows the
standards' reference-picture bound plus four (10 at 8K HEVC/H.264, 13 for VP9),
instead of holding about 1 GB of 8K capture memory. Only hardware-reported
damage triggers recovery; software decoders conceal damage themselves and their
pictures are output, as Android's stock decoders do.
A seek after the stream has fully drained (replay or loop after EOS) restarts
the open hardware session in place with `V4L2_DEC_CMD_START`, keeping its
buffers; this works for all three codecs. The component checks the restart
result through a read-only FFmpeg option. A failed restart closes that session
and continues in software, rather than waiting indefinitely for hardware
output. A seek during playback reopens the
hardware session: on the tested Iris kernel, stopping and restarting a running
session's queues failed the driver's session admission ("current session not
supported", -12) and left buffers unreturned (videobuf2 kernel warnings), so it
is not used. On the same kernel, reopening VP9 after a seek produced a damaged
capture picture and a kernel fault in `lookup_swap_cgroup_id` during unmap, so
VP9 switches to software on a seek during playback rather than reopening that
hardware session; H.264 and HEVC retain hardware decoding after seeking.
This compatibility guard is independent of app/package identity.
Android CODECCONFIG parameter sets are prepended to the first H.264/HEVC
hardware access unit because FFmpeg's V4L2 wrapper does not submit extradata
to the driver. Parameter sets sent again mid-stream without a flush (streaming
apps, restarted encoders) replace the stored configuration and are submitted
in-band before the next access unit, rather than failing the session. Complete-frame parsers identify keyframes for bounded recovery,
including ACodec clients that omit the OMX input SYNCFRAME flag.
The plugin probes admission of an 8192x4096 decoder session before advertising
its component. On the tested SteamOS kernel, another active decoder (including
Steam's web helper) can cause Iris to reject that session. In that condition it
leaves Android's stock software decoder available. This fallback preserves
playback, but does not provide hardware performance. Admission can change after
the probe; this is not a fix for the kernel's session-accounting defect.
On the tested headset, disabling **hardware video decoding in Steam's interface**
and restarting Steam removed that conflicting session. This setting does not
disable the game's Iris decoder. It is a SteamOS-driver workaround, not a kernel
fix; FramePort does not silently change the global Steam setting.
Signalled bitstream colour (primaries, transfer, matrix, range) is reported to
ACodec as Android's stock decoders do, so the surface dataspace can follow it;
container values take precedence. Legacy RGBA conversion uses the resulting
matrix, BT.709 for unsignalled HD (Android's convention), and full-range BT.601
or BT.709 where signalled. A full-range BT.709 table matches the pinned Arm64
libyuv ABI. Both hardware NV12 and software planar conversions produce RGBA
channel order; native YUV output retains its original planes and dataspace.
Batman's native surface renderer and stereo composition are documented in
The native surface renderer and stereo composition are documented in
[Surface video playback](../../docs/SURFACE_VIDEO.md). The original decoded
panorama is retained; projected eye views are generated on the GPU.
@@ -152,21 +54,13 @@ Build on Linux/WSL with Android NDK r27c and the tested Lepton rootfs:
python native/hevc/build.py --ndk /path/to/android-ndk-r27c --lepton-root /path/to/Lepton/images/rootfs
```
Paths with spaces work: the builder links the repo, the NDK and the rootfs into
a space-free temp dir and compiles through those link paths (FFmpeg's configure
splits `--extra-cflags` on whitespace); with `-no-canonical-prefixes` clang keeps
the link path too, so the prefix maps give the same bytes wherever the real
folders are. Each build starts from freshly extracted FFmpeg sources.
The build requires Linux x86-64, Python 3.12+, Make, Perl, and a working host C
compiler with libc development headers (FFmpeg builds host tools). The NDK
revision is checked against `27.2.12479018`. Previous FFmpeg objects and install
output are discarded. Both FFmpeg and the plugin use that NDK's compiler and
linker. A private copy of its libc++ headers uses Android's platform `__1`
namespace; `-nostdinc++` prevents mixing these with the NDK's `__ndk1` headers.
The build uses libc++'s verbose-abort customization hook to avoid a newer NDK
abort symbol absent from Android 11's platform runtime. It does not edit the NDK
or ship a second libc++.
The build does not edit the NDK or ship a second libc++.
Compiler file-prefix maps cover source, NDK and runtime paths. FFmpeg's
generated configuration string is normalized separately because it embeds
@@ -178,34 +72,13 @@ clean shutdown in an isolated headset container.
AOSP headers in `platform/` come from `android-11.0.0_r48` and retain their
original license notices. `fetch_headers.py` records the upstream paths.
The builder downloads the pinned FFmpeg 7.1.1 archive from
The builder downloads unmodified FFmpeg 7.1.1 source from
https://ffmpeg.org/releases/ffmpeg-7.1.1.tar.xz and verifies SHA256
`733984395e0dbbe5c046abda2dc49a5544e7e0e1e2366bba849222ae9e3a03b1`.
`build.py` applies explicit wrapper changes: VP9 V4L2 uses the same
`vp9_superframe_split` input filter as the software decoder, empty non-LAST
capture buffers (hidden VP9 frames) are requeued rather than output or taken
for the end of a drain, and the V4L2
decoder gains a flush callback that restarts a drained decoder in place
(`V4L2_DEC_CMD_START` with both queues still streaming). Its read-only
`frameport_flush_error` option reports failed or premature restarts to the
component, because `avcodec_flush_buffers` itself has no return value.
Without it a restart after EOS could only close and reopen the hardware
session. `v4l2_export.c.inc` adds a capture-buffer export interface inside FFmpeg,
so the component does not access FFmpeg's private buffer structures. The caller
must retain the AVFrame until GPU work completes. Its LGPL build
configuration and those source changes are recorded in the builder. The license
is included in the shared version directory as `COPYING.FFmpeg`.
Its LGPL build configuration is recorded in `build.py` and the license is
included in the APK/per-game codec directory as `COPYING.FFmpeg`.
`omx_decode_probe.cpp` and `media_codec_probe.cpp` exercise decoder selection,
original-size output, timestamps, flush/seek, EOS and teardown on the headset.
The OMX probe's `restartfail` mode uses the fault shim in
`tests/fixtures/src/video_codec_faults.c` to reject a drained-session restart;
`software_rgba` exercises software fallback and legacy surface conversion
together. `color_probe.cpp` compares both platform NEON conversion paths with
the full-range BT.709 equations, including channel order and extreme values.
The OMX probe's `native` mode hashes original-size Android YUV buffers and checks
GPU submissions and replayed pixels. `native_cpu` disables Vulkan initialization;
`native_importfail` rejects memory imports. Both verify fallback preserves the
same surface contract and pixels without GPU submissions.
The native surface fixtures cover buffer ownership, GPU projection and
composition. See the surface documentation for validation and limitations.
+26 -170
View File
@@ -11,7 +11,6 @@ import os
import shutil
import subprocess
import tarfile
import tempfile
import urllib.request
from pathlib import Path
@@ -20,98 +19,10 @@ ARTIFACTS = HERE.parents[1] / "artifacts/hevc"
FFMPEG_SHA = "733984395e0dbbe5c046abda2dc49a5544e7e0e1e2366bba849222ae9e3a03b1"
RUNTIME_SHA = "456e912c75cd389abcf6a63bc80e2a53bdc334371d00b200c93680388ae955e2"
NDK_REVISION = "27.2.12479018"
FLUSH_SOURCE = r"""static void v4l2_flush(AVCodecContext *avctx)
{
V4L2m2mPriv *priv = avctx->priv_data;
V4L2m2mContext *s = priv->context;
struct v4l2_decoder_cmd cmd = { .cmd = V4L2_DEC_CMD_START };
av_packet_unref(&s->buf_pkt);
priv->frameport_flush_error = 0;
/* Only a drained decoder restarts in place: it has returned every picture
* and is stopped until V4L2_DEC_CMD_START (both queues keep streaming).
* Restarting a running decoder's queues failed Iris session admission and
* left its buffers unreturned (kernel warnings): callers reopen instead. */
if (!s->draining || !s->capture.done) {
priv->frameport_flush_error = AVERROR(EINVAL);
av_log(avctx, AV_LOG_WARNING, "flush before the drain completed; reopen the decoder\n");
return;
}
if (ioctl(s->fd, VIDIOC_DECODER_CMD, &cmd) < 0) {
priv->frameport_flush_error = AVERROR(errno);
av_log(avctx, AV_LOG_ERROR, "V4L2_DEC_CMD_START after drain: %s\n", av_err2str(priv->frameport_flush_error));
return;
}
s->draining = 0;
s->output.done = s->capture.done = 0;
}
"""
# Iris returns an empty capture buffer (bytesused 0, no LAST flag) for every
# VP9 frame that isn't shown (alt-ref/hidden frames split from superframes).
# FFmpeg's wrapper took any empty buffer during a drain for the end of the
# stream, so the pictures still in the driver after EOS were lost (two-pass VP9:
# 573 of 600), and outside a drain it returned them as pictures without content.
# Requeue them instead; the drain still ends at the LAST buffer (or EPIPE), and
# after a skipped empty buffer a silent second ends it too, so a driver that
# ends a drain with an empty buffer and no LAST flag cannot block the caller.
EMPTY_DECLARE = " .fd = ctx_to_m2mctx(ctx)->fd,\n };\n int i, ret;\n"
EMPTY_POLL = """ for (;;) {
ret = poll(&pfd, 1, timeout);
if (ret > 0)
break;
if (errno == EINTR)
continue;
return NULL;
}
"""
EMPTY_DRAIN = """ if (ctx_to_m2mctx(ctx)->draining && !V4L2_TYPE_IS_OUTPUT(ctx->type)) {
int bytesused"""
EMPTY_SKIP = """ if (!V4L2_TYPE_IS_OUTPUT(ctx->type) && ctx->buffers && buf.index < ctx->num_buffers &&
!(V4L2_TYPE_IS_MULTIPLANAR(buf.type) ? buf.m.planes[0].bytesused : buf.bytesused) &&
!(buf.flags & (V4L2_BUF_FLAG_LAST | V4L2_BUF_FLAG_ERROR))) {
/* an empty picture that doesn't end the stream (Iris: a hidden VP9 frame) */
if (ff_v4l2_buffer_enqueue(&ctx->buffers[buf.index]) < 0) {
ctx->done = 1;
return NULL;
}
skipped_empty = 1;
goto start;
}
"""
def skip_empty_pictures(context_text: str) -> str:
"""libavcodec/v4l2_context.c with empty non-LAST capture buffers requeued (see EMPTY_SKIP)."""
if any(context_text.count(text) != 1 for text in (EMPTY_DECLARE, EMPTY_POLL, EMPTY_DRAIN)):
raise RuntimeError("unexpected FFmpeg V4L2 context source")
return (context_text
.replace(EMPTY_DECLARE, EMPTY_DECLARE.replace("int i, ret;", "int i, ret, skipped_empty = 0;"))
.replace(EMPTY_POLL, EMPTY_POLL.replace(
" ret = poll(&pfd, 1, timeout);\n if (ret > 0)\n break;\n",
" ret = poll(&pfd, 1, skipped_empty && ctx_to_m2mctx(ctx)->draining &&\n"
" (timeout < 0 || timeout > 1000) ? 1000 : timeout);\n"
" if (ret > 0)\n break;\n"
" if (!ret && skipped_empty && ctx_to_m2mctx(ctx)->draining) {\n"
" ctx->done = 1;\n return NULL;\n }\n"))
.replace(EMPTY_DRAIN, EMPTY_SKIP + EMPTY_DRAIN))
def run(args, cwd=None, env=None):
# PWD keeps the shell's (and FFmpeg configure's) idea of the directory on the space-free link path
env = dict(env or os.environ, **({"PWD": str(cwd)} if cwd else {}))
subprocess.run([str(a) for a in args], check=True, cwd=cwd, env=env)
def space_free(links: Path, name: str, target: Path) -> Path:
"""FFmpeg's configure splits --extra-cflags on whitespace, so a repo/NDK/rootfs path with spaces breaks the
build. Build through unresolved symlinks in a space-free temp dir; the prefix maps name these link paths, so the
output is the same bytes wherever the real folders are."""
link = links / name
link.symlink_to(target, target_is_directory=True)
return link
def run(args, **kwargs):
subprocess.run([str(a) for a in args], check=True, **kwargs)
def main():
@@ -127,79 +38,28 @@ def main():
if not (ndk / "bin/clang++").exists():
parser.error("use the Linux NDK r27c (Windows: run this builder inside WSL)")
runtime = args.lepton_root.resolve()
with tempfile.TemporaryDirectory(prefix="fp-hevc-") as tmp:
links = Path(tmp)
if any(c.isspace() for c in str(links)):
parser.error(f"temp dir {links} has spaces: set TMPDIR to a folder without spaces")
build(parser, space_free(links, "repo", HERE.parents[1]), space_free(links, "ndk", ndk_root),
space_free(links, "rootfs", runtime))
def build(parser, repo: Path, ndk_root: Path, runtime: Path):
here = repo / HERE.relative_to(HERE.parents[1])
artifacts = repo / ARTIFACTS.relative_to(HERE.parents[1])
ndk = ndk_root / "toolchains/llvm/prebuilt/linux-x86_64"
if hashlib.sha256((runtime / "vendor/lib64/libstagefright_softomx.so").read_bytes()).hexdigest() != RUNTIME_SHA:
parser.error("unverified SoftOMX ABI; validate and update the fingerprint before rebuilding")
cache = here.parent / ".cache/hevc"
cache = HERE.parent / ".cache/hevc"
cache.mkdir(parents=True, exist_ok=True)
archive = cache / "ffmpeg-7.1.1.tar.xz"
if not archive.exists():
urllib.request.urlretrieve("https://ffmpeg.org/releases/ffmpeg-7.1.1.tar.xz", archive)
if hashlib.sha256(archive.read_bytes()).hexdigest() != FFMPEG_SHA:
raise RuntimeError("FFmpeg source checksum mismatch")
with tarfile.open(archive) as tar:
tar.extractall(cache, filter="data")
source = cache / "ffmpeg-7.1.1"
install = cache / "ffmpeg-install"
# Discard previous objects/configuration so a host compiler or changed
# configure flags cannot silently survive a rebuild (a distclean keyed on
# config.mak never ran: FFmpeg 7 keeps it in ffbuild/), and start from
# unpatched sources (the edits below are applied once).
for old in (source, install):
if old.exists():
shutil.rmtree(old)
with tarfile.open(archive) as tar:
tar.extractall(cache, filter="data")
# Unlike FFmpeg's software VP9 decoder, its V4L2 wrapper does not split
# packed VP9 superframes. Iris requires the same individual-frame input.
decoder_source = source / "libavcodec/v4l2_m2m_dec.c"
decoder_text = decoder_source.read_text()
original_vp9 = 'M2MDEC(vp9, "VP9", AV_CODEC_ID_VP9, NULL);'
if decoder_text.count(original_vp9) != 1:
raise RuntimeError("unexpected FFmpeg VP9 wrapper source")
decoder_text = decoder_text.replace(original_vp9, 'M2MDEC(vp9, "VP9", AV_CODEC_ID_VP9, "vp9_superframe_split");')
# The wrapper has no flush callback, so restarting after EOS (replay, loop)
# could only close and reopen the hardware session. Add the stateful
# decoder's restart after a completed drain (V4L2_DEC_CMD_START).
close_fn = "static av_cold int v4l2_decode_close(AVCodecContext *avctx)\n"
close_cb = " .close = v4l2_decode_close, \\\n"
if decoder_text.count(close_fn) != 1 or decoder_text.count(close_cb) != 1:
raise RuntimeError("unexpected FFmpeg V4L2 decoder source")
decoder_text = decoder_text.replace(close_fn, FLUSH_SOURCE + "\n" + close_fn).replace(
close_cb, close_cb + " .flush = v4l2_flush, \\\n")
decoder_source.write_text(decoder_text)
# avcodec_flush_buffers has no return value. Expose restart status through
# an AVOption rather than having the OMX component inspect private structs.
private_header = source / "libavcodec/v4l2_m2m.h"
private_text = private_header.read_text()
field = " int num_capture_buffers;\n} V4L2m2mPriv;"
option_end = " { NULL},\n};"
if private_text.count(field) != 1 or decoder_text.count(option_end) != 1:
raise RuntimeError("unexpected FFmpeg V4L2 private options source")
private_header.write_text(private_text.replace(field, " int num_capture_buffers;\n"
" int frameport_flush_error;\n} V4L2m2mPriv;"))
decoder_source.write_text(decoder_text.replace(option_end,
' { "frameport_flush_error", "Last drained-session restart error",\n'
' OFFSET(frameport_flush_error), AV_OPT_TYPE_INT, {.i64 = 0}, INT_MIN, 0,\n'
' FLAGS | AV_OPT_FLAG_READONLY },\n' + option_end))
context_source = source / "libavcodec/v4l2_context.c"
context_source.write_text(skip_empty_pictures(context_source.read_text()))
buffers_source = source / "libavcodec/v4l2_buffers.c"
buffers_source.write_text(buffers_source.read_text() + (here / "v4l2_export.c.inc").read_text())
# configure flags cannot silently survive a rebuild.
if (source / "config.mak").exists():
run(["make", "distclean"], cwd=source)
if install.exists():
shutil.rmtree(install)
env = dict(os.environ, PATH=str(ndk / "bin") + os.pathsep + os.environ["PATH"])
prefix_maps = [f"-ffile-prefix-map={repo}=.", f"-ffile-prefix-map={ndk_root}=android-ndk-r27c",
f"-ffile-prefix-map={runtime}=lepton-rootfs",
# clang otherwise resolves its own path (through the link) and embeds its real include dir
"-no-canonical-prefixes"]
prefix_maps = [f"-ffile-prefix-map={HERE.parents[1]}=.", f"-ffile-prefix-map={ndk_root}=android-ndk-r27c",
f"-ffile-prefix-map={runtime}=lepton-rootfs"]
run([
source / "configure", f"--prefix={install}", "--target-os=android", "--arch=aarch64",
"--enable-cross-compile", "--cc=aarch64-linux-android30-clang", "--cxx=aarch64-linux-android30-clang++",
@@ -208,9 +68,8 @@ def build(parser, repo: Path, ndk_root: Path, runtime: Path):
"--extra-cxxflags=" + " ".join(prefix_maps),
"--disable-everything", "--disable-autodetect", "--enable-v4l2-m2m", "--disable-programs",
"--disable-doc", "--enable-pic", "--enable-static", "--disable-shared",
"--enable-decoder=hevc_v4l2m2m,h264_v4l2m2m,vp9_v4l2m2m,hevc,h264,vp9",
"--enable-parser=hevc,h264,vp9", "--enable-bsf=hevc_mp4toannexb,h264_mp4toannexb,vp9_superframe_split",
"--enable-demuxer=mov,matroska", "--enable-protocol=file", "--enable-avcodec", "--enable-avformat",
"--enable-decoder=hevc_v4l2m2m", "--enable-parser=hevc", "--enable-bsf=hevc_mp4toannexb",
"--enable-demuxer=mov", "--enable-protocol=file", "--enable-avcodec", "--enable-avformat",
"--enable-avutil", "--disable-avdevice", "--disable-avfilter", "--disable-swscale",
"--disable-swresample", "--disable-postproc",
], cwd=source, env=env)
@@ -219,10 +78,10 @@ def build(parser, repo: Path, ndk_root: Path, runtime: Path):
# generated string too, retaining the options without host paths.
config = source / "config.h"
text = config.read_text()
for path, replacement in ((ndk_root, "android-ndk-r27c"), (runtime, "lepton-rootfs"), (repo, ".")):
for path, replacement in ((ndk_root, "android-ndk-r27c"), (runtime, "lepton-rootfs"), (HERE.parents[1], ".")):
text = text.replace(str(path), replacement)
config.write_text(text)
run(["make", f"-j{os.cpu_count() or 4}"], cwd=source, env=env)
run(["make", "-j4"], cwd=source, env=env)
run(["make", "install"], cwd=source, env=env)
# Use the platform libc++ namespace. Do not change the NDK's own headers or
# distribute another libc++, which would create a conflicting private ABI.
@@ -230,32 +89,29 @@ def build(parser, repo: Path, ndk_root: Path, runtime: Path):
shutil.copytree(ndk / "sysroot/usr/include/c++/v1", cpp, dirs_exist_ok=True)
site = cpp / "__config_site"
site.write_text(site.read_text().replace("_LIBCPP_ABI_NAMESPACE __ndk1", "_LIBCPP_ABI_NAMESPACE __1"))
artifacts.mkdir(parents=True, exist_ok=True)
ARTIFACTS.mkdir(parents=True, exist_ok=True)
run([
ndk / "bin/aarch64-linux-android30-clang++", "-std=gnu++17", "-O3", "-fPIC", "-shared",
*prefix_maps,
"-fno-rtti", "-fno-exceptions", "-nostdlib++", "-nostdinc++", "-Wall", "-Wextra", "-Werror",
"-D_LIBCPP_VERBOSE_ABORT(...)=__builtin_abort()",
"-Wno-unused-private-field", "-isystem", cpp, "-I", here / "platform",
"-I", here / "platform/media/openmax", "-I", install / "include", here / "frameport_hevc.cpp",
"-Wno-unused-private-field", "-isystem", cpp, "-I", HERE / "platform",
"-I", HERE / "platform/media/openmax", "-I", install / "include", HERE / "frameport_hevc.cpp",
"-L", install / "lib", "-lavcodec", "-lavutil", "-L", runtime / "vendor/lib64",
"-L", runtime / "system/lib64", "-lstagefright_softomx", "-lstagefright_foundation", "-lutils",
"-llog", "-lnativewindow", "-lyuv", "-lvulkan", "-l:libc++.so", "-lm", "-ldl", "-Wl,--no-undefined",
"-llog", "-lnativewindow", "-lyuv", "-l:libc++.so", "-lm", "-ldl", "-Wl,--no-undefined",
"-Wl,-z,max-page-size=16384",
"-Wl,-soname,libstagefrighthw.so", "-o", artifacts / "libstagefrighthw.so",
"-Wl,-soname,libstagefrighthw.so", "-o", ARTIFACTS / "libstagefrighthw.so",
], env=env)
for name in ("podman.py", "media_codecs_frameport.xml"):
shutil.copyfile(here / name, artifacts / (name + ".txt" if name == "podman.py" else name))
shutil.copyfile(source / "COPYING.LGPLv2.1", artifacts / "COPYING.FFmpeg")
shutil.copyfile(HERE / name, ARTIFACTS / (name + ".txt" if name == "podman.py" else name))
shutil.copyfile(source / "COPYING.LGPLv2.1", ARTIFACTS / "COPYING.FFmpeg")
files = {}
for name in ("libstagefrighthw.so", "podman.py", "media_codecs_frameport.xml", "COPYING.FFmpeg"):
path = artifacts / (name + ".txt" if name == "podman.py" else name)
path = ARTIFACTS / (name + ".txt" if name == "podman.py" else name)
files[name] = hashlib.sha256(path.read_bytes()).hexdigest()
# bump the revision with every change: a Frame keeps an installed codec of the same or a newer revision
manifest = {"revision": 8, "runtime_sha256": RUNTIME_SHA, "files": files,
"codecs": ["video/hevc", "video/avc", "video/x-vnd.on2.vp9"],
manifest = {"runtime_sha256": RUNTIME_SHA, "files": files,
"build": {"ndk_revision": NDK_REVISION, "ffmpeg_source_sha256": FFMPEG_SHA}}
(artifacts / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
(ARTIFACTS / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
if __name__ == "__main__":
-36
View File
@@ -1,36 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
// Compare the actual platform NEON row functions with the full-range BT.709
// equations, including shadows/highlights and RGBA channel order in both paths.
#include "yuv_color.h"
#include <cassert>
#include <cstdio>
#include <cmath>
#include <cstring>
#include <initializer_list>
extern "C" {
void NV21ToARGBRow_Any_NEON(const uint8_t*,const uint8_t*,uint8_t*,const YuvConstants*,int);
void I422ToARGBRow_Any_NEON(const uint8_t*,const uint8_t*,const uint8_t*,uint8_t*,const YuvConstants*,int);
}
static int clipped(double v) {int n=(int)std::lround(v);return n<0?0:n>255?255:n;}
int main() {
unsigned checked=0;
for(unsigned width : {2u,8u,18u})
for(int y : {0,8,16,64,128,235,247,255})
for(int u : {16,64,128,192,240})
for(int v : {16,64,128,192,240}) {
uint8_t yy[32],uu[16],vv[16],uv[32],nv[128],planar[128];
memset(yy,y,sizeof yy);memset(uu,u,sizeof uu);memset(vv,v,sizeof vv);
for(unsigned i=0;i<width/2;i++){uv[2*i]=u;uv[2*i+1]=v;}
NV21ToARGBRow_Any_NEON(yy,uv,nv,&frameport_full709_rgba,width);
I422ToARGBRow_Any_NEON(yy,vv,uu,planar,&frameport_full709_rgba,width);
int expected[]={clipped(y+1.5748*(v-128)),clipped(y-0.187324*(u-128)-0.468124*(v-128)),
clipped(y+1.8556*(u-128)),255};
for(unsigned x=0;x<width;x++)for(unsigned c=0;c<4;c++) {
assert(nv[4*x+c]==planar[4*x+c]);
assert(std::abs((int)nv[4*x+c]-expected[c])<=2);
if(u==128 && v==128 && c<3)assert(nv[4*x+c]==y);
}
checked++;
}
printf("PASS: %u full-range BT.709 colour/width combinations, planar and NV12 RGBA\n",checked);
}
-139
View File
@@ -1,139 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
// Import linear decoder/Android DMA buffers. Ownership returns to the foreign
// producer/consumer before the completed fence permits either buffer to move.
#pragma once
#include <vulkan/vulkan.h>
#include <unistd.h>
#include <sys/stat.h>
#include <cstdio>
#include <cstring>
#include <vector>
class FramePortDmaCopy {
VkInstance instance=VK_NULL_HANDLE;
VkPhysicalDevice physical=VK_NULL_HANDLE;
VkDevice device=VK_NULL_HANDLE;
VkQueue queue=VK_NULL_HANDLE;
VkCommandPool pool=VK_NULL_HANDLE;
VkCommandBuffer command=VK_NULL_HANDLE;
VkFence completed=VK_NULL_HANDLE;
uint32_t family=0;
PFN_vkGetMemoryFdPropertiesKHR properties=nullptr;
struct Buffer {VkBuffer buffer=VK_NULL_HANDLE;VkDeviceMemory memory=VK_NULL_HANDLE;};
struct Entry {dev_t device;ino_t inode;VkBufferUsageFlags usage;Buffer imported;};
std::vector<Entry> cache;
bool attempted=false,ready=false,unsafe=false;
template<class T>static T info(VkStructureType type){T value={};value.sType=type;return value;}
bool check(VkResult r,const char *where){if(r!=VK_SUCCESS)ALOGW("GPU video %s: %d",where,r);return r==VK_SUCCESS;}
bool import(int fd,VkBufferUsageFlags usage,Buffer &out) {
struct stat st={};if(fstat(fd,&st)||st.st_size<=0)return false;
auto query=info<VkPhysicalDeviceExternalBufferInfo>(VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_BUFFER_INFO);
query.usage=usage;query.handleType=VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT;
auto caps=info<VkExternalBufferProperties>(VK_STRUCTURE_TYPE_EXTERNAL_BUFFER_PROPERTIES);
vkGetPhysicalDeviceExternalBufferProperties(physical,&query,&caps);
if(!(caps.externalMemoryProperties.externalMemoryFeatures&VK_EXTERNAL_MEMORY_FEATURE_IMPORTABLE_BIT))return false;
auto external=info<VkExternalMemoryBufferCreateInfo>(VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_BUFFER_CREATE_INFO);
external.handleTypes=query.handleType;
auto create=info<VkBufferCreateInfo>(VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO);create.pNext=&external;
create.size=st.st_size;create.usage=usage;create.sharingMode=VK_SHARING_MODE_EXCLUSIVE;
if(!check(vkCreateBuffer(device,&create,nullptr,&out.buffer),"create buffer"))return false;
VkMemoryRequirements needs;vkGetBufferMemoryRequirements(device,out.buffer,&needs);
auto fdprops=info<VkMemoryFdPropertiesKHR>(VK_STRUCTURE_TYPE_MEMORY_FD_PROPERTIES_KHR);
if(!check(properties(device,query.handleType,fd,&fdprops),"fd properties"))return false;
uint32_t bits=needs.memoryTypeBits&fdprops.memoryTypeBits;
if(!bits || needs.size>(VkDeviceSize)st.st_size){ALOGW("GPU import layout mismatch: size %llu/%lld bits %#x",
(unsigned long long)needs.size,(long long)st.st_size,bits);return false;}
auto imported=info<VkImportMemoryFdInfoKHR>(VK_STRUCTURE_TYPE_IMPORT_MEMORY_FD_INFO_KHR);
imported.handleType=query.handleType;imported.fd=dup(fd);if(imported.fd<0)return false;
auto dedicated=info<VkMemoryDedicatedAllocateInfo>(VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO);
dedicated.buffer=out.buffer;
if(caps.externalMemoryProperties.externalMemoryFeatures&VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT)imported.pNext=&dedicated;
auto allocate=info<VkMemoryAllocateInfo>(VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO);allocate.pNext=&imported;
allocate.allocationSize=st.st_size;allocate.memoryTypeIndex=__builtin_ctz(bits);
if(!check(vkAllocateMemory(device,&allocate,nullptr,&out.memory),"import memory")){close(imported.fd);return false;}
return check(vkBindBufferMemory(device,out.buffer,out.memory,0),"bind buffer");
}
void release(Buffer &b){if(b.buffer)vkDestroyBuffer(device,b.buffer,nullptr);if(b.memory)vkFreeMemory(device,b.memory,nullptr);b={};}
bool cached(int fd,VkBufferUsageFlags usage,Buffer &out){
struct stat st={};if(fstat(fd,&st))return false;
for(auto &e:cache)if(e.device==st.st_dev&&e.inode==st.st_ino&&e.usage==usage){out=e.imported;return true;}
Buffer b;bool ok=import(fd,usage,b);if(!ok){release(b);return false;}
cache.push_back({st.st_dev,st.st_ino,usage,b});out=b;return true;
}
public:
void clearBuffers(){if(device){vkDeviceWaitIdle(device);for(auto &e:cache)release(e.imported);cache.clear();}}
bool available(){if(!attempted){attempted=true;ready=init();}return ready;}
bool failedAfterSubmit()const{return unsafe;}
bool init(){
auto app=info<VkApplicationInfo>(VK_STRUCTURE_TYPE_APPLICATION_INFO);app.apiVersion=VK_API_VERSION_1_1;
auto create=info<VkInstanceCreateInfo>(VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO);create.pApplicationInfo=&app;
if(!check(vkCreateInstance(&create,nullptr,&instance),"create instance"))return false;
uint32_t n=0;vkEnumeratePhysicalDevices(instance,&n,nullptr);if(!n)return false;
std::vector<VkPhysicalDevice> devices(n);vkEnumeratePhysicalDevices(instance,&n,devices.data());physical=devices[0];
VkPhysicalDeviceProperties p;vkGetPhysicalDeviceProperties(physical,&p);
if(p.apiVersion<VK_API_VERSION_1_1)return false;
vkEnumerateDeviceExtensionProperties(physical,nullptr,&n,nullptr);std::vector<VkExtensionProperties> extensions(n);
vkEnumerateDeviceExtensionProperties(physical,nullptr,&n,extensions.data());
const char *required[]={VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME,VK_EXT_EXTERNAL_MEMORY_DMA_BUF_EXTENSION_NAME,VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME};
for(auto wanted:required){bool found=false;for(auto &e:extensions)if(!strcmp(e.extensionName,wanted))found=true;
if(!found)return false;}
vkGetPhysicalDeviceQueueFamilyProperties(physical,&n,nullptr);std::vector<VkQueueFamilyProperties> families(n);
vkGetPhysicalDeviceQueueFamilyProperties(physical,&n,families.data());
family=n;for(uint32_t i=0;i<n;i++)if(families[i].queueFlags&VK_QUEUE_TRANSFER_BIT){family=i;break;}
if(family==n)return false;
float priority=1;auto q=info<VkDeviceQueueCreateInfo>(VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO);
q.queueFamilyIndex=family;q.queueCount=1;q.pQueuePriorities=&priority;
auto d=info<VkDeviceCreateInfo>(VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO);d.queueCreateInfoCount=1;d.pQueueCreateInfos=&q;
d.enabledExtensionCount=3;d.ppEnabledExtensionNames=required;
if(!check(vkCreateDevice(physical,&d,nullptr,&device),"create device"))return false;
vkGetDeviceQueue(device,family,0,&queue);
properties=(PFN_vkGetMemoryFdPropertiesKHR)vkGetDeviceProcAddr(device,"vkGetMemoryFdPropertiesKHR");if(!properties)return false;
auto cp=info<VkCommandPoolCreateInfo>(VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO);cp.queueFamilyIndex=family;
cp.flags=VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
if(!check(vkCreateCommandPool(device,&cp,nullptr,&pool),"create pool"))return false;
auto a=info<VkCommandBufferAllocateInfo>(VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO);
a.commandPool=pool;a.level=VK_COMMAND_BUFFER_LEVEL_PRIMARY;a.commandBufferCount=1;
auto f=info<VkFenceCreateInfo>(VK_STRUCTURE_TYPE_FENCE_CREATE_INFO);
return check(vkAllocateCommandBuffers(device,&a,&command),"allocate command buffer")&&
check(vkCreateFence(device,&f,nullptr,&completed),"create fence");
}
bool copy(int source,int dest,const VkBufferCopy *regions,unsigned count){
unsafe=false;
struct stat s={},d={};
if(!count||fstat(source,&s)||fstat(dest,&d))return false;
for(unsigned i=0;i<count;i++)if(!regions[i].size||(regions[i].size&3)||
(regions[i].srcOffset&3)||(regions[i].dstOffset&3)||regions[i].srcOffset>(VkDeviceSize)s.st_size||
regions[i].dstOffset>(VkDeviceSize)d.st_size||regions[i].size>(VkDeviceSize)s.st_size-regions[i].srcOffset||
regions[i].size>(VkDeviceSize)d.st_size-regions[i].dstOffset)return false;
// Make room for the pair before resolving either handle. Evicting
// while importing the destination could destroy the source handle.
if(cache.size()>30)clearBuffers();
Buffer buffers[2];bool ok=cached(source,VK_BUFFER_USAGE_TRANSFER_SRC_BIT,buffers[0])&&
cached(dest,VK_BUFFER_USAGE_TRANSFER_DST_BIT,buffers[1]);
VkCommandBuffer cmd=command;VkFence fence=completed;
if(ok)ok=check(vkResetCommandBuffer(cmd,0),"reset command buffer")&&check(vkResetFences(device,1,&fence),"reset fence");
if(ok){
auto b=info<VkCommandBufferBeginInfo>(VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO);b.flags=VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
ok=check(vkBeginCommandBuffer(cmd,&b),"begin command buffer");
VkBufferMemoryBarrier barriers[2]={};
for(unsigned i=0;i<2;i++){
barriers[i].sType=VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER;barriers[i].dstAccessMask=i?VK_ACCESS_TRANSFER_WRITE_BIT:VK_ACCESS_TRANSFER_READ_BIT;
barriers[i].srcQueueFamilyIndex=VK_QUEUE_FAMILY_FOREIGN_EXT;barriers[i].dstQueueFamilyIndex=family;
barriers[i].buffer=buffers[i].buffer;barriers[i].size=VK_WHOLE_SIZE;
}
vkCmdPipelineBarrier(cmd,VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,VK_PIPELINE_STAGE_TRANSFER_BIT,0,0,nullptr,2,barriers,0,nullptr);
vkCmdCopyBuffer(cmd,buffers[0].buffer,buffers[1].buffer,count,regions);
for(auto &barrier:barriers){barrier.srcAccessMask=barrier.dstAccessMask;barrier.dstAccessMask=0;
barrier.srcQueueFamilyIndex=family;barrier.dstQueueFamilyIndex=VK_QUEUE_FAMILY_FOREIGN_EXT;}
vkCmdPipelineBarrier(cmd,VK_PIPELINE_STAGE_TRANSFER_BIT,VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT,0,0,nullptr,2,barriers,0,nullptr);
ok=ok&&check(vkEndCommandBuffer(cmd),"end command buffer");
}
if(ok){auto s=info<VkSubmitInfo>(VK_STRUCTURE_TYPE_SUBMIT_INFO);s.commandBufferCount=1;s.pCommandBuffers=&cmd;
ok=check(vkQueueSubmit(queue,1,&s,fence),"submit");
if(ok){ok=check(vkWaitForFences(device,1,&fence,VK_TRUE,10000000000ull),"wait fence");unsafe=!ok;}}
if(!ok&&device)vkDeviceWaitIdle(device);
return ok;
}
~FramePortDmaCopy(){if(device){vkDeviceWaitIdle(device);for(auto &e:cache)release(e.imported);
if(completed)vkDestroyFence(device,completed,nullptr);if(pool)vkDestroyCommandPool(device,pool,nullptr);
vkDestroyDevice(device,nullptr);}if(instance)vkDestroyInstance(instance,nullptr);}
};
+84 -415
View File
@@ -1,12 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-only
// Android OMX integration for Iris stateful V4L2 video decoding. No OpenXR
// layer/pose manipulation, asset rewriting or package-specific behaviour.
#define LOG_TAG "FramePortVideo"
// Android OMX integration for the Iris stateful V4L2 HEVC decoder. FFmpeg is
// built with only its hardware wrapper; this component never transcodes video.
#define LOG_TAG "FramePortHEVC"
#include <media/stagefright/omx/SoftVideoDecoderOMXComponent.h>
#include <media/hardware/OMXPluginBase.h>
#include <media/openmax/OMX_IndexExt.h>
#include <media/hardware/HardwareAPI.h>
#include <media/stagefright/foundation/ColorUtils.h>
#include <android/hardware_buffer.h>
#include <unistd.h>
#include <fcntl.h>
@@ -18,55 +17,20 @@
#include <cerrno>
#include <pthread.h>
#include <time.h>
#include <poll.h>
#include "yuv_copy.h"
#include "yuv_color.h"
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
struct YuvConstants;
extern const YuvConstants kYvuH709Constants, kYvuI601Constants;
extern const YuvConstants kYvuJPEGConstants;
void NV21ToARGBRow_Any_NEON(const uint8_t*,const uint8_t*,uint8_t*,const YuvConstants*,int);
void I422ToARGBRow_Any_NEON(const uint8_t*,const uint8_t*,const uint8_t*,uint8_t*,const YuvConstants*,int);
AHardwareBuffer *ANativeWindowBuffer_getHardwareBuffer(ANativeWindowBuffer*);
const native_handle_t *AHardwareBuffer_getNativeHandle(const AHardwareBuffer*);
int frameport_v4l2_export_frame(const AVFrame*);
}
#include "dma_copy.h"
namespace android {
static const CodecProfileLevel hevc_profiles[]={{OMX_VIDEO_HEVCProfileMain,OMX_VIDEO_HEVCMainTierLevel62}};
// Android reports constrained AVC streams under their own profile values;
// stock decoders list them so format-aware clients can match them.
static const CodecProfileLevel avc_profiles[]={
{(OMX_U32)OMX_VIDEO_AVCProfileConstrainedBaseline,OMX_VIDEO_AVCLevel62},
{OMX_VIDEO_AVCProfileBaseline,OMX_VIDEO_AVCLevel62},
{(OMX_U32)OMX_VIDEO_AVCProfileConstrainedHigh,OMX_VIDEO_AVCLevel62},
{OMX_VIDEO_AVCProfileMain,OMX_VIDEO_AVCLevel62},
{OMX_VIDEO_AVCProfileHigh,OMX_VIDEO_AVCLevel62}};
static const CodecProfileLevel vp9_profiles[]={{OMX_VIDEO_VP9Profile0,OMX_VIDEO_VP9Level62}};
struct DecoderKind {
const char *name,*role,*mime,*hardware,*software;
OMX_VIDEO_CODINGTYPE coding;
uint32_t fourcc;
const CodecProfileLevel *profiles;
unsigned profile_count;
// What Iris returns pictures for on the Frame: VP9 at 7680x3840 opened but
// never returned one (2026-10-09), so larger VP9 decodes in software.
unsigned max_width,max_height;
};
static const DecoderKind kinds[]={
{"OMX.frameport.hevc.decoder","video_decoder.hevc","video/hevc","hevc_v4l2m2m","hevc",
OMX_VIDEO_CodingHEVC,V4L2_PIX_FMT_HEVC,hevc_profiles,1,8192,8192},
{"OMX.frameport.avc.decoder","video_decoder.avc","video/avc","h264_v4l2m2m","h264",
OMX_VIDEO_CodingAVC,V4L2_PIX_FMT_H264,avc_profiles,5,8192,8192},
{"OMX.frameport.vp9.decoder","video_decoder.vp9","video/x-vnd.on2.vp9","vp9_v4l2m2m","vp9",
OMX_VIDEO_CodingVP9,V4L2_PIX_FMT_VP9,vp9_profiles,1,4096,2304}};
static constexpr unsigned kind_count=sizeof(kinds)/sizeof(kinds[0]);
// When the media service loaded the plugin (= the container's start).
static int64_t plugin_loaded_ns=0;
static const CodecProfileLevel profiles[]={{OMX_VIDEO_HEVCProfileMain,OMX_VIDEO_HEVCMainTierLevel62}};
class ColorWorkers {
struct Worker {ColorWorkers *owner;unsigned band,seen;pthread_t thread;};
struct Worker {ColorWorkers *owner;unsigned band;pthread_t thread;};
Worker workers[3];unsigned count=0,generation=0,complete=0;
bool started=false,stopping=false;
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
@@ -76,24 +40,6 @@ class ColorWorkers {
FramePortYuvPlane planes[3]={};bool native_yuv=false;
void rows(unsigned band) {
unsigned bands=count+1;
if(frame->format==AV_PIX_FMT_YUV420P || frame->format==AV_PIX_FMT_YUVJ420P){
unsigned begin=(frame->height/2)*band/bands,end=(frame->height/2)*(band+1)/bands;
if(native_yuv){
for(unsigned plane=0;plane<3;plane++){
unsigned first=plane?begin:2*begin,last=plane?end:2*end,width=plane?frame->width/2:frame->width;
for(unsigned row=first;row<last;row++){
const uint8_t *src=frame->data[plane]+row*frame->linesize[plane];
uint8_t *dst=planes[plane].data+row*planes[plane].row_stride;
if(planes[plane].pixel_stride==1)memcpy(dst,src,width);
else for(unsigned x=0;x<width;x++)dst[x*planes[plane].pixel_stride]=src[x];
}
}
}else for(unsigned row=2*begin;row<2*end;row++)
I422ToARGBRow_Any_NEON(frame->data[0]+row*frame->linesize[0],
frame->data[2]+(row/2)*frame->linesize[2],frame->data[1]+(row/2)*frame->linesize[1],
output+row*stride*4,matrix,frame->width);
return;
}
if(native_yuv){
frameport_yuv_copy_band(frame->data[0],frame->data[1],frame->width,frame->height,
frame->linesize[0],frame->linesize[1],planes,band,bands);return;
@@ -104,7 +50,7 @@ class ColorWorkers {
frame->data[1]+(row/2)*frame->linesize[1],output+row*stride*4,matrix,frame->width);
}
static void *thread(void *arg) {
auto &worker=*static_cast<Worker*>(arg);auto &pool=*worker.owner;unsigned seen=worker.seen;
auto &worker=*static_cast<Worker*>(arg);auto &pool=*worker.owner;unsigned seen=0;
pthread_mutex_lock(&pool.mutex);
for(;;) {
while(!pool.stopping && seen==pool.generation)pthread_cond_wait(&pool.work,&pool.mutex);
@@ -119,17 +65,14 @@ class ColorWorkers {
public:
void convert(AVFrame *source,uint8_t *destination,size_t pitch,const YuvConstants *constants,
const AHardwareBuffer_Planes *native=nullptr) {
// Start helpers when a large picture first appears, including after
// an adaptive stream begins at a lower resolution.
if(!started && source->width*source->height>=4096*2048) {
if(!started) {
started=true;
for(unsigned i=0;i<3;i++) {
// Only this thread advances the generation, so a helper
// created now waits for the next picture, never a stale one.
workers[i]={this,i+1,generation,{}};
if(pthread_create(&workers[i].thread,nullptr,thread,&workers[i]))break;
count++;
}
if(source->width*source->height>=4096*2048)
for(unsigned i=0;i<3;i++) {
workers[i]={this,i+1,{}};
if(pthread_create(&workers[i].thread,nullptr,thread,&workers[i]))break;
count++;
}
}
pthread_mutex_lock(&mutex);
frame=source;output=destination;stride=pitch;matrix=constants;complete=0;generation++;
@@ -148,110 +91,32 @@ public:
pthread_cond_destroy(&work);pthread_cond_destroy(&done);pthread_mutex_destroy(&mutex);
}
};
class FramePortVideo final : public SoftVideoDecoderOMXComponent {
const DecoderKind &kind;
class FramePortHEVC final : public SoftVideoDecoderOMXComponent {
ColorWorkers color;
FramePortDmaCopy gpu;
bool gpu_allowed=true;
AVCodecContext *codec=nullptr;
AVCodecParserContext *parser=nullptr;
AVFrame *frame=av_frame_alloc();
uint8_t *config=nullptr;
size_t config_size=0;
bool failed=false,held=false,drained=false,replace_config=false,surface=false;
bool native_enabled=false,metadata=false;
bool hardware_active=false,prefer_software=false,eos_submitted=false,replay_eos=false;
bool prepend_config=false;
Vector<AVPacket*> history;
size_t history_bytes=0;
ssize_t replay_next=-1;
int64_t last_output_pts=AV_NOPTS_VALUE,discard_until=AV_NOPTS_VALUE;
static constexpr OMX_INDEXTYPE native_index=(OMX_INDEXTYPE)0x7f010001;
static constexpr OMX_INDEXTYPE metadata_index=(OMX_INDEXTYPE)0x7f010002;
static constexpr OMX_INDEXTYPE usage_index=(OMX_INDEXTYPE)0x7f010003;
int64_t eos_pts=0;
static int64_t clockNs() {timespec ts;clock_gettime(CLOCK_MONOTONIC,&ts);return ts.tv_sec*1000000000ll+ts.tv_nsec;}
int64_t stats_start=0, stats_lock=0, stats_convert=0, stats_unlock=0;
unsigned stats_frames=0,stats_gpu=0;
bool gpuCopy(AHardwareBuffer *hardware,const AHardwareBuffer_Planes &planes) {
if(planes.planes[1].pixelStride!=2 || planes.planes[2].pixelStride!=2 ||
planes.planes[1].rowStride!=planes.planes[2].rowStride ||
(uintptr_t)planes.planes[2].data!=(uintptr_t)planes.planes[1].data+1 ||
(uintptr_t)planes.planes[1].data<(uintptr_t)planes.planes[0].data)return false;
const auto *handle=AHardwareBuffer_getNativeHandle(hardware);
// CrOS gralloc includes duplicate plane fds and a metadata fd. The
// first fd holds the linear pixels; copy() bounds-checks both planes.
if(!handle || handle->numFds<1)return false;
int source=frameport_v4l2_export_frame(frame);if(source<0)return false;
uint64_t src_uv=(uintptr_t)frame->data[1]-(uintptr_t)frame->data[0];
uint64_t dst_uv=(uintptr_t)planes.planes[1].data-(uintptr_t)planes.planes[0].data;
std::vector<VkBufferCopy> regions;
auto append=[&](VkDeviceSize src,VkDeviceSize dst,VkDeviceSize bytes){
if(!regions.empty() && regions.back().srcOffset+regions.back().size==src &&
regions.back().dstOffset+regions.back().size==dst)regions.back().size+=bytes;
else regions.push_back({src,dst,bytes});
};
for(int row=0;row<frame->height;row++)
append((uint64_t)row*frame->linesize[0],(uint64_t)row*planes.planes[0].rowStride,frame->width);
for(int row=0;row<frame->height/2;row++)
append(src_uv+(uint64_t)row*frame->linesize[1],dst_uv+(uint64_t)row*planes.planes[1].rowStride,frame->width);
bool result=gpu.copy(source,handle->data[0],regions.data(),regions.size());close(source);return result;
}
void clearHistory() {
for(size_t i=0;i<history.size();i++){AVPacket *packet=history[i];av_packet_free(&packet);}
history.clear();history_bytes=0;replay_next=-1;
}
void rememberInput(const AVPacket *packet) {
if(!hardware_active)return;
if(packet->flags&AV_PKT_FLAG_KEY){
// Keep the preceding GOP too: the new keyframe can be submitted
// before older reordered pictures have finished coming out.
ssize_t previous=-1;
for(size_t i=0;i<history.size();i++)if(history[i]->flags&AV_PKT_FLAG_KEY)previous=i;
if(previous>0){
for(ssize_t i=0;i<previous;i++){
AVPacket *old=history[i];history_bytes-=old->size;av_packet_free(&old);
}
history.removeItemsAt(0,previous);
}
}
// References to compressed input, not copies of decoded panoramas.
// Bound memory for unusually long GOPs or malformed streams.
if(history_bytes+(size_t)packet->size>64*1024*1024 || history.size()>=512)clearHistory();
if(history.empty() && !(packet->flags&AV_PKT_FLAG_KEY))return;
AVPacket *copy=av_packet_clone(packet);
if(!copy)return;
if(history.add(copy)<0){av_packet_free(&copy);return;}
history_bytes+=copy->size;
}
bool annexB() const {
// H.264/HEVC parameter sets with start codes can go in-band. VP9's
// configuration is container metadata, never bitstream data.
return kind.coding!=OMX_VIDEO_CodingVP9 && config_size>=4 && !config[0] && !config[1] &&
(config[2]==1 || (!config[2] && config[3]==1));
}
unsigned stats_frames=0;
void fail(int result,const char *operation) {
char message[128];av_strerror(result,message,sizeof(message));
ALOGE("%s failed: %s",operation,message);
failed=true;notify(OMX_EventError,OMX_ErrorHardware,0,nullptr);
}
static unsigned captureBuffers(const DecoderKind &kind,uint64_t pixels) {
// FFmpeg's general-purpose capture budget (20) suits ordinary sizes.
// Reference pictures shrink as pictures grow (HEVC level 6.x DPB
// limits; H.264 level 6.2 allows fewer), so 20 panorama-sized buffers
// would hold about 1 GB at 8K. VP9 can keep 8 references at any size.
// Unknown sizes keep the full budget.
const uint64_t max_luma=35651584;
unsigned references=pixels<=max_luma/4?16:pixels<=max_luma/2?12:pixels<=max_luma*3/4?8:6;
if(kind.coding==OMX_VIDEO_CodingVP9 && references<9)references=9;
// Room for the picture being decoded, one held for output and queueing.
return references+4<20?references+4:20;
}
int openCodec(const char *name,bool hardware) {
const AVCodec *implementation=avcodec_find_decoder_by_name(name);
if(!implementation || !frame)return AVERROR(ENOMEM);
codec=avcodec_alloc_context3(implementation);
if(!codec)return AVERROR(ENOMEM);
bool openDecoder() {
if(codec)return true;
const AVCodec *hardware=avcodec_find_decoder_by_name("hevc_v4l2m2m");
if(!hardware || !frame){fail(AVERROR(ENOMEM),"decoder allocation");return false;}
codec=avcodec_alloc_context3(hardware);
if(!codec){fail(AVERROR(ENOMEM),"codec context");return false;}
const auto &input=editPortInfo(kInputPortIndex)->mDef.format.video;
codec->width=codec->coded_width=input.nFrameWidth;
codec->height=codec->coded_height=input.nFrameHeight;
@@ -259,67 +124,15 @@ class FramePortVideo final : public SoftVideoDecoderOMXComponent {
codec->pix_fmt=AV_PIX_FMT_NV12;
if(config_size) {
codec->extradata=(uint8_t*)av_mallocz(config_size+AV_INPUT_BUFFER_PADDING_SIZE);
if(!codec->extradata)return AVERROR(ENOMEM);
if(!codec->extradata){fail(AVERROR(ENOMEM),"codec configuration");return false;}
memcpy(codec->extradata,config,config_size);codec->extradata_size=(int)config_size;
}
AVDictionary *options=nullptr;
if(hardware){
av_dict_set(&options,"num_output_buffers","2",0);
// Leave room for reference/reordered pictures: a four-buffer
// budget stalled a valid H.264 stream on Iris.
av_dict_set_int(&options,"num_capture_buffers",
captureBuffers(kind,(uint64_t)input.nFrameWidth*input.nFrameHeight),0);
}else{codec->thread_count=4;codec->thread_type=FF_THREAD_FRAME|FF_THREAD_SLICE;}
int result=avcodec_open2(codec,implementation,&options);av_dict_free(&options);
return result;
}
bool openDecoder() {
if(codec)return true;
const auto &size=editPortInfo(kInputPortIndex)->mDef.format.video;
int result=AVERROR(ENODEV);
if(size.nFrameWidth>kind.max_width || size.nFrameHeight>kind.max_height)
ALOGW("%s %ux%u is above what Iris decodes (%ux%u); using software decoding",kind.mime,
(unsigned)size.nFrameWidth,(unsigned)size.nFrameHeight,kind.max_width,kind.max_height);
else if(!prefer_software)for(int64_t start=clockNs(),attempt=0;;attempt++) {
result=openCodec(kind.hardware,true);
if(result>=0 || (result!=AVERROR(ENOMEM) && result!=AVERROR(EBUSY)))break;
// The driver refuses a session that doesn't fit next to the open
// ones (8K while any other is open). SteamVR's link holds one for
// about 12 s around every game start, when the plugin loads too:
// wait for it then (an 8K panorama in software is a slideshow),
// otherwise 2 s, before continuing in software.
int64_t now=clockNs();
if(now-start>=2000000000ll && now-plugin_loaded_ns>=20000000000ll)break;
if(!attempt)ALOGW("Iris %s busy (%ux%u); waiting for capacity",kind.mime,(unsigned)size.nFrameWidth,
(unsigned)size.nFrameHeight);
gpu.clearBuffers();avcodec_free_context(&codec);usleep(250000);
}
hardware_active=result>=0;
if(result<0){
// Availability can change after enumeration (another app/Steam
// opens Iris). Native MediaCodec clients do not all retry a stock
// component, so retain playback within this selected component.
if(prefer_software)ALOGI("%s: continuing in software for this session",kind.mime);
else ALOGW("Iris %s initialization failed (%d); using software decoding",kind.mime,result);
gpu.clearBuffers();avcodec_free_context(&codec);
result=openCodec(kind.software,false);
if(result<0){gpu.clearBuffers();avcodec_free_context(&codec);fail(result,"software fallback initialization");return false;}
}else ALOGI("Iris hardware %s decoder active: %dx%d",kind.mime,codec->coded_width,codec->coded_height);
prepend_config=hardware_active && annexB();
parser=av_parser_init(codec->codec_id);
if(parser)parser->flags|=PARSER_FLAG_COMPLETE_FRAMES;
return true;
}
bool recoverHardware(const char *operation) {
if(!hardware_active || history.empty() || !(history[0]->flags&AV_PKT_FLAG_KEY)){
fail(AVERROR_INVALIDDATA,operation);return false;
}
ALOGW("Iris %s failed during %s; replaying %zu compressed packets in software, last pts=%lld",kind.mime,operation,history.size(),(long long)last_output_pts);
av_frame_unref(frame);held=false;av_parser_close(parser);parser=nullptr;gpu.clearBuffers();avcodec_free_context(&codec);
hardware_active=false;prefer_software=true;
int result=openCodec(kind.software,false);
if(result<0){fail(result,"software recovery initialization");return false;}
replay_next=0;replay_eos=eos_submitted;eos_submitted=false;discard_until=last_output_pts;
av_dict_set(&options,"num_output_buffers","2",0);
av_dict_set(&options,"num_capture_buffers","4",0);
int result=avcodec_open2(codec,hardware,&options);av_dict_free(&options);
if(result<0){fail(result,"Iris hardware initialization");return false;}
ALOGI("Iris hardware HEVC decoder active: %dx%d",codec->coded_width,codec->coded_height);
return true;
}
void consumeInput(BufferInfo *info) {
@@ -328,36 +141,7 @@ class FramePortVideo final : public SoftVideoDecoderOMXComponent {
header->nOffset=0;header->nFilledLen=0;info->mOwnedByUs=false;
notifyEmptyBufferDone(header);
}
void reportBitstreamColor() {
// Like Android's stock decoders, pass signalled colour information
// to ACodec, which uses it for the surface dataspace. Container
// values still take precedence (getColorAspectPreference).
if(frame->color_primaries==AVCOL_PRI_UNSPECIFIED && frame->color_trc==AVCOL_TRC_UNSPECIFIED &&
frame->colorspace==AVCOL_SPC_UNSPECIFIED && frame->color_range==AVCOL_RANGE_UNSPECIFIED)return;
// FFmpeg's colour enums use the ISO/IEC 23091-2 code points.
ColorAspects aspects;
ColorUtils::convertIsoColorAspectsToCodecAspects(frame->color_primaries,frame->color_trc,
frame->colorspace,frame->color_range==AVCOL_RANGE_JPEG,aspects);
if(colorAspectsDiffer(aspects,mBitstreamColorAspects)){
ALOGI("%s bitstream colour: primaries %d, transfer %d, matrix %d, range %d",kind.mime,
frame->color_primaries,frame->color_trc,frame->colorspace,frame->color_range);
mBitstreamColorAspects=aspects;handleColorAspectsChange();
}
}
const YuvConstants *rgbaMatrix() {
ColorAspects aspects;
{Mutex::Autolock lock(mColorAspectsLock);aspects=mFinalColorAspects;}
// Unsignalled HD video is conventionally BT.709, as Android assumes.
bool bt709=aspects.mMatrixCoeffs==ColorAspects::MatrixBT709_5 ||
(aspects.mMatrixCoeffs==ColorAspects::MatrixUnspecified && (frame->width>=1280 || frame->height>=720));
if(aspects.mRange==ColorAspects::RangeFull)return bt709?&frameport_full709_rgba:&kYvuJPEGConstants;
return bt709?&kYvuH709Constants:&kYvuI601Constants;
}
bool outputFrame() {
// Hardware errors are recovered in software. Software decoders
// conceal damage themselves; output their picture as stock decoders do.
if(frame->decode_error_flags && hardware_active)return recoverHardware("decoded picture");
reportBitstreamColor();
bool reset=false;
handlePortSettingsChange(&reset,frame->width,frame->height,
native_enabled?(OMX_COLOR_FORMATTYPE)AHARDWAREBUFFER_FORMAT_Y8Cb8Cr8_420:
@@ -369,8 +153,7 @@ class FramePortVideo final : public SoftVideoDecoderOMXComponent {
BufferInfo *info=*queue.begin();OMX_BUFFERHEADERTYPE *header=info->mHeader;
size_t stride=outputBufferWidth(),height=outputBufferHeight();
size_t bytes=native_enabled?sizeof(VideoNativeMetadata):surface?stride*height*4:stride*height*3/2;
bool planar=frame->format==AV_PIX_FMT_YUV420P || frame->format==AV_PIX_FMT_YUVJ420P;
if(bytes>header->nAllocLen || (frame->format!=AV_PIX_FMT_NV12 && !planar) ||
if(bytes>header->nAllocLen || frame->format!=AV_PIX_FMT_NV12 ||
frame->width<1 || frame->height<1 || (frame->width&1) || (frame->height&1) ||
(size_t)frame->width>stride || (size_t)frame->height>height) {
fail(AVERROR(EINVAL),"output buffer format");return false;
@@ -380,11 +163,11 @@ class FramePortVideo final : public SoftVideoDecoderOMXComponent {
// Lepton's slow scalar RGB565 renderer.
if(surface || native_enabled) {
int64_t before=clockNs(),locked=before,converted=before;
const YuvConstants *matrix=native_enabled?nullptr:rgbaMatrix();
const YuvConstants *matrix=mDefaultColorAspects.mMatrixCoeffs==ColorAspects::MatrixBT709_5
?&kYvuH709Constants:&kYvuI601Constants;
uint8_t *destination=header->pBuffer;
AHardwareBuffer *hardware=nullptr;VideoNativeMetadata *native=nullptr;
AHardwareBuffer_Planes planes={};
bool used_gpu=false;
if(native_enabled) {
native=reinterpret_cast<VideoNativeMetadata*>(header->pBuffer);
if(!metadata || native->eType!=kMetadataBufferTypeANWBuffer || !native->pBuffer) {
@@ -411,61 +194,40 @@ class FramePortVideo final : public SoftVideoDecoderOMXComponent {
AHardwareBuffer_unlock(hardware,&native->nFenceFd);
fail(AVERROR(EINVAL),"native YUV planes");return false;
}
locked=clockNs();
if(gpu_allowed && hardware_active && !planar && !(frame->width&3) &&
(uint64_t)frame->width*frame->height>=4096*2048 && gpu.available()) {
if(AHardwareBuffer_unlock(hardware,&native->nFenceFd)){
fail(AVERROR(EIO),"native layout unlock");return false;
}
if(native->nFenceFd>=0){
pollfd fence={native->nFenceFd,POLLIN,0};int waited;
do{waited=poll(&fence,1,10000);}while(waited<0 && errno==EINTR);
close(native->nFenceFd);native->nFenceFd=-1;
if(waited<=0 || !(fence.revents&POLLIN)){fail(AVERROR(EIO),"native layout fence");return false;}
}
used_gpu=gpuCopy(hardware,planes);
if(!used_gpu){
gpu_allowed=false;
if(gpu.failedAfterSubmit()){fail(AVERROR(EIO),"GPU video transfer");return false;}
if(AHardwareBuffer_lockPlanes(hardware,AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN,-1,nullptr,&planes)){
fail(AVERROR(EIO),"native CPU fallback lock");return false;
}
ALOGW("GPU video buffer sharing unavailable; retaining CPU copy");
}
}
}
if(!native_enabled)locked=clockNs();
if(native_enabled && !used_gpu)color.convert(frame,nullptr,0,nullptr,&planes);
else if(!native_enabled)color.convert(frame,destination,stride,matrix);
locked=clockNs();
if(native_enabled)color.convert(frame,nullptr,0,nullptr,&planes);
else color.convert(frame,destination,stride,matrix);
converted=clockNs();
if(hardware && !used_gpu && AHardwareBuffer_unlock(hardware,&native->nFenceFd)) {
if(hardware && AHardwareBuffer_unlock(hardware,&native->nFenceFd)) {
fail(AVERROR(EIO),"native output unlock");return false;
}
int64_t after=clockNs();
stats_lock+=locked-before;stats_convert+=converted-locked;stats_unlock+=after-converted;
if(!stats_start)stats_start=before;
stats_frames++;
if(used_gpu)stats_gpu++;
if(after-stats_start>=5000000000ll) {
ALOGI("output %.1f fps, native=%d, gpu=%u/%u, lock %.2f ms, copy/convert %.2f ms, unlock %.2f ms, pts=%lld",
stats_frames*1e9/(after-stats_start),native_enabled,stats_gpu,stats_frames,
ALOGI("output %.1f fps, native=%d, lock %.2f ms, copy/convert %.2f ms, unlock %.2f ms, pts=%lld",
stats_frames*1e9/(after-stats_start),native_enabled,
stats_lock/(1e6*stats_frames),stats_convert/(1e6*stats_frames),stats_unlock/(1e6*stats_frames),
(long long)frame->pts);
stats_start=after;stats_frames=stats_gpu=0;stats_lock=stats_convert=stats_unlock=0;
stats_start=after;stats_frames=0;stats_lock=stats_convert=stats_unlock=0;
}
} else {
// Byte buffers hold planar YUV; the same worker bands copy the
// planes (in parallel for panorama sizes).
uint8_t *y=header->pBuffer,*u=y+stride*height,*v=u+stride*height/4;
AHardwareBuffer_Planes planes={};planes.planeCount=3;
planes.planes[0]={y,1,(uint32_t)stride};
planes.planes[1]={u,1,(uint32_t)(stride/2)};
planes.planes[2]={v,1,(uint32_t)(stride/2)};
color.convert(frame,nullptr,0,nullptr,&planes);
for(int row=0;row<frame->height;row++)
memcpy(y+row*stride,frame->data[0]+row*frame->linesize[0],frame->width);
for(int row=0;row<frame->height/2;row++) {
const uint8_t *src=frame->data[1]+row*frame->linesize[1];
uint8_t *du=u+row*(stride/2),*dv=v+row*(stride/2);int x=0;
for(;x+16<=frame->width/2;x+=16) {
uint8x16x2_t pair=vld2q_u8(src+2*x);vst1q_u8(du+x,pair.val[0]);vst1q_u8(dv+x,pair.val[1]);
}
for(;x<frame->width/2;x++){du[x]=src[2*x];dv[x]=src[2*x+1];}
}
}
header->nOffset=0;header->nFilledLen=(OMX_U32)bytes;
header->nTimeStamp=frame->pts==AV_NOPTS_VALUE?frame->best_effort_timestamp:frame->pts;
last_output_pts=header->nTimeStamp;
header->nFlags=OMX_BUFFERFLAG_ENDOFFRAME;
queue.erase(queue.begin());info->mOwnedByUs=false;notifyFillBufferDone(header);
av_frame_unref(frame);held=false;return true;
@@ -539,34 +301,14 @@ protected:
if(held){if(!outputFrame())return;continue;}
if(codec) {
int result=avcodec_receive_frame(codec,frame);
if(result==0){
int64_t pts=frame->pts==AV_NOPTS_VALUE?frame->best_effort_timestamp:frame->pts;
if(discard_until!=AV_NOPTS_VALUE && pts!=AV_NOPTS_VALUE && pts<=discard_until){av_frame_unref(frame);continue;}
discard_until=AV_NOPTS_VALUE;held=true;continue;
}
if(result==0){held=true;continue;}
if(result==AVERROR_EOF) {
BufferInfo *info=*outputs.begin();auto *header=info->mHeader;
header->nOffset=0;header->nFilledLen=0;header->nTimeStamp=eos_pts;
header->nFlags=OMX_BUFFERFLAG_EOS;outputs.erase(outputs.begin());
info->mOwnedByUs=false;notifyFillBufferDone(header);drained=true;return;
}
if(result!=AVERROR(EAGAIN)){
if(hardware_active && recoverHardware("frame retrieval"))continue;
if(!failed)fail(result,"frame retrieval");return;
}
}
if(replay_next>=0){
if((size_t)replay_next<history.size()){
int result=avcodec_send_packet(codec,history[replay_next]);
if(result<0){fail(result,"software recovery packet");return;}
replay_next++;continue;
}
clearHistory();
if(replay_eos){
int result=avcodec_send_packet(codec,nullptr);
if(result<0){fail(result,"software recovery drain");return;}
replay_eos=false;eos_submitted=true;continue;
}
if(result!=AVERROR(EAGAIN)){fail(result,"hardware frame retrieval");return;}
}
if(inputs.empty())return;
BufferInfo *info=*inputs.begin();auto *header=info->mHeader;
@@ -575,8 +317,8 @@ protected:
}
if(header->nFlags&OMX_BUFFERFLAG_CODECCONFIG) {
if(replace_config){av_freep(&config);config_size=0;replace_config=false;}
if(config_size+header->nFilledLen>1024*1024) {
fail(AVERROR(EINVAL),"oversized codec configuration");return;
if(codec || config_size+header->nFilledLen>1024*1024) {
fail(AVERROR(EINVAL),"late or oversized codec configuration");return;
}
if(header->nFilledLen) {
void *grown=av_realloc(config,config_size+header->nFilledLen);
@@ -585,170 +327,97 @@ protected:
memcpy(config+config_size,header->pBuffer+header->nOffset,header->nFilledLen);
config_size+=header->nFilledLen;
}
// Streaming apps and restarted encoders can send parameter
// sets again mid-stream. Stock decoders accept them; submit
// them in-band before the next access unit.
if(codec)prepend_config=annexB();
consumeInput(info);continue;
}
if(!openDecoder())return;
if(header->nFilledLen) {
AVPacket *packet=av_packet_alloc();
if(!packet){fail(AVERROR(ENOMEM),"input packet");return;}
// Android delivers parameter sets in separate CODECCONFIG
// buffers. V4L2 expects Annex B parameter sets in its input
// stream; FFmpeg's V4L2 wrapper does not submit extradata.
size_t prefix=prepend_config?config_size:0;
int result=av_new_packet(packet,(int)(prefix+header->nFilledLen));
int result=av_new_packet(packet,(int)header->nFilledLen);
if(result>=0) {
if(prefix)memcpy(packet->data,config,prefix);
memcpy(packet->data+prefix,header->pBuffer+header->nOffset,header->nFilledLen);
memcpy(packet->data,header->pBuffer+header->nOffset,header->nFilledLen);
packet->pts=packet->dts=header->nTimeStamp;
if(header->nFlags&OMX_BUFFERFLAG_SYNCFRAME)packet->flags|=AV_PKT_FLAG_KEY;
// ACodec does not preserve MediaCodec's KEY_FRAME flag
// on decoder input. Parse complete access units so driver
// recovery also works for ordinary Android clients.
if(parser){
uint8_t *parsed=nullptr;int parsed_size=0;
av_parser_parse2(parser,codec,&parsed,&parsed_size,packet->data,packet->size,
packet->pts,packet->dts,AV_NOPTS_VALUE);
if(parser->key_frame==1)packet->flags|=AV_PKT_FLAG_KEY;
}
result=avcodec_send_packet(codec,packet);
if(result>=0){prepend_config=false;rememberInput(packet);}
}
av_packet_free(&packet);
if(result==AVERROR(EAGAIN))return;
if(result<0){
if(hardware_active && recoverHardware("packet submission"))continue;
if(!failed)fail(result,"packet submission");return;
}
if(result<0){fail(result,"hardware packet submission");return;}
header->nFilledLen=0;
}
if(header->nFlags&OMX_BUFFERFLAG_EOS) {
eos_pts=header->nTimeStamp;
int result=avcodec_send_packet(codec,nullptr);
if(result==AVERROR(EAGAIN))return;
if(result<0){fail(result,"stream drain");return;}
eos_submitted=true;
if(result<0){fail(result,"hardware stream drain");return;}
}
replace_config=true; // a later configuration replaces this one
consumeInput(info);
}
}
void onPortFlushCompleted(OMX_U32 port) override {
if(port==kInputPortIndex) {
gpu.clearBuffers();
// Release the held picture first (its V4L2 buffer returns to the driver).
av_frame_unref(frame);av_parser_close(parser);parser=nullptr;clearHistory();
// Software decoders flush in place. A hardware session restarts in
// place only after a completed drain (replay/loop after EOS):
// restarting a running Iris session's queues failed its admission
// check and left buffers unreturned, so other seeks reopen it.
bool usable=codec && !failed && (!hardware_active || drained);
eos_submitted=false;replay_eos=false;
last_output_pts=discard_until=AV_NOPTS_VALUE;
// A fresh hardware session avoids relying on partially drained
// firmware state when ExoPlayer seeks or starts another clip.
avcodec_free_context(&codec);av_frame_unref(frame);
held=false;drained=false;failed=false;replace_config=true;
if(usable) {
// Keeps the buffers and the Iris session (no reallocation).
avcodec_flush_buffers(codec);
int64_t restart_error=0;
if(hardware_active && (av_opt_get_int(codec->priv_data,"frameport_flush_error",0,&restart_error)<0 ||
restart_error<0)) {
ALOGW("Iris %s replay restart failed (%lld); continuing in software",kind.mime,(long long)restart_error);
gpu.clearBuffers();avcodec_free_context(&codec);hardware_active=false;prefer_software=true;
} else {
parser=av_parser_init(codec->codec_id);
if(parser)parser->flags|=PARSER_FLAG_COMPLETE_FRAMES;
prepend_config=annexB();
}
} else {
if(hardware_active && kind.coding==OMX_VIDEO_CodingVP9){
// Reopening Iris VP9 sessions after a seek damaged pictures and
// faulted the kernel on this Frame: continue in software.
prefer_software=true;
ALOGW("VP9 seek: continuing in software rather than reopening Iris");
}
gpu.clearBuffers();avcodec_free_context(&codec);hardware_active=false;
}
}
}
void onReset() override {
gpu_allowed=true;
av_frame_unref(frame);av_parser_close(parser);parser=nullptr;gpu.clearBuffers();avcodec_free_context(&codec);av_freep(&config);config_size=0;clearHistory();
hardware_active=false;prefer_software=false;eos_submitted=false;replay_eos=false;
last_output_pts=discard_until=AV_NOPTS_VALUE;
avcodec_free_context(&codec);av_frame_unref(frame);av_freep(&config);config_size=0;
held=false;drained=false;failed=false;replace_config=false;
SoftVideoDecoderOMXComponent::onReset();
surface=false;native_enabled=false;metadata=false;mOutputFormat=OMX_COLOR_FormatYUV420Planar;
updatePortDefinitions(true,false);
}
~FramePortVideo() override {av_frame_free(&frame);av_parser_close(parser);gpu.clearBuffers();avcodec_free_context(&codec);av_freep(&config);clearHistory();}
~FramePortHEVC() override {av_frame_free(&frame);avcodec_free_context(&codec);av_freep(&config);}
public:
FramePortVideo(const DecoderKind &type,const OMX_CALLBACKTYPE *callbacks,OMX_PTR appData,OMX_COMPONENTTYPE **component)
:SoftVideoDecoderOMXComponent(type.name,type.role,type.coding,
type.profiles,type.profile_count,320,240,callbacks,appData,component),kind(type) {
initPorts(8,2*1024*1024,4,type.mime,1);
FramePortHEVC(const char *name,const OMX_CALLBACKTYPE *callbacks,OMX_PTR appData,OMX_COMPONENTTYPE **component)
:SoftVideoDecoderOMXComponent(name,"video_decoder.hevc",OMX_VIDEO_CodingHEVC,
profiles,1,320,240,callbacks,appData,component) {
initPorts(8,2*1024*1024,4,"video/hevc",1);
}
};
class FramePortOMXPlugin final : public OMXPluginBase {
bool available[kind_count]={};
static bool probeCapacity(const DecoderKind &kind) {
// Check each coded format separately, at ordinary video geometry.
// A rejected 8K session must not hide hardware from a 1080p app.
bool available;
static bool probeCapacity() {
// Some SteamOS Iris builds count the new session's geometry for every
// open session. A concurrent Steam UI decoder can therefore reject 8K.
// Retain the stock HEVC codec if the driver's admission check fails.
int fd=open("/dev/video-dec0",O_RDWR|O_NONBLOCK);
if(fd<0)return false;
bool supported=false;
for(unsigned i=0;;i++){
v4l2_fmtdesc desc={};desc.index=i;desc.type=V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE;
if(ioctl(fd,VIDIOC_ENUM_FMT,&desc)<0)break;
if(desc.pixelformat==kind.fourcc)supported=true;
}
if(!supported){close(fd);return false;}
v4l2_format format={};format.type=V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE;
format.fmt.pix_mp.width=1920;format.fmt.pix_mp.height=1080;
format.fmt.pix_mp.pixelformat=kind.fourcc;format.fmt.pix_mp.num_planes=1;
bool ok=ioctl(fd,VIDIOC_S_FMT,&format)==0 && format.fmt.pix_mp.pixelformat==kind.fourcc;
format.fmt.pix_mp.width=8192;format.fmt.pix_mp.height=4096;
format.fmt.pix_mp.pixelformat=V4L2_PIX_FMT_HEVC;format.fmt.pix_mp.num_planes=1;
bool ok=ioctl(fd,VIDIOC_S_FMT,&format)==0;
format.type=V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE;format.fmt.pix_mp.pixelformat=V4L2_PIX_FMT_NV12;
if(ok)ok=ioctl(fd,VIDIOC_S_FMT,&format)==0 && format.fmt.pix_mp.pixelformat==V4L2_PIX_FMT_NV12;
if(ok)ok=ioctl(fd,VIDIOC_S_FMT,&format)==0;
v4l2_requestbuffers buffers={};buffers.count=2;
buffers.type=V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE;buffers.memory=V4L2_MEMORY_MMAP;
if(ok)ok=ioctl(fd,VIDIOC_REQBUFS,&buffers)==0;
close(fd);return ok;
}
public:
FramePortOMXPlugin() {
timespec now;clock_gettime(CLOCK_MONOTONIC,&now);plugin_loaded_ns=now.tv_sec*1000000000ll+now.tv_nsec;
for(unsigned i=0;i<kind_count;i++){
available[i]=probeCapacity(kinds[i]);
if(!available[i])ALOGW("Iris %s unavailable; keeping Android's stock decoder",kinds[i].mime);
}
FramePortOMXPlugin():available(probeCapacity()) {
if(!available)ALOGW("Iris 8K admission unavailable; keeping Android's stock HEVC decoder");
}
OMX_ERRORTYPE makeComponentInstance(const char *name,const OMX_CALLBACKTYPE *callbacks,
OMX_PTR appData,OMX_COMPONENTTYPE **component) override {
for(unsigned i=0;i<kind_count;i++)if(available[i] && !strcmp(name,kinds[i].name)){
auto *decoder=new FramePortVideo(kinds[i],callbacks,appData,component);
decoder->incStrong(this);return decoder->initCheck();
}
return OMX_ErrorInvalidComponentName;
if(!available || strcmp(name,"OMX.frameport.hevc.decoder"))return OMX_ErrorInvalidComponentName;
auto *decoder=new FramePortHEVC(name,callbacks,appData,component);
decoder->incStrong(this);return decoder->initCheck();
}
OMX_ERRORTYPE destroyComponentInstance(OMX_COMPONENTTYPE *component) override {
auto *decoder=static_cast<SoftOMXComponent*>(component->pComponentPrivate);
decoder->prepareForDestruction();decoder->decStrong(this);return OMX_ErrorNone;
}
OMX_ERRORTYPE enumerateComponents(OMX_STRING name,size_t size,OMX_U32 index) override {
for(unsigned i=0;i<kind_count;i++)if(available[i]){
if(index){--index;continue;}
if(size<=strlen(kinds[i].name))return OMX_ErrorBadParameter;
strcpy(name,kinds[i].name);return OMX_ErrorNone;
}
return OMX_ErrorNoMore;
if(index || !available)return OMX_ErrorNoMore;
if(size<sizeof("OMX.frameport.hevc.decoder"))return OMX_ErrorBadParameter;
strcpy(name,"OMX.frameport.hevc.decoder");return OMX_ErrorNone;
}
OMX_ERRORTYPE getRolesOfComponent(const char *name,Vector<String8> *roles) override {
for(unsigned i=0;i<kind_count;i++)if(available[i] && !strcmp(name,kinds[i].name)){
roles->clear();roles->push(String8(kinds[i].role));return OMX_ErrorNone;
}
return OMX_ErrorInvalidComponentName;
if(!available || strcmp(name,"OMX.frameport.hevc.decoder"))return OMX_ErrorInvalidComponentName;
roles->clear();roles->push(String8("video_decoder.hevc"));return OMX_ErrorNone;
}
};
}
+8 -25
View File
@@ -15,32 +15,25 @@ int main(int argc,char **argv){
bool fallback=argc>2 && !strcmp(argv[2],"fallback");
assert(argc>=2);int fd=open(argv[1],O_RDONLY);assert(fd>=0);struct stat st;assert(!fstat(fd,&st));
auto *extractor=AMediaExtractor_new();assert(AMediaExtractor_setDataSourceFd(extractor,fd,0,st.st_size)==AMEDIA_OK);
AMediaFormat *format=nullptr;int width=0,height=0;const char *video_mime=nullptr;
AMediaFormat *format=nullptr;int width=0,height=0;
for(size_t i=0;i<AMediaExtractor_getTrackCount(extractor);i++){
auto *candidate=AMediaExtractor_getTrackFormat(extractor,i);const char *mime=nullptr;
if(AMediaFormat_getString(candidate,AMEDIAFORMAT_KEY_MIME,&mime)&&
(!strcmp(mime,"video/hevc") || !strcmp(mime,"video/avc") || !strcmp(mime,"video/x-vnd.on2.vp9"))){
format=candidate;video_mime=mime;assert(AMediaExtractor_selectTrack(extractor,i)==AMEDIA_OK);break;
if(AMediaFormat_getString(candidate,AMEDIAFORMAT_KEY_MIME,&mime)&&!strcmp(mime,"video/hevc")){
format=candidate;assert(AMediaExtractor_selectTrack(extractor,i)==AMEDIA_OK);break;
}AMediaFormat_delete(candidate);
}assert(format);
assert(AMediaFormat_getInt32(format,AMEDIAFORMAT_KEY_WIDTH,&width));assert(AMediaFormat_getInt32(format,AMEDIAFORMAT_KEY_HEIGHT,&height));
const char *expected=!strcmp(video_mime,"video/hevc")?"OMX.frameport.hevc.decoder":
!strcmp(video_mime,"video/avc")?"OMX.frameport.avc.decoder":"OMX.frameport.vp9.decoder";
const char *stock=!strcmp(video_mime,"video/hevc")?"OMX.google.hevc.decoder":
!strcmp(video_mime,"video/avc")?"OMX.google.h264.decoder":"OMX.google.vp9.decoder";
auto *codec=AMediaCodec_createDecoderByType(video_mime);assert(codec);char *name=nullptr;
auto *codec=AMediaCodec_createDecoderByType("video/hevc");assert(codec);char *name=nullptr;
assert(AMediaCodec_getName(codec,&name)==AMEDIA_OK);printf("Selected codec: %s\n",name);fflush(stdout);
if(argc>2 && !strcmp(argv[2],"query")){
assert(!strcmp(name,expected) || !strcmp(name,stock));
assert(!strcmp(name,"OMX.frameport.hevc.decoder") || !strcmp(name,"OMX.google.hevc.decoder"));
AMediaCodec_releaseName(codec,name);AMediaCodec_delete(codec);
AMediaFormat_delete(format);AMediaExtractor_delete(extractor);close(fd);
puts("PASS default HEVC selection");return 0;
}
assert(!strcmp(name,fallback?stock:expected));AMediaCodec_releaseName(codec,name);
assert(!strcmp(name,fallback?"OMX.google.hevc.decoder":"OMX.frameport.hevc.decoder"));AMediaCodec_releaseName(codec,name);
AImageReader *reader=nullptr;ANativeWindow *window=nullptr;
if(argc>2){assert(AImageReader_newWithUsage(width,height,AIMAGE_FORMAT_PRIVATE,AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE,3,&reader)==AMEDIA_OK);assert(AImageReader_getWindow(reader,&window)==AMEDIA_OK);AMediaFormat_setInt32(format,"allow-frame-drop",0);}
printf("Input format: %s\n",AMediaFormat_toString(format));
const char *keys[]={"csd-0","csd-1"};for(const char *key:keys){void *p=nullptr;size_t n=0;if(AMediaFormat_getBuffer(format,key,&p,&n)){auto *b=(unsigned char*)p;printf("%s: %zu bytes %02x %02x %02x %02x\n",key,n,b[0],b[1],b[2],b[3]);}}
if(argc>2){assert(AImageReader_newWithUsage(width,height,AIMAGE_FORMAT_PRIVATE,AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE,3,&reader)==AMEDIA_OK);assert(AImageReader_getWindow(reader,&window)==AMEDIA_OK);}
assert(AMediaCodec_configure(codec,format,window,nullptr,0)==AMEDIA_OK);assert(AMediaCodec_start(codec)==AMEDIA_OK);
for(unsigned phase=0;phase<2;phase++){
unsigned goal=fallback?(phase?2:6):(phase?120:600),sent=0,received=0,images=0;bool input_eos=false,output_eos=false;double start=now();
@@ -49,23 +42,13 @@ int main(int argc,char **argv){
size_t capacity=0;auto *data=AMediaCodec_getInputBuffer(codec,id,&capacity);assert(data);
if(sent==goal){assert(AMediaCodec_queueInputBuffer(codec,id,0,0,sent*1000000/60,AMEDIACODEC_BUFFER_FLAG_END_OF_STREAM)==AMEDIA_OK);input_eos=true;}
else {ssize_t size=AMediaExtractor_readSampleData(extractor,data,capacity);assert(size>0);int64_t pts=AMediaExtractor_getSampleTime(extractor);
if(sent<2)printf("Input packet: %zd bytes %02x %02x %02x %02x\n",size,data[0],data[1],data[2],data[3]);
assert(AMediaCodec_queueInputBuffer(codec,id,0,size,pts,AMediaExtractor_getSampleFlags(extractor)&1?AMEDIACODEC_BUFFER_FLAG_KEY_FRAME:0)==AMEDIA_OK);
assert(AMediaExtractor_advance(extractor));sent++;
}
}}
AMediaCodecBufferInfo info={};ssize_t id=AMediaCodec_dequeueOutputBuffer(codec,&info,1000);
if(id>=0){if(received<2 || (info.flags&AMEDIACODEC_BUFFER_FLAG_END_OF_STREAM))printf("Buffer id=%zd size=%d flags=%u pts=%lld\n",id,info.size,info.flags,(long long)info.presentationTimeUs);if(info.size)received++;output_eos=info.flags&AMEDIACODEC_BUFFER_FLAG_END_OF_STREAM;
if(id>=0){if(info.size)received++;output_eos=info.flags&AMEDIACODEC_BUFFER_FLAG_END_OF_STREAM;
assert(AMediaCodec_releaseOutputBuffer(codec,id,window!=nullptr)==AMEDIA_OK);
// Surface delivery is asynchronous. Drain each rendered picture before
// submitting another, so this unpaced fixture does not test frame dropping.
if(reader && info.size){double deadline=now()+1;unsigned before=images;
do{AImage *image=nullptr;if(AImageReader_acquireNextImage(reader,&image)==AMEDIA_OK){
int32_t w=0,h=0;assert(AImage_getWidth(image,&w)==AMEDIA_OK && AImage_getHeight(image,&h)==AMEDIA_OK);
assert(w==width && h==height);images++;AImage_delete(image);
}else usleep(100);}while(images==before && now()<deadline);
assert(images>before);
}
}else if(id==AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED){auto *out=AMediaCodec_getOutputFormat(codec);printf("Output: %s\n",AMediaFormat_toString(out));AMediaFormat_delete(out);fflush(stdout);}
if(reader){AImage *image=nullptr;while(AImageReader_acquireNextImage(reader,&image)==AMEDIA_OK){images++;AImage_delete(image);}}
}
-18
View File
@@ -10,23 +10,5 @@
<Limit name="bitrate" range="1-245000000" />
<Limit name="concurrent-instances" max="1" />
</MediaCodec>
<MediaCodec name="OMX.frameport.avc.decoder" type="video/avc" rank="64">
<Limit name="size" min="128x128" max="8192x8192" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Limit name="block-count" range="1-138240" />
<Limit name="blocks-per-second" range="1-7864320" />
<Limit name="bitrate" range="1-245000000" />
<Limit name="concurrent-instances" max="1" />
</MediaCodec>
<MediaCodec name="OMX.frameport.vp9.decoder" type="video/x-vnd.on2.vp9" rank="64">
<Limit name="size" min="128x128" max="4096x2304" />
<Limit name="alignment" value="2x2" />
<Limit name="block-size" value="16x16" />
<Limit name="block-count" range="1-36864" />
<Limit name="blocks-per-second" range="1-2211840" />
<Limit name="bitrate" range="1-245000000" />
<Limit name="concurrent-instances" max="1" />
</MediaCodec>
</Decoders>
</MediaCodecs>
+23 -152
View File
@@ -1,8 +1,6 @@
// Exercises the actual Android OMX component, including port renegotiation,
// output conversion, timestamps and normal state/buffer lifetimes.
#include <media/hardware/OMXPluginBase.h>
#include <media/hardware/HardwareAPI.h>
#include <media/stagefright/foundation/ColorUtils.h>
#include <media/openmax/OMX_Component.h>
#include <media/openmax/OMX_IndexExt.h>
#include <utils/String8.h>
@@ -12,12 +10,6 @@
#include <cstring>
#include <cassert>
#include <cstdlib>
#include <unistd.h>
#include <android/hardware_buffer.h>
#include <cutils/native_handle.h>
#include <sys/stat.h>
extern "C" const native_handle_t *AHardwareBuffer_getNativeHandle(const AHardwareBuffer*);
namespace android { ANativeWindowBuffer *AHardwareBuffer_to_ANativeWindowBuffer(AHardwareBuffer*); }
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/bsf.h>
@@ -33,18 +25,11 @@ struct Test {
unsigned state=OMX_StateLoaded,port_event=0;
OMX_COMMANDTYPE port_command=OMX_CommandStateSet;
int64_t last_pts=-1;
uint64_t first_hash=0;
uint64_t pictures[120]={};
bool replay=false;
bool native=false;
unsigned width=0,height=0;
AHardwareBuffer *hardware[16]={};
};
static OMX_ERRORTYPE event(OMX_HANDLETYPE,OMX_PTR data,OMX_EVENTTYPE type,OMX_U32 a,OMX_U32 b,OMX_PTR) {
auto &t=*(Test*)data;pthread_mutex_lock(&t.lock);
if(type==OMX_EventError){fprintf(stderr,"OMX error: %x %x\n",a,b);t.error=true;}
// As ACodec: only definition changes reallocate; crop/colour are updates.
if(type==OMX_EventPortSettingsChanged && a==1 && (b==0 || b==OMX_IndexParamPortDefinition))t.resize=true;
if(type==OMX_EventPortSettingsChanged && a==1)t.resize=true;
if(type==OMX_EventCmdComplete) {
if(a==OMX_CommandStateSet)t.state=b;
else {t.port_event=b==OMX_ALL?2:b+1;t.port_command=(OMX_COMMANDTYPE)a;}
@@ -58,42 +43,8 @@ static OMX_ERRORTYPE empty(OMX_HANDLETYPE,OMX_PTR data,OMX_BUFFERHEADERTYPE *hea
static OMX_ERRORTYPE fill(OMX_HANDLETYPE,OMX_PTR data,OMX_BUFFERHEADERTYPE *header) {
auto &t=*(Test*)data;pthread_mutex_lock(&t.lock);assert(t.out_count<64);
if(header->nFilledLen) {
AHardwareBuffer_Planes planes={};AHardwareBuffer *hardware=nullptr;
if(t.native){
auto *meta=reinterpret_cast<android::VideoNativeMetadata*>(header->pBuffer);
for(auto *b:t.hardware)if(b && android::AHardwareBuffer_to_ANativeWindowBuffer(b)==meta->pBuffer)hardware=b;
assert(hardware);
assert(AHardwareBuffer_lockPlanes(hardware,AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN,meta->nFenceFd,nullptr,&planes)==0);
if(meta->nFenceFd>=0)close(meta->nFenceFd);meta->nFenceFd=-1;
}
auto byte=[&](unsigned i)->uint8_t{
if(!t.native)return header->pBuffer[i];
unsigned y=t.width*t.height,plane=i<y?0:i<y+y/4?1:2;
unsigned index=plane?i-y-(plane==2?y/4:0):i,width=plane?t.width/2:t.width;
return ((uint8_t*)planes.planes[plane].data)[(index/width)*planes.planes[plane].rowStride+
(index%width)*planes.planes[plane].pixelStride];
};
if(t.frames<120){
uint64_t hash=1469598103934665603ull;
unsigned pixel_bytes=t.native?t.width*t.height*3/2:header->nFilledLen;
unsigned step=pixel_bytes/1024;if(!step)step=1;
for(unsigned i=0;i<pixel_bytes;i+=step){hash^=byte(i);hash*=1099511628211ull;}
if(t.replay && t.pictures[t.frames]!=hash){
fprintf(stderr,"Picture %u changed after seek/recovery\n",t.frames);t.error=true;
}
t.pictures[t.frames]=hash;
}
if(!t.frames){
uint64_t hash=1469598103934665603ull;
unsigned pixel_bytes=t.native?t.width*t.height*3/2:header->nFilledLen;
for(unsigned i=0;i<pixel_bytes;i++){hash^=byte(i);hash*=1099511628211ull;}
printf("First-picture hash: %016llx\n",(unsigned long long)hash);fflush(stdout);
if(t.first_hash && t.first_hash!=hash){fprintf(stderr,"Seek did not reproduce the first picture\n");t.error=true;}
t.first_hash=hash;
}
if(header->nTimeStamp<t.last_pts){fprintf(stderr,"PTS went backwards\n");t.error=true;}
t.last_pts=header->nTimeStamp;t.frames++;
if(hardware)assert(AHardwareBuffer_unlock(hardware,nullptr)==0);
}
if(header->nFlags&OMX_BUFFERFLAG_EOS)t.eos=true;
t.output_free[t.out_count++]=header;
@@ -114,22 +65,6 @@ static void allocate(Test &t,unsigned port,OMX_BUFFERHEADERTYPE **headers,unsign
count=def.nBufferCountActual;assert(count<=16);
printf("Allocate port %u: %ux%u, %u buffers x %u bytes\n",port,def.format.video.nFrameWidth,def.format.video.nFrameHeight,count,def.nBufferSize);fflush(stdout);
for(unsigned i=0;i<count;i++)assert(t.component->AllocateBuffer(t.component,&headers[i],port,nullptr,def.nBufferSize)==OMX_ErrorNone);
if(port==1 && t.native){
t.width=def.format.video.nFrameWidth;t.height=def.format.video.nFrameHeight;
for(unsigned i=0;i<count;i++){
AHardwareBuffer_Desc d={};d.width=t.width;d.height=t.height;d.layers=1;d.format=AHARDWAREBUFFER_FORMAT_Y8Cb8Cr8_420;
d.usage=AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN|AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN|AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE;
assert(AHardwareBuffer_allocate(&d,&t.hardware[i])==0);
if(!i){auto *handle=AHardwareBuffer_getNativeHandle(t.hardware[i]);
printf("Native buffer handles: %d\n",handle->numFds);
for(int fd=0;fd<handle->numFds;fd++){struct stat st={};assert(fstat(handle->data[fd],&st)==0);
printf(" fd %d: device=%llu inode=%llu bytes=%lld\n",fd,(unsigned long long)st.st_dev,
(unsigned long long)st.st_ino,(long long)st.st_size);}fflush(stdout);}
auto *meta=reinterpret_cast<android::VideoNativeMetadata*>(headers[i]->pBuffer);
meta->eType=android::kMetadataBufferTypeANWBuffer;meta->pBuffer=android::AHardwareBuffer_to_ANativeWindowBuffer(t.hardware[i]);
meta->nFenceFd=-1;
}
}
}
static void queue_output(Test &t) {
for(;;) {
@@ -141,66 +76,21 @@ static void queue_output(Test &t) {
}
}
int main(int argc,char **argv) {
alarm(60);
assert(argc==2 || argc==3);const char *path=getenv("FRAMEPORT_PROBE_CODEC");
void *lib=dlopen(path?path:"/probe/libstagefrighthw.so",RTLD_NOW);
assert(argc==2 || argc==3);void *lib=dlopen("/probe/libstagefrighthw.so",RTLD_NOW);
if(!lib){fprintf(stderr,"%s\n",dlerror());return 1;}
auto factory=(android::OMXPluginBase*(*)())dlsym(lib,"createOMXPlugin");assert(factory);
auto *plugin=factory();Test t;OMX_CALLBACKTYPE callbacks={event,empty,fill};
t.native=argc==3 && (!strcmp(argv[2],"native") || !strcmp(argv[2],"native_cpu") || !strcmp(argv[2],"native_importfail"));
if(t.native && !strcmp(argv[2],"native_cpu")){
auto deny=(void(*)())dlsym(RTLD_DEFAULT,"frameport_probe_deny_gpu");assert(deny);deny();
}
if(t.native && !strcmp(argv[2],"native_importfail")){
auto deny=(void(*)())dlsym(RTLD_DEFAULT,"frameport_probe_deny_gpu_import");assert(deny);deny();
}
if(argc==3 && (!strcmp(argv[2],"software") || !strcmp(argv[2],"software_rgba"))){
auto deny=(void(*)())dlsym(RTLD_DEFAULT,"frameport_probe_deny_device");assert(deny);deny();
}
if(argc==3 && !strcmp(argv[2],"recover")){
auto corrupt=(void(*)())dlsym(RTLD_DEFAULT,"frameport_probe_corrupt_picture");assert(corrupt);corrupt();
}
if(argc==3 && !strcmp(argv[2],"restartfail")){
auto deny=(void(*)())dlsym(RTLD_DEFAULT,"frameport_probe_deny_restart");assert(deny);deny();
}
AVFormatContext *format=nullptr;assert(avformat_open_input(&format,argv[1],nullptr,nullptr)>=0);
unsigned video=0;while(video<format->nb_streams && format->streams[video]->codecpar->codec_type!=AVMEDIA_TYPE_VIDEO)video++;
assert(video<format->nb_streams);auto *stream=format->streams[video];
const char *name=nullptr,*filter=nullptr;
switch(stream->codecpar->codec_id){
case AV_CODEC_ID_HEVC:name="OMX.frameport.hevc.decoder";filter="hevc_mp4toannexb";break;
case AV_CODEC_ID_H264:name="OMX.frameport.avc.decoder";filter="h264_mp4toannexb";break;
case AV_CODEC_ID_VP9:name="OMX.frameport.vp9.decoder";break;
default:assert(false);
}
printf("Testing %s\n",name);fflush(stdout);
assert(plugin->makeComponentInstance(name,&callbacks,&t,&t.component)==OMX_ErrorNone);
if(t.native)for(const char *name:{"OMX.google.android.index.enableAndroidNativeBuffers","OMX.google.android.index.storeANWBufferInMetadata"}){
OMX_INDEXTYPE index;assert(t.component->GetExtensionIndex(t.component,(char*)name,&index)==OMX_ErrorNone);
auto mode=parameter<android::EnableAndroidNativeBuffersParams>(1);mode.enable=OMX_TRUE;
assert(t.component->SetParameter(t.component,index,&mode)==OMX_ErrorNone);
}
// ACodec supplies the container's colour metadata before decoding. Use the
// same contract so full-range fixtures exercise the intended conversion.
auto *par=stream->codecpar;
if(par->color_primaries!=AVCOL_PRI_UNSPECIFIED || par->color_trc!=AVCOL_TRC_UNSPECIFIED ||
par->color_space!=AVCOL_SPC_UNSPECIFIED || par->color_range!=AVCOL_RANGE_UNSPECIFIED) {
OMX_INDEXTYPE index;
char extension[]="OMX.google.android.index.describeColorAspects";
assert(t.component->GetExtensionIndex(t.component,extension,&index)==OMX_ErrorNone);
auto aspects=parameter<android::DescribeColorAspectsParams>(1);
android::ColorUtils::convertIsoColorAspectsToCodecAspects(par->color_primaries,par->color_trc,
par->color_space,par->color_range==AVCOL_RANGE_JPEG,aspects.sAspects);
assert(t.component->SetConfig(t.component,index,&aspects)==OMX_ErrorNone);
}
if(argc==3 && (!strcmp(argv[2],"rgba") || !strcmp(argv[2],"software_rgba"))) {
assert(plugin->makeComponentInstance("OMX.frameport.hevc.decoder",&callbacks,&t,&t.component)==OMX_ErrorNone);
if(argc==3) {
auto mode=parameter<OMX_PARAM_U32TYPE>(1);
assert(t.component->GetParameter(t.component,(OMX_INDEXTYPE)OMX_IndexParamVideoAndroidRequiresSwRenderer,&mode)==OMX_ErrorNone);
assert(mode.nU32==1);
}
AVBSFContext *bsf=nullptr;
if(filter){assert(av_bsf_alloc(av_bsf_get_by_name(filter),&bsf)>=0);
assert(avcodec_parameters_copy(bsf->par_in,stream->codecpar)>=0);bsf->time_base_in=stream->time_base;assert(av_bsf_init(bsf)>=0);}
AVFormatContext *format=nullptr;assert(avformat_open_input(&format,argv[1],nullptr,nullptr)>=0);
unsigned video=0;while(video<format->nb_streams && format->streams[video]->codecpar->codec_type!=AVMEDIA_TYPE_VIDEO)video++;
assert(video<format->nb_streams);auto *stream=format->streams[video];
AVBSFContext *bsf=nullptr;assert(av_bsf_alloc(av_bsf_get_by_name("hevc_mp4toannexb"),&bsf)>=0);
assert(avcodec_parameters_copy(bsf->par_in,stream->codecpar)>=0);bsf->time_base_in=stream->time_base;assert(av_bsf_init(bsf)>=0);
auto input_def=parameter<OMX_PARAM_PORTDEFINITIONTYPE>(0);
assert(t.component->GetParameter(t.component,OMX_IndexParamPortDefinition,&input_def)==OMX_ErrorNone);
input_def.format.video.nFrameWidth=stream->codecpar->width;input_def.format.video.nFrameHeight=stream->codecpar->height;
@@ -212,11 +102,9 @@ int main(int argc,char **argv) {
for(unsigned i=0;i<output_count;i++)t.output_free[t.out_count++]=outputs[i];
assert(t.component->SendCommand(t.component,OMX_CommandStateSet,OMX_StateExecuting,nullptr)==OMX_ErrorNone);wait_state(t,OMX_StateExecuting);
AVPacket *packet=av_packet_alloc();
// midseek: seek mid-playback (no EOS first), as players usually do.
bool midseek=argc==3 && !strcmp(argv[2],"midseek");
for(unsigned phase=0;phase<2;phase++) {
bool sent_config=false,sent_eos=false,resent_config=false;unsigned submitted=0;
unsigned goal=phase?120:midseek?300:600;
bool sent_config=false,sent_eos=false;unsigned submitted=0;
unsigned goal=phase?120:600;
int64_t start=av_gettime_relative();
for(;;) {
queue_output(t);
@@ -226,61 +114,47 @@ int main(int argc,char **argv) {
bool resized=t.resize;t.resize=false;bool finished=t.eos;
pthread_mutex_unlock(&t.lock);
if(finished)break;
if(midseek && !phase && submitted>=goal) {
pthread_mutex_lock(&t.lock);bool enough=t.frames>=200;pthread_mutex_unlock(&t.lock);
if(enough)break;
usleep(1000);
}
if(resized) {
assert(t.component->SendCommand(t.component,OMX_CommandPortDisable,1,nullptr)==OMX_ErrorNone);
pthread_mutex_lock(&t.lock);while(t.out_count<output_count && !t.error)pthread_cond_wait(&t.changed,&t.lock);assert(!t.error);t.out_count=0;pthread_mutex_unlock(&t.lock);
for(unsigned i=0;i<output_count;i++)assert(t.component->FreeBuffer(t.component,1,outputs[i])==OMX_ErrorNone);
if(t.native)for(auto &b:t.hardware)if(b){AHardwareBuffer_release(b);b=nullptr;}
wait_port(t,OMX_CommandPortDisable);
assert(t.component->SendCommand(t.component,OMX_CommandPortEnable,1,nullptr)==OMX_ErrorNone);
allocate(t,1,outputs,output_count);wait_port(t,OMX_CommandPortEnable);
pthread_mutex_lock(&t.lock);for(unsigned i=0;i<output_count;i++)t.output_free[t.out_count++]=outputs[i];pthread_mutex_unlock(&t.lock);
continue;
}
if(sent_eos || (midseek && !phase && submitted>=goal))continue;
if(sent_eos)continue;
pthread_mutex_lock(&t.lock);
auto *header=t.in_count?t.input_free[--t.in_count]:nullptr;pthread_mutex_unlock(&t.lock);
if(!header)continue;
header->nOffset=0;header->nFilledLen=0;header->nTimeStamp=0;header->nFlags=0;
// midconfig: parameter sets sent again mid-stream without a flush.
bool midconfig=argc==3 && !strcmp(argv[2],"midconfig") && !phase && submitted==300 && !resent_config;
if(!sent_config || midconfig) {
const auto *params=bsf?bsf->par_out:stream->codecpar;
if(midconfig){resent_config=true;puts("Resending codec configuration mid-stream.");fflush(stdout);}
assert((unsigned)params->extradata_size<=header->nAllocLen);
if(params->extradata_size)memcpy(header->pBuffer,params->extradata,params->extradata_size);
header->nFilledLen=params->extradata_size;header->nFlags=OMX_BUFFERFLAG_CODECCONFIG;sent_config=true;
if(!sent_config) {
assert((unsigned)bsf->par_out->extradata_size<=header->nAllocLen);
memcpy(header->pBuffer,bsf->par_out->extradata,bsf->par_out->extradata_size);
header->nFilledLen=bsf->par_out->extradata_size;header->nFlags=OMX_BUFFERFLAG_CODECCONFIG;sent_config=true;
} else if(submitted>=goal) {
header->nFlags=OMX_BUFFERFLAG_EOS;header->nTimeStamp=goal*1000000/60;sent_eos=true;
} else {
int result;
do{result=av_read_frame(format,packet);assert(result>=0);if(packet->stream_index!=(int)video)av_packet_unref(packet);}while(packet->stream_index!=(int)video);
if(bsf){assert(av_bsf_send_packet(bsf,packet)>=0);assert(av_bsf_receive_packet(bsf,packet)>=0);}
assert(av_bsf_send_packet(bsf,packet)>=0);assert(av_bsf_receive_packet(bsf,packet)>=0);
av_packet_rescale_ts(packet,stream->time_base,AVRational{1,1000000});
assert((unsigned)packet->size<=header->nAllocLen);memcpy(header->pBuffer,packet->data,packet->size);header->nFilledLen=packet->size;
header->nTimeStamp=packet->pts;header->nFlags=OMX_BUFFERFLAG_ENDOFFRAME;
// Recovery runs model ACodec decoder input, which omits SYNCFRAME.
if((packet->flags&AV_PKT_FLAG_KEY) && !(argc==3 && !strcmp(argv[2],"recover")))
header->nFlags|=OMX_BUFFERFLAG_SYNCFRAME;
if(submitted<2){printf("Input phase %u: pts=%lld key=%d bytes=%d\n",phase,(long long)packet->pts,
!!(packet->flags&AV_PKT_FLAG_KEY),packet->size);fflush(stdout);}
if(packet->flags&AV_PKT_FLAG_KEY)header->nFlags|=OMX_BUFFERFLAG_SYNCFRAME;
av_packet_unref(packet);submitted++;
}
assert(t.component->EmptyThisBuffer(t.component,header)==OMX_ErrorNone);
}
double seconds=(av_gettime_relative()-start)/1000000.0;
printf("OMX decode + full-size delivery: %u frames in %.3fs = %.1ffps\n",t.frames,seconds,t.frames/seconds);fflush(stdout);
assert(midseek && !phase?t.frames>=200:t.frames==goal);
printf("OMX hardware + full-size planar delivery: %u frames in %.3fs = %.1ffps\n",t.frames,seconds,t.frames/seconds);fflush(stdout);
assert(t.frames==goal);
if(!phase) {
assert(t.component->SendCommand(t.component,OMX_CommandFlush,OMX_ALL,nullptr)==OMX_ErrorNone);
wait_port(t,OMX_CommandFlush);
assert(av_seek_frame(format,video,0,AVSEEK_FLAG_BACKWARD)>=0);if(bsf)av_bsf_flush(bsf);
pthread_mutex_lock(&t.lock);t.eos=false;t.frames=0;t.last_pts=-1;t.replay=true;pthread_mutex_unlock(&t.lock);
assert(av_seek_frame(format,video,0,AVSEEK_FLAG_BACKWARD)>=0);av_bsf_flush(bsf);
pthread_mutex_lock(&t.lock);t.eos=false;t.frames=0;t.last_pts=-1;pthread_mutex_unlock(&t.lock);
puts("Replaying after a complete OMX flush and seek to the beginning.");
}
}
@@ -288,10 +162,7 @@ int main(int argc,char **argv) {
assert(t.component->SendCommand(t.component,OMX_CommandStateSet,OMX_StateLoaded,nullptr)==OMX_ErrorNone);
for(unsigned i=0;i<input_count;i++)assert(t.component->FreeBuffer(t.component,0,inputs[i])==OMX_ErrorNone);
for(unsigned i=0;i<output_count;i++)assert(t.component->FreeBuffer(t.component,1,outputs[i])==OMX_ErrorNone);
if(t.native)for(auto &b:t.hardware)if(b){AHardwareBuffer_release(b);b=nullptr;}
wait_state(t,OMX_StateLoaded);assert(plugin->destroyComponentInstance(t.component)==OMX_ErrorNone);
delete plugin;dlclose(lib);av_packet_free(&packet);av_bsf_free(&bsf);avformat_close_input(&format);
if(t.native){auto count=(unsigned(*)())dlsym(RTLD_DEFAULT,"frameport_probe_gpu_submissions");assert(count);
printf("GPU submissions: %u\n",count());assert(!strcmp(argv[2],"native")?count()>0:count()==0);}
puts("PASS: OMX decode, ordered timestamps, EOS, seek and shutdown.");
puts("PASS: hardware OMX decode, planar frames, ordered timestamps, EOS and shutdown.");
}
+18 -63
View File
@@ -1,9 +1,8 @@
#!/usr/bin/python3
"""Add shared codecs to Lepton containers without editing their shared rootfs."""
"""Add a per-game codec to Lepton's container, without editing its shared rootfs."""
import hashlib
import json
import os
import re
import shutil
import sys
from pathlib import Path
@@ -19,30 +18,11 @@ def mounts(directory, config, args):
name = args[i + 1]
elif arg.startswith("--name="):
name = arg.partition("=")[2]
if config.get("scope") == "shared":
# The common launcher supplies these values. Do not intercept arbitrary
# Podman containers or depend on a game's package/modified APK.
appid = os.environ.get("SteamAppId", "")
if not re.fullmatch(r"[0-9]+", appid) or name != f"lepton-steamlaunch-{appid}":
return []
app = Path(os.environ.get("STEAM_COMPAT_INSTALL_PATH", ""))
if app.name != "lepton-app" or not app.is_dir():
return []
root_arg = None
for i, arg in enumerate(args):
if arg == "--rootfs" and i + 1 < len(args):
root_arg = args[i + 1]
elif arg.startswith("--rootfs="):
root_arg = arg.partition("=")[2]
if not root_arg or not root_arg.endswith(":O"):
return []
root = Path(root_arg[:-2]).resolve()
else: # old per-game deployments remain compatible during migration
if name != f"lepton-steamlaunch-{config['appid']}":
return []
root = Path(config["lepton"]).resolve().parent / "images" / "rootfs"
if name != f"lepton-steamlaunch-{config['appid']}":
return []
root = Path(config["lepton"]).resolve().parent / "images" / "rootfs"
expected_root = str(root) + ":O"
if not any(arg in (expected_root, "--rootfs=" + expected_root) for arg in args):
if not any(arg == expected_root for arg in args):
return []
device = Path("/dev/video-dec0")
runtime = root / "vendor/lib64/libstagefright_softomx.so"
@@ -56,53 +36,30 @@ def mounts(directory, config, args):
if target == "/vendor/lib64/libstagefright_softomx.so" and fields.get("source"):
runtime = Path(fields["source"])
if upstream_plugin:
print("FramePort video: using the runtime's hardware codec plugin", file=sys.stderr)
print("FramePort HEVC: using the runtime's hardware codec plugin", file=sys.stderr)
return []
if not device.exists() or hashlib.sha256(runtime.read_bytes()).hexdigest() != config["runtime_sha256"]:
print("FramePort video: device or runtime ABI differs; retaining the stock codecs", file=sys.stderr)
print("FramePort HEVC: device or runtime ABI differs; retaining the stock codecs", file=sys.stderr)
return []
xml = ET.parse(root / "vendor/etc/media_codecs.xml")
if not any(node.get("href") == "media_codecs_frameport.xml" for node in xml.getroot().findall("Include")):
ET.SubElement(xml.getroot(), "Include", href="media_codecs_frameport.xml")
# Immutable plugin versions can serve simultaneous app launches and
# different Lepton installations. Never share a temporary XML filename.
# The version directory stays as the agent verified it: the merged list goes to the user's runtime dir.
root_key = hashlib.sha256(str(root).encode()).hexdigest()[:16]
merged = merged_dir() / f"media_codecs.{root_key}.xml"
temporary = merged.with_suffix(f".{os.getpid()}.tmp")
ET.SubElement(xml.getroot(), "Include", href="media_codecs_frameport.xml")
merged = directory / "media_codecs.xml"
temporary = merged.with_suffix(".tmp")
xml.write(temporary, encoding="utf-8", xml_declaration=True)
temporary.replace(merged)
# Lepton supplies its own /dev tmpfs. A Podman --device node disappears
# beneath it; a bind mount matches Lepton's existing GPU/sound device setup.
result = ["--mount", f"type=bind,source={device},destination=/dev/video-dec0,rw"]
for path, target in (
(directory / "libstagefrighthw.so", "/vendor/lib64/libstagefrighthw.so"),
(merged, "/vendor/etc/media_codecs.xml"),
(directory / "media_codecs_frameport.xml", "/vendor/etc/media_codecs_frameport.xml"),
for source, target in (
("libstagefrighthw.so", "/vendor/lib64/libstagefrighthw.so"),
("media_codecs.xml", "/vendor/etc/media_codecs.xml"),
("media_codecs_frameport.xml", "/vendor/etc/media_codecs_frameport.xml"),
):
if not path.is_file():
raise FileNotFoundError(f"missing video codec mount: {path}")
result += ["--mount", f"type=bind,source={path},destination={target},ro"]
print("FramePort video: loading the Iris hardware codec plugin for this container", file=sys.stderr)
result += ["--mount", f"type=bind,source={directory / source},destination={target},ro"]
print("FramePort HEVC: loading the Iris hardware codec plugin for this container", file=sys.stderr)
return result
def merged_dir():
runtime = os.environ.get("XDG_RUNTIME_DIR", "")
base = Path(runtime) if runtime and Path(runtime).is_dir() else Path.home() / ".cache"
path = base / "frameport-video"
path.mkdir(mode=0o700, parents=True, exist_ok=True)
return path
def switched_off(directory):
"""FRAMEPORT_NO_HW_VIDEO=1 (e.g. in a game's Steam launch options) or the Frame-wide switch (FramePort's
setting; the agent writes video-codec/disabled) leave every container with Android's stock codecs."""
if os.environ.get("FRAMEPORT_NO_HW_VIDEO", "") not in ("", "0"):
return True
return directory.parent.name == "versions" and (directory.parent.parent / "disabled").exists()
def real_podman(directory):
"""Find Podman independently of deployment.json, without recursing into this wrapper."""
own_bin = (directory / "bin").resolve()
@@ -127,18 +84,16 @@ def main():
directory = Path(__file__).resolve().parent.parent
args = sys.argv[1:]
fallback = real_podman(directory)
if switched_off(directory):
os.execv(fallback, [fallback, *args])
try:
config = json.loads((directory / "deployment.json").read_text())
podman = Path(config.get("podman", fallback)).resolve()
podman = Path(config["podman"]).resolve()
if podman.parent == (directory / "bin").resolve() or podman == Path(__file__).resolve():
raise ValueError("configured Podman points to the codec wrapper")
extra = mounts(directory, config, args)
launch_args = [args[0], *extra, *args[1:]] if extra else args
os.execv(str(podman), [str(podman), *launch_args])
except Exception as exc: # a codec/configuration failure must never prevent the stock container from starting
print(f"FramePort video: retaining stock codecs: {exc}", file=sys.stderr)
print(f"FramePort HEVC: retaining stock codecs: {exc}", file=sys.stderr)
os.execv(fallback, [fallback, *args])
-21
View File
@@ -1,21 +0,0 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
/* Explicit interface; caller holds the AVFrame until GPU work
* completes, so the driver's capture buffer cannot be requeued meanwhile. */
int frameport_v4l2_export_frame(const AVFrame *frame);
int frameport_v4l2_export_frame(const AVFrame *frame)
{
V4L2Buffer *buf;
struct v4l2_exportbuffer exp = {0};
if (!frame || frame->format != AV_PIX_FMT_NV12 || !frame->buf[0])
return AVERROR(EINVAL);
buf = av_buffer_get_opaque(frame->buf[0]);
if (!buf || buf->num_planes != 1 || buf->context->type != V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE ||
buf->plane_info[0].mm_addr != frame->buf[0]->data || frame->data[0] != frame->buf[0]->data)
return AVERROR(EINVAL);
exp.type = buf->buf.type;
exp.index = buf->buf.index;
exp.flags = O_CLOEXEC | O_RDWR;
if (ioctl(buf_to_m2mctx(buf)->fd, VIDIOC_EXPBUF, &exp) < 0)
return AVERROR(errno);
return exp.fd;
}
-24
View File
@@ -1,24 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
// Arm64 table ABI of the pinned Android 11 libyuv (row.h / row_neon64.cc):
// https://android.googlesource.com/platform/external/libyuv/+/android-11.0.0_r48/files/include/libyuv/row.h
#ifndef FRAMEPORT_YUV_COLOR_H
#define FRAMEPORT_YUV_COLOR_H
#include <stdint.h>
struct alignas(16) YuvConstants {
uint16_t rb[8],rb2[8],g[8],g2[8];
int16_t bias[8];
int32_t y[4];
};
static_assert(sizeof(YuvConstants)==96,"pinned Arm64 libyuv table ABI");
// Full-range BT.709: R=Y+1.5748(V-128), B=Y+1.8556(U-128),
// G=Y-0.187324(U-128)-0.468124(V-128). Libyuv uses six fractional
// bits and a 32 rounding bias. Mirrored coefficients + swapped U/V
// produce RGBA bytes with its ARGB row functions, as in the NV12 path.
static constexpr YuvConstants frameport_full709_rgba={
{101,119,101,119,101,119,101,119},
{101,119,101,119,101,119,101,119},
{30,12,30,12,30,12,30,12},
{30,12,30,12,30,12,30,12},
{-12896,5408,-15200,0,0,0,0,0},
{0x0101*16320,0,0,0}};
#endif
+1 -8
View File
@@ -27,8 +27,7 @@ IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
# 5: unreal_quest_gates (Quest-only branches in ILMxLAB's Unreal: frame.unreal_quest_precompile/_keymap) (2026-10)
# 6: gl_multiview_libs (own-engine libraries with OVR_multiview GLSL: frame.gl_multiview_fbo) (2026-10)
# 7: unreal_thumb_touch (UE4 OculusInput's ThumbUp from near-touch, matched exactly: frame.unreal_thumb_touch)
# 8: media_codec (plays video through Android's decoders: MediaCodec/ExoPlayer/Media3 or VLC: frame.hw_video_decode)
ANALYSIS_VERSION = 8
ANALYSIS_VERSION = 7
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
@@ -150,9 +149,6 @@ def vr_kind(libs: set[str], manifest_strings: list[str]) -> str:
return "none"
# dex type names of Android's video decoding APIs (frame.hw_video_decode)
MEDIA_CODEC_MARKERS = (b"Landroid/media/MediaCodec;", b"Landroidx/media3/exoplayer", b"Lcom/google/android/exoplayer2/")
# OpenXR composition-layer extensions the Frame runtime lacks (see docs/FRAME_RUNTIME.md).
LAYER_EXTENSIONS = ("XR_KHR_composition_layer_cylinder", "XR_KHR_composition_layer_equirect",
"XR_KHR_composition_layer_equirect2", "XR_KHR_composition_layer_cube")
@@ -171,8 +167,6 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
sdl_java = any(b"Lorg/libsdl/app/SDLClipboardHandler;" in d for d in dexes)
# Vivox voice chat calling Android 12 audio-routing methods: crashes in Lepton (Android 11)
vivox_api31 = any(b"Lcom/vivox/sdk/AudioChangeListener;" in d and b"CommunicationDevice" in d 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
del dexes
manifest = z.read("AndroidManifest.xml")
lib_bytes = {}
@@ -293,7 +287,6 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
"ovr_runtime_msaa": bool(il2cpp_meta) and b"\0useRecommendedMSAALevel\0" in il2cpp_meta,
"sdl_java": sdl_java,
"vivox_api31": vivox_api31,
"media_codec": media_codec,
"oculus_xr_plugin": bool(il2cpp_meta) and b"\0m_StereoRenderingModeAndroid\0" in il2cpp_meta,
# Unity's built-in Oculus support checks for Meta's system apps before VR (frame.unity_oculus_check)
"unity_oculus_check": b"\0com.oculus.systemactivities\0" in lib_bytes.get("libunity.so", b""),
+9 -2
View File
@@ -1,6 +1,7 @@
"""Build stage: OVRPort → Frame fixes (apk-stage patches) → sign → static validation."""
from __future__ import annotations
import dataclasses
import hashlib
import shutil
from pathlib import Path
@@ -61,8 +62,11 @@ def apply_frame_fixes(apk_in: Path, apk_out_unsigned: Path, analysis: Analysis,
def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path, reporter: Reporter,
keep_work: bool = False) -> BuildResult:
keep_work: bool = False, pc: bool = False) -> BuildResult:
"""pc: the build for this PC (AXRB): only OVRPort's patches and the on_pc APK fixes (base.pc_selection)."""
pkg = analysis.package
if pc:
recipe = dataclasses.replace(recipe, patches=base.pc_selection(recipe.patches))
work = work_dir() / pkg
shutil.rmtree(work, ignore_errors=True)
work.mkdir(parents=True)
@@ -73,6 +77,9 @@ def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path,
# Frame patches are applied on top. Its alternate build is the copy saved next to it, or (no copy) one more
# OVRPort run for the alternate patches; the stand-ins patch relinks what that drops.
converted = recipe.overport and analysis.is_overport_output
if pc and converted:
reporter.check("Original APK", None, "this copy was already converted for the Steam Frame and keeps its Frame "
"fixes; add the game's original APK for a clean build for this PC")
variants = [("primary", False)] + ([("alt", True)] if recipe.alt_patches and recipe.overport else [])
alt_copy = Path(source.apk).with_name(f"{pkg}.alt-noforcequit.apk")
results = {}
@@ -100,7 +107,7 @@ def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path,
sign.sign(unsigned, final, pkg)
unsigned.unlink(missing_ok=True)
reporter.stage(f"validate ({variant})")
checks += check_apk(final, pkg, expect_adapter=recipe.overport)
checks += check_apk(final, pkg, expect_adapter=recipe.overport and not pc)
for c in checks:
reporter.check(c["name"], c["ok"], c["detail"])
results[variant] = final
+17 -4
View File
@@ -421,13 +421,14 @@ def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Op
password: Optional[str] = typer.Option(None, help="the Frame's password (first connection only)"),
apk_only: bool = typer.Option(False, help="reuse game data already on the Frame"),
no_library: bool = typer.Option(False, help="don't add to the Steam library now"),
to: str = typer.Option("frame", help="frame, or pc (PC VR games only: install on this PC)"),
to: str = typer.Option("frame", help="frame, or pc (install on this PC: PC VR games through Revive, "
"Quest games through AXRB, experimental)"),
apk: Optional[Path] = typer.Option(None, help="install this APK instead of the last build (one game; e.g. "
"a test build signed with the game's key)"),
dest: Optional[str] = typer.Option(None, help="drive for new installs: 'internal' or a drive's path from "
"'frameport frame drives' (default: the app's setting); "
"installed games stay where they are")):
"""Install games on the Frame (or PC VR games on this PC) and add them to the Steam library."""
"""Install games on the Frame (or on this PC) and add them to the Steam library."""
target = _target(frame, password, to)
if dest is not None and hasattr(target, "dest"):
target.dest = "" if dest == "internal" else dest
@@ -446,7 +447,7 @@ def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Op
def test(package: Optional[str] = typer.Argument(None), all_: bool = typer.Option(False, "--all", help=ALL_HELP),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
seconds: int = typer.Option(45, help="how long the game runs before it's stopped"),
to: str = typer.Option("frame", help="frame, or pc (PC VR games installed on this PC)")):
to: str = typer.Option("frame", help="frame, or pc (games installed on this PC)")):
"""Headless launch on the Frame (or this PC) + log triage."""
target = _target(frame, to=to)
installed = {g["package"] for g in target.installed()}
@@ -692,10 +693,22 @@ def frame_proton(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
# ------------------------------------------------------------------------------------------ pc (Revive)
@pc_app.command("info")
def pc_info():
"""Steam, SteamVR and Revive on this PC (JSON)."""
"""Steam, SteamVR, Revive and AXRB on this PC (JSON)."""
typer.echo(json.dumps(_target(None, to="pc").describe(), indent=1, default=str))
@pc_app.command("install-axrb")
def pc_install_axrb():
"""Install AXRB (Android XR Bridge, for Quest games on this PC) and set up its Android runtime. Normally done by
the first `frameport install <quest game> --to pc`. Downloads AXRB's official installer plus Google's Android
emulator pieces (about 2.5 GB)."""
from .targets import pc_android
from .tools import axrb
pc_android.ensure_axrb(printing_reporter(False))
typer.echo(json.dumps(axrb.status(), indent=1, default=str))
@pc_app.command("install-revive")
def pc_install_revive():
"""Download Revive's latest release and unpack it into FramePort's tools (no installer, no admin rights)."""
-16
View File
@@ -280,25 +280,9 @@ def _migrate(data: dict) -> bool:
"pcvr.steamvr_tuning", "Adjusts the refresh rate / motion smoothing when the game can't keep up.")
done.append("rift_steamvr_tuning")
changed = True
if "batman_video_patches" not in done:
# Batman's cutscene playback (hardware HEVC + its native video renderer) was tied to its package name inside
# frame.adapter; it is now frame.hw_video_decode + adapter.surface_native, so user-owned recipes keep it
for pkg, g in (data.get("games") or {}).items():
r = g.get("recipe")
if pkg == BATMAN and isinstance(r, dict):
r.setdefault("patches", {}).setdefault("frame.hw_video_decode", {})
r["patches"].setdefault("adapter.surface_native", {"value": 1})
r.setdefault("reasons", {}).setdefault(
"frame.hw_video_decode", "Its cutscenes are 8K HEVC video: decode them on the Frame's hardware.")
r["reasons"].setdefault("adapter.surface_native", "Shows its cutscenes as stereo panoramas.")
done.append("batman_video_patches")
changed = True
return changed
BATMAN = "com.camouflaj.manta"
def _rift_launch_mode(g: dict) -> bool:
"""Re-analyze a Rift game (keeping the chosen exe) and re-derive its recipe. False if its folder isn't there."""
from ..analysis import rift
+1 -79
View File
@@ -6,7 +6,6 @@ password if given.
"""
from __future__ import annotations
import base64
import contextlib
import hashlib
import json
@@ -22,23 +21,13 @@ from pathlib import Path
import paramiko
from ..core.paths import agent_file, artifacts_dir, ssh_dir, user_data_dir, write_atomic
from ..core.paths import agent_file, ssh_dir, user_data_dir, write_atomic
log = logging.getLogger(__name__)
REMOTE_AGENT_DIR = ".local/share/frameport/agent"
def video_decoding_enabled() -> bool:
"""FramePort's switch for hardware video decoding on the Frame (Settings; default on)."""
try:
from ..core import library
return bool(library.peek_setting("video.hw_decode", True))
except Exception: # noqa: BLE001 - no library yet
return True
def agent_version_of(text: str) -> int:
"""AGENT_VERSION from an agent's source (0 if missing)."""
m = re.search(r"^AGENT_VERSION\s*=\s*(\d+)", text, re.M)
@@ -360,8 +349,6 @@ class Frame:
_pool: SftpPool | None = None # SFTP channels shared by all threads (see sftp)
_lock: threading.Lock = field(default_factory=threading.Lock)
_agent_digest: str = "" # the agent version known to be on the Frame (checked once per connection)
_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 = ""
# ------------------------------------------------------------------ connect
@@ -489,7 +476,6 @@ class Frame:
self.client.close()
self.client = None
self._agent_digest = ""
self._video_codec_digest = ""
def alive(self) -> bool:
"""The SSH connection itself still works (a failed agent command doesn't mean the Frame is gone)."""
@@ -531,7 +517,6 @@ class Frame:
remote_dir = posixpath.join(self.home, REMOTE_AGENT_DIR)
remote = posixpath.join(remote_dir, "frameport_agent.py")
if self._agent_digest == digest:
self._ensure_video_codec_if_supported(text)
return remote
code, out, _ = self.run(f"sha256sum {sh_quote(remote)} 2>/dev/null | cut -c1-16; "
f"grep -m1 '^AGENT_VERSION' {sh_quote(remote)} 2>/dev/null")
@@ -542,7 +527,6 @@ class Frame:
# stay compatible with older apps; replacing it broke the newer one's launchers (2026-10-04: an older
# app put agent 43 back over 45 every few seconds)
self._agent_digest = digest
self._ensure_video_codec_if_supported(text)
return remote
if lines[0].strip() != digest:
self.run(f"mkdir -p {sh_quote(remote_dir)}")
@@ -553,70 +537,8 @@ class Frame:
if code:
raise AgentFailed(f"couldn't install the FramePort agent on the Frame: {err.strip()[-300:]}")
self._agent_digest = digest
self._ensure_video_codec_if_supported(text)
return remote
def _ensure_video_codec_if_supported(self, agent_source: bytes) -> None:
if agent_version_of(agent_source.decode("utf-8", "replace")) < 71:
return
try:
self.ensure_video_codec()
except Exception as exc:
# Optional acceleration must never block pairing, installs or play.
log.warning("Hardware video decoder unavailable; retaining stock codecs: %s", exc)
def ensure_video_codec(self, enabled: bool | None = None) -> None:
"""The shared hardware video codec (games with frame.hw_video_decode use it): installed once per Frame, and
FramePort's on/off setting (library setting video.hw_decode) applied. Checked once per connection: a failed
install isn't retried at every agent call (it would upload ~6 MB each time)."""
import io
import zipfile
if enabled is None:
if self._video_codec_digest:
return # handled on this connection (a changed setting comes through apply_video_decoding)
enabled = video_decoding_enabled()
with self._video_codec_lock:
directory = artifacts_dir() / "hevc"
raw = (directory / "manifest.json").read_bytes()
digest = hashlib.sha256(raw).hexdigest()
key = f"{digest}:{'on' if enabled else 'off'}"
if self._video_codec_digest == key:
return
self._video_codec_digest = key # also after a failure: once per connection
manifest = json.loads(raw)
status = self.agent("video_codec_status", ensure=False)
if bool(status.get("disabled")) == enabled:
status = self.agent("video_codec_switch", ensure=False, enabled=enabled)
ours, theirs = manifest.get("revision", 1), status.get("revision", 0)
if not enabled or status.get("digest") == digest:
return
if status.get("digest") and theirs >= ours:
# the same revision built elsewhere (another PC's FramePort) or a newer one: keep the Frame's, so
# two PCs don't replace each other's codec at every connection
log.info("Keeping the Frame's video codec (revision %s, %s; this app has revision %s, %s)",
theirs, status["digest"][:12], ours, digest[:12])
return
bundle = io.BytesIO()
with zipfile.ZipFile(bundle, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("manifest.json", raw)
for name, expected in manifest["files"].items():
path = directory / (name + ".txt" if name == "podman.py" else name)
data = path.read_bytes()
if hashlib.sha256(data).hexdigest() != expected:
raise AgentFailed(f"video codec asset checksum mismatch: {name}")
archive.writestr(name, data)
result = self.agent("install_video_codec", ensure=False, digest=digest,
bundle=base64.b64encode(bundle.getvalue()).decode("ascii"))
if result.get("kept"):
log.info("The Frame kept its video codec (revision %s)", result.get("revision"))
def apply_video_decoding(self) -> None:
"""Apply a changed "Hardware video decoding" setting now (Settings saves it first); errors are logged."""
with self._video_codec_lock:
self._video_codec_digest = ""
self.ensure_agent()
def agent(self, command: str, timeout: float | None = 600, ensure: bool = True, **args):
"""Run an agent command. ensure=False uses the agent already on the Frame (never re-uploads it)."""
remote = self.ensure_agent() if ensure else posixpath.join(self.home, REMOTE_AGENT_DIR, "frameport_agent.py")
+8 -9
View File
@@ -123,7 +123,6 @@
"Already patched: don't change the game's files": "",
"Already patched: install as is (skip patching)": "",
"Already set up: on your network": "",
"Also delete its saves and everything it stored on the Frame (mods, downloaded content)": "",
"Also delete this game's files on this PC ({size})": "",
"Also in your library: {value} version": "",
"Also remove FramePort's games and files from the Frame": "",
@@ -162,6 +161,7 @@
"At most this many redraws per second of a 360° layer (0 = no limit). Video players need at least the video's frame rate.": "",
"Automatic ({auto:.0%})": "",
"Automatic SteamVR performance settings (this PC)": "",
"AXRB {value} · Android emulator": "",
"Back to library": "",
"Back up the signing keys ({len}) to {shown_dir} first": "",
"Banner": "",
@@ -282,7 +282,6 @@
"Declare Meta-only permissions": "",
"Declares com.oculus.permission.* / horizonos.permission.* that the app uses, so Android grants them at install (e.g. the 'use spatial data' permission of mixed-reality games like Demeter).": "",
"Declares Meta's permissions some mixed-reality games ask for, so they don't stop.": "",
"Decodes the game's videos on the Frame's hardware video decoder instead of Android's software decoders, which stutter or fall behind with 4K/8K video (e.g. video players, Batman: Arkham Shadow's cutscenes). Adds FramePort's codec plugin (OMX.frameport.avc/hevc/vp9.decoder) to this game's container only; the APK isn't changed. When the hardware is busy (e.g. Steam's own hardware video decoding is on) the video is decoded in software as before.": "",
"Delete": "",
"Delete {names}?": "",
"Delete {n} items…": "",
@@ -377,6 +376,7 @@
"Executable": "",
"experimental": "",
"Experimental Vulkan mutable-format workaround.": "",
"Experimental: runs the game on this PC in AXRB (Android XR Bridge), an Android emulator that shows Android VR games through SteamVR. The first install downloads AXRB and its Android (about 2.5 GB) and needs Windows Hypervisor Platform, an NVIDIA or AMD graphics card and 12 GB of memory. Many games don't work in AXRB yet.": "",
"Experimental: shows HUD, menu and other flat panels that stay black because the Frame's graphics driver skips them (e.g. Doom3Quest's HUD and PDA).": "",
"Extra diagnostics (log)": "",
"Extra launch environment (Frame)": "",
@@ -460,6 +460,7 @@
"FramePort found more than one arm64 program in this app. Pick the one that starts it (helpers such as crash reporters or updaters are the wrong choice).": "",
"FramePort found more than one program that could start this game. Pick the one you'd double-click to play it. Oculus builds usually work better with Revive than Steam builds.": "",
"FramePort has Steam on the Frame download Proton and the runtime it needs (about 1 GiB). Steam restarts once, which closes a running game.": "",
"FramePort installs AXRB from its official release (per user, no administrator rights) and Google's Android emulator files. AXRB is a separate program with its own licence; you can remove it later from Windows' app list.": "",
"FramePort installs games on the Frame over your network": "",
"FramePort installs games on the Frame over your network.": "",
"FramePort looked at your last session of {title} and has something to try.": "",
@@ -510,7 +511,6 @@
"Game: the running FramePort game's processes (its Android container or Proton) plus the busiest others. Steam & SteamVR: Steam, SteamVR and the desktop. All: every program of your user on the Frame. A lock marks programs whose end would close Steam, SteamVR or the desktop.": "",
"Games see this folder as {value}": "",
"Games that ask the headset for its controller models (Meta's runtime controller models, XR_FB_render_model) get the Steam Frame controllers instead of Quest Touch controllers. The models come from the Frame's own SteamVR and are converted on the Frame at install time. Games that ship their own controller meshes aren't affected. Turning it on needs a rebuild (it adds a small library in front of OVRPort's loader).": "",
"Games with the patch \"Hardware video decoding\" (e.g. video players, Batman: Arkham Shadow) decode their videos on the Frame's video hardware instead of Android's slower software decoders. Switch it off if videos misbehave in such a game; it then applies to every game the next time it starts. One game only: put FRAMEPORT_NO_HW_VIDEO=1 %command% in its Steam launch options.": "",
"Generate OVRPort config": "",
"Generates no-op stubs for ovr_* functions the game imports but OVRPort's platform loader lacks (e.g. Espire 1/2, Wallace & Gromit, The Climb 2). Symptom: UnsatisfiedLinkError / 'cannot locate symbol ovr_...'. Online/store features stay unavailable.": "",
"Get FramePort ready": "",
@@ -531,8 +531,6 @@
"Graphics": "",
"Group": "",
"hardware encoder": "",
"Hardware video decoding (H.264, HEVC, VP9)": "",
"Hardware video decoding on the Frame (for games set up for it: videos play on the Frame's video hardware)": "",
"Hide Android's navigation bar": "",
"Hide details": "",
"Hide its processes": "",
@@ -568,6 +566,7 @@
"In the menu and on the desktop": "",
"In your Steam library": "",
"In your Steam library · launch settings changed — update it": "",
"In your Steam library · patches changed — update it": "",
"Install": "",
"Install anyway": "",
"Install dev build": "",
@@ -744,7 +743,6 @@
"Must be at least the video's own frame rate.": "",
"Mutable swapchain fix": "",
"Name": "",
"Native video surfaces (Batman renderer)": "",
"Needed by another patch": "",
"Needs the Oculus Platform (Meta Horizon app) for its license check, which the Frame doesn't have — it crashes at startup there. Play it on this PC.": "",
"Needs Windows (or WSL on Windows)": "",
@@ -903,8 +901,6 @@
"Play on this PC": "",
"Play your Quest, Android and PC VR games on the Steam Frame. Three steps and you're set.": "",
"Play: starts the game through the Frame's Steam (put the headset on)": "",
"Plays the game's Android video surface through GPU-shared buffers and composes it as a stereo panorama (Batman: Arkham Shadow's cutscenes, see docs/SURFACE_VIDEO.md). Specific to that game's renderer: set by its recipe, not shown in Game settings. Use with frame.hw_video_decode.": "",
"Plays the game's videos on the Frame's video hardware, so 4K and 8K video runs smoothly.": "",
"Point FramePort at a folder with Android games (APK + OBB, e.g. Quest games) or PC VR games (one folder per game, or a folder of them). It finds them, works out what each needs and fetches artwork.": "",
"Pointer angle": "",
"Pointer start": "",
@@ -993,7 +989,7 @@
"Removes the game from the Frame. Saves are kept, so a reinstall picks up where you left off.": "",
"Removes the previous version kept after each reinstall and any leftover uploads. Games and saves aren't touched.": "",
"Removes {title} from this PC's Steam library (Steam restarts once). The game folder isn't touched.": "",
"Removes {title}'s game files from the Frame. Saves and the game's own files are kept unless you tick the box below; the Steam entry disappears after the next Steam restart.": "",
"Removes {title}'s game files from the Frame. Saves are kept; the Steam entry disappears after the next Steam restart.": "",
"Rename": "",
"Rename {name}": "",
"Rename…": "",
@@ -1071,6 +1067,7 @@
"Select games to install": "",
"Sends this game's recipe (the patches and settings it uses) to FramePort's GitHub as a prefilled issue, so it can become a built-in recipe for everyone. You review and submit it in the browser; no GitHub token and no game files are involved.": "",
"Set by you.": "",
"Set up Quest games on this PC?": "",
"Set up the connection to your Steam Frame first": "",
"Set up the Frame": "",
"Set up with a USB cable": "",
@@ -1146,6 +1143,7 @@
"Starting…": "",
"Starts a video stream on the Frame and opens it in your browser.": "",
"Starts the game on the Frame while nobody is wearing it and reads the log: did it start, create a VR session and render frames. It can't check what you'd see: tracking only runs with the headset on.": "",
"Starts the game through Steam on this PC (SteamVR + AXRB's Android)": "",
"Starts the game through Steam on this PC (SteamVR + Revive)": "",
"Starts the game through the Frame's Steam — put the headset on": "",
"Starts the game with options, e.g. to choose SteamVR or OpenXR.": "",
@@ -1276,6 +1274,7 @@
"This game's data file (.obb) wasn't found next to the APK. Put the .obb files in a folder named {package} (or obb/) next to the APK and add the folder again; without it the game hangs at start.": "",
"This game's folder has several programs and FramePort isn't sure which one starts the game. Open the game (or right-click → Change executable) to pick it.": "",
"this PC": "",
"This PC (AXRB, experimental)": "",
"This PC (for PC VR games)": "",
"This PC (Steam + Revive)": "",
"This PC's disk is full. Free some space and try again.": "",
+9
View File
@@ -74,6 +74,7 @@ class Patch:
default_on: bool = False # part of the always-recommended baseline
experimental: bool = False
needs_vr: bool = True # only matters for VR apps (hidden for Android apps without VR)
on_pc: bool = False # also in the build for this PC (AXRB): a fix of the APK itself, not of the Frame/Lepton
revision: int = 1 # bump when apply() writes something different: installed builds then show "Update on Frame"
package_revisions: dict[str, int] = {} # narrower updates to otherwise shared patches
@@ -139,6 +140,14 @@ def for_game(patch: Patch, analysis: Analysis) -> bool:
return (patch.category == "pcvr") == rift
def pc_selection(patches: dict) -> dict:
"""The part of a Quest recipe that goes into the build for this PC (AXRB): OVRPort's patches and the APK fixes
marked on_pc. Steam Frame fixes (FrameBridge, Lepton workarounds) stay out; AXRB has its own OpenXR runtime."""
load_all()
return {pid: params for pid, params in patches.items()
if pid in REGISTRY and (REGISTRY[pid].category == "overport" or REGISTRY[pid].on_pc)}
def all_patches() -> list[Patch]:
load_all()
return sorted(REGISTRY.values(), key=lambda p: (CATEGORIES.index(p.category), p.order, p.id))
+22 -9
View File
@@ -21,6 +21,7 @@ def settings_text(recipe_patches: dict) -> bytes:
class FrameBridgeAdapter(Patch):
id = "frame.adapter"
package_revisions = {"com.camouflaj.manta": 20}
title = "FrameBridge OpenXR adapter"
description = (
"Wraps OVRPort's generic OpenXR loader (renamed libopenxr_loader_original.so). Fixes the Frame runtime's "
@@ -43,6 +44,11 @@ class FrameBridgeAdapter(Patch):
raise RuntimeError("OVRPort output has no libopenxr_loader_generic.so (not an OVRPort build?)")
adapter = artifact(ws.abi, GENERIC)
settings = settings_text(ctx.recipe_patches)
# This client submits its Android video surface as stereo panoramas.
# Keep the implementation choice internal; no recipe/UI parameter.
native_video = ctx.analysis.package == "com.camouflaj.manta" and ws.abi == "arm64-v8a"
if native_video:
settings += b"surface_native=1\n"
changed = False
if not ws.has(ws.lib(ORIGINAL)):
ws.move(ws.lib(GENERIC), ws.lib(ORIGINAL))
@@ -54,17 +60,25 @@ class FrameBridgeAdapter(Patch):
if not ws.has(ws.lib(SETTINGS)) or ws.read_lib(SETTINGS) != settings:
ws.put(ws.lib(SETTINGS), settings)
changed = True
if needs_xrshim(ctx.recipe_patches) and ws.abi == "arm64-v8a":
if (needs_xrshim(ctx.recipe_patches) or native_video) and ws.abi == "arm64-v8a":
changed |= add_xrshim(ctx)
# The decoder now lives in the agent's shared, versioned codec store (frame.hw_video_decode selects it per
# game). Remove old embedded payloads without changing any game/video assets.
# Only the validated Batman renderer needs this private hardware codec.
# Assets are extracted into its container; the shared runtime is untouched.
for name in ("libstagefrighthw.so", "media_codecs_frameport.xml", "podman.py", "manifest.json",
"COPYING.FFmpeg"):
target = f"assets/frameport/hevc/{name}"
if ws.has(target):
ws.remove.add(target)
ws.add.pop(target, None)
ws.replace.pop(target, None)
if not native_video:
if ws.has(target):
ws.remove.add(target)
ws.add.pop(target, None)
ws.replace.pop(target, None)
changed = True
continue
# Flet compiles/removes .py files in bundled data, so keep the
# remote wrapper's source under a non-Python extension on the PC.
data = artifact("hevc", name + ".txt" if name == "podman.py" else name)
if not ws.has(target) or ws.read(target) != data:
ws.put(target, data)
changed = True
return changed
@@ -79,8 +93,7 @@ def needs_xrshim(recipe_patches: dict) -> bool:
from ..settings import adapter_settings
s = adapter_settings(recipe_patches)
# surface_native: Batman's native video renderer hooks Vulkan functions OVRPort's dispatcher doesn't know
return bool(s.get("controller_models") or s.get("haptic_fix") or s.get("surface_native"))
return bool(s.get("controller_models") or s.get("haptic_fix"))
def add_xrshim(ctx: ApkContext) -> bool:
@@ -1,39 +0,0 @@
"""Hardware video decoding: H.264, HEVC and VP9 on the Frame's Iris decoder instead of Android's software decoders.
Lepton's Android 11 has only Google's software codecs (OMX.google.*), which can't keep up with 4K/8K video. FramePort's
OMX plugin (native/hevc: `OMX.frameport.{avc,hevc,vp9}.decoder`, FFmpeg's v4l2m2m wrappers on /dev/video-dec0, with an
FFmpeg software fallback inside the component) is installed once per Frame by the agent (`install_video_codec`, shared
versioned store, see frame/connection.ensure_video_codec). This patch only selects it per game: the agent gives the
game's launcher the codec line when its recipe has this patch (deployment.json `hw_video_decode`), which puts the
shared Podman wrapper first on Lepton's PATH; the wrapper mounts the plugin read-only into this game's container only.
The APK is not changed (older builds' bundled codec assets are removed by frame.adapter). FramePort's setting
"Hardware video decoding" (library setting video.hw_decode) or FRAMEPORT_NO_HW_VIDEO=1 turns it off for every game.
"""
from __future__ import annotations
from ..base import Patch, Suggestion, register
class HwVideoDecode(Patch):
id = "frame.hw_video_decode"
title = "Hardware video decoding (H.264, HEVC, VP9)"
description = ("Decodes the game's videos on the Frame's hardware video decoder instead of Android's software "
"decoders, which stutter or fall behind with 4K/8K video (e.g. video players, Batman: Arkham "
"Shadow's cutscenes). Adds FramePort's codec plugin (OMX.frameport.avc/hevc/vp9.decoder) to this "
"game's container only; the APK isn't changed. When the hardware is busy (e.g. Steam's own hardware "
"video decoding is on) the video is decoded in software as before.")
stage = "install" # the agent reads it from the recipe at finalize: no APK change
order = 48
needs_vr = False # 2D video apps benefit as much
def applies(self, a):
return "arm64-v8a" in a.abis # the plugin runs in Lepton's arm64 media service
def detect(self, a):
if self.applies(a) and (a.extra or {}).get("media_codec"):
return Suggestion(True, "The game plays video through Android's decoders (MediaCodec, ExoPlayer or "
"VLC): decode it on the Frame's hardware.")
return None
register(HwVideoDecode)
@@ -29,6 +29,7 @@ class PlatformCompat(Patch):
"skipped.")
order = 30
default_on = True
on_pc = True
def detect(self, a):
return Suggestion(True, "Applied automatically when the OVRPort build needs it.")
@@ -52,6 +53,7 @@ class OvrStubs(Patch):
"'cannot locate symbol ovr_...'. Online/store features stay unavailable.")
order = 31
default_on = True
on_pc = True # missing Meta platform symbols crash the game in any Android, not only on the Frame
def detect(self, a):
return Suggestion(True, "Applied automatically when the OVRPort build needs it.")
@@ -45,6 +45,7 @@ class SwapchainLimit(Patch):
"the Frame's runtime (8192 px max) decides instead.")
order = 45
default_on = True
on_pc = True # the guard is in OVRPort's dispatcher, which every OVRPort build carries
def detect(self, a):
video = sorted(lib for lib in a.libs if lib.lower().startswith(VIDEO_LIBS))
-8
View File
@@ -37,10 +37,6 @@ SETTINGS = [
"The Frame's runtime has no cube-map layers and refuses their swapchains; OVRPlugin then crashes when it submits "
"the next frame (e.g. Budget Cuts Ultimate). FrameBridge serves such a swapchain itself (a GL cube map, GLES "
"games) and drops its layers, so the game runs on without that layer."),
("surface_native", "int", 0, "Native video surfaces (Batman renderer)",
"Plays the game's Android video surface through GPU-shared buffers and composes it as a stereo panorama (Batman: "
"Arkham Shadow's cutscenes, see docs/SURFACE_VIDEO.md). Specific to that game's renderer: set by its recipe, not "
"shown in Game settings. Use with frame.hw_video_decode."),
("layer_fix", "int", 1, "Drop invalid layers",
"Drop layers whose swapchain failed or whose extension isn't enabled."),
("passthrough_emul", "int", 1, "Emulate passthrough",
@@ -224,9 +220,6 @@ SETTINGS = [
# since 0.12.1: no game is known to need that, and a bump would mark every OVRPlugin game's build outdated
REVISIONS = {"vk_shader_fix": 2, "vk_query_slots": 3, "vk_spec_fixes": 2, "haptic_fix": 2, "pose_consistency": 2}
# Settings a game's recipe sets that the Game settings dialog doesn't show (engine-specific, not user choices)
HIDDEN = {"surface_native"}
# How the "Game settings" dialog shows each setting to non-technical users: group, level (common settings are always
# shown; advanced ones only under "Show advanced settings"), a plain label and one-line help, the control, and the
# setting it depends on (only shown while that one is on). The technical title/description above stay for the CLI.
@@ -421,7 +414,6 @@ class AdapterSetting(Patch):
"zink_shader_fix": ap.is_gles,
"zink_shader_dump": ap.is_gles,
"haptic_fix": lambda a: "libOVRPlugin.so" in a.libs,
"surface_native": lambda a: "arm64-v8a" in a.abis, # the xrshim it needs is arm64 only
"pose_time_fix": lambda a: "libOVRPlugin.so" in a.libs,
"proximity_emul": lambda a: "libOVRPlugin.so" in a.libs,
"vk_spec_fixes": lambda a: a.engine == "Unreal" and ap.is_vulkan(a),
-2
View File
@@ -78,8 +78,6 @@ SUMMARIES = {
"frame.swapchain_limit": "Allows very large pictures (8K video, theatres) instead of quitting.",
"frame.vrapi_stub": "Stops Meta's leftover VrApi library from closing the game at start (e.g. Jurassic World "
"Aftermath).",
"frame.hw_video_decode": "Plays the game's videos on the Frame's video hardware, so 4K and 8K video runs "
"smoothly.",
"frame.tbxr_vendor": "Lets Team Beef ports (e.g. Lambda1VR) start on the Frame and use their Meta Quest setup.",
"frame.asset_files": "Lets the game find content it keeps in separate files next to its data (e.g. Star Wars "
"Tales' seasons).",
+44 -16
View File
@@ -286,6 +286,16 @@ def is_linux(entry: dict) -> bool:
return entry.get("kind") == "linux"
def pc_installable(entry: dict) -> bool:
"""Can go on this PC: Rift games (Revive) and Android OpenXR games (Quest / Khronos loader; AXRB, experimental);
not Linux/Windows/2D apps."""
if entry.get("kind") == "rift":
return True
if entry.get("kind"):
return False
return (((entry.get("analysis") or {}).get("extra") or {}).get("vr_kind") or "quest") in ("quest", "openxr")
def analysis_warnings(entry: dict) -> list[str]:
"""Blockers read from a Quest/Android game's APK, for the CLI (the game page shows them as callouts)."""
if is_rift(entry) or is_linux(entry) or not entry.get("analysis"):
@@ -715,7 +725,21 @@ def _build_lock(package: str) -> threading.Lock:
return _build_locks.setdefault(package, threading.Lock())
def build_game(package: str, reporter: Reporter, outdir: Path | None = None) -> dict:
def pc_recipe_fingerprint(recipe: dict, package: str = "") -> str:
"""Fingerprint of what the build for this PC contains (Frame-only patch changes don't make it outdated)."""
from .patches.base import pc_selection
return recipe_fingerprint(dict(recipe, patches=pc_selection(recipe.get("patches") or {})), package)
def build_key(entry: dict, pc: bool) -> str:
"""Where a Quest game's build is recorded: "build" (Steam Frame), "build_pc" (this PC, AXRB). Installed-as-is
games have one build for both."""
return "build_pc" if pc and not library.recipe_from_dict(entry["recipe"]).as_is else "build"
def build_game(package: str, reporter: Reporter, outdir: Path | None = None, pc: bool = False) -> dict:
"""pc: the build for this PC (Quest games through AXRB): OVRPort's patches without the Steam Frame fixes."""
if analysis_outdated(library.game(package) or {}): # analysed by an older FramePort: new fields first
refresh_analyses([package], reporter)
entry = library.game(package)
@@ -728,15 +752,17 @@ def build_game(package: str, reporter: Reporter, outdir: Path | None = None) ->
src = source_of(entry)
a = library.analysis_from_dict(entry["analysis"])
recipe = library.recipe_from_dict(entry["recipe"])
out = outdir or (output_dir() / quest_dump.display_name(entry.get("name") or package))
name = quest_dump.display_name(entry.get("name") or package)
out = outdir or (output_dir() / (f"{name} (PC)" if pc else name))
with _build_lock(package): # one build per game at a time: builds share the game's work folder
res = builder.build(src, a, recipe, out, reporter)
res = builder.build(src, a, recipe, out, reporter, **({"pc": True} if pc else {}))
art, store_title = artwork.fetch(package, res.apk)
build_info = {"apk": str(res.apk), "alt_apk": str(res.alt_apk) if res.alt_apk else None, "sha256": res.sha256,
"alt_sha256": res.alt_sha256, "applied": res.applied, "checks": res.checks, "ok": res.ok,
"overport": res.meta.get("overport"), "recipe_fp": recipe_fingerprint(entry["recipe"], package),
"overport": res.meta.get("overport"),
"recipe_fp": (pc_recipe_fingerprint if pc else recipe_fingerprint)(entry["recipe"], package),
"superseded": res.meta.get("superseded") or {}}
library.upsert_game(package, build=build_info, title=entry.get("title") or store_title)
library.upsert_game(package, **{build_key(entry, pc): build_info}, title=entry.get("title") or store_title)
return build_info
@@ -749,14 +775,15 @@ def install_game(package: str, target: Target, reporter: Reporter, apk_only: boo
if is_rift(entry):
return install_rift(package, target, reporter, add_to_library)
test_build = apk is not None
b = entry.get("build") or {}
pc = getattr(target, "kind", "") == "pc" # Quest games on this PC (AXRB): their own build, no Frame fixes
b = entry.get(build_key(entry, pc)) or {}
recipe = library.recipe_from_dict(entry["recipe"])
if not test_build and not b.get("apk"): # never built yet (e.g. `frameport install` right after a scan)
build_game(package, reporter)
build_game(package, reporter, pc=pc)
return install_game(package, target, reporter, apk_only, add_to_library)
apk = Path(apk) if apk else Path(b["alt_apk"] if recipe.use_alt and b.get("alt_apk") else b["apk"])
if not test_build and not apk.exists(): # the converted copy was removed after an earlier install: make it again
build_game(package, reporter)
build_game(package, reporter, pc=pc)
return install_game(package, target, reporter, apk_only, add_to_library)
if not test_build and b.get("superseded"): # workarounds this build left out (upstream fixed): not in settings.conf
recipe = dataclasses.replace(recipe, patches=upstream.without_superseded(recipe.patches, b["superseded"]))
@@ -885,15 +912,16 @@ def remove_converted_copies(package: str) -> int:
game files the user added. Returns the bytes freed."""
if library.setting("build.keep_copies", False):
return 0
b = (library.game(package) or {}).get("build") or {}
g = library.game(package) or {}
out, freed = output_dir().resolve(), 0
for key in ("apk", "alt_apk"):
p = Path(b[key]) if b.get(key) else None
if p and p.exists() and out in p.resolve().parents:
freed += p.stat().st_size
p.unlink()
if not any(p.parent.iterdir()):
p.parent.rmdir()
for b in (g.get("build") or {}, g.get("build_pc") or {}):
for key in ("apk", "alt_apk"):
p = Path(b[key]) if b.get(key) else None
if p and p.exists() and out in p.resolve().parents:
freed += p.stat().st_size
p.unlink()
if not any(p.parent.iterdir()):
p.parent.rmdir()
return freed
+228
View File
@@ -0,0 +1,228 @@
"""Quest games on this Windows PC through AXRB (tools/axrb.py): the "This PC" target's Android half.
An install sets AXRB up when needed (its installer + its Android runtime, downloaded only now), starts the emulator,
installs the game's build for this PC (OVRPort's conversion without the Steam Frame fixes, signed with the game's key)
and its OBB data, and records <data>/pc/<pkg>/deployment.json with kind "android". Play goes through a Steam shortcut
that runs FramePort's launcher script, which hands the game to AXRB's own run script (emulator, SteamVR identity,
host bridge). See docs/PC_ANDROID.md.
"""
from __future__ import annotations
import json
import re
import subprocess
import time
from pathlib import Path
from ..core import winhost
from ..core.events import Reporter
from ..core.models import Recipe, data_manifest
from ..tools import axrb
KIND = "android"
OBB_ROOT = "/sdcard/Android/obb"
def powershell_win() -> str:
return axrb.powershell_win()
def shortcut_fields(dep: dict) -> tuple[str, str, str]:
"""(Exe, StartDir, LaunchOptions) of the Steam shortcut: powershell.exe running FramePort's AXRB launcher."""
launcher = dep["launcher_win"]
start = '"' + launcher.rsplit("\\", 1)[0] + '\\"'
return f'"{dep["powershell_win"]}"', start, axrb.launcher_args(launcher, dep["package"], dep["activity"],
dep["title"])
def _adb_ok(r: subprocess.CompletedProcess, what: str) -> str:
text = (r.stdout + r.stderr).strip()
if r.returncode != 0 or "Failure" in text or "Error" in text and "Success" not in text:
if "INSTALL_FAILED_UPDATE_INCOMPATIBLE" in text:
raise RuntimeError(f"{what}: a copy of this game signed with another key is installed in AXRB. "
"Uninstall it there first (that deletes its Android saves), then install again.")
raise RuntimeError(f"{what}: {text[-400:]}")
return text
def resolve_activity(package: str) -> str:
r = axrb.adb(["shell", "cmd", "package", "resolve-activity", "--brief", package], timeout=60)
last = (r.stdout.strip().splitlines() or [""])[-1].strip()
if not re.fullmatch(r"[\w.]+/[\w.$]+", last):
raise RuntimeError(f"{package} has no launcher activity in Android (is it an OVRPort build?)")
return last
def remote_sizes(package: str) -> dict[str, int]:
"""Sizes of the files already in the game's OBB folder in AXRB's Android (a re-install sends only the rest)."""
r = axrb.adb(["shell", f"cd {OBB_ROOT}/{package} 2>/dev/null && find . -type f -exec stat -c '%s %n' {{}} +"],
timeout=120)
out = {}
for line in r.stdout.splitlines():
size, _, name = line.strip().partition(" ")
if size.isdigit() and name.startswith("./"):
out[name[2:]] = int(size)
return out
def push_data(package: str, data_dir: Path, files: list[str] | None, reporter: Reporter) -> int:
manifest = data_manifest(data_dir, files)
if not manifest:
return 0
have = remote_sizes(package)
todo = {rel: size for rel, size in manifest.items() if have.get(rel) != size}
total, done = sum(todo.values()) or 1, 0
reporter.log(f"game data: {len(todo)} of {len(manifest)} files to copy ({total / 2**30:.1f} GiB)")
for rel, size in todo.items():
reporter.check_cancel()
reporter.progress(done / total, f"copying game data: {rel}")
r = axrb.adb(["push", winhost.to_windows(data_dir / rel), f"{OBB_ROOT}/{package}/{rel}"],
timeout=600 + size / 4e6)
_adb_ok(r, f"copying {rel}")
done += size
reporter.progress(1.0, "game data copied")
return len(todo)
def ensure_axrb(reporter: Reporter) -> Path:
"""AXRB and its Android runtime, installed/set up on first use. Returns the AXRB app folder."""
app = axrb.app_dir()
if not app:
reporter.stage("Download AXRB (Android XR Bridge)")
app = axrb.install(lambda f: reporter.progress(f, "downloading AXRB"), reporter.check_cancel)
reporter.check("AXRB", True, f"{axrb.installed_version(app) or '?'} at {winhost.to_windows(app)}")
info = axrb.requirements(app)
problems = axrb.requirement_problems(info)
if problems:
raise RuntimeError("This PC can't run Quest games through AXRB: " + "; ".join(problems))
reporter.check("PC requirements (AXRB)", True, f"{info.get('gpu')}, {info.get('memoryGB')} GB")
state = axrb.setup_state(app=app)
if state["components"] or not state["avd"]:
reporter.stage(f"Set up AXRB's Android ({state['download_bytes'] / 2**30:.1f} GiB download)")
axrb.setup_files(lambda f, name: reporter.progress(f, f"downloading {name}"), reporter.check_cancel, app=app)
return app
def install(package: str, title: str, apk: Path, data_dir: Path | None, recipe: Recipe, reporter: Reporter,
apk_only: bool = False, data_files: list[str] | None = None, record_dir: Path | None = None,
appid=None) -> dict:
app = ensure_axrb(reporter)
reporter.stage("Start Android (AXRB; the first start takes several minutes)")
started = axrb.start_emulator(app=app)
try:
axrb.ensure_runtime_apk(app=app)
reporter.stage("Install the game into AXRB's Android")
_adb_ok(axrb.adb(["install", "--no-incremental", "-r", winhost.to_windows(apk)], timeout=900),
"installing the game")
activity = resolve_activity(package)
sent = 0 if apk_only or not data_dir else push_data(package, Path(data_dir), data_files, reporter)
axrb.adb(["shell", "sync"], timeout=120)
finally:
if started:
reporter.stage("Stop Android")
axrb.stop_emulator()
launcher = axrb.write_launcher(app=app)
from ..build import sha256
from ..patches.base import pc_selection
dep = {"package": package, "kind": KIND, "title": title, "activity": activity, "apk": str(apk),
"sha256": sha256(apk), "launcher_win": winhost.to_windows(launcher), "powershell_win": powershell_win(),
"axrb_version": axrb.installed_version(app),
"recipe": {"patches": sorted(pc_selection(recipe.patches)), "source": recipe.source}, "time": time.time()}
dep["appid"] = appid(shortcut_fields(dep)[0], title) if appid else None
if record_dir:
record_dir.mkdir(parents=True, exist_ok=True)
(record_dir / "deployment.json").write_text(json.dumps(dep, indent=2), encoding="utf-8")
reporter.log(f"installed in AXRB ({activity}); {sent} data files copied")
return {"ok": True, "appid": dep["appid"], "activity": activity}
def game_logs() -> dict[str, str]:
"""AXRB's session logs (newest session) for triage and diagnostics."""
root = axrb.runtime_root()
out = {}
if not root:
return out
logs = axrb.log_dir(root)
for name, sub, limit in (("session.json", "game/session.json", 1 << 16), ("host.err", "game/host.err", 1 << 20),
("host.log", "game/host.log", 1 << 20), ("guest.log", "game/guest.log", 2 << 20),
("emulator.stderr.log", "emulator/emulator.stderr.log", 1 << 18),
("frameport-emulator-start.log", "frameport-emulator-start.log", 1 << 18)):
p = logs / sub
if p.is_file():
out[name] = axrb.read_log(p, limit)
return out
def launch_test(dep: dict, reporter: Reporter, seconds: int = 45, triage=None):
"""Start the game through FramePort's launcher (= AXRB's run script), watch its Android process, stop it again."""
reporter.stage("Launch test (this PC, AXRB)")
package = dep["package"]
if axrb.emulator_online() and _pid(package):
raise RuntimeError(f"{dep['title']} is already running")
log = axrb.log_dir() / "frameport-launch-test.log"
log.parent.mkdir(parents=True, exist_ok=True)
log.unlink(missing_ok=True)
before = time.time()
q = axrb._ps_quote
pid = axrb.spawn_detached(axrb.logged(
f"& {q(dep['launcher_win'])} -Package {q(package)} -Activity {q(dep['activity'])} "
f"-GameName {q(dep['title'])} -CaptureGuestLog", winhost.to_windows(log)))
seen, state, boot_deadline = False, "NEVER_STARTED", time.time() + 900 # the emulator boot comes first
watch_end = None
while time.time() < boot_deadline and axrb.pid_running(pid):
reporter.check_cancel()
time.sleep(3)
running = axrb.emulator_online() and bool(_pid(package))
if running and not seen:
seen, watch_end = True, time.time() + seconds
if seen and not running:
state = "EXITED"
break
if watch_end and time.time() >= watch_end:
state = "RUNNING"
break
if seen and state == "NEVER_STARTED":
state = "EXITED"
if axrb.pid_running(pid):
try: # AXRB's script sees the game stop, cleans up (and shuts Android down if it started it)
axrb.adb(["shell", "am", "force-stop", package], timeout=30)
except (axrb.AxrbError, OSError, subprocess.TimeoutExpired):
pass
end = time.time() + 300
while time.time() < end and axrb.pid_running(pid):
time.sleep(3)
logs = game_logs()
text = "\n".join(f"===== {name}\n{body}" for name, body in logs.items()
if name != "frameport-emulator-start.log")
text += "\n===== launcher\n" + axrb.read_log(log, 1 << 16)
result = triage(text, state, package, kind="axrb") if triage else None
reporter.check("Game process", state == "RUNNING", f"{state} ({round(time.time() - before)}s incl. Android start)")
if result:
for m in result.milestones:
reporter.check(m, True)
for f in result.findings:
reporter.check(f.id, None if f.severity in ("warning", "info") else False,
f"{f.diagnosis} [{f.evidence[:160]}]")
return result, text
def _pid(package: str) -> str:
try:
return axrb.adb(["shell", "pidof", package], timeout=20).stdout.strip()
except (axrb.AxrbError, OSError, subprocess.TimeoutExpired):
return ""
def uninstall(dep: dict, keep_data: bool = True) -> bool:
"""Remove the game from AXRB's Android (saves kept unless keep_data is False). False if AXRB isn't there."""
if not axrb.app_dir() or not axrb.adb_exe():
return False
started = axrb.start_emulator()
try:
args = ["shell", "pm", "uninstall", "-k", dep["package"]] if keep_data else ["uninstall", dep["package"]]
r = axrb.adb(args, timeout=240)
return "Success" in r.stdout
finally:
if started:
axrb.stop_emulator()
+38 -12
View File
@@ -1,5 +1,6 @@
"""PC VR target: Oculus Rift games run on this Windows PC through Revive (LibOVR -> OpenXR/SteamVR), with a non-Steam
shortcut in the local Steam library (play on the Frame by streaming from Steam/SteamVR).
"""PC target ("This PC"): Oculus Rift games run on this Windows PC through Revive (LibOVR -> OpenXR/SteamVR), Quest
games through AXRB (targets/pc_android.py), each with a non-Steam shortcut in the local Steam library (play on the
Frame by streaming from Steam/SteamVR).
Works on native Windows and from WSL (Windows programs via interop). Revive is FramePort's portable copy
(tools/revive.py), so nothing is installed system-wide. Nothing is copied: the shortcut points at the game folder.
@@ -18,7 +19,9 @@ from ..core.events import Reporter
from ..core.models import Recipe
from ..core.paths import agent_file, user_data_dir
from ..patches.pcvr import game_args
from ..tools import axrb
from ..validate.triage import triage
from . import pc_android
from .base import Target
TAG = "FramePort PC VR" # marks the Steam shortcuts FramePort made (for updates and cleanup)
@@ -61,8 +64,20 @@ def pc_dir() -> Path:
return path
def is_android(dep: dict) -> bool:
"""A Quest game installed on this PC (AXRB), not a Rift game."""
return dep.get("kind") == pc_android.KIND
def options_key(dep: dict) -> str | None:
"""Quest games on this PC share their shortcut Exe (powershell.exe): their shortcut is also matched by this."""
return f"-Package {dep['package']} " if is_android(dep) else None
def shortcut_fields(dep: dict) -> tuple[str, str, str]:
"""(Exe, StartDir, LaunchOptions) for the Steam shortcut of an installed game."""
if is_android(dep):
return pc_android.shortcut_fields(dep)
exe_win = dep["exe_win"]
start = '"' + exe_win.rsplit("\\", 1)[0] + '\\"'
extra = " ".join(dep.get("game_args") or [])
@@ -106,6 +121,7 @@ class PcReviveTarget(Target):
"steamvr": bool(root and winhost.app_installed(root, winhost.STEAMVR_APPID)),
"revive": str(revive.revive_dir()) if revive.revive_dir() else None,
"revive_version": revive.installed_version(),
"axrb": axrb.status(),
"installed": self.installed(),
}
@@ -116,7 +132,7 @@ class PcReviveTarget(Target):
dep = json.loads(f.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
dep["apk_present"] = Path(dep.get("exe_local", "")).exists()
dep["apk_present"] = True if is_android(dep) else Path(dep.get("exe_local", "")).exists()
out.append(dep)
return out
@@ -131,7 +147,8 @@ class PcReviveTarget(Target):
log_path = (winhost.env_path("LOCALAPPDATA") or Path("/nonexistent")) / REVIVE_LOG
if log_path.exists():
files["ReviveInjector.txt"] = log_path.read_text(encoding="utf-8", errors="replace")[-(2 << 20):]
out = {"host": {"steamvr_running": winhost.steamvr_running()}, "files": files}
files.update({f"axrb/{name}": text for name, text in pc_android.game_logs().items()})
out = {"host": {"steamvr_running": winhost.steamvr_running(), "axrb": axrb.status()}, "files": files}
if package:
try:
files["deployment.json"] = json.dumps(self._dep(package), indent=1)
@@ -142,7 +159,12 @@ class PcReviveTarget(Target):
# ------------------------------------------------------------------ install
def install(self, package, title, apk, data_dir, recipe, reporter, apk_only=False, data_files=None):
raise NotImplementedError("Quest (APK) games install on the Steam Frame, not on the PC")
"""A Quest game (its build for this PC) into AXRB's Android emulator."""
if not self.steam:
raise RuntimeError("Steam for Windows was not found on this PC")
return pc_android.install(package, title, Path(apk), Path(data_dir) if data_dir else None, recipe, reporter,
apk_only, data_files, record_dir=pc_dir() / package,
appid=_vdf().shortcut_appid)
def install_pcvr(self, package, title, game_dir, exe, recipe: Recipe, reporter: Reporter, **extra):
from ..tools import revive
@@ -204,7 +226,7 @@ class PcReviveTarget(Target):
try:
dep = self._dep(pkg)
exe, start, opts = shortcut_fields(dep)
entry = library.game(pkg) or {"kind": "rift"}
entry = library.game(pkg) or ({"package": pkg} if is_android(dep) else {"kind": "rift"})
if not sources.has_art(pkg):
artwork.fetch(pkg, lookup=dep.get("art_lookup"))
art = steam_set_for(pkg)
@@ -215,13 +237,13 @@ class PcReviveTarget(Target):
old.unlink(missing_ok=True)
reporter.log(f"removed the old Steam entry for {dep['title']} (its launch command changed)")
ident = vdf_mod.shortcut_appid(exe, dep["title"])
icon = dep["exe_win"]
icon = dep.get("exe_win") or ""
if "icon" in art: # Steam wants a local path for the shortcut icon
icon_path = grid / f"{ident}_icon.png"
shutil.copy(art["icon"], icon_path)
icon = winhost.to_windows(icon_path)
got = vdf_mod.upsert_shortcut(str(vdf), exe, dep["title"], start, icon, TAG, opts,
tags=steam_tags(entry, "pc"))
tags=steam_tags(entry, "pc"), options_key=options_key(dep))
for kind, f in art.items():
suffix = {"portrait": "p", "landscape": "", "hero": "_hero", "logo": "_logo"}.get(kind)
if suffix is None:
@@ -248,7 +270,7 @@ class PcReviveTarget(Target):
if not root or not dep.get("appid"):
raise RuntimeError("Steam or the game's Steam shortcut wasn't found on this PC")
vr = winhost.start_steamvr(root) # Revive binds to SteamVR; without it the game falls back to flatscreen
tuning = self._tune(root, dep) if vr else None
tuning = self._tune(root, dep) if vr and not is_android(dep) else None
winhost.start_detached(root / "steam.exe", [f"steam://rungameid/{(int(dep['appid']) << 32) | 0x02000000}"])
return {"package": package, "title": dep.get("title"), "steamvr": vr, "tuning": tuning}
@@ -267,8 +289,10 @@ class PcReviveTarget(Target):
return result
def launch_test(self, package, reporter, seconds=45):
reporter.stage("Launch test (this PC)")
dep = self._dep(package)
if is_android(dep):
return pc_android.launch_test(dep, reporter, seconds, triage)
reporter.stage("Launch test (this PC)")
image = Path(dep["exe_local"]).name
if winhost.process_running(image):
raise RuntimeError(f"{dep['title']} is already running")
@@ -320,9 +344,11 @@ class PcReviveTarget(Target):
vdf = root / "userdata" / user / "config" / "shortcuts.vdf"
was_running = winhost.stop_steam(root)
try:
removed = _vdf().remove_shortcut(str(vdf), shortcut_fields(dep)[0])
removed = _vdf().remove_shortcut(str(vdf), shortcut_fields(dep)[0], options_key(dep))
finally:
if was_running:
winhost.start_steam(root)
android_removed = pc_android.uninstall(dep, keep_data) if is_android(dep) else None
shutil.rmtree(pc_dir() / package, ignore_errors=True)
return {"removed": True, "shortcut_removed": removed, "kept_saves": True}
return {"removed": True, "shortcut_removed": removed, "kept_saves": keep_data or not is_android(dep),
**({"android_removed": android_removed} if is_android(dep) else {})}
+568
View File
@@ -0,0 +1,568 @@
"""AXRB (Android XR Bridge) as an external tool: Quest games on this Windows PC.
AXRB (github.com/Android-XR-Bridge/AXRB) runs Android OpenXR apps in an Android 16 emulator (x86_64 image, arm64 code
through Android's ARM translation) and shows them through the PC's OpenXR runtime (SteamVR, ...). FramePort never
bundles or modifies it: the official installer is downloaded only when a user installs a Quest game on this PC, and
FramePort talks to it through its public interfaces (its PowerShell scripts, adb). See docs/PC_ANDROID.md.
The emulator runtime (Android SDK pieces + the AVD) is set up the way AXRB's own launcher does it, in AXRB's managed
folder (%LOCALAPPDATA%\\AXRB Runtime), so AXRB's launcher sees the same runtime and games: the pinned downloads come
from the installed AXRB's own component list (resources/app.asar core/components.json).
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import re
import shutil
import struct
import subprocess
import time
import zipfile
from pathlib import Path
from ..core import cache, winhost
from ..core.paths import write_atomic
AXRB_RELEASE = "https://api.github.com/repos/Android-XR-Bridge/AXRB/releases/latest"
APP_FOLDER = "axrb-launcher" # electron-builder installs under %LOCALAPPDATA%\Programs\<package name>
RUNTIME_FOLDER = "AXRB Runtime"
AVD = "axrb-managed-api36"
PORT = 5584 # emulator console port of AXRB's managed AVD -> adb serial emulator-5584
SERIAL = f"emulator-{PORT}"
ADB_PORT = "5038" # AXRB's own adb server, away from Android Studio's 5037
RUNTIME_PKG = "com.axrb.openxrruntime"
RUNTIME_APK = "out/android/runtime-arm64-v8a/axrb-openxr-runtime-debug.apk"
DOWNLOAD_HOST = "https://dl.google.com/android/repository/"
MIN_MEMORY_GB = 12
MEMORY_MB, CPU_CORES, STORAGE_GB = 8192, 4, 32 # AXRB's launcher defaults
LAUNCHER_NAME = "fp-axrb-run.ps1"
class AxrbError(RuntimeError):
pass
# ------------------------------------------------------------------------------------------ where things are
def local_appdata() -> Path | None:
return winhost.env_path("LOCALAPPDATA") if winhost.available() else None
def app_dir() -> Path | None:
"""The installed AXRB (folder with AXRB.exe): FRAMEPORT_AXRB_DIR, else the per-user install."""
if os.environ.get("FRAMEPORT_AXRB_DIR"):
d = Path(os.environ["FRAMEPORT_AXRB_DIR"])
return d if (d / "resources" / "runtime").is_dir() else None
local = local_appdata()
d = local / "Programs" / APP_FOLDER if local else None
return d if d and (d / "AXRB.exe").is_file() and (d / "resources" / "runtime").is_dir() else None
def resources(app: Path | None = None) -> Path | None:
app = app or app_dir()
return app / "resources" / "runtime" if app else None
def runtime_root() -> Path | None:
"""AXRB's managed emulator folder (SDK, AVD, logs)."""
if os.environ.get("FRAMEPORT_AXRB_RUNTIME"):
return Path(os.environ["FRAMEPORT_AXRB_RUNTIME"])
local = local_appdata()
return local / RUNTIME_FOLDER if local else None
def adb_exe(root: Path | None = None) -> Path | None:
root = root or runtime_root()
p = root / "sdk" / "platform-tools" / "adb.exe" if root else None
return p if p and p.is_file() else None
# ------------------------------------------------------------------------------------------ app.asar
def asar_read(asar: Path, name: str) -> bytes:
"""One file from an Electron asar archive (header: pickle sizes + a JSON index; data after the header)."""
with open(asar, "rb") as f:
_, header_size, _, json_len = struct.unpack("<4I", f.read(16))
index = json.loads(f.read(json_len))
node = index
for part in name.split("/"):
node = (node.get("files") or {}).get(part)
if node is None:
raise KeyError(name)
if "offset" not in node:
raise KeyError(f"{name} is unpacked or a folder")
f.seek(8 + header_size + int(node["offset"]))
return f.read(node["size"])
def installed_version(app: Path | None = None) -> str | None:
app = app or app_dir()
if not app:
return None
try:
return json.loads(asar_read(app / "resources" / "app.asar", "package.json")).get("version")
except (OSError, KeyError, ValueError, struct.error):
return None
def components(app: Path | None = None) -> list[dict]:
"""The emulator pieces the installed AXRB pins (emulator, platform-tools, build-tools, system image)."""
app = app or app_dir()
if not app:
raise AxrbError("AXRB is not installed")
items = json.loads(asar_read(app / "resources" / "app.asar", "core/components.json"))
for c in items: # same rule as AXRB's launcher: only Google's official repository
if not str(c.get("url", "")).startswith(DOWNLOAD_HOST) or not (c.get("sha256") or c.get("sha1")):
raise AxrbError(f"unexpected AXRB component download: {c.get('url')}")
return items
# ------------------------------------------------------------------------------------------ install AXRB
def latest() -> dict | None:
"""{version, setup_url, sums_url} of AXRB's latest release."""
rel = cache.cached_json("axrb-release.json", AXRB_RELEASE, max_age=6 * 3600)
if not rel:
return None
assets = {a["name"]: a["browser_download_url"] for a in rel.get("assets", [])}
setup = next((n for n in assets if re.fullmatch(r"AXRB-Setup-[\w.]+\.exe", n)), None)
sums = next((n for n in assets if n.startswith("SHA256SUMS") and n.endswith(".txt")), None)
if not setup or not sums:
return None
return {"version": rel.get("tag_name", "").lstrip("v"), "setup": setup, "setup_url": assets[setup],
"sums_url": assets[sums]}
def parse_sums(text: str) -> dict[str, str]:
out = {}
for line in text.splitlines():
m = re.match(r"^([0-9a-fA-F]{64})\s+\*?(.+?)\s*$", line)
if m:
out[m[2]] = m[1].lower()
return out
def install(progress=None, cancel=None, wait: float = 600) -> Path:
"""Download AXRB's official installer (checksum from the release's SHA256SUMS) and run it silently (per user,
no admin). Returns the app folder."""
if app_dir():
return app_dir()
rel = latest()
if not rel:
raise AxrbError("could not reach GitHub to find AXRB's latest release")
sums = parse_sums(cache.http_get(rel["sums_url"], timeout=30).text)
want = sums.get(rel["setup"])
if not want:
raise AxrbError(f"AXRB's release has no checksum for {rel['setup']}")
temp = winhost.env_path("TEMP") or local_appdata()
if not temp:
raise AxrbError("Windows' temporary folder was not found")
setup = cache.download(rel["setup_url"], temp / "FramePort" / rel["setup"], progress, expected_sha256=want)
if cancel:
cancel()
subprocess.run([str(setup), "/S"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=wait, cwd=str(setup.parent))
end = time.time() + wait # the NSIS stub can return before its installer child is done
while time.time() < end and not app_dir():
if cancel:
cancel()
time.sleep(2)
setup.unlink(missing_ok=True)
if not app_dir():
raise AxrbError("AXRB's installer finished but AXRB was not found in %LOCALAPPDATA%\\Programs")
return app_dir()
# ------------------------------------------------------------------------------------------ runtime setup
def avd_config(image_win: str, memory_mb: int = MEMORY_MB, cores: int = CPU_CORES, storage_gb: int = STORAGE_GB) -> str:
"""The AVD config.ini AXRB's launcher writes (core/setup.mjs avdConfig)."""
cfg = {"avd.ini.encoding": "UTF-8", "AvdId": AVD, "avd.ini.displayname": "AXRB", "abi.type": "x86_64",
"hw.cpu.arch": "x86_64", "hw.cpu.ncore": cores, "hw.ramSize": memory_mb, "hw.gpu.enabled": "yes",
"hw.gpu.mode": "host", "hw.audioOutput": "yes", "hw.audioInput": "yes", "hw.lcd.width": 1080,
"hw.lcd.height": 1920, "hw.lcd.density": 420, "hw.keyboard": "no", "hw.mainKeys": "no", "hw.useext4": "yes",
"disk.dataPartition.size": f"{storage_gb}G", "disk.cachePartition.size": "66MB", "vm.heapSize": 576,
"image.sysdir.1": image_win.rstrip("\\") + "\\", "tag.id": "google_apis", "target": "android-36",
"fastboot.forceColdBoot": "no", "fastboot.forceFastBoot": "yes", "showDeviceFrame": "no",
"runtime.network.speed": "full", "runtime.network.latency": "none", "PlayStore.enabled": "no"}
return "".join(f"{k}={v}\n" for k, v in cfg.items())
def _digest(c: dict) -> str:
return c.get("sha256") or c["sha1"]
def missing_components(root: Path | None = None, app: Path | None = None) -> list[dict]:
root = root or runtime_root()
out = []
for c in components(app):
dest = root / "sdk" / c["destination"]
try:
ok = (dest / c["probe"]).exists() and \
json.loads((dest / ".axrb-component.json").read_text()).get("digest") == _digest(c)
except (OSError, ValueError):
ok = False
if not ok:
out.append(c)
return out
def runtime_hash(app: Path | None = None) -> str:
h = hashlib.sha256()
with open(resources(app) / RUNTIME_APK, "rb") as f:
for chunk in iter(lambda: f.read(1 << 22), b""):
h.update(chunk)
return h.hexdigest()
def setup_state(root: Path | None = None, app: Path | None = None) -> dict:
"""What the runtime still needs: {components: [...], download_bytes, avd: bool, runtime_apk: bool, ready}."""
root = root or runtime_root()
if not root:
raise AxrbError("Windows' local app data folder was not found")
comps = missing_components(root, app)
avd = (root / "avd" / f"{AVD}.avd" / "config.ini").is_file()
try:
receipt = json.loads((root / "ready.json").read_text())
except (OSError, ValueError):
receipt = {}
apk_ok = receipt.get("runtimeHash") == runtime_hash(app)
return {"components": [c["id"] for c in comps], "download_bytes": sum(c["size"] for c in comps), "avd": avd,
"runtime_apk": apk_ok, "ready": not comps and avd and apk_ok}
def setup_files(progress=None, cancel=None, root: Path | None = None, app: Path | None = None) -> None:
"""Download + verify + unpack the pinned emulator pieces and write the AVD (no emulator start yet)."""
root = root or runtime_root()
sdk, cache_dir = root / "sdk", root / "downloads"
cache_dir.mkdir(parents=True, exist_ok=True)
write_atomic(root / "license-acceptance.json",
json.dumps({"license": "android-sdk-license", "acceptedAt": _now()}))
todo = missing_components(root, app)
total = sum(c["size"] for c in todo) or 1
done = 0
for c in todo:
if cancel:
cancel()
archive = cache_dir / f"{c['id']}.zip"
cache.download(c["url"], archive,
(lambda f, base=done, size=c["size"], name=c["name"]: progress((base + f * size) / total, name))
if progress else None,
expected_sha256=c.get("sha256"), expected_sha1=c.get("sha1"))
if cancel:
cancel()
staging = cache_dir / f"{c['id']}-extract"
shutil.rmtree(staging, ignore_errors=True)
try:
with zipfile.ZipFile(archive) as z:
z.extractall(staging)
src = staging / c["prefix"]
if not (src / c["probe"]).exists():
raise AxrbError(f"{c['name']}: expected files are missing from the download")
dest = sdk / c["destination"]
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.rmtree(dest, ignore_errors=True)
shutil.move(str(src), str(dest))
(dest / ".axrb-component.json").write_text(json.dumps({"digest": _digest(c)}))
finally:
shutil.rmtree(staging, ignore_errors=True)
archive.unlink(missing_ok=True)
done += c["size"]
avd_dir = root / "avd" / f"{AVD}.avd"
avd_dir.mkdir(parents=True, exist_ok=True)
root_win = winhost.to_windows(root)
if not (avd_dir / "config.ini").is_file():
(avd_dir / "config.ini").write_text(avd_config(root_win + r"\sdk\system-images\android-36\google_apis\x86_64"))
(root / "avd" / f"{AVD}.ini").write_text(
f"avd.ini.encoding=UTF-8\npath={root_win}\\avd\\{AVD}.avd\ntarget=android-36\n")
for d in ("android", "output"):
(root / d).mkdir(exist_ok=True)
def _now() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
# ------------------------------------------------------------------------------------------ scripts + adb
def script_env(root: Path | None = None, app: Path | None = None) -> dict[str, str]:
"""Environment every AXRB script needs when it runs without AXRB's launcher (Windows paths)."""
root_win = winhost.to_windows(root or runtime_root())
res_win = winhost.to_windows(resources(app))
return {"AXRB_DATA_HOME": root_win + r"\output", "ANDROID_AVD_HOME": root_win + r"\avd",
"ANDROID_USER_HOME": root_win + r"\android", "ANDROID_HOME": root_win + r"\sdk",
"ANDROID_SDK_ROOT": root_win + r"\sdk", "ANDROID_ADB_SERVER_PORT": ADB_PORT, "ADB_USB_LEGACY": "1",
"ADB_LOCAL_TRANSPORT_MAX_PORT": "5683", "FP_AXRB_PYTHON": res_win + r"\tools\python"}
def _ps_quote(s: str) -> str:
return "'" + str(s).replace("'", "''") + "'"
def ps_command(script: str, args: dict, root: Path | None = None, app: Path | None = None,
log_win: str | None = None) -> str:
"""A PowerShell command line that sets AXRB's environment and runs one of its scripts (its output appended to
log_win when given)."""
env = script_env(root, app)
sets = "; ".join(f"$env:{k} = {_ps_quote(v)}" for k, v in env.items() if k != "FP_AXRB_PYTHON")
path = f"$env:PATH = {_ps_quote(env['FP_AXRB_PYTHON'])} + ';' + $env:PATH; $env:ADB_SERVER_SOCKET = $null"
parts = []
for k, v in args.items():
if v is True:
parts.append(f"-{k}")
elif v not in (None, False):
parts.append(f"-{k} {_ps_quote(v)}")
call = f"& {_ps_quote(winhost.to_windows(resources(app) / script))} {' '.join(parts)}"
if log_win:
return f"$ErrorActionPreference = 'Stop'; {sets}; {path}; {logged(call, log_win)}"
return f"$ErrorActionPreference = 'Stop'; {sets}; {path}; {call}; exit $LASTEXITCODE"
EXIT_MARK = "FP_EXIT"
def logged(call: str, log_win: str) -> str:
"""A PowerShell call with all its output appended to a log, including the error that ended it (AXRB's scripts
throw their failure reason), and a final "FP_EXIT <code>" line (the caller can't see the exit code of a process
started through WMI)."""
log = _ps_quote(log_win)
return (f"try {{ {call} *>> {log}; $c = $(if ($LASTEXITCODE) {{ $LASTEXITCODE }} else {{ 0 }}) }} "
f"catch {{ ($_ | Out-String) *>> {log}; $c = 1 }}; \"{EXIT_MARK} $c\" *>> {log}; exit $c")
def exit_code(log: Path) -> int | None:
found = re.findall(rf"^{EXIT_MARK} (-?\d+)\s*$", read_log(log, 1 << 16), re.M)
return int(found[-1]) if found else None
def powershell_win() -> str:
"""powershell.exe as a Windows path."""
if winhost.is_windows():
return powershell_exe()
root = winhost.env_path("SystemRoot")
return (winhost.to_windows(root / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe") if root
else r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe")
def spawn_detached(command: str) -> int:
"""Start a hidden PowerShell running `command` through WMI (Win32_Process.Create) and return its process id.
Nothing is inherited from FramePort: the emulator and adb server an AXRB script leaves running would otherwise hold
FramePort's pipes (or WSL's interop relay, which exists even for /dev/null) open, and the call would never end."""
encoded = base64.b64encode(command.encode("utf-16-le")).decode()
cmdline = (f'"{powershell_win()}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden '
f"-EncodedCommand {encoded}")
ps = ("$si = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ShowWindow = [uint16]0}; "
"$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments "
f"@{{CommandLine = {_ps_quote(cmdline)}; CurrentDirectory = 'C:\\'; ProcessStartupInformation = $si}}; "
'"$($r.ReturnValue) $($r.ProcessId)"')
r = winhost.powershell(ps, timeout=90)
last = (r.stdout.strip().splitlines() or [""])[-1].split()
if len(last) != 2 or last[0] != "0" or not last[1].isdigit():
raise AxrbError(f"could not start PowerShell for AXRB: {(r.stdout + r.stderr).strip()[-300:]}")
return int(last[1])
def pid_running(pid: int) -> bool:
try:
r = winhost.run_win([winhost.system32("tasklist.exe"), "/FI", f"PID eq {pid}", "/NH", "/FO", "CSV"], timeout=20)
except (OSError, subprocess.TimeoutExpired):
return True # unknown: keep waiting until the deadline
return f'"{pid}"' in r.stdout
def powershell_exe() -> str:
if winhost.is_windows():
return str(Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32/WindowsPowerShell/v1.0/powershell.exe")
return winhost.system32("WindowsPowerShell/v1.0/powershell.exe")
def run_script(script: str, args: dict, log: Path, timeout: float, root: Path | None = None,
app: Path | None = None) -> int:
"""Run an AXRB script detached (spawn_detached), its output in the log file, and wait for it. Returns its exit
code (1 when it ended without saying)."""
log.parent.mkdir(parents=True, exist_ok=True)
log.unlink(missing_ok=True)
pid = spawn_detached(ps_command(script, args, root, app, winhost.to_windows(log)))
end = time.time() + timeout
while pid_running(pid):
if time.time() > end:
raise AxrbError(f"AXRB's {Path(script).name} didn't finish within {timeout / 60:.0f} minutes")
time.sleep(2)
code = exit_code(log)
return 1 if code is None else code
def adb(args: list[str], timeout: float = 60, serial: bool = True,
root: Path | None = None) -> subprocess.CompletedProcess:
exe = adb_exe(root)
if not exe:
raise AxrbError("AXRB's Android runtime is not set up")
cmd = [str(exe), "-P", ADB_PORT] + (["-s", SERIAL] if serial else []) + args
return subprocess.run(cmd, stdin=subprocess.DEVNULL, capture_output=True, text=True, errors="replace",
timeout=timeout)
def start_adb_server(root: Path | None = None) -> None:
"""Start AXRB's adb server ourselves with no inherited pipes (see run_script)."""
exe = adb_exe(root)
if exe:
subprocess.run([str(exe), "-P", ADB_PORT, "start-server"], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60)
def emulator_online(root: Path | None = None) -> bool:
try:
r = adb(["devices"], timeout=20, serial=False, root=root)
except (AxrbError, OSError, subprocess.TimeoutExpired):
return False
return bool(re.search(rf"^{SERIAL}\s+device\s*$", r.stdout, re.M))
def log_dir(root: Path | None = None) -> Path:
return (root or runtime_root()) / "output" / "logs"
def read_log(path: Path, limit: int = 1 << 20) -> str:
"""A log written on Windows: Windows PowerShell 5.1's redirection writes UTF-16 with a BOM, others UTF-8."""
try:
data = path.read_bytes()
except OSError:
return ""
if data[:2] in (b"\xff\xfe", b"\xfe\xff"):
text = data.decode("utf-16", errors="replace")
else:
text = data.decode("utf-8-sig", errors="replace")
return text[-limit:]
def start_emulator(root: Path | None = None, app: Path | None = None, timeout: float = 1200) -> bool:
"""Boot AXRB's AVD headless (AXRB's own Start action: clock adapter, GPU layer, guest checks). Returns True if
this call started it (the caller then stops it again)."""
if emulator_online(root):
return False
start_adb_server(root)
log = log_dir(root) / "frameport-emulator-start.log"
rc = run_script("scripts/emulator/windows_android_emulator.ps1",
{"Action": "Start", "Sdk": winhost.to_windows((root or runtime_root()) / "sdk"), "Avd": AVD,
"Port": PORT, "ApiLevel": 36, "Abi": "arm64-v8a", "MemoryMB": MEMORY_MB, "CpuCores": CPU_CORES,
"GuestClock": "TscCorrected", "GpuSharing": True}, log, timeout, root, app)
if rc != 0 or not emulator_online(root):
tail = read_log(log, 600)
raise AxrbError(f"Android (AXRB's emulator) did not start: {tail.strip() or f'exit {rc}'}")
return True
def stop_emulator(root: Path | None = None, wait: float = 300) -> None:
try:
adb(["shell", "sync"], timeout=60, root=root)
adb(["emu", "kill"], timeout=30, root=root)
except (AxrbError, OSError, subprocess.TimeoutExpired):
return
lock = (root or runtime_root()) / "avd" / f"{AVD}.avd" / "hardware-qemu.ini.lock"
end = time.time() + wait # shutdown writes a quick-boot snapshot: can take a while, never fatal
while time.time() < end and (emulator_online(root) or lock.exists()):
time.sleep(2)
def ensure_runtime_apk(root: Path | None = None, app: Path | None = None) -> None:
"""AXRB's OpenXR runtime inside Android (reinstalled when AXRB updated it), and AXRB's ready receipt."""
root = root or runtime_root()
want = runtime_hash(app)
try:
receipt = json.loads((root / "ready.json").read_text())
except (OSError, ValueError):
receipt = {}
have = "package:" in adb(["shell", "pm", "path", RUNTIME_PKG], root=root).stdout
if not have or receipt.get("runtimeHash") != want:
r = adb(["install", "--no-incremental", "--force-queryable", "-r",
winhost.to_windows(resources(app) / RUNTIME_APK)], timeout=240, root=root)
if "Success" not in r.stdout:
raise AxrbError(f"AXRB's runtime didn't install: {(r.stdout + r.stderr).strip()[-300:]}")
write_atomic(root / "ready.json", json.dumps({"version": 1, "runtimeHash": want, "completedAt": _now()}))
def requirements(app: Path | None = None) -> dict:
"""AXRB's own hardware check: {hypervisor, gpu, supportedGpu, memoryGB, x64} (+ ok)."""
app = app or app_dir()
if not app:
return {}
script = winhost.to_windows(resources(app) / "scripts" / "emulator" / "check_windows.ps1")
r = winhost.powershell(f"& {_ps_quote(script)}", timeout=90)
try:
info = json.loads(r.stdout.strip().splitlines()[-1])
except (ValueError, IndexError):
return {"error": (r.stdout + r.stderr).strip()[-300:]}
info["ok"] = bool(info.get("hypervisor") and info.get("supportedGpu") and info.get("x64")
and (info.get("memoryGB") or 0) >= MIN_MEMORY_GB)
return info
def requirement_problems(info: dict) -> list[str]:
out = []
if info.get("error"):
return [f"AXRB's hardware check failed: {info['error']}"]
if not info.get("x64"):
out.append("needs a 64-bit (x64) Windows PC")
if (info.get("memoryGB") or 0) < MIN_MEMORY_GB:
out.append(f"needs at least {MIN_MEMORY_GB} GB of memory ({info.get('memoryGB')} GB found)")
if not info.get("supportedGpu"):
out.append(f"needs an NVIDIA or AMD graphics card ({info.get('gpu') or 'none found'})")
if not info.get("hypervisor"):
out.append("needs Windows Hypervisor Platform: turn it on in 'Turn Windows features on or off', then restart")
return out
def status() -> dict:
"""For `frameport pc info` / Settings: what is installed and set up (no downloads, no emulator start)."""
app = app_dir()
out = {"installed": bool(app), "version": installed_version(app), "app": str(app) if app else None,
"runtime": str(runtime_root()) if runtime_root() else None}
if app:
try:
out["setup"] = setup_state(app=app)
except (AxrbError, OSError, KeyError, ValueError) as exc:
out["setup"] = {"error": str(exc)}
return out
# ------------------------------------------------------------------------------------------ the Play launcher
LAUNCHER = r"""param([Parameter(Mandatory)][string]$Package, [Parameter(Mandatory)][string]$Activity,
[string]$GameName = $Package, [switch]$CaptureGuestLog)
# Written by FramePort: starts a Quest game installed on this PC through AXRB's own run script, with AXRB's managed
# Android runtime (what AXRB's launcher passes it). AXRB boots the emulator when needed and shuts it down afterwards.
$ErrorActionPreference = 'Stop'
@ENV@
$env:ADB_SERVER_SOCKET = $null
$env:PATH = (Join-Path @RES@ 'tools\python') + ';' + $env:PATH
$run = Join-Path @RES@ 'scripts\run\run_windows_game.ps1'
& $run -Avd '@AVD@' -Port @PORT@ -Sdk $env:ANDROID_HOME -MemoryMB @MEM@ -CpuCores @CORES@ -TextureMode gpu `
-Package $Package -Activity $Activity -GameName $GameName -GuestClock Auto -CaptureGuestLog:$CaptureGuestLog `
-RuntimeApk (Join-Path @RES@ '@APK@')
exit $LASTEXITCODE
"""
def launcher_text(root: Path | None = None, app: Path | None = None) -> str:
env = script_env(root, app)
sets = "\n".join(f"$env:{k} = {_ps_quote(v)}" for k, v in env.items() if k != "FP_AXRB_PYTHON")
return (LAUNCHER.replace("@ENV@", sets).replace("@RES@", _ps_quote(winhost.to_windows(resources(app))))
.replace("@AVD@", AVD).replace("@PORT@", str(PORT)).replace("@MEM@", str(MEMORY_MB))
.replace("@CORES@", str(CPU_CORES)).replace("@APK@", RUNTIME_APK.replace("/", "\\")))
def write_launcher(root: Path | None = None, app: Path | None = None) -> Path:
"""FramePort's launcher script in %LOCALAPPDATA%\\FramePort\\axrb (Steam shortcuts point at it). Local path."""
local = local_appdata()
if not local:
raise AxrbError("Windows' local app data folder was not found")
path = local / "FramePort" / "axrb" / LAUNCHER_NAME
path.parent.mkdir(parents=True, exist_ok=True)
text = launcher_text(root, app)
if not path.exists() or path.read_text(encoding="utf-8-sig", errors="replace") != text:
path.write_text(text, encoding="utf-8-sig") # BOM: Windows PowerShell 5.1 reads BOM-less files as ANSI
return path
def launcher_args(launcher_win: str, package: str, activity: str, title: str) -> str:
"""Steam shortcut launch options for powershell.exe."""
name = re.sub(r'["`$]', "", title).strip() or package
return (f'-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "{launcher_win}" '
f'-Package {package} -Activity {activity} -GameName "{name}"')
+49 -20
View File
@@ -635,10 +635,11 @@ class FramePortApp:
frame_opt = (frame_label, ft.Icons.VIEW_IN_AR_ROUNDED, lambda e: self.install(pkg, "frame"), False, None) \
if connected else (tr("Connect your Frame"), ft.Icons.LINK_ROUNDED, lambda e: self.go("frame"), False,
tr("Set up the connection to your Steam Frame first"))
if g.get("kind") != "rift":
if not pipeline.pc_installable(g):
return [frame_opt]
from ..core import winhost
rift = g.get("kind") == "rift"
on_pc = pkg in self.pc_installs()
stale = on_pc and C.pc_outdated(g, self.pc_installs()[pkg])
pc_label = (tr("Update on this PC") if stale else tr("Reinstall on this PC") if on_pc
@@ -646,8 +647,11 @@ class FramePortApp:
pc_opt = (pc_label,
ft.Icons.COMPUTER_ROUNDED, lambda e: self.install(pkg, "pc"), not winhost.available(),
tr("The launch settings changed since it was installed: update the Steam shortcut") if stale else
None if winhost.available() else tr("Needs Windows (or WSL on Windows)"))
return [frame_opt, pc_opt] if connected or not winhost.available() else [pc_opt, frame_opt]
tr("Needs Windows (or WSL on Windows)") if not winhost.available() else
None if rift else C.HELP["pc_android"])
# Quest games: the Frame stays the main place (the PC path through AXRB is experimental)
pc_first = not connected and winhost.available() and (rift or on_pc)
return [pc_opt, frame_opt] if pc_first else [frame_opt, pc_opt]
def play_options(self, g: dict) -> list[tuple]:
"""[(label, icon, on_click, disabled, tooltip)] for where the game is installed and can be started now."""
@@ -656,10 +660,11 @@ class FramePortApp:
if self.frame_state == "connected" and C.install_state(g, self.frame_info) in ("installed", "outdated"):
out.append((tr("Play on Frame"), ft.Icons.PLAY_ARROW_ROUNDED, lambda e: self.play(pkg, "frame"), False,
tr("Starts the game through the Frame's Steam — put the headset on")))
if g.get("kind") == "rift" and pkg in self.pc_installs() and \
if pipeline.pc_installable(g) and pkg in self.pc_installs() and \
not ((g.get("analysis") or {}).get("extra") or {}).get("flat"):
out.append((tr("Play on this PC"), ft.Icons.PLAY_ARROW_ROUNDED, lambda e: self.play(pkg, "pc"), False,
tr("Starts the game through Steam on this PC (SteamVR + Revive)")))
tr("Starts the game through Steam on this PC (SteamVR + Revive)") if g.get("kind") == "rift"
else tr("Starts the game through Steam on this PC (SteamVR + AXRB's Android)")))
return out
PLAY_COOLDOWN = 20 # s: a second Play while Steam/Lepton still start the game only gets Steam's AppError_16
@@ -779,7 +784,7 @@ class FramePortApp:
self.play_options(g) + self.install_options(g) if handler and not disabled]
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()
on_pc = pipeline.pc_installable(g) and pkg in self.pc_installs()
if on_frame:
out.append((tr("Launch test on Frame"), ft.Icons.SCIENCE_OUTLINED,
lambda e: self.test_game(pkg, "frame")))
@@ -896,7 +901,7 @@ class FramePortApp:
games = [library.game(p) for p in pkgs]
games = [g for g in games if g and not self.jobs.busy_with(g["package"])]
skipped = [g for g in games if to == "pc" and g.get("kind") != "rift" or
skipped = [g for g in games if to == "pc" and not pipeline.pc_installable(g) or
not allow_blocked and g.get("kind") != "rift"
and (g.get("recipe") or {}).get("status") == "unsupported"]
games = [g for g in games if g not in skipped]
@@ -913,10 +918,40 @@ class FramePortApp:
else:
ask_frame_oculus()
def ask_axrb():
# Quest games on this PC: AXRB (+ its Android) is downloaded on first use only, after asking
quest = [g for g in games if g.get("kind") != "rift"]
if not quest:
return ask_obb()
from ..tools import axrb
try:
ready = bool(axrb.app_dir()) and axrb.setup_state()["ready"]
except Exception: # noqa: BLE001 - unknown: ask
ready = False
if ready:
return ask_obb()
pick = C.one_choice()
def ok(e):
self.page.pop_dialog()
ask_obb()
self.page.show_dialog(ft.AlertDialog(
title=ft.Text(tr("Set up Quest games on this PC?"), weight=ft.FontWeight.W_600),
content=ft.Container(C.body(
C.HELP["pc_android"] + "\n\n" +
tr("FramePort installs AXRB from its official release (per user, no administrator rights) and "
"Google's Android emulator files. AXRB is a separate program with its own licence; "
"you can remove it later from Windows' app list.")), width=T.px(520)),
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
modal=True, on_dismiss=pick(closed),
actions=[C.ghost(tr("Cancel"), on_click=pick(cancel)),
C.primary(tr("Download and install"), on_click=pick(ok))]))
def ask_frame_oculus():
# Installing an Oculus/LibOVR Rift game on the Frame: warn that it needs Revive (which can't run there)
if to != "frame":
return ask_obb()
return ask_axrb()
oculus = [g for g in games if g.get("kind") == "rift"
and "pcvr.revive" in (g.get("recipe") or {}).get("patches", {})]
if not oculus:
@@ -1164,7 +1199,7 @@ class FramePortApp:
self._record(pkg, to=to, state="running")
as_is = library.recipe_from_dict(library.game(pkg)["recipe"]).as_is
rep.stage("Checking the game" if rift or as_is else "Patching the game")
info = pipeline.build_game(pkg, rep)
info = pipeline.build_game(pkg, rep, pc=to == "pc")
if not info["ok"]:
raise RuntimeError(tr("the game didn't pass its checks (see the list above)"))
rep.check_cancel()
@@ -1198,7 +1233,7 @@ class FramePortApp:
"""[(package, "frame" | "pc")] installs with an update ready (a newer build or changed patch settings)."""
out = []
frame_ok = self.frame_state == "connected"
pc = self.pc_installs() if any(g.get("kind") == "rift" for g in library.games()) else {}
pc = self.pc_installs()
for g in library.games():
pkg = g["package"]
if self.jobs.busy_with(pkg):
@@ -1341,21 +1376,15 @@ class FramePortApp:
def uninstall(self, pkg: str, to: str = "frame") -> None:
title = self._title(pkg)
text = tr("Removes {title}'s game files from the Frame. Saves and the game's own files are kept unless you "
"tick the box below; the Steam entry disappears after the next Steam restart.").format(
title=title) if to == "frame" else \
text = (tr("Removes {title}'s game files from the Frame. Saves are kept; the Steam entry disappears "
"after the next Steam restart.").format(title=title)) if to == "frame" else \
tr("Removes {title} from this PC's Steam library (Steam restarts once). "
"The game folder isn't touched.").format(title=title)
delete_local, extra = self._delete_local_option(pkg)
delete_data = None
if to == "frame": # GitHub #130: what the game stored (saves, mods, downloads) used to survive every uninstall
delete_data = ft.Checkbox(label=tr("Also delete its saves and everything it stored on the Frame (mods, "
"downloaded content)"), value=False, active_color=T.ERROR)
extra = delete_data if extra is None else ft.Column([delete_data, extra], spacing=T.px(8), tight=True)
def run(job: Job):
self._target_for(to).uninstall(pkg, keep_data=not (delete_data and delete_data.value))
self._target_for(to).uninstall(pkg, keep_data=True)
self._pc_cache = None
if delete_local and delete_local.value:
done, freed = pipeline.delete_local_files(pkg)
@@ -1427,7 +1456,7 @@ class FramePortApp:
def _diag_target(self, pkg: str | None):
g = library.game(pkg) if pkg else None
if g and g.get("kind") == "rift" and pkg in self.pc_installs() and \
if g and pipeline.pc_installable(g) and pkg in self.pc_installs() and \
C.install_state(g, self.frame_info) not in ("installed", "outdated"):
return self._target_for("pc")
return self.target if self.frame_state == "connected" else None
+6 -1
View File
@@ -131,9 +131,14 @@ def recipe_changed(game: dict) -> bool:
def pc_outdated(g: dict, dep: dict) -> bool:
"""A PC install whose launch settings (pcvr patches + their parameters) differ from the game's recipe now."""
"""A PC install whose launch settings (pcvr patches + their parameters) differ from the game's recipe now. Quest
games (AXRB): the patches of their build for this PC."""
installed = set((dep.get("recipe") or {}).get("patches") or [])
want = set((g.get("recipe") or {}).get("patches") or {})
if dep.get("kind") == "android":
from ..patches.base import pc_selection
return installed != set(pc_selection({p: {} for p in want}))
from ..core.library import recipe_from_dict
from ..patches.pcvr import game_args
+4 -5
View File
@@ -62,11 +62,6 @@ HELP: dict[str, str] = _Translated({
"anyway.",
"where": "Steam Frame: installed on the headset with an entry in its Steam library. This PC: a Steam shortcut on "
"this Windows PC that starts the game through Revive, for a PC-tethered headset.",
"hw_video_decode": "Games with the patch \"Hardware video decoding\" (e.g. video players, Batman: Arkham Shadow) "
"decode their videos on the Frame's video hardware instead of Android's slower software "
"decoders. Switch it off if videos misbehave in such a game; it then applies to every game the "
"next time it starts. One game only: put FRAMEPORT_NO_HW_VIDEO=1 %command% in its Steam "
"launch options.",
"launch_test": "Starts the game on the Frame while nobody is wearing it and reads the log: did it start, create a "
"VR session and render frames. It can't check what you'd see: tracking only runs with the "
"headset on.",
@@ -153,6 +148,10 @@ HELP: dict[str, str] = _Translated({
"next time it starts.",
"frame_summary": "Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR "
"games can run on the Frame.",
"pc_android": "Experimental: runs the game on this PC in AXRB (Android XR Bridge), an Android emulator that shows "
"Android VR games through SteamVR. The first install downloads AXRB and its Android (about 2.5 GB) "
"and needs Windows Hypervisor Platform, an NVIDIA or AMD graphics card and 12 GB of memory. Many "
"games don't work in AXRB yet.",
# ---- settings
"data_folder": "FramePort's tools, library, artwork and the signing keys of your rebuilt games. Back it up: an "
"update must be signed with the same key, or the game (and its saves) has to be reinstalled.",
+3 -4
View File
@@ -11,7 +11,7 @@ from ... import pipeline
from ...core import library
from ...i18n import tr
from ...patches import base
from ...patches.settings import BASE_SETTINGS, GROUPS, HIDDEN, SETTINGS, UI, adapter_settings
from ...patches.settings import BASE_SETTINGS, GROUPS, SETTINGS, UI, adapter_settings
from .. import components as C
from .. import theme as T
@@ -19,9 +19,8 @@ if TYPE_CHECKING:
from ..app import FramePortApp
# the dialog's settings: numbers and switches (text settings such as vk_shader_fix are recipe data: saving the dialog
# keeps them as they are, as it keeps the HIDDEN ones)
SPECS = {key: (kind, default) for key, kind, default, _, _ in SETTINGS
if kind in ("int", "float") and key not in HIDDEN}
# keeps them as they are)
SPECS = {key: (kind, default) for key, kind, default, _, _ in SETTINGS if kind in ("int", "float")}
def default(key: str):
+13 -1
View File
@@ -90,7 +90,7 @@ class GameView:
st = C.install_state(g, app.frame_info)
if st in ("installed", "outdated"):
chips.append(C.install_badge(st))
if self.rift and pkg in app.pc_installs():
if pkg in app.pc_installs():
chips.append(C.install_badge("on_pc"))
actions = self.actions()
return ft.Container(
@@ -203,6 +203,18 @@ class GameView:
lambda e: app.test_game(pkg, "pc")),
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Remove from this PC's Steam library"),
lambda e: app.uninstall(pkg, "pc"))] if dep else []))
elif app.pc_installs().get(pkg): # a Quest game also installed on this PC (AXRB, experimental)
dep = app.pc_installs()[pkg]
stale = C.pc_outdated(g, dep)
cards.append(self.target_card(
ft.Icons.COMPUTER_ROUNDED, tr("This PC (AXRB, experimental)"),
tr("In your Steam library · patches changed — update it") if stale else tr("In your Steam library"),
T.WARN if stale else T.PC, tr("AXRB {value} · Android emulator").format(
value=dep.get("axrb_version") or ""),
[C.icon_btn(ft.Icons.SCIENCE_OUTLINED, tr("Launch test on this PC"),
lambda e: app.test_game(pkg, "pc")),
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Remove from this PC"),
lambda e: app.uninstall(pkg, "pc"))]))
return C.section(tr("Where it's installed"), ft.Row(cards, spacing=T.S3), help="where")
def target_card(self, icon, name, line, color, sub, buttons) -> ft.Control:
+1 -1
View File
@@ -480,7 +480,7 @@ class LibraryView:
has_rift = any(g.get("kind") == "rift" for g in games)
platforms = platform_filter_options(games)
where = [("all", tr("All")), ("frame", tr("On Frame")), ("none", tr("Not installed"))]
if has_rift:
if has_rift or n_pc: # Quest games can be on this PC too (AXRB)
where.insert(2, ("pc", tr("On this PC")))
# the filters wrap onto a second line in a narrow window; count and sort stay on the right
left = ft.Row([
+2 -14
View File
@@ -82,12 +82,6 @@ class SettingsView:
def keep_changed(e):
library.set_setting("build.keep_copies", bool(e.control.value))
def video_changed(e):
library.set_setting("video.hw_decode", bool(e.control.value))
target = self.app.target if self.app.frame_state == "connected" else None
if target is not None: # else applied at the next connection
self.app.run_bg(target.frame.apply_video_decoding)
def clean(e):
def work():
freed = pipeline.remove_all_converted_copies()
@@ -99,14 +93,8 @@ class SettingsView:
launch = C.switch(tr("Launch test after installing on the Frame (starts the game once without the headset "
"and checks its log)"), value=bool(library.setting("install.launch_test", True)),
on_change=changed)
video = ft.Row([ft.Container(C.switch(tr("Hardware video decoding on the Frame (for games set up for "
"it: videos play on the Frame's video hardware)"),
value=bool(library.setting("video.hw_decode", True)),
on_change=video_changed), expand=True),
C.help_icon("hw_video_decode")], spacing=T.S1)
return ft.Column([launch, keep, video,
C.ghost(tr("Remove converted copies now"), ft.Icons.CLEANING_SERVICES_ROUNDED, clean,
tooltip=tr("Your own game files aren't touched."))],
return ft.Column([launch, keep, C.ghost(tr("Remove converted copies now"), ft.Icons.CLEANING_SERVICES_ROUNDED,
clean, tooltip=tr("Your own game files aren't touched."))],
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
def updates_card(self) -> ft.Control:
+4 -3
View File
@@ -122,9 +122,10 @@ def grid_files(grid: Path, appid) -> list[Path]:
def remove_pc_shortcuts(reporter: Reporter) -> None:
"""All PC VR shortcuts FramePort added to Windows Steam, with one Steam restart."""
"""All PC shortcuts (PC VR and Quest games) FramePort added to Windows Steam, with one Steam restart. AXRB and
the games inside its Android stay: AXRB is the user's program (uninstall it from Windows' app list)."""
from .core import winhost
from .targets.pc_revive import _vdf, local_installs, shortcut_fields
from .targets.pc_revive import _vdf, local_installs, options_key, shortcut_fields
deps = local_installs()
if not deps or not winhost.available():
@@ -140,7 +141,7 @@ def remove_pc_shortcuts(reporter: Reporter) -> None:
try:
for pkg, dep in deps.items():
exe = shortcut_fields(dep)[0]
removed = vdf_mod.remove_shortcut(str(cfg / "shortcuts.vdf"), exe)
removed = vdf_mod.remove_shortcut(str(cfg / "shortcuts.vdf"), exe, options_key(dep))
for art in grid_files(cfg / "grid", dep.get("appid")):
art.unlink(missing_ok=True)
reporter.check(f"Steam shortcut: {dep.get('title') or pkg}", True if removed else None,
+6 -5
View File
@@ -82,8 +82,7 @@ def game_lines(log: str, package: str | None = None) -> list[str]:
# "Boot complete!", which the container-not-started signature checks for: it used to be dropped here)
if not (len(f) > 3 and LOGCAT_LINE.match(ln) and f[2].isdigit()):
out.append(ln)
elif f[2] in pids or "lepton" in ln.lower() or "APP_ACTIVITY" in ln or " FramePortVideo" in ln:
# FramePortVideo: the hardware codec plugin logs from Android's media service, not the game's process
elif f[2] in pids or "lepton" in ln.lower() or "APP_ACTIVITY" in ln:
out.append(ln)
return out
@@ -95,11 +94,13 @@ def split_suggestion(s: str) -> tuple[str, str | None]:
def triage(log: str, state: str = "UNKNOWN", package: str | None = None, crash: str = "",
kernel: str = "") -> TriageResult:
kernel: str = "", kind: str | None = None) -> TriageResult:
"""`crash` = the container's crash logcat (tombstones come from crash_dump's pid, so it isn't pid-filtered);
`kernel` = kernel log lines of a play session ("kernel: …", GPU hangs; agent session_log)."""
`kernel` = kernel log lines of a play session ("kernel: …", GPU hangs; agent session_log). `kind`: which
signatures apply ("quest" Lepton logcat, "pcvr" Proton/Revive, "axrb" a Quest game on this PC); default from
the package."""
db = database()
kind = "pcvr" if package and package.startswith("rift.") else "quest" # Proton/Revive logs vs Lepton logcat
kind = kind or ("pcvr" if package and package.startswith("rift.") else "quest")
lines = game_lines(log, package) if kind == "quest" else [ANSI.sub("", ln) for ln in log.splitlines()]
lines += [ANSI.sub("", ln) for ln in crash.splitlines()]
lines += [ANSI.sub("", ln) for ln in kernel.splitlines()]
-76
View File
@@ -1,76 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
// Test-only fault injection: deny Iris opens AFTER plugin enumeration, to
// exercise software fallback without altering the host's decoder or devices.
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <stdarg.h>
#include <string.h>
#include <sys/ioctl.h>
#include <linux/videodev2.h>
static int denied;
static int corrupt_after=-1;
static int deny_restart;
void frameport_probe_deny_device(void) { denied=1; }
void frameport_probe_corrupt_picture(void) { corrupt_after=30; }
void frameport_probe_deny_restart(void) { deny_restart=1; }
int ioctl(int fd,int request,...) {
static int (*original)(int,int,...);
if(!original) *(void**)&original=dlsym(RTLD_NEXT,"ioctl");
va_list args;va_start(args,request);void *data=va_arg(args,void*);va_end(args);
if(deny_restart && (unsigned)request==VIDIOC_DECODER_CMD &&
((struct v4l2_decoder_cmd*)data)->cmd==V4L2_DEC_CMD_START){errno=EIO;return -1;}
int result=original(fd,request,data);
if(result==0 && (unsigned)request==VIDIOC_DQBUF && corrupt_after>=0){
struct v4l2_buffer *buffer=data;
if(buffer->type==V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE && buffer->m.planes[0].bytesused){
if(!corrupt_after--){buffer->flags|=V4L2_BUF_FLAG_ERROR;corrupt_after=-1;}
}
}
return result;
}
int open(const char *path,int flags,...) {
static int (*original)(const char*,int,...);
if(!original) *(void**)&original=dlsym(RTLD_NEXT,"open");
if(denied && !strncmp(path,"/dev/video",10)){errno=ENODEV;return -1;}
mode_t mode=0;
if(flags&O_CREAT){va_list args;va_start(args,flags);mode=va_arg(args,int);va_end(args);}
return original(path,flags,mode);
}
int open64(const char *path,int flags,...) {
mode_t mode=0;
if(flags&O_CREAT){va_list args;va_start(args,flags);mode=va_arg(args,int);va_end(args);}
return open(path,flags,mode);
}
#include <vulkan/vulkan.h>
static unsigned gpu_submissions;
static int deny_gpu;
static int deny_gpu_import;
static void *vulkan_symbol(const char *name) {
// The plugin is RTLD_LOCAL in the OMX probe, so RTLD_NEXT cannot see its
// Vulkan dependency. Resolve the actual library, not this preload shim.
static void *library;
if(!library)library=dlopen("libvulkan.so",RTLD_NOW|RTLD_LOCAL);
return library?dlsym(library,name):0;
}
void frameport_probe_deny_gpu(void) { deny_gpu=1; }
void frameport_probe_deny_gpu_import(void) { deny_gpu_import=1; }
unsigned frameport_probe_gpu_submissions(void) { return __atomic_load_n(&gpu_submissions,__ATOMIC_RELAXED); }
VKAPI_ATTR VkResult VKAPI_CALL vkCreateInstance(const VkInstanceCreateInfo *info,const VkAllocationCallbacks *allocator,VkInstance *instance) {
if(deny_gpu)return VK_ERROR_FEATURE_NOT_PRESENT;
PFN_vkCreateInstance real=(PFN_vkCreateInstance)vulkan_symbol("vkCreateInstance");
if(!real)return VK_ERROR_INITIALIZATION_FAILED;
return real(info,allocator,instance);
}
VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit(VkQueue queue,uint32_t count,const VkSubmitInfo *submits,VkFence fence) {
PFN_vkQueueSubmit real=(PFN_vkQueueSubmit)vulkan_symbol("vkQueueSubmit");
if(!real)return VK_ERROR_INITIALIZATION_FAILED;
VkResult result=real(queue,count,submits,fence);
if(result==VK_SUCCESS)__atomic_add_fetch(&gpu_submissions,1,__ATOMIC_RELAXED);
return result;
}
VKAPI_ATTR VkResult VKAPI_CALL vkAllocateMemory(VkDevice device,const VkMemoryAllocateInfo *info,const VkAllocationCallbacks *allocator,VkDeviceMemory *memory) {
if(deny_gpu_import)return VK_ERROR_OUT_OF_DEVICE_MEMORY;
PFN_vkAllocateMemory real=(PFN_vkAllocateMemory)vulkan_symbol("vkAllocateMemory");
return real?real(device,info,allocator,memory):VK_ERROR_INITIALIZATION_FAILED;
}
+1 -14
View File
@@ -438,19 +438,6 @@ def test_uninstall_quest_keeping_saves_drops_the_install_record(monkeypatch, tmp
assert (anchor / "lepton-data/saves/slot1").read_text() == "progress" # saves kept
def test_uninstall_quest_deleting_data_removes_saves_and_mods(monkeypatch, tmp_path):
"""GitHub #130: the Uninstall dialog's "Also delete its saves…" box (keep_data False) removes lepton-data too."""
a = load_agent(monkeypatch, tmp_path)
anchor = tmp_path / "Applications/quest-frame/com.x.y"
(anchor / "lepton-app").mkdir(parents=True)
(anchor / "lepton-data/external/ModData/songs").mkdir(parents=True)
(anchor / "lepton-data/external/ModData/songs/old.zip").write_bytes(b"x")
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "base": str(anchor), "appid": 1}))
monkeypatch.setattr(a, "container_running", lambda appid: False)
assert a.cmd_uninstall({"package": "com.x.y", "keep_data": False})["removed"]
assert not (anchor / "lepton-data").exists()
def test_prune_shortcuts_on_relaunch_change(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
vdf = str(tmp_path / "shortcuts.vdf")
@@ -1429,7 +1416,7 @@ def test_second_launch_while_starting_is_ignored(monkeypatch, tmp_path):
lepton.chmod(0o755)
text = a.LAUNCH_SH.format(title="T", pkg="com.x.y", base_q=str(base), appid=1, lepton_q=str(lepton), extra_env="",
watchdog=a.WATCHDOG, dashboard="true", logcat="true", single=a.SINGLE_LINE,
plays_start="true", plays_end="true", video_codec="")
plays_start="true", plays_end="true")
launcher = tmp_path / "launch.sh"
launcher.write_text(text)
launcher.chmod(0o755)
-9
View File
@@ -51,12 +51,3 @@ def test_package_script_checks_the_bundle(tmp_path):
package.check_bundle(tmp_path)
(data / "frameport_agent.py.txt").write_text("x")
package.check_bundle(tmp_path)
def test_pc_shortcut_code_loads_without_fcntl(monkeypatch):
"""GitHub #131: on Windows "Install on this PC" loads the agent for its VDF code; its `import fcntl` (POSIX only)
made every PC install fail."""
from frameport.targets import pc_revive
monkeypatch.setitem(sys.modules, "fcntl", None) # import fails as on Windows
assert callable(pc_revive._vdf().vdf_decode)
+50 -108
View File
@@ -3,6 +3,9 @@ import hashlib
import importlib.util
import json
import os
import sys
import types
import zipfile
from pathlib import Path
import pytest
@@ -36,44 +39,66 @@ def test_mounts_only_matching_game_and_runtime(tmp_path, monkeypatch):
xml.write_text('<MediaCodecs><Include href="stock.xml" /></MediaCodecs>')
directory = tmp_path / "game/frameport-codec"
directory.mkdir(parents=True)
for name in ("libstagefrighthw.so", "media_codecs_frameport.xml"):
(directory / name).write_bytes(b"codec asset")
config = {"lepton": str(tmp_path / "lepton/lepton"), "appid": "123",
"runtime_sha256": hashlib.sha256(b"runtime ABI").hexdigest()}
exists = Path.exists
monkeypatch.setattr(Path, "exists", lambda p: True if p.as_posix() == "/dev/video-dec0" else exists(p))
(tmp_path / "run").mkdir()
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path / "run"))
args = ["run", "--name", "lepton-steamlaunch-123", "--rootfs", str(runtime) + ":O", "/init"]
extra = wrapper.mounts(directory, config, args)
assert [s.replace("\\", "/") for s in extra[:2]] == [
"--mount", "type=bind,source=/dev/video-dec0,destination=/dev/video-dec0,rw"]
# the merged codec list goes to the runtime dir: the verified version directory is never written
merged = next((tmp_path / "run/frameport-video").glob("media_codecs.*.xml"))
assert "media_codecs_frameport.xml" in merged.read_text()
assert sorted(p.name for p in directory.iterdir()) == ["libstagefrighthw.so", "media_codecs_frameport.xml"]
for spec in extra[1::2][1:]:
fields = dict(item.split("=", 1) for item in spec.split(",") if "=" in item)
assert Path(fields["source"]).is_file()
assert "media_codecs_frameport.xml" in (directory / "media_codecs.xml").read_text()
assert "frameport" not in xml.read_text() # shared runtime stays unchanged
assert wrapper.mounts(directory, config, ["kill", "lepton-steamlaunch-123"]) == []
assert wrapper.mounts(directory, config, [s.replace("123", "456") for s in args]) == []
config["runtime_sha256"] = "wrong ABI"
assert wrapper.mounts(directory, config, args) == []
# The shared wrapper serves any package with an unchanged APK: the launcher (frame.hw_video_decode in the game's
# recipe) puts it on PATH, the container identity limits it to that game's container.
app = tmp_path / "another-package/lepton-app"
app.mkdir(parents=True)
monkeypatch.setenv("SteamAppId", "123")
monkeypatch.setenv("STEAM_COMPAT_INSTALL_PATH", str(app))
shared = {"scope": "shared", "runtime_sha256": hashlib.sha256(b"runtime ABI").hexdigest()}
assert wrapper.mounts(directory, shared, args)
assert wrapper.mounts(directory, shared, [s.replace("123", "456") for s in args]) == []
assert wrapper.mounts(directory, shared, [s.replace(":O", "") for s in args]) == []
assert wrapper.mounts(directory, shared, ["kill", "lepton-steamlaunch-123"]) == []
monkeypatch.delenv("SteamAppId")
assert wrapper.mounts(directory, shared, args) == []
def test_agent_extracts_only_verified_assets_and_removes_old_wrapper(tmp_path, monkeypatch):
# The agent targets Linux; this test only exercises stdlib ZIP/file work.
if sys.platform == "win32":
monkeypatch.setitem(sys.modules, "fcntl", types.SimpleNamespace())
agent = load_module(ROOT / "agent/frameport_agent.py", "codec_agent")
monkeypatch.setattr(agent.shutil, "which", lambda _: "/usr/bin/podman")
base = tmp_path / "game"
(base / "lepton-app/obb").mkdir(parents=True)
video = base / "lepton-app/obb/movie.mp4"
video.write_bytes(b"original large asset")
apk = base / "lepton-app/game.apk"
assets = ROOT / "artifacts/hevc"
with zipfile.ZipFile(apk, "w") as z:
for name in ("manifest.json", "podman.py", "libstagefrighthw.so", "media_codecs_frameport.xml",
"COPYING.FFmpeg"):
z.write(assets / (name + ".txt" if name == "podman.py" else name), "assets/frameport/hevc/" + name)
replace = agent.os.replace
published = []
def publish(source, target):
target = Path(target)
if target.name == "podman":
config = json.loads((base / "frameport-codec/deployment.json").read_text())
assert config["podman"] == "/usr/bin/podman"
assert all((base / "frameport-codec" / n).is_file()
for n in ("libstagefrighthw.so", "media_codecs_frameport.xml", "COPYING.FFmpeg"))
published.append(target.name)
replace(source, target)
monkeypatch.setattr(agent.os, "replace", publish)
assert agent.install_video_codec(str(base), "/lepton/lepton", 123)
assert published[0] == "deployment.json" and published[-1] == "podman"
wrapper = base / "frameport-codec/bin/podman"
assert wrapper.read_bytes() == (assets / "podman.py.txt").read_bytes()
assert video.read_bytes() == b"original large asset"
with zipfile.ZipFile(apk, "w") as z:
z.writestr("AndroidManifest.xml", b"old APK")
assert not agent.install_video_codec(str(base), "/lepton/lepton", 123)
assert not wrapper.exists()
with zipfile.ZipFile(apk, "w") as z:
z.write(assets / "manifest.json", "assets/frameport/hevc/manifest.json")
z.writestr("assets/frameport/hevc/libstagefrighthw.so", b"corrupt")
with pytest.raises(agent.AgentError, match="checksum mismatch"):
agent.install_video_codec(str(base), "/lepton/lepton", 123)
@pytest.mark.parametrize("configuration", [None, "{", "[]", "{}", '{"podman":null}',
@@ -158,89 +183,6 @@ def test_wrapper_finds_podman_under_the_android_path(tmp_path, monkeypatch):
wrapper = load_module(ROOT / "native/hevc/podman.py", "android_path_wrapper")
monkeypatch.setattr(wrapper, "__file__", str(tmp_path / "codec/bin/podman"))
monkeypatch.setenv("PATH", "/product/bin:/system/bin:/vendor/bin")
monkeypatch.setattr(
wrapper.shutil, "which",
lambda _, path: path + "/podman" if Path(path).as_posix().endswith("/usr/bin") else None,
)
monkeypatch.setattr(wrapper.shutil, "which", lambda _, path: path + "/podman" if path.endswith("usr/bin") else None)
monkeypatch.setattr(wrapper.Path, "resolve", lambda self: self)
assert Path(wrapper.real_podman(tmp_path / "codec")).as_posix().endswith("/usr/bin/podman")
def test_codec_list_matches_the_plugin_table():
"""H.264, HEVC and VP9: the codec XML names exactly the components the plugin's table enumerates."""
from xml.etree import ElementTree as ET
xml = ET.parse(ROOT / "artifacts/hevc/media_codecs_frameport.xml").getroot()
entries = {c.get("name"): c.get("type") for c in xml.iter("MediaCodec")}
assert entries == {"OMX.frameport.avc.decoder": "video/avc", "OMX.frameport.hevc.decoder": "video/hevc",
"OMX.frameport.vp9.decoder": "video/x-vnd.on2.vp9"}
for codec in xml.iter("MediaCodec"):
limits = {limit.get("name"): limit for limit in codec.iter("Limit")}
# VP9 at 7680x3840 never returned a picture on the Frame (2026-10-09): it stops at 4K
expected = "4096x2304" if "vp9" in codec.get("name") else "8192x8192"
assert limits["concurrent-instances"].get("max") == "1" and limits["size"].get("max") == expected
source = (ROOT / "native/hevc/frameport_hevc.cpp").read_text()
for name, mime in entries.items():
assert f'"{name}"' in source and f'"{mime}"' in source
assert "4096,2304}" in source # the plugin's own VP9 limit (larger VP9 decodes in software)
build = (ROOT / "native/hevc/build.py").read_text()
for decoder in ("h264_v4l2m2m", "hevc_v4l2m2m", "vp9_v4l2m2m"):
assert f'"{decoder}"' in source and decoder in build
assert (ROOT / "artifacts/hevc/media_codecs_frameport.xml").read_bytes() == (
ROOT / "native/hevc/media_codecs_frameport.xml").read_bytes()
@pytest.mark.parametrize("how", ["env", "flag"])
def test_switched_off_wrapper_runs_stock_podman_untouched(tmp_path, monkeypatch, how):
"""FRAMEPORT_NO_HW_VIDEO=1 or FramePort's setting (video-codec/disabled): the wrapper adds nothing."""
wrapper = load_module(ROOT / "native/hevc/podman.py", f"off_wrapper_{how}")
directory = tmp_path / "video-codec/versions/abc"
own = directory / "bin/podman"
own.parent.mkdir(parents=True)
(directory / "deployment.json").write_text('{"scope": "shared", "runtime_sha256": "x"}')
monkeypatch.setattr(wrapper, "__file__", str(own))
monkeypatch.setattr(wrapper, "real_podman", lambda _: "/usr/bin/podman")
monkeypatch.delenv("FRAMEPORT_NO_HW_VIDEO", raising=False)
if how == "env":
monkeypatch.setenv("FRAMEPORT_NO_HW_VIDEO", "1")
else:
(tmp_path / "video-codec/disabled").write_text("off")
monkeypatch.setattr(wrapper, "mounts", lambda *_: pytest.fail("mounts while switched off"))
args = ["run", "--name", "lepton-steamlaunch-123", "/init"]
monkeypatch.setattr(wrapper.sys, "argv", [str(own), *args])
executed = []
class ExecSucceeded(BaseException):
pass
def execute(path, argv):
executed.append(argv)
raise ExecSucceeded
monkeypatch.setattr(wrapper.os, "execv", execute)
with pytest.raises(ExecSucceeded):
wrapper.main()
assert executed == [["/usr/bin/podman", *args]]
monkeypatch.setenv("FRAMEPORT_NO_HW_VIDEO", "0")
(tmp_path / "video-codec/disabled").unlink(missing_ok=True)
assert not wrapper.switched_off(directory)
def test_empty_capture_buffers_are_requeued_not_taken_for_the_end():
"""Iris returns an empty capture buffer (no LAST flag) for each hidden VP9 frame. FFmpeg's wrapper ended the EOS
drain at the first one and lost the pictures still in the driver (two-pass VP9 4K: 573 of 600, 2026-10-10)."""
build = load_module(ROOT / "native/hevc/build.py", "hevc_build")
stand_in = ("static V4L2Buffer* v4l2_dequeue_v4l2buf(V4L2Context *ctx, int timeout)\n{\n struct pollfd pfd = {\n"
+ build.EMPTY_DECLARE + "start:\n" + build.EMPTY_POLL + "dequeue:\n"
+ build.EMPTY_DRAIN + " = 0;\n }\n}\n")
patched = build.skip_empty_pictures(stand_in)
assert "int i, ret, skipped_empty = 0;" in patched
# empty buffers are skipped before the drain's "empty buffer = end" check, unless LAST/ERROR is set
assert patched.index("ff_v4l2_buffer_enqueue(&ctx->buffers[buf.index])") < patched.index(build.EMPTY_DRAIN)
assert "!(buf.flags & (V4L2_BUF_FLAG_LAST | V4L2_BUF_FLAG_ERROR))" in patched
# a drain can't block forever after a skipped empty buffer
assert "if (!ret && skipped_empty && ctx_to_m2mctx(ctx)->draining) {" in patched
with pytest.raises(RuntimeError):
build.skip_empty_pictures(stand_in.replace(build.EMPTY_POLL, ""))
# the component no longer guesses which pictures belong to hidden frames
assert "vp9Hidden" not in (ROOT / "native/hevc/frameport_hevc.cpp").read_text()
-96
View File
@@ -1,96 +0,0 @@
"""frame.hw_video_decode: the shared Iris codec plugin, chosen per game (it used to be tied to Batman's package)."""
import zipfile
from pathlib import Path
from test_patches import _analysis, _apk
from frameport.analysis import detect
from frameport.apk.workspace import ApkWorkspace
from frameport.core import library
from frameport.core.events import Reporter
from frameport.patches import base
from frameport.patches.frame.adapter import needs_xrshim
from frameport.recommend import catalog
def _dex_apk(path, manifest, dex=b"", libs=()):
with zipfile.ZipFile(path, "w") as z:
z.writestr("AndroidManifest.xml", manifest)
z.writestr("classes.dex", b"dex\n035\0" + dex)
for lib in libs:
z.writestr(f"lib/arm64-v8a/{lib}", b"\x7fELF")
return path
def test_detect_finds_android_video_decoding(tmp_path, quest_manifest):
assert detect.ANALYSIS_VERSION >= 8
cases = [(b"...Landroid/media/MediaCodec;...", (), True),
(b"...Landroidx/media3/exoplayer/ExoPlayer;...", (), True),
(b"...Lcom/google/android/exoplayer2/SimpleExoPlayer;...", (), True),
(b"", ("libvlc.so",), True),
(b"...Landroid/media/MediaPlayer;...", (), False)]
for i, (dex, libs, expected) in enumerate(cases):
a = detect.analyze(_dex_apk(tmp_path / f"{i}.apk", quest_manifest, dex, libs))
assert a.extra["media_codec"] is expected, dex
patch = base.get("frame.hw_video_decode")
assert patch.detect(_analysis(extra={"media_codec": True})).recommended
assert patch.detect(_analysis(extra={})) is None
assert patch.detect(_analysis(extra={"media_codec": True}, abis=["armeabi-v7a"])) is None
assert not patch.needs_vr # 2D video apps too
def test_the_patch_changes_no_apk_and_old_codec_assets_go(tmp_path, quest_manifest):
"""The codec is installed once per Frame (agent install_video_codec); the patch only reaches the agent through the
recipe (deployment.json), so a build never carries codec assets, and older bundled ones are removed."""
patch = base.get("frame.hw_video_decode")
assert patch.stage == "install"
apk = _apk(tmp_path, quest_manifest)
with zipfile.ZipFile(apk, "a") as z:
z.writestr("assets/frameport/hevc/libstagefrighthw.so", b"old codec")
z.writestr("assets/video/intro.mp4", b"game video")
with ApkWorkspace(apk) as ws:
ctx = base.ApkContext(ws, _analysis(), {}, Reporter(), {"frame.adapter": {}, "frame.hw_video_decode": {}})
assert base.get("frame.adapter").apply(ctx)
assert not any(n.startswith("assets/frameport/hevc/") for n in ws.names())
assert ws.has("assets/video/intro.mp4")
def test_the_recipe_carries_it_to_the_agent():
"""installer.install sends the whole patch selection (install-stage patches too); the agent's finalize decides
the launcher line from it (tests/test_shared_video_codec.py)."""
source = (Path(__file__).resolve().parents[1] / "src/frameport/install/installer.py").read_text()
assert 'recipe={"patches": sorted(plan.recipe.patches)' in source
agent = (Path(__file__).resolve().parents[1] / "agent/frameport_agent.py").read_text()
assert 'HW_VIDEO_PATCH = "frame.hw_video_decode"' in agent
def test_surface_native_is_a_hidden_recipe_setting_that_needs_the_shim():
from frameport.patches.settings import HIDDEN, UI, adapter_settings
patch = base.get("adapter.surface_native")
assert patch.default == 0 and "surface_native" in HIDDEN and "surface_native" not in UI
assert "surface_native" not in adapter_settings({})
assert adapter_settings({"adapter.surface_native": {"value": 1}})["surface_native"] == 1
assert needs_xrshim({"adapter.surface_native": {"value": 1}}) and not needs_xrshim({})
assert patch.detect(_analysis()) is None
def test_migration_gives_existing_batman_recipes_the_video_patches():
user = {"source": "user", "patches": {"frame.adapter": {}, "adapter.scale": {"value": 0.8}}, "reasons": {}}
other = {"source": "user", "patches": {"frame.adapter": {}}, "reasons": {}}
data = {"settings": {"migrations": []}, "games": {"com.camouflaj.manta": {"recipe": user},
"com.example.other": {"recipe": other}}}
assert library._migrate(data)
assert user["patches"]["frame.hw_video_decode"] == {}
assert user["patches"]["adapter.surface_native"] == {"value": 1}
assert user["patches"]["adapter.scale"] == {"value": 0.8} # the user's own choices stay
assert not {"frame.hw_video_decode", "adapter.surface_native"} & set(other["patches"])
assert "batman_video_patches" in data["settings"]["migrations"]
user["patches"].pop("frame.hw_video_decode")
library._migrate(data) # runs once
assert "frame.hw_video_decode" not in user["patches"]
def test_batman_catalog_recipe_has_the_video_patches():
entry = catalog.load()["com.camouflaj.manta"]
assert "frame.hw_video_decode" in entry.frame and entry.adapter.get("surface_native") == 1
+10 -16
View File
@@ -70,24 +70,20 @@ def test_adapter_refreshes_existing_wrapper(tmp_path, quest_manifest):
assert not patch.apply(ctx)
def test_native_video_follows_the_recipe_not_the_package(tmp_path, quest_manifest):
"""Batman's cutscene setup = frame.hw_video_decode + adapter.surface_native (its recipe), for any package. The
decoder itself is installed once per Frame by the agent, never embedded in a game's APK."""
def test_native_video_is_internal_and_scoped_to_batman(tmp_path, quest_manifest):
from pathlib import Path
plugin = (Path(__file__).with_name("fixtures") / "libfakeovrplugin_arm64.so").read_bytes()
video = {"frame.adapter": {}, "frame.hw_video_decode": {}, "adapter.surface_native": {"value": 1}}
for package, recipe, enabled in (("com.camouflaj.manta", video, True), ("com.example.questgame", video, True),
("com.camouflaj.manta", {"frame.adapter": {}}, False)):
for package, enabled in (("com.camouflaj.manta", True), ("com.example.questgame", False)):
apk = _apk(tmp_path, quest_manifest)
with zipfile.ZipFile(apk, "a") as z:
z.writestr("lib/arm64-v8a/libOVRPlugin.so", plugin)
with ApkWorkspace(apk) as ws:
ctx = base.ApkContext(ws, _analysis(package=package), {}, Reporter(), recipe)
ctx = base.ApkContext(ws, _analysis(package=package), {}, Reporter(), {"frame.adapter": {}})
assert base.get("frame.adapter").apply(ctx)
assert (b"surface_native=1\n" in ws.read_lib("libframe_settings.so")) is enabled
assert ws.has(ws.lib("libframe_xrshim.so")) is enabled
assert not any(n.startswith("assets/frameport/hevc/") for n in ws.names())
assert any(n.startswith("assets/frameport/hevc/") for n in ws.names()) is enabled
if enabled:
assert b"libframe_xrshim.so\0" in ws.read_lib("libOVRPlugin.so")
assert not base.get("frame.adapter").apply(ctx)
@@ -112,16 +108,15 @@ def test_adapter_removes_unneeded_codec_without_touching_video_assets(tmp_path,
assert not any(n.startswith("assets/frameport/hevc/") for n in z.namelist())
def test_arm32_does_not_get_the_arm64_codec_or_shim(tmp_path):
def test_batman_arm32_does_not_get_unvalidated_native_video(tmp_path):
apk = tmp_path / "arm32.apk"
with zipfile.ZipFile(apk, "w") as z:
z.writestr("lib/armeabi-v7a/libopenxr_loader_generic.so", b"original loader")
recipe = {"frame.adapter": {}, "frame.hw_video_decode": {}, "adapter.surface_native": {"value": 1}}
analysis = _analysis(package="com.camouflaj.manta", abis=["armeabi-v7a"])
assert not base.get("frame.hw_video_decode").applies(analysis)
with ApkWorkspace(apk) as ws:
ctx = base.ApkContext(ws, analysis, {}, Reporter(), recipe)
ctx = base.ApkContext(ws, _analysis(package="com.camouflaj.manta", abis=["armeabi-v7a"]),
{}, Reporter(), {"frame.adapter": {}})
assert base.get("frame.adapter").apply(ctx)
assert b"surface_native" not in ws.read_lib("libframe_settings.so")
assert not ws.has(ws.lib("libframe_xrshim.so"))
assert not any(n.startswith("assets/frameport/hevc/") for n in ws.names())
@@ -482,11 +477,10 @@ def test_game_settings_dialog_shows_relevant_settings_and_saves_only_changes():
from frameport.core import library
from frameport.core.models import Recipe
from frameport.patches.settings import HIDDEN, SETTINGS, UI
from frameport.patches.settings import SETTINGS, UI
from frameport.ui.views import adapter_dialog as ad
# every number/switch has a label/control, except recipe-only ones
assert set(UI) == {k for k, kind, *_ in SETTINGS if kind != "str"} - HIDDEN
assert set(UI) == {k for k, kind, *_ in SETTINGS if kind != "str"} # every number/switch has a label/control
vulkan = _analysis()
gles = _analysis(graphics="GLES (declared in manifest)")
assert ad.relevant(vulkan, "flip_emul", 1) and not ad.relevant(gles, "flip_emul", 1) # Vulkan-only
+311
View File
@@ -0,0 +1,311 @@
"""Quest games on this PC through AXRB (tools/axrb.py, targets/pc_android.py) against fakes; no Windows needed."""
import hashlib
import io
import json
import struct
import subprocess
import zipfile
from pathlib import Path
import pytest
from test_pc_target import fake_steam, vdf_mod
from frameport.core import winhost
from frameport.core.events import Reporter
from frameport.core.models import Recipe
from frameport.tools import axrb
def make_asar(path: Path, files: dict[str, bytes]) -> None:
"""A minimal Electron asar: pickle sizes + JSON index + file data."""
index, blob, offset = {"files": {}}, b"", 0
for name, data in files.items():
node = index
*dirs, leaf = name.split("/")
for d in dirs:
node = node["files"].setdefault(d, {"files": {}})
node["files"][leaf] = {"size": len(data), "offset": str(offset)}
blob += data
offset += len(data)
js = json.dumps(index).encode()
payload = struct.pack("<I", len(js)) + js
payload += b"\0" * (-len(payload) % 4)
header = struct.pack("<I", len(payload)) + payload
path.write_bytes(struct.pack("<I", 4) + struct.pack("<I", len(header)) + header + blob)
def fake_app(tmp_path, comps) -> Path:
app = tmp_path / "Programs" / "axrb-launcher"
res = app / "resources" / "runtime"
(res / "out/android/runtime-arm64-v8a").mkdir(parents=True)
(res / axrb.RUNTIME_APK).write_bytes(b"runtime-apk")
(app / "AXRB.exe").write_bytes(b"MZ")
make_asar(app / "resources" / "app.asar", {"package.json": json.dumps({"version": "1.0.4"}).encode(),
"core/components.json": json.dumps(comps).encode()})
return app
def zip_with(prefix: str, probe: str) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr(f"{prefix}/{probe}", b"x")
return buf.getvalue()
def component(cid, prefix, probe, dest):
data = zip_with(prefix, probe)
return {"id": cid, "name": cid, "url": f"{axrb.DOWNLOAD_HOST}{cid}.zip", "size": len(data),
"sha256": hashlib.sha256(data).hexdigest(), "destination": dest, "prefix": prefix, "probe": probe}, data
@pytest.fixture
def win(monkeypatch):
monkeypatch.setattr(winhost, "available", lambda: True)
monkeypatch.setattr(winhost, "is_wsl", lambda: False)
monkeypatch.setattr(winhost, "to_windows", lambda p: "W:" + str(p).replace("/", "\\"))
def test_asar_read_and_version(tmp_path):
app = fake_app(tmp_path, [])
assert axrb.installed_version(app) == "1.0.4"
assert axrb.asar_read(app / "resources" / "app.asar", "core/components.json") == b"[]"
with pytest.raises(KeyError):
axrb.asar_read(app / "resources" / "app.asar", "core/missing.json")
def test_components_only_from_googles_repository(tmp_path):
good, _ = component("emulator", "emulator", "emulator.exe", "emulator")
assert axrb.components(fake_app(tmp_path, [good]))[0]["id"] == "emulator"
bad = dict(good, url="https://example.com/emulator.zip")
with pytest.raises(axrb.AxrbError):
axrb.components(fake_app(tmp_path / "b", [bad]))
def test_setup_files_writes_axrbs_layout(tmp_path, monkeypatch, win):
emu, emu_zip = component("emulator", "emulator", "emulator.exe", "emulator")
img, img_zip = component("image", "x86_64", "system.img", "system-images/android-36/google_apis/x86_64")
app = fake_app(tmp_path, [emu, img])
root = tmp_path / "AXRB Runtime"
blobs = {emu["url"]: emu_zip, img["url"]: img_zip}
def download(url, dest, progress=None, expected_sha256=None, expected_sha1=None):
assert hashlib.sha256(blobs[url]).hexdigest() == expected_sha256
dest.write_bytes(blobs[url])
if progress:
progress(1.0)
return dest
monkeypatch.setattr(axrb.cache, "download", download)
st = axrb.setup_state(root, app)
assert st["components"] == ["emulator", "image"] and not st["ready"]
seen = []
axrb.setup_files(lambda f, name: seen.append(name), root=root, app=app)
assert (root / "sdk/emulator/emulator.exe").exists()
assert (root / "sdk/system-images/android-36/google_apis/x86_64/system.img").exists()
cfg = (root / "avd" / f"{axrb.AVD}.avd" / "config.ini").read_text()
assert "hw.gpu.mode=host" in cfg and "image.sysdir.1=W:" in cfg and cfg.rstrip().endswith("PlayStore.enabled=no")
assert f"path=W:{str(root).replace('/', chr(92))}\\avd\\{axrb.AVD}.avd" in \
(root / "avd" / f"{axrb.AVD}.ini").read_text()
assert json.loads((root / "license-acceptance.json").read_text())["license"] == "android-sdk-license"
assert not list((root / "downloads").iterdir()) and seen
st = axrb.setup_state(root, app)
assert st["components"] == [] and st["avd"] and not st["runtime_apk"] # the runtime APK goes in after boot
(root / "ready.json").write_text(json.dumps({"runtimeHash": axrb.runtime_hash(app)}))
assert axrb.setup_state(root, app)["ready"]
def test_ps_command_sets_axrbs_environment(tmp_path, win):
app = fake_app(tmp_path, [])
cmd = axrb.ps_command("scripts/emulator/windows_android_emulator.ps1",
{"Action": "Start", "Port": 5584, "GpuSharing": True, "ColdBoot": False},
tmp_path / "AXRB Runtime", app)
assert "$env:ANDROID_ADB_SERVER_PORT = '5038'" in cmd and "$env:AXRB_DATA_HOME = 'W:" in cmd
assert "tools\\python' + ';' + $env:PATH" in cmd
assert "-Action 'Start' -Port '5584' -GpuSharing" in cmd and "ColdBoot" not in cmd
assert cmd.endswith("exit $LASTEXITCODE")
assert axrb._ps_quote("it's") == "'it''s'"
def test_launcher_script_and_steam_arguments(tmp_path, win):
app = fake_app(tmp_path, [])
text = axrb.launcher_text(tmp_path / "AXRB Runtime", app)
assert "run_windows_game.ps1" in text and f"-Avd '{axrb.AVD}' -Port {axrb.PORT}" in text
assert "@" not in text.replace("@{", "") # every placeholder filled
args = axrb.launcher_args(r"C:\x\fp-axrb-run.ps1", "com.a.b", "com.a.b/.Main", 'Say "Hi" $now')
assert '-File "C:\\x\\fp-axrb-run.ps1" -Package com.a.b -Activity com.a.b/.Main -GameName "Say Hi now"' in args
def test_read_log_handles_powershell_utf16(tmp_path):
p = tmp_path / "a.log"
p.write_bytes(b"\xff\xfe" + "Host failed to start: x\n".encode("utf-16-le"))
assert axrb.read_log(p) == "Host failed to start: x\n"
p.write_bytes("plain".encode())
assert axrb.read_log(p, 3) == "ain"
assert axrb.read_log(tmp_path / "missing.log") == ""
def test_logged_call_reports_its_exit_code(tmp_path, win):
cmd = axrb.logged("& 'x.ps1' -A 1", r"C:\l.log")
assert "*>> 'C:\\l.log'" in cmd and '"FP_EXIT $c"' in cmd and cmd.endswith("exit $c")
log = tmp_path / "l.log"
log.write_bytes(b"\xff\xfe" + "output\nFP_EXIT 4\n".encode("utf-16-le"))
assert axrb.exit_code(log) == 4
log.write_text("no marker")
assert axrb.exit_code(log) is None
def test_release_sums_and_requirements():
sums = axrb.parse_sums("310d" + "0" * 60 + " AXRB-Setup-1.0.4.exe\nnot a line\n")
assert sums == {"AXRB-Setup-1.0.4.exe": "310d" + "0" * 60}
ok = {"hypervisor": True, "supportedGpu": True, "x64": True, "memoryGB": 64, "gpu": "RTX"}
assert axrb.requirement_problems(ok) == []
bad = axrb.requirement_problems(dict(ok, hypervisor=False, memoryGB=8))
assert len(bad) == 2 and "Hypervisor" in bad[1] and "12 GB" in bad[0]
def test_pc_selection_keeps_ovrport_and_apk_fixes_only():
from frameport import pipeline
from frameport.patches.base import pc_selection
patches = {"patch_oculus_unity": {}, "frame.adapter": {}, "frame.ovrstubs": {}, "frame.swapchain_limit": {},
"adapter.focus_hold": {"ms": 5000}, "frame.vk_sanitize": {}}
assert set(pc_selection(patches)) == {"patch_oculus_unity", "frame.ovrstubs", "frame.swapchain_limit"}
r1 = {"patches": patches}
r2 = {"patches": dict(patches, **{"adapter.scale": {"value": 0.8}})} # a Frame-only change
assert pipeline.pc_recipe_fingerprint(r1) == pipeline.pc_recipe_fingerprint(r2)
assert pipeline.recipe_fingerprint(r1) != pipeline.recipe_fingerprint(r2)
def test_pc_installable():
from frameport.pipeline import pc_installable
assert pc_installable({"kind": "rift"})
assert pc_installable({"analysis": {"extra": {"vr_kind": "quest"}}})
assert pc_installable({"analysis": {"extra": {"vr_kind": "openxr"}}})
assert pc_installable({}) # older entries without vr_kind are Quest games
assert not pc_installable({"analysis": {"extra": {"vr_kind": "none"}}})
assert not pc_installable({"kind": "linux"})
def test_shared_exe_shortcuts_are_matched_by_package(tmp_path):
m = vdf_mod()
vdf = str(tmp_path / "shortcuts.vdf")
exe = '"C:\\Windows\\powershell.exe"'
a = m.upsert_shortcut(vdf, exe, "Game A", "", "", "FramePort PC VR", "-Package com.a -Activity x",
options_key="-Package com.a ")
b = m.upsert_shortcut(vdf, exe, "Game B", "", "", "FramePort PC VR", "-Package com.b -Activity y",
options_key="-Package com.b ")
again = m.upsert_shortcut(vdf, exe, "Game A (renamed)", "", "", "FramePort PC VR", "-Package com.a -Activity x",
options_key="-Package com.a ")
sc = m.vdf_decode(Path(vdf).read_bytes())["shortcuts"]
assert a != b and again == a and len(sc) == 2
assert m.remove_shortcut(vdf, exe, "-Package com.a ")
left = list(m.vdf_decode(Path(vdf).read_bytes())["shortcuts"].values())
assert [v["appname"] for v in left] == ["Game B"]
class FakeAdb:
def __init__(self, remote=None):
self.calls, self.remote = [], remote or {}
def __call__(self, args, timeout=60, serial=True, root=None):
self.calls.append(args)
out = "Success\n"
if args[:2] == ["shell", "cmd"]:
out = "priority=0 preferredOrder=0\ncom.game.x/com.unity3d.player.UnityPlayerActivity\n"
elif args[0] == "shell" and "find . -type f" in args[1]:
out = "".join(f"{s} ./{n}\n" for n, s in self.remote.items())
return subprocess.CompletedProcess(args, 0, out, "")
def test_install_into_axrb(tmp_path, monkeypatch, win):
from frameport.targets import pc_android
apk = tmp_path / "out" / "com.game.x.apk"
apk.parent.mkdir()
apk.write_bytes(b"apk")
data = tmp_path / "obb"
data.mkdir()
(data / "main.1.com.game.x.obb").write_bytes(b"1" * 10)
(data / "patch.1.com.game.x.obb").write_bytes(b"2" * 5)
adb = FakeAdb(remote={"main.1.com.game.x.obb": 10}) # already there from an earlier install
events = []
monkeypatch.setattr(pc_android, "ensure_axrb", lambda rep: tmp_path / "app")
monkeypatch.setattr(axrb, "start_emulator", lambda **k: events.append("start") or True)
monkeypatch.setattr(axrb, "stop_emulator", lambda *a, **k: events.append("stop"))
monkeypatch.setattr(axrb, "ensure_runtime_apk", lambda **k: events.append("runtime"))
monkeypatch.setattr(axrb, "installed_version", lambda app=None: "1.0.4")
monkeypatch.setattr(axrb, "write_launcher", lambda **k: tmp_path / "FramePort/axrb/fp-axrb-run.ps1")
monkeypatch.setattr(axrb, "adb", adb)
monkeypatch.setattr(pc_android, "powershell_win", lambda: r"C:\Windows\powershell.exe")
recipe = Recipe("com.game.x", {"patch_oculus_unity": {}, "frame.adapter": {}, "frame.ovrstubs": {}})
res = pc_android.install("com.game.x", "Game X", apk, data, recipe, Reporter(), record_dir=tmp_path / "rec",
appid=lambda exe, title: 0x80001234)
assert res == {"ok": True, "appid": 0x80001234, "activity": "com.game.x/com.unity3d.player.UnityPlayerActivity"}
assert events == ["start", "runtime", "stop"]
pushes = [c for c in adb.calls if c[0] == "push"]
assert len(pushes) == 1 and pushes[0][2] == "/sdcard/Android/obb/com.game.x/patch.1.com.game.x.obb"
dep = json.loads((tmp_path / "rec" / "deployment.json").read_text())
assert dep["kind"] == "android" and dep["recipe"]["patches"] == ["frame.ovrstubs", "patch_oculus_unity"]
exe, start, opts = pc_android.shortcut_fields(dep)
assert exe == '"C:\\Windows\\powershell.exe"' and "-Package com.game.x " in opts
assert start.endswith('axrb\\"')
def test_signature_conflict_explained():
from frameport.targets import pc_android
r = subprocess.CompletedProcess([], 1, "", "Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: ...]")
with pytest.raises(RuntimeError, match="signed with another key"):
pc_android._adb_ok(r, "installing the game")
def test_two_quest_games_get_their_own_pc_shortcuts(tmp_path, monkeypatch, win):
from frameport.targets import pc_revive
root = fake_steam(tmp_path)
monkeypatch.setattr(winhost, "env_path", lambda name: None)
monkeypatch.setattr(winhost, "steam_root", lambda: root)
monkeypatch.setattr(winhost, "stop_steam", lambda r: True)
monkeypatch.setattr(winhost, "start_steam", lambda r: None)
monkeypatch.setattr("frameport.artwork.fetch.cache.http_get", lambda *a, **k: (_ for _ in ()).throw(OSError()))
for pkg, title in (("com.a.game", "Game A"), ("com.b.game", "Game B")):
d = pc_revive.pc_dir() / pkg
d.mkdir(parents=True)
(d / "deployment.json").write_text(json.dumps({
"package": pkg, "kind": "android", "title": title, "activity": f"{pkg}/.Main",
"launcher_win": r"C:\Users\u\AppData\Local\FramePort\axrb\fp-axrb-run.ps1",
"powershell_win": r"C:\Windows\powershell.exe"}))
t = pc_revive.PcReviveTarget()
assert {d["package"] for d in t.installed()} == {"com.a.game", "com.b.game"}
t.add_to_library(["com.a.game", "com.b.game"], Reporter())
sc = vdf_mod().vdf_decode((root / "userdata/12345/config/shortcuts.vdf").read_bytes())["shortcuts"]
assert sorted(v["appname"] for v in sc.values()) == ["Game A", "Game B"]
assert all(v["tags"]["0"] == pc_revive.TAG for v in sc.values())
def test_pc_outdated_for_android_follows_the_pc_build():
from frameport.ui import components as C
dep = {"kind": "android", "recipe": {"patches": ["frame.ovrstubs", "patch_oculus_unity"]}}
g = {"recipe": {"patches": {"patch_oculus_unity": {}, "frame.ovrstubs": {}, "frame.adapter": {}}}}
assert not C.pc_outdated(g, dep) # Frame-only patches don't matter on the PC
g["recipe"]["patches"]["patch_oculus_unreal"] = {}
assert C.pc_outdated(g, dep)
def test_axrb_triage_uses_its_own_signatures():
from frameport.validate.triage import triage
host = ("AXRB OpenXR: adjacent loader \"openxr_loader.dll\": loaded (Windows error 0)\n"
"AXRB OpenXR: failed to find D3D11 adapter requested by OpenXR runtime\n"
"Host pose server did not become ready within 45 seconds.\n")
r = triage(host, "NEVER_STARTED", "com.zenstudios.PFXVRQuest", kind="axrb")
assert {f.id for f in r.findings} == {"axrb-no-headset", "axrb-host-not-ready"}
guest = ("I OpenXR-Loader: Got runtime: package: com.axrb.openxrruntime, so filename: libopenxr_runtime.so\n"
"I OVRPlugin: Preinitialize: xrCreateInstance() succeeded\n"
"F DEBUG : #00 pc 000000000006fb32 /vendor/lib64/hw/vulkan.ranchu.so "
"(gfxstream::vk::ResourceTracker::on_vkAllocateMemory(void*)+802)\n")
r = triage(guest, "EXITED", "com.zenstudios.PFXVRQuest", kind="axrb")
assert [f.id for f in r.findings] == ["axrb-gfxstream-crash"] and r.findings[0].report
assert r.milestone == "OpenXR instance created"
assert not [f for f in triage(host, "EXITED", "com.zenstudios.PFXVRQuest").findings if f.id.startswith("axrb")]
-340
View File
@@ -1,340 +0,0 @@
"""Shared deployment: no APK edits or renderer changes; games opt in through their recipe (frame.hw_video_decode)."""
import base64
import hashlib
import importlib.util
import io
import json
import sys
import threading
import types
import zipfile
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
def agent_module(monkeypatch, tmp_path):
if sys.platform == "win32":
monkeypatch.setitem(sys.modules, "fcntl", types.SimpleNamespace(flock=lambda *_: None, LOCK_EX=2))
spec = importlib.util.spec_from_file_location("shared_codec_agent", ROOT / "agent/frameport_agent.py")
agent = importlib.util.module_from_spec(spec)
spec.loader.exec_module(agent)
monkeypatch.setattr(agent, "VIDEO_CODEC_DIR", str(tmp_path / "codec"))
monkeypatch.setattr(agent, "ANCHORS", str(tmp_path / "apps"))
return agent
def bundle(revision=2, corrupt=False):
files = {"libstagefrighthw.so": b"plugin", "media_codecs_frameport.xml": b"xml",
"podman.py": b"wrapper", "COPYING.FFmpeg": b"license"}
manifest = {"revision": revision, "runtime_sha256": "runtime",
"files": {n: hashlib.sha256(data).hexdigest() for n, data in files.items()}}
raw = json.dumps(manifest).encode()
data = io.BytesIO()
with zipfile.ZipFile(data, "w") as z:
z.writestr("manifest.json", raw)
for name, payload in files.items():
z.writestr(name, b"broken" if corrupt and name == "libstagefrighthw.so" else payload)
return {"bundle": base64.b64encode(data.getvalue()).decode(), "digest": hashlib.sha256(raw).hexdigest()}
@pytest.mark.skipif(sys.platform == "win32", reason="agent publication uses Linux symlinks")
def test_shared_deployment_without_any_apk_and_no_downgrade(monkeypatch, tmp_path):
agent = agent_module(monkeypatch, tmp_path)
current = tmp_path / "codec/current"
replace = agent.os.replace
activated = []
def activate(source, destination):
if Path(destination) == current:
version = Path(source).resolve()
assert json.loads((version / "deployment.json").read_text())["scope"] == "shared"
assert (version / "bin/podman").read_bytes() == b"wrapper"
assert (version / "libstagefrighthw.so").read_bytes() == b"plugin"
assert not current.exists() # no partially published first version
activated.append(version)
replace(source, destination)
monkeypatch.setattr(agent.os, "replace", activate)
first = bundle(revision=3)
assert agent.cmd_install_video_codec(first)["digest"] == first["digest"]
assert len(activated) == 1
assert agent.cmd_video_codec_status({}) == {"digest": first["digest"], "revision": 3, "disabled": False}
assert agent.cmd_install_video_codec(first)["digest"] == first["digest"]
assert agent.cmd_install_video_codec(bundle(revision=2))["digest"] == first["digest"]
assert len(activated) == 1
with pytest.raises(agent.AgentError, match="checksum mismatch"):
agent.cmd_install_video_codec(bundle(revision=4, corrupt=True))
assert agent.cmd_video_codec_status({})["digest"] == first["digest"]
(current / "libstagefrighthw.so").write_bytes(b"bad on disk")
assert agent.cmd_video_codec_status({}) == {"disabled": False}
@pytest.mark.skipif(sys.platform == "win32", reason="agent publication uses Linux symlinks")
def test_superseded_versions_are_pruned(monkeypatch, tmp_path):
agent = agent_module(monkeypatch, tmp_path)
versions = tmp_path / "codec/versions"
installed = [agent.cmd_install_video_codec(bundle(revision=r))["digest"] for r in (3, 4)]
(versions / ".install-stale").mkdir()
(versions / "notes.txt").write_text("not a codec version")
installed.append(agent.cmd_install_video_codec(bundle(revision=5))["digest"])
# The active version and the one it replaced (in-flight launches) remain.
assert {p.name for p in versions.iterdir()} == {installed[1], installed[2], "notes.txt"}
assert agent.cmd_video_codec_status({})["digest"] == installed[2]
def test_corrupt_bundle_never_activates(monkeypatch, tmp_path):
agent = agent_module(monkeypatch, tmp_path)
with pytest.raises(agent.AgentError, match="checksum mismatch"):
agent.cmd_install_video_codec(bundle(corrupt=True))
assert not (tmp_path / "codec").exists()
LEPTON = "export LEPTON_ENV_FRAMEBRIDGE_CONFIG=\"$app_dir/settings.conf\"\n{line}\nchild=''\nsetsid /lepton start\n"
def anchor(agent, tmp_path, name, launcher, patches=(), legacy=False, dep_extra=None):
directory = tmp_path / "apps" / name
base = tmp_path / "games" / name
directory.mkdir(parents=True)
(base / "lepton-app").mkdir(parents=True)
(base / "lepton-app/game.apk").write_bytes(b"unchanged APK")
if legacy: # agent <= 70 extracted this game's codec from its APK
(base / "frameport-codec/bin").mkdir(parents=True)
(base / "frameport-codec/bin/podman").write_text("old wrapper")
dep = {"package": name, "base": str(base), "recipe": {"patches": list(patches)}, **(dep_extra or {})}
(directory / "deployment.json").write_text(json.dumps(dep))
(directory / "launch.sh").write_text(launcher)
return directory, base
def test_launchers_get_the_codec_line_only_for_games_with_the_patch(monkeypatch, tmp_path):
agent = agent_module(monkeypatch, tmp_path)
old, pr = agent.OLD_CODEC_LINES
plain, _ = anchor(agent, tmp_path, "com.any.game", LEPTON.format(line=old))
player, _ = anchor(agent, tmp_path, "cn.vr4p.oculus4xvrplayerov", LEPTON.format(line=pr),
patches=["frame.adapter", "frame.hw_video_decode"])
batman, batman_base = anchor(agent, tmp_path, "com.camouflaj.manta", LEPTON.format(line=old), legacy=True)
other_old, other_base = anchor(agent, tmp_path, "com.old.game", LEPTON.format(line=old))
(other_base / "frameport-codec").mkdir() # an APK without codec assets: agent 70 removed its wrapper
rift, _ = anchor(agent, tmp_path, "rift.game", "exec proton\n")
assert set(agent.upgrade_launchers()) == {"com.any.game", "cn.vr4p.oculus4xvrplayerov", "com.camouflaj.manta",
"com.old.game"}
for directory, want in ((plain, False), (player, True), (batman, True), (other_old, False)):
text = (directory / "launch.sh").read_text()
assert (agent.VIDEO_CODEC_LINE in text) is want, directory.name
assert not any(line in text for line in agent.OLD_CODEC_LINES)
assert text.endswith("child=''\nsetsid /lepton start\n")
assert "surface_native" not in text
assert (player / "launch.sh").read_text().count(agent.VIDEO_CODEC_LINE) == 1
assert (rift / "launch.sh").read_text() == "exec proton\n"
# Batman's choice is kept, so its old codec folder can go (ensure_host_fixes)
assert json.loads((batman / "deployment.json").read_text())["hw_video_decode"] is True
assert set(agent.remove_old_codec_dirs()) == {"com.camouflaj.manta", "com.old.game"}
assert not (batman_base / "frameport-codec").exists() and not (other_base / "frameport-codec").exists()
assert (batman_base / "lepton-app/game.apk").read_bytes() == b"unchanged APK"
assert agent.upgrade_launchers() == [] # idempotent, and Batman keeps the line without its old folder
assert agent.VIDEO_CODEC_LINE in (batman / "launch.sh").read_text()
# a reinstall without the patch (finalize writes hw_video_decode: false) drops the line
dep = json.loads((player / "deployment.json").read_text())
(player / "deployment.json").write_text(json.dumps(dict(dep, hw_video_decode=False)))
assert agent.upgrade_launchers() == ["cn.vr4p.oculus4xvrplayerov"]
assert agent.VIDEO_CODEC_LINE not in (player / "launch.sh").read_text()
def test_unconverted_old_codec_folder_stays(monkeypatch, tmp_path):
agent = agent_module(monkeypatch, tmp_path)
_, base = anchor(agent, tmp_path, "com.camouflaj.manta", LEPTON.format(line=agent.OLD_CODEC_LINES[0]),
legacy=True)
assert agent.remove_old_codec_dirs() == [] # its launcher hasn't been converted yet
assert (base / "frameport-codec/bin/podman").exists()
def test_old_codec_folder_goes_when_the_recipe_has_the_patch(monkeypatch, tmp_path):
"""Seen on the dev Frame: Batman's recipe already listed frame.hw_video_decode, so wants_hw_video answered from the
recipe and saved nothing; the old folder must still go once the launcher is converted."""
agent = agent_module(monkeypatch, tmp_path)
_, base = anchor(agent, tmp_path, "com.camouflaj.manta", LEPTON.format(line=agent.OLD_CODEC_LINES[0]),
patches=["frame.adapter", "frame.hw_video_decode"], legacy=True)
assert agent.upgrade_launchers() == ["com.camouflaj.manta"]
assert agent.remove_old_codec_dirs() == ["com.camouflaj.manta"]
assert not (base / "frameport-codec").exists()
def test_new_launchers_follow_the_recipe(monkeypatch, tmp_path):
agent = agent_module(monkeypatch, tmp_path)
for want in (True, False):
directory = tmp_path / f"apps/game{want}"
directory.mkdir(parents=True)
agent.write_launcher(str(directory), str(tmp_path / "base"), "com.example.game", "Game", 123,
"/lepton/lepton", {}, hw_video=want)
text = (directory / "launch.sh").read_text()
assert (agent.VIDEO_CODEC_LINE in text) is want
assert agent.set_codec_line(text, want) == text # upgrade_launchers leaves it as it is
@pytest.mark.skipif(sys.platform == "win32", reason="runs the launcher line in bash")
@pytest.mark.parametrize("case,expected", [("on", True), ("env", False), ("env0", True), ("flag", False),
("missing", False)])
def test_codec_line_switches(monkeypatch, tmp_path, case, expected):
"""The launcher line: the shared wrapper first on PATH unless switched off (FramePort's setting writes
video-codec/disabled; FRAMEPORT_NO_HW_VIDEO=1 in a game's Steam launch options) or not installed."""
import shutil
import subprocess
if not shutil.which("bash"):
pytest.skip("no bash")
agent = agent_module(monkeypatch, tmp_path)
home = tmp_path / "home"
codec = home / ".local/share/frameport/video-codec"
if case != "missing":
(codec / "versions/abc/bin").mkdir(parents=True)
(codec / "versions/abc/bin/podman").write_text("#!/bin/sh\n")
(codec / "versions/abc/bin/podman").chmod(0o755)
(codec / "current").symlink_to("versions/abc")
if case == "flag":
(codec / "disabled").write_text("off")
env = {"HOME": str(home), "PATH": "/usr/bin:/bin"}
if case.startswith("env"):
env["FRAMEPORT_NO_HW_VIDEO"] = "0" if case == "env0" else "1"
out = subprocess.run(["bash", "-euc", agent.VIDEO_CODEC_LINE + '\necho "$PATH"'], env=env, capture_output=True,
text=True, check=True).stdout.strip()
assert out.startswith(str(codec / "current/bin") + ":") is expected
assert out.endswith("/usr/bin:/bin")
def test_switch_writes_the_frame_wide_flag(monkeypatch, tmp_path):
agent = agent_module(monkeypatch, tmp_path)
assert agent.cmd_video_codec_switch({"enabled": False}) == {"disabled": True}
assert (tmp_path / "codec/disabled").exists()
assert agent.cmd_video_codec_switch({"enabled": True}) == {"disabled": False}
assert not (tmp_path / "codec/disabled").exists()
@pytest.mark.skipif(sys.platform == "win32", reason="agent publication uses Linux symlinks")
def test_same_revision_built_elsewhere_is_kept(monkeypatch, tmp_path):
"""Two PCs whose builds of one revision differ must not replace each other's codec at every connection."""
agent = agent_module(monkeypatch, tmp_path)
first = bundle(revision=7)
agent.cmd_install_video_codec(first)
other = bundle(revision=7)
data = io.BytesIO(base64.b64decode(other["bundle"]))
with zipfile.ZipFile(data) as z:
files = {n: z.read(n) for n in z.namelist()}
manifest = json.loads(files["manifest.json"])
manifest["build"] = "another PC"
files["manifest.json"] = json.dumps(manifest).encode()
rebuilt = io.BytesIO()
with zipfile.ZipFile(rebuilt, "w") as z:
for name, payload in files.items():
z.writestr(name, payload)
other = {"bundle": base64.b64encode(rebuilt.getvalue()).decode(),
"digest": hashlib.sha256(files["manifest.json"]).hexdigest()}
result = agent.cmd_install_video_codec(other)
assert result["kept"] and result["digest"] == first["digest"]
assert agent.cmd_video_codec_status({})["digest"] == first["digest"]
assert agent.cmd_install_video_codec(bundle(revision=8))["revision"] == 8 # a newer revision still installs
def test_connection_deploys_shared_codec_once_and_does_not_rebuild_games(monkeypatch):
from frameport.frame import connection
frame = connection.Frame.__new__(connection.Frame)
frame._video_codec_lock = threading.Lock()
frame._video_codec_digest = ""
calls = []
def remote(command, **args):
assert args.pop("ensure") is False # no ensure_agent recursion
calls.append(command)
if command == "video_codec_status":
return {}
with zipfile.ZipFile(io.BytesIO(base64.b64decode(args["bundle"]))) as z:
assert "manifest.json" in z.namelist()
assert not any(name.endswith(".apk") for name in z.namelist())
return {"digest": args["digest"]}
monkeypatch.setattr(frame, "agent", remote)
frame.ensure_video_codec()
frame.ensure_video_codec()
assert calls == ["video_codec_status", "install_video_codec"]
def test_newer_shared_codec_is_kept(monkeypatch):
from frameport.frame import connection
frame = connection.Frame.__new__(connection.Frame)
frame._video_codec_lock = threading.Lock()
frame._video_codec_digest = ""
calls = []
def remote(command, **_):
calls.append(command)
return {"digest": "newer-version", "revision": 999}
monkeypatch.setattr(frame, "agent", remote)
frame.ensure_video_codec()
assert calls == ["video_codec_status"]
def connection_frame(monkeypatch, replies):
from frameport.frame import connection
frame = connection.Frame.__new__(connection.Frame)
frame._video_codec_lock = threading.Lock()
frame._video_codec_digest = ""
calls = []
def remote(command, **args):
assert args.pop("ensure") is False
calls.append((command, args.get("enabled")))
reply = replies.get(command, {})
if isinstance(reply, Exception):
raise reply
return reply
monkeypatch.setattr(frame, "agent", remote)
return frame, calls
def test_failed_install_is_not_retried_on_this_connection(monkeypatch):
from frameport.frame import connection
frame, calls = connection_frame(monkeypatch, {"install_video_codec": connection.AgentFailed("disk full")})
with pytest.raises(connection.AgentFailed):
frame.ensure_video_codec()
frame.ensure_video_codec() # every agent call comes through here: no second ~6 MB upload
frame._ensure_video_codec_if_supported(b"AGENT_VERSION = 71\n")
assert [c for c, _ in calls] == ["video_codec_status", "install_video_codec"]
def test_same_revision_on_the_frame_is_kept(monkeypatch):
revision = json.loads((ROOT / "artifacts/hevc/manifest.json").read_text())["revision"]
frame, calls = connection_frame(monkeypatch, {"video_codec_status": {"digest": "built-elsewhere",
"revision": revision}})
frame.ensure_video_codec()
assert [c for c, _ in calls] == ["video_codec_status"]
frame, calls = connection_frame(monkeypatch, {"video_codec_status": {"digest": "older",
"revision": revision - 1}})
frame.ensure_video_codec()
assert [c for c, _ in calls] == ["video_codec_status", "install_video_codec"]
def test_setting_off_switches_the_frame_off_without_uploading(monkeypatch):
from frameport.core import library
library.set_setting("video.hw_decode", False)
replies = {"video_codec_status": {"disabled": False}, "video_codec_switch": {"disabled": True}}
frame, calls = connection_frame(monkeypatch, replies)
frame.ensure_video_codec()
assert calls == [("video_codec_status", None), ("video_codec_switch", False)]
# switched on again in Settings: applied at once (switch + install)
library.set_setting("video.hw_decode", True)
replies.update(video_codec_status={"disabled": True}, video_codec_switch={"disabled": False})
monkeypatch.setattr(frame, "ensure_agent", lambda: frame.ensure_video_codec())
calls.clear()
frame.apply_video_decoding()
assert [c for c, _ in calls] == ["video_codec_status", "video_codec_switch", "install_video_codec"]
assert calls[1] == ("video_codec_switch", True)
+38 -10
View File
@@ -6,23 +6,51 @@ from pathlib import Path
import pytest
def test_video_patches_outdate_only_the_recipes_that_gain_them():
"""Batman's cutscene setup moved from a package check in frame.adapter to its recipe (frame.hw_video_decode +
adapter.surface_native): its build is outdated by the recipe change; other games' builds stay installed."""
def test_video_adapter_update_uses_existing_install_state(monkeypatch):
from frameport.patches.base import get, recipe_fingerprint
from frameport.ui.components import install_state
assert get("frame.adapter").revision == 1 and not get("frame.adapter").package_revisions
before = {"patches": {"frame.adapter": {}}}
after = {"patches": {"frame.adapter": {}, "frame.hw_video_decode": {}, "adapter.surface_native": {"value": 1}}}
for package, recipe, expected in (("com.camouflaj.manta", after, "outdated"),
("com.example.other", before, "installed")):
game = {"package": package, "recipe": recipe,
"build": {"sha256": "installed", "recipe_fp": recipe_fingerprint(before, package)}}
adapter = get("frame.adapter")
recipe = {"patches": {"frame.adapter": {}}}
with monkeypatch.context() as old:
old.setattr(adapter, "revision", 1)
previous = recipe_fingerprint(recipe)
game = {"package": "com.camouflaj.manta", "recipe": recipe,
"build": {"sha256": "installed", "recipe_fp": previous}}
frame = {"installed": [{"package": game["package"], "sha256": "installed"}]}
assert install_state(game, frame) == "outdated"
game["build"]["recipe_fp"] = recipe_fingerprint(recipe, game["package"])
assert install_state(game, frame) == "installed"
def test_video_revision_only_outdates_batman(monkeypatch):
from frameport.patches.base import get, recipe_fingerprint
from frameport.ui.components import install_state
adapter = get("frame.adapter")
recipe = {"patches": {"frame.adapter": {}}}
with monkeypatch.context() as old:
old.setattr(adapter, "package_revisions", {})
previous = recipe_fingerprint(recipe)
for package, expected in (("com.camouflaj.manta", "outdated"), ("com.example.other", "installed")):
game = {"package": package, "recipe": recipe, "build": {"sha256": "installed", "recipe_fp": previous}}
frame = {"installed": [{"package": package, "sha256": "installed"}]}
assert install_state(game, frame) == expected
def test_video_revision_scopes_older_builds_without_fingerprints():
from frameport.patches.base import get, revised_since_unrecorded
from frameport.ui.components import install_state
recipe = {"patches": {"frame.adapter": {}}}
assert get("frame.adapter").revision == 1 # preserve upstream's shared revision
for package, expected in (("com.camouflaj.manta", "outdated"), ("com.example.other", "installed")):
game = {"package": package, "recipe": recipe, "build": {"sha256": "installed"}}
frame = {"installed": [{"package": package, "sha256": "installed"}]}
assert revised_since_unrecorded(recipe, package) is (expected == "outdated")
assert install_state(game, frame) == expected
def test_projection_shader_validates(tmp_path):
compiler, validator = shutil.which("glslangValidator"), shutil.which("spirv-val")
if not compiler or not validator:
-11
View File
@@ -274,17 +274,6 @@ def test_unity_pcvr_signatures():
assert not triage(log, "EXITED", "com.example.game").findings # PC VR signatures only for rift games
def test_hw_video_decoder_busy_from_the_media_service():
"""The codec plugin logs from Android's media service (another pid than the game's): still triaged."""
for line in ("W FramePortVideo: Iris video/hevc unavailable; keeping Android's stock decoder",
"W FramePortVideo: Iris video/hevc initialization failed (-12); using software decoding"):
log = LOG_OK + f"09-28 17:39:01.500 412 412 {line}\n"
r = triage(log, "RUNNING", "com.example.game")
assert [f.id for f in r.findings] == ["hw-video-decoder-busy"] and r.verdict == "pass"
log = LOG_OK + "09-28 17:39:01.500 412 412 I FramePortVideo: Iris hardware video/hevc decoder active\n"
assert not triage(log, "RUNNING", "com.example.game").findings
def test_lepton_lines_survive_the_game_filter():
"""With the package known, triage keeps only the game's logcat lines plus Lepton's own: the short "Boot complete!"
used to be dropped, so Lepton 3's transient "is not a running context" failed every launch test in the GUI."""
+1 -1
View File
@@ -331,7 +331,7 @@ def test_install_builds_a_game_that_was_never_built(tmp_path, monkeypatch):
out = tmp_path / "built.apk"
out.write_bytes(b"PK")
def build(package, reporter):
def build(package, reporter, pc=False):
library.upsert_game(package, build={"apk": str(out)})
monkeypatch.setattr(pipeline, "build_game", build)
monkeypatch.setattr(pipeline, "remove_converted_copies", lambda package: 0)