mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-10 17:00:46 +02:00
Compare commits
2
Commits
dev
...
pc-android-axrb
| 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).
|
||||
|
||||
@@ -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.
|
||||
@@ -39,7 +39,7 @@ import zipfile
|
||||
import zlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
AGENT_VERSION = 70
|
||||
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")
|
||||
@@ -887,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}}
|
||||
@@ -989,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)}
|
||||
|
||||
@@ -456,7 +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: []
|
||||
# ---- 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}
|
||||
|
||||
@@ -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).
|
||||
@@ -220,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 |
|
||||
@@ -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)."""
|
||||
|
||||
@@ -161,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": "",
|
||||
@@ -375,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)": "",
|
||||
@@ -458,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.": "",
|
||||
@@ -563,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": "",
|
||||
@@ -1063,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": "",
|
||||
@@ -1138,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.": "",
|
||||
@@ -1268,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.": "",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
+44
-16
@@ -286,6 +286,16 @@ def is_linux(entry: dict) -> bool:
|
||||
return entry.get("kind") == "linux"
|
||||
|
||||
|
||||
def pc_installable(entry: dict) -> bool:
|
||||
"""Can go on this PC: Rift games (Revive) and Android OpenXR games (Quest / Khronos loader; AXRB, experimental);
|
||||
not Linux/Windows/2D apps."""
|
||||
if entry.get("kind") == "rift":
|
||||
return True
|
||||
if entry.get("kind"):
|
||||
return False
|
||||
return (((entry.get("analysis") or {}).get("extra") or {}).get("vr_kind") or "quest") in ("quest", "openxr")
|
||||
|
||||
|
||||
def analysis_warnings(entry: dict) -> list[str]:
|
||||
"""Blockers read from a Quest/Android game's APK, for the CLI (the game page shows them as callouts)."""
|
||||
if is_rift(entry) or is_linux(entry) or not entry.get("analysis"):
|
||||
@@ -715,7 +725,21 @@ def _build_lock(package: str) -> threading.Lock:
|
||||
return _build_locks.setdefault(package, threading.Lock())
|
||||
|
||||
|
||||
def build_game(package: str, reporter: Reporter, outdir: Path | None = None) -> dict:
|
||||
def pc_recipe_fingerprint(recipe: dict, package: str = "") -> str:
|
||||
"""Fingerprint of what the build for this PC contains (Frame-only patch changes don't make it outdated)."""
|
||||
from .patches.base import pc_selection
|
||||
|
||||
return recipe_fingerprint(dict(recipe, patches=pc_selection(recipe.get("patches") or {})), package)
|
||||
|
||||
|
||||
def build_key(entry: dict, pc: bool) -> str:
|
||||
"""Where a Quest game's build is recorded: "build" (Steam Frame), "build_pc" (this PC, AXRB). Installed-as-is
|
||||
games have one build for both."""
|
||||
return "build_pc" if pc and not library.recipe_from_dict(entry["recipe"]).as_is else "build"
|
||||
|
||||
|
||||
def build_game(package: str, reporter: Reporter, outdir: Path | None = None, pc: bool = False) -> dict:
|
||||
"""pc: the build for this PC (Quest games through AXRB): OVRPort's patches without the Steam Frame fixes."""
|
||||
if analysis_outdated(library.game(package) or {}): # analysed by an older FramePort: new fields first
|
||||
refresh_analyses([package], reporter)
|
||||
entry = library.game(package)
|
||||
@@ -728,15 +752,17 @@ def build_game(package: str, reporter: Reporter, outdir: Path | None = None) ->
|
||||
src = source_of(entry)
|
||||
a = library.analysis_from_dict(entry["analysis"])
|
||||
recipe = library.recipe_from_dict(entry["recipe"])
|
||||
out = outdir or (output_dir() / quest_dump.display_name(entry.get("name") or package))
|
||||
name = quest_dump.display_name(entry.get("name") or package)
|
||||
out = outdir or (output_dir() / (f"{name} (PC)" if pc else name))
|
||||
with _build_lock(package): # one build per game at a time: builds share the game's work folder
|
||||
res = builder.build(src, a, recipe, out, reporter)
|
||||
res = builder.build(src, a, recipe, out, reporter, **({"pc": True} if pc else {}))
|
||||
art, store_title = artwork.fetch(package, res.apk)
|
||||
build_info = {"apk": str(res.apk), "alt_apk": str(res.alt_apk) if res.alt_apk else None, "sha256": res.sha256,
|
||||
"alt_sha256": res.alt_sha256, "applied": res.applied, "checks": res.checks, "ok": res.ok,
|
||||
"overport": res.meta.get("overport"), "recipe_fp": recipe_fingerprint(entry["recipe"], package),
|
||||
"overport": res.meta.get("overport"),
|
||||
"recipe_fp": (pc_recipe_fingerprint if pc else recipe_fingerprint)(entry["recipe"], package),
|
||||
"superseded": res.meta.get("superseded") or {}}
|
||||
library.upsert_game(package, build=build_info, title=entry.get("title") or store_title)
|
||||
library.upsert_game(package, **{build_key(entry, pc): build_info}, title=entry.get("title") or store_title)
|
||||
return build_info
|
||||
|
||||
|
||||
@@ -749,14 +775,15 @@ def install_game(package: str, target: Target, reporter: Reporter, apk_only: boo
|
||||
if is_rift(entry):
|
||||
return install_rift(package, target, reporter, add_to_library)
|
||||
test_build = apk is not None
|
||||
b = entry.get("build") or {}
|
||||
pc = getattr(target, "kind", "") == "pc" # Quest games on this PC (AXRB): their own build, no Frame fixes
|
||||
b = entry.get(build_key(entry, pc)) or {}
|
||||
recipe = library.recipe_from_dict(entry["recipe"])
|
||||
if not test_build and not b.get("apk"): # never built yet (e.g. `frameport install` right after a scan)
|
||||
build_game(package, reporter)
|
||||
build_game(package, reporter, pc=pc)
|
||||
return install_game(package, target, reporter, apk_only, add_to_library)
|
||||
apk = Path(apk) if apk else Path(b["alt_apk"] if recipe.use_alt and b.get("alt_apk") else b["apk"])
|
||||
if not test_build and not apk.exists(): # the converted copy was removed after an earlier install: make it again
|
||||
build_game(package, reporter)
|
||||
build_game(package, reporter, pc=pc)
|
||||
return install_game(package, target, reporter, apk_only, add_to_library)
|
||||
if not test_build and b.get("superseded"): # workarounds this build left out (upstream fixed): not in settings.conf
|
||||
recipe = dataclasses.replace(recipe, patches=upstream.without_superseded(recipe.patches, b["superseded"]))
|
||||
@@ -885,15 +912,16 @@ def remove_converted_copies(package: str) -> int:
|
||||
game files the user added. Returns the bytes freed."""
|
||||
if library.setting("build.keep_copies", False):
|
||||
return 0
|
||||
b = (library.game(package) or {}).get("build") or {}
|
||||
g = library.game(package) or {}
|
||||
out, freed = output_dir().resolve(), 0
|
||||
for key in ("apk", "alt_apk"):
|
||||
p = Path(b[key]) if b.get(key) else None
|
||||
if p and p.exists() and out in p.resolve().parents:
|
||||
freed += p.stat().st_size
|
||||
p.unlink()
|
||||
if not any(p.parent.iterdir()):
|
||||
p.parent.rmdir()
|
||||
for b in (g.get("build") or {}, g.get("build_pc") or {}):
|
||||
for key in ("apk", "alt_apk"):
|
||||
p = Path(b[key]) if b.get(key) else None
|
||||
if p and p.exists() and out in p.resolve().parents:
|
||||
freed += p.stat().st_size
|
||||
p.unlink()
|
||||
if not any(p.parent.iterdir()):
|
||||
p.parent.rmdir()
|
||||
return freed
|
||||
|
||||
|
||||
|
||||
@@ -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}"')
|
||||
+46
-11
@@ -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):
|
||||
@@ -1421,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
|
||||
|
||||
@@ -131,9 +131,14 @@ def recipe_changed(game: dict) -> bool:
|
||||
|
||||
|
||||
def pc_outdated(g: dict, dep: dict) -> bool:
|
||||
"""A PC install whose launch settings (pcvr patches + their parameters) differ from the game's recipe now."""
|
||||
"""A PC install whose launch settings (pcvr patches + their parameters) differ from the game's recipe now. Quest
|
||||
games (AXRB): the patches of their build for this PC."""
|
||||
installed = set((dep.get("recipe") or {}).get("patches") or [])
|
||||
want = set((g.get("recipe") or {}).get("patches") or {})
|
||||
if dep.get("kind") == "android":
|
||||
from ..patches.base import pc_selection
|
||||
|
||||
return installed != set(pc_selection({p: {} for p in want}))
|
||||
from ..core.library import recipe_from_dict
|
||||
from ..patches.pcvr import game_args
|
||||
|
||||
|
||||
@@ -148,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.",
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -94,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()]
|
||||
|
||||
@@ -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")]
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user