mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-10 23:01:36 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1512955fb1 | ||
|
|
9f49414bd6 |
No files matched your search
@@ -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,40 +668,12 @@ 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.
|
||||
**Vulkan shader dump (GitHub #140, 2026-10-10):** adapter `vk_shader_dump=1` (Vulkan shim, `native/vkshim/shader_dump.h`;
|
||||
applies where vk_sanitize can: Unreal/Other arm64) writes each distinct SPIR-V module once to
|
||||
`files/fp_vk_shaders/<size>_<sha256>.spv` (tmp + rename) and one `index.txt` line per vkCreateShaderModule (`<seq> <ms>
|
||||
<unix ms> <name> new|known|again|failed`, O_APPEND) to find the module behind a GPU hang. Agent v72 `collect_diag`
|
||||
returns `shaders` (newest modules of fp_vk_shaders + fp_spirv, ≤4 MB each, base64) → bundle
|
||||
`games/<pkg>/target/shaders/`. Triage `gpu-hang` suggests both dumps; `triage.graphics_api` (FrameBridge's
|
||||
xrCreateSwapchain formats: <0x1000 Vulkan, else GL) keeps only the session's API's (`API_ONLY`). Host-tested only.
|
||||
**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
|
||||
once (`Dex.return_early`: return-void / `const/4 v0,0; return v0`; nopping the invoke would leave a move-result the
|
||||
verifier rejects). Older Vivox (Eleven Table Tennis, BattleSisters) lacks that code and isn't matched. Verified
|
||||
headless: Vivox initialises, 150 s at ~65-70 fps.
|
||||
**Steam Input gamepad for 2D apps (GitHub #162, agent v72, 2026-10-10):** Lepton's Android only has the Wayland seat's `wayland_touch/keyboard/pointer`. Steam makes its virtual pad (uinput, `/devices/virtual/input`, 28de:11ff, "Microsoft X-Box 360 pad N", ACL for steamos) only while the Frame's controllers are on (controller.txt "Steam Controller reserving XInput slot 0"; gone when they sleep), also with no game running; steamos-manager's 28de:0000 keys device isn't a pad (no BTN_SOUTH). Lepton's container root (system_server too) = steamos (`keep-id:uid=0`), so a bind-mounted `/dev/input/eventN` opens. Opt-in `device.steam_gamepad` (vr_kind none; suggested for `android.hardware.gamepad`/`LEANBACK_LAUNCHER`, analysis `gamepad`, ANALYSIS_VERSION 9): deployment.json `steam_gamepad` → launcher `GAMEPAD_LINE` (after the codec line: exports FRAMEPORT_GAMEPAD + LEPTON_ENV_SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD, puts `agent/bin` first on PATH) → `PODMAN_WRAPPER` (written by ensure_host_fixes; non-`run` calls go straight on; `run` imports the agent's `podman_run_args`; hands on to the next Podman after its own PATH entry with itself removed from PATH, so it chains to the codec wrapper either way round) adds the pads + `Vendor_28de_Product_<pid>.kl` (Xbox 360 layout). Verified headless with a uinput stand-in pad (Stremio's container: EventHub `classes=0x80000141`, our key layout, KeyEvent BUTTON_A dispatched); with Steam's real pad and a game: not yet. Pads made after the start aren't seen until the next one.
|
||||
**Multiview programs on flat framebuffers (GitHub #77, Doom3Quest, 2026-10-09, prototype):** Mesa enforces OVR_multiview's
|
||||
"program num_views == draw framebuffer views" rule (`draw_validate.c`, the draw is dropped silently); Qualcomm doesn't.
|
||||
Doom3Quest compiles every VS with `layout(num_views=2) in;` and draws its HUD/PDA into 2D-texture FBOs → black. Opt-in
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
-526
@@ -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,51 +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 = 72
|
||||
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"')
|
||||
# Steam Input's virtual gamepad for 2D Android apps (patch device.steam_gamepad, per game, GitHub #162): Lepton's
|
||||
# Android only gets keyboard/pointer/touch from the Wayland seat, so a game never sees a controller. The launcher of a
|
||||
# game that has it puts FramePort's own Podman wrapper (PODMAN_WRAPPER, written next to the agent, independent of the
|
||||
# video codec) first on Lepton's PATH; for that game's `podman run` it bind-mounts Steam's virtual pads
|
||||
# (/dev/input/eventN) and a key layout, then hands on to the next Podman on PATH (the codec wrapper, if that game has
|
||||
# it, else Podman itself). LEPTON_ENV_SDL_... = SDL's hint that stops it ignoring Steam's virtual pad (Lepton passes
|
||||
# LEPTON_ENV_<NAME> to the app as <NAME>). Off for one game: FRAMEPORT_NO_GAMEPAD=1 in its launch options.
|
||||
GAMEPAD_PATCH = "device.steam_gamepad"
|
||||
PODMAN_BIN = os.path.join(HOME, ".local/share/frameport/agent/bin")
|
||||
GAMEPAD_LINE = ('[[ "${FRAMEPORT_NO_GAMEPAD:-0}" != 0 ]] || { export FRAMEPORT_GAMEPAD=1 '
|
||||
'LEPTON_ENV_SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=1; '
|
||||
'[[ ! -x "$HOME/.local/share/frameport/agent/bin/podman" ]] || '
|
||||
'export PATH="$HOME/.local/share/frameport/agent/bin:$PATH"; }')
|
||||
|
||||
|
||||
class AgentError(Exception):
|
||||
@@ -709,22 +676,12 @@ def ensure_host_fixes():
|
||||
with open(CONTAINERS_CONF, "w") as f:
|
||||
f.write(text)
|
||||
changed.append("podman keyring=false")
|
||||
try:
|
||||
ensure_podman_wrapper() # used only by launchers with the gamepad line (device.steam_gamepad)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
upgraded = upgrade_launchers()
|
||||
except Exception: # noqa: BLE001
|
||||
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
|
||||
@@ -930,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}}
|
||||
@@ -1032,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)}
|
||||
@@ -2232,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
|
||||
@@ -2310,11 +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)))
|
||||
new = set_gamepad_line(new, wants_gamepad(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:
|
||||
@@ -2348,315 +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 wants_gamepad(anchor):
|
||||
"""Whether this game's launcher passes Steam Input's virtual gamepad into its container (device.steam_gamepad):
|
||||
deployment.json's choice (written at finalize), else its recipe's patches."""
|
||||
try:
|
||||
with open(os.path.join(anchor, "deployment.json")) as f:
|
||||
dep = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
if not isinstance(dep, dict):
|
||||
return False
|
||||
if "steam_gamepad" in dep:
|
||||
return bool(dep["steam_gamepad"])
|
||||
recipe = dep.get("recipe") if isinstance(dep.get("recipe"), dict) else {}
|
||||
return GAMEPAD_PATCH in (recipe.get("patches") or [])
|
||||
|
||||
|
||||
def wrapper_lines(hw_video, gamepad):
|
||||
"""The launcher's Podman wrapper lines (empty, one or both). The gamepad line comes last, so FramePort's wrapper is
|
||||
first on PATH and hands on to the codec wrapper."""
|
||||
return "\n".join(line for line, want in ((VIDEO_CODEC_LINE, hw_video), (GAMEPAD_LINE, gamepad)) if want)
|
||||
|
||||
|
||||
def set_gamepad_line(text, want):
|
||||
"""A Lepton launcher with the gamepad line (want) or without it. It always follows the codec line (when there is
|
||||
one), else stands where the codec line would."""
|
||||
text = text.replace(GAMEPAD_LINE + "\n", "")
|
||||
if not want:
|
||||
return text
|
||||
i = text.find(VIDEO_CODEC_LINE + "\n")
|
||||
if i >= 0:
|
||||
at = i + len(VIDEO_CODEC_LINE) + 1
|
||||
else:
|
||||
at = next((text.index(a) + 1 for a in ("\nchild=''\n", "\nsetsid ") if a in text), -1)
|
||||
return text if at < 0 else text[:at] + GAMEPAD_LINE + "\n" + text[at:]
|
||||
|
||||
|
||||
# Android's layout for an Xbox 360 pad (AOSP's Vendor_045e_Product_028e.kl, with Select as BUTTON_SELECT instead of
|
||||
# BACK, which would close many apps): Steam's virtual pad reports Valve's ids, so Android would fall back to Generic.kl
|
||||
# (triggers on Z/RZ, right stick on RX/RY: games then mix up the right stick and the triggers).
|
||||
GAMEPAD_KL = """# Steam Input virtual gamepad (Xbox 360 layout). Written by FramePort (device.steam_gamepad).
|
||||
key 304 BUTTON_A
|
||||
key 305 BUTTON_B
|
||||
key 307 BUTTON_X
|
||||
key 308 BUTTON_Y
|
||||
key 310 BUTTON_L1
|
||||
key 311 BUTTON_R1
|
||||
key 314 BUTTON_SELECT
|
||||
key 315 BUTTON_START
|
||||
key 316 BUTTON_MODE
|
||||
key 317 BUTTON_THUMBL
|
||||
key 318 BUTTON_THUMBR
|
||||
axis 0x00 X flat 4096
|
||||
axis 0x01 Y flat 4096
|
||||
axis 0x03 Z flat 4096
|
||||
axis 0x04 RZ flat 4096
|
||||
axis 0x02 LTRIGGER
|
||||
axis 0x05 RTRIGGER
|
||||
axis 0x10 HAT_X
|
||||
axis 0x11 HAT_Y
|
||||
"""
|
||||
BTN_SOUTH = 0x130 # BTN_A / BTN_GAMEPAD: Linux's gamepad button range starts here
|
||||
|
||||
|
||||
def has_key_bit(caps, code):
|
||||
"""Whether a sysfs capabilities/key bitmap (hex words, most significant first, one per long) has `code`."""
|
||||
words = caps.split()
|
||||
bits = 8 * struct.calcsize("l") # the kernel's long (64 on the Frame); words aren't zero-padded
|
||||
index = len(words) - 1 - code // bits
|
||||
try:
|
||||
return index >= 0 and bool(int(words[index], 16) >> (code % bits) & 1)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def steam_gamepads(sys_root="/sys", dev_root="/dev"):
|
||||
"""Steam Input's virtual gamepads ([{event, product, name}]): input devices Steam creates through uinput
|
||||
(/sys/devices/virtual/input) with Valve's vendor id (28de) and gamepad buttons. Steam names them "Microsoft X-Box
|
||||
360 pad N" (seen on the Frame; older clients "Steam Virtual Gamepad"); Valve's other virtual devices (e.g.
|
||||
steamos-manager's keys, 28de:0000) have no gamepad buttons."""
|
||||
pads = []
|
||||
for sys_event in sorted(glob.glob(os.path.join(sys_root, "class/input/event*")),
|
||||
key=lambda p: int(re.sub(r"\D", "", os.path.basename(p)) or 0)):
|
||||
event = os.path.basename(sys_event)
|
||||
device = os.path.join(sys_event, "device")
|
||||
|
||||
def read(rel, device=device):
|
||||
try:
|
||||
with open(os.path.join(device, rel)) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
if read("id/vendor").lower() != "28de" or not has_key_bit(read("capabilities/key"), BTN_SOUTH):
|
||||
continue
|
||||
if "/devices/virtual/" not in os.path.realpath(device) + "/":
|
||||
continue # Valve hardware itself (a Steam Deck's controls): Steam Input reads it and makes a virtual pad
|
||||
node = os.path.join(dev_root, "input", event)
|
||||
if not os.path.exists(node) or not os.access(node, os.R_OK | os.W_OK):
|
||||
continue
|
||||
pads.append({"event": event, "product": read("id/product").lower() or "0000", "name": read("name")})
|
||||
return pads
|
||||
|
||||
|
||||
def podman_run_args(args, env, sys_root="/sys", dev_root="/dev"):
|
||||
"""FramePort's Podman wrapper (PODMAN_WRAPPER) for `podman run`: the game's own container
|
||||
(lepton-steamlaunch-<SteamAppId>, launcher env) gets Steam's virtual gamepads and their key layout when its
|
||||
launcher asked for them (FRAMEPORT_GAMEPAD=1, the gamepad line). Anything else: the arguments unchanged. Lepton
|
||||
mounts a tmpfs over /dev, so a `--device` node would vanish under it: bind mounts, like Lepton's own GPU nodes."""
|
||||
if args[:1] != ["run"] or env.get("FRAMEPORT_GAMEPAD") != "1" or \
|
||||
env.get("FRAMEPORT_NO_GAMEPAD", "0") not in ("", "0"):
|
||||
return args
|
||||
appid = env.get("SteamAppId", "")
|
||||
name = None
|
||||
for i, arg in enumerate(args):
|
||||
if arg == "--name" and i + 1 < len(args):
|
||||
name = args[i + 1]
|
||||
elif arg.startswith("--name="):
|
||||
name = arg.partition("=")[2]
|
||||
if not re.fullmatch(r"[0-9]+", appid) or name != f"lepton-steamlaunch-{appid}":
|
||||
return args
|
||||
taken = set()
|
||||
for i, arg in enumerate(args): # destinations Lepton mounts itself (a future Lepton passing pads through)
|
||||
spec = args[i + 1] if arg == "--mount" and i + 1 < len(args) else arg.partition("=")[2] \
|
||||
if arg.startswith("--mount=") else ""
|
||||
for item in spec.split(","):
|
||||
key, _, value = item.partition("=")
|
||||
if key in ("destination", "target", "dst"):
|
||||
taken.add(value)
|
||||
pads = steam_gamepads(sys_root, dev_root)
|
||||
extra = []
|
||||
for pad in pads:
|
||||
node = f"/dev/input/{pad['event']}"
|
||||
if node not in taken:
|
||||
extra += ["--mount", f"type=bind,source={os.path.join(dev_root, 'input', pad['event'])},"
|
||||
f"destination={node},rw"]
|
||||
layout = gamepad_layout_file() if extra else ""
|
||||
for product in sorted({p["product"] for p in pads}):
|
||||
target = f"/system/usr/keylayout/Vendor_28de_Product_{product}.kl"
|
||||
if layout and target not in taken and re.fullmatch(r"[0-9a-f]{4}", product):
|
||||
extra += ["--mount", f"type=bind,source={layout},destination={target},ro"]
|
||||
names = ", ".join(f"{p['event']} ({p['name']}, 28de:{p['product']})" for p in pads) or "none"
|
||||
print(f"FramePort gamepad: Steam Input virtual gamepads for this container: {names}", file=sys.stderr)
|
||||
return [args[0], *extra, *args[1:]] if extra else args
|
||||
|
||||
|
||||
def gamepad_layout_file():
|
||||
path = os.path.join(os.path.dirname(PODMAN_BIN), "steam-gamepad.kl")
|
||||
try:
|
||||
with open(path) as f:
|
||||
if f.read() == GAMEPAD_KL:
|
||||
return path
|
||||
except OSError:
|
||||
pass
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = f"{path}.{os.getpid()}.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(GAMEPAD_KL)
|
||||
os.replace(tmp, path)
|
||||
return path
|
||||
|
||||
|
||||
# FramePort's Podman wrapper (agent/bin/podman). Small on purpose: every Podman call Lepton makes goes through it while
|
||||
# a launcher has it on PATH, so only `run` loads the agent (podman_run_args); everything else, and any failure, goes
|
||||
# straight to the next Podman on PATH: the one after this folder (the codec wrapper of a game with hardware video
|
||||
# decoding, else Podman itself), with this folder taken off PATH so the next wrapper can't come back here.
|
||||
PODMAN_WRAPPER = r'''#!/usr/bin/python3
|
||||
"""FramePort's Podman wrapper (written by frameport_agent.py: ensure_podman_wrapper; see podman_run_args)."""
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
|
||||
def chain():
|
||||
"""The next Podman (after this folder on PATH, else the first other one) and PATH without this folder."""
|
||||
own = os.path.realpath(__file__)
|
||||
entries = os.environ.get("PATH", os.defpath).split(os.pathsep)
|
||||
mine = [i for i, e in enumerate(entries) if os.path.realpath(e or os.curdir) == HERE]
|
||||
order = entries[mine[0] + 1:] if mine else entries
|
||||
for entry in order + ["/usr/local/bin", "/usr/bin", "/bin"]:
|
||||
folder = os.path.realpath(entry or os.curdir)
|
||||
found = shutil.which("podman", path=folder) if folder != HERE else None
|
||||
if found and os.path.realpath(found) != own:
|
||||
return found, os.pathsep.join(e for i, e in enumerate(entries) if i not in mine)
|
||||
sys.exit("FramePort: no Podman found after its wrapper")
|
||||
|
||||
|
||||
podman, path = chain()
|
||||
args = sys.argv[1:]
|
||||
env = dict(os.environ, PATH=path)
|
||||
if args[:1] == ["run"]:
|
||||
try:
|
||||
sys.path.insert(0, os.path.dirname(HERE))
|
||||
import frameport_agent
|
||||
args = frameport_agent.podman_run_args(args, env)
|
||||
except Exception as exc: # a gamepad problem must never stop the game's container from starting
|
||||
print(f"FramePort: Podman wrapper left the arguments unchanged: {exc}", file=sys.stderr)
|
||||
args = sys.argv[1:]
|
||||
os.execve(podman, [podman, *args], env)
|
||||
'''
|
||||
|
||||
|
||||
def ensure_podman_wrapper():
|
||||
"""Write FramePort's Podman wrapper (PODMAN_BIN/podman) when it is missing or differs; returns whether it was
|
||||
written. Launchers only use it while their gamepad line is there."""
|
||||
path = os.path.join(PODMAN_BIN, "podman")
|
||||
try:
|
||||
with open(path) as f:
|
||||
if f.read() == PODMAN_WRAPPER and os.access(path, os.X_OK):
|
||||
return False
|
||||
except OSError:
|
||||
pass
|
||||
os.makedirs(PODMAN_BIN, exist_ok=True)
|
||||
tmp = f"{path}.{os.getpid()}.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(PODMAN_WRAPPER)
|
||||
os.chmod(tmp, 0o755)
|
||||
os.replace(tmp, path)
|
||||
return True
|
||||
|
||||
|
||||
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."""
|
||||
@@ -2669,13 +2318,12 @@ def upgrade_linux_launcher(text):
|
||||
return text
|
||||
|
||||
|
||||
def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False, gamepad=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=wrapper_lines(hw_video, gamepad),
|
||||
plays_start=plays_lines(anchor)[0],
|
||||
plays_end=plays_lines(anchor)[1])
|
||||
path = os.path.join(anchor, "launch.sh")
|
||||
@@ -2689,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):
|
||||
@@ -2877,12 +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)
|
||||
gamepad = GAMEPAD_PATCH in (recipe.get("patches") or []) # Steam Input's virtual gamepad (PODMAN_WRAPPER)
|
||||
if gamepad:
|
||||
ensure_podman_wrapper()
|
||||
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"), hw_video, gamepad)
|
||||
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)
|
||||
@@ -2890,8 +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, "steam_gamepad": gamepad, "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}
|
||||
@@ -4904,50 +4480,6 @@ def _tail(path, max_bytes):
|
||||
return None
|
||||
|
||||
|
||||
# shader dumps in a game's files dir: the Vulkan shim's vk_shader_dump and the shader-fix layer's zink_shader_dump
|
||||
SHADER_DUMP_DIRS = ("fp_vk_shaders", "fp_spirv")
|
||||
SHADER_DUMP_BYTES = 4 << 20 # newest modules per diagnostics run, base64 on the wire
|
||||
SHADER_DUMP_FILES = 200
|
||||
|
||||
|
||||
def shader_dumps(files_dir, max_bytes=SHADER_DUMP_BYTES):
|
||||
"""{dir: {"index": tail of index.txt, "modules": {name: base64}, "total": n, "skipped": n}} for the dump folders
|
||||
in a game's files dir: the newest modules (by time written) up to max_bytes, so the shader created right before a
|
||||
GPU hang comes along. Unreadable files are skipped (the app writes them inside its container)."""
|
||||
out = {}
|
||||
for d in SHADER_DUMP_DIRS:
|
||||
path = os.path.join(files_dir, d)
|
||||
try:
|
||||
names = [n for n in os.listdir(path) if n.endswith(".spv")]
|
||||
except OSError:
|
||||
continue
|
||||
mods = []
|
||||
for n in names:
|
||||
try:
|
||||
mods.append((os.path.getmtime(os.path.join(path, n)), n))
|
||||
except OSError:
|
||||
pass
|
||||
mods.sort(reverse=True)
|
||||
res = {"total": len(names), "modules": {}, "skipped": 0}
|
||||
index = _tail(os.path.join(path, "index.txt"), 1 << 20)
|
||||
if index is not None:
|
||||
res["index"] = index
|
||||
used = 0
|
||||
for _, n in mods[:SHADER_DUMP_FILES]:
|
||||
try:
|
||||
with open(os.path.join(path, n), "rb") as f:
|
||||
data = f.read(max_bytes - used + 1)
|
||||
except OSError:
|
||||
res["skipped"] += 1
|
||||
continue
|
||||
if used + len(data) > max_bytes:
|
||||
break
|
||||
used += len(data)
|
||||
res["modules"][n] = base64.b64encode(data).decode("ascii")
|
||||
out[d] = res
|
||||
return out
|
||||
|
||||
|
||||
def cmd_collect_diag(args):
|
||||
"""Everything useful for debugging without the game or the PC app: host runtime facts and, with a package, the
|
||||
game's launcher, settings, deployment, logs (launch, Lepton logcat, Proton/Revive/Unreal) and its file listing.
|
||||
@@ -5028,9 +4560,6 @@ def cmd_collect_diag(args):
|
||||
for p in sorted(cands, key=lambda p: next((i for i, k in enumerate(order) if k in os.path.basename(p)), 9)):
|
||||
name = os.path.basename(p)
|
||||
files[name if name.startswith("logcat") else "logcat-" + name] = _tail(p, max_bytes)
|
||||
shaders = shader_dumps(data_files_dir(base, pkg), int(args.get("shader_bytes", SHADER_DUMP_BYTES)))
|
||||
if shaders:
|
||||
out["shaders"] = shaders
|
||||
try:
|
||||
listing = cmd_list_files({"package": pkg, "limit": 20000})
|
||||
out["listing"] = {"missing": listing["missing"], "truncated": listing["truncated"],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
e7554c6343ee2989b0a273ee6230e65c25bfe6499eefd00de19f5f0fee58754f ./arm64-v8a/libfp_langpack.so
|
||||
321da502e0f8f46f0880aad39fe6b84bdef88f925164568814cf704a1b31a577 ./arm64-v8a/libfp_ovrp.so
|
||||
40defdaddcda53bd2649eb48076fae1622bfc2bfc88e9e03c94fc8b45af2a4f2 ./arm64-v8a/libfp_ovrtrace.so
|
||||
e8c0965c188665a4e565e4a3af983ef1866d1cf9225665cc90d8df286bc96ef3 ./arm64-v8a/libfp_vk.so
|
||||
7522a7cfb236e48d3442d418c4820cd37760a9627c6bdef80fb0df82df5fd826 ./arm64-v8a/libfp_vk.so
|
||||
383054f8b3b41dde76d062c71856cd3163655e50009c08454bc1793f393ead65 ./arm64-v8a/libfpg.so
|
||||
5432674a59d02f411cd853a5dc57e3fa547d1ac518f25bf433fc54c43677177a ./arm64-v8a/libfpglmv.so
|
||||
8a8f6b1da8952cb3933a5b53558424a5fc56043b2527a16bb6dd62a00d2de01a ./arm64-v8a/libframe_xrshim.so
|
||||
@@ -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.
Binary file not shown.
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -1,23 +0,0 @@
|
||||
package: com.CortopiaStudios.DTRH
|
||||
title: Down the Rabbit Hole
|
||||
status: works
|
||||
tested_version: 01.04.434
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- 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-10'
|
||||
known_good_sha256: ce9e02e9b8fc64dc37bec2bd869d2ed1c4a99f28adffd6b5ec38c87b3cb639f7
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 145
|
||||
source_hint: '8430807483680247'
|
||||
@@ -1,22 +0,0 @@
|
||||
package: com.PeanutButton.Retropolis
|
||||
title: Retropolis
|
||||
status: works
|
||||
tested_version: '0.87'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: 4656b980fccd812d8edac15805dcd86c2f44029abdab98c3187699d1fc878f88
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 153
|
||||
source_hint: com.PeanutButton.Retropolis
|
||||
@@ -1,22 +0,0 @@
|
||||
package: com.ScytheDevTeam.ContainmentProtocol
|
||||
title: Deep Cuts
|
||||
status: works
|
||||
tested_version: 1.1.3271
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: d8d4bfb0954059c5ed1bcaa0edfb8b18f154ff710049f4808c42cfd8d88ee5a2
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 144
|
||||
source_hint: com.ScytheDevTeam.ContainmentProtocol
|
||||
@@ -1,22 +0,0 @@
|
||||
package: com.UNIVRS.Freedom
|
||||
title: Freedom
|
||||
status: issues
|
||||
notes: Volume can't be changed in the game.
|
||||
tested_version: 1.0.2950
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: 24662272c4cbe5dc7c4f3c803bdb9dead9661ac3d59bf80cca2e260256acc4bc
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 132
|
||||
source_hint: AOT VR
|
||||
@@ -1,22 +0,0 @@
|
||||
package: com.XRGames.ZombielandVR
|
||||
title: ZombielandVR
|
||||
status: works
|
||||
tested_version: 1.7.0
|
||||
engine: Unity
|
||||
xr: VrApi
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.vrapi_stub
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: 750067107cd739c80b65516dd1c18219283b2a29b39430bb21ed85d48b9b94ac
|
||||
app: 0.12.1.dev252
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 71
|
||||
issue: 141
|
||||
source_hint: ZombielandVR
|
||||
@@ -1,7 +1,6 @@
|
||||
package: com.beatgames.beatsaber.lj369vr
|
||||
title: Beat Saber
|
||||
status: works
|
||||
notes: Purchased DLC isn't detected (ownership comes from Meta's servers).
|
||||
tested_version: 1.40.8_7379
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package: com.beatgames.beatsaber
|
||||
title: Beat Saber
|
||||
status: works
|
||||
notes: Purchased DLC isn't detected (ownership comes from Meta's servers).
|
||||
tested_version: 1.40.8_7379
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
|
||||
@@ -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
|
||||
@@ -1,8 +1,8 @@
|
||||
package: com.drbeef.doom3quest
|
||||
title: Doom3Quest
|
||||
status: works
|
||||
notes: HUD and PDA show with frame.gl_multiview_fbo (Xandrix1987's headset test, GitHub 77, no frame-rate drop with
|
||||
the PDA open). Walking and running can switch when the frame rate drops, which seems to be the game's own behaviour.
|
||||
status: issues
|
||||
notes: 'HUD and PDA show with frame.gl_multiview_fbo (Xandrix1987''s headset test, GitHub 77, no frame-rate drop
|
||||
with the PDA open). Open: the player sometimes switches between walking and running.'
|
||||
details: Doom3Quest compiles every vertex shader for two views (OVR_multiview) and draws its HUD/PDA into single-view
|
||||
framebuffers. Mesa (the Frame's GL) rejects that combination as the spec requires and drops the draw, so they
|
||||
stayed black; frame.gl_multiview_fbo draws those passes with a single-view copy of the shader. Xandrix1987's framebuffer.cpp
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package: com.endspace.quest
|
||||
title: End Space
|
||||
status: issues
|
||||
notes: Controls stop responding after the first mission loads.
|
||||
tested_version: 1.0.6.1
|
||||
engine: Unity
|
||||
xr: VrApi
|
||||
frame:
|
||||
- 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-10'
|
||||
known_good_sha256: c5eb892311a130686f238e6f61cbdb619324f5c21f8c0641c0fcd49d77369eaf
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 148
|
||||
source_hint: com.endspace.quest
|
||||
@@ -1,22 +0,0 @@
|
||||
package: com.forcefieldxr.explorevr
|
||||
title: ExploreVR
|
||||
status: issues
|
||||
notes: The 3D world warps and shifts with head movement (wrong perspective).
|
||||
tested_version: 1.2.1
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
frame:
|
||||
- frame.nodebug
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: 04576c46a91f4bb851a344c75e022b17a5d2837d054e7323a184b81436cf8019
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 149
|
||||
source_hint: com.forcefieldxr.explorevr
|
||||
@@ -1,9 +0,0 @@
|
||||
package: com.golfscope.proputt
|
||||
title: GOLF+
|
||||
status: unsupported
|
||||
notes: Black screen. The game's servers reject the login on this platform (Authentication not supported).
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
issue: 147
|
||||
@@ -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
|
||||
@@ -1,21 +0,0 @@
|
||||
package: com.kluge.SynthRiders
|
||||
title: SynthRiders
|
||||
status: works
|
||||
tested_version: 3.6.7a1
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
controller_models: 1
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: fb982ad7617629df50190c98d1de906ac9b41903d782c0ada0087b32fc95a5f1
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
issue: 158
|
||||
source_hint: com.kluge.SynthRiders
|
||||
@@ -1,21 +0,0 @@
|
||||
package: com.miketeevee.shoresofloci
|
||||
title: Shores of Loci
|
||||
status: works
|
||||
tested_version: '1.2'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: c223a99cc1f1597a477a4677c1390cf744d517bd1e8fa7cf82e20b1baefaba26
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 154
|
||||
source_hint: com.miketeevee.shoresofloci
|
||||
@@ -1,23 +0,0 @@
|
||||
package: com.onehamsa.RNXQ
|
||||
title: 'Racket: Nx'
|
||||
status: works
|
||||
tested_version: 2.8.31
|
||||
engine: Unity
|
||||
xr: VrApi
|
||||
frame:
|
||||
- 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-10'
|
||||
known_good_sha256: f78de8051c5808e4244d9fa757050d6c131350112315ad6e01e2b1457f2e2053
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 151
|
||||
source_hint: '9030540110323274'
|
||||
@@ -1,22 +0,0 @@
|
||||
package: com.quaternionsoftware.rcpilottrainer
|
||||
title: RC Pilot Trainer
|
||||
status: works
|
||||
tested_version: 0.7.0.2
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: 1b98f0dea74f058a838ac17299a0e1c67362cb1ecfbc97e4cb2aad3bcbdfb173
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 152
|
||||
source_hint: com.quaternionsoftware.rcpilottrainer
|
||||
@@ -1,20 +0,0 @@
|
||||
package: com.rrrgames.CaveCrave
|
||||
title: Caves
|
||||
status: works
|
||||
notes: First cave loads up perfectly, didn't play to unlock other game modes
|
||||
tested_version: 1.1.4
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.oculusos
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: 9720a9af42590dfdb742f278352583e56f36efbf5d96c56c59ac4b6a615f276e
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 142
|
||||
source_hint: com.rrrgames.CaveCrave
|
||||
@@ -1,22 +0,0 @@
|
||||
package: de.erthu.ancientdungeonfull
|
||||
title: Ancient_Dungeon
|
||||
status: issues
|
||||
notes: Multiplayer isn't available (offline).
|
||||
tested_version: ea0.1.10.2
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: 0bcbd6d01d085183df586ee692267341a9ea15d1f52fbc33265601d55c73c2d2
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 143
|
||||
source_hint: de.erthu.ancientdungeonfull
|
||||
@@ -1,11 +0,0 @@
|
||||
package: quest.eleven.forfunlabs
|
||||
title: Eleven Table Tennis
|
||||
status: unsupported
|
||||
notes: Stops at the splash screen. The game needs Meta's online services after start, which aren't available on
|
||||
the Frame.
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
pcvr_alternative: The PC VR (Rift) version works on the Frame through FramePort.
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
issue: 160
|
||||
+34
-17
@@ -110,11 +110,10 @@ signatures:
|
||||
pattern: 'kernel: .*(?i:hangcheck|gpu fault|msm_drm.*(hang|recover)|kgsl.*(hang|fault)|adreno.*(hang|fault))'
|
||||
severity: fatal
|
||||
diagnosis: The Frame's GPU hung while the game ran (the kernel reset it; the game froze or closed). Usually one
|
||||
shader or effect the Frame's driver can't handle (e.g. Vader Immortal's lightspeed jump, a VR4 cutscene).
|
||||
FramePort can't fix that on its own; capture the game's shaders (the shader dump), play to the hang once more and
|
||||
send a problem report with diagnostics, so a shader fix can be added for this game.
|
||||
# triage keeps only the dump for the graphics API the session used (FrameBridge's swapchain formats)
|
||||
suggest: [adapter.vk_shader_dump, adapter.zink_shader_dump]
|
||||
shader or effect the Frame's driver can't handle (e.g. Vader Immortal's lightspeed jump). FramePort can't fix
|
||||
that on its own; capture the game's shaders (OpenGL ES games) and send a problem report with diagnostics, so a
|
||||
shader fix can be added for this game.
|
||||
suggest: [adapter.zink_shader_dump]
|
||||
report: true
|
||||
- id: space-warp-used
|
||||
pattern: 'xrCreateSwapchain \d+x\d+ format=(97|129) '
|
||||
@@ -337,11 +336,6 @@ signatures:
|
||||
severity: fatal
|
||||
diagnosis: The Lepton container failed to start (see the error above it in launch.log).
|
||||
suggest: []
|
||||
- id: unity-data-missing
|
||||
pattern: 'Unable to open archive file|is corrupted! Remove it and launch unity again|Failed to read data for the AssetBundle'
|
||||
severity: fatal
|
||||
diagnosis: The game can't read its own data files (OBB/data folder) - they are incomplete or from another version of the game than the APK. Copy the whole data folder again from the same install as the APK, add the game again and reinstall.
|
||||
suggest: []
|
||||
- id: permission-denied-save
|
||||
pattern: 'Permission denied.*(Android/data|/sdcard)|EACCES.*Android/data'
|
||||
severity: warning
|
||||
@@ -462,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}
|
||||
|
||||
+1
-9
@@ -40,16 +40,8 @@ games/<pkg>/target/ from the Frame (or the PC): launch.sh, settings.con
|
||||
ReviveInjector.txt / steam-<appid>.log / game-log-N.txt (PC VR), files.json (+ missing)
|
||||
(game-log-N.txt: Unreal Saved/Logs + crash summaries, Unity Player.log / Player-prev.log /
|
||||
output_log.txt + crash error.log from the Proton prefix, agent v67)
|
||||
games/<pkg>/target/shaders/ shader dumps (agent v72), when the game wrote any:
|
||||
fp_vk_shaders/ adapter vk_shader_dump=1 (Vulkan shim): <size>_<sha256>.spv + index.txt
|
||||
fp_spirv/ adapter zink_shader_dump=1 (OpenGL ES, shader-fix layer under Zink): <size>_<sha256>.spv
|
||||
```
|
||||
Each log keeps at most its last 4 MB (2 MB per file from the Frame). Shader dumps: the newest modules (by time
|
||||
written) up to 4 MB per folder, SPIR-V unchanged (compiled game shaders, not redacted); a module the game created
|
||||
for the first time right before a GPU hang is among them. `index.txt` (Vulkan) lists every vkCreateShaderModule as
|
||||
`<seq> <ms since the first module> <unix ms> <size>_<sha256>.spv new|known|again|failed`; match the unix time with
|
||||
the kernel's `hangcheck detected gpu lockup` line (this-boot-kernel.txt). All modules stay in the game's storage on
|
||||
the Frame (`Android/data/<pkg>/files/fp_vk_shaders/`, Files tab → the game's storage).
|
||||
Each log keeps at most its last 4 MB (2 MB per file from the Frame).
|
||||
|
||||
## Redaction
|
||||
`diag/redact.py` runs on every file and on the issue text.
|
||||
|
||||
@@ -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).
|
||||
+12
-5
@@ -87,7 +87,6 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| A game no longer starts after a reinstall that was launch-tested right away: logcat `Zip: EOCD not found, …/base.apk is not zip` / `Failed to parse …base.apk`, no `Start proc` for the game (VR HOT, 2026-10-09) | The launch test's 45 s ran from the launcher, but the first start after an APK change spends ~1–2 min booting Lepton and installing the app: stopping the container mid-install left a broken installed copy | Agent v68: the test window counts from Lepton's `Waiting for app` (at most 240 s extra for boot + install). Repair a broken one: `touch <base>/lepton-app/game.apk` (Lepton re-installs it on the next start) |
|
||||
| Unreal game (OVRPlugin) never starts VR: launch.log has `OVRPlugin: JNI_OnLoad` but never `CompositorOpenXR::PreInitialize`, no OpenXR session, then a crash a few seconds in (e.g. Star Wars Pinball VR, UE 4.25: null pointer in `FSceneRenderer::GetMultiViewSceneColor`, GitHub #83) | Unreal's Oculus module looks up every `ovrp_*` function of the OVRPlugin it was built against (`InitializeOculusPluginWrapper`, all ANDed); OVRPort's OpenXR OVRPlugin lacks a few old ones (UE 4.25 / OVRPlugin 1.44: `ovrp_GetPTWNear`), so the wrapper fails and OculusHMD is never pre-initialised | `frame.unreal_ovrp_entrypoints` (suggested when analysis `unreal_ovrp_lookups` name functions the shipped OVRPlugin lacks): generated `libfp_ovrpstubs.so` (DT_NEEDED of libOVRPlugin.so; dlsym on the plugin's handle searches its dependencies) with a stand-in per missing name returning ovrpFailure (-1000); build note lists the names |
|
||||
| Only the Android home screen shows; the log has Unity's VR device as `None` (e.g. Accounting+, Unity 2017) | Unity 2017–2018 built-in Oculus support starts VR only when `com.oculus.systemactivities` is installed; Lepton has no Meta system apps, so Unity falls back to non-VR | `frame.unity_oculus_check`: that package name in libunity.so → `android`, plus `native/ovrpshim` (libfp_ovrp.so): Unity 2017's legacy frame loop (`ovrp_Update2`/`ovrp_BeginFrame`) never calls `ovrp_WaitToBeginFrame`, so without the shim no `xrWaitFrame` happens, `CompositorOpenXR::Update … outside of frame bounds` floods the log and the dashboard freezes. With it: log `ovrp frame loop shim: waited for frame N`, ~71 fps. Accounting+ then stops at "press any button" although its OVRInput gets clean input (`INPUT_PROBE` in the patch logs connected controllers/buttons/input focus per call) — unresolved |
|
||||
| Stuck on the loading screen or quits at start; `Unable to open archive file`, `is corrupted! Remove it and launch unity again` or `Failed to read data for the AssetBundle` in launch.log (e.g. Batman, GitHub #155) | The game's data folder (OBB) is incomplete or from another version than the APK | Copy the whole data folder again from the same install as the APK, add the game again, reinstall (triage `unity-data-missing`) |
|
||||
| Black flat window, then a GPU hang: kernel `hangcheck detected gpu lockup` (offending task the game), `zink: DEVICE LOST`, Unity render-thread crash; `outside of frame bounds` thousands of times a second (e.g. BattleSisters, Unity 2019.4) | Unity 2019 on its built-in VR (no `libOculusXRPlugin.so`) uses the same legacy frame loop as Unity 2017: no `xrWaitFrame`, the render thread floods the GPU | `frame.unity_oculus_check` adds the ovrpshim frame wait for these too (revision 2) |
|
||||
| Unity built-in VR game (with `frame.unity_oculus_check`) freezes at a scene switch: the log stops after `Boot: Activating Scene: …`, the process stays alive, every thread sleeps (e.g. Sniper Elite VR, Unity 2019.4) | The shim's `xrWaitFrame` blocks until the previously waited frame is begun; Unity skipped beginning it at the scene switch and its render thread then waited for the main thread (gdb: UnityMain in vrclient `CSxrSession::StartNextFrame` ← `ovrp_WaitToBeginFrame` ← `fpov_Update2`). OVRPlugin also only begins the frame index it waited for | `frame.unity_oculus_check` revision 5: libunity.so's `ovrp_BeginFrame`/`ovrp_EndFrame` go through the shim, which waits ≤ 50 ms for the last waited frame to begin, else skips the wait (log `the last waited frame wasn't begun`), and gives a begin/end of another index the waited one |
|
||||
| Unity built-in VR game: the hands/weapons trail the controllers by centimetres when moving (headset fine, 72 fps), e.g. Sniper Elite VR | Unity's physics-step `ovrp_Update2` (prediction 0) makes OVRPlugin locate the nodes at the monotonic clock's "now", the XrTime base on a Quest but 0.05–0.9 s behind the Frame's XrTime; the runtime extrapolates backwards and the render-step reads after it get those poses too. A larger prediction is capped by OVRPlugin (~0.07 s) | `frame.unity_oculus_check` revision 5 holds the physics-step update back: both steps read the render update's display-time poses |
|
||||
@@ -105,24 +104,21 @@ 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 |
|
||||
|---|---|---|
|
||||
| Found after playing: the game page's "Last session" (or `frameport session <pkg>`) lists a finding (agent v70 `session_log`: the newest `plays.log` session's launch.log, ≤4 MB, + its crash logcat + kernel GPU lines) | launch tests never reach FOCUSED or gameplay; the same `catalog/triage.yaml` signatures (sync_guard, pac_hints, unreal_gl_shim, vk_spec_fixes, …) now also run on real sessions, plus the frame-rate and focus-dip findings below | "Try this fix" (FrameBridge settings: written on the Frame at once via `set_settings`, used from the next start) / "Rebuild with this fix" (APK patches); launch-test sessions (`test <unix>` in plays.log) are skipped |
|
||||
| Textures flicker, jump or smear while moving in an OVRPlugin game that renders space-warp swapchains (`xrCreateSwapchain 376x376 format=97` / `format=129`; triage `space-warp-used`, info + question) | Application SpaceWarp: half the frames plus motion vectors; the Frame's reprojection of them misbehaves (Into The Radius 2, Metro Awakening) | adapter `hide_space_warp=1`; offered as "Did textures flicker…?" after a play session, never applied on its own |
|
||||
| The game froze or closed and the kernel log has `hangcheck` / GPU fault / `msm_drm … recover` lines during the session (triage `gpu-hang`, play sessions only) | a shader or effect hangs the Frame's GPU (e.g. Vader Immortal's lightspeed jump) | capture the shaders (`adapter.vk_shader_dump=1` Vulkan, `adapter.zink_shader_dump=1` OpenGL ES: triage keeps the one matching the session's swapchain formats), play to the hang again and send a problem report (diagnostics carry the newest dumped modules); the maintainer adds a `vk_shader_fix` / `zink_shader_fix` catalog entry. `zink-device-lost` keeps its MSAA suggestions |
|
||||
| The game froze or closed and the kernel log has `hangcheck` / GPU fault / `msm_drm … recover` lines during the session (triage `gpu-hang`, play sessions only) | a shader or effect hangs the Frame's GPU (e.g. Vader Immortal's lightspeed jump) | capture the shaders (`adapter.zink_shader_dump=1`, GLES) and send a problem report; the maintainer adds a `zink_shader_fix` / `vk_shader_fix` catalog entry. `zink-device-lost` keeps its MSAA suggestions |
|
||||
| The game keeps pausing or recentring for a moment while worn (session finding `focus-dips`: ≥3 `focus: back after N ms` lines longer than the game's focus_hold_ms, up to 5 s) | the Frame's wear sensor flickers "HMD off"; FrameBridge logs every runtime focus loss/return (always, before focus_hold) | `adapter.focus_hold_ms=<longest dip + 250 ms, in 500 ms steps, ≤5000>` (or `adapter.focus_hold=1` when off) |
|
||||
| Judder or smearing on head turns; FrameBridge pacing below the display rate (session finding `slow-frames`: >30 % of the 5 s windows under 90 % of the nearest refresh rate ≥ the session's 90th-percentile fps, after the first two windows, ≥1 min) | the game renders below the refresh rate; the runtime reprojects | `adapter.scale=<current × 0.85>` first, then `frame.unity_runtime_msaa_off` (Unity) / `adapter.hide_space_warp` (Unreal 5 + OVRPlugin) |
|
||||
| Unreal game (ILMxLAB, e.g. Vader Immortal): controllers track but grip/trigger do nothing, grabbing never works (Unreal's own input values move, the game's stay 0) | `URPOCKeyMapManagerComponent` picks the Quest or the Gear VR key set by `IsRunningOnSantaCruz()`; on the Frame it takes Gear VR, empty in a Quest build | `frame.unreal_quest_keymap` (the Oculus case keeps the Quest set, for action and axis mappings; found by Klownicle, GitHub #49) |
|
||||
| The hands' thumbs never move when you touch the thumbstick or buttons (Unreal `ThumbUp` stays 1, e.g. Vader Immortal) | OVRPlugin reports thumb proximity from XR_FB_touch_controller_proximity, which the Frame's runtime lacks; OVRPort's loader offers it anyway and the proximity actions never get a binding | adapter `proximity_emul=1` (thumb proximity from the touch inputs: thumbstick, face buttons, thumb rest; 2 = also the index finger from trigger touch); log `proximity_emul: finger proximity bound to touch`. **For UE4 Oculus games this didn't animate the thumbs** (Vader, GitHub #49): use `frame.unreal_thumb_touch` (found by Klownicle): `OculusInput::FOculusInput::SendControllerEvents` computes ThumbUp from NearTouches (masks 0x2/0x8); the patch makes it read Touches (the slot 4 bytes before) with masks 0x0f00 (X/Y/stick/thumb rest) / 0x000f (A/B/stick/thumb rest), keeping the inversion; three instructions, matched exactly (build note `OculusInput ThumbUp: from the capacitive touches`). Matches Vader Immortal Ep. I, Robo Recall, Phantom: Covert Ops, Time Stall, Star Wars: Tales from the Galaxy's Edge; newer UE4 builds (e.g. Asgard's Wrath 2, RE4, In Death) compile it differently (shared mask registers) and aren't matched |
|
||||
| Vulkan game (Unreal / own engine with the Vulkan shim): the GPU hangs at one effect or cutscene (kernel `hangcheck detected gpu lockup`, freeze/exit; e.g. a VR4 cutscene, GitHub #140) | a SPIR-V shader reads a loop counter/accumulator before setting it (VR4's campaign shader, GitHub #10) | adapter `vk_shader_dump=1` (`frame.vk_sanitize`'s shim; log `vk shim: dumping SPIR-V modules to …`) writes every distinct module once to `files/fp_vk_shaders/<size>_<sha256>.spv` + `index.txt` (`<seq> <ms> <unix ms> <name> new\|known\|again`); play to the hang, collect diagnostics (`target/shaders/fp_vk_shaders/`, newest 4 MB) and take the modules first created (`new`) shortly before the kernel's hangcheck time; disassemble (`spirv-dis`), find the variable read before its first store, write a `vk_shader_fix` (`<size>:<sha256>:<byte offset>:<words>` = inserted OpStores; log `vk shim: fixed shader module`); switch the dump off again |
|
||||
| OpenGL ES game: the GPU hangs at one effect (freeze/exit, compositor watchdog, e.g. Vader Immortal's lightspeed jump) | a shader (as Zink compiles it) reads loop counters/accumulators before setting them | capture the SPIR-V (adapter `zink_shader_dump=1` writes every module to `files/fp_spirv/`, or a GPU capture), then a `zink_shader_fix` entry (same format as `vk_shader_fix`) + `frame.zink_shader_fix` (Vulkan layer under Zink, activated through GraphicsEnv's debug layer list); log `shader fix layer: fixed shader module N`; triage `zink-shader-fix-mismatch` when a Frame update changed the driver's output |
|
||||
| 2D Android app: Steam says it's running but nothing shows | Lepton runs apps headless unless the app folder has `lepton-show-flatscreen` | FramePort adds it for apps without VR (agent v28+); reinstall apps installed before |
|
||||
| A phone/tablet app shows nothing in the headset, or a VR app opens as a flat window | FramePort's VR/2D guess (`vr_kind`) is wrong for this app | patch `device.display_mode` → **Flat window** or **VR** (Customize → "Show as VR or as a flat window"), then Update on Frame |
|
||||
| "Install with FrameDrop" button opens FrameDrop (or nothing) instead of FramePort | another program owns `framedrop://`, or the switch is off, or macOS (Flet can't receive links there) | Settings → Install links → **Use FramePort for these links**; else Add games → Install from a link… (paste the button's address) |
|
||||
| 2D Android game ignores the controllers: they only move a pointer, an SDL game keeps its touch controls ("no controller attached"); Android's log lists only `wayland_touch`/`wayland_keyboard`/`wayland_pointer` (GitHub #162, Skate 3) | Lepton's Android gets keyboard, pointer and touch from the Wayland seat only: no gamepad device | patch `device.steam_gamepad` (2D apps; suggested when the manifest declares `android.hardware.gamepad` or `LEANBACK_LAUNCHER`; no APK change, reinstall): the launcher puts FramePort's Podman wrapper (`~/.local/share/frameport/agent/bin/podman`) first on PATH, which bind-mounts Steam Input's virtual pads (uinput, 28de, "Microsoft X-Box 360 pad N") at `/dev/input/eventN` plus an Xbox 360 key layout, and exports `LEPTON_ENV_SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=1`. Check: launch.log `FramePort gamepad: Steam Input virtual gamepads for this container: event5 (…)` ("none" = Steam had no pad when the game started: Steam only makes it while the controllers are on), `podman exec lepton-steamlaunch-<appid> dumpsys input` lists the pad. Steam Input must be on for the shortcut. A pad Steam makes after the start (controllers woke later, reconnect) isn't seen until the next start. Off for one start: `FRAMEPORT_NO_GAMEPAD=1 %command%` |
|
||||
| 2D Android app: back/home/recents buttons cover the app's own buttons | Android's navigation bar | patch `device.hide_navbar` (on by default for apps without VR) sets `qemu.hw.mainkeys=1`; reinstall to apply |
|
||||
| Black screen, audio works, GLES engine with direct VrApi | Mesa rejects Quest-style GLSL | `frame.gl_shim` (logs `GLShim: SHADER COMPILE FAILED` + source lines) |
|
||||
| Black screen, audio works, log `Unsupported VrApi layer type N` | bridge drops frames containing that layer | extend the bridge (cylinder=3 is converted to a quad already) |
|
||||
@@ -224,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
@@ -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
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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(©);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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);}}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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])
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,50 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// SHA-256 (FIPS 180-4) for the Vulkan shim's shader fixes and dumps (static: included by vkshim.c and its host test).
|
||||
#pragma once
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
static const uint32_t K[64] = {
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01,
|
||||
0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
||||
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
|
||||
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08,
|
||||
0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
|
||||
#define ROR(x, n) (((x) >> (n)) | ((x) << (32 - (n))))
|
||||
|
||||
static void sha256_block(uint32_t h[8], const uint8_t *p) {
|
||||
uint32_t w[64];
|
||||
for (int i = 0; i < 16; i++) w[i] = (uint32_t)p[4 * i] << 24 | (uint32_t)p[4 * i + 1] << 16 | (uint32_t)p[4 * i + 2] << 8 | p[4 * i + 3];
|
||||
for (int i = 16; i < 64; i++) {
|
||||
uint32_t s0 = ROR(w[i - 15], 7) ^ ROR(w[i - 15], 18) ^ (w[i - 15] >> 3);
|
||||
uint32_t s1 = ROR(w[i - 2], 17) ^ ROR(w[i - 2], 19) ^ (w[i - 2] >> 10);
|
||||
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
|
||||
}
|
||||
uint32_t a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7];
|
||||
for (int i = 0; i < 64; i++) {
|
||||
uint32_t t1 = hh + (ROR(e, 6) ^ ROR(e, 11) ^ ROR(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i];
|
||||
uint32_t t2 = (ROR(a, 2) ^ ROR(a, 13) ^ ROR(a, 22)) + ((a & b) ^ (a & c) ^ (b & c));
|
||||
hh = g; g = f; f = e; e = d + t1; d = c; c = b; b = a; a = t1 + t2;
|
||||
}
|
||||
h[0] += a; h[1] += b; h[2] += c; h[3] += d; h[4] += e; h[5] += f; h[6] += g; h[7] += hh;
|
||||
}
|
||||
|
||||
static void sha256(const uint8_t *data, size_t len, uint8_t out[32]) {
|
||||
uint32_t h[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
|
||||
size_t i = 0;
|
||||
for (; i + 64 <= len; i += 64) sha256_block(h, data + i);
|
||||
uint8_t tail[128] = {0};
|
||||
size_t rest = len - i;
|
||||
memcpy(tail, data + i, rest);
|
||||
tail[rest] = 0x80;
|
||||
size_t blocks = rest + 9 > 64 ? 2 : 1;
|
||||
uint64_t bits = (uint64_t)len * 8;
|
||||
for (int b = 0; b < 8; b++) tail[blocks * 64 - 1 - b] = (uint8_t)(bits >> (8 * b));
|
||||
for (size_t b = 0; b < blocks; b++) sha256_block(h, tail + 64 * b);
|
||||
for (int b = 0; b < 8; b++) {
|
||||
out[4 * b] = h[b] >> 24; out[4 * b + 1] = h[b] >> 16; out[4 * b + 2] = h[b] >> 8; out[4 * b + 3] = h[b];
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Shader dump for the Vulkan shim (adapter setting vk_shader_dump=1, diagnostics): every distinct SPIR-V module the
|
||||
// game creates is written once to <files>/fp_vk_shaders/<size>_<sha256>.spv (written to a temporary name, then
|
||||
// renamed: a game killed mid-write leaves no partial module), and every vkCreateShaderModule call gets one line in
|
||||
// <files>/fp_vk_shaders/index.txt:
|
||||
// <seq> <ms since the process's first module> <unix ms> <size>_<sha256>.spv new|known|again|failed
|
||||
// new = first written now, known = already on disk from an earlier run, again = created before in this run,
|
||||
// failed = couldn't be written. Each
|
||||
// process start adds "# start <unix s> pid <pid>". With the kernel's "hangcheck detected gpu lockup" time (journal),
|
||||
// the modules created last before a GPU hang are the candidates for a vk_shader_fix. A module is hashed on every call
|
||||
// but written only on first sight; index lines are single O_APPEND writes (they survive the game being killed).
|
||||
// Needs LOG(...) and sha256() before inclusion (vkshim.c; tests/test_vk_shader_dump.py builds it for the host).
|
||||
#pragma once
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define DUMP_SET 16384 // first sights remembered per process (a power of two)
|
||||
#define DUMP_MAX_INDEX 200000 // index lines per process
|
||||
static int dump_on; // vk_shader_dump
|
||||
static char dump_dir[512];
|
||||
static int dump_index_fd = -1;
|
||||
static pthread_mutex_t dump_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static uint64_t dump_seen[DUMP_SET]; // first 8 digest bytes; 0 = empty
|
||||
static unsigned dump_nseen, dump_seq, dump_written;
|
||||
static int64_t dump_t0;
|
||||
|
||||
static int64_t dump_ms(clockid_t clock) {
|
||||
struct timespec ts;
|
||||
clock_gettime(clock, &ts);
|
||||
return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||||
}
|
||||
|
||||
// Turns the dump on for modules created from now on; `files` = the app's files dir. Returns 1 when it can write.
|
||||
static int dump_init(const char *files) {
|
||||
if (!files || !*files || strlen(files) > sizeof dump_dir - 64) return 0;
|
||||
snprintf(dump_dir, sizeof dump_dir, "%s/fp_vk_shaders", files);
|
||||
if (mkdir(dump_dir, 0775) != 0 && errno != EEXIST) {
|
||||
LOG("vk shim: shader dump: can't create %s (%s)", dump_dir, strerror(errno));
|
||||
return 0;
|
||||
}
|
||||
char path[600];
|
||||
snprintf(path, sizeof path, "%s/index.txt", dump_dir);
|
||||
dump_index_fd = open(path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0664);
|
||||
if (dump_index_fd < 0) {
|
||||
LOG("vk shim: shader dump: can't open %s (%s)", path, strerror(errno));
|
||||
return 0;
|
||||
}
|
||||
char line[96];
|
||||
int n = snprintf(line, sizeof line, "# start %lld pid %d\n", (long long)(dump_ms(CLOCK_REALTIME) / 1000),
|
||||
(int)getpid());
|
||||
if (write(dump_index_fd, line, (size_t)n) < 0) { /* the dump itself may still work */ }
|
||||
dump_t0 = dump_ms(CLOCK_MONOTONIC);
|
||||
dump_on = 1;
|
||||
LOG("vk shim: dumping SPIR-V modules to %s (index.txt: creation order and time)", dump_dir);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 1 = first sight in this process (remembered now), 0 = seen before (or the set is full: the file check decides)
|
||||
static int dump_first_sight(uint64_t key) {
|
||||
if (!key) key = 1;
|
||||
for (unsigned i = (unsigned)(key * 0x9E3779B97F4A7C15ull >> 50) & (DUMP_SET - 1), n = 0; n < DUMP_SET;
|
||||
i = (i + 1) & (DUMP_SET - 1), n++) {
|
||||
if (dump_seen[i] == key) return 0;
|
||||
if (!dump_seen[i]) {
|
||||
if (dump_nseen >= DUMP_SET * 3 / 4) return 1;
|
||||
dump_seen[i] = key;
|
||||
dump_nseen++;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void dump_module(const uint32_t *code, size_t size) {
|
||||
if (!dump_on || !code || !size) return;
|
||||
uint8_t digest[32];
|
||||
sha256((const uint8_t *)code, size, digest);
|
||||
uint64_t key = 0;
|
||||
memcpy(&key, digest, 8);
|
||||
pthread_mutex_lock(&dump_lock);
|
||||
unsigned seq = ++dump_seq;
|
||||
int first = dump_first_sight(key);
|
||||
pthread_mutex_unlock(&dump_lock);
|
||||
char name[96], path[700];
|
||||
int len = snprintf(name, sizeof name, "%zu_", size);
|
||||
for (int i = 0; i < 32; i++) len += snprintf(name + len, sizeof name - (size_t)len, "%02x", digest[i]);
|
||||
snprintf(name + len, sizeof name - (size_t)len, ".spv");
|
||||
snprintf(path, sizeof path, "%s/%s", dump_dir, name);
|
||||
const char *what = "again";
|
||||
if (first) {
|
||||
what = "known";
|
||||
if (access(path, F_OK) != 0) {
|
||||
char tmp[720];
|
||||
snprintf(tmp, sizeof tmp, "%s.%d.tmp", path, (int)gettid());
|
||||
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0664);
|
||||
ssize_t w = fd >= 0 ? write(fd, code, size) : -1;
|
||||
if (fd >= 0) close(fd);
|
||||
if (w == (ssize_t)size && rename(tmp, path) == 0) {
|
||||
what = "new";
|
||||
pthread_mutex_lock(&dump_lock);
|
||||
unsigned written = ++dump_written;
|
||||
pthread_mutex_unlock(&dump_lock);
|
||||
if (written == 1 || written % 500 == 0) LOG("vk shim: shader dump: %u module(s) written", written);
|
||||
} else {
|
||||
unlink(tmp);
|
||||
what = "failed";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dump_index_fd >= 0 && seq <= DUMP_MAX_INDEX) {
|
||||
char line[200];
|
||||
int n = snprintf(line, sizeof line, "%u %lld %lld %s %s\n", seq, (long long)(dump_ms(CLOCK_MONOTONIC) - dump_t0),
|
||||
(long long)dump_ms(CLOCK_REALTIME), name, what);
|
||||
if (write(dump_index_fd, line, (size_t)n) < 0) { /* best effort */ }
|
||||
if (seq == DUMP_MAX_INDEX) LOG("vk shim: shader dump: index full (%d lines)", DUMP_MAX_INDEX);
|
||||
}
|
||||
}
|
||||
+47
-14
@@ -15,9 +15,6 @@
|
||||
// copied with extra words inserted at a byte offset, e.g. stores that initialize locals a shader reads before writing
|
||||
// (an undefined loop counter hung the GPU in VR4's campaign). Every other module passes through unchanged.
|
||||
// Format: vk_shader_fix=<size>:<sha256 hex>:<byte offset>:<word>,<word>,...[;<next fix>]
|
||||
// Shader dump (vk_shader_dump=1, diagnostics): every distinct module the game creates is written once to the app's
|
||||
// files/fp_vk_shaders/ with an index of every creation (order, time), to find the shader behind a GPU hang
|
||||
// (shader_dump.h).
|
||||
//
|
||||
// Query slots (adapter setting vk_query_slots=2, per game): with multiview, Vulkan counts a query that runs in a
|
||||
// multiview render pass as one query per view (N consecutive indices). Mesa's drivers (the Frame's Turnip) write a
|
||||
@@ -102,8 +99,6 @@ static int spec_fixes; // vk_spec_fixes: make two Unreal habits valid Vulk
|
||||
#define VALIDATION_LAYER "VK_LAYER_KHRONOS_validation"
|
||||
static int validation; // vk_validation: add Khronos' validation layer (bundled in the APK) to the instance
|
||||
static int hide_fdm; // vk_hide_fdm: the game doesn't see VK_EXT_fragment_density_map(2)
|
||||
static int want_dump; // vk_shader_dump: write the game's SPIR-V modules to files/fp_vk_shaders/
|
||||
static char app_files[400]; // /sdcard/Android/data/<package>/files
|
||||
|
||||
static void read_settings(const char *path) {
|
||||
FILE *f = fopen(path, "r");
|
||||
@@ -114,7 +109,6 @@ static void read_settings(const char *path) {
|
||||
if (!strncmp(line, "vk_validation=", 14)) validation = atoi(line + 14) != 0;
|
||||
if (!strncmp(line, "vk_hide_fdm=", 12)) hide_fdm = atoi(line + 12) != 0;
|
||||
if (!strncmp(line, "vk_spec_fixes=", 14)) spec_fixes = atoi(line + 14) != 0;
|
||||
if (!strncmp(line, "vk_shader_dump=", 15)) want_dump = atoi(line + 15) != 0;
|
||||
if (!strncmp(line, "vk_query_slots=", 15)) {
|
||||
int v = atoi(line + 15);
|
||||
query_slots = v == 1 ? 2 : v >= 2 && v <= 4 ? v : 1; // 1 = on (the settings dialog's switch) = 2 slots
|
||||
@@ -146,17 +140,58 @@ static void read_all_settings(void) {
|
||||
}
|
||||
char *colon = strchr(pkg, ':');
|
||||
if (colon) *colon = 0;
|
||||
if (*pkg && !strchr(pkg, '/') && strlen(pkg) < 300) {
|
||||
snprintf(app_files, sizeof app_files, "/sdcard/Android/data/%s/files", pkg);
|
||||
snprintf(path, sizeof path, "%s/framebridge.conf", app_files);
|
||||
if (*pkg && !strchr(pkg, '/')) {
|
||||
snprintf(path, sizeof path, "/sdcard/Android/data/%s/files/framebridge.conf", pkg);
|
||||
read_settings(path);
|
||||
}
|
||||
const char *env = getenv("FRAMEBRIDGE_CONFIG");
|
||||
if (env && *env) read_settings(env);
|
||||
}
|
||||
|
||||
#include "sha256.h" // only run for modules whose size matches a fix, or every module with vk_shader_dump
|
||||
#include "shader_dump.h"
|
||||
// SHA-256 (FIPS 180-4), only run for modules whose size matches a fix
|
||||
static const uint32_t K[64] = {
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01,
|
||||
0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
||||
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
|
||||
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08,
|
||||
0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
|
||||
#define ROR(x, n) (((x) >> (n)) | ((x) << (32 - (n))))
|
||||
|
||||
static void sha256_block(uint32_t h[8], const uint8_t *p) {
|
||||
uint32_t w[64];
|
||||
for (int i = 0; i < 16; i++) w[i] = (uint32_t)p[4 * i] << 24 | (uint32_t)p[4 * i + 1] << 16 | (uint32_t)p[4 * i + 2] << 8 | p[4 * i + 3];
|
||||
for (int i = 16; i < 64; i++) {
|
||||
uint32_t s0 = ROR(w[i - 15], 7) ^ ROR(w[i - 15], 18) ^ (w[i - 15] >> 3);
|
||||
uint32_t s1 = ROR(w[i - 2], 17) ^ ROR(w[i - 2], 19) ^ (w[i - 2] >> 10);
|
||||
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
|
||||
}
|
||||
uint32_t a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7];
|
||||
for (int i = 0; i < 64; i++) {
|
||||
uint32_t t1 = hh + (ROR(e, 6) ^ ROR(e, 11) ^ ROR(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i];
|
||||
uint32_t t2 = (ROR(a, 2) ^ ROR(a, 13) ^ ROR(a, 22)) + ((a & b) ^ (a & c) ^ (b & c));
|
||||
hh = g; g = f; f = e; e = d + t1; d = c; c = b; b = a; a = t1 + t2;
|
||||
}
|
||||
h[0] += a; h[1] += b; h[2] += c; h[3] += d; h[4] += e; h[5] += f; h[6] += g; h[7] += hh;
|
||||
}
|
||||
|
||||
static void sha256(const uint8_t *data, size_t len, uint8_t out[32]) {
|
||||
uint32_t h[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
|
||||
size_t i = 0;
|
||||
for (; i + 64 <= len; i += 64) sha256_block(h, data + i);
|
||||
uint8_t tail[128] = {0};
|
||||
size_t rest = len - i;
|
||||
memcpy(tail, data + i, rest);
|
||||
tail[rest] = 0x80;
|
||||
size_t blocks = rest + 9 > 64 ? 2 : 1;
|
||||
uint64_t bits = (uint64_t)len * 8;
|
||||
for (int b = 0; b < 8; b++) tail[blocks * 64 - 1 - b] = (uint8_t)(bits >> (8 * b));
|
||||
for (size_t b = 0; b < blocks; b++) sha256_block(h, tail + 64 * b);
|
||||
for (int b = 0; b < 8; b++) {
|
||||
out[4 * b] = h[b] >> 24; out[4 * b + 1] = h[b] >> 16; out[4 * b + 2] = h[b] >> 8; out[4 * b + 3] = h[b];
|
||||
}
|
||||
}
|
||||
|
||||
// The fixed copy of `code` (caller frees) and its size, or NULL when no fix matches.
|
||||
static uint32_t *fixed_shader(const uint32_t *code, size_t size, size_t *out_size) {
|
||||
@@ -183,7 +218,6 @@ static void init(void) {
|
||||
if (query_slots > 1) LOG("vk shim: %d slots per occlusion query", query_slots);
|
||||
if (validation) LOG("vk shim: adding %s to the instance", VALIDATION_LAYER);
|
||||
if (hide_fdm) LOG("vk shim: fragment density map extensions hidden from the game");
|
||||
if (want_dump && !dump_init(app_files)) LOG("vk shim: shader dump not possible (no package name or files dir)");
|
||||
if (spec_fixes) LOG("vk shim: Vulkan spec fixes on (depth images can be cleared, no depth resolve in shader-resolve "
|
||||
"subpasses)");
|
||||
real_vk = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL);
|
||||
@@ -465,7 +499,6 @@ EXPORT VKAPI_ATTR VkResult VKAPI_CALL vkCreateShaderModule(VkDevice device, cons
|
||||
PFN_vkCreateShaderModule real = (PFN_vkCreateShaderModule)device_fn(device, FN_CSM);
|
||||
if (!real) real = real_csm_trampoline;
|
||||
if (!real) return VK_ERROR_INITIALIZATION_FAILED;
|
||||
if (dump_on && ci) dump_module(ci->pCode, ci->codeSize); // the game's own module, before any fix
|
||||
size_t size = 0;
|
||||
uint32_t *code = nfixes && ci && ci->pCode ? fixed_shader(ci->pCode, ci->codeSize, &size) : NULL;
|
||||
if (!code) return real(device, ci, alloc, module);
|
||||
@@ -855,7 +888,7 @@ static PFN_vkVoidFunction wrap(const char *name) {
|
||||
switch (wrapped_index(name)) {
|
||||
case FN_RP2: return (PFN_vkVoidFunction)vkCreateRenderPass2;
|
||||
case FN_RP2KHR: return (PFN_vkVoidFunction)create_render_pass2_khr;
|
||||
case FN_CSM: return nfixes || dump_on ? (PFN_vkVoidFunction)vkCreateShaderModule : NULL; // else no detour
|
||||
case FN_CSM: return nfixes ? (PFN_vkVoidFunction)vkCreateShaderModule : NULL; // nothing to fix: no detour
|
||||
case FN_CIMG: return spec_fixes ? (PFN_vkVoidFunction)create_image : NULL;
|
||||
case FN_PB: return spec_fixes ? (PFN_vkVoidFunction)cmd_pipeline_barrier : NULL;
|
||||
case FN_PB2: return spec_fixes ? (PFN_vkVoidFunction)cmd_pipeline_barrier2 : NULL;
|
||||
|
||||
@@ -27,10 +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)
|
||||
# 9: gamepad (the manifest declares gamepad support: android.hardware.gamepad / Android TV's LEANBACK_LAUNCHER:
|
||||
# device.steam_gamepad) (2026-10)
|
||||
ANALYSIS_VERSION = 9
|
||||
ANALYSIS_VERSION = 7
|
||||
|
||||
|
||||
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
|
||||
@@ -152,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")
|
||||
@@ -173,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 = {}
|
||||
@@ -295,9 +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,
|
||||
# declares gamepad support (SDL's manifest template has both): device.steam_gamepad
|
||||
"gamepad": "android.hardware.gamepad" in features or axml.LEANBACK_LAUNCHER in cats,
|
||||
"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""),
|
||||
|
||||
@@ -21,7 +21,6 @@ TYPE_INT_HEX = 0x11
|
||||
ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
||||
LAUNCHER = "android.intent.category.LAUNCHER"
|
||||
INFO = "android.intent.category.INFO"
|
||||
LEANBACK_LAUNCHER = "android.intent.category.LEANBACK_LAUNCHER" # Android TV's launcher (apps made for a gamepad)
|
||||
MAIN = "android.intent.action.MAIN"
|
||||
DEFAULT = "android.intent.category.DEFAULT"
|
||||
VR_CATEGORY = "com.oculus.intent.category.VR"
|
||||
|
||||
@@ -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
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
Frame or this app (see docs/DIAGNOSTICS.md for the layout). Users attach it to a GitHub issue."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
@@ -43,8 +41,6 @@ Created by FramePort {version} on {created}. Personal data was replaced by place
|
||||
| games/<pkg>/logs/ | launch-test logs saved on the PC |
|
||||
| games/<pkg>/target/ | from the Frame (or PC): launch.sh, settings.conf, deployment.json, launch/logcat/Proton/game"""
|
||||
""" logs, file list |
|
||||
| games/<pkg>/target/shaders/<dir>/ | shader dumps (vk_shader_dump: fp_vk_shaders, zink_shader_dump: fp_spirv): the"""
|
||||
""" newest SPIR-V modules (binary, not redacted: compiled game shaders) and index.txt (creation order and time) |
|
||||
|
||||
## Debugging from this bundle
|
||||
1. `frameport diag inspect <this zip>` re-runs triage with the current signatures (catalog/triage.yaml).
|
||||
@@ -113,28 +109,6 @@ class _Writer:
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ pieces
|
||||
def add_shader_dumps(w: _Writer, prefix: str, shaders: dict) -> None:
|
||||
"""The agent's shader dumps (collect_diag "shaders", agent >= 72): SPIR-V modules as they are (compiled game
|
||||
shaders, nothing personal), the index as redacted text."""
|
||||
for folder, res in shaders.items():
|
||||
if not isinstance(res, dict) or folder not in ("fp_vk_shaders", "fp_spirv"):
|
||||
continue
|
||||
base = f"{prefix}{folder}/"
|
||||
if res.get("index"):
|
||||
w.text(base + "index.txt", res["index"])
|
||||
modules = res.get("modules") or {}
|
||||
for name, b64 in modules.items():
|
||||
if not re.fullmatch(r"\d+_[0-9a-f]{64}\.spv", name):
|
||||
continue
|
||||
try:
|
||||
w.files[base + name] = base64.b64decode(b64, validate=True)
|
||||
except (ValueError, TypeError):
|
||||
w.warnings.append(f"{base}{name}: not base64")
|
||||
left = int(res.get("total") or 0) - len(modules)
|
||||
if left > 0:
|
||||
w.warnings.append(f"{base}: {left} older module(s) stayed on the Frame (size limit)")
|
||||
|
||||
|
||||
def env_info(target_info: dict | None = None) -> dict:
|
||||
"""Versions and platform facts (manifest.json and the "environment" of GitHub issues)."""
|
||||
from ..frame.connection import bundled_agent_version
|
||||
@@ -330,7 +304,6 @@ def _game(w: _Writer, g: dict, target, reporter: Reporter) -> dict:
|
||||
w.text(d + "target/" + name.replace("/", "_"), text or "")
|
||||
if remote.get("listing"):
|
||||
w.json(d + "target/files.json", remote["listing"])
|
||||
add_shader_dumps(w, d + "target/shaders/", remote.get("shaders") or {})
|
||||
if not remote.get("installed", True):
|
||||
w.warnings.append(f"{pkg}: not installed on {target.label}")
|
||||
return {"package": pkg, "title": g.get("title"), "kind": g.get("kind", "quest"), "status": g.get("status"),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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": "",
|
||||
@@ -243,7 +243,6 @@
|
||||
"Continue": "",
|
||||
"Controller input diagnostics (log)": "",
|
||||
"Controllers": "",
|
||||
"Controllers as a gamepad (Steam Input)": "",
|
||||
"Converts Quest games to OpenXR": "",
|
||||
"Copied": "",
|
||||
"Copies the game's files, data and saves to the other drive, checks the copy and then removes the old one. The Steam entry, settings and saves stay valid. The game must be closed. A game on a microSD card only starts while the card is inserted.": "",
|
||||
@@ -283,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…": "",
|
||||
@@ -378,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)": "",
|
||||
@@ -461,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.": "",
|
||||
@@ -511,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": "",
|
||||
@@ -532,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": "",
|
||||
@@ -569,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": "",
|
||||
@@ -655,7 +653,6 @@
|
||||
"Lepton only launches an activity with category LAUNCHER (Quest apps often use INFO). Symptom when missing: launch.log says 'APP_ACTIVITY is empty' and nothing starts.": "",
|
||||
"Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as \"Gamescope\" (the Frame's compositor). Pairs with \"Make Unity text fields work without a system keyboard\".": "",
|
||||
"Lepton's Android has no clipboard service, and SDL's Java code (SDL2 and LÖVE games, e.g. Dramatic Shape) registers a clipboard listener at start without checking, so the app crashes with a NullPointerException in SDLClipboardHandler. That call is skipped (an in-place edit of classes.dex).": "",
|
||||
"Lepton's Android only gets a keyboard, a pointer and touch from the Frame, so a 2D game never sees a controller (the controllers only move the pointer; SDL games show their touch controls). This passes Steam Input's virtual gamepad (\"Microsoft X-Box 360 pad\", made by Steam for the Frame's controllers and paired Bluetooth pads) into this app's container with an Xbox 360 key layout, and tells SDL games to use it (SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD). Steam Input must be on for the game's shortcut; the controllers must be on when the game starts (a pad Steam makes later isn't seen until the next start). The APK isn't changed. Off for one start: FRAMEPORT_NO_GAMEPAD=1 in the game's launch options.": "",
|
||||
"Lets games made for Oculus PCs run on SteamVR.": "",
|
||||
"Lets games start that would stop on Meta's avatar system (e.g. BlazeRush); no Meta avatars.": "",
|
||||
"Lets games with Vivox voice chat start on the Frame (they'd crash on an Android 12 audio call).": "",
|
||||
@@ -668,7 +665,6 @@
|
||||
"Lets the game find content it keeps in separate files next to its data (e.g. Star Wars Tales' seasons).": "",
|
||||
"Lets the game find language files (like de.lang) that are already in its data, instead of waiting for a download that never comes.": "",
|
||||
"Lets the game leave its loading screen: it waited for a Quest-only step that never starts on the Frame (e.g. Vader Immortal).": "",
|
||||
"Lets the game use your controllers as a gamepad instead of a pointer (2D games that show touch controls, e.g. SDL games).": "",
|
||||
"Lets typing reach this app: Steam's on-screen keyboard or your computer's keyboard (VR is unaffected).": "",
|
||||
"Lets Unity games accept a headset that isn't a Quest.": "",
|
||||
"Lets Unity start the graphics itself instead of Stress Level Zero's plugin, which crashes on the Frame (e.g. BONELAB 1.2974).": "",
|
||||
@@ -747,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)": "",
|
||||
@@ -906,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": "",
|
||||
@@ -996,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…": "",
|
||||
@@ -1074,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": "",
|
||||
@@ -1149,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.": "",
|
||||
@@ -1279,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.": "",
|
||||
@@ -1418,13 +1414,11 @@
|
||||
"VR (OpenXR)": "",
|
||||
"VrApi → OpenXR adapter (OVRPort)": "",
|
||||
"VrApi → OpenXR bridge": "",
|
||||
"Vulkan shader dump (diagnostics)": "",
|
||||
"Vulkan shader fixes": "",
|
||||
"Vulkan shim (frame.vk_sanitize): makes two Unreal habits valid Vulkan - depth images get VK_IMAGE_USAGE_TRANSFER_DST_BIT (they are cleared with vkCmdClearDepthStencilImage) and Qualcomm shader-resolve subpasses lose an invalid depth resolve. Found with the validation layer in Into The Radius 2 (flickering models, windows behind models). Also leaves out image barriers without an image and refuses image views without one, which crash the Frame's driver (e.g. Metro Awakening).": "",
|
||||
"Vulkan shim (frame.vk_sanitize): on gives every occlusion query room for both eyes. In multiview passes the Frame's driver writes a zero result for the second eye into the next query, so engines that count on one slot cull visible objects (models popping in and out, e.g. Into The Radius 2). 0 = off, 1 = on (2 slots).": "",
|
||||
"Vulkan shim (frame.vk_sanitize): SPIR-V modules matching size + SHA-256 get words inserted at a byte offset, e.g. stores that initialize locals a shader reads before writing (an undefined loop counter hung the GPU in VR4's campaign). Format: <size>:<sha256>:<byte offset>:<word>,<word>,...; several separated by ';'. Comes from a game's recipe.": "",
|
||||
"Vulkan shim (frame.vk_sanitize): the game doesn't see VK_EXT_fragment_density_map(2). Unreal Engine 5 turns on fragment-density-map foveation when the driver offers it and expects the density map from the headset, which the Frame doesn't provide: image views and barriers for a missing image, then a crash in the Frame's Vulkan driver (e.g. Metro Awakening). Valve's own foveation layer isn't affected.": "",
|
||||
"Vulkan shim (frame.vk_sanitize): writes every distinct SPIR-V module the game creates to Android/data/<package>/files/fp_vk_shaders/<size>_<sha256>.spv, with index.txt listing every creation in order with its time, to find the shader behind a GPU hang and capture it for a new vk_shader_fix. Diagnostics include the newest modules. No effect on the game.": "",
|
||||
"Vulkan shim: hide fragment density maps": "",
|
||||
"Vulkan shim: validation layer": "",
|
||||
"Vulkan: drop invalid render-pass pointers": "",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
@@ -181,11 +177,6 @@ SETTINGS = [
|
||||
"Shader-fix Vulkan layer (frame.zink_shader_fix): writes every distinct SPIR-V module the GL driver creates to "
|
||||
"Android/data/<package>/files/fp_spirv/<size>_<sha256>.spv, to capture a shader for a new zink_shader_fix. No "
|
||||
"effect on the game."),
|
||||
("vk_shader_dump", "int", 0, "Vulkan shader dump (diagnostics)",
|
||||
"Vulkan shim (frame.vk_sanitize): writes every distinct SPIR-V module the game creates to "
|
||||
"Android/data/<package>/files/fp_vk_shaders/<size>_<sha256>.spv, with index.txt listing every creation in order "
|
||||
"with its time, to find the shader behind a GPU hang and capture it for a new vk_shader_fix. Diagnostics include "
|
||||
"the newest modules. No effect on the game."),
|
||||
("vk_query_slots", "int", 0, "Vulkan shim: slots per occlusion query",
|
||||
"Vulkan shim (frame.vk_sanitize): on gives every occlusion query room for both eyes. In multiview passes the "
|
||||
"Frame's driver writes a zero result for the second eye into the next query, so engines that count on one slot "
|
||||
@@ -229,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.
|
||||
@@ -343,7 +331,7 @@ UI: dict[str, dict] = {
|
||||
"mutable_fix", "flip_quads", "swap_eyes", "vk_validation", "rect_clamp", "gl_hide_msrtt", "strip_color_bias",
|
||||
"snapshot", "strip_depth", "respace_kick", "layer_debug", "eye_debug", "input_diag", "release_wait",
|
||||
"vk_hide_fdm", "ovrp_begin_gate", "ovrp_hold_physics", "pose_debug",
|
||||
"zink_shader_dump", "vk_shader_dump", "gl_mv_debug")},
|
||||
"zink_shader_dump", "gl_mv_debug")},
|
||||
}
|
||||
|
||||
|
||||
@@ -423,12 +411,9 @@ class AdapterSetting(Patch):
|
||||
"vk_hide_fdm": lambda a: a.engine == "Unreal", # Unreal's Vulkan isn't always detected (Metro Awakening)
|
||||
"vk_query_slots": lambda a: a.engine == "Unreal" and ap.is_vulkan(a),
|
||||
"vk_validation": lambda a: a.engine == "Unreal",
|
||||
# wherever the Vulkan shim can be (vk_sanitize); Unreal's Vulkan builds are rarely detected as such
|
||||
"vk_shader_dump": lambda a: a.engine in ("Unreal", "Other") and "arm64-v8a" in a.abis,
|
||||
"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),
|
||||
@@ -523,34 +508,6 @@ class HideNavBar(Patch):
|
||||
ctx.env.update(self.ENV)
|
||||
|
||||
|
||||
class SteamGamepad(Patch):
|
||||
id = "device.steam_gamepad"
|
||||
title = "Controllers as a gamepad (Steam Input)"
|
||||
description = ("Lepton's Android only gets a keyboard, a pointer and touch from the Frame, so a 2D game never sees "
|
||||
"a controller (the controllers only move the pointer; SDL games show their touch controls). This "
|
||||
"passes Steam Input's virtual gamepad (\"Microsoft X-Box 360 pad\", made by Steam for the Frame's "
|
||||
"controllers and paired Bluetooth pads) into this app's container with an Xbox 360 key layout, and "
|
||||
"tells SDL games to use it (SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD). Steam Input must be "
|
||||
"on for the game's shortcut; the controllers must be on when the game starts (a pad Steam makes "
|
||||
"later isn't seen until the next start). The APK isn't changed. Off for one start: "
|
||||
"FRAMEPORT_NO_GAMEPAD=1 in the game's launch options.")
|
||||
category = "device"
|
||||
needs_vr = False
|
||||
stage = "install" # the agent reads it from the recipe at finalize (deployment.json steam_gamepad)
|
||||
|
||||
def applies(self, a):
|
||||
return a.vr_kind == "none"
|
||||
|
||||
def detect(self, a):
|
||||
if a.vr_kind == "none" and (a.extra or {}).get("gamepad"):
|
||||
return Suggestion(True, "2D app that declares gamepad support (android.hardware.gamepad or Android TV's "
|
||||
"launcher): pass the controllers to it as a gamepad.")
|
||||
return None
|
||||
|
||||
def install(self, ctx: InstallContext) -> None:
|
||||
pass # the launcher line (agent: GAMEPAD_LINE) exports SDL's hint and puts the Podman wrapper on PATH
|
||||
|
||||
|
||||
class Foveation(Patch):
|
||||
id = "device.foveation"
|
||||
title = "Eye-tracked foveation (Valve)"
|
||||
@@ -618,7 +575,6 @@ for _spec in SETTINGS:
|
||||
register(DeviceFiles)
|
||||
register(LeptonEnv)
|
||||
register(HideNavBar)
|
||||
register(SteamGamepad)
|
||||
register(TextInputWindow)
|
||||
register(DisplayMode)
|
||||
register(Foveation)
|
||||
@@ -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).",
|
||||
@@ -106,8 +104,6 @@ SUMMARIES = {
|
||||
"(VR is unaffected).",
|
||||
"device.display_mode": "Choose \"Flat window\" if a phone/tablet app shows nothing in the headset, \"VR\" if a VR "
|
||||
"app opens as a flat window.",
|
||||
"device.steam_gamepad": "Lets the game use your controllers as a gamepad instead of a pointer (2D games that "
|
||||
"show touch controls, e.g. SDL games).",
|
||||
"device.lepton_env": "Extra settings for the Android container (for testing).",
|
||||
"device.foveation": "Try \"Fixed\" or \"Off\" if one eye shimmers or jitters while menus look fine.",
|
||||
# PC VR
|
||||
|
||||
+47
-30
@@ -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"):
|
||||
@@ -695,16 +705,11 @@ def prepare_as_is(package: str, reporter: Reporter) -> dict:
|
||||
art, store_title = artwork.fetch(package, apk)
|
||||
info = {"apk": str(apk), "alt_apk": None, "sha256": sha256(apk), "alt_sha256": None, "applied": [],
|
||||
"checks": checks, "ok": all(c["ok"] is not False for c in checks), "as_is": True,
|
||||
"recipe_fp": recipe_fingerprint(entry["recipe"], package), "source_version": _source_version(entry)}
|
||||
"recipe_fp": recipe_fingerprint(entry["recipe"], package)}
|
||||
library.upsert_game(package, build=info, title=entry.get("title") or store_title)
|
||||
return info
|
||||
|
||||
|
||||
def _source_version(entry: dict) -> str | None:
|
||||
"""The game version a build was made from: a newer APK added later shows the game as outdated (GitHub #161)."""
|
||||
return (entry.get("analysis") or {}).get("version") or None
|
||||
|
||||
|
||||
def recipe_fingerprint(recipe: dict, package: str = "") -> str:
|
||||
from .patches.base import recipe_fingerprint as fp
|
||||
|
||||
@@ -720,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)
|
||||
@@ -733,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),
|
||||
"superseded": res.meta.get("superseded") or {}, "source_version": _source_version(entry)}
|
||||
library.upsert_game(package, build=build_info, title=entry.get("title") or store_title)
|
||||
"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_key(entry, pc): build_info}, title=entry.get("title") or store_title)
|
||||
return build_info
|
||||
|
||||
|
||||
@@ -754,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"]))
|
||||
@@ -890,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
|
||||
|
||||
|
||||
@@ -967,24 +990,18 @@ def proton_alternative_worth_trying(package: str, verdict: str | None) -> bool:
|
||||
|
||||
def useful_suggestions(package: str, suggestions: list[str]) -> list[str]:
|
||||
"""Triage suggestions that would change something: not already on (a value suggestion: not already that value),
|
||||
applicable to the game, and not conflicting with the recipe (e.g. no Revive for a repack that runs its own)."""
|
||||
and not conflicting with the recipe (e.g. no Revive for a repack that runs its own)."""
|
||||
from .patches.base import REGISTRY
|
||||
from .validate.triage import split_suggestion
|
||||
|
||||
entry = library.game(package) or {}
|
||||
patches = (entry.get("recipe") or {}).get("patches") or {}
|
||||
try:
|
||||
analysis = library.analysis_from_dict(entry["analysis"]) if entry.get("analysis") else None
|
||||
except Exception: # noqa: BLE001
|
||||
analysis = None
|
||||
out = []
|
||||
for s in suggestions:
|
||||
pid, value = split_suggestion(s)
|
||||
p = REGISTRY.get(pid)
|
||||
if p and any(c in patches for c in p.conflicts):
|
||||
continue
|
||||
if p and analysis is not None and not p.applies(analysis):
|
||||
continue # can't matter for this game, e.g. GLES-only 360° emulation for a Vulkan game (GitHub #138)
|
||||
if pid in patches and (value is None or _same_value((patches[pid] or {}).get("value"), value)):
|
||||
continue
|
||||
if s not in out:
|
||||
|
||||
@@ -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()
|
||||
@@ -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 {})}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -116,9 +116,6 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
|
||||
return "outdated"
|
||||
if built and dep.get("sha256") in built and recipe_changed(game):
|
||||
return "outdated" # the recipe changed since this build (e.g. a catalog fix): it needs a new build
|
||||
if built and dep.get("sha256") in built and b.get("source_version") and \
|
||||
b["source_version"] != (game.get("analysis") or {}).get("version"):
|
||||
return "outdated" # a newer (or older) APK of the game was added since this build (GitHub #161)
|
||||
return "outdated" if settings_diff(game, frame_info) else "installed"
|
||||
|
||||
|
||||
@@ -134,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
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -82,27 +82,11 @@ 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
|
||||
|
||||
|
||||
# suggestions that only work with one graphics API: the Vulkan shim's settings for Vulkan games, the shader-fix layer
|
||||
# under Zink for OpenGL ES games
|
||||
API_ONLY = {"adapter.vk_shader_dump": "vulkan", "adapter.vk_shader_fix": "vulkan",
|
||||
"adapter.zink_shader_dump": "gles", "adapter.zink_shader_fix": "gles"}
|
||||
SWAPCHAIN_FORMAT = re.compile(r"xrCreateSwapchain \d+x\d+ format=(\d+) .*result=0\b")
|
||||
|
||||
|
||||
def graphics_api(text: str) -> str | None:
|
||||
""""vulkan" or "gles" from the swapchain formats FrameBridge logged: OpenXR passes the API's own format numbers,
|
||||
Vulkan's VkFormat values are small (e.g. 43 = sRGB), GL's internal formats are 0x8000+ (35907 = GL_SRGB8_ALPHA8).
|
||||
None when the log has no (or both kinds of) swapchains."""
|
||||
kinds = {"gles" if int(m) >= 0x1000 else "vulkan" for m in SWAPCHAIN_FORMAT.findall(text)}
|
||||
return kinds.pop() if len(kinds) == 1 else None
|
||||
|
||||
|
||||
def split_suggestion(s: str) -> tuple[str, str | None]:
|
||||
"""A suggestion is a patch id, or a value for an adapter setting ("adapter.focus_hold_ms=2500")."""
|
||||
pid, eq, value = s.partition("=")
|
||||
@@ -110,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()]
|
||||
@@ -137,10 +123,6 @@ def triage(log: str, state: str = "UNKNOWN", package: str | None = None, crash:
|
||||
res.findings.append(Finding(sig["id"], sig["severity"], sig["diagnosis"], list(sig.get("suggest") or []),
|
||||
line.strip()[:300], bool(sig.get("use_alt")), sig.get("question") or "",
|
||||
bool(sig.get("report"))))
|
||||
api = graphics_api(text)
|
||||
if api: # a setting for the other graphics API can't help (e.g. the GPU-hang shader dumps)
|
||||
for f in res.findings:
|
||||
f.suggest = [s for s in f.suggest if API_ONLY.get(split_suggestion(s)[0], api) == api]
|
||||
# a root-cause finding hides the generic crash findings it explains
|
||||
hidden = {h for sig in db["signatures"] if any(f.id == sig["id"] for f in res.findings)
|
||||
for h in sig.get("supersedes") or []}
|
||||
|
||||
-76
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
@@ -115,7 +115,7 @@ def test_gpu_hang_from_kernel_lines():
|
||||
kernel = "kernel: 1790000000.0 steamos kernel: msm_dpu: [drm:a6xx_hangcheck] hangcheck detected gpu lockup\n"
|
||||
r = triage(START, "UNKNOWN", PKG, kernel=kernel)
|
||||
f = next(f for f in r.findings if f.id == "gpu-hang")
|
||||
assert f.report and f.suggest == ["adapter.vk_shader_dump", "adapter.zink_shader_dump"] and f.severity == "fatal"
|
||||
assert f.report and f.suggest == ["adapter.zink_shader_dump"] and f.severity == "fatal"
|
||||
assert "gpu-hang" not in {f.id for f in triage(START + "hangcheck in an app's own log\n", "UNKNOWN", PKG).findings}
|
||||
|
||||
|
||||
@@ -265,12 +265,3 @@ def test_last_session_callout():
|
||||
assert view.session_callout() is None
|
||||
view.g = {"package": PKG, "last_session": {"findings": [ls["findings"][3]]}}
|
||||
assert view.session_callout() is None # nothing worth showing
|
||||
|
||||
|
||||
def test_suggestions_that_cant_matter_for_the_game_are_dropped():
|
||||
"""GitHub #138: a Vulkan game (AC Nexus) was offered the GLES-only 360° emulation."""
|
||||
add_game()
|
||||
library.update_game(PKG, lambda e: e["analysis"].update(graphics="Vulkan"))
|
||||
assert pipeline.useful_suggestions(PKG, ["adapter.equirect_emul", "adapter.scale=0.85"]) == ["adapter.scale=0.85"]
|
||||
library.update_game(PKG, lambda e: e["analysis"].update(graphics="GLES3"))
|
||||
assert pipeline.useful_suggestions(PKG, ["adapter.equirect_emul"]) == ["adapter.equirect_emul"]
|
||||
@@ -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)
|
||||
@@ -1,286 +0,0 @@
|
||||
"""device.steam_gamepad (GitHub #162): Steam Input's virtual gamepad passed into a 2D app's Lepton container by
|
||||
FramePort's Podman wrapper, chosen per game; works with or without the hardware video codec wrapper."""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core.models import Analysis
|
||||
from frameport.patches import base
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
NEEDS_POSIX = pytest.mark.skipif(sys.platform == "win32" or not shutil.which("bash"), reason="Linux paths and bash")
|
||||
|
||||
|
||||
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("gamepad_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"))
|
||||
monkeypatch.setattr(agent, "PODMAN_BIN", str(tmp_path / "agent/bin"))
|
||||
return agent
|
||||
|
||||
|
||||
# capabilities/key of an Xbox 360 pad (BTN_SOUTH..BTN_THUMBR set) and of steamos-manager's keys (no gamepad buttons),
|
||||
# as the Frame's kernel prints them (64-bit words, most significant first, not zero-padded)
|
||||
PAD_KEYS = "7cdb000000000000 0 0 0 0"
|
||||
MANAGER_KEYS = "568000000000 4040860000000"
|
||||
|
||||
|
||||
def fake_input(root, number, vendor, product, name, keys, virtual=True):
|
||||
"""sysfs (class/input/eventN -> devices/.../inputN/eventN, device -> ..) and its /dev/input node."""
|
||||
parent = "devices/virtual/input" if virtual else "devices/platform/soc/usb1/input"
|
||||
device = root / "sys" / parent / f"input{number}"
|
||||
event = device / f"event{number}"
|
||||
(device / "id").mkdir(parents=True)
|
||||
(device / "capabilities").mkdir()
|
||||
event.mkdir()
|
||||
(event / "device").symlink_to("..")
|
||||
(device / "id/vendor").write_text(vendor + "\n")
|
||||
(device / "id/product").write_text(product + "\n")
|
||||
(device / "name").write_text(name + "\n")
|
||||
(device / "capabilities/key").write_text(keys + "\n")
|
||||
(root / "sys/class/input").mkdir(parents=True, exist_ok=True)
|
||||
(root / "sys/class/input" / f"event{number}").symlink_to(event)
|
||||
(root / "dev/input").mkdir(parents=True, exist_ok=True)
|
||||
(root / "dev/input" / f"event{number}").write_text("")
|
||||
|
||||
|
||||
def frame_inputs(root):
|
||||
fake_input(root, 4, "28de", "0000", "steamos-manager", MANAGER_KEYS)
|
||||
fake_input(root, 5, "28de", "11ff", "Microsoft X-Box 360 pad 0", PAD_KEYS)
|
||||
fake_input(root, 6, "28de", "1205", "Steam Deck", PAD_KEYS, virtual=False)
|
||||
fake_input(root, 7, "045e", "028e", "Microsoft X-Box 360 pad", PAD_KEYS)
|
||||
|
||||
|
||||
RUN = ["run", "--rm", "--name", "lepton-steamlaunch-123", "--rootfs", "/lepton/rootfs:O", "/init"]
|
||||
ENV = {"FRAMEPORT_GAMEPAD": "1", "SteamAppId": "123"}
|
||||
|
||||
|
||||
@NEEDS_POSIX
|
||||
def test_only_steam_input_virtual_pads_are_found(monkeypatch, tmp_path):
|
||||
agent = agent_module(monkeypatch, tmp_path)
|
||||
frame_inputs(tmp_path)
|
||||
pads = agent.steam_gamepads(str(tmp_path / "sys"), str(tmp_path / "dev"))
|
||||
assert pads == [{"event": "event5", "product": "11ff", "name": "Microsoft X-Box 360 pad 0"}]
|
||||
assert agent.has_key_bit(PAD_KEYS, agent.BTN_SOUTH) and not agent.has_key_bit(MANAGER_KEYS, agent.BTN_SOUTH)
|
||||
assert not agent.has_key_bit("", agent.BTN_SOUTH) and not agent.has_key_bit("zz 0 0 0 0", agent.BTN_SOUTH)
|
||||
|
||||
|
||||
@NEEDS_POSIX
|
||||
def test_run_args_get_the_pads_and_their_key_layout(monkeypatch, tmp_path):
|
||||
agent = agent_module(monkeypatch, tmp_path)
|
||||
frame_inputs(tmp_path)
|
||||
fake_input(tmp_path, 8, "28de", "11ff", "Microsoft X-Box 360 pad 1", PAD_KEYS)
|
||||
sys_root, dev_root = str(tmp_path / "sys"), str(tmp_path / "dev")
|
||||
out = agent.podman_run_args(RUN, ENV, sys_root, dev_root)
|
||||
layout = tmp_path / "agent/steam-gamepad.kl"
|
||||
assert out == ["run",
|
||||
"--mount", f"type=bind,source={dev_root}/input/event5,destination=/dev/input/event5,rw",
|
||||
"--mount", f"type=bind,source={dev_root}/input/event8,destination=/dev/input/event8,rw",
|
||||
"--mount", f"type=bind,source={layout},destination=/system/usr/keylayout/"
|
||||
"Vendor_28de_Product_11ff.kl,ro",
|
||||
*RUN[1:]]
|
||||
assert layout.read_text() == agent.GAMEPAD_KL
|
||||
assert "key 304 BUTTON_A" in agent.GAMEPAD_KL and "axis 0x05 RTRIGGER" in agent.GAMEPAD_KL
|
||||
# a destination Lepton mounts itself isn't mounted twice
|
||||
taken = ["run", "--mount", "type=bind,source=/x,destination=/dev/input/event5,rw", *RUN[1:]]
|
||||
out = " ".join(agent.podman_run_args(taken, ENV, sys_root, dev_root))
|
||||
assert out.count("destination=/dev/input/event5,") == 1 and "destination=/dev/input/event8," in out
|
||||
|
||||
|
||||
@NEEDS_POSIX
|
||||
@pytest.mark.parametrize("args,env", [
|
||||
(RUN, {"SteamAppId": "123"}), # the launcher didn't ask for it
|
||||
(RUN, dict(ENV, FRAMEPORT_NO_GAMEPAD="1")), # kill switch
|
||||
(RUN, dict(ENV, SteamAppId="999")), # another game's container
|
||||
(["run", "--name=lepton-other", "/init"], ENV),
|
||||
(["exec", "lepton-steamlaunch-123", "logcat"], ENV), # only `run`
|
||||
])
|
||||
def test_run_args_unchanged(monkeypatch, tmp_path, args, env):
|
||||
agent = agent_module(monkeypatch, tmp_path)
|
||||
frame_inputs(tmp_path)
|
||||
assert agent.podman_run_args(args, env, str(tmp_path / "sys"), str(tmp_path / "dev")) == args
|
||||
|
||||
|
||||
@NEEDS_POSIX
|
||||
def test_no_pads_no_mounts(monkeypatch, tmp_path):
|
||||
agent = agent_module(monkeypatch, tmp_path)
|
||||
fake_input(tmp_path, 4, "28de", "0000", "steamos-manager", MANAGER_KEYS)
|
||||
assert agent.podman_run_args(RUN, ENV, str(tmp_path / "sys"), str(tmp_path / "dev")) == RUN
|
||||
assert not (tmp_path / "agent/steam-gamepad.kl").exists()
|
||||
|
||||
|
||||
def fake_podman(folder, label):
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
script = folder / "podman"
|
||||
script.write_text(f'#!/bin/sh\necho "{label} PATH=$PATH ARGS=$*"\n')
|
||||
script.chmod(0o755)
|
||||
|
||||
|
||||
def wrapper(agent, tmp_path):
|
||||
assert agent.ensure_podman_wrapper() is True
|
||||
assert agent.ensure_podman_wrapper() is False # unchanged
|
||||
path = tmp_path / "agent/bin/podman"
|
||||
assert os.access(path, os.X_OK)
|
||||
return path
|
||||
|
||||
|
||||
@NEEDS_POSIX
|
||||
def test_wrapper_hands_on_to_the_codec_wrapper_and_leaves_path(monkeypatch, tmp_path):
|
||||
agent = agent_module(monkeypatch, tmp_path)
|
||||
ours = wrapper(agent, tmp_path)
|
||||
codec, real = tmp_path / "codec/current/bin", tmp_path / "usr/bin"
|
||||
fake_podman(codec, "codec")
|
||||
fake_podman(real, "real")
|
||||
path = f"{ours.parent}:{codec}:{real}:/bin"
|
||||
out = subprocess.run([str(ours), "exec", "x", "logcat"], env={"PATH": path}, capture_output=True, text=True,
|
||||
check=True).stdout
|
||||
assert out == f"codec PATH={codec}:{real}:/bin ARGS=exec x logcat\n"
|
||||
# the other order (codec wrapper first: it calls the next Podman it finds, this wrapper): no loop back
|
||||
path = f"{codec}:{ours.parent}:{real}:/bin"
|
||||
out = subprocess.run([str(ours), "ps"], env={"PATH": path}, capture_output=True, text=True, check=True).stdout
|
||||
assert out == f"real PATH={codec}:{real}:/bin ARGS=ps\n"
|
||||
|
||||
|
||||
@NEEDS_POSIX
|
||||
def test_wrapper_run_goes_through_the_agent_and_never_fails_the_start(monkeypatch, tmp_path):
|
||||
agent = agent_module(monkeypatch, tmp_path)
|
||||
ours = wrapper(agent, tmp_path)
|
||||
real = tmp_path / "usr/bin"
|
||||
fake_podman(real, "real")
|
||||
env = dict(ENV, PATH=f"{ours.parent}:{real}:/bin")
|
||||
# no agent next to the wrapper (or a broken one): arguments unchanged
|
||||
done = subprocess.run([str(ours), *RUN], env=env, capture_output=True, text=True, check=True)
|
||||
assert done.stdout == f"real PATH={real}:/bin ARGS={' '.join(RUN)}\n"
|
||||
assert "left the arguments unchanged" in done.stderr
|
||||
# with the agent: podman_run_args runs (this machine's /sys has no Steam pads to add)
|
||||
shutil.copy(ROOT / "agent/frameport_agent.py", tmp_path / "agent/frameport_agent.py")
|
||||
done = subprocess.run([str(ours), *RUN], env=env, capture_output=True, text=True, check=True)
|
||||
assert done.stdout.startswith(f"real PATH={real}:/bin ARGS=run ")
|
||||
assert "FramePort gamepad: Steam Input virtual gamepads for this container" in done.stderr
|
||||
|
||||
|
||||
LEPTON = "export LEPTON_ENV_FRAMEBRIDGE_CONFIG=\"$app_dir/settings.conf\"\n{lines}child=''\nsetsid /lepton start\n"
|
||||
|
||||
|
||||
def anchor(tmp_path, name, launcher, **dep):
|
||||
directory = tmp_path / "apps" / name
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "deployment.json").write_text(json.dumps({"package": name, **dep}))
|
||||
(directory / "launch.sh").write_text(launcher)
|
||||
return directory
|
||||
|
||||
|
||||
COMBOS = [(False, False), (True, False), (False, True), (True, True)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hw_video,gamepad", COMBOS)
|
||||
def test_new_launchers_get_the_wrapper_lines(monkeypatch, tmp_path, hw_video, gamepad):
|
||||
agent = agent_module(monkeypatch, tmp_path)
|
||||
directory = tmp_path / "apps/game"
|
||||
directory.mkdir(parents=True)
|
||||
agent.write_launcher(str(directory), str(tmp_path / "base"), "com.example.game", "Game", 123, "/lepton/lepton", {},
|
||||
hw_video=hw_video, gamepad=gamepad)
|
||||
text = (directory / "launch.sh").read_text()
|
||||
assert (agent.VIDEO_CODEC_LINE in text) is hw_video and (agent.GAMEPAD_LINE in text) is gamepad
|
||||
if hw_video and gamepad: # FramePort's wrapper first on PATH: its line comes last
|
||||
assert text.index(agent.VIDEO_CODEC_LINE) < text.index(agent.GAMEPAD_LINE)
|
||||
assert agent.set_gamepad_line(agent.set_codec_line(text, hw_video), gamepad) == text # upgrades keep it
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hw_video,gamepad", COMBOS)
|
||||
def test_upgrade_launchers_follow_the_deployment(monkeypatch, tmp_path, hw_video, gamepad):
|
||||
agent = agent_module(monkeypatch, tmp_path)
|
||||
plain = LEPTON.format(lines="")
|
||||
both = LEPTON.format(lines=agent.VIDEO_CODEC_LINE + "\n" + agent.GAMEPAD_LINE + "\n")
|
||||
# one launcher from nothing, one with both lines (the other way round: a reinstall without them)
|
||||
new = anchor(tmp_path, "com.new.game", plain, hw_video_decode=hw_video, steam_gamepad=gamepad)
|
||||
old = anchor(tmp_path, "com.old.game", both, hw_video_decode=hw_video, steam_gamepad=gamepad)
|
||||
agent.upgrade_launchers()
|
||||
for directory in (new, old):
|
||||
text = (directory / "launch.sh").read_text()
|
||||
assert text.count(agent.VIDEO_CODEC_LINE) == int(hw_video), directory.name
|
||||
assert text.count(agent.GAMEPAD_LINE) == int(gamepad), directory.name
|
||||
if hw_video and gamepad:
|
||||
assert text.index(agent.VIDEO_CODEC_LINE) < text.index(agent.GAMEPAD_LINE)
|
||||
assert text.endswith("child=''\nsetsid /lepton start\n")
|
||||
assert agent.upgrade_launchers() == [] # idempotent
|
||||
|
||||
|
||||
def test_codec_added_later_still_comes_before_the_gamepad_line(monkeypatch, tmp_path):
|
||||
agent = agent_module(monkeypatch, tmp_path)
|
||||
directory = anchor(tmp_path, "com.game", LEPTON.format(lines=agent.GAMEPAD_LINE + "\n"),
|
||||
recipe={"patches": ["device.steam_gamepad", "frame.hw_video_decode"]})
|
||||
assert agent.upgrade_launchers() == ["com.game"]
|
||||
text = (directory / "launch.sh").read_text()
|
||||
assert text.index(agent.VIDEO_CODEC_LINE) < text.index(agent.GAMEPAD_LINE)
|
||||
|
||||
|
||||
def test_finalize_records_the_choice(monkeypatch, tmp_path):
|
||||
"""wants_gamepad: deployment.json's steam_gamepad (finalize), else the recipe."""
|
||||
agent = agent_module(monkeypatch, tmp_path)
|
||||
assert agent.wants_gamepad(str(anchor(tmp_path, "a.b", "", recipe={"patches": ["device.steam_gamepad"]})))
|
||||
assert not agent.wants_gamepad(str(anchor(tmp_path, "c.d", "", steam_gamepad=False,
|
||||
recipe={"patches": ["device.steam_gamepad"]})))
|
||||
assert not agent.wants_gamepad(str(tmp_path / "apps/missing"))
|
||||
source = (ROOT / "agent/frameport_agent.py").read_text()
|
||||
assert 'GAMEPAD_PATCH = "device.steam_gamepad"' in source and '"steam_gamepad": gamepad' in source
|
||||
|
||||
|
||||
@NEEDS_POSIX
|
||||
@pytest.mark.parametrize("case", ["on", "off", "off0", "missing"])
|
||||
def test_gamepad_line(tmp_path, case):
|
||||
"""The launcher line: SDL's hint for the app and FramePort's wrapper first on PATH, unless FRAMEPORT_NO_GAMEPAD=1
|
||||
(the hint alone when the wrapper isn't there)."""
|
||||
spec = importlib.util.spec_from_file_location("gamepad_agent_line", ROOT / "agent/frameport_agent.py")
|
||||
agent = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(agent)
|
||||
home = tmp_path / "home"
|
||||
if case != "missing":
|
||||
fake_podman(home / ".local/share/frameport/agent/bin", "ours")
|
||||
env = {"HOME": str(home), "PATH": "/usr/bin:/bin"}
|
||||
if case.startswith("off"):
|
||||
env["FRAMEPORT_NO_GAMEPAD"] = "0" if case == "off0" else "1"
|
||||
script = agent.GAMEPAD_LINE + '\necho "$PATH|${FRAMEPORT_GAMEPAD:-}|${LEPTON_ENV_SDL_GAMECONTROLLER_ALLOW_STEAM_' \
|
||||
'VIRTUAL_GAMEPAD:-}"'
|
||||
out = subprocess.run(["bash", "-euc", script], env=env, capture_output=True, text=True, check=True).stdout.strip()
|
||||
path, flag, hint = out.split("|")
|
||||
on = case in ("on", "off0")
|
||||
assert path.startswith(str(home / ".local/share/frameport/agent/bin") + ":") is on
|
||||
assert (flag, hint) == (("1", "1") if case != "off" else ("", ""))
|
||||
|
||||
|
||||
def analysis(vr_kind="none", **extra):
|
||||
return Analysis("com.example", "1", "Example", ["arm64-v8a"], "Other", "?", "GLES or unknown", False, [], None,
|
||||
False, [], False, 0, False, False, False, extra={"vr_kind": vr_kind, **extra})
|
||||
|
||||
|
||||
def test_patch_applies_to_2d_apps_and_is_suggested_for_gamepad_ones():
|
||||
patch = base.get("device.steam_gamepad")
|
||||
assert patch.stage == "install" and patch.category == "device" and not patch.needs_vr
|
||||
assert patch.applies(analysis()) and not patch.applies(analysis("quest"))
|
||||
assert patch.detect(analysis()) is None # applicable, not suggested
|
||||
assert patch.detect(analysis(gamepad=True)).recommended
|
||||
assert patch.detect(analysis("quest", gamepad=True)) is None
|
||||
assert patch.summary and patch.title
|
||||
ctx = base.InstallContext("com.example", {}, {}, {}, {})
|
||||
patch.install(ctx)
|
||||
assert ctx.env == {} and not ctx.flatscreen # the agent does it all (launcher line + wrapper)
|
||||
|
||||
|
||||
def test_manifest_gamepad_detection():
|
||||
from frameport.analysis import detect
|
||||
|
||||
assert detect.ANALYSIS_VERSION >= 9
|
||||
source = (ROOT / "src/frameport/analysis/detect.py").read_text()
|
||||
assert '"gamepad": "android.hardware.gamepad" in features or axml.LEANBACK_LAUNCHER in cats' in source
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
@@ -301,14 +290,3 @@ def test_lepton_lines_survive_the_game_filter():
|
||||
assert any("FrameBridge" in ln for ln in lines)
|
||||
res = triage.triage(log, "RUNNING", package="com.x.y")
|
||||
assert "container-not-started" not in [f.id for f in res.findings]
|
||||
|
||||
|
||||
def test_unity_data_missing_fails_the_launch_test():
|
||||
"""GitHub #155: Batman's launch test passed while Unity was stuck on a bundle missing from the data folder."""
|
||||
from frameport.validate import triage
|
||||
|
||||
log = ("10-10 00:51:10.000 1 2 E Unity : Unable to open archive file: /sdcard/Android/obb/com.camouflaj.manta/"
|
||||
"localization-assets-french(france)(fr-fr)_assets_all.bundle\n"
|
||||
"10-10 00:51:20.000 1 2 I FrameBridge: pacing: 72.0 fps\n")
|
||||
r = triage.triage(log, "RUNNING")
|
||||
assert "unity-data-missing" in [f.id for f in r.findings] and r.verdict == "fail"
|
||||
@@ -30,16 +30,6 @@ def test_outdated():
|
||||
assert install_state(game("a"), frame({"package": "com.x", "sha256": "old"})) == "outdated"
|
||||
|
||||
|
||||
|
||||
def test_outdated_after_a_newer_apk_was_added():
|
||||
"""GitHub #161: re-adding a newer APK of an installed game shows "Update on Frame", not "Reinstall"."""
|
||||
g = game("a")
|
||||
g["build"]["source_version"] = "1.0"
|
||||
g["analysis"] = {"version": "1.0"}
|
||||
assert install_state(g, frame({"package": "com.x", "sha256": "a"})) == "installed"
|
||||
g["analysis"]["version"] = "1.1"
|
||||
assert install_state(g, frame({"package": "com.x", "sha256": "a"})) == "outdated"
|
||||
|
||||
# ------------------------------------------------------------------------------------------ library filters / tags
|
||||
def lib_games():
|
||||
return [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
"""vk_shader_dump (GitHub #140): the Vulkan shim's SPIR-V dump, its setting, the GPU-hang triage per graphics API and
|
||||
the shader dumps in diagnostics."""
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core.models import Analysis
|
||||
from frameport.diag import bundle, redact
|
||||
from frameport.patches import base
|
||||
from frameport.patches.settings import UI
|
||||
from frameport.validate import session
|
||||
from frameport.validate.triage import graphics_api, triage
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PKG = "com.example.game"
|
||||
START = "09-28 17:39:01.000 1000 1000 I ActivityManager: Start proc 1147:com.example.game/u0a55 for activity\n"
|
||||
HANG = "kernel: 1790000000.0 steamos kernel: msm_dpu: [drm:a6xx_hangcheck] hangcheck detected gpu lockup\n"
|
||||
|
||||
|
||||
def fb(msg: str) -> str:
|
||||
return f"09-28 17:39:02.000 1147 1174 I FrameBridge: {msg}\n"
|
||||
|
||||
|
||||
def swapchain(fmt: int, result: int = 0) -> str:
|
||||
return fb(f"xrCreateSwapchain 1680x1760 format={fmt} samples=1 array=2 faces=1 usage=0x21 flags=0x0 "
|
||||
f"result={result}")
|
||||
|
||||
|
||||
def _analysis(**kw):
|
||||
d = dict(package=PKG, version="1.0", label="Game", abis=["arm64-v8a"], engine="Unreal", xr="OpenXR",
|
||||
graphics="GLES or unknown (no Vulkan declaration)", direct_vrapi=False, libs=["libUE4.so"],
|
||||
launcher_activity=None, has_info_category=True, meta_permissions=[], uses_glad_gl=False,
|
||||
unity_msaa_levels=0, oculus_os_classes=False, is_overport_output=True, debuggable=False, extra={})
|
||||
d.update(kw)
|
||||
return Analysis(**d)
|
||||
|
||||
|
||||
def test_setting_registered_like_zink_shader_dump():
|
||||
base.load_all()
|
||||
p = base.REGISTRY["adapter.vk_shader_dump"]
|
||||
assert p.category == "adapter" and p.default == 0 and not p.default_on
|
||||
assert "fp_vk_shaders" in p.description
|
||||
assert UI["vk_shader_dump"]["group"] == "troubleshooting" and UI["vk_shader_dump"]["level"] == "advanced"
|
||||
assert p.applies(_analysis()) and p.applies(_analysis(engine="Other"))
|
||||
assert not p.applies(_analysis(engine="Unity", libs=["libunity.so"])) # no Vulkan shim in Unity games
|
||||
assert not p.applies(_analysis(abis=["armeabi-v7a"])) # the shim is arm64 only
|
||||
|
||||
|
||||
def test_graphics_api_from_swapchain_formats():
|
||||
assert graphics_api(swapchain(43)) == "vulkan"
|
||||
assert graphics_api(swapchain(35907)) == "gles"
|
||||
assert graphics_api(swapchain(43, result=-26)) is None # refused swapchains don't count
|
||||
assert graphics_api(swapchain(43) + swapchain(35907)) is None
|
||||
assert graphics_api(START) is None
|
||||
|
||||
|
||||
def test_gpu_hang_suggests_the_dump_for_the_sessions_api():
|
||||
def hang(log):
|
||||
r = triage(START + log, "UNKNOWN", PKG, kernel=HANG)
|
||||
return next(f for f in r.findings if f.id == "gpu-hang")
|
||||
|
||||
assert hang(swapchain(43)).suggest == ["adapter.vk_shader_dump"]
|
||||
assert hang(swapchain(35907)).suggest == ["adapter.zink_shader_dump"]
|
||||
assert hang("").suggest == ["adapter.vk_shader_dump", "adapter.zink_shader_dump"] # unknown: both
|
||||
# the game's analysis filters too: a Unity game never gets the Vulkan shim
|
||||
entry = {"analysis": _analysis(engine="Unity", libs=["libunity.so"]).__dict__}
|
||||
assert session.applicable(entry, ["adapter.vk_shader_dump", "adapter.zink_shader_dump"]) == \
|
||||
["adapter.zink_shader_dump"]
|
||||
vulkan_unity = {"analysis": _analysis(engine="Unity", graphics="Vulkan (declared in manifest)").__dict__}
|
||||
assert session.applicable(vulkan_unity, ["adapter.vk_shader_dump", "adapter.zink_shader_dump"]) == []
|
||||
|
||||
|
||||
def test_artifact_reads_the_setting():
|
||||
so = (ROOT / "artifacts/arm64-v8a/libfp_vk.so").read_bytes()
|
||||
assert b"vk_shader_dump=" in so and b"fp_vk_shaders" in so and b"index.txt" in so
|
||||
|
||||
|
||||
HOST_TEST = r"""
|
||||
#define _GNU_SOURCE
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#define LOG(...) (fprintf(stderr, __VA_ARGS__), fputc('\n', stderr))
|
||||
#include "sha256.h"
|
||||
#include "shader_dump.h"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 2 || !dump_init(argv[1])) return 2;
|
||||
uint32_t a[5] = {0x07230203, 0x00010000, 0, 10, 0}, b[7] = {0x07230203, 0x00010300, 0, 20, 0, 1, 2};
|
||||
dump_module(a, sizeof a);
|
||||
dump_module(b, sizeof b);
|
||||
dump_module(a, sizeof a);
|
||||
return 0;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _host_binary(tmp_path):
|
||||
cc = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
|
||||
if not cc or os.name == "nt":
|
||||
pytest.skip("needs a Linux host C compiler")
|
||||
src = tmp_path / "t.c"
|
||||
src.write_text(HOST_TEST)
|
||||
exe = tmp_path / "t"
|
||||
subprocess.run([cc, "-std=gnu11", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "native/vkshim"), str(src),
|
||||
"-pthread", "-o", str(exe)], check=True)
|
||||
return exe
|
||||
|
||||
|
||||
def test_dump_writes_each_module_once_with_an_index(tmp_path):
|
||||
exe = _host_binary(tmp_path)
|
||||
files = tmp_path / "files"
|
||||
files.mkdir()
|
||||
before = int(time.time() * 1000)
|
||||
run = subprocess.run([str(exe), str(files)], capture_output=True, text=True)
|
||||
assert run.returncode == 0, run.stderr
|
||||
assert "dumping SPIR-V modules to" in run.stderr and "1 module(s) written" in run.stderr
|
||||
a = b"".join(w.to_bytes(4, "little") for w in (0x07230203, 0x00010000, 0, 10, 0))
|
||||
b = b"".join(w.to_bytes(4, "little") for w in (0x07230203, 0x00010300, 0, 20, 0, 1, 2))
|
||||
name_a, name_b = (f"{len(x)}_{hashlib.sha256(x).hexdigest()}.spv" for x in (a, b))
|
||||
out = files / "fp_vk_shaders"
|
||||
assert sorted(p.name for p in out.iterdir()) == sorted([name_a, name_b, "index.txt"]) # no temporary files
|
||||
assert (out / name_a).read_bytes() == a and (out / name_b).read_bytes() == b
|
||||
lines = (out / "index.txt").read_text().splitlines()
|
||||
assert lines[0].startswith("# start ") and "pid " in lines[0]
|
||||
rows = [ln.split() for ln in lines[1:]]
|
||||
assert [(r[0], r[3], r[4]) for r in rows] == [("1", name_a, "new"), ("2", name_b, "new"), ("3", name_a, "again")]
|
||||
assert all(int(r[2]) >= before - 1000 for r in rows) and int(rows[0][1]) <= int(rows[2][1])
|
||||
# a second run: modules already on disk are "known", nothing rewritten; the index keeps both runs
|
||||
run = subprocess.run([str(exe), str(files)], capture_output=True, text=True)
|
||||
assert run.returncode == 0 and "module(s) written" not in run.stderr
|
||||
lines = (out / "index.txt").read_text().splitlines()
|
||||
assert sum(ln.startswith("# start ") for ln in lines) == 2
|
||||
assert [ln.split()[4] for ln in lines[-3:]] == ["known", "known", "again"]
|
||||
|
||||
|
||||
def _load_agent(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
spec = importlib.util.spec_from_file_location("frameport_agent", ROOT / "agent/frameport_agent.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def test_agent_collects_the_newest_modules(monkeypatch, tmp_path):
|
||||
agent = _load_agent(monkeypatch, tmp_path)
|
||||
files = tmp_path / "files"
|
||||
d = files / "fp_vk_shaders"
|
||||
d.mkdir(parents=True)
|
||||
now = time.time()
|
||||
names = []
|
||||
for i in range(5):
|
||||
data = bytes([i]) * 1000
|
||||
n = f"{len(data)}_{hashlib.sha256(data).hexdigest()}.spv"
|
||||
(d / n).write_bytes(data)
|
||||
os.utime(d / n, (now - 100 + i, now - 100 + i)) # i = 4 is the newest
|
||||
names.append(n)
|
||||
(d / "index.txt").write_text("# start 1 pid 2\n1 0 1000 x.spv new\n")
|
||||
out = agent.shader_dumps(str(files), max_bytes=2500)
|
||||
res = out["fp_vk_shaders"]
|
||||
assert set(res["modules"]) == {names[4], names[3]} and res["total"] == 5 # the newest two fit in 2500 bytes
|
||||
assert base64.b64decode(res["modules"][names[4]]) == bytes([4]) * 1000
|
||||
assert res["index"].startswith("# start")
|
||||
assert "fp_spirv" not in out and agent.shader_dumps(str(tmp_path / "nothing")) == {}
|
||||
assert agent.AGENT_VERSION >= 72
|
||||
|
||||
|
||||
def test_bundle_writes_the_dumps(tmp_path):
|
||||
w = bundle._Writer(redact.Redactor())
|
||||
data = b"\x03\x02\x23\x07" * 5
|
||||
name = f"{len(data)}_{hashlib.sha256(data).hexdigest()}.spv"
|
||||
bundle.add_shader_dumps(w, "games/x/target/shaders/", {
|
||||
"fp_vk_shaders": {"total": 3, "index": "1 0 1 a.spv new /home/someone/x\n",
|
||||
"modules": {name: base64.b64encode(data).decode(), "../evil.spv": "AAAA"}},
|
||||
"../other": {"modules": {name: "AAAA"}}})
|
||||
assert w.files["games/x/target/shaders/fp_vk_shaders/" + name] == data
|
||||
assert b"someone" not in w.files["games/x/target/shaders/fp_vk_shaders/index.txt"]
|
||||
assert not any("evil" in n or "other" in n for n in w.files)
|
||||
assert any("older module" in m for m in w.warnings)
|
||||
Reference in new issue
Block a user